rewrite: prevent placeholder re-expansion in injected query (#7761)
Some checks failed
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Has been cancelled
Tests / test (s390x on IBM Z) (push) Has been cancelled
Tests / goreleaser-check (push) Has been cancelled
Cross-Build / build (~1.26.0, 1.26, aix) (push) Has been cancelled
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Has been cancelled
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Has been cancelled
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Has been cancelled
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Has been cancelled
Cross-Build / build (~1.26.0, 1.26, linux) (push) Has been cancelled
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Has been cancelled
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Has been cancelled
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Has been cancelled
Cross-Build / build (~1.26.0, 1.26, windows) (push) Has been cancelled
Lint / lint (push) Has been cancelled
Lint / lint-1 (push) Has been cancelled
Lint / lint-2 (push) Has been cancelled
Lint / govulncheck (push) Has been cancelled
Lint / dependency-review (push) Has been cancelled
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled

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>
This commit is contained in:
Lohit 2026-05-27 04:21:18 +05:30 committed by GitHub
parent 4c04143261
commit 176b043b01
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 45 additions and 0 deletions

View file

@ -223,6 +223,15 @@ func (rewr Rewrite) Rewrite(r *http.Request, repl *caddy.Replacer) bool {
newPath, injectedQuery = before, after
// don't overwrite explicitly-configured query string
if query == "" {
// the injected query came from the first-pass placeholder
// expansion above, which means any '{' or '}' bytes in it
// must have come from replacement values (e.g. a request
// header), not from operator-written placeholder syntax.
// escape them so buildQueryString does not re-expand them,
// which would allow attacker input like {env.SECRET} to be
// evaluated (see GHSA-j8px-rmrx-76h9).
injectedQuery = strings.ReplaceAll(injectedQuery, "{", "%7B")
injectedQuery = strings.ReplaceAll(injectedQuery, "}", "%7D")
query = injectedQuery
}
}

View file

@ -18,6 +18,7 @@ import (
"net/http"
"reflect"
"regexp"
"strings"
"testing"
"github.com/caddyserver/caddy/v2"
@ -350,6 +351,32 @@ func TestRewrite(t *testing.T) {
input: newRequest(t, "GET", "/foo//bar///baz?a=b//c"),
expect: newRequest(t, "GET", "/foo/bar/baz?a=b//c"),
},
// regression tests for GHSA-j8px-rmrx-76h9: when the rewrite URI
// ends with a literal '?', the first-pass placeholder expansion
// may produce a path containing attacker-controlled bytes that
// then get split at '?' and fed into buildQueryString, which runs
// a SECOND placeholder pass. Bytes injected via a header value (or
// any other client-controlled placeholder) must not be treated as
// placeholder syntax during this second pass.
{
// literal header value containing placeholder syntax is not re-expanded into query
rule: Rewrite{URI: "/serve/{http.request.header.X-Fwd}?"},
input: newRequestWithHeader(t, "GET", "/anything", "X-Fwd", "foo?{env.CADDY_REWRITE_TEST_SECRET}=leak"),
expect: newRequest(t, "GET", "/serve/foo?%7Benv.CADDY_REWRITE_TEST_SECRET%7D=leak"),
},
{
// literal header value with placeholder syntax in query position is not re-expanded
rule: Rewrite{URI: "/serve/{http.request.header.X-Fwd}?"},
input: newRequestWithHeader(t, "GET", "/anything", "X-Fwd", "ok?key={env.CADDY_REWRITE_TEST_SECRET}"),
expect: newRequest(t, "GET", "/serve/ok?key=%7Benv.CADDY_REWRITE_TEST_SECRET%7D"),
},
{
// literal header value with embedded file placeholder is not re-expanded
rule: Rewrite{URI: "/serve/{http.request.header.X-Fwd}?"},
input: newRequestWithHeader(t, "GET", "/anything", "X-Fwd", "ok?path={file./etc/passwd}"),
expect: newRequest(t, "GET", "/serve/ok?path=%7Bfile./etc/passwd%7D"),
},
} {
// copy the original input just enough so that we can
// compare it after the rewrite to see if it changed
@ -364,6 +391,9 @@ func TestRewrite(t *testing.T) {
repl.Set("http.request.uri", tc.input.RequestURI)
repl.Set("http.request.uri.path", tc.input.URL.Path)
repl.Set("http.request.uri.query", tc.input.URL.RawQuery)
for field, vals := range tc.input.Header {
repl.Set("http.request.header."+field, strings.Join(vals, ","))
}
// we can't directly call Provision() without a valid caddy.Context
// (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 {
return req
}
func newRequestWithHeader(t *testing.T, method, uri, headerKey, headerVal string) *http.Request {
req := newRequest(t, method, uri)
req.Header.Set(headerKey, headerVal)
return req
}
// reqEqual if r1 and r2 are equal enough for our purposes.
func reqEqual(r1, r2 *http.Request) bool {
if r1.Method != r2.Method {