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, "/") {