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, 01:00 UTC
fastcgi: reject paths whose escaped form selects another script (#8357)
Kévin Dunglas committed
Nov 5, 2026 at 00:02 UTC
3d005442e4ee1b1fd8c129ff47c74ae636438fdf
3 files changed
+80
-18
modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
+24
@@ -20,6 +20,7 @@ import (
20
"fmt"
21
"net"
22
"net/http"
23
+ "net/url"
24
"path/filepath"
25
"runtime"
26
"strconv"
@@ -322,6 +323,21 @@ func (t Transport) buildEnv(r *http.Request) (envVars, error) {
323
return nil, caddyhttp.Error(http.StatusBadRequest, errors.New("illegal script path"))
324
}
325
326
+ // Path matchers whose pattern contains "%" clean the escaped path, where "%2f"
327
+ // and "%2e%2e" are neither separators nor dot segments, so both forms must
328
+ // select the same script. On Windows, they read a backslash, escaped even
329
+ // without RawPath, as "%2f".
330
+ backslash := runtime.GOOS == "windows" && strings.Contains(r.URL.Path, `\`)
331
+ if r.URL.RawPath != "" || backslash {
332
+ escaped, err := url.PathUnescape(caddyhttp.NormalizedPath(r.URL.EscapedPath(), true))
333
+ if backslash {
334
+ escaped = strings.ReplaceAll(escaped, `\`, "/")
335
+ }
336
+ if err != nil || t.scriptPart(escaped) != docURI {
337
+ return nil, caddyhttp.Error(http.StatusBadRequest, errors.New("escaped and decoded paths select different scripts"))
338
+ }
339
+ }
340
+
341
// Try to grab the path remainder from a file matcher
342
// if we didn't get a split result here.
343
// See https://github.com/caddyserver/caddy/issues/3718
@@ -526,6 +542,14 @@ func (t Transport) splitPos(path string) int {
542
return -1
543
}
544
545
+// scriptPart returns the part of path naming the script.
546
+func (t Transport) scriptPart(path string) string {
547
+ if pos := t.splitPos(path); pos > -1 {
548
+ return path[:pos]
549
+ }
550
+ return path
551
+}
552
+
553
type envVars map[string]string
554
555
// loggableEnv is a simple type to allow for speeding up zap log encoding.
modules/caddyhttp/reverseproxy/fastcgi/fastcgi_test.go
+39
-12
@@ -568,18 +568,6 @@ func TestBuildEnvDotSegmentsCannotSelectAnotherScript(t *testing.T) {
568
expectedScriptName: "/other.php",
569
expectedPathInfo: "",
570
},
571
- {
572
- name: "percent-encoded separators",
573
- target: "/admin.php/..%2f..%2fother.php",
574
- expectedScriptName: "/other.php",
575
- expectedPathInfo: "",
576
- },
577
- {
578
- name: "percent-encoded dots",
579
- target: "/admin.php/%2e%2e/other.php",
580
- expectedScriptName: "/other.php",
581
- expectedPathInfo: "",
582
- },
571
{
572
name: "dot segments before legitimate path info",
573
target: "/admin.php/../other.php/extra/path",
@@ -620,3 +608,42 @@ func TestBuildEnvDotSegmentsCannotSelectAnotherScript(t *testing.T) {
608
})
609
}
610
}
611
+
612
+// Path matchers whose pattern contains "%" clean the escaped path, so
613
+// "/public/tool.php%2f..%2f..%2fadmin%2ftool.php" matches "path /public/%*"
614
+// while the decoded path selects admin/tool.php.
615
+func TestBuildEnvEscapedPathCannotSelectAnotherScript(t *testing.T) {
616
+ t.Parallel()
617
+
618
+ for _, tc := range []struct {
619
+ target string
620
+ wantErr bool
621
+ expectedScriptName string
622
+ }{
623
+ {target: "/public/tool.php%2f..%2f..%2fadmin%2ftool.php", wantErr: true},
624
+ {target: "/admin.php/..%2f..%2fother.php", wantErr: true},
625
+ {target: "/admin.php/%2e%2e/other.php", wantErr: true},
626
+ {target: "/dir%2fscript.php/a", expectedScriptName: "/dir/script.php"},
627
+ {target: "/index.php/a%2f..%2fb", expectedScriptName: "/index.php"},
628
+ {target: "/index.php/a%2Fb", expectedScriptName: "/index.php"},
629
+ } {
630
+ t.Run(tc.target, func(t *testing.T) {
631
+ r := httptest.NewRequest(http.MethodGet, tc.target, nil)
632
+ ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, caddy.NewReplacer())
633
+ ctx = context.WithValue(ctx, caddyhttp.VarsCtxKey, map[string]any{})
634
+ ctx = context.WithValue(ctx, caddyhttp.OriginalRequestCtxKey, *r)
635
+
636
+ tr := Transport{Root: t.TempDir(), SplitPath: []string{".php"}}
637
+ env, err := tr.buildEnv(r.WithContext(ctx))
638
+ if !tc.wantErr {
639
+ require.NoError(t, err)
640
+ assert.Equal(t, tc.expectedScriptName, env["SCRIPT_NAME"])
641
+ return
642
+ }
643
+
644
+ var herr caddyhttp.HandlerError
645
+ require.ErrorAs(t, err, &herr)
646
+ assert.Equal(t, http.StatusBadRequest, herr.StatusCode)
647
+ })
648
+ }
649
+}
modules/caddyhttp/reverseproxy/fastcgi/fastcgi_windows_test.go
+17
-6
@@ -31,7 +31,8 @@ import (
31
// On Windows the path matchers additionally fold backslashes into separators and
32
// trim per-component trailing dots and spaces (see caddyhttp.NormalizedPath),
33
// so the transport must do the same or the dot-segment bypass simply reappears
34
-// spelled with backslashes.
34
+// spelled with backslashes. Backslash dot segments are rejected outright because
35
+// the "%" path matchers read them as "%2f..%2f", which they do not resolve.
36
func TestBuildEnvWindowsSeparatorsCannotSelectAnotherScript(t *testing.T) {
37
t.Parallel()
38
@@ -46,21 +47,31 @@ func TestBuildEnvWindowsSeparatorsCannotSelectAnotherScript(t *testing.T) {
47
for _, tc := range []struct {
48
name string
49
target string
50
+ wantErr bool
51
expectedScriptName string
52
expectedPathInfo string
53
}{
52
- {"backslash dot segments", `/admin.php\..\other.php`, "/other.php", ""},
53
- {"backslash, two levels", `/admin.php\..\..\other.php`, "/other.php", ""},
54
- {"mixed separators", `/admin.php\../other.php`, "/other.php", ""},
54
+ {"backslash dot segments", `/admin.php\..\other.php`, true, "", ""},
55
+ {"backslash, two levels", `/admin.php\..\..\other.php`, true, "", ""},
56
+ {"mixed separators", `/admin.php\../other.php`, true, "", ""},
57
+ {"escaped backslash dot segments", "/admin.php%5C..%5Cother.php", true, "", ""},
58
+ {"backslash separator", `/dir\other.php\a`, false, "/dir/other.php", "/a"},
59
+ {"escaped backslash separator", "/dir%5Cother.php%5Ca", false, "/dir/other.php", "/a"},
60
// Per-component trailing dot/space folding is Windows-only and is not
61
// something CleanPath does, so this case fails unless the transport
62
// applies the same normalization the matchers do.
58
- {"trailing dot in a path-info component", "/other.php/a./b", "/other.php", "/a/b"},
59
- {"trailing space in a path-info component", "/other.php/a%20/b", "/other.php", "/a/b"},
63
+ {"trailing dot in a path-info component", "/other.php/a./b", false, "/other.php", "/a/b"},
64
+ {"trailing space in a path-info component", "/other.php/a%20/b", false, "/other.php", "/a/b"},
65
} {
66
t.Run(tc.name, func(t *testing.T) {
67
tr := Transport{Root: `C:\srv`, SplitPath: []string{".php"}}
68
env, err := tr.buildEnv(newRequest(tc.target))
69
+ if tc.wantErr {
70
+ var herr caddyhttp.HandlerError
71
+ require.ErrorAs(t, err, &herr)
72
+ assert.Equal(t, http.StatusBadRequest, herr.StatusCode)
73
+ return
74
+ }
75
require.NoError(t, err)
76
77
assert.Equal(t, tc.expectedScriptName, env["SCRIPT_NAME"],