Merge branch 'master' into add-tests

This commit is contained in:
Mohammed Al Sahaf 2026-06-05 19:33:22 +03:00 committed by GitHub
commit 5fe8407e2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 1474 additions and 163 deletions

View file

@ -70,7 +70,8 @@ func init() {
// `{http.request.orig_uri.query}` | The request's original query string (without `?`)
// `{http.request.orig_uri.prefixed_query}` | The request's original query string with a `?` prefix, if non-empty
// `{http.request.port}` | The port part of the request's Host header
// `{http.request.proto}` | The protocol of the request
// `{http.request.proto}` | The raw protocol of the request as returned by Go (e.g., HTTP/2.0 or HTTP/3.0)
// `{http.request.proto_name}` | The spec-defined protocol of the request (e.g., HTTP/2 or HTTP/3)
// `{http.request.local.host}` | The host (IP) part of the local address the connection arrived on
// `{http.request.local.port}` | The port part of the local address the connection arrived on
// `{http.request.local}` | The local address the connection arrived on

View file

@ -37,6 +37,12 @@ func init() {
// `{http.auth.user.*}` placeholders may be set for any authentication
// modules that provide user metadata.
//
// If authentication is rejected but a provider returns user information,
// the placeholder `{http.auth.candidate.id}` will be set to the candidate
// username, and also `{http.auth.candidate.*}` placeholders may be set
// for candidate user metadata. Candidate placeholders do not represent a
// successfully authenticated principal.
//
// In case of an error, the placeholder `{http.auth.<provider>.error}`
// will be set to the error message returned by the authentication
// provider.
@ -78,6 +84,8 @@ func (a *Authentication) Provision(ctx caddy.Context) error {
func (a Authentication) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
var user User
var candidate User
var hasCandidate bool
var authed bool
var err error
for provName, prov := range a.Providers {
@ -94,19 +102,34 @@ func (a Authentication) ServeHTTP(w http.ResponseWriter, r *http.Request, next c
if authed {
break
}
if userHasInfo(user) {
candidate = user
hasCandidate = true
}
}
if !authed {
if hasCandidate {
setAuthUserPlaceholders(repl, "http.auth.candidate", candidate)
}
return caddyhttp.Error(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
}
repl.Set("http.auth.user.id", user.ID)
for k, v := range user.Metadata {
repl.Set("http.auth.user."+k, v)
}
setAuthUserPlaceholders(repl, "http.auth.user", user)
return next.ServeHTTP(w, r)
}
func userHasInfo(user User) bool {
return user.ID != "" || len(user.Metadata) > 0
}
func setAuthUserPlaceholders(repl *caddy.Replacer, namespace string, user User) {
repl.Set(namespace+".id", user.ID)
for k, v := range user.Metadata {
repl.Set(namespace+"."+k, v)
}
}
// Authenticator is a type which can authenticate a request.
// If a request was not authenticated, it returns false. An
// error is only returned if authenticating the request fails

View file

@ -0,0 +1,197 @@
// Copyright 2015 Matthew Holt and The Caddy Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package caddyauth
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"go.uber.org/zap"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
)
func TestAuthenticationRejectedUserSetsCandidatePlaceholders(t *testing.T) {
auth := Authentication{
Providers: map[string]Authenticator{
"test": staticAuthenticator{
user: User{
ID: "alice",
Metadata: map[string]string{
"role": "admin",
},
},
},
},
logger: zap.NewNop(),
}
req, repl := newRequestWithReplacer()
nextCalled := false
err := auth.ServeHTTP(httptest.NewRecorder(), req, caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
nextCalled = true
return nil
}))
if err == nil {
t.Fatal("expected authentication error")
}
var handlerErr caddyhttp.HandlerError
if !errors.As(err, &handlerErr) {
t.Fatalf("expected HandlerError, got %T", err)
}
if handlerErr.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, handlerErr.StatusCode)
}
if nextCalled {
t.Fatal("next handler was called for rejected authentication")
}
assertPlaceholder(t, repl, "http.auth.candidate.id", "alice")
assertPlaceholder(t, repl, "http.auth.candidate.role", "admin")
assertPlaceholderAbsent(t, repl, "http.auth.user.id")
assertPlaceholderAbsent(t, repl, "http.auth.user.role")
}
func TestAuthenticationSuccessfulUserSetsUserPlaceholdersOnly(t *testing.T) {
auth := Authentication{
Providers: map[string]Authenticator{
"test": staticAuthenticator{
user: User{
ID: "alice",
Metadata: map[string]string{
"role": "admin",
},
},
authed: true,
},
},
logger: zap.NewNop(),
}
req, repl := newRequestWithReplacer()
nextCalled := false
err := auth.ServeHTTP(httptest.NewRecorder(), req, caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
nextCalled = true
return nil
}))
if err != nil {
t.Fatalf("expected no authentication error, got %v", err)
}
if !nextCalled {
t.Fatal("next handler was not called for successful authentication")
}
assertPlaceholder(t, repl, "http.auth.user.id", "alice")
assertPlaceholder(t, repl, "http.auth.user.role", "admin")
assertPlaceholderAbsent(t, repl, "http.auth.candidate.id")
assertPlaceholderAbsent(t, repl, "http.auth.candidate.role")
}
func TestAuthenticationSuccessfulProviderDoesNotExposeEarlierCandidate(t *testing.T) {
auth := Authentication{
Providers: map[string]Authenticator{
"first": staticAuthenticator{
user: User{
ID: "rejected",
Metadata: map[string]string{
"role": "guest",
},
},
},
"second": staticAuthenticator{
user: User{
ID: "accepted",
Metadata: map[string]string{
"role": "admin",
},
},
authed: true,
},
},
logger: zap.NewNop(),
}
req, repl := newRequestWithReplacer()
err := auth.ServeHTTP(httptest.NewRecorder(), req, caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
return nil
}))
if err != nil {
t.Fatalf("expected no authentication error, got %v", err)
}
assertPlaceholder(t, repl, "http.auth.user.id", "accepted")
assertPlaceholder(t, repl, "http.auth.user.role", "admin")
assertPlaceholderAbsent(t, repl, "http.auth.candidate.id")
assertPlaceholderAbsent(t, repl, "http.auth.candidate.role")
}
func TestAuthenticationRejectedEmptyUserDoesNotSetCandidatePlaceholders(t *testing.T) {
auth := Authentication{
Providers: map[string]Authenticator{
"test": staticAuthenticator{},
},
logger: zap.NewNop(),
}
req, repl := newRequestWithReplacer()
err := auth.ServeHTTP(httptest.NewRecorder(), req, caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
t.Fatal("next handler was called for rejected authentication")
return nil
}))
if err == nil {
t.Fatal("expected authentication error")
}
assertPlaceholderAbsent(t, repl, "http.auth.candidate.id")
}
func newRequestWithReplacer() (*http.Request, *caddy.Replacer) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
repl := caddy.NewReplacer()
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
return req.WithContext(ctx), repl
}
func assertPlaceholder(t *testing.T, repl *caddy.Replacer, key, expected string) {
t.Helper()
actual, ok := repl.GetString(key)
if !ok {
t.Fatalf("expected placeholder %q to be set", key)
}
if actual != expected {
t.Fatalf("expected placeholder %q to be %q, got %q", key, expected, actual)
}
}
func assertPlaceholderAbsent(t *testing.T, repl *caddy.Replacer, key string) {
t.Helper()
if actual, ok := repl.GetString(key); ok {
t.Fatalf("expected placeholder %q to be absent, got %q", key, actual)
}
}
type staticAuthenticator struct {
user User
authed bool
err error
}
func (a staticAuthenticator) Authenticate(http.ResponseWriter, *http.Request) (User, bool, error) {
return a.user, a.authed, a.err
}

