@marcel91200 / caddy Mirror · read-only / commits / 39af0aec
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, 07:15 UTC

rewrite: fix URI splitting when a query or fragment arrives via a placeholder (#7947)

* rewrite: don't drop a trailing '=' from the query string buildQueryString scanned for '=' unconditionally when looking for the end of a component, but '=' only delimits a key from its value once; any further '=' bytes are literal data. When the query ended with '=', that byte was consumed as a delimiter with nothing following it to re-emit, so it was silently lost: ?x=1&sig=YWJjZA== => ?x=1&sig=YWJjZA= This corrupts base64 padding in the last query parameter, which is the shape of an S3 presigned URL (X-Amz-Signature), turning a valid signature into a 403. Only the final '=' of the query was affected; ?sig=YWJjZA==&x=1 came through intact. Disable the '=' search while consuming a value so that only '&' ends it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * rewrite: honor a query string injected by a replacement value Which URI components get written back was decided from the literal config string, before placeholders were expanded, but an injected query is only detected after expansion. The two were never reconciled: for `rewrite * {rp.header.X-Accel-Redirect}` there is no literal '?', so qsStart stayed -1, and the correctly-built query string was computed and then discarded by the `if qsStart >= 0` guard. Only half the split was applied. The path was still truncated at the injected '?', so the query was not preserved either -- it was dropped, and any query already on the request survived in its place: GET /orig?keep=me, X-Accel-Redirect: /hello?some=param => /hello?keep=me Track whether a query was actually injected and include that in the write-back condition. Appending a literal '?' to the rewrite value was the known workaround precisely because it set qsStart; that keeps working and is now unnecessary. The flag is only set where the injected query is adopted, so an explicitly configured query still wins, and a value with no '?' still leaves the query untouched -- which is what the implicit rewrites of try_files and php_fastcgi rely on. Those stay safe regardless, since escapePathPlaceholders already escapes the two placeholders they use, so a client-supplied %3F cannot split the URI. Fixes #5208 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * rewrite: drop a fragment injected by a replacement value The scan that separates path, query and fragment runs on the literal config string, so a '#' arriving later via a replacement value was never treated as a delimiter. It leaked into whichever component it landed in: X-Accel-Redirect: /hello?p=x#frag => RawQuery = "p=x#frag" Everything after '#' is fragment (RFC 3986 section 4.2) and a fragment is never sent to the server, so drop it before the path is split, mirroring how the scan already handles a literal '#'. An escaped %23 is unaffected, so a real '#' in a path or query is still expressible, and a configured fragment still wins over an injected one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

Francis Lavoie committed Aug 25, 2026 at 14:06 UTC 39af0aec318cd2d07c6e920055565f238c7f4830
2 files changed +121 -1
modules/caddyhttp/rewrite/rewrite.go
+25 -1
@@ -215,8 +215,19 @@ func (rewr Rewrite) Rewrite(r *http.Request, repl *caddy.Replacer) bool {
215 newPath = repl.ReplaceAll(path, "")
216 }
217
218 + // a fragment may have snuck into the path component during
219 + // replacements; everything after the first '#' is fragment
220 + // (RFC 3986 section 4.2), which is never sent to the server,
221 + // so drop it. this mirrors how a literal '#' in the configured
222 + // URI is handled by the scan above, and prevents the fragment
223 + // from being mistaken for part of the path or query below.
224 + if before, _, found := strings.Cut(newPath, "#"); found {
225 + newPath = before
226 + }
227 +
228 // before continuing, we need to check if a query string
229 // snuck into the path component during replacements
230 + queryInjected := false
231 if before, after, found := strings.Cut(newPath, "?"); found {
232 // recompute; new path contains a query string
233 var injectedQuery string
@@ -233,6 +244,13 @@ func (rewr Rewrite) Rewrite(r *http.Request, repl *caddy.Replacer) bool {
244 injectedQuery = strings.ReplaceAll(injectedQuery, "{", "%7B")
245 injectedQuery = strings.ReplaceAll(injectedQuery, "}", "%7D")
246 query = injectedQuery
247 +
248 + // the replacement value spoke about the query string, so the
249 + // query must be written back even though the configured URI
250 + // had no literal '?' to set qsStart. an injected query that
251 + // is empty (a value ending in '?') clears the query, which is
252 + // consistent with configuring a bare '?'.
253 + queryInjected = true
254 }
255 }
256
@@ -253,7 +271,7 @@ func (rewr Rewrite) Rewrite(r *http.Request, repl *caddy.Replacer) bool {
271 }
272 r.URL.RawPath = "" // force recomputing when EscapedPath() is called
273 }
256 - if qsStart >= 0 {
274 + if qsStart >= 0 || queryInjected {
275 r.URL.RawQuery = newQuery
276 }
277 if fragStart >= 0 {
@@ -344,6 +362,12 @@ func buildQueryString(qs string, repl *caddy.Replacer) string {
362 // determine the end of this component, which will be at
363 // the next equal sign or ampersand, whichever comes first
364 nextEq, nextAmp := strings.Index(qs, "="), strings.Index(qs, "&")
365 + if !wroteVal {
366 + // we are consuming a value, and '=' only delimits a key from
367 + // its value; any further '=' bytes are literal data, such as
368 + // base64 padding in a signature. only '&' ends a value.
369 + nextEq = -1
370 + }
371 ampIsNext := nextAmp >= 0 && (nextAmp < nextEq || nextEq < 0)
372 end := len(qs) // assume no delimiter remains...
373 if ampIsNext {
modules/caddyhttp/rewrite/rewrite_test.go
+96
@@ -396,6 +396,102 @@ func TestRewrite(t *testing.T) {
396 input: newRequestWithHeader(t, "GET", "/anything", "X-Fwd", "ok?path={file./etc/passwd}"),
397 expect: newRequest(t, "GET", "/serve/ok?path=%7Bfile./etc/passwd%7D"),
398 },
399 +
400 + // '=' delimits a key from its value only once; any further '=' bytes
401 + // are literal data, such as base64 padding in a signature.
402 + {
403 + rule: Rewrite{URI: "?sig=YWJjZA=="},
404 + input: newRequest(t, "GET", "/hello"),
405 + expect: newRequest(t, "GET", "/hello?sig=YWJjZA=="),
406 + },
407 + {
408 + rule: Rewrite{URI: "?x=1&sig=YWJjZA=="},
409 + input: newRequest(t, "GET", "/hello"),
410 + expect: newRequest(t, "GET", "/hello?x=1&sig=YWJjZA=="),
411 + },
412 +
413 + // a query string that arrives via a replacement value is honored even
414 + // though the configured URI has no literal '?' (see issue #5208).
415 + {
416 + rule: Rewrite{URI: "{http.request.header.X-Accel-Redirect}"},
417 + input: newRequestWithHeader(t, "GET", "/orig", "X-Accel-Redirect", "/hello?some=param&some_other=param"),
418 + expect: newRequest(t, "GET", "/hello?some=param&some_other=param"),
419 + },
420 + {
421 + // an injected query replaces the original one, as a configured query would
422 + rule: Rewrite{URI: "{http.request.header.X-Accel-Redirect}"},
423 + input: newRequestWithHeader(t, "GET", "/orig?keep=me", "X-Accel-Redirect", "/hello?some=param"),
424 + expect: newRequest(t, "GET", "/hello?some=param"),
425 + },
426 + {
427 + // a value ending in '?' clears the query, same as configuring a bare '?'
428 + rule: Rewrite{URI: "{http.request.header.X-Accel-Redirect}"},
429 + input: newRequestWithHeader(t, "GET", "/orig?keep=me", "X-Accel-Redirect", "/hello?"),
430 + expect: newRequest(t, "GET", "/hello"),
431 + },
432 + {
433 + // no '?' in the value means the query is not touched
434 + rule: Rewrite{URI: "{http.request.header.X-Accel-Redirect}"},
435 + input: newRequestWithHeader(t, "GET", "/orig?keep=me", "X-Accel-Redirect", "/hello"),
436 + expect: newRequest(t, "GET", "/hello?keep=me"),
437 + },
438 + {
439 + // an explicitly configured query still wins over an injected one
440 + rule: Rewrite{URI: "{http.request.header.X-Accel-Redirect}?a=b"},
441 + input: newRequestWithHeader(t, "GET", "/orig", "X-Accel-Redirect", "/hello?some=param"),
442 + expect: newRequest(t, "GET", "/hello?a=b"),
443 + },
444 + {
445 + // an escaped '?' in the request path does not split the URI, so the
446 + // implicit rewrites of try_files and php_fastcgi stay safe
447 + rule: Rewrite{URI: "{http.request.uri.path}"},
448 + input: newRequest(t, "GET", "/bar%3Fbaz?keep=me"),
449 + expect: newRequest(t, "GET", "/bar%3Fbaz?keep=me"),
450 + },
451 + {
452 + // an S3 presigned URL arriving via a replacement value survives intact
453 + rule: Rewrite{URI: "{http.request.header.X-Ph}"},
454 + input: newRequestWithHeader(t, "GET", "/orig", "X-Ph", "/f.jpg?X-Amz-Credential=AK%2Ffr-par%2Fs3&X-Amz-Signature=YWJjZA=="),
455 + expect: newRequest(t, "GET", "/f.jpg?X-Amz-Credential=AK%2Ffr-par%2Fs3&X-Amz-Signature=YWJjZA=="),
456 + },
457 +
458 + // a fragment that arrives via a replacement value is dropped: everything
459 + // after '#' is fragment (RFC 3986 section 4.2) and is never sent to the
460 + // server, so it must not leak into the path or query.
461 + {
462 + rule: Rewrite{URI: "{http.request.header.X-Ph}"},
463 + input: newRequestWithHeader(t, "GET", "/orig", "X-Ph", "/hello#frag"),
464 + expect: newRequest(t, "GET", "/hello"),
465 + },
466 + {
467 + rule: Rewrite{URI: "{http.request.header.X-Ph}"},
468 + input: newRequestWithHeader(t, "GET", "/orig", "X-Ph", "/hello?a=b#frag"),
469 + expect: newRequest(t, "GET", "/hello?a=b"),
470 + },
471 + {
472 + // a '?' after the injected '#' is fragment, not a query delimiter
473 + rule: Rewrite{URI: "{http.request.header.X-Ph}"},
474 + input: newRequestWithHeader(t, "GET", "/orig", "X-Ph", "/hello#frag?notquery"),
475 + expect: newRequest(t, "GET", "/hello"),
476 + },
477 + {
478 + // a configured fragment still wins over an injected one
479 + rule: Rewrite{URI: "{http.request.header.X-Ph}#cfgfrag"},
480 + input: newRequestWithHeader(t, "GET", "/orig", "X-Ph", "/hello?a=b#frag"),
481 + expect: newRequest(t, "GET", "/hello?a=b#cfgfrag"),
482 + },
483 + {
484 + // an escaped '#' is not a fragment delimiter and is preserved
485 + rule: Rewrite{URI: "{http.request.header.X-Ph}"},
486 + input: newRequestWithHeader(t, "GET", "/orig", "X-Ph", "/hello%23frag"),
487 + expect: newRequest(t, "GET", "/hello%23frag"),
488 + },
489 + {
490 + // a fragment-only value leaves the original query alone
491 + rule: Rewrite{URI: "{http.request.header.X-Ph}"},
492 + input: newRequestWithHeader(t, "GET", "/orig?keep=me", "X-Ph", "/hello#frag"),
493 + expect: newRequest(t, "GET", "/hello?keep=me"),
494 + },
495 } {
496 // copy the original input just enough so that we can
497 // compare it after the rewrite to see if it changed