@marcel91200 / caddy Mirror · read-only / commits / 3d005442
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, 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"],