mirror of
https://github.com/caddyserver/caddy.git
synced 2026-08-28 12:45:58 +00:00
Merge branch 'master' into fix-websocket-header-normalization
This commit is contained in:
commit
63d8d4ae35
36 changed files with 6445 additions and 95 deletions
2
.github/SECURITY.md
vendored
2
.github/SECURITY.md
vendored
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
169
modules/caddyhttp/encode/encode_bench_test.go
Normal file
169
modules/caddyhttp/encode/encode_bench_test.go
Normal 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
|
||||
})
|
||||
}
|
||||
400
modules/caddyhttp/encode/encode_conformance_test.go
Normal file
400
modules/caddyhttp/encode/encode_conformance_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
224
modules/caddyhttp/encode/encode_harness_test.go
Normal file
224
modules/caddyhttp/encode/encode_harness_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
8
modules/caddyhttp/encode/export_test.go
Normal file
8
modules/caddyhttp/encode/export_test.go
Normal 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)
|
||||
}
|
||||
7
modules/caddyhttp/encode/testdata/README.md
vendored
Normal file
7
modules/caddyhttp/encode/testdata/README.md
vendored
Normal 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
|
||||
2836
modules/caddyhttp/encode/testdata/caddy_asciinema_player.css
vendored
Normal file
2836
modules/caddyhttp/encode/testdata/caddy_asciinema_player.css
vendored
Normal file
File diff suppressed because it is too large
Load diff
1
modules/caddyhttp/encode/testdata/caddy_asciinema_player.js
vendored
Normal file
1
modules/caddyhttp/encode/testdata/caddy_asciinema_player.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
modules/caddyhttp/encode/testdata/caddy_config_http_servers.json
vendored
Normal file
1
modules/caddyhttp/encode/testdata/caddy_config_http_servers.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1351
modules/caddyhttp/encode/testdata/caddy_home.html
vendored
Normal file
1351
modules/caddyhttp/encode/testdata/caddy_home.html
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
191
modules/caddyhttp/reverseproxy/hopheaders_test.go
Normal file
191
modules/caddyhttp/reverseproxy/hopheaders_test.go
Normal 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{}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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:]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue