Files
caddy/modules/caddyhttp/replacer_test.go
T
Abdellatif Anaflous 4845150fa6 caddyhttp: surface 413 for oversized request body placeholders (#7969)
when a request exceeds the request_body max_size limit, the request_body handler wraps the http.MaxBytesError into a caddyhttp.HandlerError carrying status 413, but the {http.request.body} and {http.request.body_base64} placeholders ran io.Copy with the error ignored, so they silently returned the truncated prefix as though it were the complete body. docs promise a 413 for reads past max_size, and silently truncating is a bad failure mode in templates and vars_regexp where the body value drives decisions. see #7691 and the narrow follow-up prescribed when #7692 was closed

reading the body now returns a dedicated RequestBodyLimitError marker instead of a generic HandlerError, and only when the read failure is actually the max_size limit. the consumers (template placeholder function, vars and vars_regexp matchers) recognize exactly that marker and wrap it in a status carrying handler error so the oversized request fails with 413, while every unrelated error value keeps its old behavior: templates still render it as text and the vars matchers still match on its error text. the surfaced error is stripped of its generated id and stack trace so a placeholder stringified into a response body cannot leak the call stack

negative regressions prove an unrelated HandlerError in templates, vars and vars_regexp does not start controlling request handling, plus integration tests for the template and vars_regexp 413 paths

ai assisted (GLM agent) under Abdel's direction and local verification

Signed-off-by: Abdel <hktitof@gmail.com>
2026-09-25 22:27:19 +10:00

409 lines
11 KiB
Go

// Copyright 2015 Matthew Holt and The Caddy Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package caddyhttp
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/caddyserver/caddy/v2"
)
func TestHTTPVarReplacement(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, "/foo/bar.tar.gz?a=1&b=2", nil)
repl := caddy.NewReplacer()
localAddr, _ := net.ResolveTCPAddr("tcp", "192.168.159.1:80")
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
ctx = context.WithValue(ctx, http.LocalAddrContextKey, localAddr)
req = req.WithContext(ctx)
req.Host = "example.com:80"
req.RemoteAddr = "192.168.159.32:1234"
clientCert := []byte(`-----BEGIN CERTIFICATE-----
MIIB9jCCAV+gAwIBAgIBAjANBgkqhkiG9w0BAQsFADAYMRYwFAYDVQQDDA1DYWRk
eSBUZXN0IENBMB4XDTE4MDcyNDIxMzUwNVoXDTI4MDcyMTIxMzUwNVowHTEbMBkG
A1UEAwwSY2xpZW50LmxvY2FsZG9tYWluMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCB
iQKBgQDFDEpzF0ew68teT3xDzcUxVFaTII+jXH1ftHXxxP4BEYBU4q90qzeKFneF
z83I0nC0WAQ45ZwHfhLMYHFzHPdxr6+jkvKPASf0J2v2HDJuTM1bHBbik5Ls5eq+
fVZDP8o/VHKSBKxNs8Goc2NTsr5b07QTIpkRStQK+RJALk4x9QIDAQABo0swSTAJ
BgNVHRMEAjAAMAsGA1UdDwQEAwIHgDAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8A
AAEwEwYDVR0lBAwwCgYIKwYBBQUHAwIwDQYJKoZIhvcNAQELBQADgYEANSjz2Sk+
eqp31wM9il1n+guTNyxJd+FzVAH+hCZE5K+tCgVDdVFUlDEHHbS/wqb2PSIoouLV
3Q9fgDkiUod+uIK0IynzIKvw+Cjg+3nx6NQ0IM0zo8c7v398RzB4apbXKZyeeqUH
9fNwfEi+OoXR6s+upSKobCmLGLGi9Na5s5g=
-----END CERTIFICATE-----`)
block, _ := pem.Decode(clientCert)
if block == nil {
t.Fatalf("failed to decode PEM certificate")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("failed to decode PEM certificate: %v", err)
}
req.TLS = &tls.ConnectionState{
Version: tls.VersionTLS13,
HandshakeComplete: true,
ServerName: "example.com",
CipherSuite: tls.TLS_AES_256_GCM_SHA384,
PeerCertificates: []*x509.Certificate{cert},
NegotiatedProtocol: "h2",
NegotiatedProtocolIsMutual: true,
}
res := httptest.NewRecorder()
addHTTPVarsToReplacer(repl, req, res)
for i, tc := range []struct {
get string
expect string
}{
{
get: "http.request.scheme",
expect: "https",
},
{
get: "http.request.method",
expect: http.MethodGet,
},
{
get: "http.request.host",
expect: "example.com",
},
{
get: "http.request.port",
expect: "80",
},
{
get: "http.request.hostport",
expect: "example.com:80",
},
{
get: "http.request.local.host",
expect: "192.168.159.1",
},
{
get: "http.request.local.port",
expect: "80",
},
{
get: "http.request.local",
expect: "192.168.159.1:80",
},
{
get: "http.request.remote.host",
expect: "192.168.159.32",
},
{
get: "http.request.remote.host/24",
expect: "192.168.159.0/24",
},
{
get: "http.request.remote.host/24,32",
expect: "192.168.159.0/24",
},
{
get: "http.request.remote.host/999",
expect: "",
},
{
get: "http.request.remote.port",
expect: "1234",
},
{
get: "http.request.host.labels.0",
expect: "com",
},
{
get: "http.request.host.labels.1",
expect: "example",
},
{
get: "http.request.host.labels.2",
expect: "",
},
{
get: "http.request.uri",
expect: "/foo/bar.tar.gz?a=1&b=2",
},
{
get: "http.request.uri_escaped",
expect: "%2Ffoo%2Fbar.tar.gz%3Fa%3D1%26b%3D2",
},
{
get: "http.request.uri.path",
expect: "/foo/bar.tar.gz",
},
{
get: "http.request.uri.path_escaped",
expect: "%2Ffoo%2Fbar.tar.gz",
},
{
get: "http.request.uri.path.file",
expect: "bar.tar.gz",
},
{
get: "http.request.uri.path.file.base",
expect: "bar.tar",
},
{
// not ideal, but also most correct, given that files can have dots (example: index.<SHA>.html) TODO: maybe this isn't right..
get: "http.request.uri.path.file.ext",
expect: ".gz",
},
{
get: "http.request.uri.query",
expect: "a=1&b=2",
},
{
get: "http.request.uri.query_escaped",
expect: "a%3D1%26b%3D2",
},
{
get: "http.request.uri.query.a",
expect: "1",
},
{
get: "http.request.uri.query.b",
expect: "2",
},
{
get: "http.request.uri.prefixed_query",
expect: "?a=1&b=2",
},
{
get: "http.request.tls.cipher_suite",
expect: "TLS_AES_256_GCM_SHA384",
},
{
get: "http.request.tls.proto",
expect: "h2",
},
{
get: "http.request.tls.proto_mutual",
expect: "true",
},
{
get: "http.request.tls.resumed",
expect: "false",
},
{
get: "http.request.tls.server_name",
expect: "example.com",
},
{
get: "http.request.tls.version",
expect: "tls1.3",
},
{
get: "http.request.tls.client.fingerprint",
expect: "9f57b7b497cceacc5459b76ac1c3afedbc12b300e728071f55f84168ff0f7702",
},
{
get: "http.request.tls.client.issuer",
expect: "CN=Caddy Test CA",
},
{
get: "http.request.tls.client.serial",
expect: "2",
},
{
get: "http.request.tls.client.subject",
expect: "CN=client.localdomain",
},
{
get: "http.request.tls.client.san.dns_names",
expect: "[localhost]",
},
{
get: "http.request.tls.client.san.dns_names.0",
expect: "localhost",
},
{
get: "http.request.tls.client.san.dns_names.1",
expect: "",
},
{
get: "http.request.tls.client.san.ips",
expect: "[127.0.0.1]",
},
{
get: "http.request.tls.client.san.ips.0",
expect: "127.0.0.1",
},
{
get: "http.request.tls.client.certificate_pem",
expect: string(clientCert) + "\n", // returned value comes with a newline appended to it
},
} {
actual, got := repl.GetString(tc.get)
if !got {
t.Errorf("Test %d: Expected to recognize the placeholder name, but didn't", i)
}
if actual != tc.expect {
t.Errorf("Test %d: Expected %s to be '%s' but got '%s'",
i, tc.get, tc.expect, actual)
}
}
}
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)
}
}
}
// BenchmarkAddHTTPVarsToReplacer measures the per-request replacer setup, which
// is the common path where the request UUID is never referenced.
func BenchmarkAddHTTPVarsToReplacer(b *testing.B) {
req := httptest.NewRequest(http.MethodGet, "http://example.com/foo?a=b", nil)
req.Header.Set("User-Agent", "test-agent")
ctx := context.WithValue(req.Context(), VarsCtxKey, make(map[string]any))
ctx = context.WithValue(ctx, ExtraLogFieldsCtxKey, new(ExtraLogFields))
req = req.WithContext(ctx)
b.ReportAllocs()
for b.Loop() {
repl := caddy.NewReplacer()
addHTTPVarsToReplacer(repl, req, nil)
}
}
// TestHTTPVarReplacementUUID verifies the lazily-allocated request UUID is
// generated on first access and stays stable across references.
func TestHTTPVarReplacementUUID(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
repl := caddy.NewReplacer()
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
ctx = context.WithValue(ctx, VarsCtxKey, make(map[string]any))
ctx = context.WithValue(ctx, ExtraLogFieldsCtxKey, new(ExtraLogFields))
req = req.WithContext(ctx)
addHTTPVarsToReplacer(repl, req, nil)
first, ok := repl.GetString("http.request.uuid")
if !ok || first == "" {
t.Fatalf("expected a non-empty uuid, got %q (ok=%t)", first, ok)
}
second, _ := repl.GetString("http.request.uuid")
if first != second {
t.Errorf("expected stable uuid across references: %q != %q", first, second)
}
}
// failingBodyReader serves some bytes and then returns a fixed error, to
// exercise the read failure path of the request body placeholders.
type failingBodyReader struct {
data []byte
pos int
err error
}
func (b *failingBodyReader) Read(p []byte) (int, error) {
if b.pos < len(b.data) {
n := copy(p, b.data[b.pos:])
b.pos += n
return n, nil
}
return 0, b.err
}
func (b *failingBodyReader) Close() error { return nil }
// TestRequestBodyPlaceholderErrorScoping verifies that only a max_size-driven
// handler error from the request body read becomes a RequestBodyLimitError,
// while every other error keeps the placeholder's old ignore-and-truncate
// behavior.
func TestRequestBodyPlaceholderErrorScoping(t *testing.T) {
maxBytes := &http.MaxBytesError{Limit: 10}
for _, tc := range []struct {
name string
readErr error
wantMarker bool
}{
{name: "max_size handler error becomes the marker", readErr: HandlerError{Err: maxBytes, StatusCode: http.StatusRequestEntityTooLarge}, wantMarker: true},
{name: "unrelated handler error keeps old behavior", readErr: HandlerError{Err: errors.New("boom"), StatusCode: http.StatusInternalServerError}},
{name: "raw max bytes error keeps old behavior", readErr: maxBytes},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Body = &failingBodyReader{data: []byte("abc"), err: tc.readErr}
body, err := readRequestBodyForPlaceholder(req)
if !tc.wantMarker {
if err != nil {
t.Fatalf("unrelated errors must be ignored like before but got %v", err)
}
if string(body) != "abc" {
t.Errorf("expected the truncated prefix %q but got %q", "abc", body)
}
return
}
if err == nil {
t.Fatal("expected the body limit marker but got none")
}
var marker RequestBodyLimitError
if !errors.As(err, &marker) {
t.Fatalf("expected RequestBodyLimitError but got %T", err)
}
var handlerErr HandlerError
if errors.As(err, &handlerErr) {
t.Error("the marker must not be detectable as a HandlerError")
}
if marker.StatusCode() != http.StatusRequestEntityTooLarge {
t.Errorf("expected status 413 but got %d", marker.StatusCode())
}
var maxErr *http.MaxBytesError
if !errors.As(err, &maxErr) {
t.Error("the marker must unwrap to the MaxBytesError")
}
})
}
}