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},