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