From 6c53bf97ebce549dd3e9e1531b2ae40e8d275417 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 03:02:24 +0100 Subject: [PATCH 01/26] tests(static, celeris): a Root of '.', './' and '/' serves the tree; the test that pinned the defect is replaced (v1.6.0, Refs #888) Red on main: TestRootSpellings{ServeTheTree,ConditionalRequests,FollowSymlinksInside, RefuseSymlinksOut,TraversalMatchesTheAbsoluteRoot,BrowseAndSPA,OverTheWire}888, TestRootSlashHasNoOutside888, TestRoot304DotRootDoesNotFollowSymlinkOut888 (flipped: a path below the root now reaches the containment check and is refused with a 400) and the root package's TestFileFromDirDotAndSlashBases888. TestRootDotAndSlashServeOnlyTheRootIndex888, which pinned the defect, is removed. --- file_from_dir_root_888_test.go | 128 ++++++ .../static/root_304_dot_root_888_test.go | 65 +-- middleware/static/root_spellings_888_test.go | 425 ++++++++++++++++++ 3 files changed, 561 insertions(+), 57 deletions(-) create mode 100644 file_from_dir_root_888_test.go create mode 100644 middleware/static/root_spellings_888_test.go diff --git a/file_from_dir_root_888_test.go b/file_from_dir_root_888_test.go new file mode 100644 index 000000000..fd618d542 --- /dev/null +++ b/file_from_dir_root_888_test.go @@ -0,0 +1,128 @@ +package celeris + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// Context.FileFromDir with a baseDir of ".", "./" and "/" (celeris#888 item +// 46). The containment check compared the cleaned path with base + "/", which +// no path below "." or "/" starts with, so every file below these bases was +// refused with a 400 ("invalid file path") and middleware/static, which serves +// through it, served nothing below such a Root. Server.Static and Group.Static +// serve through it as well. + +func fileFromDir888(t *testing.T, base, userPath string) (status int, body string, err error) { + t.Helper() + s, rw := newTestStream("GET", "/f") + defer s.Release() + c := acquireContext(s) + defer releaseContext(c) + err = c.FileFromDir(base, userPath) + return rw.status, string(rw.body), err +} + +func isInvalidPath888(err error) bool { + var he *HTTPError + return errors.As(err, &he) && he.Code == 400 +} + +func TestFileFromDirDotAndSlashBases888(t *testing.T) { + tmp := t.TempDir() + root := filepath.Join(tmp, "root") + outside := filepath.Join(tmp, "outside") + for p, body := range map[string]string{ + filepath.Join(root, "a.txt"): "A", + filepath.Join(root, "sub", "b.txt"): "B", + filepath.Join(outside, "secret.txt"): "SECRET", + } { + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + links := map[string]string{ + "in-abs": filepath.Join(root, "a.txt"), + "in-rel": "a.txt", + "esc-abs": filepath.Join(outside, "secret.txt"), + "esc-rel": filepath.Join("..", "outside", "secret.txt"), + "esc-dir": outside, + } + for name, target := range links { + if err := os.Symlink(target, filepath.Join(root, name)); err != nil { + t.Skipf("symlinks are not available here: %v", err) + } + } + rootSlash := strings.TrimPrefix(filepath.ToSlash(root), "/") // below "/" + + type fcase struct { + path string + want string // the body, or "" for a 400 + } + dotCases := []fcase{ + {"a.txt", "A"}, {"/a.txt", "A"}, {"sub/b.txt", "B"}, {"./sub/../a.txt", "A"}, + {"in-abs", "A"}, {"in-rel", "A"}, + {"esc-abs", ""}, {"esc-rel", ""}, {"esc-dir/secret.txt", ""}, + {"../outside/secret.txt", ""}, {"sub/../../outside/secret.txt", ""}, + {"../../../../../../etc/passwd", ""}, {"..", ""}, {"sub", ""}, + {"a.txt\x00", ""}, {`..\outside\secret.txt`, ""}, + } + for _, base := range []string{".", "./"} { + t.Run("base "+base, func(t *testing.T) { + t.Chdir(root) + for _, tc := range dotCases { + status, body, err := fileFromDir888(t, base, tc.path) + if strings.Contains(body, "SECRET") { + t.Errorf("FileFromDir(%q, %q) sent the secret", base, tc.path) + } + if tc.want == "" { + if err == nil || status == 200 { + t.Errorf("FileFromDir(%q, %q): status %d err %v, want a refusal", base, tc.path, status, err) + } + continue + } + if err != nil || status != 200 || body != tc.want { + t.Errorf("FileFromDir(%q, %q): status %d body %q err %v, want 200 %q", base, tc.path, status, body, err, tc.want) + } + } + // The escapes are the 400 the check gives, not another error. + for _, p := range []string{"../outside/secret.txt", "esc-abs", "esc-rel", "esc-dir/secret.txt"} { + if _, _, err := fileFromDir888(t, base, p); !isInvalidPath888(err) { + t.Errorf("FileFromDir(%q, %q) = %v, want an HTTPError 400", base, p, err) + } + } + }) + } + + if runtime.GOOS == "windows" { + return + } + t.Run("base /", func(t *testing.T) { + // Everything is inside "/": the path below it is the absolute path + // without its leading slash, ".." stops at the filesystem root, and a + // symlink that leaves the tree still resolves inside "/". + for _, tc := range []fcase{ + {rootSlash + "/a.txt", "A"}, {"/" + rootSlash + "/sub/b.txt", "B"}, {rootSlash + "/in-abs", "A"}, + {rootSlash + "/esc-abs", "SECRET"}, {rootSlash + "/../outside/secret.txt", "SECRET"}, + {strings.Repeat("../", 64) + rootSlash + "/a.txt", "A"}, + {rootSlash + "/sub", ""}, {rootSlash + "/missing.txt", ""}, + } { + status, body, err := fileFromDir888(t, "/", tc.path) + if tc.want == "" { + if err == nil || status == 200 { + t.Errorf("FileFromDir(%q, %q): status %d err %v, want a refusal", "/", tc.path, status, err) + } + continue + } + if err != nil || status != 200 || body != tc.want { + t.Errorf("FileFromDir(%q, %q): status %d body %q err %v, want 200 %q", "/", tc.path, status, body, err, tc.want) + } + } + }) +} diff --git a/middleware/static/root_304_dot_root_888_test.go b/middleware/static/root_304_dot_root_888_test.go index bb04fcaf2..3e26ffeaf 100644 --- a/middleware/static/root_304_dot_root_888_test.go +++ b/middleware/static/root_304_dot_root_888_test.go @@ -14,14 +14,13 @@ import ( // 46: no test pinned these; the 304 tests for symlinks ran with an absolute // Root only. // -// What these roots do is narrower than the doc suggested (found while writing -// this, see TestRootDotAndSlashServeOnlyTheRootIndex888): serveOS compares the -// joined path with cleanRoot + "/", which for "." and "/" no path below the -// root starts with, so every path but the root's own is passed to the next -// handler. The cases below that name a path below the root therefore never -// reach the containment check; the root's own index does, and that is where an -// escape can happen. The assertions hold for both: no 304, and no validators of -// the target on the response. +// Every case reaches the containment check, the paths below the root as well +// as the root's own index (TestRootSpellingsServeTheTree888 and the other +// root_spellings_888 tests cover what these roots serve). Until celeris#888 +// item 46 was fixed serveOS compared the joined path with cleanRoot + "/", +// which no path below "." or "/" starts with, so only the root's own index got +// here and the others were passed on unlooked at. The assertions: no 304, a +// 400 as for any escape, and no validators of the target on the response. func TestRoot304DotRootDoesNotFollowSymlinkOut888(t *testing.T) { secretTag := weakETag(secretTime888, int64(len(secretBytes888))) @@ -74,16 +73,9 @@ func TestRoot304DotRootDoesNotFollowSymlinkOut888(t *testing.T) { if rec.StatusCode == 304 { t.Errorf("a path outside the Root answered 304 (etag %q, last-modified %q)", rec.Header("etag"), rec.Header("last-modified")) } - // Only the Root's own index is looked at under these roots - // (see TestRootDotAndSlashServeOnlyTheRootIndex888): there - // FileFromDir refuses with a 400; every other path is passed - // to the next handler, with nothing on the response. - if tc.path == "/" && !isHTTP400(err) { + if !isHTTP400(err) { t.Errorf("handler error %v, want a 400 (FileFromDir's refusal)", err) } - if tc.path != "/" && (err != nil || rec.StatusCode != 0) { - t.Errorf("status %d err %v for a path below the Root, want it passed on (nothing written)", rec.StatusCode, err) - } for _, h := range []string{"etag", "last-modified"} { if v := rec.Header(h); v != "" { t.Errorf("%s %q on a refused request, want none", h, v) @@ -94,44 +86,3 @@ func TestRoot304DotRootDoesNotFollowSymlinkOut888(t *testing.T) { } } } - -// TestRootDotAndSlashServeOnlyTheRootIndex888 pins what the package doc says of -// a Root of ".", "./" and "/" (celeris#888 item 46). A request for the root -// directory itself is answered by its index file through two checks that -// disagree: the static middleware's own (resolvesInside), which accepts a file -// in the working directory, and Context.FileFromDir's, which requires the "./" -// prefix a Root of "." does not have. So GET / is a 400 without a validator and -// a 304 when one matches; the file is inside the root either way. A request for -// anything below the root is passed to the next handler, whatever it names. -// If a change makes these roots serve files, this test and the doc change with it. -func TestRootDotAndSlashServeOnlyTheRootIndex888(t *testing.T) { - r := newRoot888(t) - r.write(t, filepath.Join(r.root, "index.html"), []byte("

hi

"), secretTime888) - r.write(t, filepath.Join(r.root, "d", "plain.txt"), secretBytes888, secretTime888) - t.Chdir(r.root) - indexTag := weakETag(secretTime888, int64(len("

hi

"))) - fileTag := weakETag(secretTime888, int64(len(secretBytes888))) - for _, root := range []string{".", "./"} { - mw := New(Config{Root: root}) - rec, err := get888(t, mw, "/") - if !isHTTP400(err) { - t.Errorf("Root %q: GET / -> status %d err %v, want a 400 from FileFromDir", root, rec.StatusCode, err) - } - rec, err = get888(t, mw, "/", "if-none-match", indexTag) - if err != nil || rec.StatusCode != 304 { - t.Errorf("Root %q: GET / with the index's tag -> status %d err %v, want a 304", root, rec.StatusCode, err) - } - for _, hdr := range [][]string{nil, {"if-none-match", fileTag}} { - rec, err = get888(t, mw, "/d/plain.txt", hdr...) - if err != nil || rec.StatusCode != 0 { - t.Errorf("Root %q: GET /d/plain.txt %v -> status %d err %v, want it passed on (nothing written)", root, hdr, rec.StatusCode, err) - } - } - } - // "/" is the same through the other end: the root is the filesystem's. - abs := filepath.Join(r.root, "d", "plain.txt") - rec, err := get888(t, New(Config{Root: "/"}), abs) - if err != nil || rec.StatusCode != 0 { - t.Errorf("Root %q: GET %s -> status %d err %v, want it passed on (nothing written)", "/", abs, rec.StatusCode, err) - } -} diff --git a/middleware/static/root_spellings_888_test.go b/middleware/static/root_spellings_888_test.go new file mode 100644 index 000000000..7f430ce9f --- /dev/null +++ b/middleware/static/root_spellings_888_test.go @@ -0,0 +1,425 @@ +package static + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "net/http" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/goceleris/celeris" +) + +// A Root of ".", "./" and "/" serves the tree below it (celeris#888 item 46; +// the guard that refused every path below these roots dates from v1.3.3, and +// the test that pinned it, TestRootDotAndSlashServeOnlyTheRootIndex888, is +// replaced by these). The same file served through an absolute Root is the +// control: every spelling must give the answer the control gives, with the +// traversal protections of the control. +// +// Context.FileFromDir has the same guard, so a path that static passes on to it +// is refused there with a 400 whatever it names; these tests therefore look at +// the bytes of the 200, not only at "not passed on". + +const ( + indexBody888 = "

root index

" + helloBody888 = "hello world" + subBody888 = "

sub index

