Merge branch 'master' into fix-websocket-header-normalization

This commit is contained in:
Zen Dodd 2026-06-13 22:26:07 +10:00 committed by GitHub
commit 63d8d4ae35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 6445 additions and 95 deletions

2
.github/SECURITY.md vendored
View file

@ -49,7 +49,7 @@ We'll need enough information to verify the bug and make a patch. To speed thing
Please DO NOT use containers, VMs, cloud instances or services, or any other complex infrastructure in your steps. Always prefer `curl -v` instead of web browsers.
We consider publicly-registered domain names to be public information. This necessary in order to maintain the integrity of certificate transparency, public DNS, and other public trust systems. Do not redact domain names from your reports. The actual content of your domain name affects Caddy's behavior, so we need the exact domain name(s) to reproduce with, or your report will be ignored.
We consider publicly-registered domain names to be public information. This is necessary in order to maintain the integrity of certificate transparency, public DNS, and other public trust systems. Do not redact domain names from your reports. The actual content of your domain name affects Caddy's behavior, so we need the exact domain name(s) to reproduce with, or your report will be ignored.
It will speed things up if you suggest a working patch, such as a code diff, and explain why and how it works. Reports that are not actionable, do not contain enough information, are too pushy/demanding, or are not able to convince us that it is a viable and practical attack on the web server itself may be deferred to a later time or possibly ignored, depending on available resources. Priority will be given to credible, responsible reports that are constructive, specific, and actionable. (We get a lot of invalid reports.) Thank you for understanding.

View file

@ -523,7 +523,11 @@ func (ServerType) extractNamedRoutes(
route.HandlersRaw = []json.RawMessage{caddyconfig.JSONModuleObject(handler, "handler", subroute.CaddyModule().ID.Name(), h.warnings)}
}
namedRoutes[sb.block.GetKeysText()[0]] = &route
key := sb.block.GetKeysText()[0]
if _, exists := namedRoutes[key]; exists {
return nil, fmt.Errorf("cannot have duplicate named_routes: %s", key)
}
namedRoutes[key] = &route
}
options["named_routes"] = namedRoutes

View file

@ -36,27 +36,28 @@ type serverOptions struct {
ListenerAddress string
// These will all map 1:1 to the caddyhttp.Server struct
Name string
ListenerWrappersRaw []json.RawMessage
PacketConnWrappersRaw []json.RawMessage
ReadTimeout caddy.Duration
ReadHeaderTimeout caddy.Duration
WriteTimeout caddy.Duration
IdleTimeout caddy.Duration
KeepAliveInterval caddy.Duration
KeepAliveIdle caddy.Duration
KeepAliveCount int
MaxHeaderBytes int
EnableFullDuplex bool
Protocols []string
StrictSNIHost *bool
TrustedProxiesRaw json.RawMessage
TrustedProxiesStrict int
TrustedProxiesUnix bool
ClientIPHeaders []string
ShouldLogCredentials bool
Metrics *caddyhttp.Metrics
Trace bool // TODO: EXPERIMENTAL
Name string
ListenerWrappersRaw []json.RawMessage
PacketConnWrappersRaw []json.RawMessage
ReadTimeout caddy.Duration
ReadHeaderTimeout caddy.Duration
WriteTimeout caddy.Duration
IdleTimeout caddy.Duration
KeepAliveInterval caddy.Duration
KeepAliveIdle caddy.Duration
KeepAliveCount int
MaxHeaderBytes int
EnableFullDuplex bool
ExpectedUnderscoreHeaders []string
Protocols []string
StrictSNIHost *bool
TrustedProxiesRaw json.RawMessage
TrustedProxiesStrict int
TrustedProxiesUnix bool
ClientIPHeaders []string
ShouldLogCredentials bool
Metrics *caddyhttp.Metrics
Trace bool // TODO: EXPERIMENTAL
// If set, overrides whether QUIC listeners allow 0-RTT (early data).
// If nil, the default behavior is used (currently allowed).
Allow0RTT *bool
@ -218,6 +219,13 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) {
}
serverOpts.EnableFullDuplex = true
case "expected_underscore_headers":
args := d.RemainingArgs()
if len(args) == 0 {
return nil, d.ArgErr()
}
serverOpts.ExpectedUnderscoreHeaders = args
case "log_credentials":
if d.NextArg() {
return nil, d.ArgErr()
@ -380,6 +388,7 @@ func applyServerOptions(
server.KeepAliveCount = opts.KeepAliveCount
server.MaxHeaderBytes = opts.MaxHeaderBytes
server.EnableFullDuplex = opts.EnableFullDuplex
server.ExpectedUnderscoreHeaders = opts.ExpectedUnderscoreHeaders
server.Protocols = opts.Protocols
server.StrictSNIHost = opts.StrictSNIHost
server.TrustedProxiesRaw = opts.TrustedProxiesRaw

View file

@ -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

View file

@ -0,0 +1,12 @@
localhost:9080 {
forward_auth :9091 {
uri /first
uri /second
copy_headers Remote-User
}
respond "ok" 200
}
----------
parsing caddyfile tokens for 'forward_auth': cannot re-declare uri: /second

View file

@ -38,3 +38,72 @@ func TestIntercept(t *testing.T) {
tester.AssertGetResponse("http://localhost:9080/no-intercept", 200, "I'm not a teapot")
}
func TestInterceptReplaceStatusWithMatcher(t *testing.T) {
tester := caddytest.NewTester(t)
tester.InitServer(`{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
grace_period 1ns
}
localhost:9080 {
respond /error "boom" 500
intercept {
@err status 5xx
replace_status @err 200
}
}
`, "caddyfile")
tester.AssertGetResponse("http://localhost:9080/error", 200, "boom")
}
func TestInterceptReplaceStatusWithoutMatcher(t *testing.T) {
tester := caddytest.NewTester(t)
tester.InitServer(`{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
grace_period 1ns
}
localhost:9080 {
respond /forbidden "denied" 403
intercept {
replace_status 200
}
}
`, "caddyfile")
tester.AssertGetResponse("http://localhost:9080/forbidden", 200, "denied")
}
func TestInterceptReplaceStatusNotMatched(t *testing.T) {
tester := caddytest.NewTester(t)
tester.InitServer(`{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
grace_period 1ns
}
localhost:9080 {
respond /ok "all good" 200
intercept {
@err status 5xx
replace_status @err 503
}
}
`, "caddyfile")
// 200 does not match @err (5xx), so status should pass through unchanged
tester.AssertGetResponse("http://localhost:9080/ok", 200, "all good")
}

View file

@ -199,7 +199,7 @@ func TestReverseProxyWithPlaceholderDialAddress(t *testing.T) {
],
"handle": [
{
"handler": "reverse_proxy",
"upstreams": [
{
@ -293,7 +293,7 @@ func TestReverseProxyWithPlaceholderTCPDialAddress(t *testing.T) {
],
"handle": [
{
"handler": "reverse_proxy",
"upstreams": [
{
@ -374,7 +374,7 @@ func TestReverseProxyHealthCheck(t *testing.T) {
http://localhost:9080 {
reverse_proxy {
to localhost:2020
health_uri /health
health_port 2021
health_interval 10ms
@ -495,7 +495,7 @@ func TestReverseProxyHealthCheckUnixSocket(t *testing.T) {
http://localhost:9080 {
reverse_proxy {
to unix/%s
health_uri /health
health_port 2021
health_interval 2s
@ -553,7 +553,7 @@ func TestReverseProxyHealthCheckUnixSocketWithoutPort(t *testing.T) {
http://localhost:9080 {
reverse_proxy {
to unix/%s
health_uri /health
health_interval 2s
health_timeout 5s
@ -793,3 +793,103 @@ 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")
}
}
func TestWeightedRoundRobinSelectionValidation(t *testing.T) {
configTemplate := `
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [":18080"],
"routes": [
{
"handle": [
{
"handler": "reverse_proxy",
"load_balancing": {
"selection_policy": {
"policy": "weighted_round_robin",
"weights": %s
}
},
"upstreams": [
{"dial": "localhost:18081"},
{"dial": "localhost:18082"}
]
}
]
}
]
}
}
}
}
}`
tests := []struct {
name string
weights string
errMsg string
}{
{
name: "negative weight",
weights: "[-1, 2]",
errMsg: "weight of an upstream cannot be negative",
},
{
name: "zero total weight",
weights: "[0, 0]",
errMsg: "requires at least one upstream with a positive weight",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caddytest.AssertLoadError(
t,
fmt.Sprintf(configTemplate, tc.weights),
"json",
tc.errMsg,
)
})
}
}

View file

@ -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

View file

@ -70,7 +70,8 @@ func init() {
// `{http.request.orig_uri.query}` | The request's original query string (without `?`)
// `{http.request.orig_uri.prefixed_query}` | The request's original query string with a `?` prefix, if non-empty
// `{http.request.port}` | The port part of the request's Host header
// `{http.request.proto}` | The protocol of the request
// `{http.request.proto}` | The raw protocol of the request as returned by Go (e.g., HTTP/2.0 or HTTP/3.0)
// `{http.request.proto_name}` | The spec-defined protocol of the request (e.g., HTTP/2 or HTTP/3)
// `{http.request.local.host}` | The host (IP) part of the local address the connection arrived on
// `{http.request.local.port}` | The port part of the local address the connection arrived on
// `{http.request.local}` | The local address the connection arrived on
@ -256,6 +257,12 @@ func (app *App) Provision(ctx caddy.Context) error {
}
}
// limit max header bytes to a more reasonable default than 1MB from Go std lib
// (see https://github.com/php/frankenphp/issues/2459#issuecomment-4655612909)
if srv.MaxHeaderBytes <= 0 {
srv.MaxHeaderBytes = 16 * 1024
}
// if not explicitly configured by the user, disallow TLS
// client auth bypass (domain fronting) which could
// otherwise be exploited by sending an unprotected SNI
@ -286,6 +293,11 @@ func (app *App) Provision(ctx caddy.Context) error {
srv.ClientIPHeaders = []string{"X-Forwarded-For"}
}
// precompute underscore header allowlist rules
if err := srv.provisionUnderscoreHeaders(); err != nil {
return fmt.Errorf("server %s: %v", srvName, err)
}
// process each listener address
for i := range srv.Listen {
lnOut, err := repl.ReplaceOrErr(srv.Listen[i], true, true)

View file

@ -251,13 +251,16 @@ type responseWriter struct {
statusCode int
wroteHeader bool
isConnect bool
disabled bool // disable encoding (for error responses)
disabled bool // disable encoding for this response
}
// WriteHeader stores the status to write when the time comes
// to actually write the header.
func (rw *responseWriter) WriteHeader(status int) {
rw.statusCode = status
if status == http.StatusPartialContent {
rw.disabled = true // partial representations must not be dynamically re-encoded
}
// See #5849 and RFC 9110 section 15.4.5 (https://www.rfc-editor.org/rfc/rfc9110.html#section-15.4.5) - 304
// Not Modified must have certain headers set as if it was a 200 response, and according to the issue
@ -444,8 +447,7 @@ func (rw *responseWriter) Unwrap() http.ResponseWriter {
// init should be called before we write a response, if rw.buf has contents.
func (rw *responseWriter) init() {
// Don't initialize encoder for error responses
// This prevents response corruption when handle_errors is used
// Don't initialize encoder for responses that must not be encoded.
if rw.disabled {
return
}

View file

@ -0,0 +1,169 @@
package encode_test
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/caddyserver/caddy/v2/modules/caddyhttp/encode"
)
const (
benchmarkParallelism = 4
handlerBenchWarmupIterations = 5
)
// BenchmarkStandardEncodingPayloads measures raw encoder throughput (NewEncoder → Write → Close)
// across the standard HTML/JSON/JS/CSS payloads and gzip/zstd compression levels.
// Each subtest runs with 4 parallel workers (SetParallelism).
func BenchmarkStandardEncodingPayloads(b *testing.B) {
forEachBenchmarkCase(b, func(b *testing.B, corpus benchmarkCorpus, encCase encoderCase) {
benchmarkEncode(b, corpus.data, encCase.encoding)
})
}
// BenchmarkEncodeHandlerCorpus measures the full encode middleware path (ServeHTTP,
// responseWriter, writer pools) using the same payload and level grid.
func BenchmarkEncodeHandlerCorpus(b *testing.B) {
forEachBenchmarkCase(b, func(b *testing.B, corpus benchmarkCorpus, encCase encoderCase) {
enc := newEncodeHandler(b, encCase, 1)
benchmarkEncodeHandler(b, enc, encCase, corpus)
})
}
func forEachBenchmarkCase(b *testing.B, fn func(b *testing.B, corpus benchmarkCorpus, encCase encoderCase)) {
for _, corpus := range benchmarkCorpora(b) {
for _, encCase := range benchmarkEncoderCases(b) {
b.Run(benchmarkSubtestName(corpus.name, encCase), func(b *testing.B) {
fn(b, corpus, encCase)
})
}
}
}
func benchmarkSubtestName(corpus string, encCase encoderCase) string {
return fmt.Sprintf("payload-%s/encoder-%s/compress-level-%s",
corpus, encCase.encoder, encCase.level)
}
func benchmarkEncode(b *testing.B, payload []byte, encoding encode.Encoding) {
b.Helper()
b.ReportAllocs()
b.SetBytes(int64(len(payload)))
b.SetParallelism(benchmarkParallelism)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
encoder := encoding.NewEncoder()
var dst bytes.Buffer
for pb.Next() {
dst.Reset()
encoder.Reset(&dst)
if _, err := encoder.Write(payload); err != nil {
b.Fatalf("Write() error = %v", err)
}
if err := encoder.Close(); err != nil {
b.Fatalf("Close() error = %v", err)
}
}
})
}
func benchmarkEncodeHandler(b *testing.B, enc *encode.Encode, encCase encoderCase, corpus benchmarkCorpus) {
b.Helper()
b.ReportAllocs()
b.SetBytes(int64(len(corpus.data)))
b.SetParallelism(benchmarkParallelism)
next := corpusHandler(corpus)
w := newBenchmarkResponseWriter()
r := newHandlerBenchRequest(encCase)
warmupEncodeHandler(enc, w, r, next)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
w := newBenchmarkResponseWriter()
r := newHandlerBenchRequest(encCase)
for pb.Next() {
w.reset()
if err := enc.ServeHTTP(w, r, next); err != nil {
b.Fatalf("ServeHTTP() error = %v", err)
}
}
})
}
func newHandlerBenchRequest(encCase encoderCase) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Accept-Encoding", encCase.encoding.AcceptEncoding())
return r
}
func warmupEncodeHandler(enc *encode.Encode, w *benchmarkResponseWriter, r *http.Request, next caddyhttp.Handler) {
for range handlerBenchWarmupIterations {
w.reset()
if err := enc.ServeHTTP(w, r, next); err != nil {
panic("warmup ServeHTTP: " + err.Error())
}
}
}
// benchmarkResponseWriter is a resettable http.ResponseWriter for handler benchmarks.
// httptest.ResponseRecorder cannot be safely reused because it keeps unexported state.
type benchmarkResponseWriter struct {
header http.Header
code int
body bytes.Buffer
wroteHeader bool
}
func newBenchmarkResponseWriter() *benchmarkResponseWriter {
return &benchmarkResponseWriter{
header: make(http.Header),
}
}
func (w *benchmarkResponseWriter) reset() {
w.code = 0
w.wroteHeader = false
w.body.Reset()
for k := range w.header {
delete(w.header, k)
}
}
func (w *benchmarkResponseWriter) Header() http.Header {
return w.header
}
func (w *benchmarkResponseWriter) Write(p []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.body.Write(p)
}
func (w *benchmarkResponseWriter) WriteHeader(statusCode int) {
if w.wroteHeader {
return
}
w.code = statusCode
w.wroteHeader = true
}
func (w *benchmarkResponseWriter) Flush() {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
}
func corpusHandler(corpus benchmarkCorpus) caddyhttp.Handler {
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", corpus.contentType)
_, err := w.Write(corpus.data)
return err
})
}

View file

@ -0,0 +1,400 @@
package encode_test
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/caddyserver/caddy/v2/modules/caddyhttp/encode"
)
const conformanceContentType = "text/plain"
// TestStandardEncoderContract verifies Reset, Flush, Close, and Reset-after-Close
// reuse for each encoder using the same HTML/JSON/JS/CSS payloads as the benchmark suite.
func TestStandardEncoderContract(t *testing.T) {
for _, encCase := range standardEncoderCases(t) {
t.Run(encCase.name, func(t *testing.T) {
for _, corpus := range benchmarkCorpora(t) {
t.Run(corpus.name, func(t *testing.T) {
encoder := encCase.encoding.NewEncoder()
original := corpus.data
encodeAndVerifyRoundTrip(t, encCase, encoder, original)
// Simulate writer-pool reuse: Close → Reset(nil) → Reset(writer).
encoder.Reset(nil)
encodeAndVerifyRoundTrip(t, encCase, encoder, original)
})
}
})
}
}
// TestEncodeCorpusResponse verifies encoded-response semantics (Content-Encoding, Vary,
// ETag suffix, header stripping) for each benchmark corpus and encoder.
func TestEncodeCorpusResponse(t *testing.T) {
for _, encCase := range standardEncoderCases(t) {
t.Run(encCase.name, func(t *testing.T) {
for _, corpus := range benchmarkCorpora(t) {
t.Run(corpus.name, func(t *testing.T) {
enc := newEncodeHandler(t, encCase, 1)
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Accept-Encoding", encCase.encoding.AcceptEncoding())
w := httptest.NewRecorder()
next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", corpus.contentType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(corpus.data)))
w.Header().Set("Accept-Ranges", "bytes")
w.Header().Set("Etag", `"response"`)
_, err := w.Write(corpus.data)
return err
})
if err := enc.ServeHTTP(w, r, next); err != nil {
t.Fatalf("ServeHTTP() error = %v", err)
}
checkEncodedCorpusResponse(t, w, encCase, corpus)
})
}
})
}
}
type encodeScenario struct {
name string
method string
minLength int
reqHeaders func(encoderCase) http.Header
checkRequest func(*testing.T, *http.Request)
next func(encoderCase) caddyhttp.Handler
checkResponse func(*testing.T, *httptest.ResponseRecorder, encoderCase)
}
var encodeScenarios = []encodeScenario{
{
name: "minimum length prevents encoding",
method: http.MethodGet,
minLength: 1024,
next: func(encoderCase) caddyhttp.Handler {
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", conformanceContentType)
_, err := w.Write([]byte("short"))
return err
})
},
checkResponse: checkMinLengthPreventsEncoding,
},
{
name: "not modified adds vary without encoding",
method: http.MethodGet,
minLength: 1,
next: func(encoderCase) caddyhttp.Handler {
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(http.StatusNotModified)
return nil
})
},
checkResponse: checkNotModifiedVary,
},
{
name: "head response headers can be encoded without body",
method: http.MethodHead,
minLength: 1,
next: func(encoderCase) caddyhttp.Handler {
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", conformanceContentType)
w.Header().Set("Content-Length", "128")
return nil
})
},
checkResponse: checkHeadEncodedHeaders,
},
{
name: "range response bypasses encoding",
method: http.MethodGet,
minLength: 1,
reqHeaders: func(encoderCase) http.Header {
return http.Header{"Range": {"bytes=0-15"}}
},
next: func(encoderCase) caddyhttp.Handler {
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", conformanceContentType)
w.Header().Set("Content-Range", "bytes 0-15/128")
w.Header().Set("Accept-Ranges", "bytes")
w.WriteHeader(http.StatusPartialContent)
_, err := w.Write([]byte("0123456789abcdef"))
return err
})
},
checkResponse: checkRangeResponseBypassesEncoding,
},
{
name: "websocket handshake bypasses encoding",
method: http.MethodGet,
minLength: 1,
reqHeaders: func(encoderCase) http.Header {
return http.Header{
"Connection": {"Upgrade"},
"Sec-WebSocket-Key": {"dGhlIHNhbXBsZSBub25jZQ=="},
"Upgrade": {"websocket"},
}
},
next: func(encoderCase) caddyhttp.Handler {
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(http.StatusSwitchingProtocols)
return nil
})
},
checkResponse: checkWebSocketBypass,
},
{
name: "strips encoded etag suffix before next handler",
method: http.MethodGet,
minLength: 1,
reqHeaders: func(encCase encoderCase) http.Header {
return http.Header{
"If-None-Match": {fmt.Sprintf(`"response-%s"`, encCase.encoding.AcceptEncoding())},
}
},
checkRequest: func(t *testing.T, r *http.Request) {
if got := r.Header.Get("If-None-Match"); got != `"response"` {
t.Fatalf("If-None-Match = %q, want %q", got, `"response"`)
}
},
next: func(encoderCase) caddyhttp.Handler {
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(http.StatusNotModified)
return nil
})
},
checkResponse: checkStripsEncodedETagSuffix,
},
{
name: "request cache-control no-transform prevents encoding",
method: http.MethodGet,
minLength: 1,
reqHeaders: func(encoderCase) http.Header {
return http.Header{"Cache-Control": {"no-cache, no-transform"}}
},
next: func(encoderCase) caddyhttp.Handler {
return conformanceLargeBodyHandler(conformanceContentType)
},
checkResponse: checkBypassesEncoding,
},
{
name: "response cache-control no-transform prevents encoding",
method: http.MethodGet,
minLength: 1,
next: func(encoderCase) caddyhttp.Handler {
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", conformanceContentType)
w.Header().Set("Cache-Control", "no-cache, no-transform")
_, err := w.Write(conformanceLargeBody())
return err
})
},
checkResponse: checkBypassesEncoding,
},
{
name: "content type matcher rejection prevents encoding",
method: http.MethodGet,
minLength: 1,
next: func(encoderCase) caddyhttp.Handler {
return conformanceLargeBodyHandler("image/png")
},
checkResponse: checkBypassesEncoding,
},
}
// TestEncodeResponseSemantics verifies HTTP edge cases (304, HEAD, range, WebSocket,
// minimum_length, ETag request rewriting, no-transform, matcher rejection) independent
// of the benchmark corpora.
func TestEncodeResponseSemantics(t *testing.T) {
for _, encCase := range standardEncoderCases(t) {
t.Run(encCase.name, func(t *testing.T) {
for _, sc := range encodeScenarios {
t.Run(sc.name, func(t *testing.T) {
runEncodeScenario(t, encCase, sc)
})
}
})
}
}
func runEncodeScenario(t *testing.T, encCase encoderCase, sc encodeScenario) {
t.Helper()
enc := newEncodeHandler(t, encCase, sc.minLength)
r := httptest.NewRequest(sc.method, "/", nil)
r.Header.Set("Accept-Encoding", encCase.encoding.AcceptEncoding())
if sc.reqHeaders != nil {
for name, values := range sc.reqHeaders(encCase) {
r.Header.Del(name)
for _, value := range values {
r.Header.Add(name, value)
}
}
}
w := httptest.NewRecorder()
var rw http.ResponseWriter = w
if sc.method == http.MethodHead {
// httptest.ResponseRecorder still stores body writes on HEAD; discard them
// so Close() path matches real clients that must not receive a body.
rw = noBodyResponseWriter{ResponseRecorder: w}
}
next := sc.next(encCase)
if sc.checkRequest != nil {
inner := next
next = caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
sc.checkRequest(t, r)
return inner.ServeHTTP(w, r)
})
}
if err := enc.ServeHTTP(rw, r, next); err != nil {
t.Fatalf("%s: ServeHTTP() error = %v", sc.name, err)
}
sc.checkResponse(t, w, encCase)
}
// noBodyResponseWriter discards Write data while still allowing the encode
// middleware to observe writes for Content-Length / min-length decisions.
type noBodyResponseWriter struct {
*httptest.ResponseRecorder
}
func (w noBodyResponseWriter) Write(p []byte) (int, error) {
return len(p), nil
}
func checkEncodedCorpusResponse(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase, corpus benchmarkCorpus) {
t.Helper()
encName := encCase.encoding.AcceptEncoding()
if got := w.Header().Get("Content-Encoding"); got != encName {
t.Fatalf("Content-Encoding = %q, want %q", got, encName)
}
if !encode.HasVaryValue(w.Header(), "Accept-Encoding") {
t.Fatalf("Vary = %q, want Accept-Encoding", w.Header().Values("Vary"))
}
if got := w.Header().Get("Content-Length"); got != "" {
t.Fatalf("Content-Length = %q, want empty", got)
}
if got := w.Header().Get("Accept-Ranges"); got != "" {
t.Fatalf("Accept-Ranges = %q, want empty", got)
}
wantETag := fmt.Sprintf(`"response-%s"`, encName)
if got := w.Header().Get("Etag"); got != wantETag {
t.Fatalf("Etag = %q, want %q", got, wantETag)
}
assertDecompresses(t, encCase, w.Body.Bytes(), corpus.data)
}
func checkMinLengthPreventsEncoding(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
t.Helper()
if got := w.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty", got)
}
if got := w.Body.String(); got != "short" {
t.Fatalf("body = %q, want short", got)
}
}
func checkNotModifiedVary(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
t.Helper()
if got := w.Code; got != http.StatusNotModified {
t.Fatalf("status = %d, want %d", got, http.StatusNotModified)
}
if got := w.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty", got)
}
if !encode.HasVaryValue(w.Header(), "Accept-Encoding") {
t.Fatalf("Vary = %q, want Accept-Encoding", w.Header().Values("Vary"))
}
if got := w.Body.Len(); got != 0 {
t.Fatalf("body length = %d, want 0", got)
}
}
func checkHeadEncodedHeaders(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
t.Helper()
if got := w.Header().Get("Content-Encoding"); got != encCase.encoding.AcceptEncoding() {
t.Fatalf("Content-Encoding = %q, want %q", got, encCase.encoding.AcceptEncoding())
}
if got := w.Body.Len(); got != 0 {
t.Fatalf("body length = %d, want 0", got)
}
}
func checkRangeResponseBypassesEncoding(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
t.Helper()
if got := w.Code; got != http.StatusPartialContent {
t.Fatalf("status = %d, want %d", got, http.StatusPartialContent)
}
if got := w.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty", got)
}
if got := w.Header().Get("Content-Range"); got != "bytes 0-15/128" {
t.Fatalf("Content-Range = %q, want %q", got, "bytes 0-15/128")
}
if got := w.Header().Get("Accept-Ranges"); got != "bytes" {
t.Fatalf("Accept-Ranges = %q, want bytes", got)
}
if got := w.Body.String(); got != "0123456789abcdef" {
t.Fatalf("body = %q, want %q", got, "0123456789abcdef")
}
}
func checkWebSocketBypass(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
t.Helper()
if got := w.Code; got != http.StatusSwitchingProtocols {
t.Fatalf("status = %d, want %d", got, http.StatusSwitchingProtocols)
}
if got := w.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty", got)
}
}
func checkStripsEncodedETagSuffix(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
t.Helper()
if got := w.Code; got != http.StatusNotModified {
t.Fatalf("status = %d, want %d", got, http.StatusNotModified)
}
if !encode.HasVaryValue(w.Header(), "Accept-Encoding") {
t.Fatalf("Vary = %q, want Accept-Encoding", w.Header().Values("Vary"))
}
}
func conformanceLargeBodyHandler(contentType string) caddyhttp.Handler {
body := conformanceLargeBody()
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", contentType)
_, err := w.Write(body)
return err
})
}
func checkBypassesEncoding(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
t.Helper()
want := conformanceLargeBody()
if got := w.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty", got)
}
if !bytes.Equal(w.Body.Bytes(), want) {
t.Fatalf("body len = %d, want len = %d", w.Body.Len(), len(want))
}
}

