@marcel91200 / caddy Mirror · read-only / commits / 4ec5a5c3
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

Merge commit from fork

* fastcgi: derive SCRIPT_FILENAME from the normalized request path Path matchers evaluate a normalized request path — MatchPath applies normalizeWindowsPath and then CleanPath before comparing — but the FastCGI transport split r.URL.Path verbatim. Caddy does not rewrite r.URL.Path itself, so the two disagreed whenever the path contained dot segments after the split token: /admin.php/../../other.php was matched as /other.php while SCRIPT_FILENAME resolved to <root>/admin.php, and on Windows the same held for /admin.php\..\other.php. A route gated by a path matcher could therefore execute a script that matcher never evaluated. php_fastcgi generates a rewrite route that normally normalizes the path first, but that route can fail to match, so the transport establishes the invariant itself rather than inheriting it. The reason it can fail to match is addressed in the following commit; this change does not depend on it. caddyhttp.NormalizedPath is added so a consumer acting on a path a matcher has already judged derives it exactly as the matcher did, rather than reassembling the two steps and risking drift. Scope — two divergences of the same class are NOT addressed here: * SCRIPT_NAME remains a prefix of the request path, so a matcher naming a script exactly (path /admin.php) is still reachable via PATH_INFO at /admin.php/anything. Matchers protecting a script need a trailing wildcard regardless; that is inherent to CGI, not to this bug. * On Windows, NTFS 8.3 short names are not rejected here. fileserver's ServeHTTP rejects them (and ':' for ADS) precisely because matchers cannot see through them, so GET /ADMIN~1.PHP is judged as /admin~1.php by a path matcher while NTFS resolves SCRIPT_FILENAME to admin.php. Closing that means sharing fileserver's hasWindowsShortName across packages, which is a larger change than this fix; it is reported alongside this one and a patch is available on request. Side effects: PATH_INFO and DOCUMENT_URI are now derived from the normalized path, so repeated slashes are merged and "." segments removed (/index.php//a//b yields PATH_INFO=/a/b rather than //a//b), and on Windows per-component trailing dots and spaces are folded. An application routing on a raw PATH_INFO will see this. An empty URL.Path stays empty rather than becoming ".". REQUEST_URI is unchanged and still carries the original target. Tests: a buildEnv regression over literal and percent-encoded dot segments, covering that legitimate PATH_INFO is preserved and an empty path stays empty; a Windows-only counterpart whose cases each fail without the Windows normalization; and an end-to-end test of a basic_auth-protected php_fastcgi route against a FastCGI responder reporting the SCRIPT_FILENAME it received. The end-to-end test uses `index off` so no rewrite route exists, ensuring it exercises the transport rather than the rewrite. * fileserver: make first_exist_fallback fall back when the file is absent The try_policy first_exist_fallback is documented to match unconditionally, falling back to the last try_files entry when no earlier candidate exists. It did not: candidates are built with fs.Glob, which returns only paths that exist, so an absent final entry left the list empty and the fallback branch — nested inside the loop over that list — was unreachable. php_fastcgi hard-codes this policy so its generated rewrite route always runs; when the fallback failed, that rewrite was silently skipped. Keep the pattern itself as a candidate when nothing globbed. This also aligns Linux with Windows, where globResults is populated without an existence check and the fallback already fired. Behaviour change worth calling out: on a php_fastcgi site whose root has no index.php (or no file matching the configured index), the rewrite now always runs, so a request for a path that exists neither on the filesystem nor as a PHP script is proxied to the backend as <root>/index.php instead of falling through to file_server and returning 404. That is what the documented fallback promises, but for a site of standalone .php scripts plus static assets it is a visible change: unmatched requests now reach PHP-FPM. The other try policies still test existence explicitly, so a candidate that does not exist is skipped — but note this applies to ANY pattern that expands to nothing, not only a malformed glob: a file whose literal name contains glob metacharacters can now match under first_exist, largest_size and smallest_size where previously globbing found it only via expansion. * fileserver: synthesize the literal candidate only for the fallback entry Address review: the previous commit appended the pattern itself as a candidate inside makeCandidates whenever globbing found nothing. Since makeCandidates is shared by every try_policy, first_exist, largest_size, smallest_size and most_recently_modified could then stat the literal pattern, so a file whose name contains glob metacharacters (for example "lit[1].txt") matched where it did not before. Restore makeCandidates to its original behaviour and build the literal candidate only for the final entry of first_exist_fallback, the one entry that is documented to match unconditionally. Placeholders in that entry are expanded without glob escaping, since nothing is globbed, so a request for /a[b].txt falls back to /a[b].txt rather than /a\[b].txt. The path is still cleaned and joined with SanitizedPathJoin, so it cannot escape the root. This supersedes the last paragraph of the previous commit message: the other policies now behave exactly as they did before that commit. The php_fastcgi behaviour change described there still applies. Add a regression test that creates a file literally named "lit[1].txt" and checks that only the fallback entry of first_exist_fallback matches it by its literal name, including the split_path remainder. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fastcgi: add license headers to new test files Address review: phpfastcgi_test.go and fastcgi_windows_test.go were added without the Apache license header used across the codebase. On the Windows test, the header goes above the build constraint, matching replacer_fuzz.go. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fastcgi: avoid hardcoded Unix paths in dot-segment tests Address review: the new tests compared SCRIPT_FILENAME against strings with forward slashes ("/srv/other.php", a "/admin.php" suffix), but the transport makes the root absolute and joins it with filepath.Join, so on Windows the value is C:\srv\other.php and the assertions would fail. Use a temporary directory as the root and build the expected paths with filepath instead. Also lower the bcrypt cost of the integration test's basic_auth hash to the minimum. At cost 14 the authenticated request exceeded the 5s test client timeout under -race. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

pamnario committed Oct 13, 2026 at 18:13 UTC 4ec5a5c39a93f57531d28b2028a90ac725c8ab2f
7 files changed +560 -1
caddytest/integration/phpfastcgi_test.go new
+151
@@ -0,0 +1,151 @@
1 +// Copyright 2015 Matthew Holt and The Caddy Authors
2 +//
3 +// Licensed under the Apache License, Version 2.0 (the "License");
4 +// you may not use this file except in compliance with the License.
5 +// You may obtain a copy of the License at
6 +//
7 +// http://www.apache.org/licenses/LICENSE-2.0
8 +//
9 +// Unless required by applicable law or agreed to in writing, software
10 +// distributed under the License is distributed on an "AS IS" BASIS,
11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +// See the License for the specific language governing permissions and
13 +// limitations under the License.
14 +
15 +package integration
16 +
17 +import (
18 + "fmt"
19 + "io"
20 + "net"
21 + "net/http"
22 + "net/http/fcgi"
23 + "os"
24 + "path/filepath"
25 + "strings"
26 + "testing"
27 +
28 + "github.com/caddyserver/caddy/v2/caddytest"
29 +)
30 +
31 +// startFakePHPFPM starts a FastCGI responder that reports the SCRIPT_FILENAME it
32 +// was asked to execute, so a test can assert which script Caddy selected.
33 +func startFakePHPFPM(t *testing.T) string {
34 + t.Helper()
35 +
36 + ln, err := net.Listen("tcp", "127.0.0.1:0")
37 + if err != nil {
38 + t.Fatalf("listening: %v", err)
39 + }
40 + t.Cleanup(func() { ln.Close() })
41 +
42 + go func() {
43 + _ = fcgi.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
44 + env := fcgi.ProcessEnv(r)
45 + fmt.Fprintf(w, "SCRIPT_FILENAME=%s", env["SCRIPT_FILENAME"])
46 + }))
47 + }()
48 +
49 + return ln.Addr().String()
50 +}
51 +
52 +// A php_fastcgi route protected by a path matcher must not execute a script the
53 +// matcher never evaluated. Path matchers judge the cleaned request path, so
54 +// /admin.php/../../other.php is matched as /other.php and must not cause
55 +// <root>/admin.php to be executed.
56 +//
57 +// `index off` is used so that php_fastcgi generates no rewrite route at all
58 +// (fastcgi/caddyfile.go skips the redirect and try_files routes entirely). That
59 +// rewrite is otherwise the only step that normalizes r.URL.Path before the
60 +// transport reads it, so without this the test would be satisfied by the rewrite
61 +// rather than by the transport, and would not cover the transport's own
62 +// normalization.
63 +func TestPHPFastCGIProtectedRouteIgnoresDotSegments(t *testing.T) {
64 + root := t.TempDir()
65 + if err := os.WriteFile(filepath.Join(root, "admin.php"), []byte("<?php // protected"), 0o644); err != nil {
66 + t.Fatalf("writing admin.php: %v", err)
67 + }
68 +
69 + backend := startFakePHPFPM(t)
70 +
71 + tester := caddytest.NewTester(t)
72 + tester.InitServer(fmt.Sprintf(`{
73 + skip_install_trust
74 + admin localhost:2999
75 + http_port 9080
76 + https_port 9443
77 + grace_period 1ns
78 + }
79 +
80 + localhost:9080 {
81 + root * %s
82 +
83 + handle /admin* {
84 + basic_auth {
85 + alice $2a$04$wqmCpxiUKZl83bZKH.Xp0uFvNQoGz3HoBH3IxBwRMrKoCRfTmc3KK
86 + }
87 + php_fastcgi %s {
88 + index off
89 + }
90 + }
91 +
92 + handle {
93 + php_fastcgi %s {
94 + index off
95 + }
96 + }
97 + }
98 + `, root, backend, backend), "caddyfile")
99 +
100 + get := func(t *testing.T, uri string, basicAuth bool) (int, string) {
101 + t.Helper()
102 + req, err := http.NewRequest(http.MethodGet, "http://localhost:9080"+uri, nil)
103 + if err != nil {
104 + t.Fatalf("building request: %v", err)
105 + }
106 + if basicAuth {
107 + req.SetBasicAuth("alice", "test-password")
108 + }
109 + resp, err := tester.Client.Do(req)
110 + if err != nil {
111 + t.Fatalf("requesting %q: %v", uri, err)
112 + }
113 + defer resp.Body.Close()
114 + body, err := io.ReadAll(resp.Body)
115 + if err != nil {
116 + t.Fatalf("reading body: %v", err)
117 + }
118 + return resp.StatusCode, string(body)
119 + }
120 +
121 + // The gate works for a direct request...
122 + if code, _ := get(t, "/admin.php", false); code != http.StatusUnauthorized {
123 + t.Fatalf("GET /admin.php without credentials: expected 401, got %d", code)
124 + }
125 +
126 + // ...and valid credentials reach the protected script, which proves the route
127 + // is wired up and the 401 above is the gate rather than a misconfiguration.
128 + code, body := get(t, "/admin.php", true)
129 + if code != http.StatusOK {
130 + t.Fatalf("GET /admin.php with credentials: expected 200, got %d", code)
131 + }
132 + if !strings.HasSuffix(body, filepath.Join(root, "admin.php")) {
133 + t.Fatalf("authenticated request should execute admin.php, got %q", body)
134 + }
135 +
136 + // The bypass: every path matcher sees /other.php, so the /admin* gate does not
137 + // fire. The transport must not then select admin.php.
138 + for _, payload := range []string{
139 + "/admin.php/../../other.php",
140 + "/admin.php/../other.php",
141 + "/admin.php/..%2f..%2fother.php",
142 + "/admin.php/%2e%2e/other.php",
143 + } {
144 + t.Run(payload, func(t *testing.T) {
145 + _, body := get(t, payload, false)
146 + if strings.Contains(body, "admin.php") {
147 + t.Fatalf("unauthenticated request %q selected the protected script: %s", payload, body)
148 + }
149 + })
150 + }
151 +}
modules/caddyhttp/fileserver/matcher.go
+29
@@ -416,6 +416,31 @@ func (m MatchFile) selectFile(r *http.Request) (bool, error) {
416 return candidates
417 }
418
419 + // makeLiteralCandidate builds a candidate from file as a literal path,
420 + // without expanding globs or checking that the file exists. It is used only
421 + // for the final entry of first_exist_fallback, which matches unconditionally
422 + // even when globbing found nothing because the file is absent.
423 + makeLiteralCandidate := func(file string) matchCandidate {
424 + // placeholders are not glob-escaped here, since nothing is globbed;
425 + // errors were already logged by makeCandidates for the same pattern
426 + expandedFile, err := repl.ReplaceFunc(file, func(_ string, val any) (any, error) {
427 + return val, nil
428 + })
429 + if err != nil {
430 + expandedFile = file
431 + }
432 + beforeSplit, afterSplit := m.firstSplit(path.Clean(expandedFile))
433 + if strings.HasSuffix(file, "/") {
434 + beforeSplit += "/"
435 + }
436 + fullPath := caddyhttp.SanitizedPathJoin(root, beforeSplit)
437 + return matchCandidate{
438 + fullpath: fullPath,
439 + relative: strings.TrimPrefix(fullPath, root),
440 + splitRemainder: afterSplit,
441 + }
442 + }
443 +
444 // setPlaceholders creates the placeholders for the matched file
445 setPlaceholders := func(candidate matchCandidate, isDir bool) {
446 repl.Set("http.matchers.file.relative", filepath.ToSlash(candidate.relative))
@@ -446,6 +471,10 @@ func (m MatchFile) selectFile(r *http.Request) (bool, error) {
471 }
472
473 candidates := makeCandidates(pattern)
474 + // globbing finds only files that exist, but the fallback entry matches anyway
475 + if i == maxI && len(candidates) == 0 {
476 + candidates = []matchCandidate{makeLiteralCandidate(pattern)}
477 + }
478 for _, c := range candidates {
479 // Skip the IO if using fallback policy and it's the latest item
480 if i == maxI {
modules/caddyhttp/fileserver/matcher_test.go
+196
@@ -547,3 +547,199 @@ func TestMatchExpressionMatch(t *testing.T) {
547 })
548 }
549 }
550 +
551 +// try_policy first_exist_fallback is documented to match unconditionally,
552 +// falling back to the last try_files entry when no earlier candidate exists.
553 +// It must therefore match even when that last entry names a file that is not
554 +// on disk. php_fastcgi relies on this: its generated rewrite route is the only
555 +// step that normalizes r.URL.Path before the FastCGI transport reads it, so a
556 +// matcher that fails here silently removes that normalization.
557 +func TestFirstExistFallbackMatchesWhenFinalFileIsAbsent(t *testing.T) {
558 + for i, tc := range []struct {
559 + name string
560 + tryFiles []string
561 + policy string
562 + path string
563 + matched bool
564 + expectedPath string
565 + }{
566 + {
567 + name: "fallback matches although the final file is absent",
568 + tryFiles: []string{"{http.request.uri.path}", "no-such-index.php"},
569 + policy: tryPolicyFirstExistFallback,
570 + path: "/does-not-exist.php",
571 + matched: true,
572 + expectedPath: "/no-such-index.php",
573 + },
574 + {
575 + name: "fallback still prefers an earlier candidate that exists",
576 + tryFiles: []string{"{http.request.uri.path}", "no-such-index.php"},
577 + policy: tryPolicyFirstExistFallback,
578 + path: "/foo.txt",
579 + matched: true,
580 + expectedPath: "/foo.txt",
581 + },
582 + {
583 + name: "fallback uses the final entry when it does exist",
584 + tryFiles: []string{"{http.request.uri.path}", "index.php"},
585 + policy: tryPolicyFirstExistFallback,
586 + path: "/does-not-exist.php",
587 + matched: true,
588 + expectedPath: "/index.php",
589 + },
590 + {
591 + name: "first_exist does NOT fall back",
592 + tryFiles: []string{"{http.request.uri.path}", "no-such-index.php"},
593 + policy: tryPolicyFirstExist,
594 + path: "/does-not-exist.php",
595 + matched: false,
596 + },
597 + {
598 + name: "empty policy behaves as first_exist",
599 + tryFiles: []string{"{http.request.uri.path}", "no-such-index.php"},
600 + policy: "",
601 + path: "/does-not-exist.php",
602 + matched: false,
603 + },
604 + {
605 + name: "fallback uses the final entry literally when its glob expands to nothing",
606 + tryFiles: []string{"{http.request.uri.path}", "absent[0-9].php"},
607 + policy: tryPolicyFirstExistFallback,
608 + path: "/does-not-exist.php",
609 + matched: true,
610 + expectedPath: "/absent[0-9].php",
611 + },
612 + {
613 + name: "first_exist does not use a pattern expanding to nothing literally",
614 + tryFiles: []string{"absent[0-9].txt"},
615 + policy: tryPolicyFirstExist,
616 + path: "/whatever",
617 + matched: false,
618 + },
619 + } {
620 + t.Run(tc.name, func(t *testing.T) {
621 + m := &MatchFile{
622 + fsmap: &filesystems.FileSystemMap{},
623 + Root: "./testdata",
624 + TryFiles: tc.tryFiles,
625 + TryPolicy: tc.policy,
626 + SplitPath: []string{".php"},
627 + }
628 +
629 + u, err := url.Parse(tc.path)
630 + if err != nil {
631 + t.Fatalf("Test %d: parsing path: %v", i, err)
632 + }
633 + req := &http.Request{URL: u}
634 + repl := caddyhttp.NewTestReplacer(req)
635 +
636 + result, err := m.MatchWithError(req)
637 + if err != nil {
638 + t.Fatalf("Test %d: unexpected error: %v", i, err)
639 + }
640 + if result != tc.matched {
641 + t.Fatalf("Test %d: expected match=%t, got %t", i, tc.matched, result)
642 + }
643 + if !result {
644 + return
645 + }
646 +
647 + rel, _ := repl.Get("http.matchers.file.relative")
648 + if rel != tc.expectedPath {
649 + t.Errorf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath)
650 + }
651 + })
652 + }
653 +}
654 +
655 +// Only the final entry of first_exist_fallback may be used without globbing.
656 +// Every other policy must keep expanding patterns as globs, so a file whose
657 +// literal name contains glob metacharacters is not matched by that name.
658 +func TestLiteralGlobNameMatchesOnlyAsFallback(t *testing.T) {
659 + if runtime.GOOS == "windows" {
660 + t.Skip("globs are not evaluated on Windows")
661 + }
662 +
663 + root := t.TempDir()
664 + if err := os.WriteFile(filepath.Join(root, "lit[1].txt"), []byte("x"), 0o644); err != nil {
665 + t.Fatal(err)
666 + }
667 +
668 + for i, tc := range []struct {
669 + tryFiles []string
670 + policy string
671 + path string
672 + matched bool
673 + expectedPath string
674 + expectedRemainder string
675 + }{
676 + {tryFiles: []string{"lit[1].txt"}, policy: tryPolicyFirstExist, path: "/", matched: false},
677 + {tryFiles: []string{"lit[1].txt"}, policy: tryPolicyLargestSize, path: "/", matched: false},
678 + {tryFiles: []string{"lit[1].txt"}, policy: tryPolicySmallestSize, path: "/", matched: false},
679 + {tryFiles: []string{"lit[1].txt"}, policy: tryPolicyMostRecentlyMod, path: "/", matched: false},
680 + {
681 + tryFiles: []string{"{http.request.uri.path}", "lit[1].txt"},
682 + policy: tryPolicyFirstExistFallback,
683 + path: "/absent.txt",
684 + matched: true,
685 + expectedPath: "/lit[1].txt",
686 + },
687 + {
688 + // only the final entry is used literally; earlier entries are
689 + // still globbed, so lit[1].txt is not matched by its literal name
690 + tryFiles: []string{"lit[1].txt", "fallback.txt"},
691 + policy: tryPolicyFirstExistFallback,
692 + path: "/",
693 + matched: true,
694 + expectedPath: "/fallback.txt",
695 + },
696 + {
697 + tryFiles: []string{"{http.request.uri.path}"},
698 + policy: tryPolicyFirstExistFallback,
699 + path: "/absent.php/extra",
700 + matched: true,
701 + expectedPath: "/absent.php",
702 + expectedRemainder: "/extra",
703 + },
704 + {
705 + // placeholder values are glob-escaped for expansion, but the
706 + // literal fallback must use the unescaped value
707 + tryFiles: []string{"{http.request.uri.path}"},
708 + policy: tryPolicyFirstExistFallback,
709 + path: "/a[b].txt",
710 + matched: true,
711 + expectedPath: "/a[b].txt",
712 + },
713 + } {
714 + m := &MatchFile{
715 + fsmap: &filesystems.FileSystemMap{},
716 + Root: root,
717 + TryFiles: tc.tryFiles,
718 + TryPolicy: tc.policy,
719 + SplitPath: []string{".php"},
720 + }
721 +
722 + req := &http.Request{URL: &url.URL{Path: tc.path}}
723 + repl := caddyhttp.NewTestReplacer(req)
724 +
725 + result, err := m.MatchWithError(req)
726 + if err != nil {
727 + t.Fatalf("Test %d: unexpected error: %v", i, err)
728 + }
729 + if result != tc.matched {
730 + t.Fatalf("Test %d (%s): expected match=%t, got %t", i, tc.policy, tc.matched, result)
731 + }
732 + if !result {
733 + continue
734 + }
735 +
736 + rel, _ := repl.Get("http.matchers.file.relative")
737 + if rel != tc.expectedPath {
738 + t.Errorf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath)
739 + }
740 + rem, _ := repl.Get("http.matchers.file.remainder")
741 + if rem != tc.expectedRemainder {
742 + t.Errorf("Test %d: actual remainder: %v, expected: %v", i, rem, tc.expectedRemainder)
743 + }
744 + }
745 +}
modules/caddyhttp/matchers.go
+12
@@ -662,6 +662,18 @@ var windowsEscapedPathSeparatorRepl = strings.NewReplacer(
662 "%5C", "%2f",
663 )
664
665 +// NormalizedPath returns a decoded request path in the form the path matchers
666 +// evaluate it: rewritten the way the Windows filesystem resolves it (a no-op on
667 +// other platforms), then cleaned. Consumers that act on a path a matcher has
668 +// already judged should derive it with this, so the two cannot disagree about
669 +// which resource a request names. An empty path is returned unchanged.
670 +func NormalizedPath(p string, mergeSlashes bool) string {
671 + if p == "" {
672 + return ""
673 + }
674 + return CleanPath(normalizeWindowsPath(p), mergeSlashes)
675 +}
676 +
677 // normalizeWindowsPath rewrites a decoded request path the way the Windows
678 // filesystem resolves it, so that path matchers (both `path` and `path_regexp`)
679 // cannot be bypassed on Windows: backslashes are treated as path separators,
modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
+9 -1
@@ -293,7 +293,15 @@ func (t Transport) buildEnv(r *http.Request) (envVars, error) {
293 }
294 }
295
296 - fpath := r.URL.Path
296 + // Derive the script from the same path model the matchers judged: Windows
297 + // separators and per-component trailing dots/spaces normalized, then cleaned
298 + // (mirroring MatchPath and MatchURLPattern). Caddy does not rewrite
299 + // r.URL.Path itself, and the rewrite route php_fastcgi relies on to normalize
300 + // it can fail to match, so the transport must establish this invariant rather
301 + // than inherit it. Without this, /admin.php/../../other.php (or, on Windows,
302 + // /admin.php\..\other.php) is judged by every path matcher as /other.php
303 + // while SCRIPT_FILENAME resolves to /admin.php.
304 + fpath := caddyhttp.NormalizedPath(r.URL.Path, true)
305 scriptName := fpath
306
307 docURI := fpath
modules/caddyhttp/reverseproxy/fastcgi/fastcgi_test.go
+89
@@ -5,6 +5,7 @@ import (
5 "net"
6 "net/http"
7 "net/http/httptest"
8 + "path/filepath"
9 "strings"
10 "testing"
11
@@ -485,3 +486,91 @@ func TestBuildEnvDropsProxyHeader(t *testing.T) {
486 assert.Equal(t, "http://trusted.example:3128", env["HTTP_PROXY"])
487 })
488 }
489 +
490 +// A request path containing dot segments must not let the client select a
491 +// different script than the one every path matcher in the chain evaluated.
492 +// Matchers judge the cleaned path (see caddyhttp.MatchPath), so the transport
493 +// must derive SCRIPT_NAME/SCRIPT_FILENAME/PATH_INFO from the cleaned path too.
494 +// Before this was fixed, /admin.php/../../other.php was matched as /other.php
495 +// but executed <root>/admin.php.
496 +func TestBuildEnvDotSegmentsCannotSelectAnotherScript(t *testing.T) {
497 + t.Parallel()
498 +
499 + newRequest := func(target string) *http.Request {
500 + r := httptest.NewRequest(http.MethodGet, target, nil)
501 + ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, caddy.NewReplacer())
502 + ctx = context.WithValue(ctx, caddyhttp.VarsCtxKey, map[string]any{})
503 + ctx = context.WithValue(ctx, caddyhttp.OriginalRequestCtxKey, *r)
504 + return r.WithContext(ctx)
505 + }
506 +
507 + for _, tc := range []struct {
508 + name string
509 + target string
510 + expectedScriptName string
511 + expectedPathInfo string
512 + }{
513 + {
514 + name: "literal dot segments",
515 + target: "/admin.php/../../other.php",
516 + expectedScriptName: "/other.php",
517 + expectedPathInfo: "",
518 + },
519 + {
520 + name: "single dot segment",
521 + target: "/admin.php/../other.php",
522 + expectedScriptName: "/other.php",
523 + expectedPathInfo: "",
524 + },
525 + {
526 + name: "percent-encoded separators",
527 + target: "/admin.php/..%2f..%2fother.php",
528 + expectedScriptName: "/other.php",
529 + expectedPathInfo: "",
530 + },
531 + {
532 + name: "percent-encoded dots",
533 + target: "/admin.php/%2e%2e/other.php",
534 + expectedScriptName: "/other.php",
535 + expectedPathInfo: "",
536 + },
537 + {
538 + name: "dot segments before legitimate path info",
539 + target: "/admin.php/../other.php/extra/path",
540 + expectedScriptName: "/other.php",
541 + expectedPathInfo: "/extra/path",
542 + },
543 + {
544 + name: "no dot segments is unaffected",
545 + target: "/other.php/extra/path",
546 + expectedScriptName: "/other.php",
547 + expectedPathInfo: "/extra/path",
548 + },
549 + {
550 + // path.Clean("") is ".", which must not become the script name;
551 + // an absolute-form request-target can leave URL.Path empty.
552 + name: "empty path stays empty",
553 + target: "http://example.com",
554 + expectedScriptName: "",
555 + expectedPathInfo: "",
556 + },
557 + } {
558 + t.Run(tc.name, func(t *testing.T) {
559 + root := t.TempDir()
560 + tr := Transport{Root: root, SplitPath: []string{".php"}}
561 + env, err := tr.buildEnv(newRequest(tc.target))
562 + require.NoError(t, err)
563 +
564 + assert.Equal(t, tc.expectedScriptName, env["SCRIPT_NAME"],
565 + "SCRIPT_NAME must come from the cleaned path")
566 + assert.Equal(t, tc.expectedPathInfo, env["PATH_INFO"],
567 + "PATH_INFO must come from the cleaned path")
568 + if tc.expectedScriptName != "" {
569 + assert.Equal(t, filepath.Join(root, filepath.FromSlash(tc.expectedScriptName)), env["SCRIPT_FILENAME"],
570 + "SCRIPT_FILENAME must name the script the matchers evaluated")
571 + }
572 + assert.NotContains(t, env["SCRIPT_FILENAME"], "admin.php",
573 + "a pre-clean path component must never become the executed script")
574 + })
575 + }
576 +}
modules/caddyhttp/reverseproxy/fastcgi/fastcgi_windows_test.go new
+74
@@ -0,0 +1,74 @@
1 +// Copyright 2015 Matthew Holt and The Caddy Authors
2 +//
3 +// Licensed under the Apache License, Version 2.0 (the "License");
4 +// you may not use this file except in compliance with the License.
5 +// You may obtain a copy of the License at
6 +//
7 +// http://www.apache.org/licenses/LICENSE-2.0
8 +//
9 +// Unless required by applicable law or agreed to in writing, software
10 +// distributed under the License is distributed on an "AS IS" BASIS,
11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +// See the License for the specific language governing permissions and
13 +// limitations under the License.
14 +
15 +//go:build windows
16 +
17 +package fastcgi
18 +
19 +import (
20 + "context"
21 + "net/http"
22 + "net/http/httptest"
23 + "testing"
24 +
25 + "github.com/caddyserver/caddy/v2"
26 + "github.com/caddyserver/caddy/v2/modules/caddyhttp"
27 + "github.com/stretchr/testify/assert"
28 + "github.com/stretchr/testify/require"
29 +)
30 +
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.
35 +func TestBuildEnvWindowsSeparatorsCannotSelectAnotherScript(t *testing.T) {
36 + t.Parallel()
37 +
38 + newRequest := func(target string) *http.Request {
39 + r := httptest.NewRequest(http.MethodGet, target, nil)
40 + ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, caddy.NewReplacer())
41 + ctx = context.WithValue(ctx, caddyhttp.VarsCtxKey, map[string]any{})
42 + ctx = context.WithValue(ctx, caddyhttp.OriginalRequestCtxKey, *r)
43 + return r.WithContext(ctx)
44 + }
45 +
46 + for _, tc := range []struct {
47 + name string
48 + target string
49 + expectedScriptName string
50 + expectedPathInfo string
51 + }{
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", ""},
55 + // Per-component trailing dot/space folding is Windows-only and is not
56 + // something CleanPath does, so this case fails unless the transport
57 + // 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"},
60 + } {
61 + t.Run(tc.name, func(t *testing.T) {
62 + tr := Transport{Root: `C:\srv`, SplitPath: []string{".php"}}
63 + env, err := tr.buildEnv(newRequest(tc.target))
64 + require.NoError(t, err)
65 +
66 + assert.Equal(t, tc.expectedScriptName, env["SCRIPT_NAME"],
67 + "SCRIPT_NAME must come from the normalized, cleaned path")
68 + assert.Equal(t, tc.expectedPathInfo, env["PATH_INFO"],
69 + "PATH_INFO must come from the normalized, cleaned path")
70 + assert.NotContains(t, env["SCRIPT_FILENAME"], "admin.php",
71 + "a pre-normalization path component must never become the executed script")
72 + })
73 + }
74 +}