" + pageBody888 = "page" + envBody888 = "SECRET=1" +) + +// tree888 writes the tree the tests serve, below r.root: +// +// index.html, hello.txt, .env, .hidden/x.txt, sub/index.html, sub/page.txt +// in-abs -> /hello.txt (a symlink to a file inside, absolute) +// in-rel -> hello.txt (relative) +// in-dir -> /sub (a symlink to a directory inside, absolute) +// esc-abs -> /secret.txt (a symlink out of the root) +// esc-rel -> ../outside/secret.txt +// esc-dir -> (a directory symlink out of the root) +// sub/esc-up -> ../../outside/secret.txt +func tree888(t *testing.T) root888 { + t.Helper() + r := newRoot888(t) + for name, body := range map[string]string{ + "index.html": indexBody888, + "hello.txt": helloBody888, + ".env": envBody888, + ".hidden/x.txt": envBody888, + "sub/index.html": subBody888, + "sub/page.txt": pageBody888, + "sub/../real.txt": pageBody888, + } { + r.write(t, filepath.Join(r.root, filepath.FromSlash(name)), []byte(body), secretTime888) + } + r.link(t, filepath.Join(r.root, "hello.txt"), "in-abs") + r.link(t, "hello.txt", "in-rel") + r.link(t, filepath.Join(r.root, "sub"), "in-dir") + r.link(t, filepath.Join(r.outside, "secret.txt"), "esc-abs") + r.link(t, filepath.Join("..", "outside", "secret.txt"), "esc-rel") + r.link(t, r.outside, "esc-dir") + r.link(t, filepath.Join("..", "..", "outside", "secret.txt"), filepath.Join("sub", "esc-up")) + return r +} + +// spelling888 is one way to name the tree. url turns a path below the root +// ("/hello.txt") into the request path; chdir is whether the root is the +// working directory. +type spelling888 struct { + name string + root string + chdir bool + url func(r root888, p string) string +} + +func spellings888() []spelling888 { + rel := func(r root888, p string) string { return p } + out := []spelling888{ + {"dot", ".", true, rel}, + {"dot-slash", "./", true, rel}, + } + if runtime.GOOS != "windows" { + // The filesystem root: the tree is reached by its absolute path. + out = append(out, spelling888{"slash", "/", false, func(r root888, p string) string { + return strings.TrimSuffix(filepath.ToSlash(r.root), "/") + p + }}) + } + return out +} + +// open888 makes the middleware for s, in the right working directory. +func (s spelling888) open888(t *testing.T, r root888, cfg Config) celeris.HandlerFunc { + t.Helper() + if s.chdir { + t.Chdir(r.root) + } + cfg.Root = s.root + return New(cfg) +} + +type outcome888 struct { + status int + kind string // "ok", "passed-on", "400", or the other error's text + body string +} + +func (o outcome888) String() string { + return fmt.Sprintf("{status %d, %s, body %q}", o.status, o.kind, o.body) +} + +func outcomeOf888(t *testing.T, mw celeris.HandlerFunc, path string, hdr ...string) (outcome888, string) { + t.Helper() + rec, err := get888(t, mw, path, hdr...) + o := outcome888{status: rec.StatusCode, body: string(rec.Body)} + switch { + case isHTTP400(err): + o.kind = "400" + case err != nil: + o.kind = "error" + case rec.StatusCode == 0: + o.kind = "passed-on" + default: + o.kind = "ok" + } + return o, rec.Header("etag") +} + +// TestRootSpellingsServeTheTree888: the files, directories, index files and +// dotfiles of the tree are served under "." and "./" (relative to the working +// directory) and "/" (by absolute path), as under the absolute Root. +func TestRootSpellingsServeTheTree888(t *testing.T) { + cases := []struct { + path string + want string // the body of the 200 + }{ + {"/", indexBody888}, + {"/index.html", indexBody888}, + {"/hello.txt", helloBody888}, + {"/sub/", subBody888}, + {"/sub", subBody888}, + {"/sub/page.txt", pageBody888}, + {"/./hello.txt", helloBody888}, + {"/sub/../hello.txt", helloBody888}, + // The package has no dotfile rule: a dotfile below the root is served, + // as under any Root. + {"/.env", envBody888}, + {"/.hidden/x.txt", envBody888}, + } + for _, sp := range append(spellings888(), spelling888{"absolute", "", false, nil}) { + for _, tc := range cases { + t.Run(sp.name+tc.path, func(t *testing.T) { + r := tree888(t) + var mw celeris.HandlerFunc + path := tc.path + if sp.name == "absolute" { + mw = New(Config{Root: r.root}) + } else { + mw = sp.open888(t, r, Config{}) + path = sp.url(r, tc.path) + } + o, _ := outcomeOf888(t, mw, path) + if o.kind != "ok" || o.status != 200 || o.body != tc.want { + t.Errorf("GET %s: %v, want a 200 with %q", path, o, tc.want) + } + }) + } + } +} + +// TestRootSpellingsConditionalRequests888: a file below the root answers a +// matching validator with a 304, an index file too (GET / was a 400 without a +// validator and a 304 with one). +func TestRootSpellingsConditionalRequests888(t *testing.T) { + for _, sp := range spellings888() { + for _, tc := range []struct { + path string + size int + }{{"/", len(indexBody888)}, {"/hello.txt", len(helloBody888)}, {"/sub/", len(subBody888)}} { + t.Run(sp.name+tc.path, func(t *testing.T) { + r := tree888(t) + mw := sp.open888(t, r, Config{}) + path := sp.url(r, tc.path) + o, tag := outcomeOf888(t, mw, path) + if o.status != 200 || tag != weakETag(secretTime888, int64(tc.size)) { + t.Fatalf("GET %s: %v, etag %q, want a 200 with the file's tag", path, o, tag) + } + o, _ = outcomeOf888(t, mw, path, "if-none-match", tag) + if o.status != 304 || o.kind != "ok" { + t.Errorf("GET %s with its tag: %v, want a 304", path, o) + } + }) + } + } +} + +// TestRootSpellingsFollowSymlinksInside888: a symlink that stays inside the +// root is served (a file, a directory, a relative and an absolute target), +// with a 304 for its validator. +func TestRootSpellingsFollowSymlinksInside888(t *testing.T) { + cases := []struct { + path, want string + size int + }{ + {"/in-abs", helloBody888, len(helloBody888)}, + {"/in-rel", helloBody888, len(helloBody888)}, + {"/in-dir/page.txt", pageBody888, len(pageBody888)}, + {"/in-dir/", subBody888, len(subBody888)}, + } + for _, sp := range spellings888() { + for _, tc := range cases { + t.Run(sp.name+tc.path, func(t *testing.T) { + r := tree888(t) + mw := sp.open888(t, r, Config{}) + path := sp.url(r, tc.path) + o, tag := outcomeOf888(t, mw, path) + if o.kind != "ok" || o.status != 200 || o.body != tc.want { + t.Fatalf("GET %s: %v, want a 200 with %q", path, o, tc.want) + } + o, _ = outcomeOf888(t, mw, path, "if-none-match", tag) + if o.status != 304 { + t.Errorf("GET %s with its tag: %v, want a 304", path, o) + } + }) + } + } +} + +// TestRootSpellingsRefuseSymlinksOut888: under "." and "./" a symlink that +// leaves the working directory is refused (400), with and without a validator, +// and the secret never reaches the response. (Under "/" nothing is outside: +// TestRootSlashHasNoOutside888.) +func TestRootSpellingsRefuseSymlinksOut888(t *testing.T) { + paths := []string{"/esc-abs", "/esc-rel", "/esc-dir/secret.txt", "/sub/esc-up"} + tag := weakETag(secretTime888, int64(len(secretBytes888))) + hdrs := [][]string{nil, {"if-none-match", tag}, {"if-none-match", "*"}, + {"if-modified-since", secretTime888.UTC().Format(http.TimeFormat)}} + for _, sp := range spellings888() { + if !sp.chdir { + continue + } + for _, p := range paths { + for _, hdr := range hdrs { + t.Run(fmt.Sprint(sp.name, p, hdr), func(t *testing.T) { + r := tree888(t) + mw := sp.open888(t, r, Config{}) + rec, err := get888(t, mw, p, hdr...) + if !isHTTP400(err) || rec.StatusCode == 304 || bytes.Contains(rec.Body, secretBytes888) { + t.Errorf("GET %s %v: status %d err %v body %q, want a 400", p, hdr, rec.StatusCode, err, rec.Body) + } + for _, h := range []string{"etag", "last-modified"} { + if v := rec.Header(h); v != "" { + t.Errorf("%s %q on a refused request, want none", h, v) + } + } + }) + } + } + } +} + +// TestRootSlashHasNoOutside888 pins what Root "/" is: every path is inside it, +// so a traversal ("..") stops at the filesystem root and a symlink that leaves +// the tree still resolves inside "/". Nothing here is a bypass: the operator +// asked for the whole filesystem (the Config.Root doc says so). +func TestRootSlashHasNoOutside888(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Root \"/\" is the current volume's root on Windows") + } + r := tree888(t) + mw := New(Config{Root: "/"}) + base := strings.TrimSuffix(filepath.ToSlash(r.root), "/") + for _, p := range []string{ + base + "/esc-abs", + base + "/esc-dir/secret.txt", + base + "/../outside/secret.txt", + strings.Repeat("/..", 64) + base + "/../outside/secret.txt", + } { + o, _ := outcomeOf888(t, mw, p) + if o.kind != "ok" || o.body != string(secretBytes888) { + t.Errorf("GET %s: %v, want the file (everything is inside \"/\")", p, o) + } + } +} + +// TestRootSpellingsTraversalMatchesTheAbsoluteRoot888: the paths that try to +// leave the root (".." in the clear and encoded, backslashes, an encoded +// slash, a NUL byte, a doubled slash) get under "." and "./" the answer the +// absolute Root gives, and none of them returns the secret. c.Path() is the +// raw path, so an encoded dot or slash names a file that does not exist. +func TestRootSpellingsTraversalMatchesTheAbsoluteRoot888(t *testing.T) { + paths := []string{ + "/../outside/secret.txt", + "/../../outside/secret.txt", + "/sub/../../outside/secret.txt", + "/sub/../../../../../../etc/passwd", + "/..", + "/../", + "/%2e%2e/outside/secret.txt", + "/..%2foutside%2fsecret.txt", + "/%2e%2e%2foutside%2fsecret.txt", + "/..%5coutside%5csecret.txt", + `/..\outside\secret.txt`, + `/sub\..\..\outside\secret.txt`, + "//outside/secret.txt", + "/hello.txt\x00", + "/\x00/../outside/secret.txt", + "/hello.txt%00", + "/sub/%2e%2e/hello.txt", + "/../etc/passwd", + "/./../outside/secret.txt", + } + for _, sp := range spellings888() { + if !sp.chdir { + continue + } + for _, p := range paths { + t.Run(fmt.Sprintf("%s%q", sp.name, p), func(t *testing.T) { + r := tree888(t) + want, _ := outcomeOf888(t, New(Config{Root: r.root}), p) + mw := sp.open888(t, r, Config{}) + got, _ := outcomeOf888(t, mw, p) + if got != want { + t.Errorf("GET %q: %v, the absolute Root gives %v", p, got, want) + } + if strings.Contains(got.body, string(secretBytes888)) || strings.Contains(got.body, "root:") { + t.Errorf("GET %q returned content from outside the root: %v", p, got) + } + // The same with Browse and SPA, which read the tree too. + for _, cfg := range []Config{{Browse: true}, {SPA: true}} { + wantC, _ := outcomeOf888(t, New(Config{Root: r.root, Browse: cfg.Browse, SPA: cfg.SPA}), p) + gotC, _ := outcomeOf888(t, sp.open888(t, r, cfg), p) + if gotC != wantC || strings.Contains(gotC.body, string(secretBytes888)) { + t.Errorf("GET %q (Browse %v, SPA %v): %v, the absolute Root gives %v", p, cfg.Browse, cfg.SPA, gotC, wantC) + } + } + }) + } + } +} + +// TestRootSpellingsBrowseAndSPA888: a directory listing and the SPA fallback +// work below these roots too. +func TestRootSpellingsBrowseAndSPA888(t *testing.T) { + for _, sp := range spellings888() { + t.Run(sp.name, func(t *testing.T) { + r := tree888(t) + r.write(t, filepath.Join(r.root, "bare", "f.txt"), []byte("f"), secretTime888) + mw := sp.open888(t, r, Config{Browse: true}) + o, _ := outcomeOf888(t, mw, sp.url(r, "/bare/")) + if o.status != 200 || !strings.Contains(o.body, "f.txt") { + t.Errorf("listing of /bare/: %v", o) + } + spa := sp.open888(t, r, Config{SPA: true}) + o, _ = outcomeOf888(t, spa, sp.url(r, "/no/such/route")) + if o.status != 200 && sp.chdir { + t.Errorf("SPA fallback: %v, want the index", o) + } + }) + } +} + +// TestRootSpellingsOverTheWire888: the same requests through a server, raw, so +// that nothing cleans the path first. +func TestRootSpellingsOverTheWire888(t *testing.T) { + for _, sp := range spellings888() { + t.Run(sp.name, func(t *testing.T) { + r := tree888(t) + mw := sp.open888(t, r, Config{}) + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + s := celeris.New(celeris.Config{Engine: celeris.Std}) + s.Use(mw) + done := make(chan error, 1) + go func() { done <- s.StartWithListener(ln) }() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + <-done + }) + raw := func(path string) string { + var lastErr error + for range 100 { + c, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + lastErr = err + time.Sleep(20 * time.Millisecond) + continue + } + defer func() { _ = c.Close() }() + _ = c.SetDeadline(time.Now().Add(10 * time.Second)) + _, _ = fmt.Fprintf(c, "GET %s HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n", path) + b, _ := io.ReadAll(c) + return string(b) + } + t.Fatalf("cannot connect: %v", lastErr) + return "" + } + if got := raw(sp.url(r, "/hello.txt")); !strings.HasPrefix(got, "HTTP/1.1 200") || !strings.HasSuffix(got, helloBody888) { + t.Errorf("GET %s: %q, want a 200 with %q", sp.url(r, "/hello.txt"), got, helloBody888) + } + if got := raw(sp.url(r, "/sub/page.txt")); !strings.HasPrefix(got, "HTTP/1.1 200") || !strings.HasSuffix(got, pageBody888) { + t.Errorf("GET /sub/page.txt: %q", got) + } + if !sp.chdir { + return + } + for _, p := range []string{"/../outside/secret.txt", "/%2e%2e/outside/secret.txt", "/..%2foutside%2fsecret.txt", + "/sub/%2e%2e/%2e%2e/outside/secret.txt", `/..\outside\secret.txt`, "/esc-abs", "/esc-dir/secret.txt"} { + got := raw(p) + if strings.Contains(got, string(secretBytes888)) || strings.HasPrefix(got, "HTTP/1.1 200") { + t.Errorf("GET %s answered %q: content from outside the root", p, got) + } + } + }) + } +} From fa7d092fe4baeb9f38251f4f789065319a4d559c Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 03:03:40 +0100 Subject: [PATCH 02/26] fix(static, celeris): a Root of '.', './' and '/' serves the tree below it, as Context.FileFromDir does for those bases (v1.6.0, Refs #888) The containment check compared the cleaned path with root + separator: no path below '.' starts with './' (filepath.Join drops it) and none below '/' starts with '//'. So static passed every request below these roots on, and Context.FileFromDir refused every file below them with a 400. One comparison now (ctxkit.PathWithin) serves the five sites: static's lexical guard, its browse and 304 containment checks, and FileFromDir's lexical and symlink checks. A symlink with an absolute target that is resolved under a relative base is compared with the base made absolute (ctxkit.ResolvedWithin). Release note: static Root '.', './' and '/' now serve the tree; before they served nothing below the root. Context.FileFromDir (and so Server.Static and Group.Static) takes the same bases. --- context_response.go | 11 +++- internal/ctxkit/pathwithin.go | 56 +++++++++++++++++++ internal/ctxkit/pathwithin_test.go | 89 ++++++++++++++++++++++++++++++ middleware/static/config.go | 16 ++++++ middleware/static/doc.go | 14 ++--- middleware/static/static.go | 26 ++------- 6 files changed, 179 insertions(+), 33 deletions(-) create mode 100644 internal/ctxkit/pathwithin.go create mode 100644 internal/ctxkit/pathwithin_test.go diff --git a/context_response.go b/context_response.go index 0ecd7523b..dbbe94af3 100644 --- a/context_response.go +++ b/context_response.go @@ -23,6 +23,7 @@ import ( "unicode/utf8" "unsafe" + "github.com/goceleris/celeris/internal/ctxkit" "github.com/goceleris/celeris/internal/httprange" "github.com/goceleris/celeris/internal/negotiate" "github.com/goceleris/celeris/internal/protocol/h2/stream" @@ -1069,7 +1070,11 @@ func (c *Context) trySendFile(f *os.File, status int, contentType string, offset // FileFromDir safely serves a file from within baseDir. The userPath is // cleaned and joined with baseDir; if the result escapes baseDir, a 400 // error is returned. Symlinks are resolved and rechecked to prevent a -// symlink under baseDir from escaping the directory boundary. +// symlink under baseDir from escaping the directory boundary. A baseDir of +// "." or "./" is the working directory and one of "/" the filesystem's root +// (before v1.6.0 these two refused every file with a 400): with "/" nothing is +// outside baseDir, so only the file's own existence limits what userPath may +// name. func (c *Context) FileFromDir(baseDir, userPath string) error { return c.fileFromDir(baseDir, userPath, "", nil) } @@ -1078,7 +1083,7 @@ func (c *Context) FileFromDir(baseDir, userPath string) error { func (c *Context) fileFromDir(baseDir, userPath, contentType string, onOpen func(modTime time.Time, size int64)) error { abs := filepath.Clean(filepath.Join(baseDir, filepath.FromSlash(userPath))) base := filepath.Clean(baseDir) - if abs != base && !strings.HasPrefix(abs, base+string(filepath.Separator)) { + if !ctxkit.PathWithin(base, abs) { return NewHTTPError(400, "invalid file path") } // Resolve symlinks and recheck prefix to prevent symlink escape. @@ -1090,7 +1095,7 @@ func (c *Context) fileFromDir(baseDir, userPath, contentType string, onOpen func if err != nil { return err } - if resolved != resolvedBase && !strings.HasPrefix(resolved, resolvedBase+string(filepath.Separator)) { + if !ctxkit.ResolvedWithin(resolvedBase, resolved) { return NewHTTPError(400, "invalid file path") } info, err := os.Stat(resolved) diff --git a/internal/ctxkit/pathwithin.go b/internal/ctxkit/pathwithin.go new file mode 100644 index 000000000..628bce816 --- /dev/null +++ b/internal/ctxkit/pathwithin.go @@ -0,0 +1,56 @@ +package ctxkit + +import ( + "path/filepath" + "strings" +) + +// PathWithin reports whether p is base or a path below it. Both are cleaned +// paths (filepath.Clean), of the same kind: both relative or both absolute. +// +// The test "p starts with base + separator" is wrong for two bases. The +// working directory, ".", is a prefix of no cleaned path (filepath.Join(".", +// "a/b") is "a/b"), so everything below it was refused: p is below "." when it +// is relative and does not climb out ("..", "../x"). A base that ends in a +// separator, the root of a filesystem ("/", `C:\`), has "//" as its prefix, which +// no path starts with: p is below it when it starts with base itself. +func PathWithin(base, p string) bool { + if p == base { + return true + } + if base == "." { + if filepath.IsAbs(p) || filepath.VolumeName(p) != "" || p == ".." { + return false + } + return !strings.HasPrefix(p, ".."+string(filepath.Separator)) + } + if !strings.HasPrefix(p, base) { + return false + } + return base[len(base)-1] == filepath.Separator || p[len(base)] == filepath.Separator +} + +// ResolvedWithin is PathWithin for the results of filepath.EvalSymlinks: +// resolved is the resolved path and base the resolved base. EvalSymlinks keeps a +// relative path relative unless a symlink on the way has an absolute target, so +// under a relative base (".", "public") a resolved path can be absolute and +// still inside it. That is the case ResolvedWithin settles, with the base made +// absolute and resolved the same way; a resolved path that stays relative is +// compared as it is (one that climbs, "../x", is refused). +func ResolvedWithin(base, resolved string) bool { + if PathWithin(base, resolved) { + return true + } + if filepath.IsAbs(base) || !filepath.IsAbs(resolved) { + return false + } + abs, err := filepath.Abs(base) + if err != nil { + return false + } + abs, err = filepath.EvalSymlinks(abs) + if err != nil { + return false + } + return PathWithin(abs, resolved) +} diff --git a/internal/ctxkit/pathwithin_test.go b/internal/ctxkit/pathwithin_test.go new file mode 100644 index 000000000..db263e96e --- /dev/null +++ b/internal/ctxkit/pathwithin_test.go @@ -0,0 +1,89 @@ +package ctxkit + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestPathWithin(t *testing.T) { + sep := string(filepath.Separator) + cases := []struct { + base, p string + want bool + }{ + {"/srv/www", "/srv/www", true}, + {"/srv/www", "/srv/www/a", true}, + {"/srv/www", "/srv/www/a/b.txt", true}, + {"/srv/www", "/srv/wwwx", false}, + {"/srv/www", "/srv/ww", false}, + {"/srv/www", "/srv", false}, + {"/srv/www", "/etc/passwd", false}, + {"/srv/www", "/", false}, + {"public", "public/a", true}, + {"public", "publicity/a", false}, + {"public", "a", false}, + {".", ".", true}, + {".", "a", true}, + {".", "a/b/c.txt", true}, + {".", ".hidden", true}, + {".", "..a", true}, // a file named "..a" is not a climb + {".", "..", false}, + {".", "../a", false}, + {".", "../../etc/passwd", false}, + {".", "/etc/passwd", false}, + {"/", "/", true}, + {"/", "/etc/passwd", true}, + {"/", "/a", true}, + } + for _, tc := range cases { + if runtime.GOOS == "windows" { + break // the cases are slash paths; the volume root is checked below + } + if got := PathWithin(tc.base, tc.p); got != tc.want { + t.Errorf("PathWithin(%q, %q) = %v, want %v", tc.base, tc.p, got, tc.want) + } + } + // The root of the volume, whatever the separator. + root := filepath.VolumeName(os.TempDir()) + sep + if !PathWithin(root, filepath.Join(os.TempDir(), "x")) || !PathWithin(root, root) { + t.Errorf("PathWithin(%q, ...) refuses a path below the volume root", root) + } +} + +func TestResolvedWithin(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("slash paths") + } + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Chdir(dir) + // A relative base: "." and "sub", a resolved path that a symlink with an + // absolute target made absolute. + if err := os.Mkdir("sub", 0o755); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + base, resolved string + want bool + }{ + {".", "a/b", true}, + {".", filepath.Join(dir, "sub", "f"), true}, + {".", dir, true}, + {".", filepath.Join(filepath.Dir(dir), "other"), false}, + {".", "/etc/passwd", false}, + {".", "../x", false}, + {"sub", filepath.Join(dir, "sub", "f"), true}, + {"sub", filepath.Join(dir, "other"), false}, + {dir, filepath.Join(dir, "a"), true}, + {dir, "/etc/passwd", false}, + {"/", "/etc/passwd", true}, + } { + if got := ResolvedWithin(tc.base, tc.resolved); got != tc.want { + t.Errorf("ResolvedWithin(%q, %q) = %v, want %v", tc.base, tc.resolved, got, tc.want) + } + } +} diff --git a/middleware/static/config.go b/middleware/static/config.go index e1c998aaa..3c25d0043 100644 --- a/middleware/static/config.go +++ b/middleware/static/config.go @@ -18,6 +18,22 @@ type Config struct { // Root is the directory path on the OS filesystem from which to serve // files. Mutually exclusive with FS. If neither Root nor FS is set, // the middleware panics. + // + // Everything below Root is served, dotfiles and dot directories included + // (.env, .git/config): the package has no rule against them. A path that + // leaves Root, by "..", an absolute path, or a symlink that resolves outside + // it, is refused. A relative Root is read against the working directory at + // each request. + // + // Root may be ".", "./" or "/", and serves the tree below it as any other + // directory does. Mind what that is: "." is the process's working + // directory, with its sources and configuration, and "/" is the whole + // filesystem (/etc/passwd, the home directories, /proc/self/environ, the + // process's environment: whatever the process may read), where nothing is + // outside Root any more, so the containment check that protects the others + // protects nothing. Before v1.6.0 these three served nothing below the + // root, so an upgrade starts to serve what the config always named: name a + // directory that holds only what is meant to be public. Root string // FS is an [fs.FS] from which to serve files (e.g. embed.FS). diff --git a/middleware/static/doc.go b/middleware/static/doc.go index 3680565fd..76d83c3d6 100644 --- a/middleware/static/doc.go +++ b/middleware/static/doc.go @@ -31,14 +31,12 @@ // containment check, the one [celeris.Context.FileFromDir] applies to a file it // sends (symlinks resolved), has passed for its path, and that check runs for // every conditional request, whether or not its validator matches. The two -// checks agree for a Root that names a directory. A Root of ".", "./" or "/" -// is not served from: a request for anything below it is passed to the next -// handler (the guard against ".." compares the path with the root and a -// separator, which no path below these roots starts with). For "." and "./" -// the request for the root directory itself, answered by its index file, gets -// a 400 from FileFromDir, which requires the "./" prefix a Root of "." lacks, -// and a 304 when a validator matches; the file is inside the root either way. -// Name the directory. +// checks agree, for a Root of ".", "./" and "/" as for any other directory: +// these three serve the tree below them (before v1.6.0 they served nothing below +// the root, and the root's own index answered 400 or 304 depending on whether a +// validator matched). Their meaning is wide: "." is the working directory and +// "/" the whole filesystem, and dotfiles are served under every Root. Name the +// directory that holds what is public; see [Config].Root. // // With compress in front of an [Config].FS, the strong tag of a file that compress // encodes is made weak on the 200 (W/"hash"), and static marks the response so diff --git a/middleware/static/static.go b/middleware/static/static.go index fcb7d5ca9..221a325a3 100644 --- a/middleware/static/static.go +++ b/middleware/static/static.go @@ -176,7 +176,7 @@ func serveOS(c *celeris.Context, cleanRoot, filePath, index string, browse, spa // Prevent directory traversal: filepath.Join resolves ".." segments, // so verify the result stays within the root directory. - if fullPath != cleanRoot && !strings.HasPrefix(fullPath, cleanRoot+string(filepath.Separator)) { + if !ctxkit.PathWithin(cleanRoot, fullPath) { return c.Next() } @@ -213,7 +213,7 @@ func serveOS(c *celeris.Context, cleanRoot, filePath, index string, browse, spa if err != nil { return c.Next() } - if resolved != resolvedRoot && !strings.HasPrefix(resolved, resolvedRoot+string(filepath.Separator)) { + if !ctxkit.ResolvedWithin(resolvedRoot, resolved) { return c.Next() } return serveDirListingOS(c, resolved) @@ -314,13 +314,7 @@ func resolvesInside(cleanRoot, filePath string) bool { resolvesInsideHook() } full := filepath.Join(cleanRoot, filepath.FromSlash(filePath)) - if cleanRoot == "." { - // Join drops a leading ".", so a path that leaves the working - // directory starts with "..". - if full == ".." || strings.HasPrefix(full, ".."+string(filepath.Separator)) { - return false - } - } else if !pathUnder(cleanRoot, full) { + if !ctxkit.PathWithin(cleanRoot, full) { return false } start := len(cleanRoot) @@ -355,7 +349,7 @@ func resolvesInside(cleanRoot, filePath string) bool { if err != nil { return false } - return resolved == resolvedRoot || strings.HasPrefix(resolved, resolvedRoot+string(filepath.Separator)) + return ctxkit.ResolvedWithin(resolvedRoot, resolved) } // resolvesInsideHook is a test seam (celeris#888 item 35): when set it runs at @@ -363,18 +357,6 @@ func resolvesInside(cleanRoot, filePath string) bool { // conditional request pays. var resolvesInsideHook func() -// pathUnder reports whether p is root or a path below it, both cleaned, without -// building root + separator. -func pathUnder(root, p string) bool { - if p == root { - return true - } - if !strings.HasPrefix(p, root) { - return false - } - return root[len(root)-1] == filepath.Separator || p[len(root)] == filepath.Separator -} - // acceptsCoding reports whether the request's Accept-Encoding accepts the // content coding enc (q-values read, q=0 refuses), and names Accept-Encoding // in Vary, once. A client that accepts x-gzip accepts gzip (RFC 9110 8.4.1.3: From e59f26e85c5ee0dc9b8d67919e2d763ea54df825 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 03:05:01 +0100 Subject: [PATCH 03/26] tests(static): a Browse listing of a symlinked directory outside a '.' root is refused (kills the recheck mutant) --- middleware/static/root_spellings_888_test.go | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/middleware/static/root_spellings_888_test.go b/middleware/static/root_spellings_888_test.go index 7f430ce9f..cbb9a2ae7 100644 --- a/middleware/static/root_spellings_888_test.go +++ b/middleware/static/root_spellings_888_test.go @@ -423,3 +423,29 @@ func TestRootSpellingsOverTheWire888(t *testing.T) { }) } } + +// TestRootSpellingsBrowseDoesNotListASymlinkedDirOut888: a directory listing +// is refused for a symlink to a directory outside the root (the recheck in +// serveOS), whichever way the root is spelled, and a symlink to a directory +// inside is listed. +func TestRootSpellingsBrowseDoesNotListASymlinkedDirOut888(t *testing.T) { + for _, sp := range spellings888() { + if !sp.chdir { + continue + } + t.Run(sp.name, func(t *testing.T) { + r := tree888(t) + r.write(t, filepath.Join(r.root, "plain", "listed.txt"), []byte("x"), secretTime888) + r.link(t, "plain", "in-plain") + mw := sp.open888(t, r, Config{Browse: true}) + o, _ := outcomeOf888(t, mw, "/esc-dir/") + if o.status == 200 || strings.Contains(o.body, "secret.txt") { + t.Errorf("GET /esc-dir/: %v, want the listing of a directory outside the root refused", o) + } + o, _ = outcomeOf888(t, mw, "/in-plain/") + if o.status != 200 || !strings.Contains(o.body, "listed.txt") { + t.Errorf("GET /in-plain/: %v, want its listing", o) + } + }) + } +} From 7b36e8025b891aa2ad795f8aa0a4bc288b6ee799 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 03:12:38 +0100 Subject: [PATCH 04/26] tests(celeris): an empty FileFromDir baseDir stays refused; a relative baseDir takes an in-tree absolute symlink (Refs #888) --- file_from_dir_root_888_test.go | 58 ++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/file_from_dir_root_888_test.go b/file_from_dir_root_888_test.go index fd618d542..5c17a36d1 100644 --- a/file_from_dir_root_888_test.go +++ b/file_from_dir_root_888_test.go @@ -126,3 +126,61 @@ func TestFileFromDirDotAndSlashBases888(t *testing.T) { } }) } + +// An empty baseDir is not the working directory: filepath.Clean("") is ".", +// and the check that now accepts everything below "." must not accept it. +// Server.Static and Group.Static pass their root on unchecked, so +// s.Static("/files", os.Getenv("STATIC_DIR")) with the variable unset was +// answered with a 400 for every file, and must still be. +func TestFileFromDirEmptyBaseIsRefused888(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("A"), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + for _, p := range []string{"a.txt", "/a.txt", "./a.txt", ""} { + status, body, err := fileFromDir888(t, "", p) + if !isInvalidPath888(err) || status == 200 || body != "" { + t.Errorf("FileFromDir(%q, %q): status %d body %q err %v, want an HTTPError 400", "", p, status, body, err) + } + } +} + +// A relative baseDir that is not "." widens a little with this change: a +// symlink with an absolute target that stays inside it is served (it was a 400: +// the resolved path is absolute, the resolved base relative); one that leaves +// it is refused as before. +func TestFileFromDirRelativeBaseAbsoluteSymlink888(t *testing.T) { + tmp, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + for p, body := range map[string]string{ + filepath.Join(tmp, "public", "a.txt"): "A", + filepath.Join(tmp, "private", "s.txt"): "S", + } { + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + for name, target := range map[string]string{ + "in": filepath.Join(tmp, "public", "a.txt"), + "out": filepath.Join(tmp, "private", "s.txt"), + } { + if err := os.Symlink(target, filepath.Join(tmp, "public", name)); err != nil { + t.Skipf("symlinks are not available here: %v", err) + } + } + t.Chdir(tmp) + for _, base := range []string{"public", "./public"} { + if status, body, err := fileFromDir888(t, base, "in"); err != nil || status != 200 || body != "A" { + t.Errorf("FileFromDir(%q, in): status %d body %q err %v, want 200 A", base, status, body, err) + } + if status, body, err := fileFromDir888(t, base, "out"); !isInvalidPath888(err) || status == 200 || body != "" { + t.Errorf("FileFromDir(%q, out): status %d body %q err %v, want a 400", base, status, body, err) + } + } +} From 8dd6afa46cd0acc1d3ea705eec59b8c6133c0bcc Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 03:12:45 +0100 Subject: [PATCH 05/26] fix(celeris): FileFromDir refuses an empty baseDir instead of reading it as '.' (Refs #888) filepath.Clean("") is ".", which this PR makes servable: Server.Static and Group.Static pass their root on unchecked, so an unset root would have served the working directory where it was a 400 for every file before. --- context_response.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/context_response.go b/context_response.go index dbbe94af3..fc90ab1ab 100644 --- a/context_response.go +++ b/context_response.go @@ -1074,13 +1074,19 @@ func (c *Context) trySendFile(f *os.File, status int, contentType string, offset // "." or "./" is the working directory and one of "/" the filesystem's root // (before v1.6.0 these two refused every file with a 400): with "/" nothing is // outside baseDir, so only the file's own existence limits what userPath may -// name. +// name. An empty baseDir is refused with a 400, not read as ".". func (c *Context) FileFromDir(baseDir, userPath string) error { return c.fileFromDir(baseDir, userPath, "", nil) } // fileFromDir is FileFromDir with serveFile's contentType and onOpen. func (c *Context) fileFromDir(baseDir, userPath, contentType string, onOpen func(modTime time.Time, size int64)) error { + if baseDir == "" { + // filepath.Clean("") is ".": an unset root (Server.Static("/f", + // os.Getenv("DIR"))) would serve the working directory. It was refused + // before "." was accepted, and stays so. + return NewHTTPError(400, "invalid file path") + } abs := filepath.Clean(filepath.Join(baseDir, filepath.FromSlash(userPath))) base := filepath.Clean(baseDir) if !ctxkit.PathWithin(base, abs) { From 71b10d9948907ce03b997606506fd913f7fc3c9f Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 03:43:41 +0100 Subject: [PATCH 06/26] tests(static): a Root of '..' segments climbs out of the directory it names: matrix over root spellings, requests and entry points (Refs #888) Red on the PR head: TestDotDotRootsMiddleware888, TestDotDotRootsFileFromDir888 and TestDotDotRootsOverTheWire888 (std and epoll; middleware, Server.Static, Group.Static) for the roots '..', '../', './..', 'x/../..', 'a/../..', '../..', '../../', '../x/..' (GET /../TOPSECRET.txt answers 200 with a file outside the directory the root resolves to). --- middleware/static/root_dotdot_888_test.go | 278 ++++++++++++++++++ middleware/static/root_spellings_888_test.go | 2 +- .../static/wire_engines_888_linux_test.go | 7 + 3 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 middleware/static/root_dotdot_888_test.go create mode 100644 middleware/static/wire_engines_888_linux_test.go diff --git a/middleware/static/root_dotdot_888_test.go b/middleware/static/root_dotdot_888_test.go new file mode 100644 index 000000000..a71a54ca5 --- /dev/null +++ b/middleware/static/root_dotdot_888_test.go @@ -0,0 +1,278 @@ +package static + +import ( + "context" + "fmt" + "io" + "net" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "testing" + "time" + + "github.com/goceleris/celeris" + "github.com/goceleris/celeris/celeristest" +) + +// A Root that is made of ".." segments names the directory it resolves to, and +// nothing outside that directory is served (celeris#888 item 46, review of the +// fix for the "." roots). PathWithin special-cased "." and not "..": for a base +// of "..", "../../etc/passwd" starts with ".." and with the separator after it, +// so it was "within", and a Root of ".." or "../.." served every file the +// process could read, by a request that climbs ("GET /../../TOPSECRET.txt"). +// Server.Static and Group.Static serve through Context.FileFromDir, which has +// the same check. It is on main since v1.3.3. +// +// The tree (T is a temp dir; the working directory is T/p/c): +// +// T/TOPSECRET.txt T/p/PSECRET.txt T/p/other/o.txt +// T/p/c/{index.html,a.txt,.env} T/p/c/sub/{index.html,b.txt} T/p/c/x/ +// +// Every file has its own marker as content. For each root spelling the +// directory R it resolves to is computed independently of the code under test +// (the working directory joined with the root); then, for every file F of the +// tree, the request "/" must serve F when F is inside R, and +// must serve nothing when it is outside (the relative path then climbs: that is +// the request the exploit sends). Further requests that try to leave R (extra +// climbs, encodings, backslashes, doubled slashes, absolute paths, NUL) must +// return no marker of a file outside R. + +type dotTree888 struct { + top, cwd string // T (physical) and the working directory T/p/c + files map[string]string // absolute path -> marker +} + +func newDotTree888(t *testing.T) dotTree888 { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("slash paths and Root \"/\": not run on Windows") + } + top, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + d := dotTree888{top: top, cwd: filepath.Join(top, "p", "c"), files: map[string]string{}} + for _, f := range []string{ + "TOPSECRET.txt", "p/PSECRET.txt", "p/other/o.txt", + "p/c/index.html", "p/c/a.txt", "p/c/.env", "p/c/sub/index.html", "p/c/sub/b.txt", + } { + abs := filepath.Join(top, filepath.FromSlash(f)) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + marker := "MARK<" + f + ">" + if err := os.WriteFile(abs, []byte(marker), 0o644); err != nil { + t.Fatal(err) + } + d.files[abs] = marker + } + if err := os.MkdirAll(filepath.Join(d.cwd, "x"), 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(d.cwd) + return d +} + +// rootSpelling888 is a way to write a Root (or a FileFromDir base): the string, +// and the directory it resolves to. +type rootSpelling888 struct{ root, resolves string } + +func (d dotTree888) spellings() []rootSpelling888 { + abs := func(rel string) string { return filepath.Join(d.cwd, rel) } + return []rootSpelling888{ + {".", d.cwd}, {"./", d.cwd}, {"x/..", d.cwd}, {"sub/..", d.cwd}, {"./sub/../", d.cwd}, + {"..", abs("..")}, {"../", abs("..")}, {"./..", abs("..")}, {"x/../..", abs("..")}, {"a/../..", abs("..")}, + {"../..", abs("../..")}, {"../../", abs("../..")}, + {"../x/..", abs("..")}, {"../c", d.cwd}, {"../other", abs("../other")}, + {"sub", abs("sub")}, {"sub/", abs("sub")}, + {d.cwd, d.cwd}, {d.cwd + "/", d.cwd}, {filepath.Join(d.cwd, ".."), abs("..")}, + {"/", "/"}, + } +} + +func isUnder888(root, p string) bool { + return root == "/" || p == root || strings.HasPrefix(p, root+"/") +} + +// requests888 are the requests for the spelling: the plain ones, with the +// expectation (a file inside R must be served), and the hostile ones. +func (d dotTree888) requests(sp rootSpelling888) (plain []req888, hostile []string) { + paths := make([]string, 0, len(d.files)) + for f := range d.files { + paths = append(paths, f) + } + sort.Strings(paths) + for _, f := range paths { + var u string + if sp.root == "/" { + u = f // by absolute path + } else { + rel, err := filepath.Rel(sp.resolves, f) + if err != nil { + continue + } + u = "/" + filepath.ToSlash(rel) + } + plain = append(plain, req888{path: u, file: f, inside: isUnder888(sp.resolves, f)}) + if sp.root != "/" { + rel := strings.TrimPrefix(u, "/") + hostile = append(hostile, + "/./"+rel, "/"+rel+"/", "//"+rel, "/x/../"+rel, "/../../../../../../"+rel, + "/%2e%2e/"+strings.TrimPrefix(rel, "../"), "/..%2f"+strings.TrimPrefix(rel, "../"), + "/"+strings.ReplaceAll(rel, "/", `\`), "/"+rel+"\x00", "/"+rel+"%00") + } + hostile = append(hostile, "/"+filepath.ToSlash(f), f) + } + hostile = append(hostile, "/..", "/../", "/../..", "/../../..", "/sub/../..", "/.%00/a.txt", "/\x00", "//", "/../../../../etc/passwd") + return plain, hostile +} + +type req888 struct { + path string + file string + inside bool +} + +// verdict888 checks one answer. body is "" when nothing was written. +func (d dotTree888) verdict(t *testing.T, label string, sp rootSpelling888, r req888, hostile bool, status int, body string) { + t.Helper() + for f, m := range d.files { + if !isUnder888(sp.resolves, f) && strings.Contains(body, m) { + t.Errorf("%s: root %q (resolves to %s) GET %q returned %s's content %q: outside the root", label, sp.root, sp.resolves, r.path, f, m) + } + } + if hostile { + return + } + if r.inside { + if status != 200 || body != d.files[r.file] { + t.Errorf("%s: root %q (resolves to %s) GET %q: status %d body %q, want 200 %q", label, sp.root, sp.resolves, r.path, status, body, d.files[r.file]) + } + } else if status == 200 { + t.Errorf("%s: root %q (resolves to %s) GET %q answered 200 for %s, outside the root", label, sp.root, sp.resolves, r.path, r.file) + } +} + +func eachRequest888(d dotTree888, sp rootSpelling888, fn func(path string, r req888, hostile bool)) { + plain, hostile := d.requests(sp) + for _, r := range plain { + fn(r.path, r, false) + } + for _, h := range hostile { + fn(h, req888{path: h}, true) + } +} + +// TestDotDotRootsMiddleware888: the static middleware, every spelling. +func TestDotDotRootsMiddleware888(t *testing.T) { + d := newDotTree888(t) + for _, sp := range d.spellings() { + mw := New(Config{Root: sp.root}) + eachRequest888(d, sp, func(path string, r req888, hostile bool) { + rec, _ := get888(t, mw, path) + d.verdict(t, "static", sp, r, hostile, rec.StatusCode, string(rec.Body)) + }) + } +} + +// TestDotDotRootsFileFromDir888: Context.FileFromDir, which Server.Static and +// Group.Static call with the route's tail (no leading slash). +func TestDotDotRootsFileFromDir888(t *testing.T) { + d := newDotTree888(t) + for _, sp := range d.spellings() { + eachRequest888(d, sp, func(path string, r req888, hostile bool) { + c, rec := celeristest.NewContext("GET", "/f") + defer celeristest.ReleaseContext(c) + _ = c.FileFromDir(sp.root, strings.TrimPrefix(path, "/")) + d.verdict(t, "FileFromDir", sp, r, hostile, rec.StatusCode, string(rec.Body)) + }) + } +} + +// wire888 starts a server, registers with reg, and returns a function that +// sends a raw request line (nothing cleans the path) and returns the status +// and body. The server is stopped with the test. +func wire888(t *testing.T, eng celeris.EngineType, reg func(s *celeris.Server)) func(path string) (int, string) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + s := celeris.New(celeris.Config{Engine: eng}) + reg(s) + done := make(chan error, 1) + go func() { done <- s.StartWithListener(ln) }() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + <-done + }) + return func(path string) (int, string) { + var lastErr error + for range 200 { + c, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + lastErr = err + time.Sleep(20 * time.Millisecond) + continue + } + defer func() { _ = c.Close() }() + _ = c.SetDeadline(time.Now().Add(10 * time.Second)) + _, _ = fmt.Fprintf(c, "GET %s HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n", path) + b, _ := io.ReadAll(c) + resp := string(b) + var status int + _, _ = fmt.Sscanf(resp, "HTTP/1.1 %d", &status) + body := "" + if i := strings.Index(resp, "\r\n\r\n"); i >= 0 { + body = resp[i+4:] + } + return status, body + } + t.Fatalf("cannot connect: %v", lastErr) + return 0, "" + } +} + +// wireEngines888 is the engines the wire tests run on; an epoll engine is added +// on Linux (wire_engines_888_linux_test.go). +var wireEngines888 = []celeris.EngineType{celeris.Std} + +// TestDotDotRootsOverTheWire888: the three entry points a user mounts, on the +// real engines: the static middleware, Server.Static and Group.Static. +func TestDotDotRootsOverTheWire888(t *testing.T) { + d := newDotTree888(t) + type entry struct { + name string + prefix string + reg func(root string) func(s *celeris.Server) + } + entries := []entry{ + {"middleware", "", func(root string) func(s *celeris.Server) { + return func(s *celeris.Server) { s.Use(New(Config{Root: root})) } + }}, + {"Server.Static", "/files", func(root string) func(s *celeris.Server) { + return func(s *celeris.Server) { s.Static("/files", root) } + }}, + {"Group.Static", "/g/files", func(root string) func(s *celeris.Server) { + return func(s *celeris.Server) { s.Group("/g").Static("/files", root) } + }}, + } + for _, eng := range wireEngines888 { + for _, e := range entries { + for _, sp := range d.spellings() { + t.Run(fmt.Sprintf("%v/%s/%s", eng, e.name, sp.root), func(t *testing.T) { + raw := wire888(t, eng, e.reg(sp.root)) + eachRequest888(d, sp, func(path string, r req888, hostile bool) { + status, body := raw(e.prefix + path) + d.verdict(t, e.name, sp, r, hostile, status, body) + }) + }) + } + } + } +} diff --git a/middleware/static/root_spellings_888_test.go b/middleware/static/root_spellings_888_test.go index cbb9a2ae7..f377ab736 100644 --- a/middleware/static/root_spellings_888_test.go +++ b/middleware/static/root_spellings_888_test.go @@ -384,7 +384,7 @@ func TestRootSpellingsOverTheWire888(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _ = s.Shutdown(ctx) - <-done + _ = <-done }) raw := func(path string) string { var lastErr error diff --git a/middleware/static/wire_engines_888_linux_test.go b/middleware/static/wire_engines_888_linux_test.go new file mode 100644 index 000000000..54afbe31f --- /dev/null +++ b/middleware/static/wire_engines_888_linux_test.go @@ -0,0 +1,7 @@ +//go:build linux + +package static + +import "github.com/goceleris/celeris" + +func init() { wireEngines888 = append(wireEngines888, celeris.Epoll) } From 0fe03b5f405e795e22a63213147c7e96eb2c40b5 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 03:44:45 +0100 Subject: [PATCH 07/26] tests(static, ctxkit): readiness probe for the wire harness, a secret above the tree, '../..' rows, HEAD and Range rows (Refs #888) --- internal/ctxkit/pathwithin_test.go | 43 ++++++++++++++++++++ middleware/static/root_dotdot_888_test.go | 23 ++++++++--- middleware/static/root_spellings_888_test.go | 26 ++++++++++++ 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/internal/ctxkit/pathwithin_test.go b/internal/ctxkit/pathwithin_test.go index db263e96e..c5e2aa7c6 100644 --- a/internal/ctxkit/pathwithin_test.go +++ b/internal/ctxkit/pathwithin_test.go @@ -37,6 +37,31 @@ func TestPathWithin(t *testing.T) { {"/", "/etc/passwd", true}, {"/", "/a", true}, } + // A base of ".." segments names a directory above the working directory; + // nothing above that one is within it. The working directory of these rows + // is a tree of three levels, so the answers do not depend on where it is. + dotdot := []struct { + base, p string + want bool + }{ + {"..", "..", true}, + {"..", "../a", true}, + {"..", "../c/a", true}, + {"..", "a", true}, // the working directory is inside its parent + {"..", "../..", false}, + {"..", "../../etc/passwd", false}, + {"../..", "../../x", true}, + {"../..", "../x", true}, + {"../..", "../../..", false}, + {"../..", "../../../x", false}, + {"../..", "../../../../etc/passwd", false}, + {"../../..", "../../../x", true}, + {"../../..", "../../../../x", false}, + {"../x", "../x/y", true}, + {"../x", "../y", false}, + {"../x", "../../x", false}, + {"..", "/etc/passwd", false}, + } for _, tc := range cases { if runtime.GOOS == "windows" { break // the cases are slash paths; the volume root is checked below @@ -45,6 +70,18 @@ func TestPathWithin(t *testing.T) { t.Errorf("PathWithin(%q, %q) = %v, want %v", tc.base, tc.p, got, tc.want) } } + if runtime.GOOS != "windows" { + wd := filepath.Join(t.TempDir(), "a", "b", "c") + if err := os.MkdirAll(wd, 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(wd) + for _, tc := range dotdot { + if got := PathWithin(tc.base, tc.p); got != tc.want { + t.Errorf("PathWithin(%q, %q) = %v, want %v", tc.base, tc.p, got, tc.want) + } + } + } // The root of the volume, whatever the separator. root := filepath.VolumeName(os.TempDir()) + sep if !PathWithin(root, filepath.Join(os.TempDir(), "x")) || !PathWithin(root, root) { @@ -81,6 +118,12 @@ func TestResolvedWithin(t *testing.T) { {dir, filepath.Join(dir, "a"), true}, {dir, "/etc/passwd", false}, {"/", "/etc/passwd", true}, + // A relative path that climbs back into the base (a symlink "../c/a" + // from a working directory named c), and one that climbs out. + {".", filepath.Join("..", filepath.Base(dir), "a"), true}, + {".", filepath.Join("..", "other"), false}, + {"..", filepath.Join("..", filepath.Base(dir), "a"), true}, + {"..", filepath.Join("..", "..", "x"), false}, } { if got := ResolvedWithin(tc.base, tc.resolved); got != tc.want { t.Errorf("ResolvedWithin(%q, %q) = %v, want %v", tc.base, tc.resolved, got, tc.want) diff --git a/middleware/static/root_dotdot_888_test.go b/middleware/static/root_dotdot_888_test.go index a71a54ca5..71f504020 100644 --- a/middleware/static/root_dotdot_888_test.go +++ b/middleware/static/root_dotdot_888_test.go @@ -28,7 +28,7 @@ import ( // // The tree (T is a temp dir; the working directory is T/p/c): // -// T/TOPSECRET.txt T/p/PSECRET.txt T/p/other/o.txt +// T/../ABOVE.txt T/TOPSECRET.txt T/p/PSECRET.txt T/p/other/o.txt // T/p/c/{index.html,a.txt,.env} T/p/c/sub/{index.html,b.txt} T/p/c/x/ // // Every file has its own marker as content. For each root spelling the @@ -50,13 +50,14 @@ func newDotTree888(t *testing.T) dotTree888 { if runtime.GOOS == "windows" { t.Skip("slash paths and Root \"/\": not run on Windows") } - top, err := filepath.EvalSymlinks(t.TempDir()) + base, err := filepath.EvalSymlinks(t.TempDir()) if err != nil { t.Fatal(err) } + top := filepath.Join(base, "top") d := dotTree888{top: top, cwd: filepath.Join(top, "p", "c"), files: map[string]string{}} for _, f := range []string{ - "TOPSECRET.txt", "p/PSECRET.txt", "p/other/o.txt", + "../ABOVE.txt", "TOPSECRET.txt", "p/PSECRET.txt", "p/other/o.txt", "p/c/index.html", "p/c/a.txt", "p/c/.env", "p/c/sub/index.html", "p/c/sub/b.txt", } { abs := filepath.Join(top, filepath.FromSlash(f)) @@ -85,7 +86,7 @@ func (d dotTree888) spellings() []rootSpelling888 { return []rootSpelling888{ {".", d.cwd}, {"./", d.cwd}, {"x/..", d.cwd}, {"sub/..", d.cwd}, {"./sub/../", d.cwd}, {"..", abs("..")}, {"../", abs("..")}, {"./..", abs("..")}, {"x/../..", abs("..")}, {"a/../..", abs("..")}, - {"../..", abs("../..")}, {"../../", abs("../..")}, + {"../..", abs("../..")}, {"../../", abs("../..")}, {"../../..", abs("../../..")}, {"../x/..", abs("..")}, {"../c", d.cwd}, {"../other", abs("../other")}, {"sub", abs("sub")}, {"sub/", abs("sub")}, {d.cwd, d.cwd}, {d.cwd + "/", d.cwd}, {filepath.Join(d.cwd, ".."), abs("..")}, @@ -211,7 +212,7 @@ func wire888(t *testing.T, eng celeris.EngineType, reg func(s *celeris.Server)) _ = s.Shutdown(ctx) <-done }) - return func(path string) (int, string) { + do := func(path string) (int, string) { var lastErr error for range 200 { c, err := net.Dial("tcp", ln.Addr().String()) @@ -236,6 +237,18 @@ func wire888(t *testing.T, eng celeris.EngineType, reg func(s *celeris.Server)) t.Fatalf("cannot connect: %v", lastErr) return 0, "" } + // The server is ready when something answers: a native engine closes the + // listener it was given and rebinds it, so an early connection is dropped. + for deadline := time.Now().Add(30 * time.Second); ; { + if status, _ := do("/ready-probe"); status != 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("the server did not answer in 30 s") + } + time.Sleep(20 * time.Millisecond) + } + return do } // wireEngines888 is the engines the wire tests run on; an epoll engine is added diff --git a/middleware/static/root_spellings_888_test.go b/middleware/static/root_spellings_888_test.go index f377ab736..09cfcb426 100644 --- a/middleware/static/root_spellings_888_test.go +++ b/middleware/static/root_spellings_888_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/goceleris/celeris" + "github.com/goceleris/celeris/celeristest" ) // A Root of ".", "./" and "/" serves the tree below it (celeris#888 item 46; @@ -449,3 +450,28 @@ func TestRootSpellingsBrowseDoesNotListASymlinkedDirOut888(t *testing.T) { }) } } + +// TestRootSpellingsHeadAndRange888: HEAD and a byte range below "." , "./" and +// "/" are answered as under any Root (the recorder keeps a HEAD's body; the +// engine drops it on the wire). +func TestRootSpellingsHeadAndRange888(t *testing.T) { + for _, sp := range spellings888() { + t.Run(sp.name, func(t *testing.T) { + r := tree888(t) + mw := sp.open888(t, r, Config{}) + path := sp.url(r, "/hello.txt") + ctx, rec := celeristest.NewContextT(t, "HEAD", path, celeristest.WithHandlers(mw)) + if err := ctx.Next(); err != nil || rec.StatusCode != 200 || rec.Header("content-length") != fmt.Sprint(len(helloBody888)) { + t.Errorf("HEAD %s: status %d, content-length %q, err %v, want 200 and %d", path, rec.StatusCode, rec.Header("content-length"), err, len(helloBody888)) + } + rg, err := get888(t, mw, path, "range", "bytes=0-4") + if err != nil || rg.StatusCode != 206 || string(rg.Body) != "hello" { + t.Errorf("GET %s bytes=0-4: status %d body %q err %v, want 206 %q", path, rg.StatusCode, rg.Body, err, "hello") + } + ri, err := get888(t, mw, sp.url(r, "/"), "range", "bytes=0-2") + if err != nil || ri.StatusCode != 206 || string(ri.Body) != indexBody888[:3] { + t.Errorf("GET / bytes=0-2: status %d body %q err %v, want 206 %q", ri.StatusCode, ri.Body, err, indexBody888[:3]) + } + }) + } +} From e6ac71f22a5ca037a9e15bd10ed403a28fa83b33 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 03:46:21 +0100 Subject: [PATCH 08/26] fix(static, celeris): a Root or FileFromDir base made of '..' segments is the directory it resolves to, and nothing outside it is served; docs for the upgrade hazard and Static (Refs #888) PathWithin special-cased '.' and not '..': for a base of '..', '../../etc/passwd' starts with '..' and a separator, so it was 'within', and Root '..' or '../..' (and FileFromDir, so Server.Static and Group.Static) served any file the process could read to a request that climbs. The old prefix test had the same flaw: it is on main since v1.3.3. A relative base that starts with '..' and the path are now made absolute against the working directory with its symlinks resolved, and compared as absolute paths. ResolvedWithin uses the same step when the lexical comparison refuses, which also serves a symlink that climbs through the root's own name (a link '../c/a' under Root '.' in a directory named c). Docs: Config.Root, doc.go, FileFromDir, Server.Static and Group.Static state that '.' serves the working directory with its dotfiles, that '.' is the whole filesystem when the working directory is '/', and that a configuration inert since v1.3.3 starts to serve after the upgrade; the package has no dotfile rule today and a default deny is being decided. --- context_response.go | 7 +- group.go | 7 +- internal/ctxkit/pathwithin.go | 77 +++++++++++++++++--- middleware/static/config.go | 27 ++++--- middleware/static/doc.go | 12 ++- middleware/static/root_spellings_888_test.go | 3 + server.go | 7 +- 7 files changed, 111 insertions(+), 29 deletions(-) diff --git a/context_response.go b/context_response.go index fc90ab1ab..17fa81976 100644 --- a/context_response.go +++ b/context_response.go @@ -1074,7 +1074,12 @@ func (c *Context) trySendFile(f *os.File, status int, contentType string, offset // "." or "./" is the working directory and one of "/" the filesystem's root // (before v1.6.0 these two refused every file with a 400): with "/" nothing is // outside baseDir, so only the file's own existence limits what userPath may -// name. An empty baseDir is refused with a 400, not read as ".". +// name. An empty baseDir is refused with a 400, not read as ".". A relative +// baseDir is read against the working directory at each call; one made of ".." +// segments ("..", "../..") means the directory it resolves to, and a userPath +// that climbs out of that directory is refused (before v1.6.0 it was not: a +// baseDir of ".." served any file the process could read). Dotfiles are served: +// there is no rule against them. func (c *Context) FileFromDir(baseDir, userPath string) error { return c.fileFromDir(baseDir, userPath, "", nil) } diff --git a/group.go b/group.go index b690df9f3..e06c0d3fe 100644 --- a/group.go +++ b/group.go @@ -123,7 +123,12 @@ func (g *RouteGroup) Handle(method, path string, handlers ...HandlerFunc) *Route } // Static registers a GET handler that serves files from root under the given -// prefix. Uses FileFromDir for path traversal protection. +// prefix. Uses FileFromDir for path traversal protection. root may be ".", +// "./" or "/" (from v1.6.0; before, every file below them was refused): "." is +// the working directory, dotfiles and .git included, and "/" the whole +// filesystem, so name a directory that holds only what is public. A root made +// of ".." segments means the directory it resolves to, and nothing outside it +// is served. An empty root serves nothing. func (g *RouteGroup) Static(prefix, root string) *Route { p := strings.TrimRight(prefix, "/") + "/*filepath" return g.GET(p, func(c *Context) error { diff --git a/internal/ctxkit/pathwithin.go b/internal/ctxkit/pathwithin.go index 628bce816..29d7adda8 100644 --- a/internal/ctxkit/pathwithin.go +++ b/internal/ctxkit/pathwithin.go @@ -1,6 +1,7 @@ package ctxkit import ( + "os" "path/filepath" "strings" ) @@ -8,21 +9,51 @@ import ( // PathWithin reports whether p is base or a path below it. Both are cleaned // paths (filepath.Clean), of the same kind: both relative or both absolute. // -// The test "p starts with base + separator" is wrong for two bases. The -// working directory, ".", is a prefix of no cleaned path (filepath.Join(".", +// The test "p starts with base + separator" is wrong for three kinds of base. +// The working directory, ".", is a prefix of no cleaned path (filepath.Join(".", // "a/b") is "a/b"), so everything below it was refused: p is below "." when it // is relative and does not climb out ("..", "../x"). A base that ends in a // separator, the root of a filesystem ("/", `C:\`), has "//" as its prefix, which -// no path starts with: p is below it when it starts with base itself. +// no path starts with: p is below it when it starts with base itself. And a +// relative base that starts with ".." names a directory above the working +// directory, so a path that "starts with" it says nothing ("../../etc/passwd" +// starts with ".." and a separator, and is not below ".."): such a base and p are +// made absolute, against the working directory with its symlinks resolved, and +// compared as absolute paths. A Root of "..", "../.." or "../x/.." serves the +// directory it resolves to and nothing outside it. func PathWithin(base, p string) bool { + if p == base { + return true + } + if !filepath.IsAbs(base) && climbs(base) { + ab, err := absPhysical(base) + if err != nil { + return false + } + ap, err := absPhysical(p) + if err != nil { + return false + } + return withinClean(ab, ap) + } + return withinClean(base, p) +} + +// climbs reports whether the cleaned relative path starts with "..". +func climbs(p string) bool { + return p == ".." || strings.HasPrefix(p, ".."+string(filepath.Separator)) +} + +// withinClean is PathWithin for two cleaned paths whose base does not climb. +func withinClean(base, p string) bool { if p == base { return true } if base == "." { - if filepath.IsAbs(p) || filepath.VolumeName(p) != "" || p == ".." { + if filepath.IsAbs(p) || filepath.VolumeName(p) != "" { return false } - return !strings.HasPrefix(p, ".."+string(filepath.Separator)) + return !climbs(p) } if !strings.HasPrefix(p, base) { return false @@ -30,27 +61,49 @@ func PathWithin(base, p string) bool { return base[len(base)-1] == filepath.Separator || p[len(base)] == filepath.Separator } +// absPhysical makes the cleaned path absolute against the working directory +// with its symlinks resolved, so that ".." in the result climbs the way the +// operating system does (a working directory reached through a symlink has a +// different parent than its logical path has). An absolute path is returned +// as it is. +func absPhysical(p string) (string, error) { + if filepath.IsAbs(p) { + return p, nil + } + wd, err := os.Getwd() + if err != nil { + return "", err + } + wd, err = filepath.EvalSymlinks(wd) + if err != nil { + return "", err + } + return filepath.Join(wd, p), nil +} + // ResolvedWithin is PathWithin for the results of filepath.EvalSymlinks: // resolved is the resolved path and base the resolved base. EvalSymlinks keeps a // relative path relative unless a symlink on the way has an absolute target, so // under a relative base (".", "public") a resolved path can be absolute and -// still inside it. That is the case ResolvedWithin settles, with the base made -// absolute and resolved the same way; a resolved path that stays relative is -// compared as it is (one that climbs, "../x", is refused). +// still inside it, or relative and climb through the base's own name +// ("../c/a" from a working directory c) and still be inside. When the lexical +// comparison refuses and one of the two is relative, both are made absolute +// (absPhysical; neither holds a symlink any more, so the result is exact) and +// compared; two absolute paths are compared as they are. func ResolvedWithin(base, resolved string) bool { if PathWithin(base, resolved) { return true } - if filepath.IsAbs(base) || !filepath.IsAbs(resolved) { + if filepath.IsAbs(base) && filepath.IsAbs(resolved) { return false } - abs, err := filepath.Abs(base) + ab, err := absPhysical(base) if err != nil { return false } - abs, err = filepath.EvalSymlinks(abs) + ar, err := absPhysical(resolved) if err != nil { return false } - return PathWithin(abs, resolved) + return withinClean(ab, ar) } diff --git a/middleware/static/config.go b/middleware/static/config.go index 3c25d0043..3d9fea826 100644 --- a/middleware/static/config.go +++ b/middleware/static/config.go @@ -20,20 +20,25 @@ type Config struct { // the middleware panics. // // Everything below Root is served, dotfiles and dot directories included - // (.env, .git/config): the package has no rule against them. A path that - // leaves Root, by "..", an absolute path, or a symlink that resolves outside - // it, is refused. A relative Root is read against the working directory at - // each request. + // (.env, .git/config): the package has no rule against them today (a + // default deny for them is being decided). A path that leaves Root, by + // "..", an absolute path, or a symlink that resolves outside it, is + // refused. A relative Root is read against the working directory at each + // request; a Root made of ".." segments ("..", "../..") means the directory + // it resolves to, and nothing outside that directory is served. // // Root may be ".", "./" or "/", and serves the tree below it as any other // directory does. Mind what that is: "." is the process's working - // directory, with its sources and configuration, and "/" is the whole - // filesystem (/etc/passwd, the home directories, /proc/self/environ, the - // process's environment: whatever the process may read), where nothing is - // outside Root any more, so the containment check that protects the others - // protects nothing. Before v1.6.0 these three served nothing below the - // root, so an upgrade starts to serve what the config always named: name a - // directory that holds only what is meant to be public. + // directory, with its sources and configuration (.env, .git), and "/" is + // the whole filesystem (/etc/passwd, the home directories, + // /proc/self/environ, the process's environment: whatever the process may + // read), where nothing is outside Root any more, so the containment check + // that protects the others protects nothing. When the working directory + // is "/" (the default of a systemd service and of a container without a + // WORKDIR), "." is the whole filesystem too. Before v1.6.0 these three + // served nothing below the root, so a configuration that was inert since + // v1.3.3 starts to serve files after the upgrade: name a directory that + // holds only what is meant to be public. Root string // FS is an [fs.FS] from which to serve files (e.g. embed.FS). diff --git a/middleware/static/doc.go b/middleware/static/doc.go index 76d83c3d6..a8406de33 100644 --- a/middleware/static/doc.go +++ b/middleware/static/doc.go @@ -34,9 +34,15 @@ // checks agree, for a Root of ".", "./" and "/" as for any other directory: // these three serve the tree below them (before v1.6.0 they served nothing below // the root, and the root's own index answered 400 or 304 depending on whether a -// validator matched). Their meaning is wide: "." is the working directory and -// "/" the whole filesystem, and dotfiles are served under every Root. Name the -// directory that holds what is public; see [Config].Root. +// validator matched). Their meaning is wide: "." is the working directory, +// dotfiles (.env, .git) included, and "/" the whole filesystem; when the +// working directory is "/" (a systemd service, a container without a WORKDIR), +// "." is the whole filesystem too, /proc/self/environ among its files. A +// configuration that was inert since v1.3.3 starts to serve files after the +// upgrade. The package has no dotfile rule today (a default deny is being +// decided). A Root made of ".." segments means the directory it resolves to, +// and nothing outside it is served. Name the directory that holds what is +// public; see [Config].Root. // // With compress in front of an [Config].FS, the strong tag of a file that compress // encodes is made weak on the 200 (W/"hash"), and static marks the response so diff --git a/middleware/static/root_spellings_888_test.go b/middleware/static/root_spellings_888_test.go index 09cfcb426..426b24435 100644 --- a/middleware/static/root_spellings_888_test.go +++ b/middleware/static/root_spellings_888_test.go @@ -41,6 +41,7 @@ const ( // index.html, hello.txt, .env, .hidden/x.txt, sub/index.html, sub/page.txt // in-abs -> /hello.txt (a symlink to a file inside, absolute) // in-rel -> hello.txt (relative) +// in-up -> ../root/hello.txt (relative, climbs through the root's own name) // in-dir -> /sub (a symlink to a directory inside, absolute) // esc-abs -> /secret.txt (a symlink out of the root) // esc-rel -> ../outside/secret.txt @@ -62,6 +63,7 @@ func tree888(t *testing.T) root888 { } r.link(t, filepath.Join(r.root, "hello.txt"), "in-abs") r.link(t, "hello.txt", "in-rel") + r.link(t, filepath.Join("..", "root", "hello.txt"), "in-up") // climbs, through the root's own name r.link(t, filepath.Join(r.root, "sub"), "in-dir") r.link(t, filepath.Join(r.outside, "secret.txt"), "esc-abs") r.link(t, filepath.Join("..", "outside", "secret.txt"), "esc-rel") @@ -210,6 +212,7 @@ func TestRootSpellingsFollowSymlinksInside888(t *testing.T) { }{ {"/in-abs", helloBody888, len(helloBody888)}, {"/in-rel", helloBody888, len(helloBody888)}, + {"/in-up", helloBody888, len(helloBody888)}, {"/in-dir/page.txt", pageBody888, len(pageBody888)}, {"/in-dir/", subBody888, len(subBody888)}, } diff --git a/server.go b/server.go index aeaa06f7a..b06980b48 100644 --- a/server.go +++ b/server.go @@ -334,7 +334,12 @@ func (s *Server) OnShutdown(fn func(ctx context.Context)) *Server { } // Static registers a GET handler that serves files from root under the given -// prefix. Uses FileFromDir for path traversal protection. +// prefix. Uses FileFromDir for path traversal protection. root may be ".", +// "./" or "/" (from v1.6.0; before, every file below them was refused): "." is +// the working directory, dotfiles and .git included, and "/" the whole +// filesystem, so name a directory that holds only what is public. A root made +// of ".." segments means the directory it resolves to, and nothing outside it +// is served. An empty root serves nothing. // // s.Static("/assets", "./public") func (s *Server) Static(prefix, root string) *Route { From ab94de1ee35cb6dd77c4d137912aa0588276fc81 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 03:49:22 +0100 Subject: [PATCH 09/26] tests(static): the wire harnesses read Start's result instead of discarding it (Refs #888) --- middleware/static/root_dotdot_888_test.go | 4 +++- middleware/static/root_spellings_888_test.go | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/middleware/static/root_dotdot_888_test.go b/middleware/static/root_dotdot_888_test.go index 71f504020..d830210ef 100644 --- a/middleware/static/root_dotdot_888_test.go +++ b/middleware/static/root_dotdot_888_test.go @@ -210,7 +210,9 @@ func wire888(t *testing.T, eng celeris.EngineType, reg func(s *celeris.Server)) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _ = s.Shutdown(ctx) - <-done + if err := <-done; err != nil { + t.Logf("Start returned %v", err) + } }) do := func(path string) (int, string) { var lastErr error diff --git a/middleware/static/root_spellings_888_test.go b/middleware/static/root_spellings_888_test.go index 426b24435..85ce1fb39 100644 --- a/middleware/static/root_spellings_888_test.go +++ b/middleware/static/root_spellings_888_test.go @@ -388,7 +388,9 @@ func TestRootSpellingsOverTheWire888(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _ = s.Shutdown(ctx) - _ = <-done + if err := <-done; err != nil { + t.Logf("Start returned %v", err) + } }) raw := func(path string) string { var lastErr error From af174598e196e92bb19eac9001393c94015e6ca2 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 10:28:37 +0100 Subject: [PATCH 10/26] static: the Dotfiles config field, DotfilesPolicy and its three values, with no behaviour yet (the API and its golden only; Refs #888) --- api/middleware/static.txt | 7 ++- internal/ctxkit/hidden.go | 110 ++++++++++++++++++++++++++++++++++++ middleware/static/config.go | 27 +++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 internal/ctxkit/hidden.go diff --git a/api/middleware/static.txt b/api/middleware/static.txt index 488684dac..69e190b40 100644 --- a/api/middleware/static.txt +++ b/api/middleware/static.txt @@ -2,10 +2,14 @@ # Package clause: static. # The exported API, generated by .github/tools/apidump: do not edit, run `mage api`. # Built for: every configuration (api/README.md). +const DotfilesAllow DotfilesPolicy = 1 +const DotfilesDeny DotfilesPolicy = 2 +const DotfilesIgnore DotfilesPolicy = 0 func New(...Config) github.com/goceleris/celeris.HandlerFunc type Config struct type Config struct, Browse bool type Config struct, Compress bool +type Config struct, Dotfiles DotfilesPolicy type Config struct, FS io/fs.FS type Config struct, Index string type Config struct, MaxAge time.Duration @@ -14,5 +18,6 @@ type Config struct, Root string type Config struct, SPA bool type Config struct, Skip func(*github.com/goceleris/celeris.Context) bool type Config struct, SkipPaths []string -type Config struct, field order Skip, SkipPaths, Root, FS, Prefix, Index, Browse, SPA, MaxAge, Compress +type Config struct, field order Skip, SkipPaths, Root, FS, Prefix, Index, Browse, SPA, MaxAge, Compress, Dotfiles type Config struct, not comparable +type DotfilesPolicy uint8 diff --git a/internal/ctxkit/hidden.go b/internal/ctxkit/hidden.go new file mode 100644 index 000000000..743e9f105 --- /dev/null +++ b/internal/ctxkit/hidden.go @@ -0,0 +1,110 @@ +package ctxkit + +import ( + "errors" + "net/url" + "path/filepath" + "strings" +) + +// ErrHidden is returned by the registered FileFromDir when the path it was +// asked to serve, once its symlinks are resolved, has a hidden segment (see +// HiddenPath) and the caller asked for hidden paths to be refused. Nothing has +// been written to the response. +var ErrHidden = errors.New("hidden path") + +// wellKnown is the one dot segment that is not hidden: RFC 8615 (ACME +// challenges, security.txt, apple-app-site-association). Only this exact name. +const wellKnown = ".well-known" + +// HiddenSegment reports whether one path segment names a hidden file or +// directory: it starts with a dot (".env", ".git", ".ssh", ".htaccess", +// ".."), except the exact name ".well-known". The segment "." and the empty +// segment are not names and are not hidden (HiddenPath drops them first). A +// segment that percent-decodes to such a name is hidden too, so a layer that +// decodes the path later cannot turn a visible segment into a dotfile. +func HiddenSegment(seg string) bool { + if hiddenName(seg) { + return true + } + if strings.IndexByte(seg, '%') >= 0 { + if dec, err := url.PathUnescape(seg); err == nil && dec != seg && hiddenName(dec) { + return true + } + } + return false +} + +func hiddenName(s string) bool { + return len(s) > 0 && s[0] == '.' && s != wellKnown +} + +// HiddenPath reports whether a slash path below a root has a hidden segment +// (HiddenSegment). The path is cleaned lexically first, as filepath.Join cleans +// the path that is then opened: empty and "." segments are dropped and ".." +// removes the segment before it, so "x/../.env" is ".env" and "/.git/../a" is +// "a". Backslashes separate segments too (on Windows they do), which makes a +// name that holds a backslash and then a dot ("a\.b") hidden on the other +// systems: harmless. Only the segments of p count: the name of the root the path +// is below never does. +func HiddenPath(p string) bool { + var stack [16]string + segs := stack[:0] + for len(p) > 0 { + i := strings.IndexAny(p, `/\`) + var seg string + if i < 0 { + seg, p = p, "" + } else { + seg, p = p[:i], p[i+1:] + } + switch seg { + case "", ".": + case "..": + if len(segs) > 0 { + segs = segs[:len(segs)-1] + } + default: + segs = append(segs, seg) + } + } + for _, s := range segs { + if HiddenSegment(s) { + return true + } + } + return false +} + +// RelWithin returns the slash path of p below base, for a p that +// ResolvedWithin(base, p) accepted. Both are results of filepath.EvalSymlinks. +func RelWithin(base, p string) (string, bool) { + if !(!filepath.IsAbs(base) && climbs(base)) && withinClean(base, p) { + switch { + case p == base: + return "", true + case base == ".": + return filepath.ToSlash(p), true + case base[len(base)-1] == filepath.Separator: + return filepath.ToSlash(p[len(base):]), true + default: + return filepath.ToSlash(p[len(base)+1:]), true + } + } + ab, err := absPhysical(base) + if err != nil { + return "", false + } + ap, err := absPhysical(p) + if err != nil { + return "", false + } + rel, err := filepath.Rel(ab, ap) + if err != nil || climbs(rel) { + return "", false + } + if rel == "." { + rel = "" + } + return filepath.ToSlash(rel), true +} diff --git a/middleware/static/config.go b/middleware/static/config.go index 3d9fea826..79bf8fc3b 100644 --- a/middleware/static/config.go +++ b/middleware/static/config.go @@ -7,6 +7,29 @@ import ( "github.com/goceleris/celeris" ) +// DotfilesPolicy is what the middleware does with a request whose path below +// the root has a segment that names a hidden file or directory: one that +// starts with a dot (.env, .git, .ssh, .htaccess), except the exact segment +// ".well-known". See [Config].Dotfiles. +type DotfilesPolicy uint8 + +const ( + // DotfilesIgnore (the default) answers such a request as if the file did + // not exist: the request goes to the next handler, as a path with no file + // does (the router's 404 when nothing else answers it), so nothing tells + // a client whether the hidden file exists. In SPA mode it gets the index + // file, as any other missing path does. + DotfilesIgnore DotfilesPolicy = iota + + // DotfilesAllow serves hidden files like any other: the behaviour before + // v1.6.0. + DotfilesAllow + + // DotfilesDeny answers such a request with 403 Forbidden, whether or not + // the file exists. + DotfilesDeny +) + // Config defines the static file middleware configuration. type Config struct { // Skip defines a function to skip this middleware for certain requests. @@ -87,6 +110,10 @@ type Config struct { // Accept-Encoding in Vary (once), whether or not the file has a variant. // Works with both OS filesystem (Root) and fs.FS. Compress bool + + // Dotfiles is the policy for hidden files and directories: names that + // start with a dot. The zero value is [DotfilesIgnore]. + Dotfiles DotfilesPolicy } var defaultConfig = Config{ From 4cc22158ca139ba9584cbd6583a4a07ebcfe1eca Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 10:30:52 +0100 Subject: [PATCH 11/26] tests(static): the default dotfile rule: Ignore, Deny, Allow, .well-known, root names, Index, Browse, SPA, symlinks, FS, HEAD/Range/conditional, the wire (Refs #888) Red on the head before the rule (against the Dotfiles field with no behaviour): TestDotfiles{Policies,HeadRangeConditional,RootNameIsNotHidden,Index,Browse,SPA,Symlinks,FS, OverTheWire}888 fail (142 FAIL lines, 244 PASS: the passes are the visible rows and the Allow rows); TestFileFromDirServesDotfiles888 passes and pins that Context.FileFromDir is unchanged. --- middleware/static/dotfiles_888_test.go | 519 +++++++++++++++++++++++++ 1 file changed, 519 insertions(+) create mode 100644 middleware/static/dotfiles_888_test.go diff --git a/middleware/static/dotfiles_888_test.go b/middleware/static/dotfiles_888_test.go new file mode 100644 index 000000000..7fa634f71 --- /dev/null +++ b/middleware/static/dotfiles_888_test.go @@ -0,0 +1,519 @@ +package static + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "testing/fstest" + + "github.com/goceleris/celeris" + "github.com/goceleris/celeris/celeristest" +) + +// The default dotfile rule (celeris#888 item 46, the decision of the maintainer +// after the review of Root "."): a request whose path below the root has a +// segment that starts with a dot is answered as if the file did not exist +// (DotfilesIgnore, the default), refused with 403 (DotfilesDeny), or served +// (DotfilesAllow). ".well-known", exactly, is not a dot segment. Only the +// segments of the request count, never the name of the root. + +const ( + tagIndex888 = "INDEX" + tagWK888 = "ACME" +) + +// dotTree writes the tree of these tests below a new temp dir and returns it. +// +// index.html a.txt a.env .env .htaccess .git/config .ssh/id dir.with.dots/z.txt +// sub/{index.html,b.txt,.hidden,.d/inner.txt} d/.index.html bare/{.a,b} +// .well-known/{acme.txt,security.txt,.secret,.sub/x.txt,child/y.txt} +// .well-known-x/z.txt .well-knownx +// link-env -> .env link-dir -> .git link-wk -> .well-known .dot-link -> a.txt +var dotFiles888 = map[string]string{ + "index.html": tagIndex888, "a.txt": "A", "a.env": "AENV", ".env": "ENV", ".htaccess": "HTA", + ".git/config": "GITCFG", ".ssh/id": "SSH", "dir.with.dots/z.txt": "DOTS", + "sub/index.html": "SUBIDX", "sub/b.txt": "B", "sub/.hidden": "SUBHID", "sub/.d/inner.txt": "INNER", + "d/.index.html": "DOTINDEX", "bare/.a": "BA", "bare/b": "BB", + ".well-known/acme.txt": tagWK888, ".well-known/security.txt": "SEC", ".well-known/.secret": "WKSECRET", + ".well-known/.sub/x.txt": "WKSUB", ".well-known/child/y.txt": "WKCHILD", + ".well-known-x/z.txt": "WKX", ".well-knownx": "WKXFILE", +} + +func dotTree(t *testing.T) string { + t.Helper() + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + for name, body := range dotFiles888 { + p := filepath.Join(root, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(p, secretTime888, secretTime888); err != nil { + t.Fatal(err) + } + } + for link, target := range map[string]string{"link-env": ".env", "link-dir": ".git", "link-wk": ".well-known", ".dot-link": "a.txt"} { + if err := os.Symlink(target, filepath.Join(root, link)); err != nil { + t.Skipf("symlinks are not available here: %v", err) + } + } + return root +} + +func skipWindows888(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("slash paths") + } +} + +type dotCase888 struct { + path string + hidden bool // answered as hidden under Ignore and Deny + body string // the body when the path is served ("" when no file is there to serve) +} + +// dotCases888 are the requests of the policy tests, with the Root of dotTree. +var dotCases888 = []dotCase888{ + // hidden + {"/.env", true, "ENV"}, {"/.htaccess", true, "HTA"}, {"/.git/config", true, "GITCFG"}, {"/.ssh/id", true, "SSH"}, + {"/sub/.hidden", true, "SUBHID"}, {"/sub/.d/inner.txt", true, "INNER"}, {"/.git/", true, ""}, {"/.git", true, ""}, + {"/.well-known/.secret", true, "WKSECRET"}, {"/.well-known/.sub/x.txt", true, "WKSUB"}, + {"/.well-known-x/z.txt", true, "WKX"}, {"/.well-knownx", true, "WKXFILE"}, {"/.well-known.txt", true, ""}, + {"/.Well-Known/acme.txt", true, ""}, {"/.WELL-KNOWN/acme.txt", true, ""}, + {"/.nothing-here", true, ""}, {"/..env", true, ""}, {"/...", true, ""}, + // the same names reached through a path that cleans to them + {"/./.env", true, "ENV"}, {"//.env", true, "ENV"}, {"/sub/../.env", true, "ENV"}, {"/x/../.env", true, "ENV"}, + {"/a.txt/../.env", true, "ENV"}, {"/.well-known/../.env", true, "ENV"}, {"/.env/", true, "ENV"}, + {"/sub/./.hidden", true, "SUBHID"}, {"/.well-known/./.secret", true, "WKSECRET"}, {"/sub/.d/../.hidden", true, "SUBHID"}, + // backslashes separate segments on Windows, so they do here + {`/\.env`, true, ""}, {`/sub\.hidden`, true, ""}, {`/.well-known\.secret`, true, ""}, + // encoded: the path is the raw one, so "%2e" names no dotfile on its own; a + // segment that decodes to a dot name is hidden all the same, and a double + // encoding is decoded once only (it is a file named "%2eenv": none) + {"/%2eenv", true, ""}, {"/%2Eenv", true, ""}, {"/%2e%2eenv", true, ""}, {"/sub/%2ehidden", true, ""}, + {"/.well-known/%2esecret", true, ""}, {"/%2e%2e/.env", true, ""}, {"/%2e", true, ""}, {"/%2e%2e", true, ""}, + {"/%252eenv", false, ""}, {"/%252e%252e/.env", true, ""}, + {"/.env\x00", true, ""}, {"/.env%00", true, ""}, + // symlinks: a visible name that leads to a dotfile or directory of the tree, and a hidden name + {"/link-env", true, "ENV"}, {"/link-dir/config", true, "GITCFG"}, {"/.dot-link", true, "A"}, + // visible + {"/", false, tagIndex888}, {"/index.html", false, tagIndex888}, {"/a.txt", false, "A"}, {"/a.env", false, "AENV"}, + {"/sub/", false, "SUBIDX"}, {"/sub", false, "SUBIDX"}, {"/sub/b.txt", false, "B"}, + {"/dir.with.dots/z.txt", false, "DOTS"}, {"/./a.txt", false, "A"}, {"//a.txt", false, "A"}, {"/sub/../a.txt", false, "A"}, + // ".well-known", exactly, and its children that are not themselves hidden + {"/.well-known/acme.txt", false, tagWK888}, {"/.well-known/security.txt", false, "SEC"}, + {"/.well-known/child/y.txt", false, "WKCHILD"}, {"/.well-known/./acme.txt", false, tagWK888}, + {"/link-wk/acme.txt", false, tagWK888}, {"/link-wk/child/y.txt", false, "WKCHILD"}, + // ".git/../a.txt" cleans to "a.txt", which is what is opened + {"/.git/../a.txt", false, "A"}, {"/.well-known/../a.txt", false, "A"}, +} + +// missing888 is the outcome of a path that does not exist: what a hidden path +// is answered with under DotfilesIgnore. +func missing888(t *testing.T, mw celeris.HandlerFunc) outcome888 { + t.Helper() + o, _ := outcomeOf888(t, mw, "/no-such-file-here.txt") + if o.kind != "passed-on" { + t.Fatalf("harness: a missing path gives %v, want it passed on", o) + } + return o +} + +// TestDotfilesPolicies888: every request of the table under the three policies. +func TestDotfilesPolicies888(t *testing.T) { + skipWindows888(t) + root := dotTree(t) + for _, pol := range []DotfilesPolicy{DotfilesIgnore, DotfilesDeny, DotfilesAllow} { + mw := New(Config{Root: root, Dotfiles: pol}) + absent := missing888(t, mw) + for _, tc := range dotCases888 { + t.Run(fmt.Sprintf("policy%d/%q", pol, tc.path), func(t *testing.T) { + rec, err := get888(t, mw, tc.path) + o := outcome888{status: rec.StatusCode, body: string(rec.Body), kind: "ok"} + switch { + case err != nil: + o.kind = "error" + case rec.StatusCode == 0: + o.kind = "passed-on" + } + if err != nil { + if he, ok := err.(*celeris.HTTPError); ok { + o.status = he.Code + } + } + switch { + case pol == DotfilesAllow || !tc.hidden: + if tc.body == "" { + // (a case-insensitive file system finds ".Well-Known" under Allow) + if o.kind == "ok" && !strings.Contains(strings.ToLower(tc.path), ".well-known/acme") { + t.Errorf("GET %q under policy %d answered %v for a file that is not there", tc.path, pol, o) + } + } else if o.status != 200 || o.body != tc.body { + t.Errorf("GET %q under policy %d: %v, want 200 %q", tc.path, pol, o, tc.body) + } + case pol == DotfilesIgnore: + if o != absent { + t.Errorf("GET %q hidden under Ignore: %v, want what a missing file gets: %v", tc.path, o, absent) + } + default: // Deny + if o.kind != "error" || o.status != 403 || o.body != "" { + t.Errorf("GET %q hidden under Deny: %v, want a 403", tc.path, o) + } + } + if tc.hidden && pol != DotfilesAllow { + if tc.body != "" && strings.Contains(o.body, tc.body) { + t.Errorf("GET %q leaked %q: %v", tc.path, tc.body, o) + } + for _, h := range []string{"etag", "last-modified", "content-type", "content-length"} { + if v := rec.Header(h); v != "" { + t.Errorf("GET %q: header %s %q on a hidden path", tc.path, h, v) + } + } + } + }) + } + } +} + +// TestDotfilesHeadRangeConditional888: HEAD, Range and a matching validator on a +// hidden file behave as on a path that is not there (Ignore: passed on, Deny: +// 403): no 304, no 206, no validators. +func TestDotfilesHeadRangeConditional888(t *testing.T) { + skipWindows888(t) + root := dotTree(t) + tag := weakETag(secretTime888, int64(len("ENV"))) + for _, pol := range []DotfilesPolicy{DotfilesIgnore, DotfilesDeny} { + mw := New(Config{Root: root, Dotfiles: pol}) + for _, p := range []string{"/.env", "/link-env", "/.git/config"} { + for _, hdr := range [][]string{{"range", "bytes=0-1"}, {"if-none-match", tag}, {"if-none-match", "*"}, + {"if-modified-since", secretTime888.UTC().Format("Mon, 02 Jan 2006 15:04:05 GMT")}, {"if-range", tag, "range", "bytes=0-1"}} { + rec, err := get888(t, mw, p, hdr...) + if rec.StatusCode == 206 || rec.StatusCode == 304 || rec.StatusCode == 200 || len(rec.Body) != 0 { + t.Errorf("policy %d GET %s %v: status %d body %q err %v", pol, p, hdr, rec.StatusCode, rec.Body, err) + } + if pol == DotfilesDeny && !(err != nil && err.(*celeris.HTTPError).Code == 403) { + t.Errorf("policy %d GET %s %v: err %v, want a 403", pol, p, hdr, err) + } + if pol == DotfilesIgnore && err != nil { + t.Errorf("policy %d GET %s %v: err %v, want it passed on", pol, p, hdr, err) + } + for _, h := range []string{"etag", "last-modified", "content-range", "accept-ranges"} { + if v := rec.Header(h); v != "" { + t.Errorf("policy %d GET %s %v: header %s %q", pol, p, hdr, h, v) + } + } + } + ctx, rec := celeristest.NewContextT(t, "HEAD", p, celeristest.WithHandlers(mw)) + err := ctx.Next() + if rec.StatusCode == 200 || rec.Header("content-length") != "" || rec.Header("etag") != "" { + t.Errorf("policy %d HEAD %s: status %d content-length %q etag %q err %v", pol, p, rec.StatusCode, rec.Header("content-length"), rec.Header("etag"), err) + } + } + } +} + +// TestDotfilesRootNameIsNotHidden888: only the segments of the request count. +// A root that is, or sits in, a directory whose name starts with a dot serves +// its files: the working directory of a ".config" directory under ".", "./" and +// "..", an absolute root with a dot in every element, a Prefix that starts with +// a dot. +func TestDotfilesRootNameIsNotHidden888(t *testing.T) { + skipWindows888(t) + base, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + hiddenRoot := filepath.Join(base, ".hidden-parent", ".config") + for _, f := range []string{"a.txt", "sub/b.txt", ".env"} { + p := filepath.Join(hiddenRoot, filepath.FromSlash(f)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("body:"+f), 0o644); err != nil { + t.Fatal(err) + } + } + inner := filepath.Join(hiddenRoot, "sub") + for _, tc := range []struct { + name, root, cwd string + }{ + {"absolute", hiddenRoot, ""}, + {"absolute-trailing-slash", hiddenRoot + "/", ""}, + {"dot", ".", hiddenRoot}, + {"dot-slash", "./", hiddenRoot}, + {"dotdot", "..", inner}, + {"relative-named", ".config", filepath.Dir(hiddenRoot)}, + {"relative-dotted-path", "./.hidden-parent/.config", base}, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.cwd != "" { + t.Chdir(tc.cwd) + } + mw := New(Config{Root: tc.root}) + for _, p := range []string{"/a.txt", "/sub/b.txt"} { + o, _ := outcomeOf888(t, mw, p) + want := "body:" + strings.TrimPrefix(p, "/") + if o.status != 200 || o.body != want { + t.Errorf("Root %q GET %s: %v, want 200 %q (the root's own name is not hidden)", tc.root, p, o, want) + } + } + if o, _ := outcomeOf888(t, mw, "/.env"); o.status == 200 { + t.Errorf("Root %q GET /.env: %v, want the dotfile below the root hidden", tc.root, o) + } + }) + } + t.Run("prefix", func(t *testing.T) { + mw := New(Config{Root: hiddenRoot, Prefix: "/.assets"}) + if o, _ := outcomeOf888(t, mw, "/.assets/a.txt"); o.status != 200 || o.body != "body:a.txt" { + t.Errorf("Prefix /.assets: %v, want 200", o) + } + if o, _ := outcomeOf888(t, mw, "/.assets/.env"); o.status == 200 { + t.Errorf("Prefix /.assets, /.env below: %v, want hidden", o) + } + }) +} + +// TestDotfilesIndex888: Index is chosen by the operator and is never hidden by +// the rule, so ".index.html" is served for a directory request; the same name +// asked for by its path is a dot segment and is hidden. +func TestDotfilesIndex888(t *testing.T) { + skipWindows888(t) + root := dotTree(t) + for _, pol := range []DotfilesPolicy{DotfilesIgnore, DotfilesDeny} { + mw := New(Config{Root: root, Index: ".index.html", Dotfiles: pol}) + if o, _ := outcomeOf888(t, mw, "/d/"); o.status != 200 || o.body != "DOTINDEX" { + t.Errorf("policy %d GET /d/ with Index .index.html: %v, want 200 DOTINDEX", pol, o) + } + rec, err := get888(t, mw, "/d/.index.html") + if rec.StatusCode == 200 || strings.Contains(string(rec.Body), "DOTINDEX") { + t.Errorf("policy %d GET /d/.index.html: status %d body %q err %v, want hidden", pol, rec.StatusCode, rec.Body, err) + } + } + // SPA fallback with the dot-named Index. + spa := New(Config{Root: root, Index: "d/.index.html", SPA: true}) + if o, _ := outcomeOf888(t, spa, "/nothing/here"); o.status != 200 || o.body != "DOTINDEX" { + t.Errorf("SPA with a dot-named Index: %v, want 200 DOTINDEX", o) + } +} + +// TestDotfilesBrowse888: a listing leaves out the entries that start with a dot +// (but not ".well-known") under Ignore and Deny, and lists them under Allow; a +// hidden directory is not listed at all. +func TestDotfilesBrowse888(t *testing.T) { + skipWindows888(t) + root := dotTree(t) + for _, pol := range []DotfilesPolicy{DotfilesIgnore, DotfilesDeny, DotfilesAllow} { + mw := New(Config{Root: root, Dotfiles: pol, Browse: true, Index: "no-index.here"}) + o, _ := outcomeOf888(t, mw, "/") + for _, name := range []string{".env", ".git", ".ssh", ".htaccess", ".well-knownx", ".dot-link"} { + has := strings.Contains(o.body, ">"+name) + if has != (pol == DotfilesAllow) { + t.Errorf("policy %d: listing of / has %q = %v, want %v", pol, name, has, pol == DotfilesAllow) + } + } + for _, name := range []string{"a.txt", ".well-known/", "dir.with.dots/", "sub/"} { + if !strings.Contains(o.body, ">"+name) { + t.Errorf("policy %d: listing of / lacks %q", pol, name) + } + } + o, _ = outcomeOf888(t, mw, "/bare/") + if has := strings.Contains(o.body, ">.a"); has != (pol == DotfilesAllow) || !strings.Contains(o.body, ">b") { + t.Errorf("policy %d: listing of /bare/ = %q", pol, o.body) + } + for _, p := range []string{"/.git/", "/sub/.d/", "/.well-known/.sub/"} { + rec, err := get888(t, mw, p) + listed := rec.StatusCode == 200 + if listed != (pol == DotfilesAllow) { + t.Errorf("policy %d GET %s: status %d err %v, listed %v", pol, p, rec.StatusCode, err, listed) + } + } + // a visible name that leads to a hidden directory is not listed either + if rec, _ := get888(t, mw, "/link-dir/"); (rec.StatusCode == 200) != (pol == DotfilesAllow) { + t.Errorf("policy %d GET /link-dir/: status %d", pol, rec.StatusCode) + } + if rec, _ := get888(t, mw, "/.well-known/"); rec.StatusCode != 200 || !strings.Contains(string(rec.Body), ">acme.txt") || strings.Contains(string(rec.Body), ">.secret") != (pol == DotfilesAllow) { + t.Errorf("policy %d GET /.well-known/: status %d body %q", pol, rec.StatusCode, rec.Body) + } + } +} + +// TestDotfilesSPA888: under Ignore a hidden path is answered as a missing one +// is, which in SPA mode is the index file; Deny refuses it. +func TestDotfilesSPA888(t *testing.T) { + skipWindows888(t) + root := dotTree(t) + ignore := New(Config{Root: root, SPA: true}) + for _, p := range []string{"/.env", "/.git/config", "/sub/.hidden", "/link-env", "/missing"} { + if o, _ := outcomeOf888(t, ignore, p); o.status != 200 || o.body != tagIndex888 { + t.Errorf("SPA Ignore GET %s: %v, want the index (as for a missing path)", p, o) + } + } + deny := New(Config{Root: root, SPA: true, Dotfiles: DotfilesDeny}) + rec, err := get888(t, deny, "/.env") + if !(err != nil && err.(*celeris.HTTPError).Code == 403) || len(rec.Body) != 0 { + t.Errorf("SPA Deny GET /.env: status %d body %q err %v, want 403", rec.StatusCode, rec.Body, err) + } + allow := New(Config{Root: root, SPA: true, Dotfiles: DotfilesAllow}) + if o, _ := outcomeOf888(t, allow, "/.env"); o.status != 200 || o.body != "ENV" { + t.Errorf("SPA Allow GET /.env: %v, want ENV", o) + } +} + +// TestDotfilesSymlinks888: the rule looks at the request path and at where the +// path leads once its symlinks are resolved: a visible name that links to a +// dotfile, or to a hidden directory, of the tree is refused like the dotfile; a +// link to ".well-known" is served; a hidden name is hidden whatever it links to. +// Under Allow they are all served. +func TestDotfilesSymlinks888(t *testing.T) { + skipWindows888(t) + root := dotTree(t) + tag := weakETag(secretTime888, int64(len("ENV"))) + for _, pol := range []DotfilesPolicy{DotfilesIgnore, DotfilesDeny, DotfilesAllow} { + mw := New(Config{Root: root, Dotfiles: pol}) + for _, p := range []string{"/link-env", "/link-dir/config", "/.dot-link"} { + rec, err := get888(t, mw, p) + served := rec.StatusCode == 200 + if served != (pol == DotfilesAllow) { + t.Errorf("policy %d GET %s: status %d body %q err %v, served %v", pol, p, rec.StatusCode, rec.Body, err, served) + } + // a conditional request is no oracle either + rec, _ = get888(t, mw, p, "if-none-match", tag) + if (rec.StatusCode == 304 || rec.StatusCode == 200) != (pol == DotfilesAllow) { + t.Errorf("policy %d GET %s with If-None-Match: status %d", pol, p, rec.StatusCode) + } + if pol != DotfilesAllow && (rec.Header("etag") != "" || rec.Header("last-modified") != "") { + t.Errorf("policy %d GET %s with If-None-Match: validators on a refused request", pol, p) + } + } + if o, _ := outcomeOf888(t, mw, "/link-wk/acme.txt"); o.status != 200 || o.body != tagWK888 { + t.Errorf("policy %d GET /link-wk/acme.txt: %v, want 200", pol, o) + } + } +} + +// TestDotfilesFS888: the same rule for Config.FS. +func TestDotfilesFS888(t *testing.T) { + m := fstest.MapFS{} + for name, body := range dotFiles888 { + m[name] = &fstest.MapFile{Data: []byte(body), ModTime: secretTime888} + } + for _, pol := range []DotfilesPolicy{DotfilesIgnore, DotfilesDeny, DotfilesAllow} { + mw := New(Config{FS: m, Dotfiles: pol}) + absent := missing888(t, mw) + for _, tc := range dotCases888 { + if strings.Contains(tc.path, "link") || strings.Contains(tc.path, `\`) || strings.Contains(tc.path, "\x00") || strings.Contains(tc.path, "%00") || strings.HasPrefix(tc.path, "/.dot") { + continue // symlinks and the OS-only rows + } + weird := strings.Contains(tc.path, "..") || strings.Contains(tc.path, "/./") || strings.Contains(tc.path, "//") + t.Run(fmt.Sprintf("policy%d/%q", pol, tc.path), func(t *testing.T) { + rec, err := get888(t, mw, tc.path) + status := rec.StatusCode + if he, ok := err.(*celeris.HTTPError); ok { + status = he.Code + } + switch { + case pol == DotfilesAllow || !tc.hidden: + // fs.FS names hold no "..", "." or empty element: those rows only check the rule + if tc.body != "" && !weird && (status != 200 || string(rec.Body) != tc.body) { + t.Errorf("GET %q: status %d body %q, want 200 %q", tc.path, status, rec.Body, tc.body) + } + case pol == DotfilesIgnore: + if status != absent.status || len(rec.Body) != 0 || err != nil { + t.Errorf("GET %q hidden under Ignore: status %d body %q err %v, want passed on", tc.path, status, rec.Body, err) + } + default: + if status != 403 || len(rec.Body) != 0 { + t.Errorf("GET %q hidden under Deny: status %d body %q err %v, want 403", tc.path, status, rec.Body, err) + } + } + }) + } + // listing + bw := New(Config{FS: m, Dotfiles: pol, Browse: true, Index: "no-index.here"}) + o, _ := outcomeOf888(t, bw, "/") + if has := strings.Contains(o.body, ">.env"); has != (pol == DotfilesAllow) { + t.Errorf("FS policy %d: listing has .env = %v", pol, has) + } + if !strings.Contains(o.body, ">.well-known/") || !strings.Contains(o.body, ">a.txt") { + t.Errorf("FS policy %d: listing of / = %q", pol, o.body) + } + } +} + +// TestDotfilesOverTheWire888: the default rule on the real engines, for the +// middleware and for Server.Static and Group.Static, which serve a directory +// too: a hidden path is a 404 with nothing of the file in it; ".well-known" and +// the visible files are served. +func TestDotfilesOverTheWire888(t *testing.T) { + skipWindows888(t) + root := dotTree(t) + hidden := []string{"/.env", "/.git/config", "/sub/.hidden", "/.well-known/.secret", "/sub/../.env", "/%2eenv", "/%2E%2e/.env", + "/link-env", "/link-dir/config", "/.ssh/id", "/.htaccess", "//.env", "/./.env", `/\.env`} + visible := map[string]string{"/a.txt": "A", "/sub/b.txt": "B", "/.well-known/acme.txt": tagWK888, "/.well-known/child/y.txt": "WKCHILD", "/dir.with.dots/z.txt": "DOTS"} + type entry struct { + name, prefix string + reg func(s *celeris.Server) + } + for _, eng := range wireEngines888 { + for _, e := range []entry{ + {"middleware", "", func(s *celeris.Server) { s.Use(New(Config{Root: root})) }}, + {"Server.Static", "/files", func(s *celeris.Server) { s.Static("/files", root) }}, + {"Group.Static", "/g/files", func(s *celeris.Server) { s.Group("/g").Static("/files", root) }}, + } { + t.Run(fmt.Sprintf("%v/%s", eng, e.name), func(t *testing.T) { + raw := wire888(t, eng, e.reg) + for _, p := range hidden { + status, body := raw(e.prefix + p) + if status != 404 { + t.Errorf("GET %s%s: status %d body %q, want 404", e.prefix, p, status, body) + } + for _, secret := range []string{"ENV", "GITCFG", "SSH", "HTA", "SUBHID", "WKSECRET"} { + if strings.Contains(body, secret) { + t.Errorf("GET %s%s leaked %q", e.prefix, p, secret) + } + } + } + for p, want := range visible { + if status, body := raw(e.prefix + p); status != 200 || body != want { + t.Errorf("GET %s%s: status %d body %q, want 200 %q", e.prefix, p, status, body, want) + } + } + }) + } + t.Run(fmt.Sprintf("%v/middleware-deny-and-allow", eng), func(t *testing.T) { + deny := wire888(t, eng, func(s *celeris.Server) { s.Use(New(Config{Root: root, Dotfiles: DotfilesDeny})) }) + if status, body := deny("/.env"); status != 403 || strings.Contains(body, "ENV") { + t.Errorf("Deny GET /.env: status %d body %q, want 403", status, body) + } + if status, body := deny("/.well-known/acme.txt"); status != 200 || body != tagWK888 { + t.Errorf("Deny GET /.well-known/acme.txt: status %d body %q", status, body) + } + allow := wire888(t, eng, func(s *celeris.Server) { s.Use(New(Config{Root: root, Dotfiles: DotfilesAllow})) }) + if status, body := allow("/.env"); status != 200 || body != "ENV" { + t.Errorf("Allow GET /.env: status %d body %q, want 200 ENV", status, body) + } + }) + } +} + +// TestFileFromDirServesDotfiles888 pins what this change leaves alone: +// Context.FileFromDir is an explicit API whose caller chooses the path, and +// serves a dotfile as before. +func TestFileFromDirServesDotfiles888(t *testing.T) { + skipWindows888(t) + root := dotTree(t) + c, rec := celeristest.NewContext("GET", "/f") + defer celeristest.ReleaseContext(c) + if err := c.FileFromDir(root, ".env"); err != nil || rec.StatusCode != 200 || string(rec.Body) != "ENV" { + t.Errorf("FileFromDir(.env): status %d body %q err %v, want 200 ENV", rec.StatusCode, rec.Body, err) + } +} From 26c75148f89b42ab877d42d4dba068b843902488 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 10:33:42 +0100 Subject: [PATCH 12/26] feat(static, celeris): hidden files and directories are not served by default; Config.Dotfiles (DotfilesIgnore, DotfilesAllow, DotfilesDeny) (v1.6.0, Refs #888) A request whose path below the root has a segment that starts with a dot (.env, .git, .ssh, .htaccess) is answered as if the file did not exist (the next handler; the index in SPA mode), whether or not it exists: DotfilesIgnore, the default. DotfilesDeny answers 403; DotfilesAllow serves them as before. The exact segment .well-known is served (RFC 8615); its children are unless their own names start with a dot. The segments are those of the cleaned request path (a/../.env is .env) with one level of percent-decoding; the name of the root never counts. The rule follows symlinks: a visible name that leads to a hidden file or directory of the tree is refused (also for a 304), and a listing (Browse) leaves hidden entries out. The Index file is the operator's choice and is exempt. It applies to Config.Root, Config.FS, Server.Static and Group.Static; Context.FileFromDir, whose caller chooses the path, is unchanged. Exported API added: DotfilesPolicy, DotfilesIgnore, DotfilesAllow, DotfilesDeny, Config.Dotfiles. Release note: static: files and directories whose name starts with a dot are not served by default (404); set Dotfiles: Allow to restore the old behaviour; .well-known is served by default; this also makes the new Root '.', './' and '/' support safe for an upgrading project. --- context_response.go | 38 ++++++++-- file_validators.go | 4 +- group.go | 13 ++-- internal/ctxkit/ctxkit.go | 6 +- internal/ctxkit/hidden.go | 6 +- internal/ctxkit/hidden_test.go | 103 +++++++++++++++++++++++++++ middleware/static/config.go | 25 +++++-- middleware/static/doc.go | 47 ++++++++++--- middleware/static/example_test.go | 16 +++++ middleware/static/static.go | 113 +++++++++++++++++++++++++----- server.go | 13 ++-- 11 files changed, 330 insertions(+), 54 deletions(-) create mode 100644 internal/ctxkit/hidden_test.go diff --git a/context_response.go b/context_response.go index 17fa81976..03f98924c 100644 --- a/context_response.go +++ b/context_response.go @@ -1078,14 +1078,33 @@ func (c *Context) trySendFile(f *os.File, status int, contentType string, offset // baseDir is read against the working directory at each call; one made of ".." // segments ("..", "../..") means the directory it resolves to, and a userPath // that climbs out of that directory is refused (before v1.6.0 it was not: a -// baseDir of ".." served any file the process could read). Dotfiles are served: -// there is no rule against them. +// baseDir of ".." served any file the process could read). Hidden files +// (names that start with a dot) are served: the caller chooses the path, and +// the dotfile rule of middleware/static, Server.Static and Group.Static does not +// apply here. func (c *Context) FileFromDir(baseDir, userPath string) error { - return c.fileFromDir(baseDir, userPath, "", nil) + return c.fileFromDir(baseDir, userPath, "", nil, nil) +} + +// serveStaticFile is what Server.Static and Group.Static serve with: FileFromDir +// for a directory server, which does not serve hidden files and directories +// (names that start with a dot, except the exact ".well-known"; see +// middleware/static, Config.Dotfiles): a path with such a segment, or that +// leads to one through a symlink, is a 404 whether or not the file exists. +// FileFromDir itself, whose caller chooses the path, is unchanged. +func (c *Context) serveStaticFile(root, tail string) error { + if ctxkit.HiddenPath(tail) { + return NewHTTPError(404, "Not Found") + } + err := c.fileFromDir(root, tail, "", nil, ctxkit.HiddenPath) + if errors.Is(err, ctxkit.ErrHidden) { + return NewHTTPError(404, "Not Found") + } + return err } // fileFromDir is FileFromDir with serveFile's contentType and onOpen. -func (c *Context) fileFromDir(baseDir, userPath, contentType string, onOpen func(modTime time.Time, size int64)) error { +func (c *Context) fileFromDir(baseDir, userPath, contentType string, onOpen func(modTime time.Time, size int64), hidden func(rel string) bool) error { if baseDir == "" { // filepath.Clean("") is ".": an unset root (Server.Static("/f", // os.Getenv("DIR"))) would serve the working directory. It was refused @@ -1109,6 +1128,17 @@ func (c *Context) fileFromDir(baseDir, userPath, contentType string, onOpen func if !ctxkit.ResolvedWithin(resolvedBase, resolved) { return NewHTTPError(400, "invalid file path") } + if hidden != nil { + // Where the path leads, not only what it says: a visible name that is a + // symlink to a dotfile of the tree is refused like the dotfile. + rel, ok := ctxkit.RelWithin(resolvedBase, resolved) + if !ok { + return NewHTTPError(400, "invalid file path") + } + if hidden(rel) { + return ctxkit.ErrHidden + } + } info, err := os.Stat(resolved) if err != nil { return err diff --git a/file_validators.go b/file_validators.go index 4067b031b..6d629b373 100644 --- a/file_validators.go +++ b/file_validators.go @@ -10,7 +10,7 @@ func init() { // middleware/static serves a file with validators taken from the open // descriptor (celeris#846); see ctxkit.FileFromDir. ctxkit.FileFromDir = func(c any, baseDir, userPath, contentType string, - onOpen func(modTime time.Time, size int64)) error { - return c.(*Context).fileFromDir(baseDir, userPath, contentType, onOpen) + onOpen func(modTime time.Time, size int64), hidden func(rel string) bool) error { + return c.(*Context).fileFromDir(baseDir, userPath, contentType, onOpen, hidden) } } diff --git a/group.go b/group.go index e06c0d3fe..67df49026 100644 --- a/group.go +++ b/group.go @@ -125,14 +125,17 @@ func (g *RouteGroup) Handle(method, path string, handlers ...HandlerFunc) *Route // Static registers a GET handler that serves files from root under the given // prefix. Uses FileFromDir for path traversal protection. root may be ".", // "./" or "/" (from v1.6.0; before, every file below them was refused): "." is -// the working directory, dotfiles and .git included, and "/" the whole -// filesystem, so name a directory that holds only what is public. A root made -// of ".." segments means the directory it resolves to, and nothing outside it -// is served. An empty root serves nothing. +// the working directory and "/" the whole filesystem, so name a directory that +// holds only what is public. A root made of ".." segments means the directory +// it resolves to, and nothing outside it is served. An empty root serves +// nothing. Files and directories whose name starts with a dot (.env, .git) are +// not served, and a path to one, or to a symlink that leads to one, is a 404 +// whether or not it exists; the exact segment ".well-known" is served. There is +// no opt-out here: use middleware/static with Dotfiles: static.DotfilesAllow. func (g *RouteGroup) Static(prefix, root string) *Route { p := strings.TrimRight(prefix, "/") + "/*filepath" return g.GET(p, func(c *Context) error { - return c.FileFromDir(root, c.Param("filepath")) + return c.serveStaticFile(root, c.Param("filepath")) }) } diff --git a/internal/ctxkit/ctxkit.go b/internal/ctxkit/ctxkit.go index a0e04dc46..4aad03576 100644 --- a/internal/ctxkit/ctxkit.go +++ b/internal/ctxkit/ctxkit.go @@ -21,8 +21,12 @@ var ReleaseContext func(c any) // (not for a 413). contentType, when not empty, replaces // the type taken from the extension of the file served (a pre-compressed ".gz" // variant is served with the original's type). +// +// hidden, when not nil, is called with the slash path of the file below the +// base once symlinks are resolved, before the file is opened; when it reports +// true the call returns ErrHidden and writes nothing. var FileFromDir func(c any, baseDir, userPath, contentType string, - onOpen func(modTime time.Time, size int64)) error + onOpen func(modTime time.Time, size int64), hidden func(rel string) bool) error // workerIDKey is the private context key carrying the event-loop worker ID // that accepted / is servicing the current connection. Engines set it at diff --git a/internal/ctxkit/hidden.go b/internal/ctxkit/hidden.go index 743e9f105..9c78f619c 100644 --- a/internal/ctxkit/hidden.go +++ b/internal/ctxkit/hidden.go @@ -19,9 +19,9 @@ const wellKnown = ".well-known" // HiddenSegment reports whether one path segment names a hidden file or // directory: it starts with a dot (".env", ".git", ".ssh", ".htaccess", -// ".."), except the exact name ".well-known". The segment "." and the empty -// segment are not names and are not hidden (HiddenPath drops them first). A -// segment that percent-decodes to such a name is hidden too, so a layer that +// ".", ".."), except the exact name ".well-known". A literal "." segment is +// not a name and never reaches it (HiddenPath drops it first); "%2e" does, and +// is hidden. A segment that percent-decodes to such a name is hidden too, so a layer that // decodes the path later cannot turn a visible segment into a dotfile. func HiddenSegment(seg string) bool { if hiddenName(seg) { diff --git a/internal/ctxkit/hidden_test.go b/internal/ctxkit/hidden_test.go new file mode 100644 index 000000000..6a25b3d5f --- /dev/null +++ b/internal/ctxkit/hidden_test.go @@ -0,0 +1,103 @@ +package ctxkit + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestHiddenSegment(t *testing.T) { + for _, tc := range []struct { + seg string + want bool + }{ + {".env", true}, {".git", true}, {".ssh", true}, {".htaccess", true}, {"..", true}, {"...", true}, {".", true}, + {"", false}, {"a", false}, {"a.b", false}, {"env.", false}, {"a.env", false}, + {".well-known", false}, {".well-knownx", true}, {".well-known-x", true}, {".well-known.", true}, + {".Well-Known", true}, {".WELL-KNOWN", true}, {"well-known", false}, + {"%2eenv", true}, {"%2Eenv", true}, {"%2e%2e", true}, {"%2e", true}, {"%252eenv", false}, {"a%2eb", false}, + {"%2ewell-known", false}, {"%zz", false}, {"%", false}, {"%2", false}, + } { + if got := HiddenSegment(tc.seg); got != tc.want { + t.Errorf("HiddenSegment(%q) = %v, want %v", tc.seg, got, tc.want) + } + } +} + +func TestHiddenPath(t *testing.T) { + for _, tc := range []struct { + p string + want bool + }{ + {"", false}, {"/", false}, {"a", false}, {"/a/b/c.txt", false}, {"a.b/c.d", false}, + {".env", true}, {"/.env", true}, {"a/.env", true}, {"a/b/.c/d", true}, {"a/.b/c", true}, {"a/b/.c", true}, + {"/.git/config", true}, {".ssh/id_rsa", true}, + // ".well-known", exactly, and its children that are not hidden themselves + {".well-known", false}, {"/.well-known/acme", false}, {".well-known/a/b", false}, + {".well-known/.x", true}, {".well-known/a/.x/b", true}, {".well-known-x/a", true}, {".well-knownx", true}, + {"a/.well-known/b", false}, {"a/.well-known/.b", true}, + // cleaned first: "." and empty segments drop, ".." removes the segment before it + {"./.env", true}, {"//.env", true}, {"a//.env", true}, {"a/./.env", true}, {"a/../.env", true}, {"a/../b", false}, + {".git/../a", false}, {".well-known/../.env", true}, {"a/.b/..", false}, {"a/.b/../c", false}, {"../a", false}, {"/../.env", true}, + {"a/b/../../.env", true}, {"a/.env/../b", false}, + // backslashes separate + {`\.env`, true}, {`a\.env`, true}, {`a\b`, false}, {`.well-known\.x`, true}, + // encoded: one decoding + {"%2eenv", true}, {"a/%2Eenv", true}, {"%2e%2e/a", true}, {"a/%2e%2e/b", true}, {"%252eenv", false}, + {".env\x00", true}, {"a\x00/.b", true}, + // the name of a root is not part of the path + {"sub/file", false}, + } { + if got := HiddenPath(tc.p); got != tc.want { + t.Errorf("HiddenPath(%q) = %v, want %v", tc.p, got, tc.want) + } + } +} + +func TestHiddenPathAllocs(t *testing.T) { + if testing.Short() { + t.Skip() + } + if n := testing.AllocsPerRun(100, func() { _ = HiddenPath("assets/css/site.min.css") }); n != 0 { + t.Errorf("HiddenPath of a plain path allocates %v times, want 0", n) + } +} + +func TestRelWithin(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("slash paths") + } + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + wd := filepath.Join(dir, "a", "b") + if err := os.MkdirAll(wd, 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(wd) + for _, tc := range []struct { + base, p, want string + ok bool + }{ + {"/srv/www", "/srv/www", "", true}, + {"/srv/www", "/srv/www/a/.env", "a/.env", true}, + {"/", "/etc/passwd", "etc/passwd", true}, + {".", "x/.env", "x/.env", true}, + {".", ".", "", true}, + {"public", "public/x/y", "x/y", true}, + {"public", filepath.Join(wd, "public", "x"), "x", true}, // relative base, absolute path + {".", filepath.Join(wd, "x", "y"), "x/y", true}, + {".", "../b/x", "x", true}, // climbs through the working directory's own name + {"..", "../b/x", "b/x", true}, + {"..", filepath.Join(dir, "a", "q"), "q", true}, + {"/srv/www", "/srv/other", "", false}, + {".", "../other", "", false}, + } { + got, ok := RelWithin(tc.base, tc.p) + if ok != tc.ok || got != tc.want { + t.Errorf("RelWithin(%q, %q) = %q, %v; want %q, %v", tc.base, tc.p, got, ok, tc.want, tc.ok) + } + } +} diff --git a/middleware/static/config.go b/middleware/static/config.go index 79bf8fc3b..0d3ab688b 100644 --- a/middleware/static/config.go +++ b/middleware/static/config.go @@ -42,9 +42,9 @@ type Config struct { // files. Mutually exclusive with FS. If neither Root nor FS is set, // the middleware panics. // - // Everything below Root is served, dotfiles and dot directories included - // (.env, .git/config): the package has no rule against them today (a - // default deny for them is being decided). A path that leaves Root, by + // Everything below Root is served, except hidden files and directories + // (names that start with a dot, see [Config].Dotfiles: .env and .git are not + // served by default). A path that leaves Root, by // "..", an absolute path, or a symlink that resolves outside it, is // refused. A relative Root is read against the working directory at each // request; a Root made of ".." segments ("..", "../..") means the directory @@ -52,11 +52,13 @@ type Config struct { // // Root may be ".", "./" or "/", and serves the tree below it as any other // directory does. Mind what that is: "." is the process's working - // directory, with its sources and configuration (.env, .git), and "/" is + // directory, with its sources and configuration, and "/" is // the whole filesystem (/etc/passwd, the home directories, // /proc/self/environ, the process's environment: whatever the process may // read), where nothing is outside Root any more, so the containment check - // that protects the others protects nothing. When the working directory + // that protects the others protects nothing; only the dotfile rule still + // applies to the request path (/home/u/.ssh is not served), and + // /etc/passwd and /proc/self/environ are not dotfiles. When the working directory // is "/" (the default of a systemd service and of a container without a // WORKDIR), "." is the whole filesystem too. Before v1.6.0 these three // served nothing below the root, so a configuration that was inert since @@ -112,7 +114,18 @@ type Config struct { Compress bool // Dotfiles is the policy for hidden files and directories: names that - // start with a dot. The zero value is [DotfilesIgnore]. + // start with a dot (.env, .git, .ssh, .htaccess). A request whose path + // below Root (or FS) has such a segment, after cleaning ("a/../.env" is + // ".env") and one level of percent-decoding, is answered as the policy says: + // [DotfilesIgnore] as if the file did not exist, whether or not it does, + // [DotfilesDeny] with 403, [DotfilesAllow] by serving it. The exact segment + // ".well-known" is not hidden (RFC 8615), but its children are if their own + // names start with a dot. Only the segments of the request count: the name + // of Root, or of the directory Root "." is in, does not. A visible name that + // is a symlink to a hidden file or directory of the tree is refused like the + // hidden name. Browse leaves hidden entries out of a listing. The Index file + // is the operator's choice and is served even if its name starts with a dot. + // The zero value is [DotfilesIgnore]. Dotfiles DotfilesPolicy } diff --git a/middleware/static/doc.go b/middleware/static/doc.go index a8406de33..ec525beae 100644 --- a/middleware/static/doc.go +++ b/middleware/static/doc.go @@ -10,6 +10,8 @@ // - [Config].SPA — single-page app mode: unknown paths serve the index file. // - [Config].MaxAge — Cache-Control max-age duration (zero = no header). // - [Config].Compress — serve pre-compressed .br/.gz variants when accepted. +// - [Config].Dotfiles — hidden files and directories (names that start with +// a dot) are not served by default. // - [Config].Skip / [Config].SkipPaths — skip the middleware dynamically or // by exact path match. // @@ -34,15 +36,42 @@ // checks agree, for a Root of ".", "./" and "/" as for any other directory: // these three serve the tree below them (before v1.6.0 they served nothing below // the root, and the root's own index answered 400 or 304 depending on whether a -// validator matched). Their meaning is wide: "." is the working directory, -// dotfiles (.env, .git) included, and "/" the whole filesystem; when the -// working directory is "/" (a systemd service, a container without a WORKDIR), -// "." is the whole filesystem too, /proc/self/environ among its files. A -// configuration that was inert since v1.3.3 starts to serve files after the -// upgrade. The package has no dotfile rule today (a default deny is being -// decided). A Root made of ".." segments means the directory it resolves to, -// and nothing outside it is served. Name the directory that holds what is -// public; see [Config].Root. +// validator matched). Their meaning is wide: "." is the working directory and +// "/" the whole filesystem; when the working directory is "/" (a systemd +// service, a container without a WORKDIR), "." is the whole filesystem too, +// /proc/self/environ among its files. A configuration that was inert since +// v1.3.3 starts to serve files after the upgrade, which the dotfile rule below +// makes safer (.env and .git are not served by default). A Root made of ".." +// segments means the directory it resolves to, and nothing outside it is +// served. Name the directory that holds what is public; see [Config].Root. +// +// # Dotfiles +// +// A file or directory whose name starts with a dot (.env, .git, .ssh, +// .htaccess) is not served by default: a request whose path below the root +// has such a segment is answered as if the file did not exist ([DotfilesIgnore]: +// the next handler, which is the router's 404 when nothing else answers; the +// index file in SPA mode), whether or not it exists, so a client learns +// nothing about it. Every segment of the request path counts, after the path +// is cleaned ("a/../.env" is ".env") and with one level of percent-decoding; +// the name of the root does not: a Root that is ".config", or "." in such a +// directory, serves its files. The rule also follows a symlink: a visible name +// that leads to a hidden file or directory of the tree is refused like it. The +// exact segment ".well-known" is served (RFC 8615: ACME challenges, +// security.txt, apple-app-site-association), but its children are served only +// if they are not hidden themselves. A directory listing (Browse) leaves the +// hidden entries out. The Index file is the operator's choice and is served +// even if its name starts with a dot. [DotfilesDeny] answers 403 instead; +// [DotfilesAllow] serves hidden files like any other, as before v1.6.0: +// +// // A project that serves a hidden file on purpose names its directory and +// // allows dotfiles there, and keeps its secrets out of that directory. +// static.New(static.Config{Root: "./public", Dotfiles: static.DotfilesAllow}) +// +// The rule is the middleware's, and that of Server.Static and Group.Static, +// which serve a directory too (without a way to opt out: use the middleware for +// that). [celeris.Context.FileFromDir], whose caller chooses the path, does not +// apply it. // // With compress in front of an [Config].FS, the strong tag of a file that compress // encodes is made weak on the 200 (W/"hash"), and static marks the response so diff --git a/middleware/static/example_test.go b/middleware/static/example_test.go index 947d77d2d..068e5cb54 100644 --- a/middleware/static/example_test.go +++ b/middleware/static/example_test.go @@ -17,6 +17,22 @@ func ExampleNew() { }) } +func ExampleNew_dotfiles() { + // Hidden files and directories (.env, .git, .htaccess) are not served by + // default: the request goes on as if the file did not exist, and the + // exact segment ".well-known" is served. Refuse them with a 403 instead: + _ = static.New(static.Config{ + Root: "./public", + Dotfiles: static.DotfilesDeny, + }) + // A project that serves a hidden file on purpose allows them for a + // directory that holds nothing secret. + _ = static.New(static.Config{ + Root: "./public", + Dotfiles: static.DotfilesAllow, + }) +} + func ExampleNew_browse() { // Enable directory listing. _ = static.New(static.Config{ diff --git a/middleware/static/static.go b/middleware/static/static.go index 221a325a3..e48c5f1a3 100644 --- a/middleware/static/static.go +++ b/middleware/static/static.go @@ -3,6 +3,7 @@ package static import ( "crypto/sha256" "encoding/hex" + "errors" "fmt" "html" "io" @@ -111,6 +112,24 @@ func New(config ...Config) celeris.HandlerFunc { root := cfg.Root fsys := cfg.FS compress := cfg.Compress + dotfiles := cfg.Dotfiles + + // hidden is nil when hidden files are served (DotfilesAllow). Otherwise it + // tells whether a slash path below the root, resolved, has a hidden + // segment; the Index file, which the operator chose, is not counted. + var hidden func(rel string) bool + if dotfiles != DotfilesAllow { + idx := strings.Trim(filepath.ToSlash(filepath.Clean(index)), "/") + hidden = func(rel string) bool { + for _, v := range [...]string{"", ".br", ".gz"} { + if t := idx + v; rel == t || strings.HasSuffix(rel, "/"+t) { + rel = strings.TrimSuffix(strings.TrimSuffix(rel, t), "/") + break + } + } + return ctxkit.HiddenPath(rel) + } + } // Precompute cleanRoot once instead of per request. var cleanRoot string @@ -161,17 +180,41 @@ func New(config ...Config) celeris.HandlerFunc { filePath := strings.TrimPrefix(reqPath, "/") filePath = strings.TrimSuffix(filePath, "/") + // notFound answers a hidden path as a path with no file is answered: + // the index in SPA mode, the next handler otherwise (DotfilesIgnore), or + // refuses it (DotfilesDeny). A hidden path is decided before the file + // system is looked at, so what is answered does not tell whether it exists. + notFound := func() error { + if dotfiles == DotfilesDeny { + return celeris.NewHTTPError(403, "Forbidden") + } + switch { + case spa && fsys != nil: + return serveFS(c, fsys, index, index, browse, false, compress, cacheControl, &fsCache, true) + case spa: + return c.FileFromDir(cleanRoot, index) + } + return c.Next() + } + if hidden != nil && ctxkit.HiddenPath(filePath) { + return notFound() + } + if fsys != nil { - return serveFS(c, fsys, filePath, index, browse, spa, compress, cacheControl, &fsCache) + return serveFS(c, fsys, filePath, index, browse, spa, compress, cacheControl, &fsCache, hidden != nil) + } + err := serveOS(c, cleanRoot, filePath, index, browse, spa, cacheControl, compress, hidden) + if errors.Is(err, ctxkit.ErrHidden) { + return notFound() } - return serveOS(c, cleanRoot, filePath, index, browse, spa, cacheControl, compress) + return err } } // serveOS serves a file from the OS filesystem using c.FileFromDir (which // handles directory traversal, symlink escape, and range requests). // cleanRoot must be filepath.Clean(root) — precomputed by New. -func serveOS(c *celeris.Context, cleanRoot, filePath, index string, browse, spa bool, cacheControl string, compress bool) error { +func serveOS(c *celeris.Context, cleanRoot, filePath, index string, browse, spa bool, cacheControl string, compress bool, hidden func(rel string) bool) error { fullPath := filepath.Join(cleanRoot, filepath.FromSlash(filePath)) // Prevent directory traversal: filepath.Join resolves ".." segments, @@ -216,7 +259,13 @@ func serveOS(c *celeris.Context, cleanRoot, filePath, index string, browse, spa if !ctxkit.ResolvedWithin(resolvedRoot, resolved) { return c.Next() } - return serveDirListingOS(c, resolved) + if hidden != nil { + // a visible name that leads to a hidden directory is not listed + if rel, ok := ctxkit.RelWithin(resolvedRoot, resolved); !ok || hidden(rel) { + return ctxkit.ErrHidden + } + } + return serveDirListingOS(c, resolved, hidden != nil) } else { return c.Next() } @@ -231,12 +280,12 @@ func serveOS(c *celeris.Context, cleanRoot, filePath, index string, browse, spa // variant when one is served; this stat only decided that there is a file, // and answers a 304. if compress { - if served, err := servePreCompressed(c, cleanRoot, filePath, fullPath, cacheControl); served { + if served, err := servePreCompressed(c, cleanRoot, filePath, fullPath, cacheControl, hidden); served { return err } } - return serveOSFile(c, cleanRoot, filePath, "", "", cacheControl, info) + return serveOSFile(c, cleanRoot, filePath, "", "", cacheControl, info, hidden) } // serveOSHook is a test seam (celeris#846 item 4): when set it runs in serveOS @@ -265,7 +314,7 @@ var serveOSHook func() // variant is served with its original's, not the one its ".gz" maps to). // encoding, when not empty, is set as Content-Encoding on the response that // carries the file, not on a 304; File drops it again from a 416. -func serveOSFile(c *celeris.Context, cleanRoot, filePath, contentType, encoding, cacheControl string, early os.FileInfo) error { +func serveOSFile(c *celeris.Context, cleanRoot, filePath, contentType, encoding, cacheControl string, early os.FileInfo, hidden func(rel string) bool) error { if c.Header("if-none-match") != "" || c.Header("if-modified-since") != "" { // Nothing goes on the response until the 304 is decided: early is a // stat, which follows a symlink out of the root before FileFromDir @@ -281,7 +330,7 @@ func serveOSFile(c *celeris.Context, cleanRoot, filePath, contentType, encoding, // FileFromDir, which refuses it (celeris#888). The check comes first // and runs for every conditional request, so what it costs does not // depend on whether the client's validator matches the target. - if resolvesInside(cleanRoot, filePath) && notModified(c, etag, early.ModTime()) { + if resolvesInside(cleanRoot, filePath, hidden) && notModified(c, etag, early.ModTime()) { if etag != "" { setValidators(c, early.ModTime(), etag, cacheControl) } @@ -293,7 +342,7 @@ func serveOSFile(c *celeris.Context, cleanRoot, filePath, contentType, encoding, if encoding != "" { c.SetHeader("content-encoding", encoding) } - }) + }, hidden) } // resolvesInside reports whether filePath, a slash path below cleanRoot, still @@ -309,7 +358,7 @@ func serveOSFile(c *celeris.Context, cleanRoot, filePath, contentType, encoding, // lexically outside the root (Config.Index is joined to the requested // directory unchecked) is refused without a syscall. The answer can change // before the 304 is sent, as it can between FileFromDir's check and its open. -func resolvesInside(cleanRoot, filePath string) bool { +func resolvesInside(cleanRoot, filePath string, hidden func(rel string) bool) bool { if resolvesInsideHook != nil { resolvesInsideHook() } @@ -349,7 +398,15 @@ func resolvesInside(cleanRoot, filePath string) bool { if err != nil { return false } - return ctxkit.ResolvedWithin(resolvedRoot, resolved) + if !ctxkit.ResolvedWithin(resolvedRoot, resolved) { + return false + } + if hidden != nil { + // a symlink that leads to a hidden file is no 304 oracle either + rel, ok := ctxkit.RelWithin(resolvedRoot, resolved) + return ok && !hidden(rel) + } + return true } // resolvesInsideHook is a test seam (celeris#888 item 35): when set it runs at @@ -374,7 +431,7 @@ func acceptsCoding(c *celeris.Context, enc string) bool { // 304, If-Range and Content-Range are its own (celeris#846), and it is served // with the original's content type. Returns (true, err) if a pre-compressed // file was served, (false, nil) to fall through to normal serving. -func servePreCompressed(c *celeris.Context, cleanRoot, filePath, fullPath, cacheControl string) (bool, error) { +func servePreCompressed(c *celeris.Context, cleanRoot, filePath, fullPath, cacheControl string, hidden func(rel string) bool) (bool, error) { if c.Header("accept-encoding") == "" { return false, nil } @@ -395,14 +452,14 @@ func servePreCompressed(c *celeris.Context, cleanRoot, filePath, fullPath, cache if ct == "" { ct = "application/octet-stream" } - return true, serveOSFile(c, cleanRoot, filePath+v.suffix, ct, v.encoding, cacheControl, vi) + return true, serveOSFile(c, cleanRoot, filePath+v.suffix, ct, v.encoding, cacheControl, vi, hidden) } return false, nil } // serveFS serves a file from an fs.FS with ETag/Last-Modified and range support. -func serveFS(c *celeris.Context, fsys fs.FS, filePath, index string, browse, spa, compress bool, cacheControl string, cache *sync.Map) error { +func serveFS(c *celeris.Context, fsys fs.FS, filePath, index string, browse, spa, compress bool, cacheControl string, cache *sync.Map, hide bool) error { // 1. Open + stat + directory handling. openPath := filePath if openPath == "" { @@ -412,7 +469,7 @@ func serveFS(c *celeris.Context, fsys fs.FS, filePath, index string, browse, spa f, err := fsys.Open(openPath) if err != nil { if spa { - return serveFS(c, fsys, index, index, browse, false, compress, cacheControl, cache) + return serveFS(c, fsys, index, index, browse, false, compress, cacheControl, cache, hide) } return c.Next() } @@ -436,11 +493,11 @@ func serveFS(c *celeris.Context, fsys fs.FS, filePath, index string, browse, spa indexStat, statErr := indexFile.Stat() _ = indexFile.Close() if statErr == nil && !indexStat.IsDir() { - return serveFS(c, fsys, indexPath, index, browse, spa, compress, cacheControl, cache) + return serveFS(c, fsys, indexPath, index, browse, spa, compress, cacheControl, cache, hide) } } if browse { - return serveDirListingFS(c, fsys, openPath) + return serveDirListingFS(c, fsys, openPath, hide) } return c.Next() } @@ -779,23 +836,41 @@ func etagMatch(inm, etag string) bool { } // serveDirListingOS renders a directory listing for an OS path. -func serveDirListingOS(c *celeris.Context, dirPath string) error { +func serveDirListingOS(c *celeris.Context, dirPath string, hide bool) error { entries, err := os.ReadDir(dirPath) if err != nil { return err } + if hide { + entries = withoutHidden(entries) + } return c.HTML(200, renderListing(c.Path(), entries)) } // serveDirListingFS renders a directory listing for an fs.FS path. -func serveDirListingFS(c *celeris.Context, fsys fs.FS, dirPath string) error { +func serveDirListingFS(c *celeris.Context, fsys fs.FS, dirPath string, hide bool) error { entries, err := fs.ReadDir(fsys, dirPath) if err != nil { return err } + if hide { + entries = withoutHidden(entries) + } return c.HTML(200, renderListing(c.Path(), entries)) } +// withoutHidden drops the entries whose name is hidden (starts with a dot, +// except ".well-known"). It filters in place: the slice is the caller's own. +func withoutHidden(entries []fs.DirEntry) []fs.DirEntry { + kept := entries[:0] + for _, e := range entries { + if !ctxkit.HiddenSegment(e.Name()) { + kept = append(kept, e) + } + } + return kept +} + // renderListing generates an HTML directory listing. Filenames are // URL-encoded in hrefs to prevent protocol-scheme injection (e.g. // javascript: filenames) and HTML-escaped in display text. diff --git a/server.go b/server.go index b06980b48..73a0c6fed 100644 --- a/server.go +++ b/server.go @@ -336,16 +336,19 @@ func (s *Server) OnShutdown(fn func(ctx context.Context)) *Server { // Static registers a GET handler that serves files from root under the given // prefix. Uses FileFromDir for path traversal protection. root may be ".", // "./" or "/" (from v1.6.0; before, every file below them was refused): "." is -// the working directory, dotfiles and .git included, and "/" the whole -// filesystem, so name a directory that holds only what is public. A root made -// of ".." segments means the directory it resolves to, and nothing outside it -// is served. An empty root serves nothing. +// the working directory and "/" the whole filesystem, so name a directory that +// holds only what is public. A root made of ".." segments means the directory +// it resolves to, and nothing outside it is served. An empty root serves +// nothing. Files and directories whose name starts with a dot (.env, .git) are +// not served, and a path to one, or to a symlink that leads to one, is a 404 +// whether or not it exists; the exact segment ".well-known" is served. There is +// no opt-out here: use middleware/static with Dotfiles: static.DotfilesAllow. // // s.Static("/assets", "./public") func (s *Server) Static(prefix, root string) *Route { p := strings.TrimRight(prefix, "/") + "/*filepath" return s.GET(p, func(c *Context) error { - return c.FileFromDir(root, c.Param("filepath")) + return c.serveStaticFile(root, c.Param("filepath")) }) } From 3ebe7a22fd71019c0a4180a91ac308465027fcf2 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 10:35:35 +0100 Subject: [PATCH 13/26] tests(static): the earlier root tests expect the dotfile rule: dotfile rows run with Dotfiles: Allow and are passed on by default; the matrix tree has no dotfile (Refs #888) --- middleware/static/root_dotdot_888_test.go | 4 +- middleware/static/root_spellings_888_test.go | 50 +++++++++++++------- 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/middleware/static/root_dotdot_888_test.go b/middleware/static/root_dotdot_888_test.go index d830210ef..7cf51cbdc 100644 --- a/middleware/static/root_dotdot_888_test.go +++ b/middleware/static/root_dotdot_888_test.go @@ -29,7 +29,7 @@ import ( // The tree (T is a temp dir; the working directory is T/p/c): // // T/../ABOVE.txt T/TOPSECRET.txt T/p/PSECRET.txt T/p/other/o.txt -// T/p/c/{index.html,a.txt,.env} T/p/c/sub/{index.html,b.txt} T/p/c/x/ +// T/p/c/{index.html,a.txt,envfile} T/p/c/sub/{index.html,b.txt} T/p/c/x/ // // Every file has its own marker as content. For each root spelling the // directory R it resolves to is computed independently of the code under test @@ -58,7 +58,7 @@ func newDotTree888(t *testing.T) dotTree888 { d := dotTree888{top: top, cwd: filepath.Join(top, "p", "c"), files: map[string]string{}} for _, f := range []string{ "../ABOVE.txt", "TOPSECRET.txt", "p/PSECRET.txt", "p/other/o.txt", - "p/c/index.html", "p/c/a.txt", "p/c/.env", "p/c/sub/index.html", "p/c/sub/b.txt", + "p/c/index.html", "p/c/a.txt", "p/c/envfile", "p/c/sub/index.html", "p/c/sub/b.txt", } { abs := filepath.Join(top, filepath.FromSlash(f)) if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { diff --git a/middleware/static/root_spellings_888_test.go b/middleware/static/root_spellings_888_test.go index 85ce1fb39..c47c010fa 100644 --- a/middleware/static/root_spellings_888_test.go +++ b/middleware/static/root_spellings_888_test.go @@ -134,43 +134,57 @@ func outcomeOf888(t *testing.T, mw celeris.HandlerFunc, path string, hdr ...stri return o, rec.Header("etag") } -// TestRootSpellingsServeTheTree888: the files, directories, index files and -// dotfiles of the tree are served under "." and "./" (relative to the working -// directory) and "/" (by absolute path), as under the absolute Root. +// TestRootSpellingsServeTheTree888: the files, directories and index files of +// the tree (and its dotfiles, with Dotfiles: Allow) are served under "." and "./" +// (relative to the working directory) and "/" (by absolute path), as under the +// absolute Root. func TestRootSpellingsServeTheTree888(t *testing.T) { cases := []struct { path string want string // the body of the 200 + dot bool // a hidden file: served with Dotfiles: DotfilesAllow, passed on by default }{ - {"/", indexBody888}, - {"/index.html", indexBody888}, - {"/hello.txt", helloBody888}, - {"/sub/", subBody888}, - {"/sub", subBody888}, - {"/sub/page.txt", pageBody888}, - {"/./hello.txt", helloBody888}, - {"/sub/../hello.txt", helloBody888}, - // The package has no dotfile rule: a dotfile below the root is served, - // as under any Root. - {"/.env", envBody888}, - {"/.hidden/x.txt", envBody888}, + {"/", indexBody888, false}, + {"/index.html", indexBody888, false}, + {"/hello.txt", helloBody888, false}, + {"/sub/", subBody888, false}, + {"/sub", subBody888, false}, + {"/sub/page.txt", pageBody888, false}, + {"/./hello.txt", helloBody888, false}, + {"/sub/../hello.txt", helloBody888, false}, + // Hidden files are not served by default (Config.Dotfiles); allowed, + // they are served under these roots as under any other. + {"/.env", envBody888, true}, + {"/.hidden/x.txt", envBody888, true}, } for _, sp := range append(spellings888(), spelling888{"absolute", "", false, nil}) { for _, tc := range cases { t.Run(sp.name+tc.path, func(t *testing.T) { r := tree888(t) - var mw celeris.HandlerFunc + cfg := Config{} + if tc.dot { + cfg.Dotfiles = DotfilesAllow + } + var mw, def celeris.HandlerFunc path := tc.path if sp.name == "absolute" { - mw = New(Config{Root: r.root}) + cfg.Root = r.root + mw = New(cfg) + def = New(Config{Root: r.root}) } else { - mw = sp.open888(t, r, Config{}) + mw = sp.open888(t, r, cfg) + def = sp.open888(t, r, Config{}) path = sp.url(r, tc.path) } o, _ := outcomeOf888(t, mw, path) if o.kind != "ok" || o.status != 200 || o.body != tc.want { t.Errorf("GET %s: %v, want a 200 with %q", path, o, tc.want) } + if tc.dot { + if o, _ := outcomeOf888(t, def, path); o.kind != "passed-on" { + t.Errorf("GET %s by default: %v, want it passed on (hidden)", path, o) + } + } }) } } From f7c79a1b5b8a5c88c54d29442768d7a4d92f4863 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 10:38:41 +0100 Subject: [PATCH 14/26] lint: De Morgan in the dotfile helpers and tests (Refs #888) --- internal/ctxkit/hidden.go | 2 +- middleware/static/dotfiles_888_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/ctxkit/hidden.go b/internal/ctxkit/hidden.go index 9c78f619c..ed14cd09d 100644 --- a/internal/ctxkit/hidden.go +++ b/internal/ctxkit/hidden.go @@ -79,7 +79,7 @@ func HiddenPath(p string) bool { // RelWithin returns the slash path of p below base, for a p that // ResolvedWithin(base, p) accepted. Both are results of filepath.EvalSymlinks. func RelWithin(base, p string) (string, bool) { - if !(!filepath.IsAbs(base) && climbs(base)) && withinClean(base, p) { + if (filepath.IsAbs(base) || !climbs(base)) && withinClean(base, p) { switch { case p == base: return "", true diff --git a/middleware/static/dotfiles_888_test.go b/middleware/static/dotfiles_888_test.go index 7fa634f71..6d9c6a762 100644 --- a/middleware/static/dotfiles_888_test.go +++ b/middleware/static/dotfiles_888_test.go @@ -200,7 +200,7 @@ func TestDotfilesHeadRangeConditional888(t *testing.T) { if rec.StatusCode == 206 || rec.StatusCode == 304 || rec.StatusCode == 200 || len(rec.Body) != 0 { t.Errorf("policy %d GET %s %v: status %d body %q err %v", pol, p, hdr, rec.StatusCode, rec.Body, err) } - if pol == DotfilesDeny && !(err != nil && err.(*celeris.HTTPError).Code == 403) { + if pol == DotfilesDeny && (err == nil || err.(*celeris.HTTPError).Code != 403) { t.Errorf("policy %d GET %s %v: err %v, want a 403", pol, p, hdr, err) } if pol == DotfilesIgnore && err != nil { @@ -359,7 +359,7 @@ func TestDotfilesSPA888(t *testing.T) { } deny := New(Config{Root: root, SPA: true, Dotfiles: DotfilesDeny}) rec, err := get888(t, deny, "/.env") - if !(err != nil && err.(*celeris.HTTPError).Code == 403) || len(rec.Body) != 0 { + if err == nil || err.(*celeris.HTTPError).Code != 403 || len(rec.Body) != 0 { t.Errorf("SPA Deny GET /.env: status %d body %q err %v, want 403", rec.StatusCode, rec.Body, err) } allow := New(Config{Root: root, SPA: true, Dotfiles: DotfilesAllow}) From f6a46ad4436a31735b75e539194325e1b62f8789 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 11:03:41 +0100 Subject: [PATCH 15/26] tests(static): round 2 of the security review: non-regular files (FIFO, socket, devices, symlinks to them) over Root, '.', '/', os.DirFS and the explicit APIs; the symlink rule under a real os.DirFS; a backslash is not a separator; a hidden .gz link; DotfilesPolicy validation; Server.Static 404 for a missing file; the cost of a '..' root (Refs #888) Red on f7c79a1: TestNonRegularFilesAreNotServed888 (every FIFO/socket cell hangs: no answer in 5 s), TestNonRegularFilesExplicitAPIs888, TestDotfilesDirFS888 (a visible link to .env is served under os.DirFS), TestDotfilesCompressVariantLink888, TestDotfilesPolicyIsValidated888, TestServerStaticMissingIs404_888, TestDotDotRootCostsAboutADotRoot888, and the new rows of TestDotfilesPolicies888 (a literal backslash name). The device cells (TestDeviceFiles*888) run in a memory-capped container, see the next commits. --- middleware/static/dotfiles_888_test.go | 23 +- middleware/static/dotfiles_r2_888_test.go | 170 +++++++++++ middleware/static/nonregular_888_test.go | 331 ++++++++++++++++++++++ 3 files changed, 521 insertions(+), 3 deletions(-) create mode 100644 middleware/static/dotfiles_r2_888_test.go create mode 100644 middleware/static/nonregular_888_test.go diff --git a/middleware/static/dotfiles_888_test.go b/middleware/static/dotfiles_888_test.go index 6d9c6a762..d5eb90633 100644 --- a/middleware/static/dotfiles_888_test.go +++ b/middleware/static/dotfiles_888_test.go @@ -48,7 +48,16 @@ func dotTree(t *testing.T) string { if err != nil { t.Fatal(err) } + files := map[string]string{} for name, body := range dotFiles888 { + files[name] = body + } + if runtime.GOOS != "windows" { + // names that hold a backslash and "..": one name on these systems, so + // ".." must not pop the segment before it + files[`.git/..\x`], files[`.s\..\y`] = "BSX", "BSY" + } + for name, body := range files { p := filepath.Join(root, filepath.FromSlash(name)) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) @@ -60,8 +69,12 @@ func dotTree(t *testing.T) string { t.Fatal(err) } } - for link, target := range map[string]string{"link-env": ".env", "link-dir": ".git", "link-wk": ".well-known", ".dot-link": "a.txt"} { - if err := os.Symlink(target, filepath.Join(root, link)); err != nil { + for link, target := range map[string]string{ + "link-env": ".env", "link-dir": ".git", "link-wk": ".well-known", ".dot-link": "a.txt", + "chain1": "chain2", "chain2": ".env", "lnkrel": "sub/../.env", "lnkabs": filepath.Join(root, ".env"), + ".well-known/g": "../.git", "ok-abs": filepath.Join(root, "a.txt"), + } { + if err := os.Symlink(target, filepath.Join(root, filepath.FromSlash(link))); err != nil { t.Skipf("symlinks are not available here: %v", err) } } @@ -105,6 +118,10 @@ var dotCases888 = []dotCase888{ {"/.env\x00", true, ""}, {"/.env%00", true, ""}, // symlinks: a visible name that leads to a dotfile or directory of the tree, and a hidden name {"/link-env", true, "ENV"}, {"/link-dir/config", true, "GITCFG"}, {"/.dot-link", true, "A"}, + {"/chain1", true, "ENV"}, {"/chain2", true, "ENV"}, {"/lnkrel", true, "ENV"}, {"/lnkabs", true, "ENV"}, + {"/.well-known/g/config", true, "GITCFG"}, {"/link-dir/", true, ""}, {"/ok-abs", false, "A"}, + // a literal backslash is part of the name on these systems (not a separator) + {`/.git/..\x`, true, "BSX"}, {`/.s\..\y`, true, "BSY"}, {`/sub/..\.git/config`, true, ""}, // visible {"/", false, tagIndex888}, {"/index.html", false, tagIndex888}, {"/a.txt", false, "A"}, {"/a.env", false, "AENV"}, {"/sub/", false, "SUBIDX"}, {"/sub", false, "SUBIDX"}, {"/sub/b.txt", false, "B"}, @@ -410,7 +427,7 @@ func TestDotfilesFS888(t *testing.T) { mw := New(Config{FS: m, Dotfiles: pol}) absent := missing888(t, mw) for _, tc := range dotCases888 { - if strings.Contains(tc.path, "link") || strings.Contains(tc.path, `\`) || strings.Contains(tc.path, "\x00") || strings.Contains(tc.path, "%00") || strings.HasPrefix(tc.path, "/.dot") { + if strings.Contains(tc.path, "link") || strings.Contains(tc.path, "chain") || strings.Contains(tc.path, "lnk") || strings.Contains(tc.path, "ok-abs") || strings.Contains(tc.path, ".well-known/g/") || strings.Contains(tc.path, `\`) || strings.Contains(tc.path, "\x00") || strings.Contains(tc.path, "%00") || strings.HasPrefix(tc.path, "/.dot") { continue // symlinks and the OS-only rows } weird := strings.Contains(tc.path, "..") || strings.Contains(tc.path, "/./") || strings.Contains(tc.path, "//") diff --git a/middleware/static/dotfiles_r2_888_test.go b/middleware/static/dotfiles_r2_888_test.go new file mode 100644 index 000000000..df51ca58e --- /dev/null +++ b/middleware/static/dotfiles_r2_888_test.go @@ -0,0 +1,170 @@ +package static + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/goceleris/celeris" + "github.com/goceleris/celeris/celeristest" +) + +// Round 2 of the security review of the dotfile rule. + +// TestDotfilesDirFS888 (R2-F1): the rule follows symlinks under Config.FS too, +// for an FS that can resolve them (a real os.DirFS, an fs.ReadLinkFS): a visible +// name that links to a dotfile, a hidden directory, a chain of links, a link +// into .well-known, a link that goes through ".." are refused like the hidden +// name, a hidden name stays hidden whatever it links to, and a link to +// .well-known is served. The same table as the Root tests. +func TestDotfilesDirFS888(t *testing.T) { + skipWindows888(t) + root := dotTree(t) + for _, pol := range []DotfilesPolicy{DotfilesIgnore, DotfilesDeny, DotfilesAllow} { + mw := New(Config{FS: os.DirFS(root), Dotfiles: pol}) + absent := missing888(t, mw) + for _, tc := range dotCases888 { + if strings.Contains(tc.path, "\x00") || strings.Contains(tc.path, "%00") { + continue + } + weird := strings.Contains(tc.path, "..") || strings.Contains(tc.path, "/./") || strings.Contains(tc.path, "//") + t.Run(fmt.Sprintf("policy%d/%q", pol, tc.path), func(t *testing.T) { + rec, err := get888(t, mw, tc.path) + status := rec.StatusCode + if he, ok := err.(*celeris.HTTPError); ok { + status = he.Code + } + switch { + case pol == DotfilesAllow || !tc.hidden: + if tc.body != "" && !weird && (status != 200 || string(rec.Body) != tc.body) { + t.Errorf("GET %q: status %d body %q, want 200 %q", tc.path, status, rec.Body, tc.body) + } + case pol == DotfilesIgnore: + if status != absent.status || len(rec.Body) != 0 || err != nil { + t.Errorf("GET %q hidden under Ignore: status %d body %q err %v, want passed on", tc.path, status, rec.Body, err) + } + default: + if status != 403 || len(rec.Body) != 0 { + t.Errorf("GET %q hidden under Deny: status %d body %q err %v, want 403", tc.path, status, rec.Body, err) + } + } + }) + } + // a listing does not enter a visible link to a hidden directory + bw := New(Config{FS: os.DirFS(root), Dotfiles: pol, Browse: true, Index: "no-index.here"}) + if rec, _ := get888(t, bw, "/link-dir/"); (rec.StatusCode == 200) != (pol == DotfilesAllow) { + t.Errorf("policy %d GET /link-dir/ (listing): status %d", pol, rec.StatusCode) + } + // and a 304 is no oracle either + rec, _ := get888(t, mw, "/link-env", "if-none-match", "*") + if (rec.StatusCode == 304 || rec.StatusCode == 200) != (pol == DotfilesAllow) { + t.Errorf("policy %d GET /link-env with If-None-Match: status %d", pol, rec.StatusCode) + } + } +} + +// TestDotfilesCompressVariantLink888 (R2-F3): a visible file whose ".gz" variant +// is a symlink to a hidden file is served as the identity file (no variant), +// not 404. +func TestDotfilesCompressVariantLink888(t *testing.T) { + skipWindows888(t) + root := dotTree(t) + if err := os.WriteFile(filepath.Join(root, "b.js"), []byte("BJS"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(".env", filepath.Join(root, "b.js.gz")); err != nil { + t.Skipf("symlinks are not available here: %v", err) + } + for _, mw := range []celeris.HandlerFunc{ + New(Config{Root: root, Compress: true}), + New(Config{FS: os.DirFS(root), Compress: true}), + } { + rec, err := get888(t, mw, "/b.js", "accept-encoding", "gzip") + if err != nil || rec.StatusCode != 200 || string(rec.Body) != "BJS" || rec.Header("content-encoding") != "" { + t.Errorf("GET /b.js with a hidden .gz link: status %d body %q content-encoding %q err %v, want the identity file", rec.StatusCode, rec.Body, rec.Header("content-encoding"), err) + } + } +} + +// TestDotfilesPolicyIsValidated888: an unknown DotfilesPolicy panics at New, as +// the other invalid Config values do. +func TestDotfilesPolicyIsValidated888(t *testing.T) { + for _, pol := range []DotfilesPolicy{3, 7, 255} { + func() { + defer func() { + r := recover() + if r == nil { + t.Errorf("New with Dotfiles %d did not panic", pol) + return + } + if msg, _ := r.(string); !strings.Contains(msg, "Dotfiles") { + t.Errorf("New with Dotfiles %d panicked with %v, want a message about Dotfiles", pol, r) + } + }() + New(Config{Root: ".", Dotfiles: pol}) + }() + } + for _, pol := range []DotfilesPolicy{DotfilesIgnore, DotfilesAllow, DotfilesDeny} { + New(Config{Root: ".", Dotfiles: pol}) // must not panic + } +} + +// TestServerStaticMissingIs404_888: Server.Static and Group.Static answer a file +// that is not there (and a dangling symlink) with 404, as they answer a hidden +// path, not 500 with the raw lstat error. +func TestServerStaticMissingIs404_888(t *testing.T) { + skipWindows888(t) + root := dotTree(t) + if err := os.Symlink("no-such-target", filepath.Join(root, "dangling")); err != nil { + t.Skipf("symlinks are not available here: %v", err) + } + for _, eng := range wireEngines888 { + raw := wire888(t, eng, func(s *celeris.Server) { + s.Static("/files", root) + s.Group("/g").Static("/files", root) + }) + for _, p := range []string{"/files/nope.txt", "/files/dangling", "/files/sub/nope.txt", "/g/files/nope.txt", "/files/%252eenv", "/files/.env", "/files/.missing"} { + if status, body := raw(p); status != 404 { + t.Errorf("%v GET %s: status %d body %q, want 404", eng, p, status, body) + } + } + if status, _ := raw("/files/a.txt"); status != 200 { + t.Errorf("%v GET /files/a.txt: status %d", eng, status) + } + } +} + +// TestDotDotRootCostsAboutADotRoot888 (R2-F6): a Root of ".." segments is +// resolved once, when the middleware is made, so a request costs what it costs +// under any relative Root, not five times that (Getwd and EvalSymlinks of the +// working directory in every containment check). +func TestDotDotRootCostsAboutADotRoot888(t *testing.T) { + skipWindows888(t) + d := newDotTree888(t) // the working directory is T/p/c + if err := os.MkdirAll(filepath.Join(d.cwd, "sub"), 0o755); err != nil { + t.Fatal(err) + } + cost := func(root, path string) float64 { + mw := New(Config{Root: root}) + return testing.AllocsPerRun(20, func() { + ctx, _ := celeristest.NewContext("GET", path, celeristest.WithHandlers(mw)) + _ = ctx.Next() + celeristest.ReleaseContext(ctx) + }) + } + dot := cost(".", "/a.txt") + for _, tc := range []struct{ root, path string }{{"..", "/c/a.txt"}, {"../c", "/a.txt"}, {"x/..", "/a.txt"}, {"../..", "/p/c/a.txt"}} { + if got := cost(tc.root, tc.path); got > dot+8 { + t.Errorf("Root %q: %.0f allocations per request, Root \".\" costs %.0f", tc.root, got, dot) + } + } + // a working directory that changes after New is not followed by a resolved root + mw := New(Config{Root: ".."}) + t.Chdir(d.cwd + "/sub") + rec, _ := get888(t, mw, "/PSECRET.txt") + if rec.StatusCode != 200 || string(rec.Body) != d.files[filepath.Join(d.top, "p", "PSECRET.txt")] { + t.Errorf("Root \"..\" after a chdir: status %d body %q, want the file of the directory it resolved to at New", rec.StatusCode, rec.Body) + } +} diff --git a/middleware/static/nonregular_888_test.go b/middleware/static/nonregular_888_test.go new file mode 100644 index 000000000..3f68f8a7f --- /dev/null +++ b/middleware/static/nonregular_888_test.go @@ -0,0 +1,331 @@ +package static + +import ( + "fmt" + "net" + "os" + "path/filepath" + "runtime" + "runtime/debug" + "strings" + "syscall" + "testing" + "time" + + "github.com/goceleris/celeris" + "github.com/goceleris/celeris/celeristest" +) + +// Non-regular files (celeris#888 item 46, round 2 of the security review). With +// Root "/" or "." in a working directory of "/" (the systemd default, a +// container without a WORKDIR) GET /dev/zero read without bound into memory (8 +// GB/s; the process was OOM-killed on std and epoll): stat reports size 0 for a +// device, the size cap passes it, and io.ReadAll never ends. A FIFO anywhere in +// a served tree hung the handler in open(2). Devices, FIFOs and sockets are not +// served: they are answered as a missing file (404 under the middleware); the +// explicit APIs (Context.File, FileFromDir, FileFromFS) return a 404 HTTPError +// the caller sees. A symlink is followed to a regular file only. +// +// The device cells run only where a runaway read cannot hurt the machine they +// run on in a way the guard does not catch: guardMemory888 exits the test binary +// at 256 MiB of heap. They do not run on macOS, where a regression would be +// writing 8 GB/s into a developer's machine (set L21_DEVICE_CELLS=1 to force). + +// guardMemory888 ends the test binary, with a message, when the heap passes +// limit: a read without bound must not take the machine with it. +func guardMemory888(t *testing.T, limit uint64) { + t.Helper() + stop := make(chan struct{}) + t.Cleanup(func() { close(stop) }) + go func() { + var ms runtime.MemStats + for { + select { + case <-stop: + return + case <-time.After(10 * time.Millisecond): + } + runtime.ReadMemStats(&ms) + if ms.HeapAlloc > limit { + fmt.Fprintf(os.Stderr, "FAIL: %s: the heap is %d MiB: a non-regular file is read without bound\n", t.Name(), ms.HeapAlloc>>20) + os.Exit(3) + } + } + }() + debug.SetMemoryLimit(int64(limit) * 2) + t.Cleanup(func() { debug.SetMemoryLimit(-1) }) +} + +func deviceCellsOK888(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("no devices, FIFOs or sockets as files") + } + if runtime.GOOS == "darwin" && os.Getenv("L21_DEVICE_CELLS") != "1" { + t.Skip("the device cells read /dev/zero when the rule is missing: run them in a memory-capped container (L21_DEVICE_CELLS=1 forces them here)") + } + if _, err := os.Stat("/dev/zero"); err != nil { + t.Skip("no /dev/zero") + } +} + +// specialTree888 is a directory with a regular file, a FIFO, a unix socket and +// symlinks to each, and to devices. +type specialTree888 struct{ dir string } + +func newSpecialTree888(t *testing.T) specialTree888 { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("no FIFOs or unix sockets as files") + } + // a short path: a unix socket's path is at most about 100 bytes + dir, err := os.MkdirTemp("", "l21") + if err != nil { + t.Fatal(err) + } + dir, err = filepath.EvalSymlinks(dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + if err := os.WriteFile(filepath.Join(dir, "reg.txt"), []byte("REG"), 0o644); err != nil { + t.Fatal(err) + } + if err := syscall.Mkfifo(filepath.Join(dir, "fifo"), 0o644); err != nil { + t.Skipf("no mkfifo here: %v", err) + } + ln, err := net.Listen("unix", filepath.Join(dir, "sock")) + if err != nil { + t.Skipf("no unix sockets here: %v", err) + } + t.Cleanup(func() { _ = ln.Close() }) + for link, target := range map[string]string{ + "lnk-fifo": "fifo", "lnk-sock": "sock", "lnk-reg": "reg.txt", + "lnk-null": "/dev/null", "lnk-zero": "/dev/zero", "lnk-urandom": "/dev/urandom", + } { + if err := os.Symlink(target, filepath.Join(dir, link)); err != nil { + t.Skipf("symlinks are not available here: %v", err) + } + } + return specialTree888{dir} +} + +// within888 runs fn and fails the test if it does not return in d: a handler +// stuck in open(2) on a FIFO is a hung request. +func within888[T any](t *testing.T, what string, d time.Duration, fn func() T) T { + t.Helper() + ch := make(chan T, 1) + go func() { ch <- fn() }() + select { + case v := <-ch: + return v + case <-time.After(d): + t.Fatalf("%s: no answer in %v (the handler is stuck)", what, d) + var zero T + return zero + } +} + +// TestNonRegularFilesAreNotServed888: a FIFO, a socket and symlinks to them, in +// a tree served by an absolute Root, by ".", and by "/" (by absolute path), and +// with an os.DirFS: answered as missing, at once, and the regular file still +// served. Symlinks to devices leave the tree under the roots that have one, +// and are refused there. +func TestNonRegularFilesAreNotServed888(t *testing.T) { + st := newSpecialTree888(t) + type cfg struct { + name string + mw func(t *testing.T) celeris.HandlerFunc + url func(p string) string + } + rel := func(p string) string { return p } + cfgs := []cfg{ + {"abs-root", func(t *testing.T) celeris.HandlerFunc { return New(Config{Root: st.dir}) }, rel}, + {"dot-root", func(t *testing.T) celeris.HandlerFunc { t.Chdir(st.dir); return New(Config{Root: "."}) }, rel}, + {"slash-root", func(t *testing.T) celeris.HandlerFunc { return New(Config{Root: "/"}) }, func(p string) string { return st.dir + p }}, + {"dirfs", func(t *testing.T) celeris.HandlerFunc { return New(Config{FS: os.DirFS(st.dir)}) }, rel}, + {"spa", func(t *testing.T) celeris.HandlerFunc { return New(Config{Root: st.dir, SPA: true}) }, rel}, + } + for _, c := range cfgs { + for _, p := range []string{"/fifo", "/sock", "/lnk-fifo", "/lnk-sock", "/lnk-null"} { + t.Run(c.name+p, func(t *testing.T) { + mw := c.mw(t) + path := c.url(p) + rec := within888(t, "GET "+path, 5*time.Second, func() *celeristest.ResponseRecorder { + rec, _ := get888(t, mw, path) + return rec + }) + body := string(rec.Body) + switch { + case c.name == "spa": + // a missing path is the index file in SPA mode (none here): passed on + if rec.StatusCode == 200 && body != "" { + t.Errorf("GET %s: status %d body %q", path, rec.StatusCode, body) + } + case rec.StatusCode != 0 || body != "": + t.Errorf("GET %s: status %d body %q, want it passed on as a missing file is", path, rec.StatusCode, body) + } + }) + } + t.Run(c.name+"/reg.txt", func(t *testing.T) { + mw := c.mw(t) + rec, _ := get888(t, mw, c.url("/reg.txt")) + if rec.StatusCode != 200 || string(rec.Body) != "REG" { + t.Errorf("the regular file: status %d body %q, want 200 REG", rec.StatusCode, rec.Body) + } + rec, _ = get888(t, mw, c.url("/lnk-reg")) + if rec.StatusCode != 200 || string(rec.Body) != "REG" { + t.Errorf("a symlink to the regular file: status %d body %q, want 200 REG", rec.StatusCode, rec.Body) + } + }) + } +} + +// TestNonRegularFilesExplicitAPIs888: Context.File, FileFromDir and FileFromFS +// return an error the caller can see (an HTTPError 404 "not a regular file") +// for a FIFO, a socket and a symlink to one, without blocking. +func TestNonRegularFilesExplicitAPIs888(t *testing.T) { + st := newSpecialTree888(t) + codeOf := func(err error) int { + if he, ok := err.(*celeris.HTTPError); ok { + return he.Code + } + return -1 + } + for _, name := range []string{"fifo", "sock", "lnk-fifo", "lnk-sock"} { + t.Run(name, func(t *testing.T) { + path := filepath.Join(st.dir, name) + calls := map[string]func(c *celeris.Context) error{ + "File": func(c *celeris.Context) error { return c.File(path) }, + "FileFromDir": func(c *celeris.Context) error { return c.FileFromDir(st.dir, name) }, + "FileFromFS": func(c *celeris.Context) error { return c.FileFromFS(name, os.DirFS(st.dir)) }, + } + for what, call := range calls { + err := within888(t, what+" "+name, 5*time.Second, func() error { + c, rec := celeristest.NewContext("GET", "/f") + defer celeristest.ReleaseContext(c) + err := call(c) + if rec.StatusCode == 200 { + return fmt.Errorf("answered 200") + } + return err + }) + if codeOf(err) != 404 { + t.Errorf("%s(%s) = %v, want an HTTPError 404", what, name, err) + } + } + }) + } + t.Run("regular", func(t *testing.T) { + c, rec := celeristest.NewContext("GET", "/f") + defer celeristest.ReleaseContext(c) + if err := c.FileFromDir(st.dir, "lnk-reg"); err != nil || string(rec.Body) != "REG" { + t.Errorf("FileFromDir(lnk-reg): %v, body %q", err, rec.Body) + } + }) +} + +// TestDeviceFilesStatic888: the middleware under the roots that reach /dev: +// "/" and "." in a working directory of "/". The guard exits the binary if a +// read runs away. +func TestDeviceFilesStatic888(t *testing.T) { + deviceCellsOK888(t) + guardMemory888(t, 256<<20) + for _, root := range []string{"/", "."} { + t.Run(root, func(t *testing.T) { + t.Chdir("/") + mw := New(Config{Root: root}) + for _, p := range []string{"/dev/zero", "/dev/null", "/dev/urandom", "/dev/random", "/dev/full"} { + rec := within888(t, "GET "+p, 10*time.Second, func() *celeristest.ResponseRecorder { + rec, _ := get888(t, mw, p) + return rec + }) + if rec.StatusCode != 0 || len(rec.Body) != 0 { + t.Errorf("Root %q GET %s: status %d, %d body bytes, want it passed on as a missing file", root, p, rec.StatusCode, len(rec.Body)) + } + } + // a symlink to a device, inside a served tree under Root "/" + st := newSpecialTree888(t) + for _, l := range []string{"lnk-zero", "lnk-null", "lnk-urandom"} { + p := st.dir + "/" + l + if root == "." { + continue + } + rec := within888(t, "GET "+p, 10*time.Second, func() *celeristest.ResponseRecorder { + rec, _ := get888(t, mw, p) + return rec + }) + if rec.StatusCode != 0 || len(rec.Body) != 0 { + t.Errorf("Root %q GET %s: status %d, %d body bytes, want it passed on", root, p, rec.StatusCode, len(rec.Body)) + } + } + }) + } + t.Run("dirfs", func(t *testing.T) { + mw := New(Config{FS: os.DirFS("/")}) + for _, p := range []string{"/dev/zero", "/dev/urandom"} { + rec := within888(t, "GET "+p, 10*time.Second, func() *celeristest.ResponseRecorder { + rec, _ := get888(t, mw, p) + return rec + }) + if rec.StatusCode != 0 || len(rec.Body) != 0 { + t.Errorf("os.DirFS(/) GET %s: status %d, %d body bytes, want it passed on", p, rec.StatusCode, len(rec.Body)) + } + } + }) +} + +// TestDeviceFilesExplicitAPIs888: c.File, FileFromDir("/", ...) and FileFromFS +// refuse a device with an HTTPError 404 and read nothing. +func TestDeviceFilesExplicitAPIs888(t *testing.T) { + deviceCellsOK888(t) + guardMemory888(t, 256<<20) + for what, call := range map[string]func(c *celeris.Context) error{ + "File": func(c *celeris.Context) error { return c.File("/dev/zero") }, + "FileFromDir": func(c *celeris.Context) error { return c.FileFromDir("/", "dev/zero") }, + "FileFromFS": func(c *celeris.Context) error { return c.FileFromFS("dev/zero", os.DirFS("/")) }, + } { + err := within888(t, what, 10*time.Second, func() error { + c, _ := celeristest.NewContext("GET", "/f") + defer celeristest.ReleaseContext(c) + return call(c) + }) + if he, ok := err.(*celeris.HTTPError); !ok || he.Code != 404 { + t.Errorf("%s of /dev/zero = %v, want an HTTPError 404", what, err) + } + } +} + +// TestDeviceFilesOverTheWire888: the exact request of the review, over the +// engines: Root "." with a working directory of "/", Root "/", Server.Static +// and Group.Static over "." — GET /dev/zero is a 404 and the server stays up. +func TestDeviceFilesOverTheWire888(t *testing.T) { + deviceCellsOK888(t) + guardMemory888(t, 256<<20) + t.Chdir("/") + type entry struct { + name, prefix string + reg func(s *celeris.Server) + } + for _, eng := range wireEngines888 { + for _, e := range []entry{ + {"middleware-dot", "", func(s *celeris.Server) { s.Use(New(Config{Root: "."})) }}, + {"middleware-slash", "", func(s *celeris.Server) { s.Use(New(Config{Root: "/"})) }}, + {"Server.Static-dot", "/files", func(s *celeris.Server) { s.Static("/files", ".") }}, + {"Group.Static-slash", "/g/files", func(s *celeris.Server) { s.Group("/g").Static("/files", "/") }}, + } { + t.Run(fmt.Sprintf("%v/%s", eng, e.name), func(t *testing.T) { + raw := wire888(t, eng, e.reg) + for _, p := range []string{"/dev/zero", "/dev/urandom", "/dev/null"} { + status, body := raw(e.prefix + p) + if status != 404 || len(body) > 64 { + t.Errorf("GET %s%s: status %d, %d body bytes, want 404", e.prefix, p, status, len(body)) + } + } + if status, _ := raw(e.prefix + "/etc/hostname"); status != 200 && !strings.Contains(e.name, "Static") { + t.Logf("GET /etc/hostname: %d (the server is up)", status) + } + }) + } + } +} From 60bebfe4f333fc7df09680d67790dfcd504fd79e Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 11:06:02 +0100 Subject: [PATCH 16/26] fix(static, celeris): devices, FIFOs and sockets are not served; the dotfile rule follows symlinks under os.DirFS; a backslash is a name character; a hidden .gz link is no variant; Dotfiles is validated; Server.Static answers a missing file with 404; a '..' root is resolved once (Refs #888) Non-regular files (R2-F7): fileFromDir, Context.File, FileFromFS, serveOS and serveFS refuse anything that is neither a regular file nor a directory, before the open (open(2) of a FIFO waits for a writer) and again on the open descriptor, and the buffered reads are bounded by the existing 100 MB cap (io.LimitReader). The middleware answers a missing file; Context.File, FileFromDir and FileFromFS return an HTTPError 404 'not a regular file' (wrapping ctxkit.ErrNotRegular). Symlinks are followed to a regular file only. R2-F1: serveFS resolves symlinks for an FS that implements fs.ReadLinkFS (os.DirFS) and applies the hidden rule to where the path leads. R2-F2: HiddenPath splits on '/' only (and on '\\' on Windows); a segment holding a backslash is hidden if a piece of it starts with a dot. R2-F3: a variant that is a hidden link or not regular is skipped. R2-F6: a root made of '..' segments is resolved once at New and at Server.Static; R2-F5: Server.Static and Group.Static map a missing file to 404. --- context_response.go | 69 ++++++++++- group.go | 1 + internal/ctxkit/hidden.go | 48 +++++++- internal/ctxkit/hidden_test.go | 5 +- internal/ctxkit/pathwithin.go | 19 +++ middleware/static/config.go | 3 + middleware/static/dotfiles_888_test.go | 3 +- middleware/static/dotfiles_r2_888_test.go | 9 +- middleware/static/static.go | 142 +++++++++++++++++++--- server.go | 1 + 10 files changed, 266 insertions(+), 34 deletions(-) diff --git a/context_response.go b/context_response.go index 03f98924c..f97f068e1 100644 --- a/context_response.go +++ b/context_response.go @@ -935,6 +935,30 @@ func (c *Context) File(filePath string) error { return c.serveFile(filePath, "", nil) } +// staticRoot is the root of Server.Static and Group.Static as the route keeps +// it: a root made of ".." segments is resolved against the working directory +// once, when the route is registered (see ctxkit.ResolveClimbingRoot); any +// other root, the empty one included, is kept as given. +func staticRoot(root string) string { + if root == "" { + return root + } + return ctxkit.ResolveClimbingRoot(filepath.Clean(root)) +} + +// errNotRegular is the error for a path that is not a regular file (a device, a +// FIFO, a socket): reading one is unbounded (/dev/zero) or never ends (a FIFO), +// so none is served. It is an HTTPError 404 that wraps ctxkit.ErrNotRegular. +func errNotRegular() error { + return NewHTTPError(404, "not a regular file").WithError(ctxkit.ErrNotRegular) +} + +// notServable reports whether info is a file that is neither a regular file nor +// a directory. +func notServable(info fs.FileInfo) bool { + return !info.IsDir() && !info.Mode().IsRegular() +} + // serveFile is File. contentType, when not empty, replaces the type taken from // filePath's extension. onOpen, when not nil, runs once the file is open and // its size and mtime are read from the open descriptor, before the response is @@ -942,6 +966,17 @@ func (c *Context) File(filePath string) error { // middleware/static sets its validators from them there, so they describe the // bytes that are sent even when the path is replaced meanwhile (celeris#846). func (c *Context) serveFile(filePath, contentType string, onOpen func(modTime time.Time, size int64)) error { + // Before the open: open(2) of a FIFO waits for a writer. + if info, err := os.Stat(filePath); err == nil && notServable(info) { + return errNotRegular() + } + return c.serveOpenFile(filePath, contentType, onOpen) +} + +// serveOpenFile is serveFile for a path that was just stat-ed as a regular file +// or a directory (fileFromDir): it checks the open descriptor again, which is +// what is read, and bounds the buffered read. +func (c *Context) serveOpenFile(filePath, contentType string, onOpen func(modTime time.Time, size int64)) error { f, err := os.Open(filePath) if err != nil { return err @@ -952,6 +987,9 @@ func (c *Context) serveFile(filePath, contentType string, onOpen func(modTime ti if err != nil { return err } + if notServable(stat) { + return errNotRegular() + } size := stat.Size() if size > int64(maxStreamBodySize) { @@ -1009,10 +1047,16 @@ func (c *Context) serveFile(filePath, contentType string, onOpen func(modTime ti return err } - data, err := io.ReadAll(f) + // A regular file has a size, and it was capped above; the read is bounded by + // the cap all the same, for a file that grows meanwhile and for a + // pseudo-file the size of which is not its length. + data, err := io.ReadAll(io.LimitReader(f, int64(maxStreamBodySize)+1)) if err != nil { return err } + if len(data) > maxStreamBodySize { + return NewHTTPError(413, "file exceeds 100MB limit") + } return c.Blob(200, contentType, data) } @@ -1097,7 +1141,9 @@ func (c *Context) serveStaticFile(root, tail string) error { return NewHTTPError(404, "Not Found") } err := c.fileFromDir(root, tail, "", nil, ctxkit.HiddenPath) - if errors.Is(err, ctxkit.ErrHidden) { + if errors.Is(err, ctxkit.ErrHidden) || errors.Is(err, fs.ErrNotExist) { + // a path to a hidden file is a 404 whether or not it exists, and so is a + // file that is not there (the raw lstat error was a 500) return NewHTTPError(404, "Not Found") } return err @@ -1146,7 +1192,10 @@ func (c *Context) fileFromDir(baseDir, userPath, contentType string, onOpen func if info.IsDir() { return NewHTTPError(400, "invalid file path") } - return c.serveFile(resolved, contentType, onOpen) + if !info.Mode().IsRegular() { + return errNotRegular() + } + return c.serveOpenFile(resolved, contentType, onOpen) } // FileFromFS serves a named file from an [fs.FS] (e.g. embed.FS). The content @@ -1159,6 +1208,12 @@ func (c *Context) fileFromDir(baseDir, userPath, contentType string, onOpen func // user-supplied paths to prevent directory traversal. For untrusted input, // use [Context.FileFromDir] instead. func (c *Context) FileFromFS(name string, fsys fs.FS) error { + // Before the open: for an os.DirFS, open(2) of a FIFO waits for a writer. + if _, ok := fsys.(fs.StatFS); ok { + if info, err := fs.Stat(fsys, name); err == nil && notServable(info) { + return errNotRegular() + } + } f, err := fsys.Open(name) if err != nil { return err @@ -1172,6 +1227,9 @@ func (c *Context) FileFromFS(name string, fsys fs.FS) error { if stat.IsDir() { return NewHTTPError(400, "path is a directory") } + if !stat.Mode().IsRegular() { + return errNotRegular() + } size := stat.Size() if size > int64(maxStreamBodySize) { return NewHTTPError(413, "file exceeds 100MB limit") @@ -1191,10 +1249,13 @@ func (c *Context) FileFromFS(name string, fsys fs.FS) error { } } - data, err := io.ReadAll(f) + data, err := io.ReadAll(io.LimitReader(f, int64(maxStreamBodySize)+1)) if err != nil { return err } + if len(data) > maxStreamBodySize { + return NewHTTPError(413, "file exceeds 100MB limit") + } return c.Blob(200, contentType, data) } diff --git a/group.go b/group.go index 67df49026..120eb4142 100644 --- a/group.go +++ b/group.go @@ -134,6 +134,7 @@ func (g *RouteGroup) Handle(method, path string, handlers ...HandlerFunc) *Route // no opt-out here: use middleware/static with Dotfiles: static.DotfilesAllow. func (g *RouteGroup) Static(prefix, root string) *Route { p := strings.TrimRight(prefix, "/") + "/*filepath" + root = staticRoot(root) return g.GET(p, func(c *Context) error { return c.serveStaticFile(root, c.Param("filepath")) }) diff --git a/internal/ctxkit/hidden.go b/internal/ctxkit/hidden.go index ed14cd09d..09207617b 100644 --- a/internal/ctxkit/hidden.go +++ b/internal/ctxkit/hidden.go @@ -4,6 +4,7 @@ import ( "errors" "net/url" "path/filepath" + "runtime" "strings" ) @@ -13,6 +14,15 @@ import ( // been written to the response. var ErrHidden = errors.New("hidden path") +// ErrNotRegular is carried (wrapped in an HTTPError 404) by the error of the +// registered FileFromDir, and by Context.File and FileFromFS, for a path that is +// not a regular file once symlinks are resolved: a device, a FIFO, a socket. +// Nothing has been written to the response. +var ErrNotRegular = errors.New("not a regular file") + +// windows is whether a backslash separates path elements. +var windows = runtime.GOOS == "windows" + // wellKnown is the one dot segment that is not hidden: RFC 8615 (ACME // challenges, security.txt, apple-app-site-association). Only this exact name. const wellKnown = ".well-known" @@ -24,6 +34,24 @@ const wellKnown = ".well-known" // is hidden. A segment that percent-decodes to such a name is hidden too, so a layer that // decodes the path later cannot turn a visible segment into a dotfile. func HiddenSegment(seg string) bool { + if hiddenDecoded(seg) { + return true + } + if !windows && strings.IndexByte(seg, '\\') >= 0 { + // A backslash is a name character here, not a separator: the file system + // sees one name. But a piece of it that starts with a dot makes it hidden + // all the same ("a\.b"), and ".." is hidden too, so it can never pop a + // segment before it. + for _, piece := range strings.Split(seg, "\\") { + if hiddenDecoded(piece) { + return true + } + } + } + return false +} + +func hiddenDecoded(seg string) bool { if hiddenName(seg) { return true } @@ -39,19 +67,29 @@ func hiddenName(s string) bool { return len(s) > 0 && s[0] == '.' && s != wellKnown } +// separators is what separates the segments of a path for HiddenPath: "/", and +// the backslash where it is a separator too (Windows). +func separators() string { + if windows { + return `/\` + } + return "/" +} + // HiddenPath reports whether a slash path below a root has a hidden segment // (HiddenSegment). The path is cleaned lexically first, as filepath.Join cleans // the path that is then opened: empty and "." segments are dropped and ".." // removes the segment before it, so "x/../.env" is ".env" and "/.git/../a" is -// "a". Backslashes separate segments too (on Windows they do), which makes a -// name that holds a backslash and then a dot ("a\.b") hidden on the other -// systems: harmless. Only the segments of p count: the name of the root the path -// is below never does. +// "a". A backslash separates segments only where it is a separator (Windows); +// elsewhere it is part of a name, and a segment that holds one is hidden if any +// piece of it starts with a dot (HiddenSegment): "/.git/..\x" is the hidden +// ".git", and a file named "..\x" is never taken for "x". Only the segments of p +// count: the name of the root the path is below never does. func HiddenPath(p string) bool { var stack [16]string segs := stack[:0] for len(p) > 0 { - i := strings.IndexAny(p, `/\`) + i := strings.IndexAny(p, separators()) var seg string if i < 0 { seg, p = p, "" diff --git a/internal/ctxkit/hidden_test.go b/internal/ctxkit/hidden_test.go index 6a25b3d5f..9b009b394 100644 --- a/internal/ctxkit/hidden_test.go +++ b/internal/ctxkit/hidden_test.go @@ -41,8 +41,11 @@ func TestHiddenPath(t *testing.T) { {"./.env", true}, {"//.env", true}, {"a//.env", true}, {"a/./.env", true}, {"a/../.env", true}, {"a/../b", false}, {".git/../a", false}, {".well-known/../.env", true}, {"a/.b/..", false}, {"a/.b/../c", false}, {"../a", false}, {"/../.env", true}, {"a/b/../../.env", true}, {"a/.env/../b", false}, - // backslashes separate + // a backslash is a name character (not a separator) except on Windows: + // a piece that starts with a dot hides the segment, ".." is hidden, and a + // ".." never pops the segment before it {`\.env`, true}, {`a\.env`, true}, {`a\b`, false}, {`.well-known\.x`, true}, + {`.git/..\x`, true}, {`.s\..\y`, true}, {`sub/..\.git/config`, true}, {`a\..\b`, true}, // encoded: one decoding {"%2eenv", true}, {"a/%2Eenv", true}, {"%2e%2e/a", true}, {"a/%2e%2e/b", true}, {"%252eenv", false}, {".env\x00", true}, {"a\x00/.b", true}, diff --git a/internal/ctxkit/pathwithin.go b/internal/ctxkit/pathwithin.go index 29d7adda8..a66687961 100644 --- a/internal/ctxkit/pathwithin.go +++ b/internal/ctxkit/pathwithin.go @@ -107,3 +107,22 @@ func ResolvedWithin(base, resolved string) bool { } return withinClean(ab, ar) } + +// ResolveClimbingRoot returns root, a cleaned path, made absolute against the +// working directory (symlinks resolved) when it is relative and starts with +// ".." ("..", "../..", "../x"): such a root names a directory above the working +// directory, and each containment check would otherwise have to resolve the +// working directory again (a Getwd and an EvalSymlinks per call). Done once, +// when the middleware or the route is made, a later change of the working +// directory does not move the root. Any other root, and one that cannot be +// resolved, is returned as it is. +func ResolveClimbingRoot(root string) string { + if root == "" || filepath.IsAbs(root) || !climbs(root) { + return root + } + abs, err := absPhysical(root) + if err != nil { + return root + } + return abs +} diff --git a/middleware/static/config.go b/middleware/static/config.go index 0d3ab688b..840d1fc87 100644 --- a/middleware/static/config.go +++ b/middleware/static/config.go @@ -151,4 +151,7 @@ func (cfg Config) validate() { if cfg.Prefix != "" && cfg.Prefix[0] != '/' { panic("static: Prefix must start with /") } + if cfg.Dotfiles > DotfilesDeny { + panic("static: Dotfiles must be DotfilesIgnore, DotfilesAllow or DotfilesDeny") + } } diff --git a/middleware/static/dotfiles_888_test.go b/middleware/static/dotfiles_888_test.go index d5eb90633..4b9aa97e0 100644 --- a/middleware/static/dotfiles_888_test.go +++ b/middleware/static/dotfiles_888_test.go @@ -119,7 +119,8 @@ var dotCases888 = []dotCase888{ // symlinks: a visible name that leads to a dotfile or directory of the tree, and a hidden name {"/link-env", true, "ENV"}, {"/link-dir/config", true, "GITCFG"}, {"/.dot-link", true, "A"}, {"/chain1", true, "ENV"}, {"/chain2", true, "ENV"}, {"/lnkrel", true, "ENV"}, {"/lnkabs", true, "ENV"}, - {"/.well-known/g/config", true, "GITCFG"}, {"/link-dir/", true, ""}, {"/ok-abs", false, "A"}, + // (a directory link to a hidden directory, with no index and no listing, is no file: passed on) + {"/.well-known/g/config", true, "GITCFG"}, {"/link-dir/", false, ""}, {"/ok-abs", false, "A"}, // a literal backslash is part of the name on these systems (not a separator) {`/.git/..\x`, true, "BSX"}, {`/.s\..\y`, true, "BSY"}, {`/sub/..\.git/config`, true, ""}, // visible diff --git a/middleware/static/dotfiles_r2_888_test.go b/middleware/static/dotfiles_r2_888_test.go index df51ca58e..0d55087c6 100644 --- a/middleware/static/dotfiles_r2_888_test.go +++ b/middleware/static/dotfiles_r2_888_test.go @@ -138,8 +138,9 @@ func TestServerStaticMissingIs404_888(t *testing.T) { // TestDotDotRootCostsAboutADotRoot888 (R2-F6): a Root of ".." segments is // resolved once, when the middleware is made, so a request costs what it costs -// under any relative Root, not five times that (Getwd and EvalSymlinks of the -// working directory in every containment check). +// under an absolute Root (the same work), not four times that (Getwd and +// EvalSymlinks of the working directory in every containment check: 388 +// allocations against 98 on the review's measurement). func TestDotDotRootCostsAboutADotRoot888(t *testing.T) { skipWindows888(t) d := newDotTree888(t) // the working directory is T/p/c @@ -154,10 +155,10 @@ func TestDotDotRootCostsAboutADotRoot888(t *testing.T) { celeristest.ReleaseContext(ctx) }) } - dot := cost(".", "/a.txt") + dot := cost(d.cwd, "/a.txt") // an absolute Root: what a resolved ".." root is for _, tc := range []struct{ root, path string }{{"..", "/c/a.txt"}, {"../c", "/a.txt"}, {"x/..", "/a.txt"}, {"../..", "/p/c/a.txt"}} { if got := cost(tc.root, tc.path); got > dot+8 { - t.Errorf("Root %q: %.0f allocations per request, Root \".\" costs %.0f", tc.root, got, dot) + t.Errorf("Root %q: %.0f allocations per request, an absolute Root costs %.0f", tc.root, got, dot) } } // a working directory that changes after New is not followed by a resolved root diff --git a/middleware/static/static.go b/middleware/static/static.go index e48c5f1a3..c14ff1321 100644 --- a/middleware/static/static.go +++ b/middleware/static/static.go @@ -134,7 +134,9 @@ func New(config ...Config) celeris.HandlerFunc { // Precompute cleanRoot once instead of per request. var cleanRoot string if root != "" { - cleanRoot = filepath.Clean(root) + // A root made of ".." segments is resolved against the working + // directory here, once (see ctxkit.ResolveClimbingRoot). + cleanRoot = ctxkit.ResolveClimbingRoot(filepath.Clean(root)) } // Precompute the cache-control header once — maxAge is closure-constant, @@ -184,28 +186,38 @@ func New(config ...Config) celeris.HandlerFunc { // the index in SPA mode, the next handler otherwise (DotfilesIgnore), or // refuses it (DotfilesDeny). A hidden path is decided before the file // system is looked at, so what is answered does not tell whether it exists. - notFound := func() error { - if dotfiles == DotfilesDeny { - return celeris.NewHTTPError(403, "Forbidden") - } + missing := func() error { switch { case spa && fsys != nil: - return serveFS(c, fsys, index, index, browse, false, compress, cacheControl, &fsCache, true) + return serveFS(c, fsys, index, index, browse, false, compress, cacheControl, &fsCache, hidden) case spa: return c.FileFromDir(cleanRoot, index) } return c.Next() } + notFound := func() error { + if dotfiles == DotfilesDeny { + return celeris.NewHTTPError(403, "Forbidden") + } + return missing() + } if hidden != nil && ctxkit.HiddenPath(filePath) { return notFound() } + var err error if fsys != nil { - return serveFS(c, fsys, filePath, index, browse, spa, compress, cacheControl, &fsCache, hidden != nil) + err = serveFS(c, fsys, filePath, index, browse, spa, compress, cacheControl, &fsCache, hidden) + } else { + err = serveOS(c, cleanRoot, filePath, index, browse, spa, cacheControl, compress, hidden) } - err := serveOS(c, cleanRoot, filePath, index, browse, spa, cacheControl, compress, hidden) - if errors.Is(err, ctxkit.ErrHidden) { + switch { + case err == nil: + case errors.Is(err, ctxkit.ErrHidden): return notFound() + case errors.Is(err, ctxkit.ErrNotRegular): + // a device, a FIFO or a socket is answered as a missing file is + return missing() } return err } @@ -238,7 +250,7 @@ func serveOS(c *celeris.Context, cleanRoot, filePath, index string, browse, spa // Try index file in the directory. indexPath := filepath.Join(fullPath, index) indexInfo, indexErr := os.Stat(indexPath) - if indexErr == nil && !indexInfo.IsDir() { + if indexErr == nil && indexInfo.Mode().IsRegular() { filePath = filepath.Join(filePath, index) // fullPath follows the file served: its pre-compressed variant is // "/index.html.gz", never ".gz" (a sibling) or, for the @@ -271,6 +283,15 @@ func serveOS(c *celeris.Context, cleanRoot, filePath, index string, browse, spa } } + if !info.Mode().IsRegular() { + // a device (/dev/zero reads without end), a FIFO (open waits for a + // writer), a socket: not served, answered as a file that is not there + if spa { + return c.FileFromDir(cleanRoot, index) + } + return c.Next() + } + if serveOSHook != nil { serveOSHook() } @@ -445,31 +466,99 @@ func servePreCompressed(c *celeris.Context, cleanRoot, filePath, fullPath, cache continue } vi, err := os.Stat(fullPath + v.suffix) - if err != nil { + if err != nil || !vi.Mode().IsRegular() { continue } ct := mime.TypeByExtension(filepath.Ext(filePath)) if ct == "" { ct = "application/octet-stream" } - return true, serveOSFile(c, cleanRoot, filePath+v.suffix, ct, v.encoding, cacheControl, vi, hidden) + err = serveOSFile(c, cleanRoot, filePath+v.suffix, ct, v.encoding, cacheControl, vi, hidden) + if errors.Is(err, ctxkit.ErrHidden) || errors.Is(err, ctxkit.ErrNotRegular) { + // a variant that is a symlink to a hidden file, or not a regular + // file, is no variant: the file itself is served + continue + } + return true, err } return false, nil } +// resolveFSLinks returns name, a slash path below the FS root, with every +// symlink on it replaced by its target, as the operating system would walk it: +// the path the file is really at, for the hidden check. A link whose target is +// absolute leaves the FS: the result is then the target, with the rest of the +// path after it, and its own segments are what counts. A path that climbs above +// the FS root keeps its "..", which the check cleans away. More than 40 links +// on the way (a loop) resolve to a hidden path: refused. +func resolveFSLinks(rl fs.ReadLinkFS, name string) string { + queue := strings.Split(name, "/") + var resolved []string + for follows := 0; len(queue) > 0; { + seg := queue[0] + queue = queue[1:] + switch seg { + case "", ".": + continue + case "..": + if n := len(resolved); n > 0 && resolved[n-1] != ".." { + resolved = resolved[:n-1] + } else { + resolved = append(resolved, "..") + } + continue + } + cand := strings.Join(append(resolved, seg), "/") + fi, err := rl.Lstat(cand) + if err != nil || fi.Mode()&fs.ModeSymlink == 0 { + resolved = append(resolved, seg) + continue + } + if follows++; follows > 40 { + return ".loop" + } + target, err := rl.ReadLink(cand) + if err != nil { + return ".unreadable-link" + } + target = filepath.ToSlash(target) + if strings.HasPrefix(target, "/") || filepath.IsAbs(target) { + return target + "/" + strings.Join(queue, "/") + } + queue = append(strings.Split(target, "/"), queue...) + } + return strings.Join(resolved, "/") +} + // serveFS serves a file from an fs.FS with ETag/Last-Modified and range support. -func serveFS(c *celeris.Context, fsys fs.FS, filePath, index string, browse, spa, compress bool, cacheControl string, cache *sync.Map, hide bool) error { +func serveFS(c *celeris.Context, fsys fs.FS, filePath, index string, browse, spa, compress bool, cacheControl string, cache *sync.Map, hidden func(rel string) bool) error { // 1. Open + stat + directory handling. openPath := filePath if openPath == "" { openPath = "." } + // Where the path leads, for an FS that can resolve symlinks (an os.DirFS): + // a visible name that links to a hidden file or directory is refused like + // the hidden name (an FS that cannot, an embed.FS or an fstest.MapFS + // without links, has none to follow). + if hidden != nil && filePath != "" { + if rl, ok := fsys.(fs.ReadLinkFS); ok && hidden(resolveFSLinks(rl, filePath)) { + return ctxkit.ErrHidden + } + } + // Before the open: for an os.DirFS, open(2) of a FIFO waits for a writer. + if _, ok := fsys.(fs.StatFS); ok { + if info, err := fs.Stat(fsys, openPath); err == nil && !info.IsDir() && !info.Mode().IsRegular() { + return ctxkit.ErrNotRegular + } + } + f, err := fsys.Open(openPath) if err != nil { if spa { - return serveFS(c, fsys, index, index, browse, false, compress, cacheControl, cache, hide) + return serveFS(c, fsys, index, index, browse, false, compress, cacheControl, cache, hidden) } return c.Next() } @@ -493,15 +582,19 @@ func serveFS(c *celeris.Context, fsys fs.FS, filePath, index string, browse, spa indexStat, statErr := indexFile.Stat() _ = indexFile.Close() if statErr == nil && !indexStat.IsDir() { - return serveFS(c, fsys, indexPath, index, browse, spa, compress, cacheControl, cache, hide) + return serveFS(c, fsys, indexPath, index, browse, spa, compress, cacheControl, cache, hidden) } } if browse { - return serveDirListingFS(c, fsys, openPath, hide) + return serveDirListingFS(c, fsys, openPath, hidden != nil) } return c.Next() } + if !stat.Mode().IsRegular() { + return ctxkit.ErrNotRegular + } + // 2. Size cap check. size := stat.Size() if size > maxFSFileSize { @@ -512,7 +605,7 @@ func serveFS(c *celeris.Context, fsys fs.FS, filePath, index string, browse, spa // representation: it brings its own validators, 304 and range, so it is // tried before the original's validators are set. if compress { - if served, err := servePreCompressedFS(c, fsys, filePath, cacheControl); served { + if served, err := servePreCompressedFS(c, fsys, filePath, cacheControl, hidden); served { return err } } @@ -621,7 +714,7 @@ func computeFSCacheStrings(modTime time.Time, size int64) (etag, lastModifiedStr // client gets, so the Last-Modified, ETag, 304 and range are its own // (celeris#846). Returns (true, err) if a compressed variant was served, // (false, nil) to fall through to normal serving. -func servePreCompressedFS(c *celeris.Context, fsys fs.FS, filePath, cacheControl string) (bool, error) { +func servePreCompressedFS(c *celeris.Context, fsys fs.FS, filePath, cacheControl string, hidden func(rel string) bool) (bool, error) { if c.Header("accept-encoding") == "" { return false, nil } @@ -640,12 +733,23 @@ func servePreCompressedFS(c *celeris.Context, fsys fs.FS, filePath, cacheControl continue } compPath := filePath + v.suffix + if hidden != nil { + // a variant that is a symlink to a hidden file is no variant + if rl, ok := fsys.(fs.ReadLinkFS); ok && hidden(resolveFSLinks(rl, compPath)) { + continue + } + } + if _, ok := fsys.(fs.StatFS); ok { + if info, err := fs.Stat(fsys, compPath); err != nil || !info.Mode().IsRegular() { + continue + } + } f, err := fsys.Open(compPath) if err != nil { continue } stat, statErr := f.Stat() - if statErr != nil || stat.IsDir() || stat.Size() > maxFSFileSize { + if statErr != nil || !stat.Mode().IsRegular() || stat.Size() > maxFSFileSize { _ = f.Close() continue } diff --git a/server.go b/server.go index 73a0c6fed..0fb3e94f9 100644 --- a/server.go +++ b/server.go @@ -347,6 +347,7 @@ func (s *Server) OnShutdown(fn func(ctx context.Context)) *Server { // s.Static("/assets", "./public") func (s *Server) Static(prefix, root string) *Route { p := strings.TrimRight(prefix, "/") + "/*filepath" + root = staticRoot(root) return s.GET(p, func(c *Context) error { return c.serveStaticFile(root, c.Param("filepath")) }) From d0a8436ddcd65733d6554ef055c57b1091595da1 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 11:07:06 +0100 Subject: [PATCH 17/26] static: the Config.AllowFilesystemRoot field, with no behaviour yet (the API and its golden; the tests that serve a filesystem root set it) (Refs #888) --- api/middleware/static.txt | 3 ++- middleware/static/config.go | 8 ++++++++ middleware/static/nonregular_888_test.go | 14 ++++++-------- middleware/static/root_dotdot_888_test.go | 7 +++++-- middleware/static/root_spellings_888_test.go | 3 ++- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/api/middleware/static.txt b/api/middleware/static.txt index 69e190b40..e86a76683 100644 --- a/api/middleware/static.txt +++ b/api/middleware/static.txt @@ -7,6 +7,7 @@ const DotfilesDeny DotfilesPolicy = 2 const DotfilesIgnore DotfilesPolicy = 0 func New(...Config) github.com/goceleris/celeris.HandlerFunc type Config struct +type Config struct, AllowFilesystemRoot bool type Config struct, Browse bool type Config struct, Compress bool type Config struct, Dotfiles DotfilesPolicy @@ -18,6 +19,6 @@ type Config struct, Root string type Config struct, SPA bool type Config struct, Skip func(*github.com/goceleris/celeris.Context) bool type Config struct, SkipPaths []string -type Config struct, field order Skip, SkipPaths, Root, FS, Prefix, Index, Browse, SPA, MaxAge, Compress, Dotfiles +type Config struct, field order Skip, SkipPaths, Root, FS, Prefix, Index, Browse, SPA, MaxAge, Compress, Dotfiles, AllowFilesystemRoot type Config struct, not comparable type DotfilesPolicy uint8 diff --git a/middleware/static/config.go b/middleware/static/config.go index 840d1fc87..6b75eb637 100644 --- a/middleware/static/config.go +++ b/middleware/static/config.go @@ -127,6 +127,14 @@ type Config struct { // is the operator's choice and is served even if its name starts with a dot. // The zero value is [DotfilesIgnore]. Dotfiles DotfilesPolicy + + // AllowFilesystemRoot acknowledges a Root that resolves to the root of a + // filesystem ("/", "." when the working directory is "/", "../../.." + // reaching it, a symlink to "/", a drive root such as C:\ on Windows). It + // is refused at New, with a panic that names the resolved path, unless this + // is true: such a Root serves every file the process can read below it, and + // nothing is outside it any more. Default: false. + AllowFilesystemRoot bool } var defaultConfig = Config{ diff --git a/middleware/static/nonregular_888_test.go b/middleware/static/nonregular_888_test.go index 3f68f8a7f..4629694be 100644 --- a/middleware/static/nonregular_888_test.go +++ b/middleware/static/nonregular_888_test.go @@ -142,7 +142,7 @@ func TestNonRegularFilesAreNotServed888(t *testing.T) { cfgs := []cfg{ {"abs-root", func(t *testing.T) celeris.HandlerFunc { return New(Config{Root: st.dir}) }, rel}, {"dot-root", func(t *testing.T) celeris.HandlerFunc { t.Chdir(st.dir); return New(Config{Root: "."}) }, rel}, - {"slash-root", func(t *testing.T) celeris.HandlerFunc { return New(Config{Root: "/"}) }, func(p string) string { return st.dir + p }}, + {"slash-root", func(t *testing.T) celeris.HandlerFunc { return New(Config{Root: "/", AllowFilesystemRoot: true}) }, func(p string) string { return st.dir + p }}, {"dirfs", func(t *testing.T) celeris.HandlerFunc { return New(Config{FS: os.DirFS(st.dir)}) }, rel}, {"spa", func(t *testing.T) celeris.HandlerFunc { return New(Config{Root: st.dir, SPA: true}) }, rel}, } @@ -234,7 +234,7 @@ func TestDeviceFilesStatic888(t *testing.T) { for _, root := range []string{"/", "."} { t.Run(root, func(t *testing.T) { t.Chdir("/") - mw := New(Config{Root: root}) + mw := New(Config{Root: root, AllowFilesystemRoot: true}) for _, p := range []string{"/dev/zero", "/dev/null", "/dev/urandom", "/dev/random", "/dev/full"} { rec := within888(t, "GET "+p, 10*time.Second, func() *celeristest.ResponseRecorder { rec, _ := get888(t, mw, p) @@ -297,8 +297,8 @@ func TestDeviceFilesExplicitAPIs888(t *testing.T) { } // TestDeviceFilesOverTheWire888: the exact request of the review, over the -// engines: Root "." with a working directory of "/", Root "/", Server.Static -// and Group.Static over "." — GET /dev/zero is a 404 and the server stays up. +// engines: Root "." with a working directory of "/" and Root "/" (acknowledged +// with AllowFilesystemRoot) — GET /dev/zero is a 404 and the server stays up. func TestDeviceFilesOverTheWire888(t *testing.T) { deviceCellsOK888(t) guardMemory888(t, 256<<20) @@ -309,10 +309,8 @@ func TestDeviceFilesOverTheWire888(t *testing.T) { } for _, eng := range wireEngines888 { for _, e := range []entry{ - {"middleware-dot", "", func(s *celeris.Server) { s.Use(New(Config{Root: "."})) }}, - {"middleware-slash", "", func(s *celeris.Server) { s.Use(New(Config{Root: "/"})) }}, - {"Server.Static-dot", "/files", func(s *celeris.Server) { s.Static("/files", ".") }}, - {"Group.Static-slash", "/g/files", func(s *celeris.Server) { s.Group("/g").Static("/files", "/") }}, + {"middleware-dot", "", func(s *celeris.Server) { s.Use(New(Config{Root: ".", AllowFilesystemRoot: true})) }}, + {"middleware-slash", "", func(s *celeris.Server) { s.Use(New(Config{Root: "/", AllowFilesystemRoot: true})) }}, } { t.Run(fmt.Sprintf("%v/%s", eng, e.name), func(t *testing.T) { raw := wire888(t, eng, e.reg) diff --git a/middleware/static/root_dotdot_888_test.go b/middleware/static/root_dotdot_888_test.go index 7cf51cbdc..c44cb9b8e 100644 --- a/middleware/static/root_dotdot_888_test.go +++ b/middleware/static/root_dotdot_888_test.go @@ -171,7 +171,7 @@ func eachRequest888(d dotTree888, sp rootSpelling888, fn func(path string, r req func TestDotDotRootsMiddleware888(t *testing.T) { d := newDotTree888(t) for _, sp := range d.spellings() { - mw := New(Config{Root: sp.root}) + mw := New(Config{Root: sp.root, AllowFilesystemRoot: sp.root == "/"}) eachRequest888(d, sp, func(path string, r req888, hostile bool) { rec, _ := get888(t, mw, path) d.verdict(t, "static", sp, r, hostile, rec.StatusCode, string(rec.Body)) @@ -268,7 +268,7 @@ func TestDotDotRootsOverTheWire888(t *testing.T) { } entries := []entry{ {"middleware", "", func(root string) func(s *celeris.Server) { - return func(s *celeris.Server) { s.Use(New(Config{Root: root})) } + return func(s *celeris.Server) { s.Use(New(Config{Root: root, AllowFilesystemRoot: root == "/"})) } }}, {"Server.Static", "/files", func(root string) func(s *celeris.Server) { return func(s *celeris.Server) { s.Static("/files", root) } @@ -280,6 +280,9 @@ func TestDotDotRootsOverTheWire888(t *testing.T) { for _, eng := range wireEngines888 { for _, e := range entries { for _, sp := range d.spellings() { + if sp.root == "/" && e.prefix != "" { + continue // Server.Static and Group.Static refuse a filesystem root (TestFilesystemRootIsRefused888) + } t.Run(fmt.Sprintf("%v/%s/%s", eng, e.name, sp.root), func(t *testing.T) { raw := wire888(t, eng, e.reg(sp.root)) eachRequest888(d, sp, func(path string, r req888, hostile bool) { diff --git a/middleware/static/root_spellings_888_test.go b/middleware/static/root_spellings_888_test.go index c47c010fa..7bf9c0cc4 100644 --- a/middleware/static/root_spellings_888_test.go +++ b/middleware/static/root_spellings_888_test.go @@ -104,6 +104,7 @@ func (s spelling888) open888(t *testing.T, r root888, cfg Config) celeris.Handle t.Chdir(r.root) } cfg.Root = s.root + cfg.AllowFilesystemRoot = s.root == "/" // "/" is a filesystem root: acknowledged return New(cfg) } @@ -291,7 +292,7 @@ func TestRootSlashHasNoOutside888(t *testing.T) { t.Skip("Root \"/\" is the current volume's root on Windows") } r := tree888(t) - mw := New(Config{Root: "/"}) + mw := New(Config{Root: "/", AllowFilesystemRoot: true}) base := strings.TrimSuffix(filepath.ToSlash(r.root), "/") for _, p := range []string{ base + "/esc-abs", From 8db4892a876f72abe933302352748f05d1c3de98 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 11:08:07 +0100 Subject: [PATCH 18/26] tests(static, ctxkit): a Root that resolves to a filesystem root is refused at New and at Server.Static / Group.Static unless Config.AllowFilesystemRoot is set (Refs #888) Red against the field with no behaviour (docker --cpus 2 -race): TestFilesystemRootIsRefused888 (12 spellings: '/', '//', '/.', '.' at '/', '..' from /usr, '../../..' up to and beyond '/', symlinks to '/'), TestStaticRoutesRefuseAFilesystemRoot888, ctxkit TestIsRootOf (Windows drive and UNC rows) and TestFilesystemRoot: 16 FAIL lines, 5 PASS (the ordinary roots and the acknowledged cases, which pass before and after). --- internal/ctxkit/fsroot.go | 6 + internal/ctxkit/fsroot_test.go | 79 +++++++ middleware/static/filesystem_root_888_test.go | 204 ++++++++++++++++++ 3 files changed, 289 insertions(+) create mode 100644 internal/ctxkit/fsroot.go create mode 100644 internal/ctxkit/fsroot_test.go create mode 100644 middleware/static/filesystem_root_888_test.go diff --git a/internal/ctxkit/fsroot.go b/internal/ctxkit/fsroot.go new file mode 100644 index 000000000..95fd3196c --- /dev/null +++ b/internal/ctxkit/fsroot.go @@ -0,0 +1,6 @@ +package ctxkit + +// FilesystemRoot is not implemented yet: the tests of the refusal come first. +func FilesystemRoot(root string) (string, bool) { return "", false } + +func isRootOf(vol, rest string, sep byte) bool { return false } diff --git a/internal/ctxkit/fsroot_test.go b/internal/ctxkit/fsroot_test.go new file mode 100644 index 000000000..b989007d0 --- /dev/null +++ b/internal/ctxkit/fsroot_test.go @@ -0,0 +1,79 @@ +package ctxkit + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// isRootOf is the OS-independent part of the filesystem-root test: what is left +// of a path once its volume name is split off. These rows include the Windows +// spellings, so they run everywhere. +func TestIsRootOf(t *testing.T) { + for _, tc := range []struct { + name string + vol, rest string + sep byte + wantIsRoot bool + }{ + {"unix root", "", "/", '/', true}, + {"unix dir", "", "/usr", '/', false}, + {"unix empty", "", "", '/', false}, + {"unix two slashes", "", "//", '/', false}, // not a cleaned path + {"drive root", `C:`, `\`, '\\', true}, + {"drive dir", `C:`, `\Users`, '\\', false}, + {"drive relative", `C:`, ``, '\\', false}, + {"drive relative dir", `C:`, `x`, '\\', false}, + {"unc root with slash", `\\host\share`, `\`, '\\', true}, + {"unc root", `\\host\share`, ``, '\\', true}, + {"unc dir", `\\host\share`, `\dir`, '\\', false}, + {"device root", `\\?\C:`, `\`, '\\', true}, + {"device dir", `\\?\C:`, `\x`, '\\', false}, + } { + if got := isRootOf(tc.vol, tc.rest, tc.sep); got != tc.wantIsRoot { + t.Errorf("%s: isRootOf(%q, %q) = %v, want %v", tc.name, tc.vol, tc.rest, got, tc.wantIsRoot) + } + } +} + +func TestFilesystemRoot(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("slash paths") + } + base, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + wd := filepath.Join(base, "a", "b") + if err := os.MkdirAll(wd, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(base, "to-root") + if err := os.Symlink("/", link); err != nil { + t.Skip(err) + } + t.Chdir(wd) + up := "../../.." + for range strings.Count(wd, "/") { + up += "/.." + } + for _, tc := range []struct { + root string + want bool + }{ + {"/", true}, {"/.", true}, {"//", true}, {"/..", true}, {link, true}, {link + "/..", true}, {up, true}, + {".", false}, {"..", false}, {"../..", false}, {wd, false}, {"/etc", false}, {"/no/such", false}, {"sub", false}, + } { + got, ok := FilesystemRoot(filepath.Clean(tc.root)) + if ok != tc.want || (ok && got != "/") { + t.Errorf("FilesystemRoot(%q) = %q, %v; want root %v", tc.root, got, ok, tc.want) + } + } + // "." at "/" + t.Chdir("/") + if got, ok := FilesystemRoot("."); !ok || got != "/" { + t.Errorf(`FilesystemRoot(".") at "/" = %q, %v`, got, ok) + } +} diff --git a/middleware/static/filesystem_root_888_test.go b/middleware/static/filesystem_root_888_test.go new file mode 100644 index 000000000..7c43fedca --- /dev/null +++ b/middleware/static/filesystem_root_888_test.go @@ -0,0 +1,204 @@ +package static + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/goceleris/celeris" +) + +// A Root that resolves to the root of a filesystem is refused at construction +// unless Config.AllowFilesystemRoot is set (celeris#888 item 46, the maintainer's +// decision after the security review): "/" and "." in a working directory of "/" +// (the systemd default, a container without a WORKDIR) serve every file the +// process can read, and no released version served anything below such a root, +// so no working deployment depends on it. The check is on the RESOLVED root, so +// "." at "/", "../../.." reaching "/" and a symlink to "/" do not get through; +// it is made when New (or Server.Static) is called, and a chdir after that is +// not covered. Server.Static and Group.Static have no acknowledgement field and +// refuse such a root; middleware/static is the way to serve one on purpose. + +func panicOf888(f func()) (v any) { + defer func() { v = recover() }() + f() + return nil +} + +func skipFSRoot888(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("the filesystem root of the tests is \"/\"") + } +} + +// deepDir888 makes a working directory several levels below "/" and returns the +// number of levels from "/" (with symlinks resolved). +func deepDir888(t *testing.T) (dir string, depth int) { + t.Helper() + base, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + dir = filepath.Join(base, "a", "b", "c") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + return dir, len(strings.Split(strings.Trim(dir, "/"), "/")) +} + +func TestFilesystemRootIsRefused888(t *testing.T) { + skipFSRoot888(t) + _, depth := deepDir888(t) + link := filepath.Join(t.TempDir(), "to-root") + if err := os.Symlink("/", link); err != nil { + t.Skipf("symlinks are not available here: %v", err) + } + up := strings.TrimSuffix(strings.Repeat("../", depth), "/") + for _, tc := range []struct { + name, root, cwd string + }{ + {"slash", "/", ""}, + {"slash-slash", "//", ""}, + {"slash-dot", "/.", ""}, + {"slash-dotdot", "/../..", ""}, + {"dot-at-slash", ".", "/"}, + {"dot-slash-at-slash", "./", "/"}, + {"dotdot-from-below-slash", "..", "/usr"}, + {"up-to-slash", up, ""}, + {"up-beyond-slash", up + "/../../..", ""}, + {"symlink-to-slash", link, ""}, + {"symlink-to-slash-slash", link + "/", ""}, + {"symlink-to-slash-dotdot", link + "/..", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.cwd != "" { + if _, err := os.Stat(tc.cwd); err != nil { + t.Skip("no " + tc.cwd) + } + t.Chdir(tc.cwd) + } + r := panicOf888(func() { New(Config{Root: tc.root}) }) + msg, _ := r.(string) + if msg == "" { + t.Fatalf("New(Root %q) did not panic (cwd %q)", tc.root, tc.cwd) + } + for _, want := range []string{`"/"`, "AllowFilesystemRoot", "static:"} { + if !strings.Contains(msg, want) { + t.Errorf("the panic %q lacks %q (the resolved root and the field must be named)", msg, want) + } + } + // acknowledged, it is built + if r := panicOf888(func() { New(Config{Root: tc.root, AllowFilesystemRoot: true}) }); r != nil { + t.Errorf("New(Root %q, AllowFilesystemRoot) panicked: %v", tc.root, r) + } + }) + } +} + +func TestOrdinaryRootsAreNotRefused888(t *testing.T) { + skipFSRoot888(t) + dir, _ := deepDir888(t) + sub := filepath.Join(dir, "sub") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(t.TempDir(), "to-dir") + if err := os.Symlink(dir, link); err != nil { + t.Skipf("symlinks are not available here: %v", err) + } + for _, root := range []string{".", "./", "sub", "./sub", "..", "../..", "../../..", dir, sub, dir + "/", link, "/tmp", "/etc", "/no/such/dir", "x/.."} { + if r := panicOf888(func() { New(Config{Root: root}) }); r != nil { + t.Errorf("New(Root %q) panicked: %v", root, r) + } + } + // FS has no root: a Root next to it is ignored, as it always was + if r := panicOf888(func() { New(Config{FS: os.DirFS("/")}) }); r != nil { + t.Errorf("New(FS: os.DirFS(/)) panicked: %v", r) + } + if r := panicOf888(func() { New(Config{FS: os.DirFS(dir), Root: "/"}) }); r != nil { + t.Errorf("New(FS and Root \"/\") panicked: %v (FS takes precedence)", r) + } +} + +// TestFilesystemRootAcknowledged888: with the acknowledgement the root serves +// (a regular file), and the other rules stay: hidden files are not served, nor a +// FIFO, nor a symlink that leads to a hidden file. +func TestFilesystemRootAcknowledged888(t *testing.T) { + skipFSRoot888(t) + st := newSpecialTree888(t) + if err := os.WriteFile(filepath.Join(st.dir, ".env"), []byte("ENV"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(".env", filepath.Join(st.dir, "lnk-env")); err != nil { + t.Skipf("symlinks are not available here: %v", err) + } + for _, root := range []string{"/", "."} { + t.Run(root, func(t *testing.T) { + t.Chdir("/") + mw := New(Config{Root: root, AllowFilesystemRoot: true}) + if o, _ := outcomeOf888(t, mw, st.dir+"/reg.txt"); o.status != 200 || o.body != "REG" { + t.Errorf("GET reg.txt: %v, want 200 REG", o) + } + for _, p := range []string{"/.env", "/lnk-env", "/fifo", "/lnk-fifo", "/sock"} { + if o, _ := outcomeOf888(t, mw, st.dir+p); o.kind != "passed-on" { + t.Errorf("GET %s under an acknowledged filesystem root: %v, want it passed on", p, o) + } + } + deny := New(Config{Root: root, AllowFilesystemRoot: true, Dotfiles: DotfilesDeny}) + if _, err := get888(t, deny, st.dir+"/.env"); err == nil { + t.Errorf("Deny: GET .env returned no error") + } + }) + } +} + +// TestStaticRoutesRefuseAFilesystemRoot888: Server.Static and Group.Static have no +// acknowledgement field: they panic at registration, and name middleware/static. +func TestStaticRoutesRefuseAFilesystemRoot888(t *testing.T) { + skipFSRoot888(t) + dir, depth := deepDir888(t) + link := filepath.Join(t.TempDir(), "to-root") + if err := os.Symlink("/", link); err != nil { + t.Skipf("symlinks are not available here: %v", err) + } + up := strings.TrimSuffix(strings.Repeat("../", depth), "/") + for _, root := range []string{"/", "//", up, link} { + for name, reg := range map[string]func(root string){ + "Server.Static": func(root string) { celeris.New(celeris.Config{}).Static("/files", root) }, + "Group.Static": func(root string) { celeris.New(celeris.Config{}).Group("/g").Static("/files", root) }, + } { + r := panicOf888(func() { reg(root) }) + msg, _ := r.(string) + if msg == "" || !strings.Contains(msg, "middleware/static") || !strings.Contains(msg, "AllowFilesystemRoot") || !strings.Contains(msg, `"/"`) { + t.Errorf("%s(root %q) panicked with %v, want a message that names the resolved root, middleware/static and AllowFilesystemRoot", name, root, r) + } + } + } + t.Chdir("/") + if r := panicOf888(func() { celeris.New(celeris.Config{}).Static("/files", ".") }); r == nil { + t.Errorf("Server.Static(., ) at a working directory of / did not panic") + } + for _, root := range []string{dir, ".", "..", "sub"} { + if root != dir { + continue // from "/" the others are the filesystem root or relative to it + } + if r := panicOf888(func() { celeris.New(celeris.Config{}).Static("/files", root) }); r != nil { + t.Errorf("Server.Static(%q) panicked: %v", root, r) + } + } + if r := panicOf888(func() { celeris.New(celeris.Config{}).Static("/files", "") }); r != nil { + t.Errorf("Server.Static with an empty root panicked: %v (it serves nothing)", r) + } +} + +func ExampleConfig_allowFilesystemRoot() { + // A filesystem root served on purpose, acknowledged. + _ = New(Config{Root: "/", AllowFilesystemRoot: true}) + fmt.Println("ok") + // Output: ok +} From dab98b6f3b74111cf263103c1484e533000552e9 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 11:09:00 +0100 Subject: [PATCH 19/26] fix(static, celeris): a Root that resolves to a filesystem root is refused at New, Server.Static and Group.Static unless Config.AllowFilesystemRoot is set; docs: not a secrets filter, not a sandbox, non-regular files, FS symlinks, the root is resolved at New (Refs #888) Release note: Root resolving to the filesystem root is refused unless Config.AllowFilesystemRoot is set; nothing in any released version served such a root. --- context_response.go | 13 +++++- group.go | 14 ++++-- internal/ctxkit/fsroot.go | 37 +++++++++++++-- internal/ctxkit/fsroot_test.go | 3 +- middleware/static/config.go | 46 ++++++++++++------- middleware/static/doc.go | 39 +++++++++++----- middleware/static/filesystem_root_888_test.go | 1 - middleware/static/static.go | 10 ++++ server.go | 14 ++++-- 9 files changed, 133 insertions(+), 44 deletions(-) diff --git a/context_response.go b/context_response.go index f97f068e1..4bf07002d 100644 --- a/context_response.go +++ b/context_response.go @@ -943,7 +943,12 @@ func staticRoot(root string) string { if root == "" { return root } - return ctxkit.ResolveClimbingRoot(filepath.Clean(root)) + root = ctxkit.ResolveClimbingRoot(filepath.Clean(root)) + if resolved, ok := ctxkit.FilesystemRoot(root); ok { + panic(fmt.Sprintf("celeris: Static root %q resolves to the filesystem root %q, which serves every file the process can read: "+ + "name the directory that holds the public files, or serve a filesystem root on purpose with middleware/static and Config.AllowFilesystemRoot", root, resolved)) + } + return root } // errNotRegular is the error for a path that is not a regular file (a device, a @@ -1125,7 +1130,11 @@ func (c *Context) trySendFile(f *os.File, status int, contentType string, offset // baseDir of ".." served any file the process could read). Hidden files // (names that start with a dot) are served: the caller chooses the path, and // the dotfile rule of middleware/static, Server.Static and Group.Static does not -// apply here. +// apply here, and neither does the refusal of a base that is the root of a +// filesystem: a baseDir of "/" serves every file the process can read to a +// userPath the caller got from a client, so choose the path with care. A device, +// a FIFO or a socket is not served: it is an HTTPError 404 "not a regular +// file". func (c *Context) FileFromDir(baseDir, userPath string) error { return c.fileFromDir(baseDir, userPath, "", nil, nil) } diff --git a/group.go b/group.go index 120eb4142..2b38529a2 100644 --- a/group.go +++ b/group.go @@ -124,11 +124,15 @@ func (g *RouteGroup) Handle(method, path string, handlers ...HandlerFunc) *Route // Static registers a GET handler that serves files from root under the given // prefix. Uses FileFromDir for path traversal protection. root may be ".", -// "./" or "/" (from v1.6.0; before, every file below them was refused): "." is -// the working directory and "/" the whole filesystem, so name a directory that -// holds only what is public. A root made of ".." segments means the directory -// it resolves to, and nothing outside it is served. An empty root serves -// nothing. Files and directories whose name starts with a dot (.env, .git) are +// "./" (from v1.6.0; before, every file below them was refused): "." is the +// working directory, so name a directory that holds only what is public. A root +// that resolves to the root of a filesystem ("/", "." when the working directory +// is "/", a symlink to "/") panics here, with no way to acknowledge it: serve a +// filesystem root on purpose with middleware/static and AllowFilesystemRoot. A +// root made of ".." segments means the directory it resolves to, resolved once +// when the route is registered, and nothing outside it is served. An empty root +// serves nothing. A file that is not there is a 404; devices, FIFOs and sockets +// are not served. Files and directories whose name starts with a dot (.env, .git) are // not served, and a path to one, or to a symlink that leads to one, is a 404 // whether or not it exists; the exact segment ".well-known" is served. There is // no opt-out here: use middleware/static with Dotfiles: static.DotfilesAllow. diff --git a/internal/ctxkit/fsroot.go b/internal/ctxkit/fsroot.go index 95fd3196c..6fa5c7735 100644 --- a/internal/ctxkit/fsroot.go +++ b/internal/ctxkit/fsroot.go @@ -1,6 +1,37 @@ package ctxkit -// FilesystemRoot is not implemented yet: the tests of the refusal come first. -func FilesystemRoot(root string) (string, bool) { return "", false } +import ( + "path/filepath" +) -func isRootOf(vol, rest string, sep byte) bool { return false } +// FilesystemRoot reports whether root, a cleaned path (relative or absolute), resolves to +// the root of a filesystem: "/", a drive root such as C:\, a UNC share root. The +// root is made absolute against the working directory, and its symlinks are +// resolved, so "." in a working directory of "/", "../../.." reaching "/" and a +// symlink to "/" are found; a root that does not exist, or cannot be resolved, is +// not one (it serves nothing). It returns the resolved path. The working directory +// is read now: a later chdir is not covered. +func FilesystemRoot(root string) (string, bool) { + abs, err := absPhysical(filepath.Clean(root)) + if err != nil { + return "", false + } + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + return "", false + } + resolved = filepath.Clean(resolved) + vol := filepath.VolumeName(resolved) + return resolved, isRootOf(vol, resolved[len(vol):], filepath.Separator) +} + +// isRootOf reports whether a cleaned path, split into its volume name and the +// rest, is the root of that volume: the rest is one separator, or empty for a +// UNC share (\\host\share), which is a root without one. A path with no volume +// and an empty rest is no path at all. +func isRootOf(vol, rest string, sep byte) bool { + if len(rest) == 1 && rest[0] == sep { + return true + } + return rest == "" && len(vol) > 2 && vol[0] == sep && vol[1] == sep +} diff --git a/internal/ctxkit/fsroot_test.go b/internal/ctxkit/fsroot_test.go index b989007d0..17d85ced3 100644 --- a/internal/ctxkit/fsroot_test.go +++ b/internal/ctxkit/fsroot_test.go @@ -63,7 +63,8 @@ func TestFilesystemRoot(t *testing.T) { root string want bool }{ - {"/", true}, {"/.", true}, {"//", true}, {"/..", true}, {link, true}, {link + "/..", true}, {up, true}, + {"/", true}, {"/.", true}, {"//", true}, {"/..", true}, {link, true}, {up, true}, + {link + "/..", false}, // cleaned lexically, as the package cleans a root: the directory that holds the link {".", false}, {"..", false}, {"../..", false}, {wd, false}, {"/etc", false}, {"/no/such", false}, {"sub", false}, } { got, ok := FilesystemRoot(filepath.Clean(tc.root)) diff --git a/middleware/static/config.go b/middleware/static/config.go index 6b75eb637..f1977f5e0 100644 --- a/middleware/static/config.go +++ b/middleware/static/config.go @@ -50,20 +50,23 @@ type Config struct { // request; a Root made of ".." segments ("..", "../..") means the directory // it resolves to, and nothing outside that directory is served. // - // Root may be ".", "./" or "/", and serves the tree below it as any other - // directory does. Mind what that is: "." is the process's working - // directory, with its sources and configuration, and "/" is - // the whole filesystem (/etc/passwd, the home directories, - // /proc/self/environ, the process's environment: whatever the process may - // read), where nothing is outside Root any more, so the containment check - // that protects the others protects nothing; only the dotfile rule still - // applies to the request path (/home/u/.ssh is not served), and - // /etc/passwd and /proc/self/environ are not dotfiles. When the working directory - // is "/" (the default of a systemd service and of a container without a - // WORKDIR), "." is the whole filesystem too. Before v1.6.0 these three - // served nothing below the root, so a configuration that was inert since - // v1.3.3 starts to serve files after the upgrade: name a directory that - // holds only what is meant to be public. + // Root may be ".", "./" or a path with ".." segments, and serves the tree + // below it as any other directory does; "." is the process's working + // directory, with its sources and configuration. A Root that resolves to the + // root of a filesystem is refused: "/", "." when the working directory is "/" + // (the default of a systemd service and of a container without a WORKDIR), + // "../../.." reaching it, a symlink to it, a drive root such as C:\ on + // Windows. New panics with a message that names the resolved path, unless + // [Config].AllowFilesystemRoot is set: such a Root serves every file the + // process can read (/etc/passwd, /proc/self/environ, keys without a dot in + // their path), nothing is outside it, and the dotfile rule is no secrets + // filter. The check resolves the Root when New is called; a chdir after that + // is not covered, so resolve a relative Root to an absolute path at startup. + // No released version served anything below such a Root, so no working + // deployment depends on it. Before v1.6.0 "." and "./" served nothing below + // the root either: a configuration that was inert since v1.3.3 starts to + // serve the working directory after the upgrade (without its hidden files: + // see [Config].Dotfiles). Root string // FS is an [fs.FS] from which to serve files (e.g. embed.FS). @@ -123,9 +126,20 @@ type Config struct { // names start with a dot. Only the segments of the request count: the name // of Root, or of the directory Root "." is in, does not. A visible name that // is a symlink to a hidden file or directory of the tree is refused like the - // hidden name. Browse leaves hidden entries out of a listing. The Index file + // hidden name (for an FS, when it can resolve symlinks: an [os.DirFS] can, as + // an [fs.ReadLinkFS]; an FS that is not one has none to follow). Browse leaves + // hidden entries out of a listing. The Index file // is the operator's choice and is served even if its name starts with a dot. - // The zero value is [DotfilesIgnore]. + // + // The rule is not a secrets filter: it looks at the names in the request + // path and at where symlinks lead, nothing else. A secret whose path has no + // dot segment (/etc/shadow readable by the process, /proc/self/environ, an + // id_rsa, /var/run/secrets/...) is served from any Root that reaches it. And + // it is not a sandbox against a writable tree: a symlink swapped between the + // check and the open, and a hard link to a hidden file, defeat any path rule, + // so the served tree must not be writable by untrusted parties. + // The zero value is [DotfilesIgnore]; any other value than the three + // constants panics at New. Dotfiles DotfilesPolicy // AllowFilesystemRoot acknowledges a Root that resolves to the root of a diff --git a/middleware/static/doc.go b/middleware/static/doc.go index ec525beae..b8aba0085 100644 --- a/middleware/static/doc.go +++ b/middleware/static/doc.go @@ -33,17 +33,25 @@ // containment check, the one [celeris.Context.FileFromDir] applies to a file it // sends (symlinks resolved), has passed for its path, and that check runs for // every conditional request, whether or not its validator matches. The two -// checks agree, for a Root of ".", "./" and "/" as for any other directory: -// these three serve the tree below them (before v1.6.0 they served nothing below -// the root, and the root's own index answered 400 or 304 depending on whether a -// validator matched). Their meaning is wide: "." is the working directory and -// "/" the whole filesystem; when the working directory is "/" (a systemd -// service, a container without a WORKDIR), "." is the whole filesystem too, -// /proc/self/environ among its files. A configuration that was inert since -// v1.3.3 starts to serve files after the upgrade, which the dotfile rule below -// makes safer (.env and .git are not served by default). A Root made of ".." -// segments means the directory it resolves to, and nothing outside it is -// served. Name the directory that holds what is public; see [Config].Root. +// checks agree, for a Root of ".", "./" as for any other directory: these +// serve the tree below them (before v1.6.0 they served nothing below the root, +// and the root's own index answered 400 or 304 depending on whether a validator +// matched), so a configuration that was inert since v1.3.3 starts to serve the +// working directory after the upgrade, without its hidden files (below). A Root +// made of ".." segments means the directory it resolves to, and nothing outside +// it is served. +// +// A Root that resolves to the root of a filesystem is refused: "/", "." when +// the working directory is "/" (a systemd service, a container without a +// WORKDIR), "../../.." reaching it, a symlink to it, a drive root on Windows. +// New panics, naming the resolved path, unless [Config].AllowFilesystemRoot is +// set; Server.Static and Group.Static refuse it with no way to acknowledge, so +// serve a filesystem root on purpose with this middleware. The Root is +// resolved when New is called: a chdir after that is not covered, so resolve a +// relative Root to an absolute path at startup. Devices, FIFOs and sockets are +// never served: they are answered as a missing file, since reading /dev/zero +// never ends and opening a FIFO waits for a writer. Name the directory that holds +// what is public; see [Config].Root. // // # Dotfiles // @@ -68,6 +76,15 @@ // // allows dotfiles there, and keeps its secrets out of that directory. // static.New(static.Config{Root: "./public", Dotfiles: static.DotfilesAllow}) // +// The rule is not a secrets filter. It looks at the names in the request path +// and at where symlinks lead, and nothing else: a secret whose path has no dot +// segment (/etc/shadow when the process can read it, /proc/self/environ, an +// id_rsa, /var/run/secrets/...) is served from any Root that reaches it, and +// that is what an acknowledged filesystem root does. Nor is it a sandbox for a +// tree that others can write to: a symlink swapped between the check and the +// open, and a hard link to a hidden file, defeat any rule about paths. Keep +// secrets out of the served directory, and the directory out of untrusted hands. +// // The rule is the middleware's, and that of Server.Static and Group.Static, // which serve a directory too (without a way to opt out: use the middleware for // that). [celeris.Context.FileFromDir], whose caller chooses the path, does not diff --git a/middleware/static/filesystem_root_888_test.go b/middleware/static/filesystem_root_888_test.go index 7c43fedca..5c6d4100b 100644 --- a/middleware/static/filesystem_root_888_test.go +++ b/middleware/static/filesystem_root_888_test.go @@ -73,7 +73,6 @@ func TestFilesystemRootIsRefused888(t *testing.T) { {"up-beyond-slash", up + "/../../..", ""}, {"symlink-to-slash", link, ""}, {"symlink-to-slash-slash", link + "/", ""}, - {"symlink-to-slash-dotdot", link + "/..", ""}, } { t.Run(tc.name, func(t *testing.T) { if tc.cwd != "" { diff --git a/middleware/static/static.go b/middleware/static/static.go index c14ff1321..161444ac0 100644 --- a/middleware/static/static.go +++ b/middleware/static/static.go @@ -137,6 +137,16 @@ func New(config ...Config) celeris.HandlerFunc { // A root made of ".." segments is resolved against the working // directory here, once (see ctxkit.ResolveClimbingRoot). cleanRoot = ctxkit.ResolveClimbingRoot(filepath.Clean(root)) + // A root that resolves to the root of a filesystem serves every file the + // process can read: refused unless acknowledged. The working directory + // and the symlinks are read now; a chdir after this is not covered. + if fsys == nil && !cfg.AllowFilesystemRoot { + if resolved, ok := ctxkit.FilesystemRoot(cleanRoot); ok { + panic(fmt.Sprintf("static: Root %q resolves to the filesystem root %q, which serves every file the process can read: "+ + "name the directory that holds the public files, or set Config.AllowFilesystemRoot to serve a filesystem root on purpose "+ + "(the working directory is read now: a later chdir is not covered)", root, resolved)) + } + } } // Precompute the cache-control header once — maxAge is closure-constant, diff --git a/server.go b/server.go index 0fb3e94f9..72ab32c02 100644 --- a/server.go +++ b/server.go @@ -335,11 +335,15 @@ func (s *Server) OnShutdown(fn func(ctx context.Context)) *Server { // Static registers a GET handler that serves files from root under the given // prefix. Uses FileFromDir for path traversal protection. root may be ".", -// "./" or "/" (from v1.6.0; before, every file below them was refused): "." is -// the working directory and "/" the whole filesystem, so name a directory that -// holds only what is public. A root made of ".." segments means the directory -// it resolves to, and nothing outside it is served. An empty root serves -// nothing. Files and directories whose name starts with a dot (.env, .git) are +// "./" (from v1.6.0; before, every file below them was refused): "." is the +// working directory, so name a directory that holds only what is public. A root +// that resolves to the root of a filesystem ("/", "." when the working directory +// is "/", a symlink to "/") panics here, with no way to acknowledge it: serve a +// filesystem root on purpose with middleware/static and AllowFilesystemRoot. A +// root made of ".." segments means the directory it resolves to, resolved once +// when the route is registered, and nothing outside it is served. An empty root +// serves nothing. A file that is not there is a 404; devices, FIFOs and sockets +// are not served. Files and directories whose name starts with a dot (.env, .git) are // not served, and a path to one, or to a symlink that leads to one, is a 404 // whether or not it exists; the exact segment ".well-known" is served. There is // no opt-out here: use middleware/static with Dotfiles: static.DotfilesAllow. From 95c424af9ec59f7e02de2066e15150354ac9085e Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 11:11:23 +0100 Subject: [PATCH 20/26] tests(static): a conditional request for a FIFO or a socket is no 304 (kills the serveOS regular-file mutant) (Refs #888) --- middleware/static/nonregular_888_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/middleware/static/nonregular_888_test.go b/middleware/static/nonregular_888_test.go index 4629694be..76b9d9e42 100644 --- a/middleware/static/nonregular_888_test.go +++ b/middleware/static/nonregular_888_test.go @@ -155,6 +155,13 @@ func TestNonRegularFilesAreNotServed888(t *testing.T) { rec, _ := get888(t, mw, path) return rec }) + // a conditional request is no oracle for a file that is not served + if cond := within888(t, "conditional GET "+path, 5*time.Second, func() *celeristest.ResponseRecorder { + rec, _ := get888(t, mw, path, "if-none-match", "*") + return rec + }); cond.StatusCode == 304 || cond.Header("etag") != "" { + t.Errorf("conditional GET %s: status %d etag %q, want it passed on", path, cond.StatusCode, cond.Header("etag")) + } body := string(rec.Body) switch { case c.name == "spa": From 72615c2fe548a9bbcb117bedbcf9519a733ec6de Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 11:20:12 +0100 Subject: [PATCH 21/26] tests(static): mkfifo behind build tags so the package vets on Windows (Refs #888) --- middleware/static/mkfifo_unix_888_test.go | 7 +++++++ middleware/static/mkfifo_windows_888_test.go | 7 +++++++ middleware/static/nonregular_888_test.go | 3 +-- 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 middleware/static/mkfifo_unix_888_test.go create mode 100644 middleware/static/mkfifo_windows_888_test.go diff --git a/middleware/static/mkfifo_unix_888_test.go b/middleware/static/mkfifo_unix_888_test.go new file mode 100644 index 000000000..405441253 --- /dev/null +++ b/middleware/static/mkfifo_unix_888_test.go @@ -0,0 +1,7 @@ +//go:build !windows + +package static + +import "syscall" + +func mkfifo888(path string) error { return syscall.Mkfifo(path, 0o644) } diff --git a/middleware/static/mkfifo_windows_888_test.go b/middleware/static/mkfifo_windows_888_test.go new file mode 100644 index 000000000..d4b9c5cbc --- /dev/null +++ b/middleware/static/mkfifo_windows_888_test.go @@ -0,0 +1,7 @@ +//go:build windows + +package static + +import "errors" + +func mkfifo888(string) error { return errors.New("no FIFOs on Windows") } diff --git a/middleware/static/nonregular_888_test.go b/middleware/static/nonregular_888_test.go index 76b9d9e42..86fdec5ea 100644 --- a/middleware/static/nonregular_888_test.go +++ b/middleware/static/nonregular_888_test.go @@ -8,7 +8,6 @@ import ( "runtime" "runtime/debug" "strings" - "syscall" "testing" "time" @@ -91,7 +90,7 @@ func newSpecialTree888(t *testing.T) specialTree888 { if err := os.WriteFile(filepath.Join(dir, "reg.txt"), []byte("REG"), 0o644); err != nil { t.Fatal(err) } - if err := syscall.Mkfifo(filepath.Join(dir, "fifo"), 0o644); err != nil { + if err := mkfifo888(filepath.Join(dir, "fifo")); err != nil { t.Skipf("no mkfifo here: %v", err) } ln, err := net.Listen("unix", filepath.Join(dir, "sock")) From 81623fbd4e5dfe862f8bfb0d3ba8f801ea3a2f6e Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 11:40:09 +0100 Subject: [PATCH 22/26] tests(static, celeris): round 3 of the security review: links that leave an FS, the walk's bound, a FIFO under fs.Sub, Server.Static's body for a FIFO, a size-0 pseudo-file, the open descriptor's own check (Refs #888) Red on 72615c2 (docker --cpus 2 -race, 1 GiB): TestDotfilesFSLinksLeavingTheFS888 (a hidden file reached through two planted links, or through an absolute link, is served under Ignore and Deny, under os.DirFS and fs.Sub), TestFSLinkWalkIsBounded888, TestFIFOUnderSubFS888 (blocked 5 s), TestServerStaticNotRegularIsNotFound888, TestZeroSizePseudoFileIsServedEmpty888 (the binary was killed: /proc/self/pagemap read to the 100 MB cap). TestServeOpenFileChecksTheOpenDescriptor888 passes and pins the check the mutant run found untested; it uses the openForServe seam. --- context_response.go | 6 +- file_from_dir_root_888_test.go | 7 + middleware/static/dotfiles_r3_888_test.go | 272 ++++++++++++++++++++++ serve_open_recheck_888_test.go | 32 +++ 4 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 middleware/static/dotfiles_r3_888_test.go create mode 100644 serve_open_recheck_888_test.go diff --git a/context_response.go b/context_response.go index 4bf07002d..d4952fac5 100644 --- a/context_response.go +++ b/context_response.go @@ -978,11 +978,15 @@ func (c *Context) serveFile(filePath, contentType string, onOpen func(modTime ti return c.serveOpenFile(filePath, contentType, onOpen) } +// openForServe opens the file serveOpenFile serves; a variable so that a test can +// put another file under a path that was just stat-ed. +var openForServe = os.Open + // serveOpenFile is serveFile for a path that was just stat-ed as a regular file // or a directory (fileFromDir): it checks the open descriptor again, which is // what is read, and bounds the buffered read. func (c *Context) serveOpenFile(filePath, contentType string, onOpen func(modTime time.Time, size int64)) error { - f, err := os.Open(filePath) + f, err := openForServe(filePath) if err != nil { return err } diff --git a/file_from_dir_root_888_test.go b/file_from_dir_root_888_test.go index 5c17a36d1..6a31252a3 100644 --- a/file_from_dir_root_888_test.go +++ b/file_from_dir_root_888_test.go @@ -7,6 +7,8 @@ import ( "runtime" "strings" "testing" + + "github.com/goceleris/celeris/internal/ctxkit" ) // Context.FileFromDir with a baseDir of ".", "./" and "/" (celeris#888 item @@ -184,3 +186,8 @@ func TestFileFromDirRelativeBaseAbsoluteSymlink888(t *testing.T) { } } } + +func isNotRegular888(err error) bool { + var he *HTTPError + return errors.As(err, &he) && he.Code == 404 && errors.Is(err, ctxkit.ErrNotRegular) +} diff --git a/middleware/static/dotfiles_r3_888_test.go b/middleware/static/dotfiles_r3_888_test.go new file mode 100644 index 000000000..e99a0d686 --- /dev/null +++ b/middleware/static/dotfiles_r3_888_test.go @@ -0,0 +1,272 @@ +package static + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/goceleris/celeris" + "github.com/goceleris/celeris/celeristest" +) + +// Round 3 of the security review: under Config.FS the symlink rule stopped at +// the first link that leaves the FS: a hidden file reached through two planted +// links (one absolute, or one that climbs out of the FS and back) was served +// under Ignore and under Deny. A link that leaves the FS is not followed any +// more: the FS cannot vouch for what is beyond it, so under Ignore and Deny the +// path is refused (as a missing file / 403), and Allow serves it as before. + +// linkTree888 is top/pub (the served directory) and top/outside, with a .env +// above pub, one in it, and the links of the review's probe. +type linkTree888 struct{ top, pub, outside string } + +func newLinkTree888(t *testing.T) linkTree888 { + t.Helper() + skipWindows888(t) + top, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + lt := linkTree888{top: top, pub: filepath.Join(top, "pub"), outside: filepath.Join(top, "outside")} + for p, body := range map[string]string{ + "pub/.env": "DOTENV", "pub/.git/config": "GITCFG", "pub/a.txt": "A", "pub/sub/b.txt": "B", "pub/d/x": "X", + ".env": "PARENTENV", "outside/vis.txt": "VIS", + } { + p = filepath.Join(top, filepath.FromSlash(p)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + for link, target := range map[string]string{ + // inside the FS + "pub/alias": ".env", "pub/c2a": "c2b", "pub/c2b": ".env", "pub/c3a": "c3b", "pub/c3b": "c3c", "pub/c3c": ".git/config", + "pub/v1": "v2", "pub/v2": "a.txt", "pub/in-visible": "sub/b.txt", "pub/sub/e": "../.env", "pub/sub/g": "../.git", + // out of the FS, and back in + "pub/parent-env": "../.env", "outside/pe": "../.env", "pub/out-parent-env": "../outside/pe", + "outside/back": "../pub/.env", "pub/out-and-back": "../outside/back", + "outside/absgit": filepath.Join(top, "pub", ".git"), "pub/out-absgit": "../outside/absgit", + "pub/up": "..", + // absolute + "pub/abs-to-alias": filepath.Join(top, "pub", "alias"), "pub/abs-sub": filepath.Join(top, "pub", "sub"), + "pub/abs-visible": filepath.Join(top, "outside", "vis.txt"), + // loops + "pub/loop1": "loop2", "pub/loop2": "loop1", + // a long link that walks many segments per follow + "pub/amp": strings.Repeat("d/../", 200) + "amp", + } { + if err := os.Symlink(target, filepath.Join(top, filepath.FromSlash(link))); err != nil { + t.Skipf("symlinks are not available here: %v", err) + } + } + return lt +} + +// fsFlavours888 are the FS values the rule must hold for: a real os.DirFS and an +// fs.Sub of one (which hides the part of the tree above pub). +func (lt linkTree888) flavours(t *testing.T) map[string]fs.FS { + t.Helper() + sub, err := fs.Sub(os.DirFS(lt.top), "pub") + if err != nil { + t.Fatal(err) + } + return map[string]fs.FS{"dirfs": os.DirFS(lt.pub), "sub": sub} +} + +func TestDotfilesFSLinksLeavingTheFS888(t *testing.T) { + lt := newLinkTree888(t) + refused := []string{ + // a single link to a hidden file, in the FS and out of it + "/alias", "/parent-env", + // chains of 2 and 3 links in the FS + "/c2a", "/c3a", "/sub/e", "/sub/g/config", + // out of the FS and back: the hidden file is in pub, or above it + "/out-and-back", "/out-parent-env", "/out-absgit/config", + // behind an absolute link + "/abs-to-alias", "/abs-sub/e", "/abs-sub/g/config", + // the FS root's parent, and an absolute link to a visible file outside + "/up/pub/a.txt", "/up/.env", "/abs-visible", + // loops + "/loop1", "/loop2", + } + served := map[string]string{"/a.txt": "A", "/sub/b.txt": "B", "/v1": "A", "/v2": "A", "/in-visible": "B"} + for name, fsys := range lt.flavours(t) { + for _, pol := range []DotfilesPolicy{DotfilesIgnore, DotfilesDeny, DotfilesAllow} { + mw := New(Config{FS: fsys, Dotfiles: pol}) + absent := missing888(t, mw) + for _, p := range refused { + t.Run(fmt.Sprintf("%s/policy%d%s", name, pol, p), func(t *testing.T) { + rec, err := get888(t, mw, p) + status := rec.StatusCode + if he, ok := err.(*celeris.HTTPError); ok { + status = he.Code + } + switch pol { + case DotfilesIgnore: + if status != absent.status || len(rec.Body) != 0 || err != nil { + t.Errorf("GET %s under Ignore: status %d body %q err %v, want what a missing file gets", p, status, rec.Body, err) + } + case DotfilesDeny: + if status != 403 || len(rec.Body) != 0 { + t.Errorf("GET %s under Deny: status %d body %q err %v, want 403", p, status, rec.Body, err) + } + default: // Allow: the OS follows every link, as before the rule + if p == "/out-parent-env" && string(rec.Body) != "PARENTENV" && name == "dirfs" { + t.Errorf("GET %s under Allow: status %d body %q, want it served as before", p, status, rec.Body) + } + } + if pol != DotfilesAllow { + for _, secret := range []string{"ENV", "GITCFG", "VIS"} { + if strings.Contains(string(rec.Body), secret) { + t.Errorf("GET %s leaked %q", p, secret) + } + } + } + }) + } + for p, want := range served { + t.Run(fmt.Sprintf("%s/policy%d-served%s", name, pol, p), func(t *testing.T) { + if o, _ := outcomeOf888(t, mw, p); o.status != 200 || o.body != want { + t.Errorf("GET %s: %v, want 200 %q (a link that stays inside the FS is judged by where it ends)", p, o, want) + } + }) + } + } + } +} + +// TestFSLinkWalkIsBounded888: a planted link that walks many segments per follow +// costs a request about what a plain one does, not 24,000 allocations; it is +// refused. +func TestFSLinkWalkIsBounded888(t *testing.T) { + lt := newLinkTree888(t) + mw := New(Config{FS: os.DirFS(lt.pub)}) + allocs := testing.AllocsPerRun(5, func() { + ctx, _ := celeristest.NewContext("GET", "/amp", celeristest.WithHandlers(mw)) + _ = ctx.Next() + celeristest.ReleaseContext(ctx) + }) + if allocs > 1500 { + t.Errorf("GET of a planted amplifying link: %.0f allocations, want it cut off early", allocs) + } + if o, _ := outcomeOf888(t, mw, "/amp"); o.kind != "passed-on" { + t.Errorf("GET /amp: %v, want it refused as missing", o) + } +} + +// TestFIFOUnderSubFS888 (R3-F3): an fs.Sub is no fs.StatFS, so the pre-open stat +// was skipped and a FIFO in a Sub of an os.DirFS blocked the handler. It is +// found through the links the Sub can read. +func TestFIFOUnderSubFS888(t *testing.T) { + st := newSpecialTree888(t) + parent := filepath.Dir(st.dir) + sub, err := fs.Sub(os.DirFS(parent), filepath.Base(st.dir)) + if err != nil { + t.Fatal(err) + } + mw := New(Config{FS: sub}) + for _, p := range []string{"/fifo", "/lnk-fifo", "/sock", "/lnk-sock"} { + rec := within888(t, "GET "+p, 5*time.Second, func() *celeristest.ResponseRecorder { + rec, _ := get888(t, mw, p) + return rec + }) + if rec.StatusCode != 0 || len(rec.Body) != 0 { + t.Errorf("fs.Sub GET %s: status %d body %q, want it passed on as a missing file", p, rec.StatusCode, rec.Body) + } + } + for _, name := range []string{"fifo", "lnk-fifo"} { + err := within888(t, "FileFromFS "+name, 5*time.Second, func() error { + c, _ := celeristest.NewContext("GET", "/f") + defer celeristest.ReleaseContext(c) + return c.FileFromFS(name, sub) + }) + if he, ok := err.(*celeris.HTTPError); !ok || he.Code != 404 { + t.Errorf("FileFromFS(%s, fs.Sub) = %v, want an HTTPError 404", name, err) + } + } + if o, _ := outcomeOf888(t, mw, "/reg.txt"); o.status != 200 || o.body != "REG" { + t.Errorf("fs.Sub GET /reg.txt: %v, want 200 REG", o) + } +} + +// TestServerStaticNotRegularIsNotFound888 (R3-F6): Server.Static answers a FIFO, +// a socket and a link to one with the body of a missing file, not a message that +// tells them apart. +func TestServerStaticNotRegularIsNotFound888(t *testing.T) { + st := newSpecialTree888(t) + for _, eng := range wireEngines888 { + raw := wire888(t, eng, func(s *celeris.Server) { s.Static("/files", st.dir) }) + _, missing := raw("/files/no-such-file") + for _, p := range []string{"/files/fifo", "/files/lnk-fifo", "/files/sock"} { + status, body := raw(p) + if status != 404 || body != missing { + t.Errorf("%v GET %s: status %d body %q, want 404 %q (as a missing file)", eng, p, status, body, missing) + } + } + } +} + +// TestZeroSizePseudoFileIsServedEmpty888 (R3-F4): a file whose size is 0 but +// that has content (/proc/self/pagemap reads 100 MB) is read for as many bytes +// as it says it has, not up to the 100 MB cap: served empty, as under epoll's +// sendfile and under an FS. Linux only. +func TestZeroSizePseudoFileIsServedEmpty888(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("procfs") + } + if _, err := os.Stat("/proc/self/pagemap"); err != nil { + t.Skip("no /proc/self/pagemap") + } + guardMemory888(t, 256<<20) + c, rec := celeristest.NewContext("GET", "/f") + defer celeristest.ReleaseContext(c) + began := time.Now() + err := c.File("/proc/self/pagemap") + if err != nil || len(rec.Body) != 0 { + t.Errorf("File(/proc/self/pagemap): %d body bytes, err %v, want an empty body", len(rec.Body), err) + } + if d := time.Since(began); d > 2*time.Second { + t.Errorf("File(/proc/self/pagemap) took %v", d) + } +} + +// BenchmarkDirFSRequest888 is the cost of the symlink walk: a deep path under an +// os.DirFS with the rule (the default) and without it (Allow). +func BenchmarkDirFSRequest888(b *testing.B) { + root, err := filepath.EvalSymlinks(b.TempDir()) + if err != nil { + b.Fatal(err) + } + deep := filepath.Join(root, "a", "b", "c", "d", "e") + if err := os.MkdirAll(deep, 0o755); err != nil { + b.Fatal(err) + } + for _, p := range []string{filepath.Join(deep, "app.js"), filepath.Join(root, "app.js")} { + if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { + b.Fatal(err) + } + } + for _, tc := range []struct { + name string + pol DotfilesPolicy + path string + }{{"rule/shallow", DotfilesIgnore, "/app.js"}, {"rule/deep", DotfilesIgnore, "/a/b/c/d/e/app.js"}, + {"allow/shallow", DotfilesAllow, "/app.js"}, {"allow/deep", DotfilesAllow, "/a/b/c/d/e/app.js"}} { + b.Run(tc.name, func(b *testing.B) { + mw := New(Config{FS: os.DirFS(root), Dotfiles: tc.pol}) + b.ReportAllocs() + for b.Loop() { + ctx, _ := celeristest.NewContext("GET", tc.path, celeristest.WithHandlers(mw)) + _ = ctx.Next() + celeristest.ReleaseContext(ctx) + } + }) + } +} diff --git a/serve_open_recheck_888_test.go b/serve_open_recheck_888_test.go new file mode 100644 index 000000000..acb7e6c11 --- /dev/null +++ b/serve_open_recheck_888_test.go @@ -0,0 +1,32 @@ +package celeris + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +// serveOpenFile checks the open descriptor again, not only the stat that came +// before it: a path that was a regular file when it was stat-ed and is something +// else when it is opened (a swap needs write access to the tree) is refused, +// whatever the path says. The test puts a device under the path after the stat. +func TestServeOpenFileChecksTheOpenDescriptor888(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no /dev/null") + } + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "reg.txt"), []byte("REG"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(os.DevNull); err != nil { + t.Skip(err) + } + old := openForServe + t.Cleanup(func() { openForServe = old }) + openForServe = func(string) (*os.File, error) { return os.Open(os.DevNull) } + status, body, err := fileFromDir888(t, dir, "reg.txt") + if !isNotRegular888(err) || status == 200 || body != "" { + t.Errorf("FileFromDir with a device under the path: status %d body %q err %v, want an HTTPError 404 (not a regular file)", status, body, err) + } +} From ae29f61448505641f41718d2bf7ab807996d56cc Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 11:41:43 +0100 Subject: [PATCH 23/26] fix(static, celeris): links that leave an FS are not followed under Ignore and Deny; the walk is bounded; a FIFO under fs.Sub is found; size-0 pseudo-files are served empty; the open is non-blocking; Server.Static answers a FIFO as not found; docs (Refs #888) R3-F1: ctxkit.ResolveFSLinks walks an FS path link by link (at most 8 links and 256 steps) and reports ok=false when it leaves the FS (an absolute target, a target that climbs above the FS root) or cannot finish: the middleware then refuses the path as hidden (404 as missing under Ignore, 403 under Deny); Allow follows as before. R3-F3: an FS that is a ReadLinkFS but no StatFS (fs.Sub) is Lstat-ed at the resolved name before the open, in the middleware and in FileFromFS. R3-F4: the buffered read is bounded by the size the open file reports. R3-F5: the file is opened with O_NONBLOCK where the platform has it. R3-F6: Server.Static maps a non-regular file to the 404 of a missing one. R3-F2 and the cases that reach a filesystem root unprotected are in the godoc. --- context_response.go | 35 ++++---- internal/ctxkit/fslinks.go | 100 ++++++++++++++++++++++ middleware/static/config.go | 33 +++++-- middleware/static/doc.go | 11 ++- middleware/static/dotfiles_r2_888_test.go | 6 ++ middleware/static/static.go | 74 +++++----------- open_unix.go | 15 ++++ open_windows.go | 7 ++ 8 files changed, 207 insertions(+), 74 deletions(-) create mode 100644 internal/ctxkit/fslinks.go create mode 100644 open_unix.go create mode 100644 open_windows.go diff --git a/context_response.go b/context_response.go index d4952fac5..08521ad00 100644 --- a/context_response.go +++ b/context_response.go @@ -978,9 +978,12 @@ func (c *Context) serveFile(filePath, contentType string, onOpen func(modTime ti return c.serveOpenFile(filePath, contentType, onOpen) } -// openForServe opens the file serveOpenFile serves; a variable so that a test can -// put another file under a path that was just stat-ed. -var openForServe = os.Open +// openForServe opens the file serveOpenFile serves: without blocking where the +// platform has O_NONBLOCK, so that a FIFO swapped in for a regular file after the +// stat does not wait in open(2) for a writer (the check on the open descriptor +// then refuses it). A variable so that a test can put another file under a path +// that was just stat-ed. +var openForServe = openNonblock // serveOpenFile is serveFile for a path that was just stat-ed as a regular file // or a directory (fileFromDir): it checks the open descriptor again, which is @@ -1056,16 +1059,14 @@ func (c *Context) serveOpenFile(filePath, contentType string, onOpen func(modTim return err } - // A regular file has a size, and it was capped above; the read is bounded by - // the cap all the same, for a file that grows meanwhile and for a - // pseudo-file the size of which is not its length. - data, err := io.ReadAll(io.LimitReader(f, int64(maxStreamBodySize)+1)) + // The read is bounded by the size the open file reports (which the cap above + // bounded): a file that grows meanwhile is cut at the size its validators + // describe, and a pseudo-file of size 0 that has content (/proc/self/pagemap + // reads 100 MB) is served empty, as the sendfile path serves it. + data, err := io.ReadAll(io.LimitReader(f, size)) if err != nil { return err } - if len(data) > maxStreamBodySize { - return NewHTTPError(413, "file exceeds 100MB limit") - } return c.Blob(200, contentType, data) } @@ -1154,7 +1155,7 @@ func (c *Context) serveStaticFile(root, tail string) error { return NewHTTPError(404, "Not Found") } err := c.fileFromDir(root, tail, "", nil, ctxkit.HiddenPath) - if errors.Is(err, ctxkit.ErrHidden) || errors.Is(err, fs.ErrNotExist) { + if errors.Is(err, ctxkit.ErrHidden) || errors.Is(err, ctxkit.ErrNotRegular) || errors.Is(err, fs.ErrNotExist) { // a path to a hidden file is a 404 whether or not it exists, and so is a // file that is not there (the raw lstat error was a 500) return NewHTTPError(404, "Not Found") @@ -1226,6 +1227,13 @@ func (c *Context) FileFromFS(name string, fsys fs.FS) error { if info, err := fs.Stat(fsys, name); err == nil && notServable(info) { return errNotRegular() } + } else if rl, ok := fsys.(fs.ReadLinkFS); ok { + // an fs.Sub of an os.DirFS is no StatFS: look at where its links lead + if resolved, ok := ctxkit.ResolveFSLinks(rl, name); ok { + if info, err := rl.Lstat(resolved); err == nil && notServable(info) { + return errNotRegular() + } + } } f, err := fsys.Open(name) if err != nil { @@ -1262,13 +1270,10 @@ func (c *Context) FileFromFS(name string, fsys fs.FS) error { } } - data, err := io.ReadAll(io.LimitReader(f, int64(maxStreamBodySize)+1)) + data, err := io.ReadAll(io.LimitReader(f, size)) if err != nil { return err } - if len(data) > maxStreamBodySize { - return NewHTTPError(413, "file exceeds 100MB limit") - } return c.Blob(200, contentType, data) } diff --git a/internal/ctxkit/fslinks.go b/internal/ctxkit/fslinks.go new file mode 100644 index 000000000..6bc42c17f --- /dev/null +++ b/internal/ctxkit/fslinks.go @@ -0,0 +1,100 @@ +package ctxkit + +import ( + "io/fs" + "path" + "strings" +) + +const ( + // maxFSLinkFollows is how many symlinks ResolveFSLinks follows for one name. + // The kernel allows 40; a served tree has none or one or two (a deploy's + // "current"), and each follow can cost a walk. + maxFSLinkFollows = 8 + // maxFSWalkSteps bounds the segments one walk looks at, links included: a + // planted link that walks many segments per follow ("d/../d/../...") is cut + // off. + maxFSWalkSteps = 256 +) + +// ResolveFSLinks returns name, a slash path below the root of an FS, with every +// symlink on it replaced by its target, as the operating system walks it: the +// place the file is at, for the hidden check and for the pre-open stat. ok is +// false when the walk cannot vouch for the result: a link leads out of the FS (an +// absolute target, or a target that climbs above the FS root, which an +// os.DirFS or an fs.Sub of one still lets the OS follow), more than +// maxFSLinkFollows links are on the way (a loop included), or the walk takes more +// than maxFSWalkSteps steps. Callers that refuse hidden files refuse such a +// path as well: the FS cannot say what is beyond it. +// +// Without any link on the way (nearly every request) the walk allocates no +// path: it looks at the prefixes of name, one Lstat each. +func ResolveFSLinks(rl fs.ReadLinkFS, name string) (resolved string, ok bool) { + if strings.Contains(name, "..") || strings.Contains(name, "//") || strings.Contains(name, "./") { + // an FS name is not cleaned for the caller: clean as the OS would + name = path.Clean(name) + if name == ".." || strings.HasPrefix(name, "../") { + return name, true // not a valid FS name: it cannot be opened, and finds nothing + } + } + for end := 0; end < len(name); { + i := strings.IndexByte(name[end:], '/') + if i < 0 { + end = len(name) + } else { + end += i + } + fi, err := rl.Lstat(name[:end]) + if err != nil { + return name, true // it does not exist: no link beyond that point + } + if fi.Mode()&fs.ModeSymlink != 0 { + return walkFSLinks(rl, name) + } + end++ + } + return name, true +} + +// walkFSLinks is the general walk, for a name with a link on it. +func walkFSLinks(rl fs.ReadLinkFS, name string) (string, bool) { + queue := strings.Split(name, "/") + var resolved []string + follows := 0 + for steps := 0; len(queue) > 0; steps++ { + if steps > maxFSWalkSteps { + return "", false + } + seg := queue[0] + queue = queue[1:] + switch seg { + case "", ".": + continue + case "..": + if len(resolved) == 0 { + return "", false // above the FS root: the FS cannot say what is there + } + resolved = resolved[:len(resolved)-1] + continue + } + cand := strings.Join(append(resolved, seg), "/") + fi, err := rl.Lstat(cand) + if err != nil || fi.Mode()&fs.ModeSymlink == 0 { + resolved = append(resolved, seg) + continue + } + if follows++; follows > maxFSLinkFollows { + return "", false + } + target, err := rl.ReadLink(cand) + if err != nil { + return "", false + } + target = strings.ReplaceAll(target, `\`, "/") + if strings.HasPrefix(target, "/") || (len(target) > 1 && target[1] == ':') { + return "", false // an absolute target leaves the FS + } + queue = append(strings.Split(target, "/"), queue...) + } + return strings.Join(resolved, "/"), true +} diff --git a/middleware/static/config.go b/middleware/static/config.go index f1977f5e0..113f71d39 100644 --- a/middleware/static/config.go +++ b/middleware/static/config.go @@ -60,8 +60,12 @@ type Config struct { // [Config].AllowFilesystemRoot is set: such a Root serves every file the // process can read (/etc/passwd, /proc/self/environ, keys without a dot in // their path), nothing is outside it, and the dotfile rule is no secrets - // filter. The check resolves the Root when New is called; a chdir after that - // is not covered, so resolve a relative Root to an absolute path at startup. + // filter. The refusal looks at the Root once, when New is called, and the Root + // should exist then (one that does not is not refused, and serves nothing + // until it does): a chdir after that is not covered, and neither is a Root + // that is or contains a symlink retargeted later, which is followed at each + // request (a "current" symlink of a deploy). Resolve a relative Root to an + // absolute path at startup. // No released version served anything below such a Root, so no working // deployment depends on it. Before v1.6.0 "." and "./" served nothing below // the root either: a configuration that was inert since v1.3.3 starts to @@ -126,8 +130,17 @@ type Config struct { // names start with a dot. Only the segments of the request count: the name // of Root, or of the directory Root "." is in, does not. A visible name that // is a symlink to a hidden file or directory of the tree is refused like the - // hidden name (for an FS, when it can resolve symlinks: an [os.DirFS] can, as - // an [fs.ReadLinkFS]; an FS that is not one has none to follow). Browse leaves + // hidden name. For an FS this needs an FS that can read symlinks (an + // [fs.ReadLinkFS], as an [os.DirFS] and an [fs.Sub] of one are; an FS that is + // not one, an embed.FS, has none to follow), and the links are followed only + // while they stay inside the FS: under DotfilesIgnore and DotfilesDeny a link + // that leaves the FS (an absolute target, or one that climbs above its root), + // more than 8 links on a path (a loop included), or a walk of more than 256 + // steps, is not followed and the path is refused like a hidden one, because + // the FS cannot say what is beyond it (an os.DirFS lets the operating system + // follow it); DotfilesAllow follows them all, as before. An FS that is neither + // an [fs.StatFS] nor an [fs.ReadLinkFS] is opened as it is: a FIFO in it + // blocks the request. Browse leaves // hidden entries out of a listing. The Index file // is the operator's choice and is served even if its name starts with a dot. // @@ -147,7 +160,17 @@ type Config struct { // reaching it, a symlink to "/", a drive root such as C:\ on Windows). It // is refused at New, with a panic that names the resolved path, unless this // is true: such a Root serves every file the process can read below it, and - // nothing is outside it any more. Default: false. + // nothing is outside it any more. A filesystem root also serves /proc and + // /sys: their regular files (/proc/self/environ, /proc/self/status) are + // served, those that report a size of 0 as an empty body. Default: false. + // + // Not every way to reach a filesystem root is refused: an FS + // ([os.DirFS]("/"), or os.DirFS(".") in a working directory of "/") has no + // root to check, [celeris.Context.FileFromDir] with a base of "/" is an + // explicit call whose caller chooses the path, a Root that is retargeted + // after New (a symlink swapped to "/", a chdir) is not looked at again, and + // a bind mount of "/" under another path looks like any directory. They are + // unprotected by design; serve a directory. AllowFilesystemRoot bool } diff --git a/middleware/static/doc.go b/middleware/static/doc.go index b8aba0085..d7553bec0 100644 --- a/middleware/static/doc.go +++ b/middleware/static/doc.go @@ -48,7 +48,9 @@ // set; Server.Static and Group.Static refuse it with no way to acknowledge, so // serve a filesystem root on purpose with this middleware. The Root is // resolved when New is called: a chdir after that is not covered, so resolve a -// relative Root to an absolute path at startup. Devices, FIFOs and sockets are +// relative Root to an absolute path at startup. The Root should exist then; a +// Root that is or contains a symlink is followed at each request, so a later +// retarget to "/" is not refused. Devices, FIFOs and sockets are // never served: they are answered as a missing file, since reading /dev/zero // never ends and opening a FIFO waits for a writer. Name the directory that holds // what is public; see [Config].Root. @@ -64,7 +66,12 @@ // is cleaned ("a/../.env" is ".env") and with one level of percent-decoding; // the name of the root does not: a Root that is ".config", or "." in such a // directory, serves its files. The rule also follows a symlink: a visible name -// that leads to a hidden file or directory of the tree is refused like it. The +// that leads to a hidden file or directory of the tree is refused like it. Under +// a [Config].FS (an os.DirFS, or an fs.Sub of one) the links are followed while +// they stay inside the FS; links that leave the FS (an absolute target, or one +// that climbs above its root) are not followed under Ignore and Deny: such a +// path is refused like a hidden one, because the FS cannot say what is beyond +// it. The // exact segment ".well-known" is served (RFC 8615: ACME challenges, // security.txt, apple-app-site-association), but its children are served only // if they are not hidden themselves. A directory listing (Browse) leaves the diff --git a/middleware/static/dotfiles_r2_888_test.go b/middleware/static/dotfiles_r2_888_test.go index 0d55087c6..743be1d80 100644 --- a/middleware/static/dotfiles_r2_888_test.go +++ b/middleware/static/dotfiles_r2_888_test.go @@ -30,6 +30,12 @@ func TestDotfilesDirFS888(t *testing.T) { continue } weird := strings.Contains(tc.path, "..") || strings.Contains(tc.path, "/./") || strings.Contains(tc.path, "//") + // a link with an absolute target leaves the FS, which cannot vouch for what + // is beyond it: refused even when the target is a visible file + tc := tc + if tc.path == "/ok-abs" { + tc.hidden = true + } t.Run(fmt.Sprintf("policy%d/%q", pol, tc.path), func(t *testing.T) { rec, err := get888(t, mw, tc.path) status := rec.StatusCode diff --git a/middleware/static/static.go b/middleware/static/static.go index 161444ac0..3848d025d 100644 --- a/middleware/static/static.go +++ b/middleware/static/static.go @@ -495,52 +495,6 @@ func servePreCompressed(c *celeris.Context, cleanRoot, filePath, fullPath, cache return false, nil } -// resolveFSLinks returns name, a slash path below the FS root, with every -// symlink on it replaced by its target, as the operating system would walk it: -// the path the file is really at, for the hidden check. A link whose target is -// absolute leaves the FS: the result is then the target, with the rest of the -// path after it, and its own segments are what counts. A path that climbs above -// the FS root keeps its "..", which the check cleans away. More than 40 links -// on the way (a loop) resolve to a hidden path: refused. -func resolveFSLinks(rl fs.ReadLinkFS, name string) string { - queue := strings.Split(name, "/") - var resolved []string - for follows := 0; len(queue) > 0; { - seg := queue[0] - queue = queue[1:] - switch seg { - case "", ".": - continue - case "..": - if n := len(resolved); n > 0 && resolved[n-1] != ".." { - resolved = resolved[:n-1] - } else { - resolved = append(resolved, "..") - } - continue - } - cand := strings.Join(append(resolved, seg), "/") - fi, err := rl.Lstat(cand) - if err != nil || fi.Mode()&fs.ModeSymlink == 0 { - resolved = append(resolved, seg) - continue - } - if follows++; follows > 40 { - return ".loop" - } - target, err := rl.ReadLink(cand) - if err != nil { - return ".unreadable-link" - } - target = filepath.ToSlash(target) - if strings.HasPrefix(target, "/") || filepath.IsAbs(target) { - return target + "/" + strings.Join(queue, "/") - } - queue = append(strings.Split(target, "/"), queue...) - } - return strings.Join(resolved, "/") -} - // serveFS serves a file from an fs.FS with ETag/Last-Modified and range support. func serveFS(c *celeris.Context, fsys fs.FS, filePath, index string, browse, spa, compress bool, cacheControl string, cache *sync.Map, hidden func(rel string) bool) error { // 1. Open + stat + directory handling. @@ -553,13 +507,26 @@ func serveFS(c *celeris.Context, fsys fs.FS, filePath, index string, browse, spa // a visible name that links to a hidden file or directory is refused like // the hidden name (an FS that cannot, an embed.FS or an fstest.MapFS // without links, has none to follow). - if hidden != nil && filePath != "" { - if rl, ok := fsys.(fs.ReadLinkFS); ok && hidden(resolveFSLinks(rl, filePath)) { + // + // A link that leaves the FS (an absolute target, or one that climbs above the + // FS root) is not followed: the FS cannot say what is beyond it, and an + // os.DirFS lets the OS follow it, so the path is refused as hidden. + _, isStat := fsys.(fs.StatFS) + if rl, ok := fsys.(fs.ReadLinkFS); ok && filePath != "" && (hidden != nil || !isStat) { + resolved, ok := ctxkit.ResolveFSLinks(rl, filePath) + if hidden != nil && (!ok || hidden(resolved)) { return ctxkit.ErrHidden } + // Before the open, for an FS that is no StatFS (an fs.Sub of an os.DirFS): + // open(2) of a FIFO waits for a writer. + if !isStat && ok { + if fi, err := rl.Lstat(resolved); err == nil && !fi.IsDir() && !fi.Mode().IsRegular() { + return ctxkit.ErrNotRegular + } + } } // Before the open: for an os.DirFS, open(2) of a FIFO waits for a writer. - if _, ok := fsys.(fs.StatFS); ok { + if isStat { if info, err := fs.Stat(fsys, openPath); err == nil && !info.IsDir() && !info.Mode().IsRegular() { return ctxkit.ErrNotRegular } @@ -744,9 +711,12 @@ func servePreCompressedFS(c *celeris.Context, fsys fs.FS, filePath, cacheControl } compPath := filePath + v.suffix if hidden != nil { - // a variant that is a symlink to a hidden file is no variant - if rl, ok := fsys.(fs.ReadLinkFS); ok && hidden(resolveFSLinks(rl, compPath)) { - continue + // a variant that is a symlink to a hidden file, or that leaves the FS, + // is no variant + if rl, ok := fsys.(fs.ReadLinkFS); ok { + if resolved, ok := ctxkit.ResolveFSLinks(rl, compPath); !ok || hidden(resolved) { + continue + } } } if _, ok := fsys.(fs.StatFS); ok { diff --git a/open_unix.go b/open_unix.go new file mode 100644 index 000000000..3106c709b --- /dev/null +++ b/open_unix.go @@ -0,0 +1,15 @@ +//go:build !windows + +package celeris + +import ( + "os" + "syscall" +) + +// openNonblock opens path read-only with O_NONBLOCK: for a regular file it +// changes nothing, and open(2) of a FIFO returns at once instead of waiting for +// a writer. +func openNonblock(path string) (*os.File, error) { + return os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) +} diff --git a/open_windows.go b/open_windows.go new file mode 100644 index 000000000..7876d50bd --- /dev/null +++ b/open_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package celeris + +import "os" + +func openNonblock(path string) (*os.File, error) { return os.Open(path) } From c73407df166e3f4415f851c0d52f8ae91d1c0e92 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 11:42:28 +0100 Subject: [PATCH 24/26] tests(celeris): openNonblock does not wait on a FIFO (Refs #888) --- open_nonblock_888_test.go | 49 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 open_nonblock_888_test.go diff --git a/open_nonblock_888_test.go b/open_nonblock_888_test.go new file mode 100644 index 000000000..be117e022 --- /dev/null +++ b/open_nonblock_888_test.go @@ -0,0 +1,49 @@ +//go:build !windows + +package celeris + +import ( + "os" + "path/filepath" + "syscall" + "testing" + "time" +) + +// openNonblock does not wait in open(2) for the writer of a FIFO: a regular file +// swapped for a FIFO between the stat and the open is then caught by the check +// on the open descriptor, instead of holding the request. +func TestOpenNonblockDoesNotWaitOnAFIFO888(t *testing.T) { + p := filepath.Join(t.TempDir(), "fifo") + if err := syscall.Mkfifo(p, 0o644); err != nil { + t.Skipf("no mkfifo here: %v", err) + } + done := make(chan *os.File, 1) + go func() { + f, _ := openNonblock(p) + done <- f + }() + select { + case f := <-done: + if f == nil { + t.Fatal("openNonblock of a FIFO failed") + } + _ = f.Close() + case <-time.After(5 * time.Second): + t.Fatal("openNonblock of a FIFO is waiting for a writer") + } + // a regular file still reads + reg := filepath.Join(t.TempDir(), "reg") + if err := os.WriteFile(reg, []byte("data"), 0o644); err != nil { + t.Fatal(err) + } + f, err := openNonblock(reg) + if err != nil { + t.Fatal(err) + } + defer func() { _ = f.Close() }() + b := make([]byte, 8) + if n, _ := f.Read(b); string(b[:n]) != "data" { + t.Errorf("a regular file opened non-blocking read %q, want data", b[:n]) + } +} From f28c6fa9038d0b42fb5a0ebe4b43ef1ef5e180f3 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 12:01:04 +0100 Subject: [PATCH 25/26] tests(static): a FIFO as a directory's index file or as a pre-compressed variant under os.DirFS and fs.Sub, and the link cases around the two that cannot be checked (Refs #888) --- middleware/static/fifo_fs_sites_888_test.go | 130 ++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 middleware/static/fifo_fs_sites_888_test.go diff --git a/middleware/static/fifo_fs_sites_888_test.go b/middleware/static/fifo_fs_sites_888_test.go new file mode 100644 index 000000000..b74504f33 --- /dev/null +++ b/middleware/static/fifo_fs_sites_888_test.go @@ -0,0 +1,130 @@ +package static + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/goceleris/celeris/celeristest" +) + +// Round 4 of the security review: a FIFO planted in the served tree still hung the +// request in the places an FS opens a name before looking at it: the index probe +// of a directory (d/index.html is a FIFO), and a pre-compressed variant +// (x.js.br is a FIFO). Every Open of a name from the tree is now preceded by the +// same check, ctxkit.NotRegularFS, on the resolved name. Two cases cannot be +// checked and are documented instead: a link that leaves an FS which is no +// fs.StatFS (an fs.Sub of an os.DirFS) under DotfilesAllow, and FileFromFS of +// such a name, because the FS cannot say what is beyond the link. + +func fifoTree888(t *testing.T) (top string, dirfs fs.FS, sub fs.FS) { + t.Helper() + st := newSpecialTree888(t) // reg.txt, fifo, sock, links + for p, body := range map[string]string{"x.js": "XJS", "d/other.txt": "O"} { + p = filepath.Join(st.dir, filepath.FromSlash(p)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + if err := mkfifo888(filepath.Join(st.dir, "d", "index.html")); err != nil { + t.Skip(err) + } + if err := mkfifo888(filepath.Join(st.dir, "x.js.br")); err != nil { + t.Skip(err) + } + s, err := fs.Sub(os.DirFS(filepath.Dir(st.dir)), filepath.Base(st.dir)) + if err != nil { + t.Fatal(err) + } + return st.dir, os.DirFS(st.dir), s +} + +// TestFIFOIndexAndVariantUnderFS888: GET /d/ where d/index.html is a FIFO is +// answered as a directory with no index (passed on, or listed with Browse), and +// GET /x.js with Accept-Encoding: br where x.js.br is a FIFO gets the identity +// file; neither waits for a writer. +func TestFIFOIndexAndVariantUnderFS888(t *testing.T) { + _, dirfs, sub := fifoTree888(t) + for name, fsys := range map[string]fs.FS{"dirfs": dirfs, "sub": sub} { + for _, pol := range []DotfilesPolicy{DotfilesIgnore, DotfilesDeny, DotfilesAllow} { + t.Run(name+"/index", func(t *testing.T) { + for _, browse := range []bool{false, true} { + mw := New(Config{FS: fsys, Dotfiles: pol, Browse: browse}) + rec := within888(t, "GET /d/", 5*time.Second, func() *celeristest.ResponseRecorder { + rec, _ := get888(t, mw, "/d/") + return rec + }) + if browse && (rec.StatusCode != 200 || !strings.Contains(string(rec.Body), "other.txt")) { + t.Errorf("%s policy %d Browse: GET /d/ status %d body %q, want the listing", name, pol, rec.StatusCode, rec.Body) + } + if !browse && (rec.StatusCode != 0 || len(rec.Body) != 0) { + t.Errorf("%s policy %d: GET /d/ status %d body %q, want it passed on", name, pol, rec.StatusCode, rec.Body) + } + } + }) + t.Run(name+"/variant", func(t *testing.T) { + mw := New(Config{FS: fsys, Dotfiles: pol, Compress: true}) + rec := within888(t, "GET /x.js", 5*time.Second, func() *celeristest.ResponseRecorder { + rec, _ := get888(t, mw, "/x.js", "accept-encoding", "br") + return rec + }) + if rec.StatusCode != 200 || string(rec.Body) != "XJS" || rec.Header("content-encoding") != "" { + t.Errorf("%s policy %d: GET /x.js with a FIFO .br: status %d body %q content-encoding %q, want the identity file", name, pol, rec.StatusCode, rec.Body, rec.Header("content-encoding")) + } + }) + } + } + // the SPA fallback does not open it either + spa := New(Config{FS: sub, SPA: true}) + rec := within888(t, "SPA GET /d/", 5*time.Second, func() *celeristest.ResponseRecorder { + rec, _ := get888(t, spa, "/d/") + return rec + }) + if rec.StatusCode == 200 && len(rec.Body) > 0 { + t.Logf("SPA GET /d/: %q (the index of the root, if any)", rec.Body) + } +} + +// TestFIFOLeavingLinksAreRefusedUnderIgnore888 pins the cases around the two that +// cannot be checked: under Ignore and Deny a link that leaves the FS is refused +// before anything is opened, and an os.DirFS (a StatFS) follows the link with +// stat, so a link to a FIFO is found even under Allow. +func TestFIFOLeavingLinksAreRefusedUnderIgnore888(t *testing.T) { + st := newSpecialTree888(t) + parent := filepath.Dir(st.dir) + outFifo := filepath.Join(parent, "outfifo") + if err := mkfifo888(outFifo); err != nil { + t.Skip(err) + } + t.Cleanup(func() { _ = os.Remove(outFifo) }) + for link, target := range map[string]string{"absfifo": outFifo, "relfifo": "../outfifo"} { + if err := os.Symlink(target, filepath.Join(st.dir, link)); err != nil { + t.Skipf("symlinks are not available here: %v", err) + } + } + sub, err := fs.Sub(os.DirFS(parent), filepath.Base(st.dir)) + if err != nil { + t.Fatal(err) + } + for _, p := range []string{"/absfifo", "/relfifo"} { + for name, cfg := range map[string]Config{ + "sub-ignore": {FS: sub}, "sub-deny": {FS: sub, Dotfiles: DotfilesDeny}, + "dirfs-ignore": {FS: os.DirFS(st.dir)}, "dirfs-allow": {FS: os.DirFS(st.dir), Dotfiles: DotfilesAllow}, + } { + mw := New(cfg) + rec := within888(t, name+" GET "+p, 5*time.Second, func() *celeristest.ResponseRecorder { + rec, _ := get888(t, mw, p) + return rec + }) + if rec.StatusCode == 200 || len(rec.Body) != 0 { + t.Errorf("%s GET %s: status %d body %q", name, p, rec.StatusCode, rec.Body) + } + } + } +} From 19fc4e031b19f9654676747ae4fd406842a71bbf Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Wed, 28 Oct 2026 12:03:21 +0100 Subject: [PATCH 26/26] fix(static, celeris): every Open of a name from an FS is preceded by one check: a FIFO as an index file or a pre-compressed variant is not opened; docs for what an FS cannot do (Refs #888) ctxkit.NotRegularFS looks at a name without opening it (fs.Stat for a StatFS; the resolved name's Lstat for an FS that reads links, an fs.Sub); the middleware's main open, its index probe and its variants, and FileFromFS use it. Two cases cannot be checked and are documented: a link that leaves an fs.Sub under DotfilesAllow, and a FIFO swapped in after the check (an FS cannot open non-blocking). The godoc says a path through a link that leaves the FS is refused whatever it leads to, and that /proc/self/environ is served empty. --- context_response.go | 13 +----- internal/ctxkit/fslinks.go | 32 +++++++++++++++ internal/ctxkit/notregular_test.go | 65 ++++++++++++++++++++++++++++++ middleware/static/config.go | 25 ++++++++---- middleware/static/doc.go | 14 +++++-- middleware/static/static.go | 40 +++++++++--------- 6 files changed, 145 insertions(+), 44 deletions(-) create mode 100644 internal/ctxkit/notregular_test.go diff --git a/context_response.go b/context_response.go index 08521ad00..68f62af9f 100644 --- a/context_response.go +++ b/context_response.go @@ -1223,17 +1223,8 @@ func (c *Context) fileFromDir(baseDir, userPath, contentType string, onOpen func // use [Context.FileFromDir] instead. func (c *Context) FileFromFS(name string, fsys fs.FS) error { // Before the open: for an os.DirFS, open(2) of a FIFO waits for a writer. - if _, ok := fsys.(fs.StatFS); ok { - if info, err := fs.Stat(fsys, name); err == nil && notServable(info) { - return errNotRegular() - } - } else if rl, ok := fsys.(fs.ReadLinkFS); ok { - // an fs.Sub of an os.DirFS is no StatFS: look at where its links lead - if resolved, ok := ctxkit.ResolveFSLinks(rl, name); ok { - if info, err := rl.Lstat(resolved); err == nil && notServable(info) { - return errNotRegular() - } - } + if bad, _ := ctxkit.NotRegularFS(fsys, name); bad { + return errNotRegular() } f, err := fsys.Open(name) if err != nil { diff --git a/internal/ctxkit/fslinks.go b/internal/ctxkit/fslinks.go index 6bc42c17f..ccbfa6459 100644 --- a/internal/ctxkit/fslinks.go +++ b/internal/ctxkit/fslinks.go @@ -98,3 +98,35 @@ func walkFSLinks(rl fs.ReadLinkFS, name string) (string, bool) { } return strings.Join(resolved, "/"), true } + +// NotRegularFS reports whether name in fsys is something that must not be +// opened: neither a regular file nor a directory (a FIFO, a socket, a device), +// found without opening it. Every Open of a name taken from a served tree goes +// through it first, because open(2) of a FIFO waits for a writer. An +// fs.StatFS is stat-ed (an os.DirFS follows links); an FS that reads links but has +// no Stat (an fs.Sub of an os.DirFS) is looked at through ResolveFSLinks and an +// Lstat of the resolved name. checked is false when neither is possible: the FS +// has no way to look (it is opened as it is), or a link on the way leaves the FS +// and the FS cannot say what is beyond it. +func NotRegularFS(fsys fs.FS, name string) (notRegular, checked bool) { + if _, ok := fsys.(fs.StatFS); ok { + info, err := fs.Stat(fsys, name) + if err != nil { + return false, true // it does not exist: the open will say so + } + return !info.IsDir() && !info.Mode().IsRegular(), true + } + rl, ok := fsys.(fs.ReadLinkFS) + if !ok { + return false, false + } + resolved, ok := ResolveFSLinks(rl, name) + if !ok { + return false, false + } + info, err := rl.Lstat(resolved) + if err != nil { + return false, true + } + return !info.IsDir() && !info.Mode().IsRegular(), true +} diff --git a/internal/ctxkit/notregular_test.go b/internal/ctxkit/notregular_test.go new file mode 100644 index 000000000..29b73be58 --- /dev/null +++ b/internal/ctxkit/notregular_test.go @@ -0,0 +1,65 @@ +package ctxkit + +import ( + "io/fs" + "testing" + "testing/fstest" +) + +// linkOnly is an FS that reads links but has no Stat, as an fs.Sub of an os.DirFS. +type linkOnly struct{ m fstest.MapFS } + +func (l linkOnly) Open(name string) (fs.File, error) { return l.m.Open(name) } +func (l linkOnly) ReadLink(name string) (string, error) { return l.m.ReadLink(name) } +func (l linkOnly) Lstat(name string) (fs.FileInfo, error) { return l.m.Lstat(name) } + +func TestNotRegularFS(t *testing.T) { + m := fstest.MapFS{ + "reg": {Data: []byte("x"), Mode: 0o644}, + "fifo": {Mode: fs.ModeNamedPipe | 0o644}, + "dev": {Mode: fs.ModeDevice | fs.ModeCharDevice}, + "d/file": {Data: []byte("y")}, + "lnk-fifo": {Data: []byte("fifo"), Mode: fs.ModeSymlink}, + "lnk-reg": {Data: []byte("reg"), Mode: fs.ModeSymlink}, + "lnk-out": {Data: []byte("../outside"), Mode: fs.ModeSymlink}, + "lnk-abs": {Data: []byte("/etc/x"), Mode: fs.ModeSymlink}, + "d2/in-fifo": {Data: []byte("../fifo"), Mode: fs.ModeSymlink}, + } + for _, tc := range []struct { + name string + wantBad, wantChecked bool + }{ + {"reg", false, true}, {"fifo", true, true}, {"dev", true, true}, {"d", false, true}, {"d/file", false, true}, + {"missing", false, true}, {"lnk-fifo", true, true}, {"lnk-reg", false, true}, {"d2/in-fifo", true, true}, + } { + // a StatFS follows the links itself (a MapFS does not follow links in Stat, so + // it is only asked about the names that are not links) + if tc.name == "lnk-fifo" || tc.name == "lnk-reg" || tc.name == "d2/in-fifo" { + continue + } + if bad, checked := NotRegularFS(m, tc.name); bad != tc.wantBad || checked != tc.wantChecked { + t.Errorf("StatFS: NotRegularFS(%q) = %v, %v; want %v, %v", tc.name, bad, checked, tc.wantBad, tc.wantChecked) + } + } + lo := linkOnly{m} + for _, tc := range []struct { + name string + wantBad, wantChecked bool + }{ + {"reg", false, true}, {"fifo", true, true}, {"dev", true, true}, {"d", false, true}, {"missing", false, true}, + {"lnk-fifo", true, true}, {"lnk-reg", false, true}, {"d2/in-fifo", true, true}, + {"lnk-out", false, false}, {"lnk-abs", false, false}, + } { + if bad, checked := NotRegularFS(lo, tc.name); bad != tc.wantBad || checked != tc.wantChecked { + t.Errorf("link-only FS: NotRegularFS(%q) = %v, %v; want %v, %v", tc.name, bad, checked, tc.wantBad, tc.wantChecked) + } + } + // an FS with neither: nothing to look with + if bad, checked := NotRegularFS(onlyOpen{m}, "fifo"); bad || checked { + t.Errorf("open-only FS: NotRegularFS = %v, %v; want false, false", bad, checked) + } +} + +type onlyOpen struct{ m fstest.MapFS } + +func (o onlyOpen) Open(name string) (fs.File, error) { return o.m.Open(name) } diff --git a/middleware/static/config.go b/middleware/static/config.go index 113f71d39..5efad1d0d 100644 --- a/middleware/static/config.go +++ b/middleware/static/config.go @@ -58,7 +58,7 @@ type Config struct { // "../../.." reaching it, a symlink to it, a drive root such as C:\ on // Windows. New panics with a message that names the resolved path, unless // [Config].AllowFilesystemRoot is set: such a Root serves every file the - // process can read (/etc/passwd, /proc/self/environ, keys without a dot in + // process can read (/etc/passwd, keys without a dot in // their path), nothing is outside it, and the dotfile rule is no secrets // filter. The refusal looks at the Root once, when New is called, and the Root // should exist then (one that does not is not refused, and serves nothing @@ -138,15 +138,24 @@ type Config struct { // more than 8 links on a path (a loop included), or a walk of more than 256 // steps, is not followed and the path is refused like a hidden one, because // the FS cannot say what is beyond it (an os.DirFS lets the operating system - // follow it); DotfilesAllow follows them all, as before. An FS that is neither - // an [fs.StatFS] nor an [fs.ReadLinkFS] is opened as it is: a FIFO in it - // blocks the request. Browse leaves + // follow it); DotfilesAllow follows them all, as before. A path through such + // a link is refused whatever it leads to: a link with an absolute target to a + // visible file, a re-entry by the FS root's own name (../pub/x), a chain of + // more than 8 links (a "current -> /srv/releases/42" layout under an FS needs + // DotfilesAllow, or a Root). Every name of the tree is looked at before it is + // opened, a directory's index file and a pre-compressed variant included, so + // a FIFO planted in an [os.DirFS] or an fs.Sub of one is answered as a + // missing file; but an FS cannot be opened without blocking, so a FIFO + // swapped in after that look can still block a request, and an FS that is + // neither an [fs.StatFS] nor an [fs.ReadLinkFS] is opened as it is, as is a + // link that leaves an fs.Sub under DotfilesAllow (the FS cannot say what is + // beyond it). Browse leaves // hidden entries out of a listing. The Index file // is the operator's choice and is served even if its name starts with a dot. // // The rule is not a secrets filter: it looks at the names in the request // path and at where symlinks lead, nothing else. A secret whose path has no - // dot segment (/etc/shadow readable by the process, /proc/self/environ, an + // dot segment (/etc/shadow readable by the process, an // id_rsa, /var/run/secrets/...) is served from any Root that reaches it. And // it is not a sandbox against a writable tree: a symlink swapped between the // check and the open, and a hard link to a hidden file, defeat any path rule, @@ -161,8 +170,10 @@ type Config struct { // is refused at New, with a panic that names the resolved path, unless this // is true: such a Root serves every file the process can read below it, and // nothing is outside it any more. A filesystem root also serves /proc and - // /sys: their regular files (/proc/self/environ, /proc/self/status) are - // served, those that report a size of 0 as an empty body. Default: false. + // /sys: the /proc files that report a size of 0 (/proc/self/environ, + // /proc/self/status) are served empty, not with their content; /sys files and + // any other pseudo-file that reports a size are served for the bytes they + // hold, and can expose process or machine state. Default: false. // // Not every way to reach a filesystem root is refused: an FS // ([os.DirFS]("/"), or os.DirFS(".") in a working directory of "/") has no diff --git a/middleware/static/doc.go b/middleware/static/doc.go index d7553bec0..1c6ffb720 100644 --- a/middleware/static/doc.go +++ b/middleware/static/doc.go @@ -51,9 +51,15 @@ // relative Root to an absolute path at startup. The Root should exist then; a // Root that is or contains a symlink is followed at each request, so a later // retarget to "/" is not refused. Devices, FIFOs and sockets are -// never served: they are answered as a missing file, since reading /dev/zero -// never ends and opening a FIFO waits for a writer. Name the directory that holds -// what is public; see [Config].Root. +// not served: a Root answers them as a missing file, since reading /dev/zero +// never ends and opening a FIFO waits for a writer. A [Config].FS cannot be held +// to as much: every name of the tree is looked at before it is opened (an +// os.DirFS and an fs.Sub of one are), so a FIFO planted in it, as an index file +// or as a pre-compressed variant too, is answered as a missing file, but an FS +// cannot open non-blocking, so a FIFO swapped in after that look can still +// block one request; and a link that leaves an fs.Sub, followed under +// DotfilesAllow, ends where the FS cannot look, so a FIFO there blocks too. +// Name the directory that holds what is public; see [Config].Root. // // # Dotfiles // @@ -85,7 +91,7 @@ // // The rule is not a secrets filter. It looks at the names in the request path // and at where symlinks lead, and nothing else: a secret whose path has no dot -// segment (/etc/shadow when the process can read it, /proc/self/environ, an +// segment (/etc/shadow when the process can read it, an // id_rsa, /var/run/secrets/...) is served from any Root that reaches it, and // that is what an acknowledged filesystem root does. Nor is it a sandbox for a // tree that others can write to: a symlink swapped between the check and the diff --git a/middleware/static/static.go b/middleware/static/static.go index 3848d025d..6acd454be 100644 --- a/middleware/static/static.go +++ b/middleware/static/static.go @@ -511,25 +511,15 @@ func serveFS(c *celeris.Context, fsys fs.FS, filePath, index string, browse, spa // A link that leaves the FS (an absolute target, or one that climbs above the // FS root) is not followed: the FS cannot say what is beyond it, and an // os.DirFS lets the OS follow it, so the path is refused as hidden. - _, isStat := fsys.(fs.StatFS) - if rl, ok := fsys.(fs.ReadLinkFS); ok && filePath != "" && (hidden != nil || !isStat) { - resolved, ok := ctxkit.ResolveFSLinks(rl, filePath) - if hidden != nil && (!ok || hidden(resolved)) { + if rl, ok := fsys.(fs.ReadLinkFS); ok && hidden != nil && filePath != "" { + if resolved, ok := ctxkit.ResolveFSLinks(rl, filePath); !ok || hidden(resolved) { return ctxkit.ErrHidden } - // Before the open, for an FS that is no StatFS (an fs.Sub of an os.DirFS): - // open(2) of a FIFO waits for a writer. - if !isStat && ok { - if fi, err := rl.Lstat(resolved); err == nil && !fi.IsDir() && !fi.Mode().IsRegular() { - return ctxkit.ErrNotRegular - } - } } - // Before the open: for an os.DirFS, open(2) of a FIFO waits for a writer. - if isStat { - if info, err := fs.Stat(fsys, openPath); err == nil && !info.IsDir() && !info.Mode().IsRegular() { - return ctxkit.ErrNotRegular - } + // Before the open: open(2) of a FIFO waits for a writer. Every Open of a name + // from the tree below is preceded by this check. + if bad, _ := ctxkit.NotRegularFS(fsys, openPath); bad { + return ctxkit.ErrNotRegular } f, err := fsys.Open(openPath) @@ -554,12 +544,15 @@ func serveFS(c *celeris.Context, fsys fs.FS, filePath, index string, browse, spa } else { indexPath = filePath + "/" + index } - indexFile, indexErr := fsys.Open(indexPath) - if indexErr == nil { - indexStat, statErr := indexFile.Stat() - _ = indexFile.Close() - if statErr == nil && !indexStat.IsDir() { - return serveFS(c, fsys, indexPath, index, browse, spa, compress, cacheControl, cache, hidden) + // an index that is a FIFO (or any non-regular file) is no index: it is not opened + if bad, _ := ctxkit.NotRegularFS(fsys, indexPath); !bad { + indexFile, indexErr := fsys.Open(indexPath) + if indexErr == nil { + indexStat, statErr := indexFile.Stat() + _ = indexFile.Close() + if statErr == nil && !indexStat.IsDir() { + return serveFS(c, fsys, indexPath, index, browse, spa, compress, cacheControl, cache, hidden) + } } } if browse { @@ -719,6 +712,9 @@ func servePreCompressedFS(c *celeris.Context, fsys fs.FS, filePath, cacheControl } } } + if bad, _ := ctxkit.NotRegularFS(fsys, compPath); bad { + continue + } if _, ok := fsys.(fs.StatFS); ok { if info, err := fs.Stat(fsys, compPath); err != nil || !info.Mode().IsRegular() { continue