View file

@ -0,0 +1,224 @@
// Package encode_test provides the standard encode benchmark and conformance suite
// for Caddy's gzip and zstd encoder modules.
//
// Run encoder-level benchmarks (direct NewEncoder calls):
//
// go test -bench=BenchmarkStandardEncodingPayloads -benchmem ./modules/caddyhttp/encode/
//
// Run middleware-level benchmarks (Encode.ServeHTTP, writer pools, responseWriter):
//
// go test -bench=BenchmarkEncodeHandlerCorpus -benchmem ./modules/caddyhttp/encode/
//
// Benchmark subtest names:
//
// payload-{html|json|js|css}/encoder-{gzip|zstd}/compress-level-{N|fastest|...}
//
// Each subtest uses 4 parallel workers (benchmarkParallelism in encode_bench_test.go).
// Go may append -{GOMAXPROCS} to the printed benchmark name; ignore it when comparing runs.
//
// Grid: 4 payloads × 6 compress levels (gzip 1/5/9, zstd fastest/default/best) = 24 subtests
// per benchmark function (48 total with encoder + handler).
//
// Run conformance tests (Reset/Flush/Close, Vary, ETag, 304/HEAD/range/WebSocket, minimum_length):
//
// go test -run='TestStandardEncoderContract|TestEncodeCorpusResponse|TestEncodeResponseSemantics' ./modules/caddyhttp/encode/
//
// Conformance also covers Cache-Control no-transform, content-type matcher rejection,
// and encoder Reset-after-Close reuse (pool pattern).
package encode_test
import (
"bytes"
stdgzip "compress/gzip"
"context"
"fmt"
"io"
"os"
"testing"
"github.com/klauspost/compress/zstd"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig"
"github.com/caddyserver/caddy/v2/modules/caddyhttp/encode"
caddygzip "github.com/caddyserver/caddy/v2/modules/caddyhttp/encode/gzip"
caddyzstd "github.com/caddyserver/caddy/v2/modules/caddyhttp/encode/zstd"
)
// benchmarkCorpus is a fixed payload used by both benchmarks and conformance tests.
type benchmarkCorpus struct {
name string
data []byte
contentType string
}
var (
benchmarkGzipLevels = []int{1, 5, 9}
benchmarkZstdLevels = []string{"fastest", "default", "best"}
)
type encoderCase struct {
name string // conformance subtest label, e.g. gzip-level-5
encoder string // gzip or zstd
level string // gzip numeric level or zstd level name
encoding encode.Encoding
decompress func([]byte) ([]byte, error)
contentType string
}
func benchmarkCorpora(tb testing.TB) []benchmarkCorpus {
tb.Helper()
return []benchmarkCorpus{
{name: "html", data: readBenchmarkPayload(tb, "testdata/caddy_home.html"), contentType: "text/html; charset=utf-8"},
{name: "json", data: readBenchmarkPayload(tb, "testdata/caddy_config_http_servers.json"), contentType: "application/json"},
{name: "js", data: readBenchmarkPayload(tb, "testdata/caddy_asciinema_player.js"), contentType: "application/javascript"},
{name: "css", data: readBenchmarkPayload(tb, "testdata/caddy_asciinema_player.css"), contentType: "text/css"},
}
}
func readBenchmarkPayload(tb testing.TB, filename string) []byte {
tb.Helper()
data, err := os.ReadFile(filename)
if err != nil {
tb.Fatalf("reading benchmark payload %s: %v", filename, err)
}
return data
}
// conformanceLargeBody returns a payload large enough to exceed default minimum_length.
func conformanceLargeBody() []byte {
data, err := os.ReadFile("testdata/caddy_home.html")
if err != nil {
panic("conformanceLargeBody: " + err.Error())
}
return data
}
func standardEncoderCases(t testing.TB) []encoderCase {
t.Helper()
return provisionEncoderCases(t, []int{5}, []string{"default"})
}
func benchmarkEncoderCases(t testing.TB) []encoderCase {
t.Helper()
return provisionEncoderCases(t, benchmarkGzipLevels, benchmarkZstdLevels)
}
func provisionEncoderCases(t testing.TB, gzipLevels []int, zstdLevels []string) []encoderCase {
t.Helper()
ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
t.Cleanup(cancel)
var cases []encoderCase
for _, level := range gzipLevels {
gzipEncoding := &caddygzip.Gzip{Level: level}
if err := gzipEncoding.Provision(ctx); err != nil {
t.Fatalf("gzip level %d Provision() error = %v", level, err)
}
cases = append(cases, encoderCase{
name: fmt.Sprintf("gzip-level-%d", level),
encoder: "gzip",
level: fmt.Sprintf("%d", level),
encoding: gzipEncoding,
decompress: decompressGzip,
contentType: "text/plain",
})
}
for _, level := range zstdLevels {
zstdEncoding := &caddyzstd.Zstd{Level: level}
if err := zstdEncoding.Provision(ctx); err != nil {
t.Fatalf("zstd level %q Provision() error = %v", level, err)
}
cases = append(cases, encoderCase{
name: "zstd-level-" + level,
encoder: "zstd",
level: level,
encoding: zstdEncoding,
decompress: decompressZstd,
contentType: "text/plain",
})
}
return cases
}
func newEncodeHandler(tb testing.TB, encCase encoderCase, minLength int) *encode.Encode {
tb.Helper()
encodingName := encCase.encoding.AcceptEncoding()
enc := &encode.Encode{
EncodingsRaw: caddy.ModuleMap{
encodingName: caddyconfig.JSON(encCase.encoding, nil),
},
Prefer: []string{encodingName},
MinLength: minLength,
}
ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
tb.Cleanup(cancel)
if err := enc.Provision(ctx); err != nil {
tb.Fatalf("Provision() error = %v", err)
}
if err := enc.Validate(); err != nil {
tb.Fatalf("Validate() error = %v", err)
}
return enc
}
func assertDecompresses(t *testing.T, encCase encoderCase, compressed, original []byte) {
t.Helper()
decompressed, err := encCase.decompress(compressed)
if err != nil {
t.Fatalf("decompress %s: %v", encCase.name, err)
}
if !bytes.Equal(decompressed, original) {
t.Fatalf("decompressed len = %d, want len = %d", len(decompressed), len(original))
}
}
// encodeAndVerifyRoundTrip exercises Write → Flush → Write → Close and verifies
// the compressed stream round-trips to original.
func encodeAndVerifyRoundTrip(t *testing.T, encCase encoderCase, encoder encode.Encoder, original []byte) {
t.Helper()
var compressed bytes.Buffer
encoder.Reset(&compressed)
if _, err := encoder.Write(original[:len(original)/2]); err != nil {
t.Fatalf("Write() error = %v", err)
}
if err := encoder.Flush(); err != nil {
t.Fatalf("Flush() error = %v", err)
}
if compressed.Len() == 0 {
t.Fatal("Flush() wrote no compressed bytes")
}
if _, err := encoder.Write(original[len(original)/2:]); err != nil {
t.Fatalf("Write() error = %v", err)
}
if err := encoder.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
assertDecompresses(t, encCase, compressed.Bytes(), original)
}
func decompressGzip(compressed []byte) ([]byte, error) {
reader, err := stdgzip.NewReader(bytes.NewReader(compressed))
if err != nil {
return nil, err
}
defer reader.Close()
return io.ReadAll(reader)
}
func decompressZstd(compressed []byte) ([]byte, error) {
decoder, err := zstd.NewReader(nil)
if err != nil {
return nil, err
}
defer decoder.Close()
return decoder.DecodeAll(compressed, nil)
}

