rewrite: fix strip_path_suffix ignoring percent-encoding (#7877)

StripPathSuffix is documented to behave like StripPathPrefix: the suffix
is matched in normalized (unescaped) space except where the pattern uses
an escape sequence. But suffix stripping was implemented as

    reverse(trimPathPrefix(reverse(escapedPath), reverse(suffix)))

Reversing the strings moves the '%' to the *end* of each "%xx" escape,
which defeats trimPathPrefix's escape detection (it expects '%' to
precede the two hex digits). As a result the escape-aware, normalized
comparison never happened for suffixes: a decoded pattern failed to
match a percent-encoded path.

Concretely, StripPathPrefix "/a/b/c" strips "/a%2Fb/c/d" to "/d", but the
mirror StripPathSuffix "/b/c" left "/a/b%2Fc" untouched instead of
producing "/a"; likewise StripPathSuffix "bc" did not strip "/a%62c".
This has been the behavior since #4948, which introduced both the
escape-aware trimPathPrefix and the reverse-based suffix trimming.

Replace the reverse trick with a dedicated trimPathSuffix that iterates
from the ends of both strings and applies the same escape-aware,
case-insensitive comparison as trimPathPrefix. An escape in the pattern
itself is still compared literally, so "%2fsuffix" continues to require
the path to contain that exact escape.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
pull/7674/head^2
TowyTowy 2026-08-28 11:44:05 +02:00 committed by GitHub
parent 3244ef4105
commit 7bf1b9057b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 73 additions and 6 deletions

View File

@ -296,7 +296,7 @@ func (rewr Rewrite) Rewrite(r *http.Request, repl *caddy.Replacer) bool {
mergeSlashes := !strings.Contains(suffix, "//")
changePath(r, func(escapedPath string) string {
escapedPath = caddyhttp.CleanPath(escapedPath, mergeSlashes)
return reverse(trimPathPrefix(reverse(escapedPath), reverse(suffix)))
return trimPathSuffix(escapedPath, suffix)
})
}
@ -464,12 +464,58 @@ func trimPathPrefix(escapedPath, prefix string) string {
return escapedPath
}
func reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
// trimPathSuffix is the suffix counterpart of trimPathPrefix: it trims suffix
// from the end of escapedPath using the same escape-aware, case-insensitive
// comparison semantics. Both strings are iterated in lock-step from their ends,
// and if escapedPath has a '%' encoding at a particular position where the
// suffix pattern uses the decoded character, escapedPath's escape is decoded so
// the comparison happens in normalized/unescaped space. Conversely, if the
// suffix pattern itself uses an escape (`%xx`), escapedPath must literally use
// the same escape at that position (the escapes are then compared byte-for-byte).
//
// A naive reverse-then-trimPathPrefix approach cannot be used here: reversing
// the strings moves the '%' to the end of each escape sequence, which defeats
// trimPathPrefix's escape detection (it expects '%' to precede the two hex
// digits) and makes escaped path bytes compare unequal to their decoded form.
func trimPathSuffix(escapedPath, suffix string) string {
iPath, iSuffix := len(escapedPath), len(suffix)
for iPath > 0 && iSuffix > 0 {
suffixCh := suffix[iSuffix-1]
ch := string(escapedPath[iPath-1])
step := 1
// if escapedPath uses a percent-encoding that ends at this position but
// the suffix pattern does not encode this position, decode escapedPath's
// escape so the comparison happens in normalized/unescaped space
pathHasEscape := iPath >= 3 && escapedPath[iPath-3] == '%'
suffixHasEscape := iSuffix >= 3 && suffix[iSuffix-3] == '%'
if pathHasEscape && !suffixHasEscape {
decoded, err := url.PathUnescape(escapedPath[iPath-3 : iPath])
if err != nil {
// should be impossible unless EscapedPath() is returning invalid values!
return escapedPath
}
ch = decoded
step = 3
}
// suffix comparisons are case-insensitive for consistency with
// trimPathPrefix, which is case-insensitive for good reasons
if !strings.EqualFold(ch, string(suffixCh)) {
return escapedPath
}
iPath -= step
iSuffix--
}
return string(r)
// if we iterated through the entire suffix, we found it, so trim it
if iSuffix <= 0 {
return escapedPath[:iPath]
}
// otherwise we did not find the suffix
return escapedPath
}
// substrReplacer describes either a simple and fast substring replacement.

View File

@ -335,6 +335,27 @@ func TestRewrite(t *testing.T) {
input: newRequest(t, "GET", "/foo/suffix/bar"),
expect: newRequest(t, "GET", "/foo/suffix/bar"),
},
{
// a decoded suffix pattern must match a percent-encoded path in
// normalized space, mirroring StripPathPrefix (see the "/a/b/c"
// vs "/a%2Fb/c/d" case above)
rule: Rewrite{StripPathSuffix: "/b/c"},
input: newRequest(t, "GET", "/a/b%2Fc"),
expect: newRequest(t, "GET", "/a"),
},
{
// decoded suffix char matches its percent-encoded form in the path
rule: Rewrite{StripPathSuffix: "bc"},
input: newRequest(t, "GET", "/a%62c"), // %62 == 'b'
expect: newRequest(t, "GET", "/a"),
},
{
// an escaped suffix pattern requires the path to use the same
// escape at that position, so a decoded path must NOT be stripped
rule: Rewrite{StripPathSuffix: "%2fsuffix"},
input: newRequest(t, "GET", "/foo/bar/suffix"),
expect: newRequest(t, "GET", "/foo/bar/suffix"),
},
{
rule: Rewrite{URISubstring: []substrReplacer{{Find: "findme", Replace: "replaced"}}},