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, 03:30 UTC
fileserver: apply hide rules to browse entries and sidecars (#8155)
ammann committed
Oct 7, 2026 at 20:26 UTC
d65583e6d2d02af71490b727ef88785458661a03
5 files changed
+135
-8
modules/caddyhttp/fileserver/browse.go
+3
-3
@@ -116,7 +116,7 @@ func (fsrv *FileServer) serveBrowse(fileSystem fs.FS, root, dirPath string, w ht
116
repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
117
118
// TODO: not entirely sure if path.Clean() is necessary here but seems like a safe plan (i.e. /%2e%2e%2f) - someone could verify this
119
- listing, err := fsrv.loadDirectoryContents(r.Context(), fileSystem, dir.(fs.ReadDirFile), root, path.Clean(r.URL.EscapedPath()), repl)
119
+ listing, err := fsrv.loadDirectoryContents(r.Context(), fileSystem, dir.(fs.ReadDirFile), dirPath, path.Clean(r.URL.EscapedPath()), repl)
120
switch {
121
case errors.Is(err, fs.ErrPermission):
122
return caddyhttp.Error(http.StatusForbidden, err)
@@ -213,7 +213,7 @@ func (fsrv *FileServer) serveBrowse(fileSystem fs.FS, root, dirPath string, w ht
213
return nil
214
}
215
216
-func (fsrv *FileServer) loadDirectoryContents(ctx context.Context, fileSystem fs.FS, dir fs.ReadDirFile, root, urlPath string, repl *caddy.Replacer) (*browseTemplateContext, error) {
216
+func (fsrv *FileServer) loadDirectoryContents(ctx context.Context, fileSystem fs.FS, dir fs.ReadDirFile, dirPath, urlPath string, repl *caddy.Replacer) (*browseTemplateContext, error) {
217
// modTime for the directory itself
218
stat, err := dir.Stat()
219
if err != nil {
@@ -231,7 +231,7 @@ func (fsrv *FileServer) loadDirectoryContents(ctx context.Context, fileSystem fs
231
// user can presumably browse "up" to parent folder if path is longer than "/"
232
canGoUp := len(urlPath) > 1
233
234
- return fsrv.directoryListing(ctx, fileSystem, stat.ModTime(), files, canGoUp, root, urlPath, repl), nil
234
+ return fsrv.directoryListing(ctx, fileSystem, stat.ModTime(), files, canGoUp, dirPath, urlPath, repl), nil
235
}
236
237
// browseApplyQueryParams applies query parameters to the listing.
modules/caddyhttp/fileserver/browsetplcontext.go
+4
-4
@@ -34,7 +34,7 @@ import (
34
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
35
)
36
37
-func (fsrv *FileServer) directoryListing(ctx context.Context, fileSystem fs.FS, parentModTime time.Time, entries []fs.DirEntry, canGoUp bool, root, urlPath string, repl *caddy.Replacer) *browseTemplateContext {
37
+func (fsrv *FileServer) directoryListing(ctx context.Context, fileSystem fs.FS, parentModTime time.Time, entries []fs.DirEntry, canGoUp bool, dirPath, urlPath string, repl *caddy.Replacer) *browseTemplateContext {
38
filesToHide := fsrv.transformHidePaths(repl)
39
40
// urlPath is escaped, but filesystem lookups need the decoded path
@@ -57,14 +57,14 @@ func (fsrv *FileServer) directoryListing(ctx context.Context, fileSystem fs.FS,
57
58
name := entry.Name()
59
60
- if fileHidden(name, filesToHide) {
60
+ if fileHidden(caddyhttp.SanitizedPathJoin(dirPath, name), filesToHide) {
61
continue
62
}
63
64
info, err := entry.Info()
65
if err != nil {
66
if c := fsrv.logger.Check(zapcore.ErrorLevel, "could not get info about directory entry"); c != nil {
67
- c.Write(zap.String("name", entry.Name()), zap.String("root", root))
67
+ c.Write(zap.String("name", entry.Name()), zap.String("dir_path", dirPath))
68
}
69
continue
70
}
@@ -83,7 +83,7 @@ func (fsrv *FileServer) directoryListing(ctx context.Context, fileSystem fs.FS,
83
var targetInfo fs.FileInfo
84
var targetPath string
85
if fileIsSymlink {
86
- targetPath = caddyhttp.SanitizedPathJoin(root, path.Join(reqPath, info.Name()))
86
+ targetPath = caddyhttp.SanitizedPathJoin(dirPath, info.Name())
87
// An error most likely means the symlink target doesn't exist,
88
// which isn't entirely unusual and shouldn't fail the listing.
89
// In this case, targetInfo stays nil and we fall back to
modules/caddyhttp/fileserver/browsetplcontext_test.go
+1
-1
@@ -136,7 +136,7 @@ func TestDirectoryListingSymlinkInEscapedPath(t *testing.T) {
136
}
137
138
fileSystem, entries := readBenchDirEntries(t, dir)
139
- listing := benchFileServer().directoryListing(context.Background(), fileSystem, time.Time{}, entries, true, root, tc.urlPath, caddy.NewReplacer())
139
+ listing := benchFileServer().directoryListing(context.Background(), fileSystem, time.Time{}, entries, true, dir, tc.urlPath, caddy.NewReplacer())
140
if len(listing.Items) != 1 || !listing.Items[0].IsDir {
141
t.Errorf("Test %d: expected the symlink in %q to be listed as a directory", i, tc.dir)
142
}
modules/caddyhttp/fileserver/staticfiles.go
+3
@@ -492,6 +492,9 @@ func (fsrv *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request, next c
492
continue
493
}
494
compressedFilename := filename + precompress.Suffix()
495
+ if fileHidden(compressedFilename, filesToHide) {
496
+ continue
497
+ }
498
compressedInfo, err := fs.Stat(fileSystem, compressedFilename)
499
if err != nil || compressedInfo.IsDir() {
500
if c := fsrv.logger.Check(zapcore.DebugLevel, "precompressed file not accessible"); c != nil {
modules/caddyhttp/fileserver/staticfiles_hide_test.go
new
+124
@@ -0,0 +1,124 @@
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 fileserver
16
+
17
+import (
18
+ "bytes"
19
+ "context"
20
+ "net/http"
21
+ "net/http/httptest"
22
+ "os"
23
+ "path/filepath"
24
+ "testing"
25
+
26
+ "github.com/caddyserver/caddy/v2"
27
+ "github.com/caddyserver/caddy/v2/modules/caddyhttp"
28
+ "github.com/caddyserver/caddy/v2/modules/caddyhttp/encode"
29
+)
30
+
31
+func TestHiddenPrecompressedSidecar(t *testing.T) {
32
+ root := t.TempDir()
33
+ base := []byte("public response body")
34
+ sidecar := gzipBytes(t, []byte("different hidden body"))
35
+ if err := os.WriteFile(filepath.Join(root, "public.txt"), base, 0o600); err != nil {
36
+ t.Fatal(err)
37
+ }
38
+ hiddenPath := filepath.Join(root, "public.txt.gz")
39
+ if err := os.WriteFile(hiddenPath, sidecar, 0o600); err != nil {
40
+ t.Fatal(err)
41
+ }
42
+
43
+ fsrv := FileServer{
44
+ Root: root,
45
+ Hide: []string{hiddenPath},
46
+ CanonicalURIs: new(bool),
47
+ PrecompressedOrder: []string{"gzip"},
48
+ }
49
+ ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()})
50
+ if err := fsrv.Provision(ctx); err != nil {
51
+ t.Fatal(err)
52
+ }
53
+ fsrv.precompressors = map[string]encode.Precompressed{
54
+ "gzip": testPrecompressed{encoding: "gzip", suffix: ".gz"},
55
+ }
56
+
57
+ w := httptest.NewRecorder()
58
+ r := newPrecompressedRequest(t, "/public.txt")
59
+ r.Header.Set("Accept-Encoding", "gzip")
60
+ if err := fsrv.ServeHTTP(w, r, nil); err != nil {
61
+ t.Fatal(err)
62
+ }
63
+ if got := w.Code; got != http.StatusOK {
64
+ t.Fatalf("status = %d, want %d", got, http.StatusOK)
65
+ }
66
+ if got := w.Header().Get("Content-Encoding"); got != "" {
67
+ t.Errorf("Content-Encoding = %q, want no encoding for a hidden sidecar", got)
68
+ }
69
+ if got := w.Body.Bytes(); !bytes.Equal(got, base) {
70
+ t.Errorf("body = %x, want base file body %x", got, base)
71
+ }
72
+}
73
+
74
+func TestBrowseHidesPathQualifiedEntries(t *testing.T) {
75
+ for _, tc := range []struct {
76
+ name string
77
+ requestURI string
78
+ dirName string
79
+ }{
80
+ {name: "root", requestURI: "/"},
81
+ {name: "encoded slash and dot segment", requestURI: "/a%2Fb/../", dirName: "a"},
82
+ } {
83
+ t.Run(tc.name, func(t *testing.T) {
84
+ root := t.TempDir()
85
+ dir := filepath.Join(root, tc.dirName)
86
+ if err := os.MkdirAll(dir, 0o700); err != nil {
87
+ t.Fatal(err)
88
+ }
89
+ hiddenPath := filepath.Join(dir, "secret.txt")
90
+ if err := os.WriteFile(hiddenPath, []byte("hidden body"), 0o600); err != nil {
91
+ t.Fatal(err)
92
+ }
93
+ if err := os.WriteFile(filepath.Join(dir, "public.txt"), []byte("public body"), 0o600); err != nil {
94
+ t.Fatal(err)
95
+ }
96
+
97
+ fsrv := FileServer{Root: root, Hide: []string{hiddenPath}, Browse: &Browse{}}
98
+ ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()})
99
+ if err := fsrv.Provision(ctx); err != nil {
100
+ t.Fatal(err)
101
+ }
102
+
103
+ r := httptest.NewRequest(http.MethodGet, tc.requestURI, nil)
104
+ original := *r
105
+ reqCtx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, caddy.NewReplacer())
106
+ reqCtx = context.WithValue(reqCtx, caddyhttp.OriginalRequestCtxKey, original)
107
+ r = r.WithContext(reqCtx)
108
+ r.Header.Set("Accept", "application/json")
109
+ w := httptest.NewRecorder()
110
+ if err := fsrv.ServeHTTP(w, r, nil); err != nil {
111
+ t.Fatal(err)
112
+ }
113
+ if got := w.Code; got != http.StatusOK {
114
+ t.Fatalf("status = %d, want %d", got, http.StatusOK)
115
+ }
116
+ if bytes.Contains(w.Body.Bytes(), []byte("secret.txt")) {
117
+ t.Errorf("browse response includes hidden path: %s", w.Body.String())
118
+ }
119
+ if !bytes.Contains(w.Body.Bytes(), []byte("public.txt")) {
120
+ t.Errorf("browse response omits public path: %s", w.Body.String())
121
+ }
122
+ })
123
+ }
124
+}