View file

@ -305,9 +305,9 @@ 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 (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)

View file

@ -0,0 +1,8 @@
package encode
import "net/http"
// HasVaryValue exposes hasVaryValue for external tests in encode_test.
func HasVaryValue(hdr http.Header, target string) bool {
return hasVaryValue(hdr, target)
}

View file

@ -0,0 +1,7 @@
These benchmark payloads are snapshots from caddyserver.com, fetched on
2026-06-06:
- `caddy_home.html`: https://caddyserver.com/
- `caddy_config_http_servers.json`: https://caddyserver.com/api/docs/config/apps/http/servers
- `caddy_asciinema_player.css`: https://caddyserver.com/resources/css/vendor/asciinema-player-3.6.1.css?v=378d6d0
- `caddy_asciinema_player.js`: https://caddyserver.com/resources/js/vendor/asciinema-player-3.6.1.min.js?v=378d6d0

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -15,7 +15,10 @@
package fileserver
import (
"bytes"
"compress/gzip"
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
@ -26,6 +29,7 @@ import (
"time"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/modules/caddyhttp/encode"
)
func TestFileHidden(t *testing.T) {
@ -184,3 +188,122 @@ func check_validator_headers(modTime time.Time, expect_headers bool, t *testing.
}
}
}
func TestPrecompressedRangeResponse(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "range.txt"), []byte("original response body"), 0o600); err != nil {
t.Fatal(err)
}
sidecar := gzipBytes(t, []byte("original response body"))
if err := os.WriteFile(filepath.Join(root, "range.txt.gz"), sidecar, 0o600); err != nil {
t.Fatal(err)
}
fsrv := FileServer{
Root: root,
CanonicalURIs: new(bool),
PrecompressedOrder: []string{"gzip"},
}
ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()})
if err := fsrv.Provision(ctx); err != nil {
t.Fatal(err)
}
fsrv.precompressors = map[string]encode.Precompressed{
"gzip": testPrecompressed{encoding: "gzip", suffix: ".gz"},
}
t.Run("full response", func(t *testing.T) {
w := httptest.NewRecorder()
r := newPrecompressedRequest(t, "/range.txt")
r.Header.Set("Accept-Encoding", "gzip")
if err := fsrv.ServeHTTP(w, r, nil); err != nil {
t.Fatal(err)
}
if got := w.Code; got != http.StatusOK {
t.Fatalf("status = %d, want %d", got, http.StatusOK)
}
if got := w.Header().Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
if got := w.Header().Get("Content-Length"); got != fmt.Sprintf("%d", len(sidecar)) {
t.Fatalf("Content-Length = %q, want %d", got, len(sidecar))
}
if got := w.Header().Get("Vary"); got != "Accept-Encoding" {
t.Fatalf("Vary = %q, want Accept-Encoding", got)
}
if got := w.Body.Bytes(); !bytes.Equal(got, sidecar) {
t.Fatalf("body len = %d, want len = %d", len(got), len(sidecar))
}
})
t.Run("range response", func(t *testing.T) {
w := httptest.NewRecorder()
r := newPrecompressedRequest(t, "/range.txt")
r.Header.Set("Accept-Encoding", "gzip")
r.Header.Set("Range", "bytes=2-5")
if err := fsrv.ServeHTTP(w, r, nil); err != nil {
t.Fatal(err)
}
if got := w.Code; got != http.StatusPartialContent {
t.Fatalf("status = %d, want %d", got, http.StatusPartialContent)
}
if got := w.Header().Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
wantContentRange := fmt.Sprintf("bytes 2-5/%d", len(sidecar))
if got := w.Header().Get("Content-Range"); got != wantContentRange {
t.Fatalf("Content-Range = %q, want %q", got, wantContentRange)
}
if got := w.Header().Get("Content-Length"); got != "4" {
t.Fatalf("Content-Length = %q, want 4", got)
}
if got := w.Header().Get("Vary"); got != "Accept-Encoding" {
t.Fatalf("Vary = %q, want Accept-Encoding", got)
}
if got, want := w.Body.Bytes(), sidecar[2:6]; !bytes.Equal(got, want) {
t.Fatalf("body = %x, want %x", got, want)
}
})
}
func gzipBytes(t *testing.T, data []byte) []byte {
t.Helper()
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
if _, err := zw.Write(data); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func newPrecompressedRequest(t *testing.T, target string) *http.Request {
t.Helper()
r := httptest.NewRequest(http.MethodGet, target, nil)
repl := caddy.NewReplacer()
ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, repl)
return r.WithContext(ctx)
}
type testPrecompressed struct {
encoding string
suffix string
}
func (p testPrecompressed) AcceptEncoding() string {
return p.encoding
}
func (p testPrecompressed) Suffix() string {
return p.suffix
}

