git » gofer » commit ad8ee97

http: Support PUT and DELETE methods

author Alberto Bertogli
2026-05-22 09:18:49 UTC
committer Alberto Bertogli
2026-07-10 09:18:33 UTC
parent 67c526503b70fc0620f7e71cfc8557c72c01e0e1

http: Support PUT and DELETE methods

This patch adds support for HTTP PUT and DELETE methods on a given
directory.

They only operate on files (directories are auto-created), and
If-Unmodified-Since is supported for atomic operations (with 1-second
granularity).

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")