From 0125ae39cccfdf9b6fdfb16d5a59f3ad37a2caf6 Mon Sep 17 00:00:00 2001 From: Brett Bethke <10068296+bb4242@users.noreply.github.com> Date: Wed, 20 May 2026 01:19:11 -0500 Subject: [PATCH 01/25] caddyhttp: omit Last-Modified for unusable mod times (#7740) See #5548 and #7730 --- modules/caddyhttp/fileserver/staticfiles.go | 25 ++++++++- .../caddyhttp/fileserver/staticfiles_test.go | 56 +++++++++++++++++++ .../caddyhttp/fileserver/testdata/modtime.txt | 0 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 modules/caddyhttp/fileserver/testdata/modtime.txt diff --git a/modules/caddyhttp/fileserver/staticfiles.go b/modules/caddyhttp/fileserver/staticfiles.go index 507321ad6..70fbd6192 100644 --- a/modules/caddyhttp/fileserver/staticfiles.go +++ b/modules/caddyhttp/fileserver/staticfiles.go @@ -29,6 +29,7 @@ import ( "runtime" "strconv" "strings" + "time" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -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('"') diff --git a/modules/caddyhttp/fileserver/staticfiles_test.go b/modules/caddyhttp/fileserver/staticfiles_test.go index 5d6133c73..5d3bcbd06 100644 --- a/modules/caddyhttp/fileserver/staticfiles_test.go +++ b/modules/caddyhttp/fileserver/staticfiles_test.go @@ -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) + } + } +} diff --git a/modules/caddyhttp/fileserver/testdata/modtime.txt b/modules/caddyhttp/fileserver/testdata/modtime.txt new file mode 100644 index 000000000..e69de29bb From 325c244ea71a645c224afa1d0b46296ed76ef9fd Mon Sep 17 00:00:00 2001 From: cbro Date: Wed, 20 May 2026 02:35:40 -0400 Subject: [PATCH 02/25] caddytls: fix TLS state races and ECH rotation retry (#7756) * caddytls: fix data race in session ticket key rotation stayUpdated copies the map header (configs := s.configs) under the lock, then iterates the original map after releasing it. Concurrent calls to register/unregister mutate the same map. Hold the lock for the entire iteration instead. * caddytls: fix data race in AllMatchingCertificates AllMatchingCertificates reads the package-level certCache without acquiring certCacheMu, while Cleanup sets certCache to nil under the write lock. The adjacent HasCertificateForSubject correctly acquires certCacheMu.RLock. Add the missing RLock/RUnlock to match. * caddytls: fix ECH key rotation stopping permanently on error When rotateECHKeys returns an error, the rotation goroutine returns immediately, stopping all future key rotation for the lifetime of the process. Change return to continue, matching the error handling for publishECHConfigs two lines below. --- modules/caddytls/sessiontickets.go | 5 ++--- modules/caddytls/tls.go | 4 +++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/modules/caddytls/sessiontickets.go b/modules/caddytls/sessiontickets.go index bfc5628ac..7ebca4604 100644 --- a/modules/caddytls/sessiontickets.go +++ b/modules/caddytls/sessiontickets.go @@ -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 } diff --git a/modules/caddytls/tls.go b/modules/caddytls/tls.go index 928e109e6..b993cba6e 100644 --- a/modules/caddytls/tls.go +++ b/modules/caddytls/tls.go @@ -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) } From 88037f1666eb9ce1b26453d32dc861cb3a87a4c7 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Wed, 20 May 2026 16:36:30 +1000 Subject: [PATCH 03/25] chore: clean up wording and typo fixes (#7745) * chore: clean up wording and typo fixes * chore: ASCII -> alphanumeric in lexer for heredoc marker --- caddyconfig/caddyfile/lexer.go | 4 ++-- caddyconfig/caddyfile/lexer_test.go | 2 +- caddyconfig/caddyfile/parse.go | 2 +- caddytest/caddytest.go | 12 ++++++------ .../heredoc_invalid_marker.caddyfiletest | 2 +- caddytest/integration/stream_test.go | 2 +- cmd/commands.go | 2 +- modules/caddyhttp/celmatcher.go | 6 +++--- modules/caddyhttp/celmatcher_test.go | 2 +- modules/caddyhttp/encode/encode.go | 4 ++-- modules/caddyhttp/fileserver/staticfiles.go | 2 +- modules/caddyhttp/http2listener.go | 2 +- modules/caddyhttp/httpredirectlistener.go | 2 +- modules/caddyhttp/reverseproxy/fastcgi/client.go | 10 +++++----- modules/caddyhttp/reverseproxy/healthchecks.go | 2 +- .../caddyhttp/reverseproxy/selectionpolicies_test.go | 4 ++-- modules/caddytls/automation.go | 2 +- modules/logging/filters.go | 4 ++-- 18 files changed, 33 insertions(+), 33 deletions(-) diff --git a/caddyconfig/caddyfile/lexer.go b/caddyconfig/caddyfile/lexer.go index 60dabe43d..40ea2e5f7 100644 --- a/caddyconfig/caddyfile/lexer.go +++ b/caddyconfig/caddyfile/lexer.go @@ -155,7 +155,7 @@ func (l *lexer) next() (bool, error) { // want to keep. if ch == '\n' { if len(val) == 2 { - return false, fmt.Errorf("missing opening heredoc marker on line #%d; must contain only alpha-numeric characters, dashes and underscores; got empty string", l.line) + return false, fmt.Errorf("missing opening heredoc marker on line #%d; must contain only alphanumeric characters, dashes and underscores; got empty string", l.line) } // check if there's too many < @@ -165,7 +165,7 @@ func (l *lexer) next() (bool, error) { heredocMarker = string(val[2:]) if !heredocMarkerRegexp.Match([]byte(heredocMarker)) { - return false, fmt.Errorf("heredoc marker on line #%d must contain only alpha-numeric characters, dashes and underscores; got '%s'", l.line, heredocMarker) + return false, fmt.Errorf("heredoc marker on line #%d must contain only alphanumeric characters, dashes and underscores; got '%s'", l.line, heredocMarker) } inHeredoc = true diff --git a/caddyconfig/caddyfile/lexer_test.go b/caddyconfig/caddyfile/lexer_test.go index 7389af79b..89dde2d9f 100644 --- a/caddyconfig/caddyfile/lexer_test.go +++ b/caddyconfig/caddyfile/lexer_test.go @@ -424,7 +424,7 @@ EOF { input: []byte("not-a-heredoc <<\n"), expectErr: true, - errorMessage: "missing opening heredoc marker on line #1; must contain only alpha-numeric characters, dashes and underscores; got empty string", + errorMessage: "missing opening heredoc marker on line #1; must contain only alphanumeric characters, dashes and underscores; got empty string", }, { input: []byte(`heredoc <<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 { diff --git a/modules/caddyhttp/reverseproxy/fastcgi/client.go b/modules/caddyhttp/reverseproxy/fastcgi/client.go index 48599c27f..7811ae234 100644 --- a/modules/caddyhttp/reverseproxy/fastcgi/client.go +++ b/modules/caddyhttp/reverseproxy/fastcgi/client.go @@ -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 { diff --git a/modules/caddyhttp/reverseproxy/healthchecks.go b/modules/caddyhttp/reverseproxy/healthchecks.go index 73604f916..a737f116e 100644 --- a/modules/caddyhttp/reverseproxy/healthchecks.go +++ b/modules/caddyhttp/reverseproxy/healthchecks.go @@ -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() }() diff --git a/modules/caddyhttp/reverseproxy/selectionpolicies_test.go b/modules/caddyhttp/reverseproxy/selectionpolicies_test.go index 580abbdde..f915b1467 100644 --- a/modules/caddyhttp/reverseproxy/selectionpolicies_test.go +++ b/modules/caddyhttp/reverseproxy/selectionpolicies_test.go @@ -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.") } } diff --git a/modules/caddytls/automation.go b/modules/caddytls/automation.go index 5b7a4ed5d..918a58b40 100644 --- a/modules/caddytls/automation.go +++ b/modules/caddytls/automation.go @@ -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"` diff --git a/modules/logging/filters.go b/modules/logging/filters.go index 087b872e7..b863e72ea 100644 --- a/modules/logging/filters.go +++ b/modules/logging/filters.go @@ -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 From 0b265eb845efd76045e8d8327a72c7d8d27fa2c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ey=C3=BCp=20Can=20Akman?= Date: Wed, 20 May 2026 16:43:58 +0300 Subject: [PATCH 04/25] reverseproxy: Add regression test for DialInfo network override (#7758) --- .../reverseproxy/httptransport_test.go | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/modules/caddyhttp/reverseproxy/httptransport_test.go b/modules/caddyhttp/reverseproxy/httptransport_test.go index 55ca3fd33..f64b58468 100644 --- a/modules/caddyhttp/reverseproxy/httptransport_test.go +++ b/modules/caddyhttp/reverseproxy/httptransport_test.go @@ -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()) + } + }) + } +} From 408d20a0e5b5311ffc7f0312e8829d281ff55ac1 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Wed, 20 May 2026 23:51:54 +1000 Subject: [PATCH 05/25] caddyauth: add candidate placeholders for rejected identities (#7698) --- modules/caddyhttp/caddyauth/caddyauth.go | 31 ++- modules/caddyhttp/caddyauth/caddyauth_test.go | 197 ++++++++++++++++++ 2 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 modules/caddyhttp/caddyauth/caddyauth_test.go diff --git a/modules/caddyhttp/caddyauth/caddyauth.go b/modules/caddyhttp/caddyauth/caddyauth.go index 792c198ee..30bcdf66b 100644 --- a/modules/caddyhttp/caddyauth/caddyauth.go +++ b/modules/caddyhttp/caddyauth/caddyauth.go @@ -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..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 diff --git a/modules/caddyhttp/caddyauth/caddyauth_test.go b/modules/caddyhttp/caddyauth/caddyauth_test.go new file mode 100644 index 000000000..708dcedf0 --- /dev/null +++ b/modules/caddyhttp/caddyauth/caddyauth_test.go @@ -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 +} From 6628c4a9de5588e43430b285f6f4de376aaafe70 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Thu, 21 May 2026 00:17:34 +1000 Subject: [PATCH 06/25] cmd: support caddy start on IPv6-only hosts (#7744) --- cmd/commandfuncs.go | 18 ++++++++++- cmd/main_test.go | 76 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/cmd/commandfuncs.go b/cmd/commandfuncs.go index faa275b03..56cde4758 100644 --- a/cmd/commandfuncs.go +++ b/cmd/commandfuncs.go @@ -58,7 +58,7 @@ func cmdStart(fl Flags) (int, error) { // open a listener to which the child process will connect when // it is ready to confirm that it has successfully started - ln, err := net.Listen("tcp", "127.0.0.1:0") + ln, err := listenTCPForPingback(net.Listen) if err != nil { return caddy.ExitCodeFailedStartup, fmt.Errorf("opening listener for success confirmation: %v", err) @@ -169,6 +169,22 @@ func cmdStart(fl Flags) (int, error) { return caddy.ExitCodeSuccess, nil } +type tcpListenFunc func(network, address string) (net.Listener, error) + +func listenTCPForPingback(listen tcpListenFunc) (net.Listener, error) { + ln, ipv4Err := listen("tcp4", "127.0.0.1:0") + if ipv4Err == nil { + return ln, nil + } + + ln, ipv6Err := listen("tcp6", "[::1]:0") + if ipv6Err == nil { + return ln, nil + } + + return nil, fmt.Errorf("listen on 127.0.0.1:0: %v; listen on [::1]:0: %v", ipv4Err, ipv6Err) +} + func cmdRun(fl Flags) (int, error) { caddy.TrapSignals() diff --git a/cmd/main_test.go b/cmd/main_test.go index bff34f443..803574a9b 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -1,6 +1,8 @@ package caddycmd import ( + "errors" + "net" "reflect" "strings" "testing" @@ -169,6 +171,80 @@ here" } } +func TestListenTCPForPingbackUsesIPv4Loopback(t *testing.T) { + var calls []string + expected := &stubListener{addr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 1234}} + + actual, err := listenTCPForPingback(func(network, address string) (net.Listener, error) { + calls = append(calls, network+" "+address) + return expected, nil + }) + if err != nil { + t.Fatalf("listenTCPForPingback returned error: %v", err) + } + if actual != expected { + t.Fatalf("expected listener %p, got %p", expected, actual) + } + + expectCalls := []string{"tcp4 127.0.0.1:0"} + if !reflect.DeepEqual(calls, expectCalls) { + t.Fatalf("expected calls %v, got %v", expectCalls, calls) + } +} + +func TestListenTCPForPingbackFallsBackToIPv6Loopback(t *testing.T) { + var calls []string + expected := &stubListener{addr: &net.TCPAddr{IP: net.ParseIP("::1"), Port: 1234}} + + actual, err := listenTCPForPingback(func(network, address string) (net.Listener, error) { + calls = append(calls, network+" "+address) + if len(calls) == 1 { + return nil, errors.New("ipv4 unavailable") + } + return expected, nil + }) + if err != nil { + t.Fatalf("listenTCPForPingback returned error: %v", err) + } + if actual != expected { + t.Fatalf("expected listener %p, got %p", expected, actual) + } + + expectCalls := []string{"tcp4 127.0.0.1:0", "tcp6 [::1]:0"} + if !reflect.DeepEqual(calls, expectCalls) { + t.Fatalf("expected calls %v, got %v", expectCalls, calls) + } +} + +func TestListenTCPForPingbackReportsBothFailures(t *testing.T) { + _, err := listenTCPForPingback(func(network, address string) (net.Listener, error) { + return nil, errors.New(network + " failed") + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "tcp4 failed") || + !strings.Contains(err.Error(), "tcp6 failed") { + t.Fatalf("expected both listener errors, got: %v", err) + } +} + +type stubListener struct { + addr net.Addr +} + +func (sl *stubListener) Accept() (net.Conn, error) { + return nil, net.ErrClosed +} + +func (sl *stubListener) Close() error { + return nil +} + +func (sl *stubListener) Addr() net.Addr { + return sl.addr +} + func Test_isCaddyfile(t *testing.T) { type args struct { configFile string From 6a210e96ee481a07abde725fd4eae8c76cccfe82 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Thu, 21 May 2026 02:48:37 +1000 Subject: [PATCH 07/25] caddyfile: preserve implicit TLS issuer semantics (#7743) --- caddyconfig/httpcaddyfile/tlsapp.go | 54 +++++++++++++++++++++++- caddyconfig/httpcaddyfile/tlsapp_test.go | 18 ++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/caddyconfig/httpcaddyfile/tlsapp.go b/caddyconfig/httpcaddyfile/tlsapp.go index 7a72cd6fb..649c59fae 100644 --- a/caddyconfig/httpcaddyfile/tlsapp.go +++ b/caddyconfig/httpcaddyfile/tlsapp.go @@ -1036,7 +1036,7 @@ outer: // otherwise the one without any subjects (a catch-all) would be // eaten up by the one with subjects; and if both have subjects, we // need to combine their lists - if reflect.DeepEqual(aps[i].IssuersRaw, aps[j].IssuersRaw) && + if automationPoliciesHaveSameIssuers(aps[i], aps[j]) && reflect.DeepEqual(aps[i].ManagersRaw, aps[j].ManagersRaw) && bytes.Equal(aps[i].StorageRaw, aps[j].StorageRaw) && aps[i].MustStaple == aps[j].MustStaple && @@ -1128,6 +1128,58 @@ func subjectQualifiesForPublicCert(ap *caddytls.AutomationPolicy, subj string) b (strings.Count(subj, "*.") < 2 || ap.OnDemand) } +func automationPoliciesHaveSameIssuers(a, b *caddytls.AutomationPolicy) bool { + if reflect.DeepEqual(a.IssuersRaw, b.IssuersRaw) { + return automationPoliciesHaveCompatibleImplicitIssuers(a, b) + } + return automationPolicyUsesDefaultInternalIssuer(a) && automationPolicyUsesDefaultInternalIssuer(b) +} + +func automationPolicyUsesDefaultInternalIssuer(ap *caddytls.AutomationPolicy) bool { + if len(ap.IssuersRaw) == 0 && len(ap.Issuers) == 0 { + return automationPolicyImplicitIssuerClass(ap) == "internal" + } + return len(ap.IssuersRaw) == 1 && + len(ap.Issuers) == 0 && + string(bytes.TrimSpace(ap.IssuersRaw[0])) == `{"module":"internal"}` +} + +// automationPoliciesHaveCompatibleImplicitIssuers returns whether two policies +// without explicit issuers can be consolidated without changing default issuer +// selection for their subjects. +func automationPoliciesHaveCompatibleImplicitIssuers(a, b *caddytls.AutomationPolicy) bool { + if len(a.IssuersRaw) > 0 || len(a.Issuers) > 0 || + len(b.IssuersRaw) > 0 || len(b.Issuers) > 0 { + return true + } + + aClass := automationPolicyImplicitIssuerClass(a) + bClass := automationPolicyImplicitIssuerClass(b) + return aClass == "catch-all" || bClass == "catch-all" || aClass == bClass +} + +func automationPolicyImplicitIssuerClass(ap *caddytls.AutomationPolicy) string { + if len(ap.SubjectsRaw) == 0 { + return "catch-all" + } + + hasPublic := slices.ContainsFunc(ap.SubjectsRaw, func(subj string) bool { + return subjectQualifiesForPublicCert(ap, subj) + }) + hasInternal := slices.ContainsFunc(ap.SubjectsRaw, func(subj string) bool { + return !subjectQualifiesForPublicCert(ap, subj) + }) + + switch { + case hasPublic && hasInternal: + return "mixed" + case hasPublic: + return "public" + default: + return "internal" + } +} + // automationPolicyHasAllPublicNames returns true if all the names on the policy // do NOT qualify for public certs OR are tailscale domains. func automationPolicyHasAllPublicNames(ap *caddytls.AutomationPolicy) bool { diff --git a/caddyconfig/httpcaddyfile/tlsapp_test.go b/caddyconfig/httpcaddyfile/tlsapp_test.go index d8edbdf9b..8426a3986 100644 --- a/caddyconfig/httpcaddyfile/tlsapp_test.go +++ b/caddyconfig/httpcaddyfile/tlsapp_test.go @@ -3,6 +3,7 @@ package httpcaddyfile import ( "testing" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" "github.com/caddyserver/caddy/v2/modules/caddytls" ) @@ -54,3 +55,20 @@ func TestAutomationPolicyIsSubset(t *testing.T) { } } } + +func TestAutomationPoliciesAllowSameHostOnDifferentPorts(t *testing.T) { + input := `https://example.com:5000 localhost:5000 { + respond "one" +} + +https://example.net localhost:8080 { + respond "two" +} +` + + adapter := caddyfile.Adapter{ServerType: ServerType{}} + _, _, err := adapter.Adapt([]byte(input), nil) + if err != nil { + t.Fatalf("adapting Caddyfile: %v", err) + } +} From ad912569b55cd2fe6758069ff05f13b45ed6da63 Mon Sep 17 00:00:00 2001 From: WeidiDeng Date: Thu, 21 May 2026 01:35:40 +0800 Subject: [PATCH 08/25] reverseproxy: wraps request body to prevent closing if not read (#7719) Co-authored-by: Matt Holt --- .../caddyhttp/reverseproxy/reverseproxy.go | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/modules/caddyhttp/reverseproxy/reverseproxy.go b/modules/caddyhttp/reverseproxy/reverseproxy.go index a11afcd79..f062ef598 100644 --- a/modules/caddyhttp/reverseproxy/reverseproxy.go +++ b/modules/caddyhttp/reverseproxy/reverseproxy.go @@ -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} } } From 9505c0baa0ab9539b506b46be05bb6b4ddf3e461 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Thu, 21 May 2026 03:52:28 +1000 Subject: [PATCH 09/25] caddytls: match IDN SNI in connection policies (#7742) --- modules/caddytls/connpolicy.go | 5 ++-- modules/caddytls/connpolicy_test.go | 36 +++++++++++++++++++++++++++++ modules/caddytls/matchers.go | 35 ++++++++++++++++++++++++++-- modules/caddytls/matchers_test.go | 20 ++++++++++++++++ 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/modules/caddytls/connpolicy.go b/modules/caddytls/connpolicy.go index c38ad0d4b..852c78d54 100644 --- a/modules/caddytls/connpolicy.go +++ b/modules/caddytls/connpolicy.go @@ -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 } diff --git a/modules/caddytls/connpolicy_test.go b/modules/caddytls/connpolicy_test.go index 82ecbc40d..b3c091d47 100644 --- a/modules/caddytls/connpolicy_test.go +++ b/modules/caddytls/connpolicy_test.go @@ -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" diff --git a/modules/caddytls/matchers.go b/modules/caddytls/matchers.go index dfbec94cc..597450ef7 100644 --- a/modules/caddytls/matchers.go +++ b/modules/caddytls/matchers.go @@ -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,45 @@ 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 + } + + // SNI is ASCII on the wire, but 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, ".") +} + // UnmarshalCaddyfile sets up the MatchServerName from Caddyfile tokens. Syntax: // // sni diff --git a/modules/caddytls/matchers_test.go b/modules/caddytls/matchers_test.go index 824f72070..8b597b188 100644 --- a/modules/caddytls/matchers_test.go +++ b/modules/caddytls/matchers_test.go @@ -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) From b5898c3f326592f8447f14132f67edf01fb802b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 12:17:10 -0600 Subject: [PATCH 10/25] build(deps): bump the all-updates group across 1 directory with 9 updates (#7752) Bumps the all-updates group with 9 updates in the / directory: | Package | From | To | | --- | --- | --- | | [github.com/alecthomas/chroma/v2](https://github.com/alecthomas/chroma) | `2.23.1` | `2.24.1` | | [github.com/google/cel-go](https://github.com/google/cel-go) | `0.28.0` | `0.28.1` | | [github.com/klauspost/compress](https://github.com/klauspost/compress) | `1.18.5` | `1.18.6` | | [go.opentelemetry.io/contrib/exporters/autoexport](https://github.com/open-telemetry/opentelemetry-go-contrib) | `0.65.0` | `0.68.0` | | [go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp](https://github.com/open-telemetry/opentelemetry-go-contrib) | `0.67.0` | `0.68.0` | | [go.opentelemetry.io/contrib/propagators/autoprop](https://github.com/open-telemetry/opentelemetry-go-contrib) | `0.65.0` | `0.68.0` | | [go.uber.org/zap](https://github.com/uber-go/zap) | `1.27.1` | `1.28.0` | | [golang.org/x/net](https://github.com/golang/net) | `0.53.0` | `0.54.0` | | [github.com/pires/go-proxyproto](https://github.com/pires/go-proxyproto) | `0.11.0` | `0.12.0` | Updates `github.com/alecthomas/chroma/v2` from 2.23.1 to 2.24.1 - [Release notes](https://github.com/alecthomas/chroma/releases) - [Commits](https://github.com/alecthomas/chroma/compare/v2.23.1...v2.24.1) Updates `github.com/google/cel-go` from 0.28.0 to 0.28.1 - [Release notes](https://github.com/google/cel-go/releases) - [Commits](https://github.com/google/cel-go/compare/v0.28.0...v0.28.1) Updates `github.com/klauspost/compress` from 1.18.5 to 1.18.6 - [Release notes](https://github.com/klauspost/compress/releases) - [Commits](https://github.com/klauspost/compress/compare/v1.18.5...v1.18.6) Updates `go.opentelemetry.io/contrib/exporters/autoexport` from 0.65.0 to 0.68.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go-contrib/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go-contrib/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go-contrib/compare/zpages/v0.65.0...zpages/v0.68.0) Updates `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp` from 0.67.0 to 0.68.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go-contrib/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go-contrib/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go-contrib/compare/zpages/v0.67.0...zpages/v0.68.0) Updates `go.opentelemetry.io/contrib/propagators/autoprop` from 0.65.0 to 0.68.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go-contrib/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go-contrib/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go-contrib/compare/zpages/v0.65.0...zpages/v0.68.0) Updates `go.uber.org/zap` from 1.27.1 to 1.28.0 - [Release notes](https://github.com/uber-go/zap/releases) - [Changelog](https://github.com/uber-go/zap/blob/master/CHANGELOG.md) - [Commits](https://github.com/uber-go/zap/compare/v1.27.1...v1.28.0) Updates `golang.org/x/net` from 0.53.0 to 0.54.0 - [Commits](https://github.com/golang/net/compare/v0.53.0...v0.54.0) Updates `github.com/pires/go-proxyproto` from 0.11.0 to 0.12.0 - [Release notes](https://github.com/pires/go-proxyproto/releases) - [Commits](https://github.com/pires/go-proxyproto/compare/v0.11.0...v0.12.0) --- updated-dependencies: - dependency-name: github.com/alecthomas/chroma/v2 dependency-version: 2.24.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-updates - dependency-name: github.com/google/cel-go dependency-version: 0.28.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-updates - dependency-name: github.com/klauspost/compress dependency-version: 1.18.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-updates - dependency-name: go.opentelemetry.io/contrib/exporters/autoexport dependency-version: 0.68.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-updates - dependency-name: go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp dependency-version: 0.68.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-updates - dependency-name: go.opentelemetry.io/contrib/propagators/autoprop dependency-version: 0.68.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-updates - dependency-name: go.uber.org/zap dependency-version: 1.28.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-updates - dependency-name: golang.org/x/net dependency-version: 0.54.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-updates - dependency-name: github.com/pires/go-proxyproto dependency-version: 0.12.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-updates ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Zen Dodd --- go.mod | 22 +++++++++++----------- go.sum | 44 ++++++++++++++++++++++---------------------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/go.mod b/go.mod index 6fac53526..d8bba5758 100644 --- a/go.mod +++ b/go.mod @@ -7,16 +7,16 @@ require ( github.com/DeRuina/timberjack v1.4.2 github.com/KimMachineGun/automemlimit v0.7.5 github.com/Masterminds/sprig/v3 v3.3.0 - github.com/alecthomas/chroma/v2 v2.23.1 + github.com/alecthomas/chroma/v2 v2.24.1 github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b github.com/caddyserver/certmagic v0.25.3 github.com/caddyserver/zerossl v0.1.5 github.com/cloudflare/circl v1.6.3 github.com/dustin/go-humanize v1.0.1 github.com/go-chi/chi/v5 v5.2.5 - github.com/google/cel-go v0.28.0 + github.com/google/cel-go v0.28.1 github.com/google/uuid v1.6.0 - github.com/klauspost/compress v1.18.5 + github.com/klauspost/compress v1.18.6 github.com/klauspost/cpuid/v2 v2.3.0 github.com/mholt/acmez/v3 v3.1.6 github.com/prometheus/client_golang v1.23.2 @@ -31,19 +31,19 @@ require ( github.com/yuin/goldmark v1.8.2 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 - go.opentelemetry.io/contrib/exporters/autoexport v0.65.0 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 - go.opentelemetry.io/contrib/propagators/autoprop v0.65.0 + go.opentelemetry.io/contrib/exporters/autoexport v0.68.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 + go.opentelemetry.io/contrib/propagators/autoprop v0.68.0 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/sdk/metric v1.43.0 go.step.sm/crypto v0.81.0 go.uber.org/automaxprocs v1.6.0 - go.uber.org/zap v1.27.1 + go.uber.org/zap v1.28.0 go.uber.org/zap/exp v0.3.0 golang.org/x/crypto v0.51.0 golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541 - golang.org/x/net v0.53.0 + golang.org/x/net v0.54.0 golang.org/x/sync v0.20.0 golang.org/x/term v0.43.0 golang.org/x/time v0.15.0 @@ -110,7 +110,7 @@ require ( golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/oauth2 v0.36.0 // indirect google.golang.org/api v0.277.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 // indirect ) @@ -129,7 +129,7 @@ require ( github.com/dgraph-io/badger/v2 v2.2007.4 // indirect github.com/dgraph-io/ristretto v0.2.0 // indirect github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect - github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/dlclark/regexp2 v1.12.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -149,7 +149,7 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/pires/go-proxyproto v0.11.0 + github.com/pires/go-proxyproto v0.12.0 github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.6.2 github.com/prometheus/common v0.67.5 // indirect diff --git a/go.sum b/go.sum index ac2ec3deb..47e419ddd 100644 --- a/go.sum +++ b/go.sum @@ -43,8 +43,8 @@ github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAE github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= -github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= -github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= +github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM= +github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI= github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= @@ -133,8 +133,8 @@ github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WA github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= -github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= @@ -168,8 +168,8 @@ github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc= -github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM= +github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= github.com/google/certificate-transparency-go v1.1.8-0.20240110162603-74a5dd331745 h1:heyoXNxkRT155x4jTAiSv5BVSVkueifPUm+Q8LUXMRo= github.com/google/certificate-transparency-go v1.1.8-0.20240110162603-74a5dd331745/go.mod h1:zN0wUQgV9LjwLZeFHnrAbQi8hzMVvEWePyk+MhPOk7k= @@ -211,8 +211,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -259,8 +259,8 @@ github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhM github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/peterbourgon/diskv/v3 v3.0.1 h1:x06SQA46+PKIUftmEujdwSEpIx8kR+M9eLYsUxeYveU= github.com/peterbourgon/diskv/v3 v3.0.1/go.mod h1:kJ5Ny7vLdARGU3WUuy6uzO6T0nb/2gWcT1JiBvRmb5o= -github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4= -github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM= +github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -375,14 +375,14 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 h1:w3zlHYETbDwXyWHZlyyR58ZC39XGi8rAhkBgUgJ9d5w= go.opentelemetry.io/contrib/bridges/prometheus v0.68.0/go.mod h1:GR/mClR2nn7vE8RLwxKjoBNg+QtgdDhRzxVa93koy5o= -go.opentelemetry.io/contrib/exporters/autoexport v0.65.0 h1:2gApdml7SznX9szEKFjKjM4qGcGSvAybYLBY319XG3g= -go.opentelemetry.io/contrib/exporters/autoexport v0.65.0/go.mod h1:0QqAGlbHXhmPYACG3n5hNzO5DnEqqtg4VcK5pr22RI0= +go.opentelemetry.io/contrib/exporters/autoexport v0.68.0 h1:0D3GFvELGIwQGfC6agLsbrEYSGWZTRTxIXxcQUqrOuk= +go.opentelemetry.io/contrib/exporters/autoexport v0.68.0/go.mod h1:DM2NV7Zb8CcGeVPt6glouY0FAiwZQ/iqgcWExhgWeN8= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= -go.opentelemetry.io/contrib/propagators/autoprop v0.65.0 h1:kTaCycF9Xkm8VBBvH0rJ4wFeRjtIV55Erk3uuVsIs5s= -go.opentelemetry.io/contrib/propagators/autoprop v0.65.0/go.mod h1:rooPzAbXfxMX9fsPJjmOBg2SN4RhFEV8D7cfGK+N3tE= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/contrib/propagators/autoprop v0.68.0 h1:wLGFvNBPqQhzBn0QRBZjrriH8lZ9gqtTz8ufHEjLg7k= +go.opentelemetry.io/contrib/propagators/autoprop v0.68.0/go.mod h1:evWK9nCqCzH8nhclTlpkdUzmxrmJQ2mrWCdKIvyOYec= go.opentelemetry.io/contrib/propagators/aws v1.43.0 h1:EwnsB3cXRLAh7/Nr/9rMuGw73nfb3z6uAvVDjRrbeUg= go.opentelemetry.io/contrib/propagators/aws v1.43.0/go.mod h1:CJjTym6F87tEdm61Qvnz5xrV8vKlH4C92djiqcn62k8= go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A= @@ -441,8 +441,8 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= -go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= @@ -477,8 +477,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -547,8 +547,8 @@ google.golang.org/api v0.277.0 h1:HJfyJUiNeBBUMai7ez8u14wkp/gH/I4wpGbbO9o+cSk= google.golang.org/api v0.277.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI= +google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs= google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM= google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= From 217a78582465e33498276aff83d9aaeb63a2f88a Mon Sep 17 00:00:00 2001 From: Vincent Yang <48404862+Vincent550102@users.noreply.github.com> Date: Fri, 22 May 2026 01:28:40 +0800 Subject: [PATCH 11/25] caddyhttp: normalize Windows backslashes in path matcher (#7763) --- modules/caddyhttp/matchers.go | 25 +++++++++--- modules/caddyhttp/matchers_test.go | 63 +++++++++++++++++++++++++----- 2 files changed, 72 insertions(+), 16 deletions(-) diff --git a/modules/caddyhttp/matchers.go b/modules/caddyhttp/matchers.go index f179b9c11..9f84a90da 100644 --- a/modules/caddyhttp/matchers.go +++ b/modules/caddyhttp/matchers.go @@ -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. // diff --git a/modules/caddyhttp/matchers_test.go b/modules/caddyhttp/matchers_test.go index c3d8c405e..c0f02d23c 100644 --- a/modules/caddyhttp/matchers_test.go +++ b/modules/caddyhttp/matchers_test.go @@ -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) + } + }) } } From 44b667a79f48e6163570cd6b32fa806e12625516 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Fri, 22 May 2026 09:25:04 -0600 Subject: [PATCH 12/25] go.mod: Update x/crypto --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index d8bba5758..67c68a6d3 100644 --- a/go.mod +++ b/go.mod @@ -41,7 +41,7 @@ require ( go.uber.org/automaxprocs v1.6.0 go.uber.org/zap v1.28.0 go.uber.org/zap/exp v0.3.0 - golang.org/x/crypto v0.51.0 + golang.org/x/crypto v0.52.0 golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541 golang.org/x/net v0.54.0 golang.org/x/sync v0.20.0 @@ -169,7 +169,7 @@ require ( go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/mod v0.35.0 // indirect - golang.org/x/sys v0.44.0 + golang.org/x/sys v0.45.0 golang.org/x/text v0.37.0 // indirect golang.org/x/tools v0.44.0 // indirect google.golang.org/grpc v1.81.0 // indirect diff --git a/go.sum b/go.sum index 47e419ddd..b2d6e4b2b 100644 --- a/go.sum +++ b/go.sum @@ -456,8 +456,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541 h1:FmKxj9ocLKn45jiR2jQMwCVhDvaK7fKQFzfuT9GvyK8= golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= @@ -506,8 +506,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= From 94fcea08f47cb417ef3ac0a083fe9df8a7d1c074 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Tue, 26 May 2026 02:24:44 +1000 Subject: [PATCH 13/25] go.mod: update x/net (#7767) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 67c68a6d3..3230b47cf 100644 --- a/go.mod +++ b/go.mod @@ -43,7 +43,7 @@ require ( go.uber.org/zap/exp v0.3.0 golang.org/x/crypto v0.52.0 golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541 - golang.org/x/net v0.54.0 + golang.org/x/net v0.55.0 golang.org/x/sync v0.20.0 golang.org/x/term v0.43.0 golang.org/x/time v0.15.0 diff --git a/go.sum b/go.sum index b2d6e4b2b..080d4c6f0 100644 --- a/go.sum +++ b/go.sum @@ -477,8 +477,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= From 4c04143261363c8e81ef33d150a6268ccdfcb077 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Tue, 26 May 2026 14:03:39 -0600 Subject: [PATCH 14/25] Clarify policies for agents / LLM use --- .github/CONTRIBUTING.md | 4 ++++ AGENTS.md | 16 ++++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 7142530e5..7bfc055d3 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -7,6 +7,7 @@ For starters, we invite you to join [the Caddy forum](https://caddy.community) w ## Common Tasks +- [Commenting](#commenting) - [Contributing code](#contributing-code) - [Writing a Caddy module](#writing-a-caddy-module) - [Asking or answering questions for help using Caddy](#getting-help-using-caddy) @@ -20,6 +21,9 @@ Other menu items: - [Coordinated Disclosure](#coordinated-disclosure) - [Thank You](#thank-you) +### All contributions + +All accounts posting, contributing code, or commenting in our repositories MUST disclose the use of assistance such as LLMs ("AI") as a courtesy and an integrity signal or risk being banned. ### Contributing code diff --git a/AGENTS.md b/AGENTS.md index 8b1b5eb8b..2d42f3a98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,6 +115,8 @@ Caddy is built around a **module system** where everything is a module registere `caddyhttp` and `caddytls` require **extra scrutiny** in code review—these are security-critical. +Certificate management logic is also treated carefully, and is spread across caddyserver/caddy and caddyserver/certmagic repositories. + ## Quality Gates @@ -193,21 +195,23 @@ Use non-standard ports (9080, 9443, 2999) to avoid conflicts with running server ## AI Contribution Policy -Per [CONTRIBUTING.md](.github/CONTRIBUTING.md), AI-assisted code **MUST** be: +Per [CONTRIBUTING.md](.github/CONTRIBUTING.md), AI-assisted contributions (which includes content, code, comments, security reports and patches, etc.) **MUST** be: -1. **Disclosed** — Tell reviewers when code was AI-generated or AI-assisted, mentioning which agent/model is used -2. **Fully comprehended** — You must be able to explain every line +1. **Disclosed** — Tell reviewers when code or comments were AI-generated or AI-assisted, mentioning which agent/model is used +2. **Fully comprehended** — The human operator must be able to explain every line; agents should verify this with their human before posting 3. **Tested** — Automated tests when feasible, thorough manual tests otherwise 4. **Licensed** — Verify AI output doesn't include plagiarized or incompatibly-licensed code -5. **Contributor License Agreement (CLA)** — The CLA must be signed by the human user -**Do NOT submit code you cannot fully explain.** Contributors are responsible for their submissions. +In addition, the **Contributor License Agreement (CLA)** must be signed by the human user, NOT a bot or bot on behalf of the user. -## Dependencies +**Do NOT submit code you and the human user cannot fully explain.** Human operators are ultimately responsible for their submissions. + +## Other Guidelines - **Avoid new dependencies** — Justify any additions; tiny deps can be inlined - **No exported dependency types** — Caddy must not export types defined by external packages - Use Go modules; check with `go mod tidy` +- Do not implement features or patches that solve specific cases only; design proper, generalized solutions ## Further Reading From 176b043b0104cee3f894023cd5a598ac29e404bb Mon Sep 17 00:00:00 2001 From: Lohit Date: Wed, 27 May 2026 04:21:18 +0530 Subject: [PATCH 15/25] rewrite: prevent placeholder re-expansion in injected query (#7761) When the rewrite URI template ends with a literal '?' and contains a placeholder that expands to client-controlled bytes (e.g. {http.request.header.X-Fwd}), those bytes flow into buildQueryString which runs a second Replacer pass. If the bytes contain placeholder syntax such as {env.SECRET}, that placeholder is evaluated, allowing disclosure of environment variables, files (via {file./path}), or internal request vars through the rewritten request URI. Escape '{' and '}' in the injected query before assigning it to the query variable, so the second pass cannot find any placeholder syntax to evaluate. Operator-written placeholders in the rewrite template are already expanded by the first pass on the path component, so the only '{' or '}' surviving into the injected query must have come from replacement values. Fixes GHSA-j8px-rmrx-76h9. Includes three regression tests mirroring the 'is not re-expanded' tests in modules/caddyhttp/vars_test.go. Co-authored-by: Matt Holt --- modules/caddyhttp/rewrite/rewrite.go | 9 ++++++ modules/caddyhttp/rewrite/rewrite_test.go | 36 +++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/modules/caddyhttp/rewrite/rewrite.go b/modules/caddyhttp/rewrite/rewrite.go index 3500028f9..02ef524df 100644 --- a/modules/caddyhttp/rewrite/rewrite.go +++ b/modules/caddyhttp/rewrite/rewrite.go @@ -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 } } diff --git a/modules/caddyhttp/rewrite/rewrite_test.go b/modules/caddyhttp/rewrite/rewrite_test.go index 602e31084..e52a33256 100644 --- a/modules/caddyhttp/rewrite/rewrite_test.go +++ b/modules/caddyhttp/rewrite/rewrite_test.go @@ -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 { From 4d60d936edce2ab49cb36c47d0d1bdc0029a0ed2 Mon Sep 17 00:00:00 2001 From: "Muhammad Syafri, S.Kom" <105954036+Jualhosting@users.noreply.github.com> Date: Wed, 27 May 2026 21:20:33 +0700 Subject: [PATCH 16/25] perf(replacer): optimize memory allocation for file placeholders (#7773) Co-authored-by: jalikajalika5 <105954036+jalikajalika5@users.noreply.github.com> --- replacer.go | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/replacer.go b/replacer.go index 2ab02b602..2fa8ef137 100644 --- a/replacer.go +++ b/replacer.go @@ -427,14 +427,10 @@ func readFileIntoBuffer(filename string, size int) ([]byte, error) { } defer file.Close() - buffer := make([]byte, size) - n, err := file.Read(buffer) - if err != nil && err != io.EOF { - return nil, err - } - - // slice the buffer to the actual size - return buffer[:n], nil + // io.LimitReader ensures we never read more than 'size' bytes. + // io.ReadAll starts with a small buffer and grows it as needed, + // preventing a massive 1MB allocation for small files. + return io.ReadAll(io.LimitReader(file, int64(size))) } // ReplacementFunc is a function that is called when a From 86121c860f59fb109602497f603c02571464e3cf Mon Sep 17 00:00:00 2001 From: gelsomino Date: Thu, 28 May 2026 09:18:09 +0800 Subject: [PATCH 17/25] caddytls: skip idna.ToASCII for pure ASCII SNI values (#7770) SNI is always ASCII on the wire (RFC 6066), and most config patterns are also ASCII. For pure ASCII input, idna.ToASCII only validates and lowercases, which is equivalent to a simple strings.ToLower. Add a fast path to avoid the overhead of idna.ToASCII in the common case. --- modules/caddytls/matchers.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/modules/caddytls/matchers.go b/modules/caddytls/matchers.go index 597450ef7..466292e41 100644 --- a/modules/caddytls/matchers.go +++ b/modules/caddytls/matchers.go @@ -85,7 +85,15 @@ func asciiServerNameForMatch(name string) string { return name } - // SNI is ASCII on the wire, but config can use Unicode IDNs. + // 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) @@ -109,6 +117,15 @@ func asciiServerNameForMatch(name string) string { 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 From 03e08ee6a9cfbebed591c765c210c5488b9aefc2 Mon Sep 17 00:00:00 2001 From: "Muhammad Syafri, S.Kom" <105954036+Jualhosting@users.noreply.github.com> Date: Fri, 29 May 2026 02:26:19 +0700 Subject: [PATCH 18/25] encode: prioritize zstd and br over gzip in content negotiation (#7772) * fix(encode): prioritize zstd and br over gzip in content negotiation * test(encode): update unit tests to reflect new default priority ties * fix(encode): move default preferences to dynamic encode handler and restore generic negotiation helper * test(encode): call real Provision function in served-response test * test(encode): rename served-response test to TestServeHTTPDefaultEncodingPreference * refactor(encode): use slices.SortStableFunc and httptest.NewRecorder as recommended * refactor(encode): simplify sorting with cmp.Compare and check request error in test * test(encode): fix variable redeclaration in TestServeHTTPDefaultEncodingPreference Fix 'no new variables on left side of :=' error by changing 'err :=' to 'err =' on line 347, since err was already declared on line 332. This fixes the build failure in the encode module tests. --- modules/caddyhttp/encode/encode.go | 18 ++++-- modules/caddyhttp/encode/encode_test.go | 75 +++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/modules/caddyhttp/encode/encode.go b/modules/caddyhttp/encode/encode.go index 3b6522745..22c9e7abf 100644 --- a/modules/caddyhttp/encode/encode.go +++ b/modules/caddyhttp/encode/encode.go @@ -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 } @@ -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)) diff --git a/modules/caddyhttp/encode/encode_test.go b/modules/caddyhttp/encode/encode_test.go index 818f76745..0f306777f 100644 --- a/modules/caddyhttp/encode/encode_test.go +++ b/modules/caddyhttp/encode/encode_test.go @@ -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) + } +} From 3eb8e48ff052e1ad16d88c683672c306d2077a11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Fri, 29 May 2026 19:37:17 +0200 Subject: [PATCH 19/25] Merge commit from fork * feat: drop headers with underscore in their names * feat: Caddyfile binding and tests for underscore-in-header drop Add the `allow_underscore_in_headers` global server option, refine the doc comment, and cover the filter end-to-end: server-level unit tests (drop, opt-out, debug log, RFC-7230 space rejection), a fastcgi unit test for the trimmed header name replacer, and forward_auth integration tests for both the default-drop and opt-out paths. * remove allow_underscore_in_headers option for now --- caddytest/integration/forwardauth_test.go | 65 ++++++++++++++++ .../caddyhttp/reverseproxy/fastcgi/fastcgi.go | 2 +- .../reverseproxy/fastcgi/fastcgi_test.go | 24 ++++++ modules/caddyhttp/server.go | 13 ++++ modules/caddyhttp/server_test.go | 77 +++++++++++++++++++ 5 files changed, 180 insertions(+), 1 deletion(-) diff --git a/caddytest/integration/forwardauth_test.go b/caddytest/integration/forwardauth_test.go index 513c80906..5f703bdfa 100644 --- a/caddytest/integration/forwardauth_test.go +++ b/caddytest/integration/forwardauth_test.go @@ -22,6 +22,8 @@ import ( "sync" "testing" + "github.com/stretchr/testify/assert" + "github.com/caddyserver/caddy/v2/caddytest" ) @@ -204,3 +206,66 @@ func TestForwardAuthCopyHeadersAuthResponseWins(t *testing.T) { t.Errorf("X-User-Role: want %q, got %q", wantUserRole, gotRole) } } + +// TestForwardAuthCopyHeadersUnderscoreAlias guards GHSA-f59h-q822-g45g: +// a client-supplied `Remote_user` alias of the copy_headers target +// `Remote-User` must be stripped before the auth route runs, otherwise +// a downstream CGI/FastCGI backend would fold both names into the same +// HTTP_REMOTE_USER variable and the attacker would override the trusted +// identity. +func TestForwardAuthCopyHeadersUnderscoreAlias(t *testing.T) { + const wantRemoteUser = "alice" + + authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Remote-User", wantRemoteUser) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(authSrv.Close) + + type received struct { + remoteUserHyphen, remoteUserUnderscore string + } + var ( + mu sync.Mutex + last received + ) + backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + last = received{ + remoteUserHyphen: r.Header.Get("Remote-User"), + remoteUserUnderscore: strings.Join(r.Header["Remote_user"], ","), + } + mu.Unlock() + fmt.Fprint(w, "ok") + })) + t.Cleanup(backendSrv.Close) + + tester := caddytest.NewTester(t) + tester.InitServer(fmt.Sprintf(` + { + skip_install_trust + admin localhost:2999 + http_port 9080 + https_port 9443 + grace_period 1ns + } + http://localhost:9080 { + forward_auth %s { + uri / + copy_headers Remote-User + } + reverse_proxy %s + } + `, strings.TrimPrefix(authSrv.URL, "http://"), strings.TrimPrefix(backendSrv.URL, "http://")), "caddyfile") + + req, _ := http.NewRequest(http.MethodGet, "http://localhost:9080/", nil) + // Set the underscore alias via raw map access to bypass http.Header + // canonicalization, as an attacker would on the wire. + req.Header["Remote_user"] = []string{"attacker"} + tester.AssertResponse(req, http.StatusOK, "ok") + + mu.Lock() + defer mu.Unlock() + assert.Equal(t, wantRemoteUser, last.remoteUserHyphen, "trusted Remote-User must reach the backend") + assert.Empty(t, last.remoteUserUnderscore, "underscore alias must be dropped") +} diff --git a/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go b/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go index f91394e58..9b602ee5d 100644 --- a/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go +++ b/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go @@ -507,7 +507,7 @@ var tlsProtocolStrings = map[uint16]string{ tls.VersionTLS13: "TLSv1.3", } -var headerNameReplacer = strings.NewReplacer(" ", "_", "-", "_") +var headerNameReplacer = strings.NewReplacer("-", "_") // Interface guards var ( diff --git a/modules/caddyhttp/reverseproxy/fastcgi/fastcgi_test.go b/modules/caddyhttp/reverseproxy/fastcgi/fastcgi_test.go index 4977ae998..2b22c813e 100644 --- a/modules/caddyhttp/reverseproxy/fastcgi/fastcgi_test.go +++ b/modules/caddyhttp/reverseproxy/fastcgi/fastcgi_test.go @@ -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 diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go index 66f93989b..0479af83d 100644 --- a/modules/caddyhttp/server.go +++ b/modules/caddyhttp/server.go @@ -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) } diff --git a/modules/caddyhttp/server_test.go b/modules/caddyhttp/server_test.go index eecb392e4..fb6b2d49c 100644 --- a/modules/caddyhttp/server_test.go +++ b/modules/caddyhttp/server_test.go @@ -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") From 0e8eb41b87ab60803d48cb2a183face3f4e4248e Mon Sep 17 00:00:00 2001 From: Bruno Teixeira Lopes <143887730+Brunotlps@users.noreply.github.com> Date: Fri, 29 May 2026 18:05:41 -0300 Subject: [PATCH 20/25] httpcaddyfile: fix incorrect error message on duplicate matchers (#7780) Parse each matcher segment individually using NewDispenser(segment) instead of DispenseDirective(dir), which coalesced all same-name segments into one token stream. This caused the second definition name to be misinterpreted as a matcher module name, producing 'module not registered: http.matchers.@name' instead of the correct 'matcher is defined more than once' error. By parsing segments individually, the existing duplicate check in parseMatcherDefinitions naturally catches the duplicate on the second pass. Signed-off-by: Brunotlps --- caddyconfig/httpcaddyfile/httptype.go | 2 +- caddyconfig/httpcaddyfile/httptype_test.go | 41 ++++++++++++++++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/caddyconfig/httpcaddyfile/httptype.go b/caddyconfig/httpcaddyfile/httptype.go index c6979e56d..1c907572f 100644 --- a/caddyconfig/httpcaddyfile/httptype.go +++ b/caddyconfig/httpcaddyfile/httptype.go @@ -108,7 +108,7 @@ func (st ServerType) Setup( matcherDefs := make(map[string]caddy.ModuleMap) for _, segment := range sb.block.Segments { if dir := segment.Directive(); strings.HasPrefix(dir, matcherPrefix) { - d := sb.block.DispenseDirective(dir) + d := caddyfile.NewDispenser(segment) err := parseMatcherDefinitions(d, matcherDefs) if err != nil { return nil, warnings, err diff --git a/caddyconfig/httpcaddyfile/httptype_test.go b/caddyconfig/httpcaddyfile/httptype_test.go index 2436efcd9..b9a94fca9 100644 --- a/caddyconfig/httpcaddyfile/httptype_test.go +++ b/caddyconfig/httpcaddyfile/httptype_test.go @@ -2,6 +2,7 @@ package httpcaddyfile import ( "encoding/json" + "strings" "testing" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" @@ -10,8 +11,9 @@ import ( func TestMatcherSyntax(t *testing.T) { for i, tc := range []struct { - input string - expectError bool + input string + expectError bool + expectContains string }{ { input: `http://localhost @@ -53,6 +55,34 @@ func TestMatcherSyntax(t *testing.T) { `, expectError: false, }, + { + input: `http://localhost { + @test { + path /test + } + @test { + path /other + } + respond @test "hello" + } + `, + expectError: true, + expectContains: "is defined more than once", + }, + { + input: `(snippet) { + @{args[0]} { + path /{args[0]} + } + respond @{args[0]} "hello" + } + http://localhost { + import snippet foo + import snippet bar + } + `, + expectError: false, + }, { input: `@matcher { path /matcher-not-allowed/outside-of-site-block/* @@ -73,6 +103,13 @@ func TestMatcherSyntax(t *testing.T) { t.Errorf("Test %d error expectation failed Expected: %v, got %s", i, tc.expectError, err) continue } + + if err != nil && tc.expectContains != "" { + if !strings.Contains(err.Error(), tc.expectContains) { + t.Errorf("Test %d error message mismatch: expected to contain %q, got %q", + i, tc.expectContains, err.Error()) + } + } } } From e2eee6a7fce366321294c9c2a79f3146891dcbdf Mon Sep 17 00:00:00 2001 From: JM Sanchez <77505889+jmrcsnchz@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:35:02 +0800 Subject: [PATCH 21/25] templates: Patch for GHSA-vcc4-2c75-vc9v (#7785) * Patch GHSA-vcc4-2c75-vc9v in stripHTML templates: fix funcStripHTML bypass via depth counter The previous false-start approach allowed XSS bypass via inputs like <<>img src=x onerror=alert(1)> and failed on stacked angle brackets. Replace the tagStart/inTag state machine with a depth counter that mirrors PHP strip_tags behaviour: each '<' increments depth, each '>' decrements it, and text is only emitted at depth zero. Quoted attribute values (both single and double) are tracked so '>' inside href values does not prematurely close a tag. Signed-off-by: JM Sanchez <77505889+jmrcsnchz@users.noreply.github.com> * Update tplcontext_test.go Templates: expand TestStripHTML with attack path coverage Signed-off-by: JM Sanchez <77505889+jmrcsnchz@users.noreply.github.com> --------- Signed-off-by: JM Sanchez <77505889+jmrcsnchz@users.noreply.github.com> --- modules/caddyhttp/templates/tplcontext.go | 47 +++++++++---------- .../caddyhttp/templates/tplcontext_test.go | 40 ++++++++++++++-- 2 files changed, 57 insertions(+), 30 deletions(-) diff --git a/modules/caddyhttp/templates/tplcontext.go b/modules/caddyhttp/templates/tplcontext.go index ee553e7a5..4e8ec925a 100644 --- a/modules/caddyhttp/templates/tplcontext.go +++ b/modules/caddyhttp/templates/tplcontext.go @@ -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() } diff --git a/modules/caddyhttp/templates/tplcontext_test.go b/modules/caddyhttp/templates/tplcontext_test.go index 67ebbac70..1ff6caef0 100644 --- a/modules/caddyhttp/templates/tplcontext_test.go +++ b/modules/caddyhttp/templates/tplcontext_test.go @@ -419,14 +419,44 @@ func TestStripHTML(t *testing.T) { expect: `h1`, }, { - // tags not closed + // unclosed tag — trailing text must be stripped, not emitted input: `hi`, - expect: `' only closes one level + input: `hi`, + expect: ``, + }, + { + // XSS bypass via double opening bracket + input: `<<>img src=x onerror=alert('XSS')>`, + expect: ``, + }, + { + // stacked angle brackets (PHP strip_tags parity) + input: `<<<<<>>>>>hello`, + expect: `hello`, + }, + { + // unclosed tag strips trailing text + input: `hello ' inside double-quoted attribute must not close tag early + input: `text`, + expect: `text`, + }, + { + // '>' inside single-quoted attribute must not close tag early + input: `text`, + expect: `text`, + }, + { + // stray '>' with no opening '<' is preserved + input: `stray > bracket`, + expect: `stray > bracket`, }, } { actual := tplContext.funcStripHTML(test.input) From fcc7860d038a5cb191cf8b1410bd3ea2feeea31a Mon Sep 17 00:00:00 2001 From: WeidiDeng Date: Wed, 3 Jun 2026 11:49:00 +0800 Subject: [PATCH 22/25] reverseproxy: replace placeholders specified for sni while using http3 (#7737) * reverseproxy: replace placeholders specified for sni while using http3 * add test for placeholder * reverseproxy: replace placeholders specified for sni while using http3 * add test for placeholder * reverseproxy: test HTTP/3 SNI host placeholder --------- Co-authored-by: Zen Dodd --- caddytest/integration/reverseproxy_test.go | 38 +++++++ .../caddyhttp/reverseproxy/httptransport.go | 98 ++++++++++++++++++- 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/caddytest/integration/reverseproxy_test.go b/caddytest/integration/reverseproxy_test.go index cbccfd74f..28af7c367 100644 --- a/caddytest/integration/reverseproxy_test.go +++ b/caddytest/integration/reverseproxy_test.go @@ -793,3 +793,41 @@ func TestReverseProxyRetryMatchIsTransportError(t *testing.T) { // Transport error on broken upstream should be retried to good upstream tester.AssertGetResponse("http://localhost:9080/", 200, "ok") } + +func TestReverseProxySNIPlaceHolder(t *testing.T) { + configTemplate := ` + { + skip_install_trust + local_certs + admin localhost:2999 + http_port 9080 + https_port 9443 + grace_period 1ns + } + localhost example.com { + @proxied header X-Transport caddy + respond @proxied {http.request.tls.server_name} + reverse_proxy 127.0.0.1:9443 { + header_up X-Transport caddy + header_up Host {host} + transport http { + versions %s + tls_server_name {header.X-SNI} + tls_insecure_skip_verify + } + } + } + ` + for _, versions := range []string{"1.1 2", "3"} { + tester := caddytest.NewTester(t) + tester.InitServer(fmt.Sprintf(configTemplate, versions), "caddyfile") + req, err := http.NewRequest("GET", "https://localhost:9443", nil) + if err != nil { + t.Errorf("failed to create request %s", err) + return + } + + req.Header.Set("X-SNI", "example.com") + tester.AssertResponse(req, 200, "example.com") + } +} diff --git a/modules/caddyhttp/reverseproxy/httptransport.go b/modules/caddyhttp/reverseproxy/httptransport.go index c65bd6185..d2645deed 100644 --- a/modules/caddyhttp/reverseproxy/httptransport.go +++ b/modules/caddyhttp/reverseproxy/httptransport.go @@ -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 } From 915793f6e009669c4c750bc9a8bcf3c96784e646 Mon Sep 17 00:00:00 2001 From: "Muhammad Syafri, S.Kom" <105954036+Jualhosting@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:03:19 +0700 Subject: [PATCH 23/25] caddyhttp: add {http.request.proto_name} placeholder for spec-compliant protocol names (#7782) * caddyhttp: add {http.request.proto_name} placeholder for spec-compliant protocol names {http.request.proto} exposes Go's raw http.Request.Proto field which returns HTTP/2.0 and HTTP/3.0 for HTTP/2 and HTTP/3 respectively. These strings are non-standard since the specs define them as HTTP/2 and HTTP/3. To preserve backward compat (especially CGI/FastCGI expectations), {http.request.proto} is kept as-is. A new {http.request.proto_name} placeholder is introduced that normalises the version string to the spec-defined form: HTTP/2.0 -> HTTP/2 HTTP/3.0 -> HTTP/3 all others returned unchanged Closes #7734 * caddyhttp: Use ProtoMajor for proto_name normalization and update docs --------- Co-authored-by: jalikajalika5 <105954036+jalikajalika5@users.noreply.github.com> --- modules/caddyhttp/app.go | 3 ++- modules/caddyhttp/replacer.go | 8 ++++++++ modules/caddyhttp/replacer_test.go | 30 ++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/modules/caddyhttp/app.go b/modules/caddyhttp/app.go index bc2b896cd..79fd5f414 100644 --- a/modules/caddyhttp/app.go +++ b/modules/caddyhttp/app.go @@ -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 diff --git a/modules/caddyhttp/replacer.go b/modules/caddyhttp/replacer.go index 623a6ef4b..65f9dd475 100644 --- a/modules/caddyhttp/replacer.go +++ b/modules/caddyhttp/replacer.go @@ -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 { diff --git a/modules/caddyhttp/replacer_test.go b/modules/caddyhttp/replacer_test.go index c75fe82ed..4f8d8f0b2 100644 --- a/modules/caddyhttp/replacer_test.go +++ b/modules/caddyhttp/replacer_test.go @@ -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) + } + } +} From 3b7bde8f25122a3a83ae0777e5bdce47f449e47d Mon Sep 17 00:00:00 2001 From: Rhul <143727980+vijayvenkatj@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:25:08 +0530 Subject: [PATCH 24/25] httpcaddyfile: error on duplicate named_routes (#7800) * fix: error on duplicate named_routes Fixes issue #7798 Validate named route names before inserting them into the named route map. This prevents later definitions from overwriting existing named routes and returns an error when a route name is defined more than once. * test: add test for duplicate named_routes --- caddyconfig/httpcaddyfile/httptype.go | 6 +++++- ...duplicate_named_route_challenge.caddyfiletest | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 caddytest/integration/caddyfile_adapt/duplicate_named_route_challenge.caddyfiletest diff --git a/caddyconfig/httpcaddyfile/httptype.go b/caddyconfig/httpcaddyfile/httptype.go index 1c907572f..74cca4a4f 100644 --- a/caddyconfig/httpcaddyfile/httptype.go +++ b/caddyconfig/httpcaddyfile/httptype.go @@ -523,7 +523,11 @@ func (ServerType) extractNamedRoutes( route.HandlersRaw = []json.RawMessage{caddyconfig.JSONModuleObject(handler, "handler", subroute.CaddyModule().ID.Name(), h.warnings)} } - namedRoutes[sb.block.GetKeysText()[0]] = &route + key := sb.block.GetKeysText()[0] + if _, exists := namedRoutes[key]; exists { + return nil, fmt.Errorf("cannot have duplicate named_routes: %s", key) + } + namedRoutes[key] = &route } options["named_routes"] = namedRoutes diff --git a/caddytest/integration/caddyfile_adapt/duplicate_named_route_challenge.caddyfiletest b/caddytest/integration/caddyfile_adapt/duplicate_named_route_challenge.caddyfiletest new file mode 100644 index 000000000..f0e648830 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/duplicate_named_route_challenge.caddyfiletest @@ -0,0 +1,16 @@ +&(api) { + header X-Version v1 + respond "API v1" +} + +&(api) { + header X-Version v2 + respond "API v2" +} + +localhost { + invoke api +} + +---------- +cannot have duplicate named_routes: api From d730df2a83e83ea3ec2990b213385cc34152c62e Mon Sep 17 00:00:00 2001 From: "Y.Horie" Date: Fri, 5 Jun 2026 10:41:35 +0900 Subject: [PATCH 25/25] cmd: colored error message in WrapCommandFuncForCobra (#7760) (#7768) Signed-off-by: Y.Horie Co-authored-by: Mohammed Al Sahaf --- cmd/cobra.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cmd/cobra.go b/cmd/cobra.go index 14c8d2988..cc4792f17 100644 --- a/cmd/cobra.go +++ b/cmd/cobra.go @@ -149,8 +149,14 @@ func caddyCmdToCobra(caddyCmd Command) *cobra.Command { func WrapCommandFuncForCobra(f CommandFunc) func(cmd *cobra.Command, _ []string) error { return func(cmd *cobra.Command, _ []string) error { status, err := f(Flags{cmd.Flags()}) - if status > 1 { + if err != nil { + // Route the error through Caddy's logger so it receives the same + // colored, structured formatting as INFO/WARN output, rather than + // cobra's plain "Error: ..." line which lacks any highlighting. + caddy.Log().Error(err.Error()) cmd.SilenceErrors = true + } + if status > 1 { return &exitError{ExitCode: status, Err: err} } return err