View file

@ -108,7 +108,7 @@ func (m *MatchExpression) UnmarshalJSON(data []byte) error {
return json.Unmarshal(data, &m.Expr)
}
// otherwise, it's a full object, so unmarshal it,
// using an temp map to avoid infinite recursion
// using a temp map to avoid infinite recursion
var tmpJson map[string]any
err := json.Unmarshal(data, &tmpJson)
*m = MatchExpression{
@ -118,7 +118,7 @@ func (m *MatchExpression) UnmarshalJSON(data []byte) error {
return err
}
// Provision sets ups m.
// Provision sets up m.
func (m *MatchExpression) Provision(ctx caddy.Context) error {
m.log = ctx.Logger()
@ -319,7 +319,7 @@ func (cr celHTTPRequest) Value() any { return cr }
var pkixNameCELType = cel.ObjectType("pkix.Name", traits.ReceiverType)
// celPkixName wraps an pkix.Name with
// celPkixName wraps a pkix.Name with
// methods to satisfy the ref.Val interface.
type celPkixName struct{ *pkix.Name }

View file

@ -79,7 +79,7 @@ eqp31wM9il1n+guTNyxJd+FzVAH+hCZE5K+tCgVDdVFUlDEHHbS/wqb2PSIoouLV
wantResult: true,
},
{
name: "header matches an placeholder replaced during the header matcher (MatchHeader)",
name: "header matches a placeholder replaced during the header matcher (MatchHeader)",
expression: &MatchExpression{
Expr: `header({'Field': '\{http.request.uri.path}'})`,
},

View file

@ -20,12 +20,12 @@
package encode
import (
"cmp"
"fmt"
"io"
"math"
"net/http"
"slices"
"sort"
"strconv"
"strings"
"sync"
@ -127,6 +127,14 @@ func (enc *Encode) Provision(ctx caddy.Context) error {
}
}
if len(enc.Prefer) == 0 {
for _, encName := range []string{"zstd", "br", "gzip"} {
if _, ok := enc.writerPools[encName]; ok {
enc.Prefer = append(enc.Prefer, encName)
}
}
}
return nil
}
@ -162,7 +170,7 @@ func (enc *Encode) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyh
// to comply with RFC 9110 section 8.8.3(.3), we modify the Etag when encoding
// by appending a hyphen and the encoder name; the problem is, the client will
// send back that Etag in a If-None-Match header, but upstream handlers that set
// send back that Etag in an If-None-Match header, but upstream handlers that set
// the Etag in the first place don't know that we appended to their Etag! so here
// we have to strip our addition so the upstream handlers can still honor client
// caches without knowing about our changes...
@ -369,7 +377,7 @@ const sniffLen = 512
// ReadFrom will try to use sendfile to copy from the reader to the response writer.
// It's only used if the response writer implements io.ReaderFrom and the data can't be compressed.
// It's based on stdlin http1.1 response writer implementation.
// It's based on the standard library HTTP/1.1 response writer implementation.
// https://github.com/golang/go/blob/f4e3ec3dbe3b8e04a058d266adf8e048bab563f2/src/net/http/server.go#L586
func (rw *responseWriter) ReadFrom(r io.Reader) (int64, error) {
rf, ok := rw.ResponseWriter.(io.ReaderFrom)
@ -538,11 +546,11 @@ func AcceptedEncodings(r *http.Request, preferredOrder []string) []string {
}
// sort preferences by descending q-factor first, then by preferOrder
sort.Slice(prefs, func(i, j int) bool {
if math.Abs(prefs[i].q-prefs[j].q) < 0.00001 {
return prefs[i].preferOrder > prefs[j].preferOrder
slices.SortStableFunc(prefs, func(a, b encodingPreference) int {
if math.Abs(a.q-b.q) < 0.00001 {
return cmp.Compare(b.preferOrder, a.preferOrder)
}
return prefs[i].q > prefs[j].q
return cmp.Compare(b.q, a.q)
})
prefEncNames := make([]string, len(prefs))

View file

@ -1,10 +1,16 @@
package encode
import (
"context"
"io"
"net/http"
"net/http/httptest"
"slices"
"sync"
"testing"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
)
func BenchmarkOpenResponseWriter(b *testing.B) {
@ -295,3 +301,72 @@ func TestIsEncodeAllowed(t *testing.T) {
})
}
}
type mockEncoder struct{}
func (mockEncoder) Write(p []byte) (n int, err error) { return len(p), nil }
func (mockEncoder) Close() error { return nil }
func (mockEncoder) Reset(w io.Writer) {}
func (mockEncoder) Flush() error { return nil }
func TestServeHTTPDefaultEncodingPreference(t *testing.T) {
enc := new(Encode)
enc.MinLength = 1 // compress everything
enc.writerPools = map[string]*sync.Pool{
"gzip": {
New: func() any { return mockEncoder{} },
},
"zstd": {
New: func() any { return mockEncoder{} },
},
}
// Call Provision() with a valid caddy.Context to exercise the real path
ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
defer cancel()
if err := enc.Provision(ctx); err != nil {
t.Fatalf("Provision failed: %v", err)
}
// Test default preference: zstd preferred over gzip
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatalf("error creating request: %v", err)
}
r.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
w := httptest.NewRecorder()
w.Header().Set("Content-Type", "text/plain")
next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte("Hello, world! This is a long enough string to satisfy min length if it wasn't 1."))
return err
})
err = enc.ServeHTTP(w, r, next)
if err != nil {
t.Fatalf("ServeHTTP returned error: %v", err)
}
// ETag suffix or Content-Encoding header should reflect zstd
contentEncoding := w.Header().Get("Content-Encoding")
if contentEncoding != "zstd" {
t.Errorf("Expected Content-Encoding to be 'zstd' by default, got '%s'", contentEncoding)
}
// Test explicit user preference: gzip over zstd
enc.Prefer = []string{"gzip", "zstd"}
w2 := httptest.NewRecorder()
w2.Header().Set("Content-Type", "text/plain")
err = enc.ServeHTTP(w2, r, next)
if err != nil {
t.Fatalf("ServeHTTP returned error: %v", err)
}
contentEncoding2 := w2.Header().Get("Content-Encoding")
if contentEncoding2 != "gzip" {
t.Errorf("Expected Content-Encoding to be 'gzip' when explicitly preferred, got '%s'", contentEncoding2)
}
}

View file

