| author | Alberto Bertogli
<albertito@blitiri.com.ar> 2026-05-22 09:18:49 UTC |
| committer | Alberto Bertogli
<albertito@blitiri.com.ar> 2026-07-10 09:18:33 UTC |
| parent | 67c526503b70fc0620f7e71cfc8557c72c01e0e1 |
| config/config.go | +4 | -1 |
| config/config_test.go | +29 | -0 |
| config/gofer.schema.cue | +2 | -0 |
| config/gofer.yaml | +23 | -1 |
| server/dir.go | +51 | -5 |
| server/dir_test.go | +41 | -0 |
| server/http.go | +11 | -2 |
| server/write.go | +185 | -0 |
| server/write_test.go | +54 | -0 |
| test/01-be.yaml | +12 | -0 |
| test/01-fe.yaml | +2 | -0 |
| test/test.sh | +166 | -0 |
| test/testdata/expected-printed-01-be-config | +12 | -0 |
| test/util/exp/exp.go | +39 | -1 |
diff --git a/config/config.go b/config/config.go index e4f58f5..9c92453 100644 --- a/config/config.go +++ b/config/config.go @@ -75,6 +75,8 @@ type Route struct { type DirOpts struct { Listing map[string]bool `yaml:",omitempty"` Exclude []PathRegexp `yaml:",omitempty"` + Put map[string]bool `yaml:",omitempty"` + Delete map[string]bool `yaml:",omitempty"` } type Raw struct { @@ -153,7 +155,8 @@ func (h HTTP) Check(c Config, addr string) []error { } for path, r := range h.Routes { - if len(r.DirOpts.Listing)+len(r.DirOpts.Exclude) > 0 && r.Dir == "" { + if len(r.DirOpts.Listing)+len(r.DirOpts.Exclude)+ + len(r.DirOpts.Put)+len(r.DirOpts.Delete) > 0 && r.Dir == "" { errs = append(errs, fmt.Errorf("%q: %q: diropts is set on non-dir route", addr, path)) diff --git a/config/config_test.go b/config/config_test.go index 6e58121..0edf345 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -181,6 +181,35 @@ https: expectErrs(t, `":https": certs or autocerts must be set`, loadAndCheck(t, contents)) + // diropts.put / diropts.delete on a non-directory. + contents = ` +https: + ":https": + certs: "/dev/null" + routes: + "/": + file: "/dev/null" + diropts: + put: + "/": true +` + expectErrs(t, `":https": "/": diropts is set on non-dir route`, + loadAndCheck(t, contents)) + + contents = ` +https: + ":https": + certs: "/dev/null" + routes: + "/": + file: "/dev/null" + diropts: + delete: + "/": true +` + expectErrs(t, `":https": "/": diropts is set on non-dir route`, + loadAndCheck(t, contents)) + // reqlog reference (http). contents = ` https: diff --git a/config/gofer.schema.cue b/config/gofer.schema.cue index 2e2191e..8cda0fe 100644 --- a/config/gofer.schema.cue +++ b/config/gofer.schema.cue @@ -58,6 +58,8 @@ https?: diropts?: { listing?: [string]: bool exclude?: [string] + put?: [string]: bool + delete?: [string]: bool } // If diropts is set, then dir must be set too. diff --git a/config/gofer.yaml b/config/gofer.yaml index f2d124b..69815c3 100644 --- a/config/gofer.yaml +++ b/config/gofer.yaml @@ -105,9 +105,31 @@ http: # Exclude files matching these regular expressions. They won't appear # in listings, and won't be served to users (404 will be returned - # instead). + # instead). The exclusions also apply to PUT and DELETE. #exclude: [".*\\.secret", ".*/config"] + # Allow PUT (upload) on the given path prefixes. Longest prefix wins. + # Default is disabled. + # When enabled, PUT writes a file at the target path, atomically + # (temp file + rename). Parent directories are created as needed. + # If-Unmodified-Since is honored: a stale value returns 412. + # WARNING: enabling PUT without "auth:" exposes the directory to + # anonymous writes. + # NOTE: symlinks under the served directory are followed (same as + # for GET). A symlink-directory under the root therefore allows PUT + # to write outside the root. Don't place such symlinks if you don't + # want that. + #put: + # "/pub/": true + + # Allow DELETE on the given path prefixes. Longest prefix wins. + # Default is disabled. Only files can be deleted; DELETE on a + # directory returns 409. If-Unmodified-Since is honored. + # WARNING: as with put, enabling DELETE without "auth:" exposes the + # directory to anonymous deletions. + #delete: + # "/pub/": true + # Enforce authentication on these paths. The target is the file containing # the user and passwords. #auth: diff --git a/server/dir.go b/server/dir.go index ec9b215..4add0fa 100644 --- a/server/dir.go +++ b/server/dir.go @@ -12,17 +12,25 @@ import ( type FileSystem struct { fs http.FileSystem + // On-disk root of the served directory. Used by the PUT/DELETE handlers + // to resolve request paths to filesystem paths. Empty if the underlying + // http.FileSystem is not backed by a local directory. + root string + opts config.DirOpts } -func NewFS(fs http.FileSystem, opts config.DirOpts) *FileSystem { +func NewFS(root string, fs http.FileSystem, opts config.DirOpts) *FileSystem { return &FileSystem{ fs: fs, + root: root, opts: opts, } } -func ListingEnabled(opts *config.DirOpts, name string) bool { +// prefixMatch returns the value associated with the longest prefix of name in +// m, or false if no prefix matches. +func prefixMatch(m map[string]bool, name string) bool { if name == "" { name = "/" } @@ -30,7 +38,7 @@ func ListingEnabled(opts *config.DirOpts, name string) bool { longestP := "" value := false - for p, val := range opts.Listing { + for p, val := range m { p = filepath.Clean(p) if strings.HasPrefix(name, p) && len(p) > len(longestP) { longestP = p @@ -41,12 +49,50 @@ func ListingEnabled(opts *config.DirOpts, name string) bool { return value } -func (fs *FileSystem) Open(name string) (http.File, error) { +func ListingEnabled(opts *config.DirOpts, name string) bool { + return prefixMatch(opts.Listing, name) +} + +func PutEnabled(opts *config.DirOpts, name string) bool { + return prefixMatch(opts.Put, name) +} + +func DeleteEnabled(opts *config.DirOpts, name string) bool { + return prefixMatch(opts.Delete, name) +} + +// localPath resolves a URL path to an absolute on-disk path under fs.root. +// It returns an error if writes are not supported (fs.root is unset) or if +// the resulting path would escape the root. +func (fs *FileSystem) localPath(name string) (string, error) { + if fs.root == "" { + return "", os.ErrPermission + } + absRoot, err := filepath.Abs(fs.root) + if err != nil { + return "", err + } + abs := filepath.Join(absRoot, filepath.FromSlash(name)) + rel, err := filepath.Rel(absRoot, abs) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", os.ErrPermission + } + return abs, nil +} + +func (fs *FileSystem) excluded(name string) bool { for _, re := range fs.opts.Exclude { if re.MatchString(name) { - return nil, os.ErrNotExist + return true } } + return false +} + +func (fs *FileSystem) Open(name string) (http.File, error) { + if fs.excluded(name) { + return nil, os.ErrNotExist + } f, err := fs.fs.Open(name) if err != nil { diff --git a/server/dir_test.go b/server/dir_test.go new file mode 100644 index 0000000..d46dfd3 --- /dev/null +++ b/server/dir_test.go @@ -0,0 +1,41 @@ +package server + +import "testing" + +func TestPrefixMatch(t *testing.T) { + m := map[string]bool{ + "/": true, + "/readonly/": false, + "/deep/sub/": true, + } + cases := []struct { + name string + want bool + }{ + // Root prefix. + {"/", true}, + {"/anything", true}, + {"/anything/else", true}, + + // Longest prefix wins. + {"/readonly/x", false}, + // filepath.Clean strips the trailing slash from both name and + // prefix, so "/readonly" matches the "/readonly/" entry too. + {"/readonly", false}, + {"/deep/sub/x", true}, + + // Empty name treated as "/". + {"", true}, + } + for _, c := range cases { + got := prefixMatch(m, c.name) + if got != c.want { + t.Errorf("prefixMatch(%q) = %v, want %v", c.name, got, c.want) + } + } + + // Empty map -> always false. + if prefixMatch(nil, "/anything") { + t.Errorf("prefixMatch(nil, ...) = true, want false") + } +} diff --git a/server/http.go b/server/http.go index dffe7b7..3fec186 100644 --- a/server/http.go +++ b/server/http.go @@ -259,7 +259,8 @@ func adjustPath(req string, from string, to string) string { } func makeDir(path string, dir string, opts config.DirOpts) http.Handler { - fs := FileServer(NewFS(http.Dir(dir), opts)) + fs := NewFS(dir, http.Dir(dir), opts) + srv := FileServer(fs) path = stripDomain(path) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -271,7 +272,15 @@ func makeDir(path string, dir string, opts config.DirOpts) http.Handler { r.URL.Path = "/" + r.URL.Path } tr.Printf("adjusted dir: %q", r.URL.Path) - fs.ServeHTTP(w, r) + + switch r.Method { + case http.MethodPut: + handlePut(fs, w, r) + case http.MethodDelete: + handleDelete(fs, w, r) + default: + srv.ServeHTTP(w, r) + } }) } diff --git a/server/write.go b/server/write.go new file mode 100644 index 0000000..ae22d1c --- /dev/null +++ b/server/write.go @@ -0,0 +1,185 @@ +package server + +import ( + "io" + "net/http" + "os" + "path" + "path/filepath" + "time" + + "blitiri.com.ar/go/gofer/trace" +) + +// handlePut implements PUT for the dir route. +// +// Required diropts: put: { "<prefix>": true } for the request path. +// On success, returns 201 Created (new file) or 204 No Content (replaced), +// with a Last-Modified header reflecting the stored file. +// PUT onto an existing directory returns 409 Conflict. +// Parent directories are created automatically. +// If-Unmodified-Since is honored when the target file already exists. +func handlePut(fs *FileSystem, w http.ResponseWriter, r *http.Request) { + tr, _ := trace.FromContext(r.Context()) + + name := path.Clean(r.URL.Path) + if !PutEnabled(&fs.opts, name) { + tr.Printf("PUT not enabled for %q", name) + http.Error(w, "405 Method Not Allowed", http.StatusMethodNotAllowed) + return + } + if fs.excluded(name) { + tr.Printf("excluded path %q", name) + http.Error(w, "404 Not found", http.StatusNotFound) + return + } + + abs, err := fs.localPath(name) + if err != nil { + tr.Printf("localPath %q: %v", name, err) + toHTTPError(w, err) + return + } + + existed := false + if fi, err := os.Stat(abs); err == nil { + if fi.IsDir() { + tr.Printf("PUT onto directory %q", abs) + http.Error(w, "409 Conflict (is a directory)", + http.StatusConflict) + return + } + existed = true + if !checkIfUnmodifiedSince(tr, r, fi.ModTime()) { + http.Error(w, "412 Precondition Failed", + http.StatusPreconditionFailed) + return + } + } + + if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil { + tr.Printf("mkdirall %q: %v", filepath.Dir(abs), err) + toHTTPError(w, err) + return + } + + // Atomic write: temp file in target directory, then rename. + tmp, err := os.CreateTemp(filepath.Dir(abs), ".gofer-put-*.tmp") + if err != nil { + tr.Printf("createtemp: %v", err) + toHTTPError(w, err) + return + } + tmpName := tmp.Name() + cleanup := true + defer func() { + if cleanup { + os.Remove(tmpName) + } + }() + + if _, err := io.Copy(tmp, r.Body); err != nil { + tmp.Close() + tr.Printf("copy body to %q: %v", tmpName, err) + toHTTPError(w, err) + return + } + if err := tmp.Close(); err != nil { + tr.Printf("close %q: %v", tmpName, err) + toHTTPError(w, err) + return + } + if err := os.Rename(tmpName, abs); err != nil { + tr.Printf("rename %q -> %q: %v", tmpName, abs, err) + toHTTPError(w, err) + return + } + cleanup = false + + // Return Last-Modified reflecting the stored file, so clients have the + // validator to use for subsequent If-Unmodified-Since requests without an + // extra round trip (RFC 9110 §9.3.4). The body is stored untransformed. + if fi, err := os.Stat(abs); err == nil { + w.Header().Set("Last-Modified", + fi.ModTime().UTC().Format(http.TimeFormat)) + } + + if existed { + tr.Printf("PUT %q: replaced", abs) + w.WriteHeader(http.StatusNoContent) + } else { + tr.Printf("PUT %q: created", abs) + w.WriteHeader(http.StatusCreated) + } +} + +// handleDelete implements DELETE for the dir route. +// +// Required diropts: delete: { "<prefix>": true } for the request path. +// Files only: DELETE on a directory returns 409 Conflict. +// If-Unmodified-Since is honored. +func handleDelete(fs *FileSystem, w http.ResponseWriter, r *http.Request) { + tr, _ := trace.FromContext(r.Context()) + + name := path.Clean(r.URL.Path) + if !DeleteEnabled(&fs.opts, name) { + tr.Printf("DELETE not enabled for %q", name) + http.Error(w, "405 Method Not Allowed", http.StatusMethodNotAllowed) + return + } + if fs.excluded(name) { + tr.Printf("excluded path %q", name) + http.Error(w, "404 Not found", http.StatusNotFound) + return + } + + abs, err := fs.localPath(name) + if err != nil { + tr.Printf("localPath %q: %v", name, err) + toHTTPError(w, err) + return + } + + fi, err := os.Lstat(abs) + if err != nil { + tr.Printf("lstat %q: %v", abs, err) + toHTTPError(w, err) + return + } + if fi.IsDir() { + tr.Printf("DELETE on directory %q", abs) + http.Error(w, "409 Conflict (is a directory)", http.StatusConflict) + return + } + if !checkIfUnmodifiedSince(tr, r, fi.ModTime()) { + http.Error(w, "412 Precondition Failed", + http.StatusPreconditionFailed) + return + } + + if err := os.Remove(abs); err != nil { + tr.Printf("remove %q: %v", abs, err) + toHTTPError(w, err) + return + } + + tr.Printf("DELETE %q", abs) + w.WriteHeader(http.StatusNoContent) +} + +// checkIfUnmodifiedSince returns true if the request may proceed: either the +// header is absent/malformed, or the file's mtime is at or before the value +// in the header (compared at 1-second granularity, per RFC 9110 §13.1.4). +func checkIfUnmodifiedSince(tr *trace.Trace, r *http.Request, mtime time.Time) bool { + h := r.Header.Get("If-Unmodified-Since") + if h == "" { + return true + } + t, err := http.ParseTime(h) + if err != nil { + tr.Printf("if-unmodified-since parse error %q: %v", h, err) + return true + } + tr.Printf("if-unmodified-since: %v, mtime: %v", t, mtime) + return mtime.Truncate(time.Second).Compare(t) <= 0 +} diff --git a/server/write_test.go b/server/write_test.go new file mode 100644 index 0000000..181e837 --- /dev/null +++ b/server/write_test.go @@ -0,0 +1,54 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "blitiri.com.ar/go/gofer/config" + "blitiri.com.ar/go/gofer/trace" +) + +// put issues a PUT through handlePut and returns the response. ius, if not +// empty, is sent as the If-Unmodified-Since header. +func put(fs *FileSystem, path, body, ius string) *http.Response { + req := httptest.NewRequest("PUT", "http://unused"+path, + strings.NewReader(body)) + if ius != "" { + req.Header.Set("If-Unmodified-Since", ius) + } + tr := trace.New("test", path) + req = req.WithContext(trace.NewContext(req.Context(), tr)) + w := httptest.NewRecorder() + handlePut(fs, w, req) + return w.Result() +} + +// TestPutLastModified checks that PUT returns a Last-Modified header, and that +// feeding it straight back as If-Unmodified-Since satisfies the precondition +// (rather than failing it). This is what lets a client chain writes without an +// extra round trip to learn the new validator. +func TestPutLastModified(t *testing.T) { + root := t.TempDir() + fs := NewFS(root, http.Dir(root), config.DirOpts{ + Put: map[string]bool{"/": true}, + }) + + // Create the file; the response carries its Last-Modified. + resp := put(fs, "/f.txt", "one", "") + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create: status = %d, want 201", resp.StatusCode) + } + lm := resp.Header.Get("Last-Modified") + if lm == "" { + t.Fatal("create: no Last-Modified header") + } + + // Replacing it with that exact validator must pass the precondition. + resp = put(fs, "/f.txt", "two", lm) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("replace: status = %d, want 204 (validator did not "+ + "round-trip)", resp.StatusCode) + } +} diff --git a/test/01-be.yaml b/test/01-be.yaml index fdbd337..9d3ee0f 100644 --- a/test/01-be.yaml +++ b/test/01-be.yaml @@ -22,6 +22,18 @@ http: "/authdir/": dir: "testdata/dir" + "/writable/": + dir: ".writedir" + diropts: + listing: + "/": true + exclude: ["/secret\\..*"] + put: + "/": true + "/readonly/": false + delete: + "/": true + "/file": file: "testdata/file" diff --git a/test/01-fe.yaml b/test/01-fe.yaml index 904d96c..b6abf57 100644 --- a/test/01-fe.yaml +++ b/test/01-fe.yaml @@ -6,6 +6,8 @@ _routes: &routes proxy: "http://localhost:8450/dir/" "/authdir/": proxy: "http://localhost:8450/authdir/" + "/writable/": + proxy: "http://localhost:8450/writable/" "/file": proxy: "http://localhost:8450/file" "/cgi/": diff --git a/test/test.sh b/test/test.sh index 89e0e0a..5163720 100755 --- a/test/test.sh +++ b/test/test.sh @@ -13,6 +13,18 @@ build # Remove old request log files, since we will be checking their contents. rm -f .01-fe.requests.log .01-be.requests.log +# Set up a writable directory for the PUT/DELETE tests, with a seed file +# whose mtime is known so we can drive If-Unmodified-Since deterministically. +rm -rf .writedir .symlink-target +mkdir -p .writedir/readonly .symlink-target +echo -n "original" > .writedir/existing +touch -d "2020-01-01 00:00:00 UTC" .writedir/existing + +# Symlink under the writable root pointing at a sibling directory (outside +# root). Used by the security tests to verify documented symlink-follow +# behaviour and that DELETE only removes the link, not its target. +ln -sfn "$(pwd)/.symlink-target" .writedir/linked + # Make sure we don't accidentally use this from the caller. unset CACERT @@ -246,6 +258,160 @@ fi unset CACERT +echo "### PUT / DELETE" + +BE=http://localhost:8450 + +# PUT disabled on /readonly/ subprefix (longest-prefix match wins). +exp $BE/writable/readonly/x -method PUT -reqbody "no" -status 405 +# DELETE is still enabled on /readonly/ (only PUT is denied there), +# but the target doesn't exist. +exp $BE/writable/readonly/x -method DELETE -status 404 + +# PUT a new file -> 201, content readable back. The response carries a +# Last-Modified validator for subsequent If-Unmodified-Since requests. +exp $BE/writable/new.txt -method PUT -reqbody "hello\n" -status 201 \ + -hdrre "Last-Modified: " +exp $BE/writable/new.txt -body "hello\n" + +# PUT over an existing file -> 204, content replaced. +exp $BE/writable/new.txt -method PUT -reqbody "again\n" -status 204 +exp $BE/writable/new.txt -body "again\n" + +# PUT auto-creates parent directories. +exp $BE/writable/a/b/c.txt -method PUT -reqbody "deep\n" -status 201 +exp $BE/writable/a/b/c.txt -body "deep\n" + +# PUT onto an existing directory -> 409. +exp $BE/writable/a -method PUT -reqbody "nope" -status 409 + +# Excluded paths return 404 on writes too. +exp $BE/writable/secret.txt -method PUT -reqbody "x" -status 404 +exp $BE/writable/secret.txt -method DELETE -status 404 + +# Empty-body PUT creates a zero-byte file. +exp $BE/writable/empty -method PUT -status 201 +exp $BE/writable/empty -body "" + +# Path traversal: net/http normalises the path. Depending on whether the +# client (or the mux) does the cleaning, we see either a 307 redirect to +# the canonical form or a direct 404. Either way, our handler is not +# called, so nothing is written outside the root. +exp "$BE/writable/../escaped" -method PUT -reqbody "x" -statuslist 307,404 +[ ! -e escaped ] || { echo "traversal wrote outside root"; exit 1; } + +# If-Unmodified-Since: stale (file mtime > header) -> 412, content unchanged. +exp $BE/writable/existing -method PUT \ + -reqhdr "If-Unmodified-Since: Wed, 01 Jan 2000 00:00:00 GMT" \ + -reqbody "should not write" -status 412 +exp $BE/writable/existing -body "original" + +# If-Unmodified-Since: in the future -> 204, content replaced. +exp $BE/writable/existing -method PUT \ + -reqhdr "If-Unmodified-Since: Thu, 01 Jan 2099 00:00:00 GMT" \ + -reqbody "updated" -status 204 +exp $BE/writable/existing -body "updated" + +# DELETE existing file -> 204; then GET -> 404. +exp $BE/writable/new.txt -method DELETE -status 204 +exp $BE/writable/new.txt -status 404 + +# DELETE with stale If-Unmodified-Since -> 412, file still there. +exp $BE/writable/empty -method DELETE \ + -reqhdr "If-Unmodified-Since: Wed, 01 Jan 2000 00:00:00 GMT" \ + -status 412 +exp $BE/writable/empty -status 200 + +# DELETE on a directory -> 409. +exp $BE/writable/a -method DELETE -status 409 + +# DELETE missing file -> 404. +exp $BE/writable/never -method DELETE -status 404 + +# PUT through the FE proxy works end-to-end. +FE=http://localhost:8441 +exp $FE/writable/via-fe.txt -method PUT -reqbody "fe\n" -status 201 +exp $FE/writable/via-fe.txt -body "fe\n" + + +echo "### PUT / DELETE security" + +# URL-encoded "..": the encoded form is preserved on the wire (clients +# that pre-normalise may behave differently). The mux still routes +# /writable/ since the cleaned-but-still-decoded path starts with it; our +# handler then sees "/../escaped-encoded" which path.Clean turns into +# "/escaped-encoded" — INSIDE the root. Lock that in. +exp "$BE/writable/%2E%2E/escaped-encoded" -method PUT -reqbody "x" -status 201 +[ -f .writedir/escaped-encoded ] || \ + { echo "expected .writedir/escaped-encoded"; exit 1; } + +# URL-encoded "../" (slash also encoded): URL.Path becomes /writable/../foo, +# the mux still routes /writable/ and our handler runs. path.Clean turns +# "/../foo" into "/foo", which lands INSIDE the root (no escape). Lock in +# this behaviour and confirm the file is under .writedir/. +exp "$BE/writable/%2e%2e%2fenc-slash" -method PUT -reqbody "x" -status 201 +[ -f .writedir/enc-slash ] || \ + { echo "expected .writedir/enc-slash"; exit 1; } + +# Double-encoded "..": decoded once to literal "%2E%2E", treated as a +# normal filename segment (no traversal). File lands inside root with a +# literal name. +exp "$BE/writable/%252E%252E/double-enc" -method PUT -reqbody "x" -status 201 +[ -f '.writedir/%2E%2E/double-enc' ] || \ + { echo "expected literal %2E%2E dir"; exit 1; } + +# Collapsed slashes: mux normalises and redirects to the canonical form. +exp "$BE/writable//collapsed" -method PUT -reqbody "x" -statuslist 301,307,308 + +# Prefix-toggle bypass attempt: "/readonly/../bypass" cleans to +# "/writable/bypass". Depending on the client, either: +# - the mux sees the dirty path, cleans it and 307-redirects (no write); +# - or the client pre-cleans the path and the canonical form is what +# reaches the handler (PUT succeeds, allowed by the "/" rule). +# Either way the toggle works on the canonical path: the readonly prefix +# is never effective against a request that resolves to a non-readonly +# path. Lock that in (no escape either way). +exp "$BE/writable/readonly/../bypass" -method PUT -reqbody "x" \ + -statuslist 201,307 +# No "bypass" file should have appeared above the root. +[ ! -e bypass ] || { echo "bypass leaked above root"; exit 1; } + +# Excludes apply after URL-decoding. +exp "$BE/writable/%73ecret.txt" -method PUT -reqbody "x" -status 404 +exp "$BE/writable/%73ecret.txt" -method DELETE -status 404 + +# Null bytes in the filename: rejected by the OS. We don't care about the +# exact status, only that nothing was written. +exp "$BE/writable/foo%00bar" -method PUT -reqbody "x" -statuslist 400,500 + +# Symlink follow (documented behaviour): PUT through a symlink-directory +# inside the root writes to the symlink target. Don't place such symlinks +# unless you want this. +exp $BE/writable/linked/via-symlink -method PUT \ + -reqbody "via-symlink" -status 201 +[ -f .symlink-target/via-symlink ] || \ + { echo "symlink follow broken"; exit 1; } + +# DELETE on a symlink removes the link itself, not what it points to. +exp $BE/writable/linked -method DELETE -status 204 +[ ! -e .writedir/linked ] || \ + { echo "symlink should be gone"; exit 1; } +[ -f .symlink-target/via-symlink ] || \ + { echo "symlink target should still exist"; exit 1; } + +# Final invariant: every PUT we issued landed under .writedir/ or +# .symlink-target/. Walk the test directory for stray files that match the +# names we tried to write through traversal. +for f in escaped escaped-encoded bypass double-enc collapsed; do + for d in . .. /tmp; do + if [ -e "$d/$f" ]; then + echo "FAIL: traversal wrote outside root: $d/$f exists" + exit 1 + fi + done +done + + echo "### Request log" function logtest() { exp http://localhost:8441/cgi/logtest diff --git a/test/testdata/expected-printed-01-be-config b/test/testdata/expected-printed-01-be-config index b1c8f02..f0fe938 100644 --- a/test/testdata/expected-printed-01-be-config +++ b/test/testdata/expected-printed-01-be-config @@ -36,6 +36,18 @@ http: - "0.5" /status/543: status: 543 + /writable/: + dir: .writedir + diropts: + listing: + /: true + exclude: + - /secret\..* + put: + /: true + /readonly/: false + delete: + /: true auth: /authdir/withoutindex/: testdata/authdb.yaml /authdir/ñaca: testdata/authdb.yaml diff --git a/test/util/exp/exp.go b/test/util/exp/exp.go index b92072c..165592d 100644 --- a/test/util/exp/exp.go +++ b/test/util/exp/exp.go @@ -16,6 +16,12 @@ import ( "strings" ) +// stringSlice is a flag.Value that accumulates repeated values. +type stringSlice []string + +func (s *stringSlice) String() string { return strings.Join(*s, ",") } +func (s *stringSlice) Set(v string) error { *s = append(*s, v); return nil } + var exitCode int = 0 func main() { @@ -46,7 +52,14 @@ func main() { "file to read CA cert from") forceLocalhost = flag.Bool("forcelocalhost", false, "force connection to go to localhost") + method = flag.String("method", "GET", + "HTTP method to use") + reqBody = flag.String("reqbody", "", + "request body (interpreted with strconv.Unquote, so \\n etc work)") + reqHdr stringSlice ) + flag.Var(&reqHdr, "reqhdr", + "request header in 'Name: value' form (repeatable)") flag.Parse() client := &http.Client{ @@ -54,7 +67,32 @@ func main() { Transport: mkTransport(*caCert, *forceLocalhost), } - resp, err := client.Get(url) + var bodyReader *strings.Reader + if *reqBody != "" { + s, err := strconv.Unquote("\"" + *reqBody + "\"") + if err != nil { + fatalf("invalid -reqbody: %v\n", err) + } + bodyReader = strings.NewReader(s) + } + var req *http.Request + var err error + if bodyReader == nil { + req, err = http.NewRequest(*method, url, nil) + } else { + req, err = http.NewRequest(*method, url, bodyReader) + } + if err != nil { + fatalf("error building request: %v\n", err) + } + for _, h := range reqHdr { + k, v, ok := strings.Cut(h, ":") + if !ok { + fatalf("bad -reqhdr %q, want 'Name: value'\n", h) + } + req.Header.Add(strings.TrimSpace(k), strings.TrimSpace(v)) + } + resp, err := client.Do(req) if *clientErrorRE != "" { if err == nil { errorf("expected client error, got nil")