diff --git a/api/middleware/static.txt b/api/middleware/static.txt index 488684dac..e86a76683 100644 --- a/api/middleware/static.txt +++ b/api/middleware/static.txt @@ -2,10 +2,15 @@ # 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, AllowFilesystemRoot bool 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 +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 +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/context_response.go b/context_response.go index 0ecd7523b..68f62af9f 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" @@ -934,6 +935,35 @@ 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 + } + 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 +// 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 @@ -941,7 +971,25 @@ 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 { - f, err := os.Open(filePath) + // 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) +} + +// 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 +// 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 := openForServe(filePath) if err != nil { return err } @@ -951,6 +999,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) { @@ -1008,7 +1059,11 @@ func (c *Context) serveFile(filePath, contentType string, onOpen func(modTime ti return err } - data, err := io.ReadAll(f) + // 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 } @@ -1069,16 +1124,56 @@ 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. 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). 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, 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) + 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) || 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") + } + 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 + // 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 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,9 +1185,20 @@ 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") } + 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 @@ -1100,7 +1206,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 @@ -1113,6 +1222,10 @@ 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 bad, _ := ctxkit.NotRegularFS(fsys, name); bad { + return errNotRegular() + } f, err := fsys.Open(name) if err != nil { return err @@ -1126,6 +1239,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") @@ -1145,7 +1261,7 @@ func (c *Context) FileFromFS(name string, fsys fs.FS) error { } } - data, err := io.ReadAll(f) + data, err := io.ReadAll(io.LimitReader(f, size)) if err != nil { return err } diff --git a/file_from_dir_root_888_test.go b/file_from_dir_root_888_test.go new file mode 100644 index 000000000..6a31252a3 --- /dev/null +++ b/file_from_dir_root_888_test.go @@ -0,0 +1,193 @@ +package celeris + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/goceleris/celeris/internal/ctxkit" +) + +// 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) + } + } + }) +} + +// 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) + } + } +} + +func isNotRegular888(err error) bool { + var he *HTTPError + return errors.As(err, &he) && he.Code == 404 && errors.Is(err, ctxkit.ErrNotRegular) +} 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 b690df9f3..2b38529a2 100644 --- a/group.go +++ b/group.go @@ -123,11 +123,24 @@ 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 ".", +// "./" (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. 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.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/fslinks.go b/internal/ctxkit/fslinks.go new file mode 100644 index 000000000..ccbfa6459 --- /dev/null +++ b/internal/ctxkit/fslinks.go @@ -0,0 +1,132 @@ +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 +} + +// 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/fsroot.go b/internal/ctxkit/fsroot.go new file mode 100644 index 000000000..6fa5c7735 --- /dev/null +++ b/internal/ctxkit/fsroot.go @@ -0,0 +1,37 @@ +package ctxkit + +import ( + "path/filepath" +) + +// 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 new file mode 100644 index 000000000..17d85ced3 --- /dev/null +++ b/internal/ctxkit/fsroot_test.go @@ -0,0 +1,80 @@ +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}, {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)) + 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/internal/ctxkit/hidden.go b/internal/ctxkit/hidden.go new file mode 100644 index 000000000..09207617b --- /dev/null +++ b/internal/ctxkit/hidden.go @@ -0,0 +1,148 @@ +package ctxkit + +import ( + "errors" + "net/url" + "path/filepath" + "runtime" + "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") + +// 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" + +// 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". 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 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 + } + 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 +} + +// 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". 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, separators()) + 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/internal/ctxkit/hidden_test.go b/internal/ctxkit/hidden_test.go new file mode 100644 index 000000000..9b009b394 --- /dev/null +++ b/internal/ctxkit/hidden_test.go @@ -0,0 +1,106 @@ +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}, + // 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}, + // 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/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/internal/ctxkit/pathwithin.go b/internal/ctxkit/pathwithin.go new file mode 100644 index 000000000..a66687961 --- /dev/null +++ b/internal/ctxkit/pathwithin.go @@ -0,0 +1,128 @@ +package ctxkit + +import ( + "os" + "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 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. 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) != "" { + return false + } + return !climbs(p) + } + if !strings.HasPrefix(p, base) { + return false + } + 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, 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) { + return false + } + ab, err := absPhysical(base) + if err != nil { + return false + } + ar, err := absPhysical(resolved) + if err != nil { + return false + } + 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/internal/ctxkit/pathwithin_test.go b/internal/ctxkit/pathwithin_test.go new file mode 100644 index 000000000..c5e2aa7c6 --- /dev/null +++ b/internal/ctxkit/pathwithin_test.go @@ -0,0 +1,132 @@ +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}, + } + // 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 + } + 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) + } + } + 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) { + 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}, + // 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/config.go b/middleware/static/config.go index e1c998aaa..5efad1d0d 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. @@ -18,6 +41,36 @@ 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, 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 + // it resolves to, and nothing outside that directory is served. + // + // 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, 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 + // 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 + // 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). @@ -66,6 +119,70 @@ 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 (.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. 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. 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, 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 + // 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. A filesystem root also serves /proc and + // /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 + // 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 } var defaultConfig = Config{ @@ -90,4 +207,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/doc.go b/middleware/static/doc.go index 3680565fd..1c6ffb720 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. // @@ -31,14 +33,75 @@ // 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 ".", "./" 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. 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 +// 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 +// +// 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. 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 +// 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 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, 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 +// 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/dotfiles_888_test.go b/middleware/static/dotfiles_888_test.go new file mode 100644 index 000000000..4b9aa97e0 --- /dev/null +++ b/middleware/static/dotfiles_888_test.go @@ -0,0 +1,537 @@ +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) + } + 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) + } + 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", + "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) + } + } + 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"}, + {"/chain1", true, "ENV"}, {"/chain2", true, "ENV"}, {"/lnkrel", true, "ENV"}, {"/lnkabs", true, "ENV"}, + // (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 + {"/", 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, "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, "//") + 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) + } +} diff --git a/middleware/static/dotfiles_r2_888_test.go b/middleware/static/dotfiles_r2_888_test.go new file mode 100644 index 000000000..743be1d80 --- /dev/null +++ b/middleware/static/dotfiles_r2_888_test.go @@ -0,0 +1,177 @@ +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, "//") + // 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 + 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 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 + 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(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, an absolute 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/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/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/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) + } + } + } +} diff --git a/middleware/static/filesystem_root_888_test.go b/middleware/static/filesystem_root_888_test.go new file mode 100644 index 000000000..5c6d4100b --- /dev/null +++ b/middleware/static/filesystem_root_888_test.go @@ -0,0 +1,203 @@ +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 + "/", ""}, + } { + 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 +} 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 new file mode 100644 index 000000000..86fdec5ea --- /dev/null +++ b/middleware/static/nonregular_888_test.go @@ -0,0 +1,335 @@ +package static + +import ( + "fmt" + "net" + "os" + "path/filepath" + "runtime" + "runtime/debug" + "strings" + "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 := mkfifo888(filepath.Join(dir, "fifo")); 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: "/", 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}, + } + 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 + }) + // 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": + // 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, 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) + 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 "/" 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) + 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: ".", 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) + 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) + } + }) + } + } +} 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_dotdot_888_test.go b/middleware/static/root_dotdot_888_test.go new file mode 100644 index 000000000..c44cb9b8e --- /dev/null +++ b/middleware/static/root_dotdot_888_test.go @@ -0,0 +1,296 @@ +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/../ABOVE.txt T/TOPSECRET.txt T/p/PSECRET.txt T/p/other/o.txt +// 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 +// (the working directory joined with the root); then, for every file F of the +// tree, the request "/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 ->