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 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 << 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) + } +} diff --git a/caddytest/caddytest.go b/caddytest/caddytest.go index 26fa0533e..b2b0766e6 100644 --- a/caddytest/caddytest.go +++ b/caddytest/caddytest.go @@ -518,7 +518,7 @@ func (tc *Tester) AssertResponseCode(req *http.Request, expectedStatusCode int) return resp } -// AssertResponse request a URI and assert the status code and the body contains a string +// AssertResponse requests a URI and asserts the status code and body. func (tc *Tester) AssertResponse(req *http.Request, expectedStatusCode int, expectedBody string) (*http.Response, string) { tc.t.Helper() @@ -541,7 +541,7 @@ func (tc *Tester) AssertResponse(req *http.Request, expectedStatusCode int, expe // Verb specific test functions -// AssertGetResponse GET a URI and expect a statusCode and body text +// AssertGetResponse requests a URI with GET and expects a status code and body text. func (tc *Tester) AssertGetResponse(requestURI string, expectedStatusCode int, expectedBody string) (*http.Response, string) { tc.t.Helper() @@ -553,7 +553,7 @@ func (tc *Tester) AssertGetResponse(requestURI string, expectedStatusCode int, e return tc.AssertResponse(req, expectedStatusCode, expectedBody) } -// AssertDeleteResponse request a URI and expect a statusCode and body text +// AssertDeleteResponse requests a URI with DELETE and expects a status code and body text. func (tc *Tester) AssertDeleteResponse(requestURI string, expectedStatusCode int, expectedBody string) (*http.Response, string) { tc.t.Helper() @@ -565,7 +565,7 @@ func (tc *Tester) AssertDeleteResponse(requestURI string, expectedStatusCode int return tc.AssertResponse(req, expectedStatusCode, expectedBody) } -// AssertPostResponseBody POST to a URI and assert the response code and body +// AssertPostResponseBody requests a URI with POST and asserts the response code and body. func (tc *Tester) AssertPostResponseBody(requestURI string, requestHeaders []string, requestBody *bytes.Buffer, expectedStatusCode int, expectedBody string) (*http.Response, string) { tc.t.Helper() @@ -580,7 +580,7 @@ func (tc *Tester) AssertPostResponseBody(requestURI string, requestHeaders []str return tc.AssertResponse(req, expectedStatusCode, expectedBody) } -// AssertPutResponseBody PUT to a URI and assert the response code and body +// AssertPutResponseBody requests a URI with PUT and asserts the response code and body. func (tc *Tester) AssertPutResponseBody(requestURI string, requestHeaders []string, requestBody *bytes.Buffer, expectedStatusCode int, expectedBody string) (*http.Response, string) { tc.t.Helper() @@ -595,7 +595,7 @@ func (tc *Tester) AssertPutResponseBody(requestURI string, requestHeaders []stri return tc.AssertResponse(req, expectedStatusCode, expectedBody) } -// AssertPatchResponseBody PATCH to a URI and assert the response code and body +// AssertPatchResponseBody requests a URI with PATCH and asserts the response code and body. func (tc *Tester) AssertPatchResponseBody(requestURI string, requestHeaders []string, requestBody *bytes.Buffer, expectedStatusCode int, expectedBody string) (*http.Response, string) { tc.t.Helper() 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 diff --git a/caddytest/integration/caddyfile_adapt/heredoc_invalid_marker.caddyfiletest b/caddytest/integration/caddyfile_adapt/heredoc_invalid_marker.caddyfiletest index 99c4e4a39..3d12d5110 100644 --- a/caddytest/integration/caddyfile_adapt/heredoc_invalid_marker.caddyfiletest +++ b/caddytest/integration/caddyfile_adapt/heredoc_invalid_marker.caddyfiletest @@ -6,4 +6,4 @@ handle { END! } ---------- -heredoc marker on line #4 must contain only alpha-numeric characters, dashes and underscores; got 'END!' \ No newline at end of file +heredoc marker on line #4 must contain only alphanumeric characters, dashes and underscores; got 'END!' 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/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/caddytest/integration/stream_test.go b/caddytest/integration/stream_test.go index 57231a527..0f9604366 100644 --- a/caddytest/integration/stream_test.go +++ b/caddytest/integration/stream_test.go @@ -159,7 +159,7 @@ func testH2ToH2CStreamServeH2C(t *testing.T) *http.Server { } // We only accept HTTP/2! if r.ProtoMajor != 2 { - t.Error("Not a HTTP/2 request, rejected!") + t.Error("Not an HTTP/2 request, rejected!") w.WriteHeader(http.StatusInternalServerError) return } 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 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/commands.go b/cmd/commands.go index 417720f06..9473c23ad 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -566,7 +566,7 @@ argument of --directory. If the directory does not exist, it will be created. // following format: // // - lowercase -// - alphanumeric and hyphen characters only +// - ASCII lowercase letters, digits and hyphens only // - cannot start or end with a hyphen // - hyphen cannot be adjacent to another hyphen // 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 diff --git a/go.mod b/go.mod index 6fac53526..3230b47cf 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 v0.52.0 golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541 - golang.org/x/net v0.53.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 @@ -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 @@ -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 ac2ec3deb..080d4c6f0 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= @@ -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= @@ -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.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= @@ -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= @@ -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= 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/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 +} diff --git a/modules/caddyhttp/celmatcher.go b/modules/caddyhttp/celmatcher.go index 3038c8926..4c79bf2d7 100644 --- a/modules/caddyhttp/celmatcher.go +++ b/modules/caddyhttp/celmatcher.go @@ -108,7 +108,7 @@ func (m *MatchExpression) UnmarshalJSON(data []byte) error { return json.Unmarshal(data, &m.Expr) } // otherwise, it's a full object, so unmarshal it, - // using an temp map to avoid infinite recursion + // using a temp map to avoid infinite recursion var tmpJson map[string]any err := json.Unmarshal(data, &tmpJson) *m = MatchExpression{ @@ -118,7 +118,7 @@ func (m *MatchExpression) UnmarshalJSON(data []byte) error { return err } -// Provision sets ups m. +// Provision sets up m. func (m *MatchExpression) Provision(ctx caddy.Context) error { m.log = ctx.Logger() @@ -319,7 +319,7 @@ func (cr celHTTPRequest) Value() any { return cr } var pkixNameCELType = cel.ObjectType("pkix.Name", traits.ReceiverType) -// celPkixName wraps an pkix.Name with +// celPkixName wraps a pkix.Name with // methods to satisfy the ref.Val interface. type celPkixName struct{ *pkix.Name } diff --git a/modules/caddyhttp/celmatcher_test.go b/modules/caddyhttp/celmatcher_test.go index 1bd8e527e..459d50733 100644 --- a/modules/caddyhttp/celmatcher_test.go +++ b/modules/caddyhttp/celmatcher_test.go @@ -79,7 +79,7 @@ eqp31wM9il1n+guTNyxJd+FzVAH+hCZE5K+tCgVDdVFUlDEHHbS/wqb2PSIoouLV wantResult: true, }, { - name: "header matches an placeholder replaced during the header matcher (MatchHeader)", + name: "header matches a placeholder replaced during the header matcher (MatchHeader)", expression: &MatchExpression{ Expr: `header({'Field': '\{http.request.uri.path}'})`, }, diff --git a/modules/caddyhttp/encode/encode.go b/modules/caddyhttp/encode/encode.go index ac995c37b..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 } @@ -162,7 +170,7 @@ func (enc *Encode) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyh // to comply with RFC 9110 section 8.8.3(.3), we modify the Etag when encoding // by appending a hyphen and the encoder name; the problem is, the client will - // send back that Etag in a If-None-Match header, but upstream handlers that set + // send back that Etag in an If-None-Match header, but upstream handlers that set // the Etag in the first place don't know that we appended to their Etag! so here // we have to strip our addition so the upstream handlers can still honor client // caches without knowing about our changes... @@ -369,7 +377,7 @@ const sniffLen = 512 // ReadFrom will try to use sendfile to copy from the reader to the response writer. // It's only used if the response writer implements io.ReaderFrom and the data can't be compressed. -// It's based on stdlin http1.1 response writer implementation. +// It's based on the standard library HTTP/1.1 response writer implementation. // https://github.com/golang/go/blob/f4e3ec3dbe3b8e04a058d266adf8e048bab563f2/src/net/http/server.go#L586 func (rw *responseWriter) ReadFrom(r io.Reader) (int64, error) { rf, ok := rw.ResponseWriter.(io.ReaderFrom) @@ -538,11 +546,11 @@ func AcceptedEncodings(r *http.Request, preferredOrder []string) []string { } // sort preferences by descending q-factor first, then by preferOrder - sort.Slice(prefs, func(i, j int) bool { - if math.Abs(prefs[i].q-prefs[j].q) < 0.00001 { - return prefs[i].preferOrder > prefs[j].preferOrder + slices.SortStableFunc(prefs, func(a, b encodingPreference) int { + if math.Abs(a.q-b.q) < 0.00001 { + return cmp.Compare(b.preferOrder, a.preferOrder) } - return prefs[i].q > prefs[j].q + return cmp.Compare(b.q, a.q) }) prefEncNames := make([]string, len(prefs)) 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) + } +} diff --git a/modules/caddyhttp/fileserver/staticfiles.go b/modules/caddyhttp/fileserver/staticfiles.go index 507321ad6..3ca7452e1 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" @@ -123,7 +124,7 @@ type FileServer struct { // put "hidden" in the list. To hide only ./hidden, put "./hidden" in the list. // // When possible, all paths are resolved to their absolute form before - // comparisons are made. For maximum clarity and explictness, use complete, + // comparisons are made. For maximum clarity and explicitness, use complete, // absolute paths; or, for greater portability, use relative paths instead. // // Note that hide comparisons are case-sensitive. On case-insensitive @@ -579,7 +580,17 @@ func (fsrv *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request, next c // that errors generated by ServeContent are written immediately // to the response, so we cannot handle them (but errors there // are rare) - http.ServeContent(w, r, info.Name(), info.ModTime(), file.(io.ReadSeeker)) + // + // There are a few file modification times that aren't useful + // to send in Last-Modified headers, but the golang http library only + // omits Last-Modified headers for the Unix epoch time. So, force + // the modification time to the epoch time if it's not useful. + zeroTime := time.Time{} + modTime := info.ModTime() + if !usefulModTime(modTime) { + modTime = zeroTime + } + http.ServeContent(w, r, info.Name(), modTime, file.(io.ReadSeeker)) return nil } @@ -726,6 +737,14 @@ func (fsrv *FileServer) notFound(w http.ResponseWriter, r *http.Request, next ca return caddyhttp.Error(http.StatusNotFound, nil) } +// Indicates whether a file's modification time is useful for validator +// generation purposes (i.e. inclusion in ETag and Last-Modified headers). +// See issues #5548 and #7730. +func usefulModTime(modTime time.Time) bool { + mtimeunix := modTime.Unix() + return mtimeunix != 0 && mtimeunix != 1 +} + // calculateEtag computes an entity tag using a strong validator // without consuming the contents of the file. It requires the // file info contain the correct size and modification time. @@ -743,8 +762,8 @@ func (fsrv *FileServer) notFound(w http.ResponseWriter, r *http.Request, next ca // which we consider precise enough to qualify as a strong validator. func calculateEtag(d os.FileInfo) string { mtime := d.ModTime() - if mtimeUnix := mtime.Unix(); mtimeUnix == 0 || mtimeUnix == 1 { - return "" // not useful anyway; see issue #5548 + if !usefulModTime(mtime) { + return "" } var sb strings.Builder sb.WriteRune('"') 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 diff --git a/modules/caddyhttp/http2listener.go b/modules/caddyhttp/http2listener.go index ad5991790..7c40e50c2 100644 --- a/modules/caddyhttp/http2listener.go +++ b/modules/caddyhttp/http2listener.go @@ -15,7 +15,7 @@ type connectionStater interface { // http2Listener wraps the listener to solve the following problems: // 1. prevent genuine h2c connections from succeeding if h2c is not enabled -// and the connection doesn't implment connectionStater or the resulting NegotiatedProtocol +// and the connection doesn't implement connectionStater or the resulting NegotiatedProtocol // isn't http2. // This does allow a connection to pass as tls enabled even if it's not, listener wrappers // can do this. diff --git a/modules/caddyhttp/httpredirectlistener.go b/modules/caddyhttp/httpredirectlistener.go index ce9ac0308..5d4ccb9ee 100644 --- a/modules/caddyhttp/httpredirectlistener.go +++ b/modules/caddyhttp/httpredirectlistener.go @@ -101,7 +101,7 @@ type httpRedirectConn struct { // Read tries to peek at the first few bytes of the request, and if we get // an error reading the headers, and that error was due to the bytes looking -// like an HTTP request, then we perform a HTTP->HTTPS redirect on the same +// like an HTTP request, then we perform an HTTP->HTTPS redirect on the same // port as the original connection. func (c *httpRedirectConn) Read(p []byte) (int, error) { if c.once { 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) + } + }) } } 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) + } + } +} 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/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/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/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 } 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()) + } + }) + } +} 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} } } 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/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 { 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") 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) 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/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..466292e41 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,62 @@ func (m MatchServerName) Match(hello *tls.ClientHelloInfo) bool { repl = caddy.NewReplacer() } + serverName := asciiServerNameForMatch(hello.ServerName) for _, name := range m { - rs := repl.ReplaceAll(name, "") - if certmagic.MatchWildcard(hello.ServerName, rs) { + rs := asciiServerNameForMatch(repl.ReplaceAll(name, "")) + if certmagic.MatchWildcard(serverName, rs) { return true } } return false } +func asciiServerNameForMatch(name string) string { + if name == "" { + return name + } + + // Fast path: if the name is pure ASCII, skip idna.ToASCII. + // SNI values on the wire are always ASCII (RFC 6066), and most + // config patterns are also ASCII. For pure ASCII input, idna.ToASCII + // only validates and lowercases, which is equivalent to our fallback. + if isPureASCII(name) { + return strings.ToLower(name) + } + + // Config can use Unicode IDNs. + ascii, err := idna.ToASCII(name) + if err == nil { + return strings.ToLower(ascii) + } + + if !strings.Contains(name, "*") { + return strings.ToLower(name) + } + + labels := strings.Split(name, ".") + for i, label := range labels { + if label == "" || label == "*" { + continue + } + ascii, err := idna.ToASCII(label) + if err != nil { + return strings.ToLower(name) + } + labels[i] = strings.ToLower(ascii) + } + return strings.Join(labels, ".") +} + +func isPureASCII(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] >= 0x80 { + return false + } + } + return true +} + // UnmarshalCaddyfile sets up the MatchServerName from Caddyfile tokens. Syntax: // // sni 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) 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) } 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 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