From ffb239f3f48a8a2144ee4bd4f4fb1bc7ee55e694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Wed, 4 Nov 2026 21:29:49 +0100 Subject: [PATCH] fix: check escaped backslashes on Windows Caddy's path matchers whose pattern contains "%" read a backslash as "%2f" on Windows, which they do not resolve as a separator. Go escapes a backslash even when RawPath is empty, so the escaped-path check skipped "/public/tool.php%5C..%5C..%5Cadmin%5Ctool.php": a "/public/%*" rule matched it, but admin/tool.php ran. The check now also runs when the path contains a backslash on Windows, and folds backslashes into separators before comparing, so legitimate paths such as "/dir\index.php" are no longer rejected with a 400. --- cgi.go | 11 ++++++++--- cgi_test.go | 31 ++++++++++++++++++++++++++++++- context.go | 3 ++- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/cgi.go b/cgi.go index 427fcdab71..fedf1fe84f 100644 --- a/cgi.go +++ b/cgi.go @@ -322,17 +322,22 @@ func cleanRequestPath(reqPath string, windows bool) string { } // escapedPathSelectsAnotherScript reports whether cleaning the escaped path, as Caddy's path matchers do for patterns containing "%", leads to another script than cleaning the decoded one. -func escapedPathSelectsAnotherScript(u *url.URL, splitPath []string) bool { - if u.RawPath == "" { +func escapedPathSelectsAnotherScript(u *url.URL, splitPath []string, windows bool) bool { + // on Windows, those matchers read a backslash, escaped even without RawPath, as an escaped separator + backslash := windows && strings.Contains(u.Path, `\`) + if u.RawPath == "" && !backslash { return false } - windows := runtime.GOOS == "windows" escaped, err := url.PathUnescape(cleanRequestPath(u.EscapedPath(), windows)) if err != nil { return true } + if backslash { + escaped = strings.ReplaceAll(escaped, `\`, "/") + } + if splitPath == nil { splitPath = []string{".php"} } diff --git a/cgi_test.go b/cgi_test.go index 21ecde7b22..a046952d04 100644 --- a/cgi_test.go +++ b/cgi_test.go @@ -277,7 +277,36 @@ func TestEscapedPathSelectsAnotherScript(t *testing.T) { if !assert.NoError(t, err) { return } - assert.Equal(t, tt.expected, escapedPathSelectsAnotherScript(u, nil)) + assert.Equal(t, tt.expected, escapedPathSelectsAnotherScript(u, nil, false)) + }) + } +} + +func TestEscapedPathSelectsAnotherScriptWindows(t *testing.T) { + t.Parallel() + + tests := []struct { + target string + expected bool + }{ + {`/dir\index.php`, false}, + {`/dir\index.php\info`, false}, + {"/dir%5Cindex.php/info", false}, + {"/index.php/a%5C..%5Cb", false}, + {`/public/tool.php\..\..\admin\tool.php`, true}, + {"/public/tool.php%5C..%5C..%5Cadmin%5Ctool.php", true}, + {"/public/tool.php%2f..%2f..%2fadmin%2ftool.php", true}, + } + + for _, tt := range tests { + t.Run(tt.target, func(t *testing.T) { + t.Parallel() + + u, err := url.ParseRequestURI(tt.target) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, tt.expected, escapedPathSelectsAnotherScript(u, nil, true)) }) } } diff --git a/context.go b/context.go index b36e82a543..07f58ef599 100644 --- a/context.go +++ b/context.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "path/filepath" + "runtime" "strconv" "strings" "time" @@ -212,7 +213,7 @@ func (fc *frankenPHPContext) closeContext() { // validate checks if the request should be outright rejected func (fc *frankenPHPContext) validate() error { - if strings.Contains(fc.request.URL.Path, "\x00") || escapedPathSelectsAnotherScript(fc.request.URL, fc.splitPath) { + if strings.Contains(fc.request.URL.Path, "\x00") || escapedPathSelectsAnotherScript(fc.request.URL, fc.splitPath, runtime.GOOS == "windows") { fc.reject(ErrInvalidRequestPath) return ErrInvalidRequestPath