View file

@ -97,8 +97,6 @@ var bufPool = sync.Pool{
},
}
// TODO: handle status code replacement
//
// EXPERIMENTAL: Subject to change or removal.
type interceptedResponseHandler struct {
caddyhttp.ResponseRecorder
@ -108,17 +106,6 @@ type interceptedResponseHandler struct {
statusCode int
}
// EXPERIMENTAL: Subject to change or removal.
func (irh interceptedResponseHandler) WriteHeader(statusCode int) {
if irh.statusCode != 0 && (statusCode < 100 || statusCode >= 200) {
irh.ResponseRecorder.WriteHeader(irh.statusCode)
return
}
irh.ResponseRecorder.WriteHeader(statusCode)
}
// EXPERIMENTAL: Subject to change or removal.
func (irh interceptedResponseHandler) Unwrap() http.ResponseWriter {
return irh.ResponseRecorder
@ -142,7 +129,7 @@ func (ir Intercept) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddy
rec.handlerIndex = i
// if configured to only change the status code,
// do that then stream
// buffer the response so we can substitute the status
if statusCodeStr := rh.StatusCode.String(); statusCodeStr != "" {
sc, err := strconv.Atoi(repl.ReplaceAll(statusCodeStr, ""))
if err != nil {
@ -150,6 +137,8 @@ func (ir Intercept) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddy
} else {
rec.statusCode = sc
}
return true
}
return rec.statusCode == 0
@ -176,6 +165,23 @@ func (ir Intercept) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddy
c.Write(zap.Int("handler", rec.handlerIndex))
}
// replace_status only: no routes to execute, just substitute status and write body
if rec.handler.Routes == nil {
if rec.statusCode == 0 {
w.WriteHeader(rec.Status())
} else {
w.WriteHeader(rec.statusCode)
}
if buf.Len() > 0 {
_, err := io.Copy(w, buf)
return err
}
return nil
}
// response recorder doesn't create a new copy of the original headers, they're
// present in the original response writer
// create a new recorder to see if any response body from the new handler is present,

View file

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

View file

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

View file

@ -129,7 +129,11 @@ func parseCaddyfile(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error)
if !dispenser.NextArg() {
return nil, dispenser.ArgErr()
}
rpHandler.Rewrite.URI = dispenser.Val()
uri := dispenser.Val()
if rpHandler.Rewrite.URI != "" {
return nil, dispenser.Errf("cannot re-declare uri: %s", uri)
}
rpHandler.Rewrite.URI = uri
dispenser.DeleteN(2)
case "copy_headers":

View file

@ -0,0 +1,191 @@
package reverseproxy
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
)
// 101 responses strip hop-by-hop headers (Alt-Svc, Keep-Alive, etc.) but preserve Upgrade and Connection
func TestFinalizeResponse_101_StripsHopByHopHeaders(t *testing.T) {
h := &Handler{logger: caddy.Log()}
req := httptest.NewRequest(http.MethodGet, "/ws", nil)
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Sec-WebSocket-Version", "13")
req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
vars := map[string]any{}
ctx := context.WithValue(req.Context(), caddyhttp.VarsCtxKey, vars)
req = req.WithContext(ctx)
res := &http.Response{
StatusCode: http.StatusSwitchingProtocols,
ProtoMajor: 1,
ProtoMinor: 1,
Header: http.Header{
"Upgrade": {"websocket"},
"Connection": {"Upgrade"},
"Sec-Websocket-Accept": {"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="},
"Alt-Svc": {"h2=\"evil.com:443\"; ma=86400"},
"Keep-Alive": {"timeout=999"},
"Proxy-Authenticate": {"Basic realm=\"phish\""},
},
Body: fakeRWC{strings.NewReader("")},
}
repl := caddy.NewReplacer()
rw := httptest.NewRecorder()
err := h.finalizeResponse(rw, req, res, repl, fakeStart, caddy.Log())
if err != nil {
t.Logf("finalizeResponse returned error (expected, no real conn): %v", err)
}
if got := rw.Header().Get("Upgrade"); got != "websocket" {
t.Errorf("Upgrade = %q, want %q", got, "websocket")
}
if got := rw.Header().Get("Connection"); got != "Upgrade" {
t.Errorf("Connection = %q, want %q", got, "Upgrade")
}
for _, hdr := range []string{"Alt-Svc", "Keep-Alive", "Proxy-Authenticate"} {
if got := rw.Header().Get(hdr); got != "" {
t.Errorf("%s = %q, want empty (should be stripped)", hdr, got)
}
}
}
// Headers named in the upstream Connection value are stripped
func TestFinalizeResponse_101_StripsConnectionNamedHeaders(t *testing.T) {
h := &Handler{logger: caddy.Log()}
req := httptest.NewRequest(http.MethodGet, "/ws", nil)
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Sec-WebSocket-Version", "13")
req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
vars := map[string]any{}
ctx := context.WithValue(req.Context(), caddyhttp.VarsCtxKey, vars)
req = req.WithContext(ctx)
res := &http.Response{
StatusCode: http.StatusSwitchingProtocols,
ProtoMajor: 1,
ProtoMinor: 1,
Header: http.Header{
"Upgrade": {"websocket"},
"Connection": {"Upgrade, X-Custom-ID"},
"Sec-Websocket-Accept": {"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="},
"X-Custom-Id": {"should-be-stripped"},
},
Body: fakeRWC{strings.NewReader("")},
}
repl := caddy.NewReplacer()
rw := httptest.NewRecorder()
err := h.finalizeResponse(rw, req, res, repl, fakeStart, caddy.Log())
if err != nil {
t.Logf("finalizeResponse returned error (no real connection): %v", err)
}
if got := rw.Header().Get("Upgrade"); got != "websocket" {
t.Errorf("Upgrade = %q, want %q", got, "websocket")
}
if got := rw.Header().Get("X-Custom-Id"); got != "" {
t.Errorf("X-Custom-Id = %q, want empty (named in Connection, should be stripped)", got)
}
if got := rw.Header().Get("Connection"); got != "Upgrade" {
t.Errorf("Connection = %q, want %q", got, "Upgrade")
}
}
// Normal 200 responses strip hop-by-hop headers
func TestFinalizeResponse_200_StillStripsHopByHop(t *testing.T) {
h := &Handler{logger: caddy.Log()}
req := httptest.NewRequest(http.MethodGet, "/", nil)
vars := map[string]any{}
ctx := context.WithValue(req.Context(), caddyhttp.VarsCtxKey, vars)
req = req.WithContext(ctx)
res := &http.Response{
StatusCode: http.StatusOK,
ProtoMajor: 1,
ProtoMinor: 1,
Header: http.Header{
"Content-Type": {"text/plain"},
"Alt-Svc": {"h2=\"evil.com:443\""},
"Keep-Alive": {"timeout=999"},
},
Body: io.NopCloser(strings.NewReader("ok")),
}
repl := caddy.NewReplacer()
rw := httptest.NewRecorder()
err := h.finalizeResponse(rw, req, res, repl, fakeStart, caddy.Log())
if err != nil {
t.Fatalf("finalizeResponse returned error: %v", err)
}
for _, hdr := range []string{"Alt-Svc", "Keep-Alive"} {
if got := rw.Header().Get(hdr); got != "" {
t.Errorf("%s = %q on 200 response, want empty (stripped)", hdr, got)
}
}
}
// Stripping still runs when the client didn't request an upgrade
func TestFinalizeResponse_101_NoUpgradeRequest(t *testing.T) {
h := &Handler{logger: caddy.Log()}
req := httptest.NewRequest(http.MethodGet, "/ws", nil)
vars := map[string]any{}
ctx := context.WithValue(req.Context(), caddyhttp.VarsCtxKey, vars)
req = req.WithContext(ctx)
res := &http.Response{
StatusCode: http.StatusSwitchingProtocols,
ProtoMajor: 1,
ProtoMinor: 1,
Header: http.Header{
"Upgrade": {"websocket"},
"Connection": {"Upgrade"},
"Alt-Svc": {"h2=\"evil.com:443\""},
},
Body: io.NopCloser(strings.NewReader("")),
}
repl := caddy.NewReplacer()
rw := httptest.NewRecorder()
err := h.finalizeResponse(rw, req, res, repl, fakeStart, caddy.Log())
if err != nil {
t.Fatalf("finalizeResponse returned error: %v", err)
}
if got := rw.Header().Get("Alt-Svc"); got != "" {
t.Errorf("Alt-Svc = %q, want empty (stripped)", got)
}
}
// fakeRWC lets handleUpgradeResponse proceed past the body type assertion in tests
type fakeRWC struct {
io.Reader
}
func (f fakeRWC) Write(p []byte) (n int, err error) { return len(p), nil }
func (f fakeRWC) Close() error { return nil }
var fakeStart = time.Time{}

