@marcel91200 / caddy Mirror · read-only / commits / 32bba154
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, 03:30 UTC

fastcgi: keep the php_fastcgi directory redirect on the same origin (#8285)

* fastcgi: keep the php_fastcgi directory redirect on the same origin * fastcgi: build the directory redirect from the escaped original segment

sam-kota committed Oct 28, 2026 at 15:15 UTC 32bba1545989e07368c0977a37652f33d80c8cd4
9 files changed +101 -7
caddytest/integration/caddyfile_adapt/php_fastcgi_expanded_form.caddyfiletest
+2 -2
@@ -8,7 +8,7 @@ route {
8 }
9 not path */
10 }
11 - redir @canonicalPath {orig_path}/{orig_?query} 308
11 + redir @canonicalPath ./{http.request.orig_uri.path.file_escaped}/{orig_?query} 308
12
13 # If the requested file does not exist, try index files
14 @indexFiles {
@@ -50,7 +50,7 @@ route {
50 "handler": "static_response",
51 "headers": {
52 "Location": [
53 - "{http.request.orig_uri.path}/{http.request.orig_uri.prefixed_query}"
53 + "./{http.request.orig_uri.path.file_escaped}/{http.request.orig_uri.prefixed_query}"
54 ]
55 },
56 "status_code": 308
caddytest/integration/caddyfile_adapt/php_fastcgi_handle_response.caddyfiletest
+1 -1
@@ -42,7 +42,7 @@
42 "handler": "static_response",
43 "headers": {
44 "Location": [
45 - "{http.request.orig_uri.path}/{http.request.orig_uri.prefixed_query}"
45 + "./{http.request.orig_uri.path.file_escaped}/{http.request.orig_uri.prefixed_query}"
46 ]
47 },
48 "status_code": 308
caddytest/integration/caddyfile_adapt/php_fastcgi_matcher.caddyfiletest
+1 -1
@@ -33,7 +33,7 @@ php_fastcgi @test localhost:9000
33 "handler": "static_response",
34 "headers": {
35 "Location": [
36 - "{http.request.orig_uri.path}/{http.request.orig_uri.prefixed_query}"
36 + "./{http.request.orig_uri.path.file_escaped}/{http.request.orig_uri.prefixed_query}"
37 ]
38 },
39 "status_code": 308
caddytest/integration/caddyfile_adapt/php_fastcgi_subdirectives.caddyfiletest
+1 -1
@@ -43,7 +43,7 @@ php_fastcgi localhost:9000 {
43 "handler": "static_response",
44 "headers": {
45 "Location": [
46 - "{http.request.orig_uri.path}/{http.request.orig_uri.prefixed_query}"
46 + "./{http.request.orig_uri.path.file_escaped}/{http.request.orig_uri.prefixed_query}"
47 ]
48 },
49 "status_code": 308
caddytest/integration/caddyfile_adapt/php_fastcgi_try_files_override.caddyfiletest
+1 -1
@@ -46,7 +46,7 @@ php_fastcgi localhost:9000 {
46 "handler": "static_response",
47 "headers": {
48 "Location": [
49 - "{http.request.orig_uri.path}/{http.request.orig_uri.prefixed_query}"
49 + "./{http.request.orig_uri.path.file_escaped}/{http.request.orig_uri.prefixed_query}"
50 ]
51 },
52 "status_code": 308
caddytest/integration/phpfastcgi_test.go
+78
@@ -22,6 +22,7 @@ import (
22 "net/http/fcgi"
23 "os"
24 "path/filepath"
25 + "runtime"
26 "strings"
27 "testing"
28
@@ -149,3 +150,80 @@ func TestPHPFastCGIProtectedRouteIgnoresDotSegments(t *testing.T) {
150 })
151 }
152 }
153 +
154 +// The php_fastcgi directory redirect must stay on the requesting origin. Its
155 +// matcher judges the cleaned request path, so /%2Fevil.com%2F..%2Fsub is matched
156 +// as /sub and resolves to <root>/sub/index.php, but the Location header used to
157 +// be built from the raw original path and came out as //evil.com/../sub/, which
158 +// browsers treat as a scheme-relative URL to another host. The relative
159 +// reference is built from the escaped original segment, so names with reserved
160 +// or non-ASCII characters must still round-trip to the same directory.
161 +func TestPHPFastCGIDirectoryRedirectStaysOnOrigin(t *testing.T) {
162 + dirs := []string{"sub", "a b", "a#b", "50%", "a;b", "café", "x/sub"}
163 + if runtime.GOOS != "windows" {
164 + dirs = append(dirs, "a?b", "a:b")
165 + }
166 + root := t.TempDir()
167 + for _, dir := range dirs {
168 + if err := os.MkdirAll(filepath.Join(root, filepath.FromSlash(dir)), 0o755); err != nil {
169 + t.Fatalf("creating %s: %v", dir, err)
170 + }
171 + if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(dir), "index.php"), []byte("<?php // index"), 0o644); err != nil {
172 + t.Fatalf("writing %s/index.php: %v", dir, err)
173 + }
174 + }
175 +
176 + tester := caddytest.NewTester(t)
177 + tester.InitServer(fmt.Sprintf(`{
178 + skip_install_trust
179 + admin localhost:2999
180 + http_port 9080
181 + https_port 9443
182 + grace_period 1ns
183 + }
184 +
185 + localhost:9080 {
186 + root * %s
187 + php_fastcgi 127.0.0.1:9
188 + }
189 + `, root), "caddyfile")
190 +
191 + tests := []struct {
192 + uri string
193 + want string
194 + }{
195 + {uri: "/sub", want: "/sub/"},
196 + {uri: "/sub?a=1", want: "/sub/?a=1"},
197 + {uri: "/%2Fevil.com%2F..%2Fsub", want: "/%2Fevil.com%2F..%2Fsub/"},
198 + {uri: "/%5Cevil.com%2F..%2Fsub", want: "/%5Cevil.com%2F..%2Fsub/"},
199 + {uri: "/a%20b", want: "/a%20b/"},
200 + {uri: "/a%23b", want: "/a%23b/"},
201 + {uri: "/50%25", want: "/50%25/"},
202 + {uri: "/a;b", want: "/a;b/"},
203 + {uri: "/caf%C3%A9", want: "/caf%C3%A9/"},
204 + {uri: "/x%2Fsub", want: "/x%2Fsub/"},
205 + }
206 + if runtime.GOOS != "windows" {
207 + tests = append(tests,
208 + struct{ uri, want string }{uri: "/a%3Fb", want: "/a%3Fb/"},
209 + struct{ uri, want string }{uri: "/a:b", want: "/a:b/"},
210 + )
211 + }
212 + for _, tc := range tests {
213 + resp := tester.AssertRedirect("http://localhost:9080"+tc.uri, "http://localhost:9080"+tc.want, http.StatusPermanentRedirect)
214 + if resp == nil {
215 + continue
216 + }
217 + loc := resp.Header.Get("Location")
218 + // a Location beginning with // or /\ is scheme-relative to browsers
219 + if strings.HasPrefix(loc, "//") || strings.HasPrefix(loc, `/\`) {
220 + t.Errorf("requesting %q: Location %q is not origin-relative", tc.uri, loc)
221 + }
222 + for i := 0; i < len(loc); i++ {
223 + if loc[i] < 0x21 || loc[i] > 0x7e {
224 + t.Errorf("requesting %q: Location %q contains an unescaped byte %#x", tc.uri, loc, loc[i])
225 + break
226 + }
227 + }
228 + }
229 +}
modules/caddyhttp/app.go
+1
@@ -76,6 +76,7 @@ func init() {
76 // `{http.request.orig_uri.path.*}` | Parts of the original path, split by `/` (0-based from left)
77 // `{http.request.orig_uri.path.dir}` | The request's original directory
78 // `{http.request.orig_uri.path.file}` | The request's original filename
79 +// `{http.request.orig_uri.path.file_escaped}` | The request's original filename, percent-encoded as in the request's escaped path
80 // `{http.request.orig_uri.query}` | The request's original query string (without `?`)
81 // `{http.request.orig_uri.prefixed_query}` | The request's original query string with a `?` prefix, if non-empty
82 // `{http.request.port}` | The port part of the request's Host header
modules/caddyhttp/replacer.go
+7
@@ -263,6 +263,13 @@ func addHTTPVarsToReplacer(repl *caddy.Replacer, req *http.Request, w http.Respo
263 or, _ := req.Context().Value(OriginalRequestCtxKey).(http.Request)
264 _, file := path.Split(or.URL.Path)
265 return file, true
266 + case "http.request.orig_uri.path.file_escaped":
267 + // split the escaped path rather than escaping the decoded
268 + // file name, since an encoded slash (%2F) in the request
269 + // is part of the last segment, not a separator
270 + or, _ := req.Context().Value(OriginalRequestCtxKey).(http.Request)
271 + _, file := path.Split(or.URL.EscapedPath())
272 + return file, true
273 case "http.request.orig_uri.path.dir":
274 or, _ := req.Context().Value(OriginalRequestCtxKey).(http.Request)
275 dir, _ := path.Split(or.URL.Path)
modules/caddyhttp/reverseproxy/fastcgi/caddyfile.go
+9 -1
@@ -346,9 +346,17 @@ func parsePHPFastCGI(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error
346 },
347 }),
348 }
349 + // The matcher above judges the cleaned request path, so the
350 + // Location must not echo the raw original path: a request for
351 + // /%2Fevil.com%2F..%2Fsub matches <root>/sub/index.php but would
352 + // redirect to //evil.com/../sub/, which browsers resolve to
353 + // another host. A reference relative to the last path segment
354 + // resolves against the URL the client actually requested, so it
355 + // can only ever land on the same origin; the leading ./ keeps a
356 + // segment containing ':' from being read as a scheme.
357 redirHandler := caddyhttp.StaticResponse{
358 StatusCode: caddyhttp.WeakString(strconv.Itoa(http.StatusPermanentRedirect)),
351 - Headers: http.Header{"Location": []string{"{http.request.orig_uri.path}/{http.request.orig_uri.prefixed_query}"}},
359 + Headers: http.Header{"Location": []string{"./{http.request.orig_uri.path.file_escaped}/{http.request.orig_uri.prefixed_query}"}},
360 }
361 redirRoute := caddyhttp.Route{
362 MatcherSetsRaw: []caddy.ModuleMap{redirMatcherSet},