@marcel91200 / caddy Mirror · read-only / commits / 176b043b
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, 09:45 UTC

rewrite: prevent placeholder re-expansion in injected query (#7761)

When the rewrite URI template ends with a literal '?' and contains a placeholder that expands to client-controlled bytes (e.g. {http.request.header.X-Fwd}), those bytes flow into buildQueryString which runs a second Replacer pass. If the bytes contain placeholder syntax such as {env.SECRET}, that placeholder is evaluated, allowing disclosure of environment variables, files (via {file./path}), or internal request vars through the rewritten request URI. Escape '{' and '}' in the injected query before assigning it to the query variable, so the second pass cannot find any placeholder syntax to evaluate. Operator-written placeholders in the rewrite template are already expanded by the first pass on the path component, so the only '{' or '}' surviving into the injected query must have come from replacement values. Fixes GHSA-j8px-rmrx-76h9. Includes three regression tests mirroring the 'is not re-expanded' tests in modules/caddyhttp/vars_test.go. Co-authored-by: Matt Holt <mholt@users.noreply.github.com>

Lohit committed May 27, 2026 at 04:21 UTC 176b043b0104cee3f894023cd5a598ac29e404bb
2 files changed +45
modules/caddyhttp/rewrite/rewrite.go
+9
@@ -223,6 +223,15 @@ func (rewr Rewrite) Rewrite(r *http.Request, repl *caddy.Replacer) bool {
223 newPath, injectedQuery = before, after
224 // don't overwrite explicitly-configured query string
225 if query == "" {
226 + // the injected query came from the first-pass placeholder
227 + // expansion above, which means any '{' or '}' bytes in it
228 + // must have come from replacement values (e.g. a request
229 + // header), not from operator-written placeholder syntax.
230 + // escape them so buildQueryString does not re-expand them,
231 + // which would allow attacker input like {env.SECRET} to be
232 + // evaluated (see GHSA-j8px-rmrx-76h9).
233 + injectedQuery = strings.ReplaceAll(injectedQuery, "{", "%7B")
234 + injectedQuery = strings.ReplaceAll(injectedQuery, "}", "%7D")
235 query = injectedQuery
236 }
237 }
modules/caddyhttp/rewrite/rewrite_test.go
+36
@@ -18,6 +18,7 @@ import (
18 "net/http"
19 "reflect"
20 "regexp"
21 + "strings"
22 "testing"
23
24 "github.com/caddyserver/caddy/v2"
@@ -350,6 +351,32 @@ func TestRewrite(t *testing.T) {
351 input: newRequest(t, "GET", "/foo//bar///baz?a=b//c"),
352 expect: newRequest(t, "GET", "/foo/bar/baz?a=b//c"),
353 },
354 +
355 + // regression tests for GHSA-j8px-rmrx-76h9: when the rewrite URI
356 + // ends with a literal '?', the first-pass placeholder expansion
357 + // may produce a path containing attacker-controlled bytes that
358 + // then get split at '?' and fed into buildQueryString, which runs
359 + // a SECOND placeholder pass. Bytes injected via a header value (or
360 + // any other client-controlled placeholder) must not be treated as
361 + // placeholder syntax during this second pass.
362 + {
363 + // literal header value containing placeholder syntax is not re-expanded into query
364 + rule: Rewrite{URI: "/serve/{http.request.header.X-Fwd}?"},
365 + input: newRequestWithHeader(t, "GET", "/anything", "X-Fwd", "foo?{env.CADDY_REWRITE_TEST_SECRET}=leak"),
366 + expect: newRequest(t, "GET", "/serve/foo?%7Benv.CADDY_REWRITE_TEST_SECRET%7D=leak"),
367 + },
368 + {
369 + // literal header value with placeholder syntax in query position is not re-expanded
370 + rule: Rewrite{URI: "/serve/{http.request.header.X-Fwd}?"},
371 + input: newRequestWithHeader(t, "GET", "/anything", "X-Fwd", "ok?key={env.CADDY_REWRITE_TEST_SECRET}"),
372 + expect: newRequest(t, "GET", "/serve/ok?key=%7Benv.CADDY_REWRITE_TEST_SECRET%7D"),
373 + },
374 + {
375 + // literal header value with embedded file placeholder is not re-expanded
376 + rule: Rewrite{URI: "/serve/{http.request.header.X-Fwd}?"},
377 + input: newRequestWithHeader(t, "GET", "/anything", "X-Fwd", "ok?path={file./etc/passwd}"),
378 + expect: newRequest(t, "GET", "/serve/ok?path=%7Bfile./etc/passwd%7D"),
379 + },
380 } {
381 // copy the original input just enough so that we can
382 // compare it after the rewrite to see if it changed
@@ -364,6 +391,9 @@ func TestRewrite(t *testing.T) {
391 repl.Set("http.request.uri", tc.input.RequestURI)
392 repl.Set("http.request.uri.path", tc.input.URL.Path)
393 repl.Set("http.request.uri.query", tc.input.URL.RawQuery)
394 + for field, vals := range tc.input.Header {
395 + repl.Set("http.request.header."+field, strings.Join(vals, ","))
396 + }
397
398 // we can't directly call Provision() without a valid caddy.Context
399 // (TODO: fix that) so here we ad-hoc compile the regex
@@ -456,6 +486,12 @@ func newRequest(t *testing.T, method, uri string) *http.Request {
486 return req
487 }
488
489 +func newRequestWithHeader(t *testing.T, method, uri, headerKey, headerVal string) *http.Request {
490 + req := newRequest(t, method, uri)
491 + req.Header.Set(headerKey, headerVal)
492 + return req
493 +}
494 +
495 // reqEqual if r1 and r2 are equal enough for our purposes.
496 func reqEqual(r1, r2 *http.Request) bool {
497 if r1.Method != r2.Method {