@marcel91200 / caddy Mirror · read-only
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, 02:15 UTC

master
go 667 lines 21.6 KB
Raw
1 package fastcgi
2
3 import (
4 "context"
5 "net"
6 "net/http"
7 "net/http/httptest"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14
15 "github.com/caddyserver/caddy/v2"
16 "github.com/caddyserver/caddy/v2/modules/caddyhttp"
17 )
18
19 func TestProvisionSplitPath(t *testing.T) {
20 tests := []struct {
21 name string
22 splitPath []string
23 wantErr error
24 wantSplitPath []string
25 }{
26 {
27 name: "valid lowercase split path",
28 splitPath: []string{".php"},
29 wantErr: nil,
30 wantSplitPath: []string{".php"},
31 },
32 {
33 name: "valid uppercase split path normalized",
34 splitPath: []string{".PHP"},
35 wantErr: nil,
36 wantSplitPath: []string{".php"},
37 },
38 {
39 name: "valid mixed case split path normalized",
40 splitPath: []string{".PhP", ".PHTML"},
41 wantErr: nil,
42 wantSplitPath: []string{".php", ".phtml"},
43 },
44 {
45 name: "empty split path",
46 splitPath: []string{},
47 wantErr: nil,
48 wantSplitPath: []string{},
49 },
50 {
51 name: "non-ASCII character in split path rejected",
52 splitPath: []string{".php", ".Ⱥphp"},
53 wantErr: ErrInvalidSplitPath,
54 },
55 {
56 name: "unicode character in split path rejected",
57 splitPath: []string{".phpⱥ"},
58 wantErr: ErrInvalidSplitPath,
59 },
60 }
61
62 for _, tt := range tests {
63 t.Run(tt.name, func(t *testing.T) {
64 tr := Transport{SplitPath: tt.splitPath}
65 err := tr.Provision(caddy.Context{})
66
67 if tt.wantErr != nil {
68 require.ErrorIs(t, err, tt.wantErr)
69
70 return
71 }
72
73 require.NoError(t, err)
74 assert.Equal(t, tt.wantSplitPath, tr.SplitPath)
75 })
76 }
77 }
78
79 func TestSplitPos(t *testing.T) {
80 tests := []struct {
81 name string
82 path string
83 splitPath []string
84 wantPos int
85 }{
86 {
87 name: "simple php extension",
88 path: "/path/to/script.php",
89 splitPath: []string{".php"},
90 wantPos: 19,
91 },
92 {
93 name: "php extension with path info",
94 path: "/path/to/script.php/some/path",
95 splitPath: []string{".php"},
96 wantPos: 19,
97 },
98 {
99 name: "case insensitive match",
100 path: "/path/to/script.PHP",
101 splitPath: []string{".php"},
102 wantPos: 19,
103 },
104 {
105 name: "mixed case match",
106 path: "/path/to/script.PhP/info",
107 splitPath: []string{".php"},
108 wantPos: 19,
109 },
110 {
111 name: "no match",
112 path: "/path/to/script.txt",
113 splitPath: []string{".php"},
114 wantPos: -1,
115 },
116 {
117 name: "empty split path",
118 path: "/path/to/script.php",
119 splitPath: []string{},
120 wantPos: 0,
121 },
122 {
123 name: "multiple split paths first match",
124 path: "/path/to/script.php",
125 splitPath: []string{".php", ".phtml"},
126 wantPos: 19,
127 },
128 {
129 name: "multiple split paths second match",
130 path: "/path/to/script.phtml",
131 splitPath: []string{".php", ".phtml"},
132 wantPos: 21,
133 },
134 // Unicode case-folding tests (security fix for GHSA-g966-83w7-6w38)
135 // U+023A (Ⱥ) lowercases to U+2C65 (ⱥ), which has different UTF-8 byte length
136 // Ⱥ: 2 bytes (C8 BA), ⱥ: 3 bytes (E2 B1 A5)
137 {
138 name: "unicode path with case-folding length expansion",
139 path: "/ȺȺȺȺshell.php",
140 splitPath: []string{".php"},
141 wantPos: 18, // correct position in original string
142 },
143 {
144 name: "unicode path with extension after expansion chars",
145 path: "/ȺȺȺȺshell.php/path/info",
146 splitPath: []string{".php"},
147 wantPos: 18,
148 },
149 {
150 name: "unicode in filename with multiple php occurrences",
151 path: "/ȺȺȺȺshell.php/txt.php",
152 splitPath: []string{".php"},
153 wantPos: 18, // should match first .php, not be confused by byte offset shift
154 },
155 {
156 name: "unicode case insensitive extension",
157 path: "/ȺȺȺȺshell.PHP",
158 splitPath: []string{".php"},
159 wantPos: 18,
160 },
161 {
162 name: "unicode in middle of path",
163 path: "/path/Ⱥtest/script.php",
164 splitPath: []string{".php"},
165 wantPos: 23, // Ⱥ is 2 bytes, so path is 23 bytes total, .php ends at byte 23
166 },
167 {
168 name: "unicode only in directory not filename",
169 path: "/Ⱥ/script.php",
170 splitPath: []string{".php"},
171 wantPos: 14,
172 },
173 // Additional Unicode characters that expand when lowercased
174 // U+0130 (İ - Turkish capital I with dot) lowercases to U+0069 + U+0307
175 {
176 name: "turkish capital I with dot",
177 path: "/İtest.php",
178 splitPath: []string{".php"},
179 wantPos: 11,
180 },
181 // Ensure standard ASCII still works correctly
182 {
183 name: "ascii only path with case variation",
184 path: "/PATH/TO/SCRIPT.PHP/INFO",
185 splitPath: []string{".php"},
186 wantPos: 19,
187 },
188 {
189 name: "path at root",
190 path: "/index.php",
191 splitPath: []string{".php"},
192 wantPos: 10,
193 },
194 {
195 name: "extension in middle of filename",
196 path: "/test.php.bak",
197 splitPath: []string{".php"},
198 wantPos: -1,
199 },
200 {
201 name: "extension in middle of filename before a later script",
202 path: "/admin.php.php",
203 splitPath: []string{".php"},
204 wantPos: 14,
205 },
206 {
207 name: "extension in middle of a directory name",
208 path: "/admin.phpx/y.php",
209 splitPath: []string{".php"},
210 wantPos: 17,
211 },
212 {
213 name: "extension in middle of a directory name with path info",
214 path: "/a.phpx/b.php/info",
215 splitPath: []string{".php"},
216 wantPos: 13,
217 },
218 // Regression tests adapted from FrankenPHP advisories
219 // GHSA-3g8v-8r37-cgjm and GHSA-v4h7-cj44-8fc8: search.IgnoreCase
220 // matched Unicode equivalents of ASCII letters as ".php", and an
221 // inner non-ASCII byte path could leave the match flag stale.
222 {
223 name: "non-ascii byte after dot must not match",
224 path: "/PoC-match-unset.¡.txt",
225 splitPath: []string{".php"},
226 wantPos: -1,
227 },
228 {
229 name: "non-ascii byte mid-extension must not match",
230 path: "/script.p\xc2\xa1p",
231 splitPath: []string{".php"},
232 wantPos: -1,
233 },
234 {
235 name: "small full stop ﹒ in extension must not match",
236 path: "/shell﹒php",
237 splitPath: []string{".php"},
238 wantPos: -1,
239 },
240 {
241 name: "fullwidth full stop . in extension must not match",
242 path: "/shell.php",
243 splitPath: []string{".php"},
244 wantPos: -1,
245 },
246 {
247 name: "fullwidth p in extension must not match",
248 path: "/shell.php",
249 splitPath: []string{".php"},
250 wantPos: -1,
251 },
252 {
253 name: "circled php must not match",
254 path: "/shell.ⓟⓗⓟ",
255 splitPath: []string{".php"},
256 wantPos: -1,
257 },
258 {
259 name: "mathematical sans-serif bold php must not match",
260 path: "/shell.\U0001D5FD\U0001D5F5\U0001D5FD",
261 splitPath: []string{".php"},
262 wantPos: -1,
263 },
264 {
265 name: "mathematical script php must not match",
266 path: "/shell.\U0001D4C5\U0001D4BD\U0001D4C5",
267 splitPath: []string{".php"},
268 wantPos: -1,
269 },
270 {
271 name: "circled php with later real php still picks the real one",
272 path: "/shell.ⓟⓗⓟ.anything-after-payload.php",
273 splitPath: []string{".php"},
274 // "/shell." (7) + "ⓟⓗⓟ" (3*3 bytes) + ".anything-after-payload.php" (27) = 43
275 wantPos: 43,
276 },
277 }
278
279 for _, tt := range tests {
280 t.Run(tt.name, func(t *testing.T) {
281 gotPos := Transport{SplitPath: tt.splitPath}.splitPos(tt.path)
282 assert.Equal(t, tt.wantPos, gotPos, "splitPos(%q, %v)", tt.path, tt.splitPath)
283
284 // Verify that the split produces valid substrings
285 if gotPos > 0 && gotPos <= len(tt.path) {
286 scriptName := tt.path[:gotPos]
287 pathInfo := tt.path[gotPos:]
288
289 // The script name should end with one of the split extensions (case-insensitive)
290 hasValidEnding := false
291 for _, split := range tt.splitPath {
292 if strings.HasSuffix(strings.ToLower(scriptName), split) {
293 hasValidEnding = true
294 break
295 }
296 }
297 assert.True(t, hasValidEnding, "script name %q should end with one of %v", scriptName, tt.splitPath)
298
299 // Original path should be reconstructable
300 assert.Equal(t, tt.path, scriptName+pathInfo, "path should be reconstructable from split parts")
301 }
302 })
303 }
304 }
305
306 // TestSplitPosUnicodeSecurityRegression specifically tests the vulnerability
307 // described in GHSA-g966-83w7-6w38 where Unicode case-folding caused
308 // incorrect SCRIPT_NAME/PATH_INFO splitting
309 func TestSplitPosUnicodeSecurityRegression(t *testing.T) {
310 // U+023A: Ⱥ (UTF-8: C8 BA). Lowercase is ⱥ (UTF-8: E2 B1 A5), longer in bytes.
311 path := "/ȺȺȺȺshell.php/txt.php"
312 split := []string{".php"}
313
314 pos := Transport{SplitPath: split}.splitPos(path)
315
316 // The vulnerable code would return 22 (computed on lowercased string)
317 // The correct code should return 18 (position in original string)
318 expectedPos := strings.Index(path, ".php") + len(".php")
319 assert.Equal(t, expectedPos, pos, "split position should match first .php in original string")
320 assert.Equal(t, 18, pos, "split position should be 18, not 22")
321
322 if pos > 0 && pos <= len(path) {
323 scriptName := path[:pos]
324 pathInfo := path[pos:]
325
326 assert.Equal(t, "/ȺȺȺȺshell.php", scriptName, "script name should be the path up to first .php")
327 assert.Equal(t, "/txt.php", pathInfo, "path info should be the remainder after first .php")
328 }
329 }
330
331 // TestHeaderNameReplacer asserts the CGI header-to-env normalization rule:
332 // hyphens are mapped to underscores while every other character (including
333 // spaces and dots) is passed through. Spaces are not RFC 7230 tokens, so they
334 // cannot reach this function from the wire; the only header names that
335 // survive untouched at the server layer are sanitized by the underscore/dot
336 // filter in caddyhttp.Server.serveHTTP (see GHSA-f59h-q822-g45g,
337 // GHSA-49wc-4hcv-v58q).
338 func TestHeaderNameReplacer(t *testing.T) {
339 tests := []struct {
340 in, want string
341 }{
342 {"X-Forwarded-For", "X_Forwarded_For"},
343 {"Remote-User", "Remote_User"},
344 // Underscores are preserved (the server has already dropped any
345 // underscore-named headers when the filter is on).
346 {"Remote_User", "Remote_User"},
347 // Dots are preserved by this replacer too; the server-layer filter is
348 // what prevents a dotted alias (e.g. Remote.User) from reaching here.
349 {"Remote.User", "Remote.User"},
350 // Spaces are not rewritten because Go's HTTP parser rejects whitespace in
351 // header field names.
352 {"Foo Bar", "Foo Bar"},
353 }
354 for _, tt := range tests {
355 assert.Equal(t, tt.want, headerNameReplacer.Replace(tt.in), "input %q", tt.in)
356 }
357 }
358
359 // TestSplitPosSecurityRegressionUnicodeBypass guards against the FrankenPHP
360 // advisories GHSA-3g8v-8r37-cgjm (uninitialized match flag on inner non-ASCII
361 // byte) and GHSA-v4h7-cj44-8fc8 (Unicode equivalence via search.IgnoreCase
362 // folding fullwidth/mathematical/circled letters onto ASCII). Every payload
363 // below produced a false positive in the vulnerable implementation; none
364 // must match here.
365 func TestSplitPosSecurityRegressionUnicodeBypass(t *testing.T) {
366 t.Parallel()
367
368 tr := Transport{SplitPath: []string{".php"}}
369 payloads := []string{
370 "/PoC-match-unset.¡.txt", // GHSA-3g8v: stale match=true on IndexString fallback
371 "/shell﹒php", // U+FE52 small full stop
372 "/shell.php", // U+FF0E fullwidth full stop
373 "/shell.php", // U+FF50 fullwidth p
374 "/shell.php", // U+FF48 fullwidth h
375 "/shell.php", // U+FF50 fullwidth p (trailing)
376 "/shell.\U0001D5C1\U0001D5B5\U0001D5C1", // mathematical sans-serif p/h
377 "/shell.\U0001D5FD\U0001D5F5\U0001D5FD", // mathematical sans-serif bold p/h
378 "/shell.\U0001D4C5\U0001D4BD\U0001D4C5", // mathematical script p/h
379 "/shell.ⓟⓗⓟ", // circled latin small
380 }
381
382 for _, p := range payloads {
383 assert.Equalf(t, -1, tr.splitPos(p), "payload %q must not be detected as .php", p)
384 }
385 }
386
387 func TestBuildEnvServerAddr(t *testing.T) {
388 testCases := []struct {
389 name string
390 localAddr net.Addr
391 envVars map[string]string
392 expectedAddr string
393 expectExists bool
394 }{
395 {
396 name: "IPv4 TCP listener",
397 localAddr: &net.TCPAddr{IP: net.ParseIP("10.0.0.12"), Port: 80},
398 expectedAddr: "10.0.0.12",
399 expectExists: true,
400 },
401 {
402 name: "IPv6 TCP listener",
403 localAddr: &net.TCPAddr{IP: net.ParseIP("2001:db8::1"), Port: 443},
404 expectedAddr: "2001:db8::1",
405 expectExists: true,
406 },
407 {
408 name: "Missing local address context",
409 localAddr: nil,
410 expectExists: false,
411 },
412 {
413 name: "Unix listener",
414 localAddr: &net.UnixAddr{Name: "/var/run/caddy.sock", Net: "unix"},
415 expectExists: false,
416 },
417 {
418 name: "Explicitly configured SERVER_ADDR override",
419 localAddr: &net.TCPAddr{IP: net.ParseIP("10.0.0.12"), Port: 80},
420 envVars: map[string]string{"SERVER_ADDR": "192.168.1.100"},
421 expectedAddr: "192.168.1.100",
422 expectExists: true,
423 },
424 }
425
426 for _, tc := range testCases {
427 t.Run(tc.name, func(t *testing.T) {
428 tr := Transport{
429 Root: "/var/www",
430 EnvVars: tc.envVars,
431 }
432 err := tr.Provision(caddy.Context{})
433 require.NoError(t, err)
434
435 req, err := http.NewRequest(http.MethodGet, "http://localhost/index.php", nil)
436 require.NoError(t, err)
437
438 req = req.WithContext(context.WithValue(req.Context(), caddyhttp.OriginalRequestCtxKey, *req))
439 repl := caddy.NewReplacer()
440 req = req.WithContext(context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl))
441
442 if tc.localAddr != nil {
443 req = req.WithContext(context.WithValue(req.Context(), http.LocalAddrContextKey, tc.localAddr))
444 }
445
446 env, err := tr.buildEnv(req)
447 require.NoError(t, err)
448
449 val, exists := env["SERVER_ADDR"]
450 assert.Equal(t, tc.expectExists, exists)
451 if tc.expectExists {
452 assert.Equal(t, tc.expectedAddr, val)
453 }
454 })
455 }
456 }
457
458 // TestBuildEnvDropsProxyHeader guards against the HTTPoxy vulnerability
459 // (CVE-2016-5385): a client-supplied `Proxy` request header must never be
460 // translated into the HTTP_PROXY environment variable, since many HTTP client
461 // libraries used by FastCGI backends honor HTTP_PROXY for outbound requests,
462 // which would let an attacker hijack the backend's traffic.
463 // See https://httpoxy.org.
464 func TestBuildEnvDropsProxyHeader(t *testing.T) {
465 t.Parallel()
466
467 newRequest := func(headers http.Header) *http.Request {
468 r := httptest.NewRequest(http.MethodGet, "http://example.com/index.php", nil)
469 // Set the header map directly so non-canonical casing is preserved,
470 // mimicking a header that reaches buildEnv unmodified.
471 r.Header = headers
472 ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, caddy.NewReplacer())
473 ctx = context.WithValue(ctx, caddyhttp.VarsCtxKey, map[string]any{})
474 ctx = context.WithValue(ctx, caddyhttp.OriginalRequestCtxKey, *r)
475 return r.WithContext(ctx)
476 }
477
478 for _, casing := range []string{"Proxy", "proxy", "pRoXy", "PROXY"} {
479 t.Run("casing "+casing, func(t *testing.T) {
480 r := newRequest(http.Header{
481 casing: []string{"http://attacker.example:8080"},
482 "X-Safe-Hdr": []string{"kept"},
483 })
484
485 env, err := Transport{}.buildEnv(r)
486 require.NoError(t, err)
487
488 assert.NotContains(t, env, "HTTP_PROXY", "HTTP_PROXY must never be set from a client header")
489 // Sanity check that unrelated headers are still forwarded, i.e. the
490 // filter is narrow and doesn't break normal header passthrough.
491 assert.Equal(t, "kept", env["HTTP_X_SAFE_HDR"])
492 })
493 }
494
495 t.Run("configured HTTP_PROXY is preserved", func(t *testing.T) {
496 // The filter must only drop the *client* header; an explicitly
497 // configured env var is trusted and must survive.
498 r := newRequest(http.Header{"Proxy": []string{"http://attacker.example:8080"}})
499
500 tr := Transport{EnvVars: map[string]string{"HTTP_PROXY": "http://trusted.example:3128"}}
501 env, err := tr.buildEnv(r)
502 require.NoError(t, err)
503
504 assert.Equal(t, "http://trusted.example:3128", env["HTTP_PROXY"])
505 })
506 }
507
508 // Over HTTP/2, net/http keeps a "host" field sent next to :authority in
509 // r.Header; it must not replace HTTP_HOST, which comes from the routed r.Host.
510 func TestBuildEnvIgnoresHostHeaderField(t *testing.T) {
511 t.Parallel()
512
513 for _, casing := range []string{"Host", "host"} {
514 t.Run("casing "+casing, func(t *testing.T) {
515 r := httptest.NewRequest(http.MethodGet, "http://example.com/index.php", nil)
516 r.Header = http.Header{casing: []string{"admin.example.com"}}
517 ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, caddy.NewReplacer())
518 ctx = context.WithValue(ctx, caddyhttp.VarsCtxKey, map[string]any{})
519 ctx = context.WithValue(ctx, caddyhttp.OriginalRequestCtxKey, *r)
520
521 env, err := Transport{}.buildEnv(r.WithContext(ctx))
522 require.NoError(t, err)
523
524 assert.Equal(t, "example.com", env["HTTP_HOST"])
525 assert.Equal(t, "example.com", env["SERVER_NAME"])
526 })
527 }
528 }
529
530 // A client header must not override an HTTP_* variable set with the env subdirective.
531 func TestBuildEnvConfiguredVarsOverrideHeaders(t *testing.T) {
532 t.Parallel()
533
534 r := httptest.NewRequest(http.MethodGet, "http://example.com/index.php", nil)
535 r.Header.Set("X-Forwarded-Proto", "http")
536 r.Header.Set("X-Auth-User", "admin")
537 repl := caddy.NewReplacer()
538 repl.Set("http.auth.user.id", "alice")
539 ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, repl)
540 ctx = context.WithValue(ctx, caddyhttp.VarsCtxKey, map[string]any{})
541 ctx = context.WithValue(ctx, caddyhttp.OriginalRequestCtxKey, *r)
542
543 tr := Transport{EnvVars: map[string]string{
544 "HTTP_X_FORWARDED_PROTO": "https",
545 "HTTP_X_AUTH_USER": "{http.auth.user.id}",
546 }}
547 env, err := tr.buildEnv(r.WithContext(ctx))
548 require.NoError(t, err)
549
550 assert.Equal(t, "https", env["HTTP_X_FORWARDED_PROTO"])
551 assert.Equal(t, "alice", env["HTTP_X_AUTH_USER"])
552 }
553
554 // A request path containing dot segments must not let the client select a
555 // different script than the one every path matcher in the chain evaluated.
556 // Matchers judge the cleaned path (see caddyhttp.MatchPath), so the transport
557 // must derive SCRIPT_NAME/SCRIPT_FILENAME/PATH_INFO from the cleaned path too.
558 // Before this was fixed, /admin.php/../../other.php was matched as /other.php
559 // but executed <root>/admin.php.
560 func TestBuildEnvDotSegmentsCannotSelectAnotherScript(t *testing.T) {
561 t.Parallel()
562
563 newRequest := func(target string) *http.Request {
564 r := httptest.NewRequest(http.MethodGet, target, nil)
565 ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, caddy.NewReplacer())
566 ctx = context.WithValue(ctx, caddyhttp.VarsCtxKey, map[string]any{})
567 ctx = context.WithValue(ctx, caddyhttp.OriginalRequestCtxKey, *r)
568 return r.WithContext(ctx)
569 }
570
571 for _, tc := range []struct {
572 name string
573 target string
574 expectedScriptName string
575 expectedPathInfo string
576 }{
577 {
578 name: "literal dot segments",
579 target: "/admin.php/../../other.php",
580 expectedScriptName: "/other.php",
581 expectedPathInfo: "",
582 },
583 {
584 name: "single dot segment",
585 target: "/admin.php/../other.php",
586 expectedScriptName: "/other.php",
587 expectedPathInfo: "",
588 },
589 {
590 name: "dot segments before legitimate path info",
591 target: "/admin.php/../other.php/extra/path",
592 expectedScriptName: "/other.php",
593 expectedPathInfo: "/extra/path",
594 },
595 {
596 name: "no dot segments is unaffected",
597 target: "/other.php/extra/path",
598 expectedScriptName: "/other.php",
599 expectedPathInfo: "/extra/path",
600 },
601 {
602 // path.Clean("") is ".", which must not become the script name;
603 // an absolute-form request-target can leave URL.Path empty.
604 name: "empty path stays empty",
605 target: "http://example.com",
606 expectedScriptName: "",
607 expectedPathInfo: "",
608 },
609 } {
610 t.Run(tc.name, func(t *testing.T) {
611 root := t.TempDir()
612 tr := Transport{Root: root, SplitPath: []string{".php"}}
613 env, err := tr.buildEnv(newRequest(tc.target))
614 require.NoError(t, err)
615
616 assert.Equal(t, tc.expectedScriptName, env["SCRIPT_NAME"],
617 "SCRIPT_NAME must come from the cleaned path")
618 assert.Equal(t, tc.expectedPathInfo, env["PATH_INFO"],
619 "PATH_INFO must come from the cleaned path")
620 if tc.expectedScriptName != "" {
621 assert.Equal(t, filepath.Join(root, filepath.FromSlash(tc.expectedScriptName)), env["SCRIPT_FILENAME"],
622 "SCRIPT_FILENAME must name the script the matchers evaluated")
623 }
624 assert.NotContains(t, env["SCRIPT_FILENAME"], "admin.php",
625 "a pre-clean path component must never become the executed script")
626 })
627 }
628 }
629
630 // Path matchers whose pattern contains "%" clean the escaped path, so
631 // "/public/tool.php%2f..%2f..%2fadmin%2ftool.php" matches "path /public/%*"
632 // while the decoded path selects admin/tool.php.
633 func TestBuildEnvEscapedPathCannotSelectAnotherScript(t *testing.T) {
634 t.Parallel()
635
636 for _, tc := range []struct {
637 target string
638 wantErr bool
639 expectedScriptName string
640 }{
641 {target: "/public/tool.php%2f..%2f..%2fadmin%2ftool.php", wantErr: true},
642 {target: "/admin.php/..%2f..%2fother.php", wantErr: true},
643 {target: "/admin.php/%2e%2e/other.php", wantErr: true},
644 {target: "/dir%2fscript.php/a", expectedScriptName: "/dir/script.php"},
645 {target: "/index.php/a%2f..%2fb", expectedScriptName: "/index.php"},
646 {target: "/index.php/a%2Fb", expectedScriptName: "/index.php"},
647 } {
648 t.Run(tc.target, func(t *testing.T) {
649 r := httptest.NewRequest(http.MethodGet, tc.target, nil)
650 ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, caddy.NewReplacer())
651 ctx = context.WithValue(ctx, caddyhttp.VarsCtxKey, map[string]any{})
652 ctx = context.WithValue(ctx, caddyhttp.OriginalRequestCtxKey, *r)
653
654 tr := Transport{Root: t.TempDir(), SplitPath: []string{".php"}}
655 env, err := tr.buildEnv(r.WithContext(ctx))
656 if !tc.wantErr {
657 require.NoError(t, err)
658 assert.Equal(t, tc.expectedScriptName, env["SCRIPT_NAME"])
659 return
660 }
661
662 var herr caddyhttp.HandlerError
663 require.ErrorAs(t, err, &herr)
664 assert.Equal(t, http.StatusBadRequest, herr.StatusCode)
665 })
666 }
667 }