@marcel91200 / caddy Mirror · read-only / commits / 4845150f
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 04:45 UTC

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>

Abdellatif Anaflous committed Sep 25, 2026 at 13:27 UTC 4845150fa663f9c854282dee7e74bd340a8f9d08
8 files changed +386 -25
caddytest/integration/handler_test.go
+83
@@ -57,3 +57,86 @@ func TestRespondWithJSON(t *testing.T) {
57 t.Errorf("expected Content-Type to be application/json, but was %s", res.Header.Get("Content-Type"))
58 }
59 }
60 +
61 +func TestRequestBodyPlaceholderRespectsMaxSizeInTemplate(t *testing.T) {
62 + tester := caddytest.NewTester(t)
63 + tester.InitServer(`
64 + {
65 + skip_install_trust
66 + admin localhost:2999
67 + http_port 9080
68 + https_port 9443
69 + grace_period 1ns
70 + }
71 + http://localhost:9080 {
72 + request_body {
73 + max_size 10
74 + }
75 + respond "{{placeholder \"http.request.body\"}}"
76 + templates
77 + }
78 + `, "caddyfile")
79 +
80 + req, err := http.NewRequest(http.MethodPost, "http://localhost:9080/", bytes.NewBufferString("abcdefghijklm"))
81 + if err != nil {
82 + t.Fatalf("creating request: %v", err)
83 + }
84 + res := tester.AssertResponseCode(req, http.StatusRequestEntityTooLarge)
85 + res.Body.Close()
86 +}
87 +
88 +func TestRequestBodyPlaceholderRespectsMaxSizeInVarsRegexp(t *testing.T) {
89 + tester := caddytest.NewTester(t)
90 + tester.InitServer(`
91 + {
92 + skip_install_trust
93 + admin localhost:2999
94 + http_port 9080
95 + https_port 9443
96 + grace_period 1ns
97 + }
98 + http://localhost:9080 {
99 + request_body {
100 + max_size 10
101 + }
102 + @bigbody {
103 + vars_regexp {http.request.body} "^.{20}"
104 + }
105 + handle @bigbody {
106 + respond "the body was too long matched"
107 + }
108 + respond "no match"
109 + }
110 + `, "caddyfile")
111 +
112 + req, err := http.NewRequest(http.MethodPost, "http://localhost:9080/", bytes.NewBufferString("abcdefghijklmnopqrstuvwxyz"))
113 + if err != nil {
114 + t.Fatalf("creating request: %v", err)
115 + }
116 + res := tester.AssertResponseCode(req, http.StatusRequestEntityTooLarge)
117 + res.Body.Close()
118 +}
119 +
120 +func TestRequestBodyPlaceholderDirectRespondExpansion(t *testing.T) {
121 + tester := caddytest.NewTester(t)
122 + tester.InitServer(`
123 + {
124 + skip_install_trust
125 + admin localhost:2999
126 + http_port 9080
127 + https_port 9443
128 + grace_period 1ns
129 + }
130 + http://localhost:9080 {
131 + request_body {
132 + max_size 10
133 + }
134 + respond "{http.request.body}"
135 + }
136 + `, "caddyfile")
137 +
138 + // direct placeholder consumers expand the sanitized request-body limit
139 + // marker to its text instead of failing the request with 413; only the
140 + // templates and vars matchers propagate the 413 status
141 + tester.AssertPostResponseBody("http://localhost:9080/", nil, bytes.NewBufferString("abcdefghijklm"), http.StatusOK, "request body: http: request body too large")
142 +}
modules/caddyhttp/app.go
+2 -2
@@ -59,8 +59,8 @@ func init() {
59 //
60 // Placeholder | Description
61 // ------------|---------------
62 -// `{http.request.body}` | The request body (⚠️ inefficient; use only for debugging)
63 -// `{http.request.body_base64}` | The request body, base64-encoded (⚠️ for debugging)
62 +// `{http.request.body}` | The request body (⚠️ inefficient; use only for debugging); if reading it exceeds a `request_body` `max_size` limit, templates and the `vars` and `vars_regexp` matchers fail the request with HTTP 413 instead of silently returning a truncated body
63 +// `{http.request.body_base64}` | The request body, base64-encoded (⚠️ for debugging); same 413 behavior on `max_size` limits as `{http.request.body}` in templates and the `vars` matchers
64 // `{http.request.cookie.*}` | HTTP request cookie
65 // `{http.request.duration}` | Time up to now spent handling the request (after decoding headers from client)
66 // `{http.request.duration_ms}` | Same as 'duration', but in milliseconds.
modules/caddyhttp/replacer.go
+74 -20
@@ -26,6 +26,7 @@ import (
26 "encoding/asn1"
27 "encoding/base64"
28 "encoding/pem"
29 + "errors"
30 "fmt"
31 "io"
32 "net"
@@ -231,31 +232,21 @@ func addHTTPVarsToReplacer(repl *caddy.Replacer, req *http.Request, w http.Respo
232 if req.Body == nil {
233 return "", true
234 }
234 - // normally net/http will close the body for us, but since we
235 - // are replacing it with a fake one, we have to ensure we close
236 - // the real body ourselves when we're done
237 - defer req.Body.Close()
238 - // read the request body into a buffer (can't pool because we
239 - // don't know its lifetime and would have to make a copy anyway)
240 - buf := new(bytes.Buffer)
241 - _, _ = io.Copy(buf, req.Body) // can't handle error, so just ignore it
242 - req.Body = io.NopCloser(buf) // replace real body with buffered data
243 - return buf.String(), true
235 + body, err := readRequestBodyForPlaceholder(req)
236 + if err != nil {
237 + return err, true
238 + }
239 + return string(body), true
240
241 case "http.request.body_base64":
242 if req.Body == nil {
243 return "", true
244 }
249 - // normally net/http will close the body for us, but since we
250 - // are replacing it with a fake one, we have to ensure we close
251 - // the real body ourselves when we're done
252 - defer req.Body.Close()
253 - // read the request body into a buffer (can't pool because we
254 - // don't know its lifetime and would have to make a copy anyway)
255 - buf := new(bytes.Buffer)
256 - _, _ = io.Copy(buf, req.Body) // can't handle error, so just ignore it
257 - req.Body = io.NopCloser(buf) // replace real body with buffered data
258 - return base64.StdEncoding.EncodeToString(buf.Bytes()), true
245 + body, err := readRequestBodyForPlaceholder(req)
246 + if err != nil {
247 + return err, true
248 + }
249 + return base64.StdEncoding.EncodeToString(body), true
250
251 // original request, before any internal changes
252 case "http.request.orig_method":
@@ -416,6 +407,69 @@ func addHTTPVarsToReplacer(repl *caddy.Replacer, req *http.Request, w http.Respo
407 repl.Map(httpVars)
408 }
409
410 +// RequestBodyLimitError is returned by the {http.request.body} and
411 +// {http.request.body_base64} placeholders when reading the request body
412 +// exceeded a request_body max_size limit. Placeholder consumers such as
413 +// templates and the vars matchers recognize exactly this condition and no
414 +// other error, so unrelated errors keep their existing behavior.
415 +type RequestBodyLimitError struct {
416 + Err error // the underlying reason, e.g. *http.MaxBytesError
417 +}
418 +
419 +// Error returns the reason the request body could not be read in full.
420 +func (e RequestBodyLimitError) Error() string {
421 + return fmt.Sprintf("request body: %v", e.Err)
422 +}
423 +
424 +// StatusCode returns 413 to match the request_body handler's response to an
425 +// oversized body.
426 +func (e RequestBodyLimitError) StatusCode() int {
427 + return http.StatusRequestEntityTooLarge
428 +}
429 +
430 +// Unwrap returns the underlying error value. See the `errors` package for info.
431 +func (e RequestBodyLimitError) Unwrap() error { return e.Err }
432 +
433 +// readRequestBodyForPlaceholder reads the request body into a buffer for the
434 +// {http.request.body} and {http.request.body_base64} placeholders. Usually the
435 +// body is copied verbatim and the buffered replacement is installed on req. If
436 +// the read fails because the request_body max_size limit was exceeded, the
437 +// request_body handler wraps the http.MaxBytesError into a HandlerError
438 +// carrying status 413; here that error is converted to a RequestBodyLimitError,
439 +// which the placeholder consumers recognize, instead of silently returning the
440 +// truncated prefix as though it were the complete body. Only a max_size-driven
441 +// handler error is converted; any other read error is still ignored, as it was
442 +// before, because the request is probably not going to be served normally
443 +// anyway and we want to keep the placeholder's behavior unchanged in that case.
444 +func readRequestBodyForPlaceholder(req *http.Request) ([]byte, error) {
445 + // normally net/http will close the body for us, but since we are replacing
446 + // it with a fake one, we have to ensure we close the real body ourselves
447 + defer req.Body.Close()
448 +
449 + // read the request body into a buffer (can't pool because we don't know its
450 + // lifetime and would have to make a copy anyway)
451 + buf := new(bytes.Buffer)
452 + _, err := io.Copy(buf, req.Body)
453 + if err != nil {
454 + var handlerErr HandlerError
455 + var maxBytes *http.MaxBytesError
456 + if errors.As(err, &handlerErr) && errors.As(handlerErr.Err, &maxBytes) {
457 + // return the dedicated body-limit marker carrying only the
458 + // MaxBytesError; the generated ID and stack trace of the handler
459 + // error are intentionally dropped so a placeholder stringified
460 + // into a response body (e.g. respond {http.request.body}) cannot
461 + // leak the call stack to the client. consumers recognize this
462 + // marker and no other error, so unrelated HandlerError values
463 + // keep their existing behavior.
464 + return nil, RequestBodyLimitError{Err: maxBytes}
465 + }
466 + }
467 +
468 + // replace the real body with the buffered data
469 + req.Body = io.NopCloser(buf)
470 + return buf.Bytes(), nil
471 +}
472 +
473 func getReqTLSReplacement(req *http.Request, key string) (any, bool) {
474 if req == nil || req.TLS == nil {
475 return nil, false
modules/caddyhttp/replacer_test.go
+71
@@ -19,6 +19,7 @@ import (
19 "crypto/tls"
20 "crypto/x509"
21 "encoding/pem"
22 + "errors"
23 "net"
24 "net/http"
25 "net/http/httptest"
@@ -335,3 +336,73 @@ func TestHTTPVarReplacementUUID(t *testing.T) {
336 }
337 }
338
339 +// failingBodyReader serves some bytes and then returns a fixed error, to
340 +// exercise the read failure path of the request body placeholders.
341 +type failingBodyReader struct {
342 + data []byte
343 + pos int
344 + err error
345 +}
346 +
347 +func (b *failingBodyReader) Read(p []byte) (int, error) {
348 + if b.pos < len(b.data) {
349 + n := copy(p, b.data[b.pos:])
350 + b.pos += n
351 + return n, nil
352 + }
353 + return 0, b.err
354 +}
355 +
356 +func (b *failingBodyReader) Close() error { return nil }
357 +
358 +// TestRequestBodyPlaceholderErrorScoping verifies that only a max_size-driven
359 +// handler error from the request body read becomes a RequestBodyLimitError,
360 +// while every other error keeps the placeholder's old ignore-and-truncate
361 +// behavior.
362 +func TestRequestBodyPlaceholderErrorScoping(t *testing.T) {
363 + maxBytes := &http.MaxBytesError{Limit: 10}
364 + for _, tc := range []struct {
365 + name string
366 + readErr error
367 + wantMarker bool
368 + }{
369 + {name: "max_size handler error becomes the marker", readErr: HandlerError{Err: maxBytes, StatusCode: http.StatusRequestEntityTooLarge}, wantMarker: true},
370 + {name: "unrelated handler error keeps old behavior", readErr: HandlerError{Err: errors.New("boom"), StatusCode: http.StatusInternalServerError}},
371 + {name: "raw max bytes error keeps old behavior", readErr: maxBytes},
372 + } {
373 + t.Run(tc.name, func(t *testing.T) {
374 + req := httptest.NewRequest(http.MethodPost, "/", nil)
375 + req.Body = &failingBodyReader{data: []byte("abc"), err: tc.readErr}
376 +
377 + body, err := readRequestBodyForPlaceholder(req)
378 +
379 + if !tc.wantMarker {
380 + if err != nil {
381 + t.Fatalf("unrelated errors must be ignored like before but got %v", err)
382 + }
383 + if string(body) != "abc" {
384 + t.Errorf("expected the truncated prefix %q but got %q", "abc", body)
385 + }
386 + return
387 + }
388 + if err == nil {
389 + t.Fatal("expected the body limit marker but got none")
390 + }
391 + var marker RequestBodyLimitError
392 + if !errors.As(err, &marker) {
393 + t.Fatalf("expected RequestBodyLimitError but got %T", err)
394 + }
395 + var handlerErr HandlerError
396 + if errors.As(err, &handlerErr) {
397 + t.Error("the marker must not be detectable as a HandlerError")
398 + }
399 + if marker.StatusCode() != http.StatusRequestEntityTooLarge {
400 + t.Errorf("expected status 413 but got %d", marker.StatusCode())
401 + }
402 + var maxErr *http.MaxBytesError
403 + if !errors.As(err, &maxErr) {
404 + t.Error("the marker must unwrap to the MaxBytesError")
405 + }
406 + })
407 + }
408 +}
modules/caddyhttp/templates/tplcontext.go
+17 -3
@@ -16,6 +16,7 @@ package templates
16
17 import (
18 "bytes"
19 + "errors"
20 "fmt"
21 "io"
22 "io/fs"
@@ -250,7 +251,7 @@ func (c *TemplateContext) executeTemplateInBuffer(tplName string, buf *bytes.Buf
251 return c.tpl.Execute(buf, c)
252 }
253
253 -func (c TemplateContext) funcPlaceholder(name string) string {
254 +func (c TemplateContext) funcPlaceholder(name string) (string, error) {
255 repl := c.Req.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
256
257 // For safety, we don't want to allow the file placeholder in
@@ -258,8 +259,21 @@ func (c TemplateContext) funcPlaceholder(name string) string {
259 // if the template contents were not trusted.
260 repl = repl.WithoutFile()
261
261 - value, _ := repl.GetString(name)
262 - return value
262 + value, _ := repl.Get(name)
263 +
264 + // propagate the request-body limit marker (a 413 from the request_body
265 + // max_size limit when {http.request.body} is truncated) so the template
266 + // aborts with that status rather than rendering a silently-truncated
267 + // body; the marker is wrapped in a status-carrying handler error so the
268 + // server renders the 413, and any other value, including unrelated
269 + // errors, is converted to a string as before
270 + if err, ok := value.(error); ok {
271 + var bodyLimit caddyhttp.RequestBodyLimitError
272 + if errors.As(err, &bodyLimit) {
273 + return "", caddyhttp.HandlerError{Err: bodyLimit, StatusCode: bodyLimit.StatusCode()}
274 + }
275 + }
276 + return caddy.ToString(value), nil
277 }
278
279 func (TemplateContext) funcEnv(varName string) string {
modules/caddyhttp/templates/tplcontext_test.go
+51
@@ -21,6 +21,7 @@ import (
21 "fmt"
22 "io/fs"
23 "net/http"
24 + "net/http/httptest"
25 "os"
26 "path/filepath"
27 "reflect"
@@ -29,6 +30,7 @@ import (
30 "testing"
31 "time"
32
33 + "github.com/caddyserver/caddy/v2"
34 "github.com/caddyserver/caddy/v2/modules/caddyhttp"
35 )
36
@@ -708,3 +710,52 @@ func initTestContext() (TemplateContext, error) {
710 RespHeader: WrappedHeader{make(http.Header)},
711 }, nil
712 }
713 +// TestFuncPlaceholderPropagatesOnlyBodyLimitMarker verifies that the template
714 +// placeholder function aborts only on the request-body limit marker and keeps
715 +// rendering unrelated errors as text, exactly like before.
716 +func TestFuncPlaceholderPropagatesOnlyBodyLimitMarker(t *testing.T) {
717 + for _, tc := range []struct {
718 + name string
719 + value any
720 + wantAbort bool
721 + }{
722 + {name: "body limit marker aborts the template", value: caddyhttp.RequestBodyLimitError{Err: &http.MaxBytesError{Limit: 10}}, wantAbort: true},
723 + {name: "unrelated handler error renders as text like before", value: caddyhttp.HandlerError{Err: errors.New("boom"), StatusCode: http.StatusInternalServerError}},
724 + } {
725 + t.Run(tc.name, func(t *testing.T) {
726 + repl := caddy.NewReplacer()
727 + repl.Map(func(name string) (any, bool) {
728 + if name == "http.request.body" {
729 + return tc.value, true
730 + }
731 + return nil, false
732 + })
733 + req := httptest.NewRequest(http.MethodPost, "/", nil)
734 + req = req.WithContext(context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl))
735 + c := TemplateContext{Req: req}
736 +
737 + out, err := c.funcPlaceholder("http.request.body")
738 +
739 + if tc.wantAbort {
740 + var marker caddyhttp.RequestBodyLimitError
741 + if err == nil || !errors.As(err, &marker) {
742 + t.Fatalf("expected the body limit marker but got %q, %v", out, err)
743 + }
744 + handlerErr, ok := err.(caddyhttp.HandlerError)
745 + if !ok || handlerErr.StatusCode != http.StatusRequestEntityTooLarge {
746 + t.Errorf("expected a 413 handler error carrying the marker but got %v", err)
747 + }
748 + if out != "" {
749 + t.Errorf("expected an empty string on abort but got %q", out)
750 + }
751 + return
752 + }
753 + if err != nil {
754 + t.Fatalf("unrelated handler errors must render as text like before but got %v", err)
755 + }
756 + if !strings.Contains(out, "boom") {
757 + t.Errorf("expected the error text in the output but got %q", out)
758 + }
759 + })
760 + }
761 +}
modules/caddyhttp/vars.go
+21
@@ -16,6 +16,7 @@ package caddyhttp
16
17 import (
18 "context"
19 + "errors"
20 "fmt"
21 "net/http"
22 "reflect"
@@ -198,6 +199,16 @@ func (m VarsMatcher) MatchWithError(r *http.Request) (bool, error) {
199 case fmt.Stringer:
200 varStr = vv.String()
201 case error:
202 + // propagate the request-body limit marker (a 413 from the
203 + // request_body max_size limit when {http.request.body} is
204 + // truncated) instead of matching on its error text; unrelated
205 + // errors are matched on their text as before
206 + var bodyLimit RequestBodyLimitError
207 + if errors.As(vv, &bodyLimit) {
208 + // wrap in a status-carrying handler error so the server
209 + // renders the 413
210 + return false, HandlerError{Err: bodyLimit, StatusCode: bodyLimit.StatusCode()}
211 + }
212 varStr = vv.Error()
213 case nil:
214 varStr = ""
@@ -335,6 +346,16 @@ func (m MatchVarsRE) MatchWithError(r *http.Request) (bool, error) {
346 case fmt.Stringer:
347 varStr = vv.String()
348 case error:
349 + // propagate the request-body limit marker (a 413 from the
350 + // request_body max_size limit when {http.request.body} is
351 + // truncated) instead of matching on its error text; unrelated
352 + // errors are matched on their text as before
353 + var bodyLimit RequestBodyLimitError
354 + if errors.As(vv, &bodyLimit) {
355 + // wrap in a status-carrying handler error so the server
356 + // renders the 413
357 + return false, HandlerError{Err: bodyLimit, StatusCode: bodyLimit.StatusCode()}
358 + }
359 varStr = vv.Error()
360 case nil:
361 varStr = ""
modules/caddyhttp/vars_test.go
+67
@@ -16,6 +16,7 @@ package caddyhttp
16
17 import (
18 "context"
19 + "errors"
20 "net/http"
21 "net/http/httptest"
22 "testing"
@@ -157,3 +158,69 @@ func TestMatchVarsREDoesNotExpandResolvedValues(t *testing.T) {
158 })
159 }
160 }
161 +// TestVarsMatchersTreatErrorsByType verifies the error handling split in the
162 +// vars matchers: only the request-body limit marker aborts request handling,
163 +// while unrelated errors are matched on their text exactly like before.
164 +func TestVarsMatchersTreatErrorsByType(t *testing.T) {
165 + unrelated := HandlerError{Err: errors.New("boom"), StatusCode: http.StatusInternalServerError}
166 + bodyLimit := RequestBodyLimitError{Err: &http.MaxBytesError{Limit: 10}}
167 +
168 + t.Run("vars matcher matches unrelated errors on text like before", func(t *testing.T) {
169 + req, _ := newVarsTestRequest(t, "", nil, map[string]any{"k": unrelated})
170 + matched, err := (VarsMatcher{"k": []string{unrelated.Error()}}).MatchWithError(req)
171 + if err != nil {
172 + t.Fatalf("unrelated handler errors must not control request handling but got %v", err)
173 + }
174 + if !matched {
175 + t.Error("expected a text match like before")
176 + }
177 + })
178 +
179 + t.Run("vars regexp matcher matches unrelated errors on text like before", func(t *testing.T) {
180 + req, _ := newVarsTestRequest(t, "", nil, map[string]any{"k": unrelated})
181 + re := MatchVarsRE{"k": &MatchRegexp{Pattern: "^.*HTTP 500.*$"}}
182 + if err := re.Provision(caddy.Context{}); err != nil {
183 + t.Fatalf("provisioning the regexp: %v", err)
184 + }
185 + matched, err := re.MatchWithError(req)
186 + if err != nil {
187 + t.Fatalf("unrelated handler errors must not control request handling but got %v", err)
188 + }
189 + if !matched {
190 + t.Error("expected the error text to match the regexp like before")
191 + }
192 + })
193 +
194 + t.Run("body limit marker is the only propagated error", func(t *testing.T) {
195 + req, _ := newVarsTestRequest(t, "", nil, map[string]any{"k": bodyLimit})
196 +
197 + matched, err := (VarsMatcher{"k": []string{"x"}}).MatchWithError(req)
198 + var marker RequestBodyLimitError
199 + if err == nil || !errors.As(err, &marker) {
200 + t.Fatalf("expected the body limit marker from the vars matcher but got %v", err)
201 + }
202 + handlerErr, ok := err.(HandlerError)
203 + if !ok || handlerErr.StatusCode != http.StatusRequestEntityTooLarge {
204 + t.Errorf("expected a 413 handler error carrying the marker but got %v", err)
205 + }
206 + if matched {
207 + t.Error("no vars match should happen when the marker aborts")
208 + }
209 +
210 + re := MatchVarsRE{"k": &MatchRegexp{Pattern: ".*"}}
211 + if err := re.Provision(caddy.Context{}); err != nil {
212 + t.Fatalf("provisioning the regexp: %v", err)
213 + }
214 + matched, err = re.MatchWithError(req)
215 + if err == nil || !errors.As(err, &marker) {
216 + t.Fatalf("expected the body limit marker from the regexp matcher too but got %v", err)
217 + }
218 + handlerErr, ok = err.(HandlerError)
219 + if !ok || handlerErr.StatusCode != http.StatusRequestEntityTooLarge {
220 + t.Errorf("expected a 413 handler error carrying the marker but got %v", err)
221 + }
222 + if matched {
223 + t.Error("no regexp match should happen when the marker aborts")
224 + }
225 + })
226 +}