@marcel91200 / caddy Mirror · read-only / commits / af4d0c37
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: ignore case for literal hide entries (#8378)

On case-insensitive filesystems (APFS and NTFS by default), a request using another letter case opens a hidden file anyway: the Caddyfile auto-hidden by httpcaddyfile is served for /caddyfile. Entries without glob characters now match regardless of letter case. Glob patterns keep their case-sensitive matching. On case-sensitive filesystems, a literal entry also hides names that differ from it only by case.

Kévin Dunglas committed Nov 8, 2026 at 22:52 UTC af4d0c37d48c93d760977eae2726f38895b22e91
3 files changed +135 -3
modules/caddyhttp/fileserver/staticfiles.go
+45 -3
@@ -27,10 +27,12 @@ import (
27 "path"
28 "path/filepath"
29 "runtime"
30 + "slices"
31 "strconv"
32 "strings"
33 "syscall"
34 "time"
35 + "unicode"
36
37 "go.uber.org/zap"
38 "go.uber.org/zap/zapcore"
@@ -128,9 +130,11 @@ type FileServer struct {
130 // comparisons are made. For maximum clarity and explicitness, use complete,
131 // absolute paths; or, for greater portability, use relative paths instead.
132 //
131 - // Note that hide comparisons are case-sensitive. On case-insensitive
132 - // filesystems, requests with different path casing may still resolve to the
133 - // same file or directory on disk, so hide should not be treated as a
133 + // Entries without glob characters are compared ignoring letter case, since
134 + // case-insensitive filesystems open differently cased paths as the same
135 + // file; glob patterns are compared case-sensitively. Hide is a best-effort
136 + // filter: glob patterns, Unicode normalization variants, symlinks and hard
137 + // links can still reach hidden files, so hide should not be treated as a
138 // security boundary for sensitive paths.
139 Hide []string `json:"hide,omitempty"`
140
@@ -724,8 +728,21 @@ func fileHidden(filename string, hide []string) bool {
728 }
729
730 var components []string
731 + var folded string
732
733 for _, h := range hide {
734 + if !hasGlobMeta(h) {
735 + // case-insensitive filesystems open other spellings of a literal
736 + // entry as the same file
737 + if folded == "" {
738 + folded = foldCase(filename)
739 + }
740 + if literalHidden(foldCase(h), folded) {
741 + return true
742 + }
743 + continue
744 + }
745 +
746 if !strings.Contains(h, separator) {
747 // if there is no separator in h, then we assume the user
748 // wants to hide any files or folders that match that
@@ -759,6 +776,31 @@ func fileHidden(filename string, hide []string) bool {
776 return false
777 }
778
779 +// literalHidden reports whether filename is the literal entry h or is inside
780 +// it, or, if h has no separator, has a path component named h.
781 +func literalHidden(h, filename string) bool {
782 + if !strings.Contains(h, separator) {
783 + return slices.Contains(strings.Split(filename, separator), h)
784 + }
785 + after, ok := strings.CutPrefix(filename, h)
786 + return ok && (after == "" || strings.HasPrefix(after, separator))
787 +}
788 +
789 +// hasGlobMeta reports whether filepath.Match treats h as a pattern.
790 +func hasGlobMeta(h string) bool {
791 + magic := `*?[\`
792 + if runtime.GOOS == "windows" {
793 + magic = `*?[`
794 + }
795 + return strings.ContainsAny(h, magic)
796 +}
797 +
798 +// foldCase maps the letter cases that case-insensitive filesystems treat as
799 +// the same name to one form.
800 +func foldCase(s string) string {
801 + return strings.Map(func(r rune) rune { return unicode.ToLower(unicode.ToUpper(r)) }, s)
802 +}
803 +
804 // notFound returns a 404 error or, if pass-thru is enabled,
805 // it calls the next handler in the chain.
806 func (fsrv *FileServer) notFound(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
modules/caddyhttp/fileserver/staticfiles_hide_test.go
+40
@@ -17,6 +17,7 @@ package fileserver
17 import (
18 "bytes"
19 "context"
20 + "errors"
21 "net/http"
22 "net/http/httptest"
23 "os"
@@ -142,3 +143,42 @@ func TestHideRelativeDataDir(t *testing.T) {
143 t.Errorf("caddyfile.go is hidden; hide list: %v", fsrv.Hide)
144 }
145 }
146 +
147 +func TestHideOtherLetterCases(t *testing.T) {
148 + root := t.TempDir()
149 + if err := os.WriteFile(filepath.Join(root, "Caddyfile"), []byte("hidden config"), 0o600); err != nil {
150 + t.Fatal(err)
151 + }
152 + if err := os.Mkdir(filepath.Join(root, "secretdir"), 0o700); err != nil {
153 + t.Fatal(err)
154 + }
155 + if err := os.WriteFile(filepath.Join(root, "secretdir", "p.txt"), []byte("hidden body"), 0o600); err != nil {
156 + t.Fatal(err)
157 + }
158 +
159 + fsrv := FileServer{
160 + Root: root,
161 + Hide: []string{filepath.Join(root, "Caddyfile"), "secretdir"},
162 + CanonicalURIs: new(bool),
163 + }
164 + ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()})
165 + if err := fsrv.Provision(ctx); err != nil {
166 + t.Fatal(err)
167 + }
168 +
169 + for _, uri := range []string{"/Caddyfile", "/caddyfile", "/CADDYFILE", "/secretdir/p.txt", "/SecretDir/p.txt"} {
170 + t.Run(uri, func(t *testing.T) {
171 + r := httptest.NewRequest(http.MethodGet, uri, nil)
172 + original := *r
173 + reqCtx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, caddy.NewReplacer())
174 + reqCtx = context.WithValue(reqCtx, caddyhttp.OriginalRequestCtxKey, original)
175 + r = r.WithContext(reqCtx)
176 + w := httptest.NewRecorder()
177 + err := fsrv.ServeHTTP(w, r, nil)
178 + var handlerErr caddyhttp.HandlerError
179 + if !errors.As(err, &handlerErr) || handlerErr.StatusCode != http.StatusNotFound {
180 + t.Errorf("got error %v and status %d, want 404", err, w.Code)
181 + }
182 + })
183 + }
184 +}
modules/caddyhttp/fileserver/staticfiles_test.go
+50
@@ -121,6 +121,56 @@ func TestFileHidden(t *testing.T) {
121 inputPath: "/second",
122 expect: true,
123 },
124 + {
125 + inputHide: []string{"/srv/Caddyfile"},
126 + inputPath: "/srv/caddyfile",
127 + expect: true,
128 + },
129 + {
130 + inputHide: []string{"/srv/Caddyfile"},
131 + inputPath: "/SRV/CADDYFILE",
132 + expect: true,
133 + },
134 + {
135 + inputHide: []string{"secretdir"},
136 + inputPath: "/SecretDir/p.txt",
137 + expect: true,
138 + },
139 + {
140 + inputHide: []string{"/srv/secretdir"},
141 + inputPath: "/srv/SECRETDIR/p.txt",
142 + expect: true,
143 + },
144 + {
145 + inputHide: []string{"/srv/public.txt.gz"},
146 + inputPath: "/srv/PUBLIC.TXT.gz",
147 + expect: true,
148 + },
149 + {
150 + inputHide: []string{"secret"},
151 + inputPath: "/\u017Fecret",
152 + expect: true,
153 + },
154 + {
155 + inputHide: []string{"kelvin"},
156 + inputPath: "/\u212Aelvin",
157 + expect: true,
158 + },
159 + {
160 + inputHide: []string{"Secret"},
161 + inputPath: "/secrets",
162 + expect: false,
163 + },
164 + {
165 + inputHide: []string{"/foo"},
166 + inputPath: "/FOOBAR",
167 + expect: false,
168 + },
169 + {
170 + inputHide: []string{"*.secret"},
171 + inputPath: "/X.SECRET",
172 + expect: false,
173 + },
174 } {
175 if runtime.GOOS == "windows" {
176 if strings.HasPrefix(tc.inputPath, "/") {