Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS
Mirrored from https://github.com/Slazee/caddy.git
Synced Nov 10, 23:45 UTC
fileserver: apply slash rules to fallback entry (#8387)
* fileserver: apply slash rules to fallback entry The literal candidate built for the final entry of first_exist_fallback cleaned the path without the rules of #8326 and #8352. With "{path}" as that entry, "/.env/" and "/.env/./" matched the regular file "/.env", bypassing path matchers that see the trailing slash. Both candidate builders now share the same cleaning and splitting. * fileserver: handle Windows separators in file matcher
Kévin Dunglas committed
Nov 8, 2026 at 23:22 UTC
fcfdef43c566a00aa5b7d6c50329b90ffe00e241
2 files changed
+78
-20
modules/caddyhttp/fileserver/matcher.go
+31
-20
@@ -19,7 +19,6 @@ import (
19
"io/fs"
20
"net/http"
21
"os"
22
- "path"
22
"path/filepath"
23
"runtime"
24
"strconv"
@@ -378,19 +377,10 @@ func (m MatchFile) selectFile(r *http.Request) (bool, error) {
377
expandedFile = file // "oh well," I guess?
378
}
379
381
- // clean the path and split, if configured -- we must split before
382
- // globbing so that the file system doesn't include the remainder
383
- // ("afterSplit") in the filename; be sure to restore trailing slash
384
- // so that "/file/" or "/file/./" cannot match a regular file
385
- cleanedFile := path.Clean(expandedFile)
386
- if strings.HasSuffix(expandedFile, "/") && !strings.HasSuffix(cleanedFile, "/") {
387
- cleanedFile += "/"
388
- }
389
- beforeSplit, afterSplit := m.firstSplit(cleanedFile)
390
-
391
- // only a pattern ending in "/" matches a directory, so with "{path}" a
392
- // request for "/dir/" falls through to the next entry (e.g. "{path}/index.php")
393
- if beforeSplit != "/" && strings.HasSuffix(beforeSplit, "/") && !strings.HasSuffix(file, "/") {
380
+ // we must split before globbing so that the file system
381
+ // doesn't include the remainder ("afterSplit") in the filename
382
+ beforeSplit, afterSplit, ok := m.cleanSplit(file, expandedFile)
383
+ if !ok {
384
return nil
385
}
386
@@ -428,7 +418,7 @@ func (m MatchFile) selectFile(r *http.Request) (bool, error) {
418
// without expanding globs or checking that the file exists. It is used only
419
// for the final entry of first_exist_fallback, which matches unconditionally
420
// even when globbing found nothing because the file is absent.
431
- makeLiteralCandidate := func(file string) matchCandidate {
421
+ makeLiteralCandidate := func(file string) (matchCandidate, bool) {
422
// placeholders are not glob-escaped here, since nothing is globbed;
423
// errors were already logged by makeCandidates for the same pattern
424
expandedFile, err := repl.ReplaceFunc(file, func(_ string, val any) (any, error) {
@@ -437,16 +427,16 @@ func (m MatchFile) selectFile(r *http.Request) (bool, error) {
427
if err != nil {
428
expandedFile = file
429
}
440
- beforeSplit, afterSplit := m.firstSplit(path.Clean(expandedFile))
441
- if strings.HasSuffix(file, "/") {
442
- beforeSplit += "/"
430
+ beforeSplit, afterSplit, ok := m.cleanSplit(file, expandedFile)
431
+ if !ok {
432
+ return matchCandidate{}, false
433
}
434
fullPath := caddyhttp.SanitizedPathJoin(root, beforeSplit)
435
return matchCandidate{
436
fullpath: fullPath,
437
relative: strings.TrimPrefix(fullPath, root),
438
splitRemainder: afterSplit,
449
- }
439
+ }, true
440
}
441
442
// setPlaceholders creates the placeholders for the matched file
@@ -481,7 +471,9 @@ func (m MatchFile) selectFile(r *http.Request) (bool, error) {
471
candidates := makeCandidates(pattern)
472
// globbing finds only files that exist, but the fallback entry matches anyway
473
if i == maxI && len(candidates) == 0 {
484
- candidates = []matchCandidate{makeLiteralCandidate(pattern)}
474
+ if c, ok := makeLiteralCandidate(pattern); ok {
475
+ candidates = []matchCandidate{c}
476
+ }
477
}
478
for _, c := range candidates {
479
// Skip the IO if using fallback policy and it's the latest item
@@ -607,6 +599,25 @@ func (m MatchFile) strictFileExists(fileSystem fs.FS, file string) (os.FileInfo,
599
return info, !info.IsDir()
600
}
601
602
+// cleanSplit cleans expanded, the expansion of pattern, and splits it if
603
+// configured. It keeps the trailing slash so that "/file/" or "/file/./"
604
+// cannot match a regular file, and reports false for a path ending in "/"
605
+// unless pattern does too, since only such patterns match a directory.
606
+func (m MatchFile) cleanSplit(pattern, expanded string) (beforeSplit, afterSplit string, ok bool) {
607
+ cleaned := caddyhttp.NormalizedPath(expanded, true)
608
+ if cleaned == "" {
609
+ // Preserve path.Clean's existing result for an empty path.
610
+ cleaned = "."
611
+ }
612
+ beforeSplit, afterSplit = m.firstSplit(cleaned)
613
+
614
+ // with "{path}", a request for "/dir/" falls through to the next entry (e.g. "{path}/index.php")
615
+ if beforeSplit != "/" && strings.HasSuffix(beforeSplit, "/") && !strings.HasSuffix(pattern, "/") {
616
+ return "", "", false
617
+ }
618
+ return beforeSplit, afterSplit, true
619
+}
620
+
621
// firstSplit returns the first result where the path
622
// can be split in two by a value in m.SplitPath. The
623
// return values are the first piece of the path that
modules/caddyhttp/fileserver/matcher_test.go
+47
@@ -626,6 +626,20 @@ func TestFirstExistFallbackMatchesWhenFinalFileIsAbsent(t *testing.T) {
626
matched: true,
627
expectedPath: "/no-such-index.php",
628
},
629
+ {
630
+ name: "fallback does not match a regular file with a trailing slash",
631
+ tryFiles: []string{"{http.request.uri.path}"},
632
+ policy: tryPolicyFirstExistFallback,
633
+ path: "/foo.txt/",
634
+ matched: false,
635
+ },
636
+ {
637
+ name: "fallback does not match a regular file with a trailing dot segment",
638
+ tryFiles: []string{"{http.request.uri.path}"},
639
+ policy: tryPolicyFirstExistFallback,
640
+ path: "/foo.txt/./",
641
+ matched: false,
642
+ },
643
{
644
name: "fallback still prefers an earlier candidate that exists",
645
tryFiles: []string{"{http.request.uri.path}", "no-such-index.php"},
@@ -707,6 +721,39 @@ func TestFirstExistFallbackMatchesWhenFinalFileIsAbsent(t *testing.T) {
721
}
722
}
723
724
+func TestFileMatcherRejectsWindowsSeparators(t *testing.T) {
725
+ if runtime.GOOS != "windows" {
726
+ t.Skip("backslash is a path separator only on Windows")
727
+ }
728
+
729
+ for _, policy := range []string{tryPolicyFirstExist, tryPolicyFirstExistFallback} {
730
+ for _, requestTarget := range []string{
731
+ "/foo.txt%5c",
732
+ "/foo.txt%5C",
733
+ `/foo.txt\`,
734
+ } {
735
+ t.Run(policy+requestTarget, func(t *testing.T) {
736
+ m := &MatchFile{
737
+ fsmap: &filesystems.FileSystemMap{},
738
+ Root: "./testdata",
739
+ TryFiles: []string{"{http.request.uri.path}"},
740
+ TryPolicy: policy,
741
+ }
742
+ req := httptest.NewRequest(http.MethodGet, requestTarget, nil)
743
+ caddyhttp.NewTestReplacer(req)
744
+
745
+ matched, err := m.MatchWithError(req)
746
+ if err != nil {
747
+ t.Fatal(err)
748
+ }
749
+ if matched {
750
+ t.Fatal("separator-qualified regular file matched")
751
+ }
752
+ })
753
+ }
754
+ }
755
+}
756
+
757
// Only the final entry of first_exist_fallback may be used without globbing.
758
// Every other policy must keep expanding patterns as globs, so a file whose
759
// literal name contains glob metacharacters is not matched by that name.