View file

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

View file

@ -1224,6 +1224,22 @@ func (h *Handler) finalizeResponse(
start time.Time,
logger *zap.Logger,
) error {
// Strip hop-by-hop headers from the upstream response.
// For 101 Switching Protocols, save the Upgrade value
// first (handleUpgradeResponse needs it for validation)
// and restore it with a canonical Connection header.
upgradeVal := res.Header.Get("Upgrade")
removeConnectionHeaders(res.Header)
for _, h := range hopHeaders {
res.Header.Del(h)
}
if res.StatusCode == http.StatusSwitchingProtocols && upgradeVal != "" {
res.Header.Set("Upgrade", upgradeVal)
res.Header.Set("Connection", "Upgrade")
}
// deal with 101 Switching Protocols responses: (WebSocket, h2c, etc)
if res.StatusCode == http.StatusSwitchingProtocols {
var wg sync.WaitGroup
@ -1232,12 +1248,6 @@ func (h *Handler) finalizeResponse(
return nil
}
removeConnectionHeaders(res.Header)
for _, h := range hopHeaders {
res.Header.Del(h)
}
// delete our Server header and use Via instead (see #6275)
rw.Header().Del("Server")
var protoPrefix string

View file

@ -127,6 +127,19 @@ func (r *WeightedRoundRobinSelection) Provision(ctx caddy.Context) error {
return nil
}
// Validate ensures that r's configuration is valid
func (r *WeightedRoundRobinSelection) Validate() error {
if r.totalWeight <= 0 {
return fmt.Errorf("weighted_round_robin requires at least one upstream with a positive weight")
}
for _, weight := range r.Weights {
if weight < 0 {
return fmt.Errorf("weight of an upstream cannot be negative")
}
}
return nil
}
// Select returns an available host, if any.
func (r *WeightedRoundRobinSelection) Select(pool UpstreamPool, _ *http.Request, _ http.ResponseWriter) *Upstream {
if len(pool) == 0 {
@ -891,6 +904,7 @@ var (
_ Selector = (*CookieHashSelection)(nil)
_ caddy.Validator = (*RandomChoiceSelection)(nil)
_ caddy.Validator = (*WeightedRoundRobinSelection)(nil)
_ caddy.Provisioner = (*RandomChoiceSelection)(nil)
_ caddy.Provisioner = (*WeightedRoundRobinSelection)(nil)

View file

@ -131,6 +131,48 @@ func TestWeightedRoundRobinPolicy(t *testing.T) {
}
}
func TestWeightedRoundRobinSelection_Validate(t *testing.T) {
tests := []struct {
name string
weights []int
wantErr bool
}{
{
name: "Valid 0 2 1 case",
weights: []int{0, 2, 1},
wantErr: false,
},
{
name: "Invalid 0 case (single)",
weights: []int{0},
wantErr: true,
},
{
name: "Invalid 0 0 case (multiple)",
weights: []int{0, 0},
wantErr: true,
},
{
name: "Valid weights",
weights: []int{1, 1, 1},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &WeightedRoundRobinSelection{
Weights: tt.weights,
}
_ = s.Provision(caddy.Context{})
err := s.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestWeightedRoundRobinPolicyWithZeroWeight(t *testing.T) {
pool := testPool()
wrrPolicy := WeightedRoundRobinSelection{

View file

@ -432,7 +432,7 @@ func trimPathPrefix(escapedPath, prefix string) string {
}
// if we iterated through the entire prefix, we found it, so trim it
if iPath >= len(prefix) {
if iPrefix >= len(prefix) {
return escapedPath[iPath:]
}

View file

@ -267,6 +267,12 @@ func TestRewrite(t *testing.T) {
input: newRequest(t, "GET", "/foo/prefix/bar"),
expect: newRequest(t, "GET", "/foo/prefix/bar"),
},
{
// shorter (percent-encoded) path that is not the prefix must be left alone
rule: Rewrite{StripPathPrefix: "/aaaaaa"},
input: newRequest(t, "GET", "/%61%61"),
expect: newRequest(t, "GET", "/%61%61"),
},
{
rule: Rewrite{StripPathPrefix: "//prefix"},
// scheme and host needed for URL parser to succeed in setting up test

View file

@ -101,7 +101,7 @@ type Server struct {
KeepAliveCount int `json:"keepalive_count,omitempty"`
// MaxHeaderBytes is the maximum size to parse from a client's
// HTTP request headers.
// HTTP request headers. Default: 16 KiB.
MaxHeaderBytes int `json:"max_header_bytes,omitempty"`
// Enable full-duplex communication for HTTP/1 requests.
@ -124,6 +124,22 @@ type Server struct {
// TODO: This is an EXPERIMENTAL feature. Subject to change or removal.
EnableFullDuplex bool `json:"enable_full_duplex,omitempty"`
// A list of header field names containing underscores that should
// be preserved instead of being dropped. By default, Caddy drops
// ALL headers with underscores to prevent ambiguity with
// CGI/FastCGI backends that map hyphens to underscores
// (GHSA-f59h-q822-g45g). When this list is configured, only the
// specified headers are kept; their hyphenated variants are
// actively dropped to prevent confusion. Entries are
// case-insensitive. A trailing "*" acts as a prefix glob
// (e.g., "webhook_*" matches any header starting with
// "webhook_"). If an allowlisted header arrives with
// multiple values (repeated field), all values are dropped
// as a safeguard against header injection.
//
// TODO: This is an EXPERIMENTAL feature. Subject to change or removal.
ExpectedUnderscoreHeaders []string `json:"expected_underscore_headers,omitempty"`
// Routes describes how this server will handle requests.
// Routes are executed sequentially. First a route's matchers
// are evaluated, then its grouping. If it matches and has
@ -293,6 +309,11 @@ type Server struct {
shutdownAt atomic.Pointer[time.Time]
// precomputed underscore header allowlist (built during provisioning)
underscoreExactAllow map[string]struct{}
underscoreExactDrop map[string]struct{}
underscorePrefixRules []underscoreRule
// registered callback functions
connStateFuncs []func(net.Conn, http.ConnState)
connContextFuncs []func(ctx context.Context, c net.Conn) context.Context
@ -300,6 +321,95 @@ type Server struct {
onStopFuncs []func(context.Context) error // TODO: Experimental (Nov. 2023)
}
// underscoreRule pairs a canonical underscore prefix with its hyphenated
// counterpart. Used for prefix-glob matching in the allowlist.
type underscoreRule struct {
allow string // canonical underscore form, e.g. "Webhook_"
drop string // canonical hyphenated form, e.g. "Webhook-"
}
// provisionUnderscoreHeaders validates the ExpectedUnderscoreHeaders
// entries and builds the precomputed maps and prefix rules used by
// the hot-path filter in serveHTTP.
func (s *Server) provisionUnderscoreHeaders() error {
if len(s.ExpectedUnderscoreHeaders) == 0 {
return nil
}
s.underscoreExactAllow = make(map[string]struct{}, len(s.ExpectedUnderscoreHeaders))
s.underscoreExactDrop = make(map[string]struct{}, len(s.ExpectedUnderscoreHeaders))
for _, entry := range s.ExpectedUnderscoreHeaders {
// Reject non-ASCII bytes: Go's HTTP parser returns 400 for
// non-ASCII header names, so such entries can never match.
for i := 0; i < len(entry); i++ {
if entry[i] >= 0x80 {
return fmt.Errorf("expected_underscore_headers: entry %q contains non-ASCII characters", entry)
}
}
isGlob := strings.HasSuffix(entry, "*")
name := entry
if isGlob {
name = strings.TrimSuffix(entry, "*")
}
// Reject entries with '*' not at the trailing position.
if strings.ContainsRune(name, '*') {
return fmt.Errorf("expected_underscore_headers: entry %q has '*' in an invalid position (only a trailing '*' is allowed)", entry)
}
// The name (without trailing '*') must contain at least one underscore.
if !strings.ContainsRune(name, '_') {
return fmt.Errorf("expected_underscore_headers: entry %q does not contain an underscore", entry)
}
canonAllow := http.CanonicalHeaderKey(name)
canonDrop := http.CanonicalHeaderKey(strings.ReplaceAll(name, "_", "-"))
if isGlob {
s.underscorePrefixRules = append(s.underscorePrefixRules, underscoreRule{
allow: canonAllow,
drop: canonDrop,
})
} else {
s.underscoreExactAllow[canonAllow] = struct{}{}
s.underscoreExactDrop[canonDrop] = struct{}{}
}
}
return nil
}
// isAllowedUnderscoreHeader reports whether key (a canonical header
// name containing an underscore) is permitted by the allowlist.
func (s *Server) isAllowedUnderscoreHeader(key string) bool {
if _, ok := s.underscoreExactAllow[key]; ok {
return true
}
for _, rule := range s.underscorePrefixRules {
if strings.HasPrefix(key, rule.allow) {
return true
}
}
return false
}
// isHyphenatedVariant reports whether key (a canonical header name
// without underscores) is the hyphenated variant of an allowlisted
// underscore header and should therefore be dropped.
func (s *Server) isHyphenatedVariant(key string) bool {
if _, ok := s.underscoreExactDrop[key]; ok {
return true
}
for _, rule := range s.underscorePrefixRules {
if strings.HasPrefix(key, rule.drop) {
return true
}
}
return false
}
var defaultProtocols = []string{"h1", "h2", "h3"}
var (
@ -497,12 +607,41 @@ 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)
//
// When an allowlist is configured, only the listed headers are kept and
// their hyphenated variants are actively dropped to prevent ambiguity.
if len(s.ExpectedUnderscoreHeaders) == 0 {
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))
if c := s.logger.Check(zapcore.DebugLevel, "dropping header containing underscore"); c != nil {
c.Write(zap.String("header", k))
}
}
}
} else {
for k := range r.Header {
if strings.ContainsRune(k, '_') {
if !s.isAllowedUnderscoreHeader(k) {
delete(r.Header, k)
if c := s.logger.Check(zapcore.DebugLevel, "dropping header containing underscore"); c != nil {
c.Write(zap.String("header", k))
}
} else if n := len(r.Header[k]); n > 1 {
delete(r.Header, k)
if c := s.logger.Check(zapcore.WarnLevel, "dropping allowlisted underscore header with repeated values (possible spoofing)"); c != nil {
c.Write(zap.String("header", k), zap.Int("count", n))
}
}
} else if s.isHyphenatedVariant(k) {
delete(r.Header, k)
if c := s.logger.Check(zapcore.DebugLevel, "dropping hyphenated variant of expected underscore header"); c != nil {
c.Write(zap.String("header", k))
}
}
}
}

View file

@ -527,6 +527,433 @@ func TestServer_serveHTTP_LogsDroppedUnderscoreHeader(t *testing.T) {
assert.Contains(t, buf.String(), `"header":"Remote_user"`)
}
// --- Allowlist: exact match ---
func TestServer_serveHTTP_AllowlistKeepsExactMatch(t *testing.T) {
got := &http.Header{}
s := &Server{
logger: zap.NewNop(),
ExpectedUnderscoreHeaders: []string{"user_id"},
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
*got = r.Header.Clone()
return nil
}),
}
require.NoError(t, s.provisionUnderscoreHeaders())
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.Header["User_id"] = []string{"zeus"}
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
assert.Equal(t, "zeus", got.Get("User_id"))
}
func TestServer_serveHTTP_AllowlistDropsHyphenatedVariant(t *testing.T) {
got := &http.Header{}
s := &Server{
logger: zap.NewNop(),
ExpectedUnderscoreHeaders: []string{"user_id"},
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
*got = r.Header.Clone()
return nil
}),
}
require.NoError(t, s.provisionUnderscoreHeaders())
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.Header["User_id"] = []string{"zeus"}
req.Header.Set("User-Id", "attacker")
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
assert.Equal(t, "zeus", got.Get("User_id"))
assert.NotContains(t, *got, "User-Id")
}
func TestServer_serveHTTP_AllowlistDropsUnlisted(t *testing.T) {
got := &http.Header{}
s := &Server{
logger: zap.NewNop(),
ExpectedUnderscoreHeaders: []string{"user_id"},
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
*got = r.Header.Clone()
return nil
}),
}
require.NoError(t, s.provisionUnderscoreHeaders())
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.Header["User_id"] = []string{"zeus"}
req.Header["Remote_user"] = []string{"attacker"}
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
assert.Equal(t, "zeus", got.Get("User_id"))
assert.NotContains(t, *got, "Remote_user")
}
func TestServer_serveHTTP_AllowlistPassesThroughNormalHeaders(t *testing.T) {
got := &http.Header{}
s := &Server{
logger: zap.NewNop(),
ExpectedUnderscoreHeaders: []string{"user_id"},
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
*got = r.Header.Clone()
return nil
}),
}
require.NoError(t, s.provisionUnderscoreHeaders())
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.Header.Set("X-Real-Header", "ok")
req.Header.Set("Content-Type", "text/plain")
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
assert.Equal(t, "ok", got.Get("X-Real-Header"))
assert.Equal(t, "text/plain", got.Get("Content-Type"))
}
// --- Allowlist: mixed underscore/hyphen entry ---
func TestServer_serveHTTP_MixedEntryKeepsOriginal(t *testing.T) {
got := &http.Header{}
s := &Server{
logger: zap.NewNop(),
ExpectedUnderscoreHeaders: []string{"__user-id"},
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
*got = r.Header.Clone()
return nil
}),
}
require.NoError(t, s.provisionUnderscoreHeaders())
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.Header["__user-Id"] = []string{"zeus"} // canonical form of __user-id
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
assert.Equal(t, "zeus", got.Get("__user-Id"))
}
func TestServer_serveHTTP_MixedEntryDropsFullyHyphenated(t *testing.T) {
got := &http.Header{}
s := &Server{
logger: zap.NewNop(),
ExpectedUnderscoreHeaders: []string{"__user-id"},
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
*got = r.Header.Clone()
return nil
}),
}
require.NoError(t, s.provisionUnderscoreHeaders())
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.Header["--User-Id"] = []string{"attacker"} // fully hyphenated variant
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
assert.NotContains(t, *got, "--User-Id")
}
func TestServer_serveHTTP_MixedEntryDropsPartialVariants(t *testing.T) {
got := &http.Header{}
s := &Server{
logger: zap.NewNop(),
ExpectedUnderscoreHeaders: []string{"__user-id"},
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
*got = r.Header.Clone()
return nil
}),
}
require.NoError(t, s.provisionUnderscoreHeaders())
// All partial variants still contain underscores and don't match
// the allowlist, so they are dropped by the normal underscore filter.
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.Header["-_user-Id"] = []string{"attacker1"} // canonical of -_user-id
req.Header["_-User-Id"] = []string{"attacker2"} // canonical of _-user-id
req.Header["__user_id"] = []string{"attacker3"} // all underscores variant
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
assert.NotContains(t, *got, "-_user-Id")
assert.NotContains(t, *got, "_-User-Id")
assert.NotContains(t, *got, "__user_id")
}
// --- Allowlist: prefix glob ---
func TestServer_serveHTTP_PrefixGlobKeepsMatch(t *testing.T) {
got := &http.Header{}
s := &Server{
logger: zap.NewNop(),
ExpectedUnderscoreHeaders: []string{"webhook_*"},
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
*got = r.Header.Clone()
return nil
}),
}
require.NoError(t, s.provisionUnderscoreHeaders())
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.Header["Webhook_event"] = []string{"push"}
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
assert.Equal(t, "push", got.Get("Webhook_event"))
}
func TestServer_serveHTTP_PrefixGlobDropsHyphenatedVariant(t *testing.T) {
got := &http.Header{}
s := &Server{
logger: zap.NewNop(),
ExpectedUnderscoreHeaders: []string{"webhook_*"},
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
*got = r.Header.Clone()
return nil
}),
}
require.NoError(t, s.provisionUnderscoreHeaders())
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.Header.Set("Webhook-Event", "push")
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
assert.NotContains(t, *got, "Webhook-Event")
}
func TestServer_serveHTTP_PrefixGlobDropsNonMatching(t *testing.T) {
got := &http.Header{}
s := &Server{
logger: zap.NewNop(),
ExpectedUnderscoreHeaders: []string{"webhook_*"},
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
*got = r.Header.Clone()
return nil
}),
}
require.NoError(t, s.provisionUnderscoreHeaders())
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.Header["Other_header"] = []string{"val"}
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
assert.NotContains(t, *got, "Other_header")
}
func TestServer_serveHTTP_PrefixGlobDropsMixedVariant(t *testing.T) {
got := &http.Header{}
s := &Server{
logger: zap.NewNop(),
ExpectedUnderscoreHeaders: []string{"webhook_*"},
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
*got = r.Header.Clone()
return nil
}),
}
require.NoError(t, s.provisionUnderscoreHeaders())
// "Webhook-Event_type" has underscores but starts with "Webhook-Event_",
// which does NOT match the allow prefix "Webhook_", so it is dropped.
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.Header["Webhook-Event_type"] = []string{"push"}
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
assert.NotContains(t, *got, "Webhook-Event_type")
}
func TestServer_serveHTTP_LiteralAsteriskInHeader(t *testing.T) {
got := &http.Header{}
s := &Server{
logger: zap.NewNop(),
ExpectedUnderscoreHeaders: []string{"webhook_*"},
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
*got = r.Header.Clone()
return nil
}),
}
require.NoError(t, s.provisionUnderscoreHeaders())
// A header literally named "Webhook_*" matches the prefix rule
// because "Webhook_" is a prefix of "Webhook_*".
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.Header["Webhook_*"] = []string{"val"}
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
assert.Equal(t, "val", got.Get("Webhook_*"))
}
// --- Combined allowlist ---
func TestServer_serveHTTP_ExactAndPrefixCoexist(t *testing.T) {
got := &http.Header{}
s := &Server{
logger: zap.NewNop(),
ExpectedUnderscoreHeaders: []string{"user_id", "webhook_*"},
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
*got = r.Header.Clone()
return nil
}),
}
require.NoError(t, s.provisionUnderscoreHeaders())
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.Header["User_id"] = []string{"zeus"} // exact match → keep
req.Header["Webhook_event"] = []string{"push"} // prefix match → keep
req.Header["Other_field"] = []string{"bad"} // unlisted → drop
req.Header.Set("User-Id", "attacker") // hyphenated exact → drop
req.Header.Set("Webhook-Event", "attacker") // hyphenated prefix → drop
req.Header.Set("X-Normal-Header", "ok") // no underscore, not a variant → pass through
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
assert.Equal(t, "zeus", got.Get("User_id"))
assert.Equal(t, "push", got.Get("Webhook_event"))
assert.Equal(t, "ok", got.Get("X-Normal-Header"))
assert.NotContains(t, *got, "Other_field")
assert.NotContains(t, *got, "User-Id")
assert.NotContains(t, *got, "Webhook-Event")
}
// --- Allowlist: repeated values ---
// TestServer_serveHTTP_AllowlistDropsRepeatedExact verifies that an
// allowlisted header arriving with multiple values (repeated field)
// is dropped entirely as a safeguard against header injection.
func TestServer_serveHTTP_AllowlistDropsRepeatedExact(t *testing.T) {
got := &http.Header{}
s := &Server{
logger: zap.NewNop(),
ExpectedUnderscoreHeaders: []string{"user_id"},
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
*got = r.Header.Clone()
return nil
}),
}
require.NoError(t, s.provisionUnderscoreHeaders())
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.Header["User_id"] = []string{"zeus", "injected"}
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
assert.NotContains(t, *got, "User_id")
}
// TestServer_serveHTTP_AllowlistDropsRepeatedPrefixGlob verifies that a
// glob-matched header arriving with multiple values is dropped entirely.
func TestServer_serveHTTP_AllowlistDropsRepeatedPrefixGlob(t *testing.T) {
got := &http.Header{}
s := &Server{
logger: zap.NewNop(),
ExpectedUnderscoreHeaders: []string{"webhook_*"},
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
*got = r.Header.Clone()
return nil
}),
}
require.NoError(t, s.provisionUnderscoreHeaders())
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.Header["Webhook_event"] = []string{"push", "injected"}
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
assert.NotContains(t, *got, "Webhook_event")
}
// TestServer_serveHTTP_LogsRepeatedValueDrop verifies that dropping an
// allowlisted header with repeated values emits a warn-level log with
// the header name and value count.
func TestServer_serveHTTP_LogsRepeatedValueDrop(t *testing.T) {
var buf bytes.Buffer
s := &Server{
logger: testLogger(buf.Write),
ExpectedUnderscoreHeaders: []string{"user_id"},
primaryHandlerChain: HandlerFunc(func(http.ResponseWriter, *http.Request) error {
return nil
}),
}
require.NoError(t, s.provisionUnderscoreHeaders())
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.Header["User_id"] = []string{"zeus", "injected"}
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
assert.Contains(t, buf.String(), `"level":"warn"`)
assert.Contains(t, buf.String(), `"msg":"dropping allowlisted underscore header with repeated values (possible spoofing)"`)
assert.Contains(t, buf.String(), `"header":"User_id"`)
assert.Contains(t, buf.String(), `"count":2`)
}
// TestServer_serveHTTP_LogsHyphenatedVariantDrop verifies that dropping a
// hyphenated variant of an allowlisted header emits a debug-level log.
func TestServer_serveHTTP_LogsHyphenatedVariantDrop(t *testing.T) {
var buf bytes.Buffer
s := &Server{
logger: testLogger(buf.Write),
ExpectedUnderscoreHeaders: []string{"user_id"},
primaryHandlerChain: HandlerFunc(func(http.ResponseWriter, *http.Request) error {
return nil
}),
}
require.NoError(t, s.provisionUnderscoreHeaders())
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.Header.Set("User-Id", "attacker")
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
assert.Contains(t, buf.String(), `"level":"debug"`)
assert.Contains(t, buf.String(), `"msg":"dropping hyphenated variant of expected underscore header"`)
assert.Contains(t, buf.String(), `"header":"User-Id"`)
}
// --- Validation ---
func TestServer_provisionUnderscoreHeaders_EmptyListIsNoOp(t *testing.T) {
s := &Server{ExpectedUnderscoreHeaders: []string{}}
// Empty slice is treated as "no allowlist" — provisionUnderscoreHeaders
// returns nil (no error) because len == 0 is a no-op.
assert.NoError(t, s.provisionUnderscoreHeaders())
}
func TestServer_provisionUnderscoreHeaders_RejectsNoUnderscore(t *testing.T) {
s := &Server{ExpectedUnderscoreHeaders: []string{"content-type"}}
assert.Error(t, s.provisionUnderscoreHeaders())
}
func TestServer_provisionUnderscoreHeaders_RejectsBareWildcard(t *testing.T) {
s := &Server{ExpectedUnderscoreHeaders: []string{"*"}}
assert.Error(t, s.provisionUnderscoreHeaders())
}
func TestServer_provisionUnderscoreHeaders_RejectsMidGlob(t *testing.T) {
s := &Server{ExpectedUnderscoreHeaders: []string{"f*oo_bar"}}
assert.Error(t, s.provisionUnderscoreHeaders())
}
func TestServer_provisionUnderscoreHeaders_RejectsLeadingGlob(t *testing.T) {
s := &Server{ExpectedUnderscoreHeaders: []string{"*_foo"}}
assert.Error(t, s.provisionUnderscoreHeaders())
}
func TestServer_provisionUnderscoreHeaders_RejectsNonASCII(t *testing.T) {
s := &Server{ExpectedUnderscoreHeaders: []string{"uşer_id"}}
assert.Error(t, s.provisionUnderscoreHeaders())
}
func TestServer_provisionUnderscoreHeaders_ValidExact(t *testing.T) {
s := &Server{ExpectedUnderscoreHeaders: []string{"user_id"}}
assert.NoError(t, s.provisionUnderscoreHeaders())
}
func TestServer_provisionUnderscoreHeaders_ValidGlob(t *testing.T) {
s := &Server{ExpectedUnderscoreHeaders: []string{"webhook_*"}}
assert.NoError(t, s.provisionUnderscoreHeaders())
}
func TestServer_provisionUnderscoreHeaders_ValidMixed(t *testing.T) {
s := &Server{ExpectedUnderscoreHeaders: []string{"__user-id"}}
assert.NoError(t, s.provisionUnderscoreHeaders())
}
func TestServer_provisionUnderscoreHeaders_DeduplicatesSilently(t *testing.T) {
s := &Server{ExpectedUnderscoreHeaders: []string{"user_id", "user_id"}}
require.NoError(t, s.provisionUnderscoreHeaders())
assert.Len(t, s.underscoreExactAllow, 1)
}
// 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

View file

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

View file

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