@marcel91200 / caddy Mirror · read-only / commits / d65583e6
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 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 +}