@ -29,6 +29,7 @@ import (
"runtime"
"strconv"
"strings"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
@ -123,7 +124,7 @@ type FileServer struct {
// put "hidden" in the list. To hide only ./hidden, put "./hidden" in the list.
//
// When possible, all paths are resolved to their absolute form before
// comparisons are made. For maximum clarity and explictness, use complete,
// comparisons are made. For maximum clarity and explicitness, use complete,
// absolute paths; or, for greater portability, use relative paths instead.
//
// Note that hide comparisons are case-sensitive. On case-insensitive
@ -579,7 +580,17 @@ func (fsrv *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request, next c
// that errors generated by ServeContent are written immediately
// to the response, so we cannot handle them (but errors there
// are rare)
http.ServeContent(w, r, info.Name(), info.ModTime(), file.(io.ReadSeeker))
//
// There are a few file modification times that aren't useful
// to send in Last-Modified headers, but the golang http library only
// omits Last-Modified headers for the Unix epoch time. So, force
// the modification time to the epoch time if it's not useful.
zeroTime := time.Time{}
modTime := info.ModTime()
if !usefulModTime(modTime) {
modTime = zeroTime
}
http.ServeContent(w, r, info.Name(), modTime, file.(io.ReadSeeker))
return nil
}
@ -726,6 +737,14 @@ func (fsrv *FileServer) notFound(w http.ResponseWriter, r *http.Request, next ca
return caddyhttp.Error(http.StatusNotFound, nil)
}
// Indicates whether a file's modification time is useful for validator
// generation purposes (i.e. inclusion in ETag and Last-Modified headers).
// See issues #5548 and #7730.
func usefulModTime(modTime time.Time) bool {
mtimeunix := modTime.Unix()
return mtimeunix != 0 && mtimeunix != 1
}
// calculateEtag computes an entity tag using a strong validator
// without consuming the contents of the file. It requires the
// file info contain the correct size and modification time.
@ -743,8 +762,8 @@ func (fsrv *FileServer) notFound(w http.ResponseWriter, r *http.Request, next ca
// which we consider precise enough to qualify as a strong validator.
func calculateEtag(d os.FileInfo) string {
mtime := d.ModTime()
if mtimeUnix := mtime.Unix(); mtimeUnix == 0 || mtimeUnix == 1 {
return "" // not useful anyway; see issue #5548
if !usefulModTime(mtime) {
return ""
}
var sb strings.Builder
sb.WriteRune('"')

View file

@ -15,10 +15,17 @@
package fileserver
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/caddyserver/caddy/v2"
)
func TestFileHidden(t *testing.T) {
@ -128,3 +135,52 @@ func TestFileHidden(t *testing.T) {
}
}
}
// Check to make sure that we don't serve ETag and Last-Modified headers
// for files with invalid modification times
func TestModTimeHeaders(t *testing.T) {
check_validator_headers(time.Now(), true, t)
check_validator_headers(time.Unix(0, 0), false, t)
check_validator_headers(time.Unix(1, 0), false, t)
check_validator_headers(time.Unix(2, 0), true, t)
}
func check_validator_headers(modTime time.Time, expect_headers bool, t *testing.T) {
f := false
fsrv := FileServer{
Root: "./testdata",
CanonicalURIs: &f,
}
w := httptest.NewRecorder()
r, err := http.NewRequest("GET", "/modtime.txt", nil)
if err != nil {
t.Fatal(err)
}
repl := caddy.NewReplacer()
ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, repl)
r = r.WithContext(ctx)
ctx2, _ := caddy.NewContext(caddy.Context{Context: context.Background()}) // module will be nil by default
fsrv.Provision(ctx2)
path := "testdata/modtime.txt"
os.Chtimes(path, modTime, modTime)
fsrv.ServeHTTP(w, r, nil)
if expect_headers {
if w.Header().Get("ETag") == "" {
t.Errorf("Didn't get ETag header for file with valid mod time %s", modTime)
}
if w.Header().Get("Last-Modified") == "" {
t.Errorf("Didn't get Last-Modified header for file with valid mod time %s", modTime)
}
} else {
if w.Header().Get("ETag") != "" {
t.Errorf("Got ETag header for file with invalid mod time %s", modTime)
}
if w.Header().Get("Last-Modified") != "" {
t.Errorf("Got Last-Modified header for file with invalid mod time %s", modTime)
}
}
}

View file

View file

@ -15,7 +15,7 @@ type connectionStater interface {
// http2Listener wraps the listener to solve the following problems:
// 1. prevent genuine h2c connections from succeeding if h2c is not enabled
// and the connection doesn't implment connectionStater or the resulting NegotiatedProtocol
// and the connection doesn't implement connectionStater or the resulting NegotiatedProtocol
// isn't http2.
// This does allow a connection to pass as tls enabled even if it's not, listener wrappers
// can do this.

View file

@ -101,7 +101,7 @@ type httpRedirectConn struct {
// Read tries to peek at the first few bytes of the request, and if we get
// an error reading the headers, and that error was due to the bytes looking
// like an HTTP request, then we perform a HTTP->HTTPS redirect on the same
// like an HTTP request, then we perform an HTTP->HTTPS redirect on the same
// port as the original connection.
func (c *httpRedirectConn) Read(p []byte) (int, error) {
if c.once {

View file

@ -435,12 +435,12 @@ func (m MatchPath) MatchWithError(r *http.Request) (bool, error) {
// can be used instead.
reqPath := strings.ToLower(r.URL.Path)
// See #2917; Windows ignores trailing dots and spaces
// when accessing files (sigh), potentially causing a
// security risk (cry) if PHP files end up being served
// as static files, exposing the source code, instead of
// being matched by *.php to be treated as PHP scripts.
if runtime.GOOS == "windows" { // issue #5613
// Windows treats backslashes as path separators and
// ignores trailing dots and spaces when accessing files
// (sigh), potentially causing a security risk (cry) if
// protected files are not matched as intended.
reqPath = strings.ReplaceAll(reqPath, `\`, "/")
reqPath = strings.TrimRight(reqPath, ". ")
}
@ -478,7 +478,12 @@ func (m MatchPath) MatchWithError(r *http.Request) (bool, error) {
// the intent is to compare that part of the path in raw/escaped
// space; i.e. "%40"=="%40", not "@", and "%2F"=="%2F", not "/"
if strings.Contains(matchPattern, "%") {
reqPathForPattern := CleanPath(r.URL.EscapedPath(), mergeSlashes)
escapedPath := r.URL.EscapedPath()
if runtime.GOOS == "windows" {
escapedPath = windowsEscapedPathSeparatorRepl.Replace(escapedPath)
matchPattern = windowsEscapedPathSeparatorRepl.Replace(matchPattern)
}
reqPathForPattern := CleanPath(escapedPath, mergeSlashes)
if m.matchPatternWithEscapeSequence(reqPathForPattern, matchPattern) {
return true, nil
}
@ -643,6 +648,14 @@ func (MatchPath) matchPatternWithEscapeSequence(escapedPath, matchPath string) b
return matches
}
// windowsEscapedPathSeparatorRepl normalizes Windows backslash separators
// while preserving escaped-path matching semantics.
var windowsEscapedPathSeparatorRepl = strings.NewReplacer(
`\`, "%2f",
"%5c", "%2f",
"%5C", "%2f",
)
// CELLibrary produces options that expose this matcher for use in CEL
// expression matchers.
//

View file

@ -461,18 +461,61 @@ func TestPathMatcherWindows(t *testing.T) {
return
}
req := &http.Request{URL: &url.URL{Path: "/index.php . . .."}}
repl := caddy.NewReplacer()
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
req = req.WithContext(ctx)
match := MatchPath{"*.php"}
matched, err := match.MatchWithError(req)
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
if !matched {
t.Errorf("Expected to match; should ignore trailing dots and spaces")
for _, tc := range []struct {
name string
path string
requestTarget string
match MatchPath
}{
{
name: "trailing dots and spaces",
path: "/index.php . . ..",
match: MatchPath{"*.php"},
},
{
name: "encoded backslash path separator",
requestTarget: `/private%5csecret.txt`,
match: MatchPath{"/private/*"},
},
{
name: "encoded backslash path separator with escaped wildcard",
requestTarget: `/private%5csecret.txt`,
match: MatchPath{"/private/%*"},
},
{
name: "uppercase encoded backslash path separator with escaped wildcard",
requestTarget: `/private%5Csecret.txt`,
match: MatchPath{"/private/%*"},
},
{
name: "encoded backslash in escaped pattern",
requestTarget: `/private%5csecret.txt`,
match: MatchPath{"/private%5c%*"},
},
} {
t.Run(tc.name, func(t *testing.T) {
u := &url.URL{Path: tc.path}
if tc.requestTarget != "" {
var err error
u, err = url.ParseRequestURI(tc.requestTarget)
if err != nil {
t.Fatalf("Parsing request target: %v", err)
}
}
req := &http.Request{URL: u}
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
req = req.WithContext(ctx)
matched, err := tc.match.MatchWithError(req)
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
if !matched {
t.Errorf("Expected %q to match %v", req.URL.Path, tc.match)
}
})
}
}

View file

@ -105,6 +105,14 @@ func addHTTPVarsToReplacer(repl *caddy.Replacer, req *http.Request, w http.Respo
return "http", true
case "http.request.proto":
return req.Proto, true
case "http.request.proto_name":
if req.ProtoMajor == 2 {
return "HTTP/2", true
}
if req.ProtoMajor == 3 {
return "HTTP/3", true
}
return req.Proto, true
case "http.request.host":
host, _, err := net.SplitHostPort(req.Host)
if err != nil {

View file

@ -266,3 +266,33 @@ eqp31wM9il1n+guTNyxJd+FzVAH+hCZE5K+tCgVDdVFUlDEHHbS/wqb2PSIoouLV
}
}
}
func TestHTTPProtoNameNormalization(t *testing.T) {
for _, tc := range []struct {
proto string
major int
expectRaw string
expectName string
}{
{proto: "HTTP/1.0", major: 1, expectRaw: "HTTP/1.0", expectName: "HTTP/1.0"},
{proto: "HTTP/1.1", major: 1, expectRaw: "HTTP/1.1", expectName: "HTTP/1.1"},
{proto: "HTTP/2.0", major: 2, expectRaw: "HTTP/2.0", expectName: "HTTP/2"},
{proto: "HTTP/3.0", major: 3, expectRaw: "HTTP/3.0", expectName: "HTTP/3"},
} {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Proto = tc.proto
req.ProtoMajor = tc.major
repl := caddy.NewReplacer()
addHTTPVarsToReplacer(repl, req, nil)
gotRaw, okRaw := repl.GetString("http.request.proto")
if !okRaw || gotRaw != tc.expectRaw {
t.Errorf("proto=%s: expected http.request.proto to be %q, got %q (ok=%t)", tc.proto, tc.expectRaw, gotRaw, okRaw)
}
gotName, okName := repl.GetString("http.request.proto_name")
if !okName || gotName != tc.expectName {
t.Errorf("proto=%s: expected http.request.proto_name to be %q, got %q (ok=%t)", tc.proto, tc.expectName, gotName, okName)
}
}
}

View file

@ -135,8 +135,8 @@ type client struct {
logger *zap.Logger
}
// Do made the request and returns a io.Reader that translates the data read
// from fcgi responder out of fcgi packet before returning it.
// Do makes the request and returns an io.Reader that translates the data read
// from the FastCGI responder out of FastCGI packets before returning it.
func (c *client) Do(p map[string]string, req io.Reader) (r io.Reader, err error) {
// check for CONTENT_LENGTH, since the lack of it or wrong value will cause the backend to hang
if clStr, ok := p["CONTENT_LENGTH"]; !ok {
@ -179,7 +179,7 @@ func (c *client) Do(p map[string]string, req io.Reader) (r io.Reader, err error)
return r, err
}
// clientCloser is a io.ReadCloser. It wraps a io.Reader with a Closer
// clientCloser is an io.ReadCloser. It wraps an io.Reader with a Closer
// that closes the client connection.
type clientCloser struct {
rwc net.Conn
@ -208,8 +208,8 @@ func (f clientCloser) Close() error {
return f.rwc.Close()
}
// Request returns a HTTP Response with Header and Body
// from fcgi responder
// Request returns an HTTP response with header and body
// from the FastCGI responder.
func (c *client) Request(p map[string]string, req io.Reader) (resp *http.Response, err error) {
r, err := c.Do(p, req)
if err != nil {

View file

@ -507,7 +507,7 @@ var tlsProtocolStrings = map[uint16]string{
tls.VersionTLS13: "TLSv1.3",
}
var headerNameReplacer = strings.NewReplacer(" ", "_", "-", "_")
var headerNameReplacer = strings.NewReplacer("-", "_")
// Interface guards
var (

View file

@ -304,6 +304,30 @@ func TestSplitPosUnicodeSecurityRegression(t *testing.T) {
}
}
// TestHeaderNameReplacer asserts the CGI header-to-env normalization rule:
// hyphens are mapped to underscores while every other character (including
// spaces) is passed through. Spaces are not RFC 7230 tokens, so they cannot
// reach this function from the wire; the only header names that survive
// untouched at the server layer are sanitized by the underscore filter in
// caddyhttp.Server.serveHTTP (see GHSA-f59h-q822-g45g).
func TestHeaderNameReplacer(t *testing.T) {
tests := []struct {
in, want string
}{
{"X-Forwarded-For", "X_Forwarded_For"},
{"Remote-User", "Remote_User"},
// Underscores are preserved (the server has already dropped any
// underscore-named headers when the filter is on).
{"Remote_User", "Remote_User"},
// Spaces are not rewritten because Go's HTTP parser rejects whitespace in
// header field names.
{"Foo Bar", "Foo Bar"},
}
for _, tt := range tests {
assert.Equal(t, tt.want, headerNameReplacer.Replace(tt.in), "input %q", tt.in)
}
}
// TestSplitPosSecurityRegressionUnicodeBypass guards against the FrankenPHP
// advisories GHSA-3g8v-8r37-cgjm (uninitialized match flag on inner non-ASCII
// byte) and GHSA-v4h7-cj44-8fc8 (Unicode equivalence via search.IgnoreCase

View file

@ -522,7 +522,7 @@ func (h *Handler) doActiveHealthCheck(dialInfo DialInfo, hostAddr string, networ
body = io.LimitReader(body, h.HealthChecks.Active.MaxSize)
}
defer func() {
// drain any remaining body so connection could be re-used
// drain any remaining body so connection could be reused
_, _ = io.Copy(io.Discard, body)
resp.Body.Close()
}()

View file

@ -32,6 +32,7 @@ import (
"time"
"github.com/pires/go-proxyproto"
"github.com/quic-go/quic-go"
"github.com/quic-go/quic-go/http3"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
@ -161,7 +162,8 @@ type HTTPTransport struct {
// `HTTPS_PROXY`, and `NO_PROXY` environment variables.
NetworkProxyRaw json.RawMessage `json:"network_proxy,omitempty" caddy:"namespace=caddy.network_proxy inline_key=from"`
h3Transport *http3.Transport // TODO: EXPERIMENTAL (May 2024)
h3Transport *http3.Transport // TODO: EXPERIMENTAL (May 2024)
quicTransport *quic.Transport // used by h3Transport if sni placeholder is used, otherwise nil
}
// CaddyModule returns the Caddy module information.
@ -499,6 +501,25 @@ func (h *HTTPTransport) NewTransport(caddyCtx caddy.Context) (*http.Transport, e
if err != nil {
return nil, fmt.Errorf("making TLS client config for HTTP/3 transport: %v", err)
}
if strings.Contains(h.TLS.ServerName, "{") {
// copied from quic-go
udpConn, err := net.ListenUDP("udp", nil)
if err != nil {
return nil, fmt.Errorf("making udp socket for HTTP/3 transport: %v", err)
}
h.quicTransport = &quic.Transport{Conn: udpConn}
h.h3Transport.Dial = func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) {
// tlsCfg is already cloned from h3Transport.TLSClientConfig
repl := ctx.Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
tlsCfg.ServerName = repl.ReplaceAll(tlsCfg.ServerName, "")
udpAddr, err := resolveUDPAddr(ctx, "udp", addr)
if err != nil {
return nil, err
}
return h.quicTransport.DialEarly(ctx, udpAddr, tlsCfg, cfg)
}
}
}
} else if len(h.Versions) > 1 && slices.Contains(h.Versions, "3") {
return nil, fmt.Errorf("if HTTP/3 is enabled to the upstream, no other HTTP versions are supported")
@ -525,6 +546,71 @@ func (h *HTTPTransport) NewTransport(caddyCtx caddy.Context) (*http.Transport, e
return rt, nil
}
// TODO: EXPERIMENTAL (May 2025)
// copied from quic-go
func resolveUDPAddr(ctx context.Context, network, addr string) (*net.UDPAddr, error) {
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
port, err := net.LookupPort(network, portStr)
if err != nil {
return nil, err
}
resolver := net.DefaultResolver
ipAddrs, err := resolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
addrs := addrList(ipAddrs)
ip := addrs.forResolve(network, addr)
return &net.UDPAddr{IP: ip.IP, Port: port, Zone: ip.Zone}, nil
}
// TODO: EXPERIMENTAL (May 2025)
// copied from quic-go
// An addrList represents a list of network endpoint addresses.
// Copy from [net.addrList] and change type from [net.Addr] to [net.IPAddr]
type addrList []net.IPAddr
// isIPv4 reports whether addr contains an IPv4 address.
func isIPv4(addr net.IPAddr) bool {
return addr.IP.To4() != nil
}
// isNotIPv4 reports whether addr does not contain an IPv4 address.
func isNotIPv4(addr net.IPAddr) bool { return !isIPv4(addr) }
// forResolve returns the most appropriate address in address for
// a call to ResolveTCPAddr, ResolveUDPAddr, or ResolveIPAddr.
// IPv4 is preferred, unless addr contains an IPv6 literal.
func (addrs addrList) forResolve(network, addr string) net.IPAddr {
var want6 bool
switch network {
case "ip":
// IPv6 literal (addr does NOT contain a port)
want6 = strings.ContainsRune(addr, ':')
case "tcp", "udp":
// IPv6 literal. (addr contains a port, so look for '[')
want6 = strings.ContainsRune(addr, '[')
}
if want6 {
return addrs.first(isNotIPv4)
}
return addrs.first(isIPv4)
}
// first returns the first address which satisfies strategy, or if
// none do, then the first address of any kind.
func (addrs addrList) first(strategy func(net.IPAddr) bool) net.IPAddr {
for _, addr := range addrs {
if strategy(addr) {
return addr
}
}
return addrs[0]
}
// RequestHeaderOps implements TransportHeaderOpsProvider. It returns header
// operations for requests when the transport's configuration indicates they
// should be applied. In particular, when TLS is enabled for this transport,
@ -623,6 +709,16 @@ func (h HTTPTransport) Cleanup() error {
return nil
}
h.Transport.CloseIdleConnections()
// h3 related cleanup, errors are ignored as nothing can be done.
// TODO: log these errors if any
if h.h3Transport != nil {
h.h3Transport.CloseIdleConnections()
_ = h.h3Transport.Close()
if h.quicTransport != nil {
_ = h.quicTransport.Close()
_ = h.quicTransport.Conn.Close()
}
}
return nil
}

View file

@ -4,11 +4,14 @@ import (
"context"
"encoding/json"
"fmt"
"net"
"net/url"
"reflect"
"testing"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
)
func TestHTTPTransportUnmarshalCaddyFileWithCaPools(t *testing.T) {
@ -194,3 +197,85 @@ func TestHTTPTransport_DialTLSContext_ProxyProtocol(t *testing.T) {
})
}
}
// TestHTTPTransport_DialContext_DialInfoOverride is a regression test for
// issue #6447: a `tcp4/`-prefixed upstream silently fell back to plain `tcp`
// because dialContext only honored DialInfo for unix networks. PR #7300 widened
// the condition so DialInfo is honored when no upstream HTTP proxy is in use,
// and skipped (for non-unix networks) when one is. Both halves are pinned here.
func TestHTTPTransport_DialContext_DialInfoOverride(t *testing.T) {
ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
defer cancel()
ln, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
c.Close()
}
}()
ht := &HTTPTransport{}
rt, err := ht.NewTransport(ctx)
if err != nil {
t.Fatalf("NewTransport: %v", err)
}
proxyURL, err := url.Parse("http://proxy.example:8080")
if err != nil {
t.Fatalf("parse proxy URL: %v", err)
}
tests := []struct {
name string
proxy bool
dialInfo string
defaultAddr string
}{
{
// no proxy: DialInfo should be applied, so the dial lands on
// the live listener despite the bogus default address.
name: "honors DialInfo when no proxy",
proxy: false,
dialInfo: ln.Addr().String(),
defaultAddr: "127.0.0.1:1",
},
{
// proxy active: DialInfo must NOT be applied for non-unix
// networks; the default address (the live listener) is used.
name: "skips DialInfo when proxy active",
proxy: true,
dialInfo: "127.0.0.1:1",
defaultAddr: ln.Addr().String(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dialCtx := context.WithValue(context.Background(), caddyhttp.VarsCtxKey, make(map[string]any))
caddyhttp.SetVar(dialCtx, dialInfoVarKey, DialInfo{
Network: "tcp4",
Address: tt.dialInfo,
})
if tt.proxy {
caddyhttp.SetVar(dialCtx, proxyVarKey, proxyURL)
}
conn, err := rt.DialContext(dialCtx, "tcp", tt.defaultAddr)
if err != nil {
t.Fatalf("DialContext: %v", err)
}
t.Cleanup(func() { conn.Close() })
if got := conn.RemoteAddr().String(); got != ln.Addr().String() {
t.Fatalf("conn.RemoteAddr() = %s, want %s", got, ln.Addr().String())
}
})
}
}

View file

@ -449,6 +449,39 @@ func (h *Handler) Cleanup() error {
return err
}
// bodyNopCloserIfNotRead wraps a request body to prevent closing if not read, i.e., when
// dialing to upstream fails.
// It will close the body as normal if the body is read.
type bodyNopCloserIfNotRead struct {
io.ReadCloser
read int // tracks the number of bytes read, -1 when first Read returns 0, io.EOF
}
func (b *bodyNopCloserIfNotRead) Read(p []byte) (int, error) {
if b.read == -1 {
return 0, io.EOF
}
n, err := b.ReadCloser.Read(p)
// first Read returns 0, io.EOF
if b.read == 0 && n == 0 && err == io.EOF {
b.read = -1
} else {
b.read += n
}
return n, err
}
func (b *bodyNopCloserIfNotRead) Close() error {
// don't close the body
if b.read == 0 {
return nil
}
// close as usual, when -1, any read will return EOF as the original read will do
// in other cases, the read will fail as body is closed because we do not want partial bodies to be sent to the upstream
// users can buffer the entire request body to allow the request to be resent
return b.ReadCloser.Close()
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
@ -510,7 +543,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht
bufPool.Put(bufferedReqBody)
}()
} else {
clonedReq.Body = io.NopCloser(clonedReq.Body)
clonedReq.Body = &bodyNopCloserIfNotRead{ReadCloser: clonedReq.Body}
}
}

View file

@ -568,7 +568,7 @@ func TestQueryHashPolicy(t *testing.T) {
pool[1].setHealthy(false)
h = queryPolicy.Select(pool, request, nil)
if h != nil {
t.Error("Expected query policy policy host to be nil.")
t.Error("Expected query policy host to be nil.")
}
request = httptest.NewRequest(http.MethodGet, "/?foo=aa11&foo=bb22", nil)
@ -630,7 +630,7 @@ func TestURIHashPolicy(t *testing.T) {
pool[1].setHealthy(false)
h = uriPolicy.Select(pool, request, nil)
if h != nil {
t.Error("Expected uri policy policy host to be nil.")
t.Error("Expected uri policy host to be nil.")
}
}

View file

@ -223,6 +223,15 @@ func (rewr Rewrite) Rewrite(r *http.Request, repl *caddy.Replacer) bool {
newPath, injectedQuery = before, after
// don't overwrite explicitly-configured query string
if query == "" {
// the injected query came from the first-pass placeholder
// expansion above, which means any '{' or '}' bytes in it
// must have come from replacement values (e.g. a request
// header), not from operator-written placeholder syntax.
// escape them so buildQueryString does not re-expand them,
// which would allow attacker input like {env.SECRET} to be
// evaluated (see GHSA-j8px-rmrx-76h9).
injectedQuery = strings.ReplaceAll(injectedQuery, "{", "%7B")
injectedQuery = strings.ReplaceAll(injectedQuery, "}", "%7D")
query = injectedQuery
}
}

View file

@ -18,6 +18,7 @@ import (
"net/http"
"reflect"
"regexp"
"strings"
"testing"
"github.com/caddyserver/caddy/v2"
@ -350,6 +351,32 @@ func TestRewrite(t *testing.T) {
input: newRequest(t, "GET", "/foo//bar///baz?a=b//c"),
expect: newRequest(t, "GET", "/foo/bar/baz?a=b//c"),
},
// regression tests for GHSA-j8px-rmrx-76h9: when the rewrite URI
// ends with a literal '?', the first-pass placeholder expansion
// may produce a path containing attacker-controlled bytes that
// then get split at '?' and fed into buildQueryString, which runs
// a SECOND placeholder pass. Bytes injected via a header value (or
// any other client-controlled placeholder) must not be treated as
// placeholder syntax during this second pass.
{
// literal header value containing placeholder syntax is not re-expanded into query
rule: Rewrite{URI: "/serve/{http.request.header.X-Fwd}?"},
input: newRequestWithHeader(t, "GET", "/anything", "X-Fwd", "foo?{env.CADDY_REWRITE_TEST_SECRET}=leak"),
expect: newRequest(t, "GET", "/serve/foo?%7Benv.CADDY_REWRITE_TEST_SECRET%7D=leak"),
},
{
// literal header value with placeholder syntax in query position is not re-expanded
rule: Rewrite{URI: "/serve/{http.request.header.X-Fwd}?"},
input: newRequestWithHeader(t, "GET", "/anything", "X-Fwd", "ok?key={env.CADDY_REWRITE_TEST_SECRET}"),
expect: newRequest(t, "GET", "/serve/ok?key=%7Benv.CADDY_REWRITE_TEST_SECRET%7D"),
},
{
// literal header value with embedded file placeholder is not re-expanded
rule: Rewrite{URI: "/serve/{http.request.header.X-Fwd}?"},
input: newRequestWithHeader(t, "GET", "/anything", "X-Fwd", "ok?path={file./etc/passwd}"),
expect: newRequest(t, "GET", "/serve/ok?path=%7Bfile./etc/passwd%7D"),
},
} {
// copy the original input just enough so that we can
// compare it after the rewrite to see if it changed
@ -364,6 +391,9 @@ func TestRewrite(t *testing.T) {
repl.Set("http.request.uri", tc.input.RequestURI)
repl.Set("http.request.uri.path", tc.input.URL.Path)
repl.Set("http.request.uri.query", tc.input.URL.RawQuery)
for field, vals := range tc.input.Header {
repl.Set("http.request.header."+field, strings.Join(vals, ","))
}
// we can't directly call Provision() without a valid caddy.Context
// (TODO: fix that) so here we ad-hoc compile the regex
@ -456,6 +486,12 @@ func newRequest(t *testing.T, method, uri string) *http.Request {
return req
}
func newRequestWithHeader(t *testing.T, method, uri, headerKey, headerVal string) *http.Request {
req := newRequest(t, method, uri)
req.Header.Set(headerKey, headerVal)
return req
}
// reqEqual if r1 and r2 are equal enough for our purposes.
func reqEqual(r1, r2 *http.Request) bool {
if r1.Method != r2.Method {

View file

@ -494,6 +494,19 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) error {
}
}
// Drop headers whose names contain `_`: once FastCGI/CGI/FrankenPHP etc. rewrites `-` to
// `_`, an underscore alias collides with the legitimate hyphenated header
// and can bypass `forward_auth copy_headers` (GHSA-f59h-q822-g45g).
for k := range r.Header {
if strings.ContainsRune(k, '_') {
delete(r.Header, k)
if c := s.logger.Check(zapcore.DebugLevel, "dropping header containing underscore"); c != nil {
c.Write(zap.String("header", k))
}
}
}
// execute the primary handler chain
return s.primaryHandlerChain.ServeHTTP(w, r)
}

View file

@ -1,16 +1,20 @@
package caddyhttp
import (
"bufio"
"bytes"
"context"
"io"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
@ -478,6 +482,79 @@ func TestServer_DetermineTrustedProxy_MatchRightMostUntrustedSkippingTrusted(t *
assert.Equal(t, clientIP, "45.54.45.54")
}
// TestServer_serveHTTP_DropsUnderscoreHeader covers GHSA-f59h-q822-g45g: an
// underscore-named alias (e.g. `Remote_user`) of a hyphenated header must be
// dropped before any handler runs.
func TestServer_serveHTTP_DropsUnderscoreHeader(t *testing.T) {
got := &http.Header{}
s := &Server{
logger: zap.NewNop(),
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
*got = r.Header.Clone()
return nil
}),
}
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.Header["X-Real-Header"] = []string{"ok"}
req.Header["Remote_user"] = []string{"attacker"}
req.Header["Remote_groups"] = []string{"admin"}
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
assert.NotContains(t, *got, "Remote_user")
assert.NotContains(t, *got, "Remote_groups")
assert.Equal(t, "ok", got.Get("X-Real-Header"))
}
// TestServer_serveHTTP_LogsDroppedUnderscoreHeader verifies each dropped
// header is emitted at debug level so operators can diagnose unexpectedly
// missing headers without spamming the log on adversarial traffic.
func TestServer_serveHTTP_LogsDroppedUnderscoreHeader(t *testing.T) {
var buf bytes.Buffer
s := &Server{
logger: testLogger(buf.Write),
primaryHandlerChain: HandlerFunc(func(http.ResponseWriter, *http.Request) error {
return nil
}),
}
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.Header["Remote_user"] = []string{"attacker"}
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
assert.Contains(t, buf.String(), `"level":"debug"`)
assert.Contains(t, buf.String(), `"msg":"dropping header containing underscore"`)
assert.Contains(t, buf.String(), `"header":"Remote_user"`)
}
// TestServer_SpaceInHeaderNameReturnsBadRequest documents why the underscore
// filter does not also strip space-named headers: Go's HTTP parser rejects a
// space in a field name with 400 before any handler runs, so such a request
// can never reach Caddy's pipeline.
func TestServer_SpaceInHeaderNameReturnsBadRequest(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("handler must not be reached; got headers %v", r.Header)
}))
t.Cleanup(srv.Close)
addr := strings.TrimPrefix(srv.URL, "http://")
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second)))
_, err = conn.Write([]byte("GET / HTTP/1.1\r\n" +
"Host: " + addr + "\r\n" +
"Remote User: attacker\r\n" +
"Connection: close\r\n\r\n"))
require.NoError(t, err)
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
require.NoError(t, err)
t.Cleanup(func() { _ = resp.Body.Close() })
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
func TestServer_DetermineTrustedProxy_MatchRightMostUntrustedFirst(t *testing.T) {
localPrivatePrefix, _ := netip.ParsePrefix("10.0.0.0/8")

View file

@ -312,35 +312,32 @@ func (c TemplateContext) Host() (string, error) {
return host, nil
}
// funcStripHTML returns s without HTML tags. It is fairly naive
// but works with most valid HTML inputs.
// funcStripHTML returns s without HTML tags. Similar to PHP's strip_tags()
func (TemplateContext) funcStripHTML(s string) string {
var buf bytes.Buffer
var inTag, inQuotes bool
var tagStart int
for i, ch := range s {
if inTag {
if ch == '>' && !inQuotes {
inTag = false
} else if ch == '<' && !inQuotes {
// false start
buf.WriteString(s[tagStart:i])
tagStart = i
} else if ch == '"' {
inQuotes = !inQuotes
depth := 0
var quoteChar rune
for _, ch := range s {
switch {
case depth > 0 && quoteChar == 0 && (ch == '"' || ch == '\''):
// entering a quoted attribute value
quoteChar = ch
case depth > 0 && ch == quoteChar:
// leaving a quoted attribute value
quoteChar = 0
case ch == '<' && quoteChar == 0:
depth++
case ch == '>' && quoteChar == 0:
if depth > 0 {
depth--
} else {
buf.WriteRune(ch) // stray '>' with no opening '<', keep it
}
default:
if depth == 0 {
buf.WriteRune(ch)
}
continue
}
if ch == '<' {
inTag = true
tagStart = i
continue
}
buf.WriteRune(ch)
}
if inTag {
// false start
buf.WriteString(s[tagStart:])
}
return buf.String()
}

View file

@ -419,14 +419,44 @@ func TestStripHTML(t *testing.T) {
expect: `h1`,
},
{
// tags not closed
// unclosed tag — trailing text must be stripped, not emitted
input: `<h1`,
expect: `<h1`,
expect: ``,
},
{
// false start
input: `<h1<b>hi`,
expect: `<h1hi`,
// false start — second '<' increments depth, single '>' only closes one level
input: `<h1<b>hi`,
expect: ``,
},
{
// XSS bypass via double opening bracket
input: `<<>img src=x onerror=alert('XSS')>`,
expect: ``,
},
{
// stacked angle brackets (PHP strip_tags parity)
input: `<<<<<>>>>><b>hello</b>`,
expect: `hello`,
},
{
// unclosed tag strips trailing text
input: `hello <world`,
expect: `hello `,
},
{
// '>' inside double-quoted attribute must not close tag early
input: `<a href="foo>bar">text</a>`,
expect: `text`,
},
{
// '>' inside single-quoted attribute must not close tag early
input: `<a href='foo>bar'>text</a>`,
expect: `text`,
},
{
// stray '>' with no opening '<' is preserved
input: `stray > bracket`,
expect: `stray > bracket`,
},
} {
actual := tplContext.funcStripHTML(test.input)

View file

@ -158,7 +158,7 @@ type AutomationPolicy struct {
DisableOCSPStapling bool `json:"disable_ocsp_stapling,omitempty"`
// Overrides the URLs of OCSP responders embedded in certificates.
// Each key is a OCSP server URL to override, and its value is the
// Each key is an OCSP server URL to override, and its value is the
// replacement. An empty value will disable querying of that server.
// EXPERIMENTAL. Subject to change.
OCSPOverrides map[string]string `json:"ocsp_overrides,omitempty"`

View file

@ -107,7 +107,8 @@ func (cp ConnectionPolicies) TLSConfig(ctx caddy.Context) *tls.Config {
if sni, ok := m.(MatchServerName); ok {
for _, sniName := range sni {
// index for fast lookups during handshakes
indexedBySNI[sniName] = append(indexedBySNI[sniName], p)
indexName := asciiServerNameForMatch(sniName)
indexedBySNI[indexName] = append(indexedBySNI[indexName], p)
}
}
}
@ -118,7 +119,7 @@ func (cp ConnectionPolicies) TLSConfig(ctx caddy.Context) *tls.Config {
// filter policies by SNI first, if possible, to speed things up
// when there may be lots of policies
possiblePolicies := cp
if indexedPolicies, ok := indexedBySNI[hello.ServerName]; ok {
if indexedPolicies, ok := indexedBySNI[asciiServerNameForMatch(hello.ServerName)]; ok {
possiblePolicies = indexedPolicies
}

View file

@ -15,6 +15,8 @@
package caddytls
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"reflect"
@ -24,6 +26,40 @@ import (
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
)
func TestConnectionPolicyIDNSNIMatcherFastPath(t *testing.T) {
ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
defer cancel()
targetTLSConfig := &tls.Config{ClientAuth: tls.RequireAnyClientCert}
policies := ConnectionPolicies{
{
matchers: []ConnectionMatcher{MatchServerName{"つ.Localhost"}},
TLSConfig: targetTLSConfig,
},
}
const sniFastPathThreshold = 30
for i := len(policies); i < sniFastPathThreshold; i++ {
policies = append(policies, &ConnectionPolicy{
matchers: []ConnectionMatcher{MatchServerName{fmt.Sprintf("example-%d.localhost", i)}},
TLSConfig: &tls.Config{},
})
}
policies = append(policies, &ConnectionPolicy{
matchers: []ConnectionMatcher{MatchServerName{"xn--k9j.localhost"}},
TLSConfig: &tls.Config{ClientAuth: tls.NoClientCert},
})
tlsConfig := policies.TLSConfig(ctx)
got, err := tlsConfig.GetConfigForClient(&tls.ClientHelloInfo{ServerName: "XN--K9J.LOCALHOST"})
if err != nil {
t.Fatalf("GetConfigForClient() error = %v", err)
}
if got != targetTLSConfig {
t.Fatalf("expected Unicode IDN policy to match before later punycode policy")
}
}
func TestClientAuthenticationUnmarshalCaddyfileWithDirectiveName(t *testing.T) {
const test_der_1 = `MIIDSzCCAjOgAwIBAgIUfIRObjWNUA4jxQ/0x8BOCvE2Vw4wDQYJKoZIhvcNAQELBQAwFjEUMBIGA1UEAwwLRWFzeS1SU0EgQ0EwHhcNMTkwODI4MTYyNTU5WhcNMjkwODI1MTYyNTU5WjAWMRQwEgYDVQQDDAtFYXN5LVJTQSBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK5m5elxhQfMp/3aVJ4JnpN9PUSz6LlP6LePAPFU7gqohVVFVtDkChJAG3FNkNQNlieVTja/bgH9IcC6oKbROwdY1h0MvNV8AHHigvl03WuJD8g2ReVFXXwsnrPmKXCFzQyMI6TYk3m2gYrXsZOU1GLnfMRC3KAMRgE2F45twOs9hqG169YJ6mM2eQjzjCHWI6S2/iUYvYxRkCOlYUbLsMD/AhgAf1plzg6LPqNxtdlwxZnA0ytgkmhK67HtzJu0+ovUCsMv0RwcMhsEo9T8nyFAGt9XLZ63X5WpBCTUApaAUhnG0XnerjmUWb6eUWw4zev54sEfY5F3x002iQaW6cECAwEAAaOBkDCBjTAdBgNVHQ4EFgQU4CBUbZsS2GaNIkGRz/cBsD5ivjswUQYDVR0jBEowSIAU4CBUbZsS2GaNIkGRz/cBsD5ivjuhGqQYMBYxFDASBgNVBAMMC0Vhc3ktUlNBIENBghR8hE5uNY1QDiPFD/THwE4K8TZXDjAMBgNVHRMEBTADAQH/MAsGA1UdDwQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAQEAKB3V4HIzoiO/Ch6WMj9bLJ2FGbpkMrcb/Eq01hT5zcfKD66lVS1MlK+cRL446Z2b2KDP1oFyVs+qmrmtdwrWgD+nfe2sBmmIHo9m9KygMkEOfG3MghGTEcS+0cTKEcoHYWYyOqQh6jnedXY8Cdm4GM1hAc9MiL3/sqV8YCVSLNnkoNysmr06/rZ0MCUZPGUtRmfd0heWhrfzAKw2HLgX+RAmpOE2MZqWcjvqKGyaRiaZks4nJkP6521aC2Lgp0HhCz1j8/uQ5ldoDszCnu/iro0NAsNtudTMD+YoLQxLqdleIh6CW+illc2VdXwj7mn6J04yns9jfE2jRjW/yTLFuQ==`
const test_cert_file_1 = "../../caddytest/caddy.ca.cer"

View file

@ -28,6 +28,7 @@ import (
"github.com/caddyserver/certmagic"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"golang.org/x/net/idna"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
@ -69,15 +70,62 @@ func (m MatchServerName) Match(hello *tls.ClientHelloInfo) bool {
repl = caddy.NewReplacer()
}
serverName := asciiServerNameForMatch(hello.ServerName)
for _, name := range m {
rs := repl.ReplaceAll(name, "")
if certmagic.MatchWildcard(hello.ServerName, rs) {
rs := asciiServerNameForMatch(repl.ReplaceAll(name, ""))
if certmagic.MatchWildcard(serverName, rs) {
return true
}
}
return false
}
func asciiServerNameForMatch(name string) string {
if name == "" {
return name
}
// Fast path: if the name is pure ASCII, skip idna.ToASCII.
// SNI values on the wire are always ASCII (RFC 6066), and most
// config patterns are also ASCII. For pure ASCII input, idna.ToASCII
// only validates and lowercases, which is equivalent to our fallback.
if isPureASCII(name) {
return strings.ToLower(name)
}
// Config can use Unicode IDNs.
ascii, err := idna.ToASCII(name)
if err == nil {
return strings.ToLower(ascii)
}
if !strings.Contains(name, "*") {
return strings.ToLower(name)
}
labels := strings.Split(name, ".")
for i, label := range labels {
if label == "" || label == "*" {
continue
}
ascii, err := idna.ToASCII(label)
if err != nil {
return strings.ToLower(name)
}
labels[i] = strings.ToLower(ascii)
}
return strings.Join(labels, ".")
}
func isPureASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] >= 0x80 {
return false
}
}
return true
}
// UnmarshalCaddyfile sets up the MatchServerName from Caddyfile tokens. Syntax:
//
// sni <domains...>

View file

@ -79,6 +79,26 @@ func TestServerNameMatcher(t *testing.T) {
input: "sub2.sub.example.com",
expect: true,
},
{
names: []string{"つ.localhost"},
input: "xn--k9j.localhost",
expect: true,
},
{
names: []string{"つ.Localhost"},
input: "XN--K9J.LOCALHOST",
expect: true,
},
{
names: []string{"*.つ.localhost"},
input: "sub.xn--k9j.localhost",
expect: true,
},
{
names: []string{"*.つ.Localhost"},
input: "Sub.XN--K9J.LOCALHOST",
expect: true,
},
} {
chi := &tls.ClientHelloInfo{ServerName: tc.input}
actual := MatchServerName(tc.names).Match(chi)

View file

@ -137,11 +137,10 @@ func (s *SessionTicketService) stayUpdated() {
case newKeys := <-keysChan:
s.mu.Lock()
s.currentKeys = newKeys
configs := s.configs
s.mu.Unlock()
for cfg := range configs {
for cfg := range s.configs {
cfg.SetSessionTicketKeys(newKeys)
}
s.mu.Unlock()
case <-s.stopChan:
return
}

View file

@ -440,7 +440,7 @@ func (t *TLS) Start() error {
t.EncryptedClientHello.configsMu.Unlock()
if err != nil {
echLogger.Error("rotating ECH configs failed", zap.Error(err))
return
continue
}
err := t.publishECHConfigs(echLogger)
if err != nil {
@ -879,6 +879,8 @@ func (t *TLS) getAutomationPolicyForName(name string) *AutomationPolicy {
// AllMatchingCertificates returns the list of all certificates in
// the cache which could be used to satisfy the given SAN.
func AllMatchingCertificates(san string) []certmagic.Certificate {
certCacheMu.RLock()
defer certCacheMu.RUnlock()
return certCache.AllMatchingCertificates(san)
}

View file

@ -149,10 +149,10 @@ func (f *ReplaceFilter) Filter(in zapcore.Field) zapcore.Field {
// list of IP addresses, where all of the values
// will be masked.
type IPMaskFilter struct {
// The IPv4 mask, as an subnet size CIDR.
// The IPv4 mask, as a subnet size CIDR.
IPv4MaskRaw int `json:"ipv4_cidr,omitempty"`
// The IPv6 mask, as an subnet size CIDR.
// The IPv6 mask, as a subnet size CIDR.
IPv6MaskRaw int `json:"ipv6_cidr,omitempty"`
v4Mask net.IPMask