git » gofer » commit ccbae27

http: Per-user directories

author Alberto Bertogli
2026-06-22 11:23:36 UTC
committer Alberto Bertogli
2026-07-10 09:18:33 UTC
parent ad8ee97548beaed9d1c7977520148d939df09c8a

http: Per-user directories

This patch implements support for per-user directories. This allows us
to map `/a/b/c` to an underlying directory `/root/a/b/c/<user>/`, which
is particularly useful when combined with PUT and DELETE.

To use such paths, HTTP authorization is mandatory. The code enforces
this and errors out if a request is made on those paths without
authorization.

As part of hardening, we now validate usernames don't contain `/` or
other potentially problematic characters.

config/config.go +33 -2
config/config_test.go +63 -0
config/gofer.schema.cue +1 -0
config/gofer.yaml +9 -0
server/auth.go +60 -6
server/http.go +24 -2
server/peruser_test.go +142 -0
test/01-be.yaml +12 -0
test/01-fe.yaml +2 -0
test/test.sh +43 -0
test/testdata/expected-printed-01-be-config +11 -0

diff --git a/config/config.go b/config/config.go
index 9c92453..9ff73a3 100644
--- a/config/config.go
+++ b/config/config.go
@@ -77,6 +77,13 @@ type DirOpts struct {
 	Exclude []PathRegexp    `yaml:",omitempty"`
 	Put     map[string]bool `yaml:",omitempty"`
 	Delete  map[string]bool `yaml:",omitempty"`
+	PerUser bool            `yaml:"per_user,omitempty"`
+}
+
+// set reports whether any directory option is configured.
+func (o DirOpts) set() bool {
+	return len(o.Listing)+len(o.Exclude)+len(o.Put)+len(o.Delete) > 0 ||
+		o.PerUser
 }
 
 type Raw struct {
@@ -155,13 +162,21 @@ func (h HTTP) Check(c Config, addr string) []error {
 	}
 
 	for path, r := range h.Routes {
-		if len(r.DirOpts.Listing)+len(r.DirOpts.Exclude)+
-			len(r.DirOpts.Put)+len(r.DirOpts.Delete) > 0 && r.Dir == "" {
+		if r.DirOpts.set() && r.Dir == "" {
 			errs = append(errs,
 				fmt.Errorf("%q: %q: diropts is set on non-dir route",
 					addr, path))
 		}
 
+		// A per_user route derives the served directory from the
+		// authenticated user, so it must be covered by an auth entry;
+		// otherwise it would reject every request.
+		if r.DirOpts.PerUser && !h.authCovers(path) {
+			errs = append(errs,
+				fmt.Errorf("%q: %q: per_user route is not covered by auth",
+					addr, path))
+		}
+
 		nSet := nTrue(
 			r.Dir != "",
 			r.File != "",
@@ -207,6 +222,22 @@ func (h HTTP) Check(c Config, addr string) []error {
 	return errs
 }
 
+// authCovers reports whether every request matching the route path is forced
+// through authentication, i.e. some auth entry is "/", an exact match, or a
+// subtree prefix ("…/") of path. Domain-scoped keys are matched as plain
+// string prefixes, which is good enough for this startup check.
+func (h HTTP) authCovers(path string) bool {
+	for a := range h.Auth {
+		if a == "/" || a == path {
+			return true
+		}
+		if strings.HasSuffix(a, "/") && strings.HasPrefix(path, a) {
+			return true
+		}
+	}
+	return false
+}
+
 // Count how many true values are in a series of bools.
 func nTrue(bs ...bool) int {
 	n := 0
diff --git a/config/config_test.go b/config/config_test.go
index 0edf345..f57cd83 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -210,6 +210,69 @@ https:
 	expectErrs(t, `":https": "/": diropts is set on non-dir route`,
 		loadAndCheck(t, contents))
 
+	// diropts.per_user on a non-directory.
+	contents = `
+https:
+  ":https":
+    certs: "/dev/null"
+    routes:
+      "/":
+        file: "/dev/null"
+        diropts:
+          per_user: true
+`
+	expectErrs(t, `":https": "/": diropts is set on non-dir route`,
+		loadAndCheck(t, contents))
+
+	// per_user route not covered by auth.
+	contents = `
+https:
+  ":https":
+    certs: "/dev/null"
+    routes:
+      "/w/":
+        dir: "/tmp"
+        diropts:
+          per_user: true
+`
+	expectErrs(t, `":https": "/w/": per_user route is not covered by auth`,
+		loadAndCheck(t, contents))
+
+	// per_user route covered by auth: no error.
+	contents = `
+https:
+  ":https":
+    certs: "/dev/null"
+    routes:
+      "/w/":
+        dir: "/tmp"
+        diropts:
+          per_user: true
+    auth:
+      "/w/": "/dev/null"
+`
+	if errs := loadAndCheck(t, contents); len(errs) > 0 {
+		t.Errorf("per_user route covered by auth: unexpected errors: %v", errs)
+	}
+
+	// per_user route covered by a subtree-prefix auth entry: no error.
+	contents = `
+https:
+  ":https":
+    certs: "/dev/null"
+    routes:
+      "/w/sub/":
+        dir: "/tmp"
+        diropts:
+          per_user: true
+    auth:
+      "/w/": "/dev/null"
+`
+	if errs := loadAndCheck(t, contents); len(errs) > 0 {
+		t.Errorf("per_user route covered by subtree auth: unexpected errors: %v",
+			errs)
+	}
+
 	// reqlog reference (http).
 	contents = `
 https:
diff --git a/config/gofer.schema.cue b/config/gofer.schema.cue
index 8cda0fe..2669190 100644
--- a/config/gofer.schema.cue
+++ b/config/gofer.schema.cue
@@ -60,6 +60,7 @@ https?:
 			exclude?: [string]
 			put?: [string]: bool
 			delete?: [string]: bool
+			per_user?: bool
 		}
 
 		// If diropts is set, then dir must be set too.
diff --git a/config/gofer.yaml b/config/gofer.yaml
index 69815c3..cf7a07a 100644
--- a/config/gofer.yaml
+++ b/config/gofer.yaml
@@ -130,6 +130,15 @@ http:
           #delete:
           #  "/pub/": true
 
+          # Serve a per-user subdirectory: map requests to <dir>/<user>/,
+          # where <user> is the authenticated user. The request path stays
+          # relative to the user's directory, so the other diropts (listing,
+          # put, delete, exclude) are evaluated within it. The user directory
+          # is created automatically on the first PUT.
+          # This requires the route to be covered by "auth:" (gofer refuses to
+          # start otherwise), since the directory is derived from the user.
+          #per_user: true
+
     # Enforce authentication on these paths. The target is the file containing
     # the user and passwords.
     #auth:
diff --git a/server/auth.go b/server/auth.go
index 0991d91..b08efb4 100644
--- a/server/auth.go
+++ b/server/auth.go
@@ -1,11 +1,14 @@
 package server
 
 import (
+	"context"
 	"crypto/sha256"
 	"encoding/hex"
+	"fmt"
 	"math/rand"
 	"net/http"
 	"os"
+	"strings"
 	"time"
 
 	"blitiri.com.ar/go/gofer/trace"
@@ -43,8 +46,7 @@ func (a *AuthWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	if dbPass, ok := a.users.Plain[user]; ok {
 		if pass == dbPass {
-			tr.Printf("auth for %q successful", user)
-			a.handler.ServeHTTP(w, r)
+			a.success(w, r, user)
 		} else {
 			tr.Printf("incorrect password (plain) for %q", user)
 			a.failed(w)
@@ -57,8 +59,7 @@ func (a *AuthWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		buf := sha256.Sum256([]byte(pass))
 		shaPass := hex.EncodeToString(buf[:])
 		if shaPass == dbPass {
-			tr.Printf("auth for %q successful", user)
-			a.handler.ServeHTTP(w, r)
+			a.success(w, r, user)
 		} else {
 			tr.Printf("incorrect password (sha256) for %q", user)
 			a.failed(w)
@@ -70,12 +71,54 @@ func (a *AuthWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	a.failed(w)
 }
 
+// success is called after a successful authentication. It associates the
+// authenticated user with the request context (so per-user handlers can read
+// it, see UserFromContext) and invokes the wrapped handler.
+func (a *AuthWrapper) success(w http.ResponseWriter, r *http.Request, user string) {
+	tr, _ := trace.FromContext(r.Context())
+	tr.Printf("auth for %q successful", user)
+	r = r.WithContext(withUser(r.Context(), user))
+	a.handler.ServeHTTP(w, r)
+}
+
 func (a *AuthWrapper) failed(w http.ResponseWriter) {
 	w.Header().Set("WWW-Authenticate", `Basic realm="Authentication"`)
 	http.Error(w, "Unauthorized", http.StatusUnauthorized)
 	return
 }
 
+type userCtxKey struct{}
+
+// withUser returns a copy of ctx carrying the authenticated user name.
+func withUser(ctx context.Context, user string) context.Context {
+	return context.WithValue(ctx, userCtxKey{}, user)
+}
+
+// UserFromContext returns the authenticated user name associated with the
+// request context. It is set by AuthWrapper on successful authentication, so
+// it is only present on routes covered by auth.
+func UserFromContext(ctx context.Context) (string, bool) {
+	user, ok := ctx.Value(userCtxKey{}).(string)
+	return user, ok
+}
+
+// validUser reports an error if name is not safe to use as a single on-disk
+// path component. Per-user directory routes derive a path from the
+// authenticated user (see makeDir), so we reject anything that could escape or
+// alter the directory structure. Checked at load time, which guarantees a
+// loaded AuthDB only contains path-safe user names.
+func validUser(name string) error {
+	switch {
+	case name == "":
+		return fmt.Errorf("empty user name")
+	case name == "." || name == "..":
+		return fmt.Errorf("invalid user name %q", name)
+	case strings.ContainsAny(name, `/\`+"\x00"):
+		return fmt.Errorf("user name %q contains an invalid character", name)
+	}
+	return nil
+}
+
 type AuthDB struct {
 	Plain  map[string]string
 	SHA256 map[string]string
@@ -88,6 +131,17 @@ func LoadAuthFile(path string) (*AuthDB, error) {
 	}
 
 	db := &AuthDB{}
-	err = yaml.Unmarshal(buf, &db)
-	return db, err
+	if err := yaml.Unmarshal(buf, &db); err != nil {
+		return nil, err
+	}
+
+	for _, users := range []map[string]string{db.Plain, db.SHA256} {
+		for user := range users {
+			if err := validUser(user); err != nil {
+				return nil, fmt.Errorf("%s: %v", path, err)
+			}
+		}
+	}
+
+	return db, nil
 }
diff --git a/server/http.go b/server/http.go
index 3fec186..038cd00 100644
--- a/server/http.go
+++ b/server/http.go
@@ -12,6 +12,7 @@ import (
 	"net/http/cgi"
 	"net/http/httputil"
 	"net/url"
+	"path/filepath"
 	"strings"
 	"time"
 
@@ -259,8 +260,8 @@ func adjustPath(req string, from string, to string) string {
 }
 
 func makeDir(path string, dir string, opts config.DirOpts) http.Handler {
-	fs := NewFS(dir, http.Dir(dir), opts)
-	srv := FileServer(fs)
+	base := NewFS(dir, http.Dir(dir), opts)
+	baseSrv := FileServer(base)
 
 	path = stripDomain(path)
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -273,6 +274,27 @@ func makeDir(path string, dir string, opts config.DirOpts) http.Handler {
 		}
 		tr.Printf("adjusted dir: %q", r.URL.Path)
 
+		// For per-user routes, root the filesystem at <dir>/<user>. The
+		// request path stays user-relative, so diropts (listing, put, ...)
+		// are evaluated relative to each user's directory.
+		fs, srv := base, baseSrv
+		if opts.PerUser {
+			user, ok := UserFromContext(r.Context())
+			if !ok {
+				// per_user requires authentication; fail closed. The config
+				// check enforces auth coverage, so we should not get here.
+				tr.Printf("per_user route reached without authenticated user")
+				http.Error(w, "403 Forbidden", http.StatusForbidden)
+				return
+			}
+			// user is validated at load time, so it is a single safe path
+			// component and cannot escape dir.
+			root := filepath.Join(dir, user)
+			tr.Printf("per-user root %q", root)
+			fs = NewFS(root, http.Dir(root), opts)
+			srv = FileServer(fs)
+		}
+
 		switch r.Method {
 		case http.MethodPut:
 			handlePut(fs, w, r)
diff --git a/server/peruser_test.go b/server/peruser_test.go
new file mode 100644
index 0000000..386df79
--- /dev/null
+++ b/server/peruser_test.go
@@ -0,0 +1,142 @@
+package server
+
+import (
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"blitiri.com.ar/go/gofer/config"
+	"blitiri.com.ar/go/gofer/trace"
+)
+
+func TestValidUser(t *testing.T) {
+	valid := []string{"alice", "bob@example.com", "a.b", "user-1", "...", "café"}
+	for _, u := range valid {
+		if err := validUser(u); err != nil {
+			t.Errorf("validUser(%q) = %v, want nil", u, err)
+		}
+	}
+
+	invalid := []string{"", ".", "..", "a/b", `a\b`, "x\x00y", "/etc"}
+	for _, u := range invalid {
+		if err := validUser(u); err == nil {
+			t.Errorf("validUser(%q) = nil, want error", u)
+		}
+	}
+}
+
+func TestLoadAuthFileValidatesUsers(t *testing.T) {
+	dir := t.TempDir()
+
+	good := filepath.Join(dir, "good.yaml")
+	os.WriteFile(good, []byte("plain:\n  alice: pw\nsha256:\n  bob: abc\n"), 0600)
+	if _, err := LoadAuthFile(good); err != nil {
+		t.Errorf("LoadAuthFile(good) = %v, want nil", err)
+	}
+
+	// An unsafe user name must make the whole file fail to load.
+	bad := filepath.Join(dir, "bad.yaml")
+	os.WriteFile(bad, []byte("plain:\n  \"../escape\": pw\n"), 0600)
+	if _, err := LoadAuthFile(bad); err == nil {
+		t.Errorf("LoadAuthFile(bad) = nil, want error")
+	}
+
+	// Malformed YAML fails to load.
+	malformed := filepath.Join(dir, "malformed.yaml")
+	os.WriteFile(malformed, []byte("plain:\n  - a list, not a map\n"), 0600)
+	if _, err := LoadAuthFile(malformed); err == nil {
+		t.Errorf("LoadAuthFile(malformed) = nil, want error")
+	}
+}
+
+// serveUser drives an http.Handler with an authenticated user in the request
+// context, mimicking what AuthWrapper sets up. An empty user means
+// unauthenticated.
+func serveUser(h http.Handler, method, target, body, user string) *http.Response {
+	var br io.Reader
+	if body != "" {
+		br = strings.NewReader(body)
+	}
+	req := httptest.NewRequest(method, target, br)
+
+	ctx := trace.NewContext(req.Context(), trace.New("test", target))
+	if user != "" {
+		ctx = withUser(ctx, user)
+	}
+	req = req.WithContext(ctx)
+
+	w := httptest.NewRecorder()
+	h.ServeHTTP(w, req)
+	return w.Result()
+}
+
+func TestPerUserDir(t *testing.T) {
+	root := t.TempDir()
+	opts := config.DirOpts{
+		PerUser: true,
+		Put:     map[string]bool{"/": true},
+		Delete:  map[string]bool{"/": true},
+		Listing: map[string]bool{"/": true},
+	}
+	h := makeDir("/w/", root, opts)
+
+	// PUT as alice creates <root>/alice/f.txt.
+	resp := serveUser(h, "PUT", "http://x/w/f.txt", "alice-data", "alice")
+	if resp.StatusCode != http.StatusCreated {
+		t.Fatalf("alice PUT: status = %d, want 201", resp.StatusCode)
+	}
+	if got, _ := os.ReadFile(filepath.Join(root, "alice", "f.txt")); string(got) != "alice-data" {
+		t.Errorf("alice file = %q, want %q", got, "alice-data")
+	}
+
+	// bob writing the same request path is isolated to his own directory.
+	resp = serveUser(h, "PUT", "http://x/w/f.txt", "bob-data", "bob")
+	if resp.StatusCode != http.StatusCreated {
+		t.Fatalf("bob PUT: status = %d, want 201", resp.StatusCode)
+	}
+	if got, _ := os.ReadFile(filepath.Join(root, "bob", "f.txt")); string(got) != "bob-data" {
+		t.Errorf("bob file = %q, want %q", got, "bob-data")
+	}
+
+	// alice GETs her own file, not bob's.
+	resp = serveUser(h, "GET", "http://x/w/f.txt", "", "alice")
+	if body, _ := io.ReadAll(resp.Body); string(body) != "alice-data" {
+		t.Errorf("alice GET = %q, want %q", body, "alice-data")
+	}
+
+	// Unauthenticated request fails closed, writes nothing.
+	resp = serveUser(h, "PUT", "http://x/w/anon.txt", "x", "")
+	if resp.StatusCode != http.StatusForbidden {
+		t.Errorf("anon PUT: status = %d, want 403", resp.StatusCode)
+	}
+	if _, err := os.Stat(filepath.Join(root, "anon.txt")); !os.IsNotExist(err) {
+		t.Errorf("anon PUT wrote a file outside any user directory")
+	}
+
+	// Traversal stays within the user's own directory: alice cannot reach
+	// the sibling top-level "bob" directory. "/w/../bob/x" resolves to
+	// <root>/alice/bob/x, not <root>/bob/x.
+	resp = serveUser(h, "PUT", "http://x/w/../bob/escape", "nope", "alice")
+	if _, err := os.Stat(filepath.Join(root, "alice", "bob", "escape")); err != nil {
+		t.Errorf("traversal target not under alice's dir: %v", err)
+	}
+	if got, _ := os.ReadFile(filepath.Join(root, "bob", "f.txt")); string(got) != "bob-data" {
+		t.Errorf("alice traversal clobbered bob's dir: bob f.txt = %q", got)
+	}
+
+	// alice DELETEs her file; bob's remains.
+	resp = serveUser(h, "DELETE", "http://x/w/f.txt", "", "alice")
+	if resp.StatusCode != http.StatusNoContent {
+		t.Errorf("alice DELETE: status = %d, want 204", resp.StatusCode)
+	}
+	if _, err := os.Stat(filepath.Join(root, "alice", "f.txt")); !os.IsNotExist(err) {
+		t.Errorf("alice file still exists after DELETE")
+	}
+	if _, err := os.Stat(filepath.Join(root, "bob", "f.txt")); err != nil {
+		t.Errorf("bob file gone after alice DELETE: %v", err)
+	}
+}
diff --git a/test/01-be.yaml b/test/01-be.yaml
index 9d3ee0f..0a8c6ab 100644
--- a/test/01-be.yaml
+++ b/test/01-be.yaml
@@ -34,6 +34,17 @@ http:
           delete:
             "/": true
 
+      "/peruser/":
+        dir: ".peruserdir"
+        diropts:
+          listing:
+            "/": true
+          put:
+            "/": true
+          delete:
+            "/": true
+          per_user: true
+
       "/file":
         file: "testdata/file"
 
@@ -56,6 +67,7 @@ http:
     auth:
       "/authdir/ñaca": "testdata/authdb.yaml"
       "/authdir/withoutindex/": "testdata/authdb.yaml"
+      "/peruser/": "testdata/authdb.yaml"
 
     setheader:
       "/file":
diff --git a/test/01-fe.yaml b/test/01-fe.yaml
index b6abf57..b42427a 100644
--- a/test/01-fe.yaml
+++ b/test/01-fe.yaml
@@ -8,6 +8,8 @@ _routes: &routes
     proxy: "http://localhost:8450/authdir/"
   "/writable/":
     proxy: "http://localhost:8450/writable/"
+  "/peruser/":
+    proxy: "http://localhost:8450/peruser/"
   "/file":
     proxy: "http://localhost:8450/file"
   "/cgi/":
diff --git a/test/test.sh b/test/test.sh
index 5163720..636eb4a 100755
--- a/test/test.sh
+++ b/test/test.sh
@@ -25,6 +25,10 @@ touch -d "2020-01-01 00:00:00 UTC" .writedir/existing
 # behaviour and that DELETE only removes the link, not its target.
 ln -sfn "$(pwd)/.symlink-target" .writedir/linked
 
+# Per-user directory tests start from a clean slate; the per-user
+# subdirectories are created on the first PUT.
+rm -rf .peruserdir
+
 # Make sure we don't accidentally use this from the caller.
 unset CACERT
 
@@ -412,6 +416,45 @@ for f in escaped escaped-encoded bypass double-enc collapsed; do
 done
 
 
+echo "### Per-user directories"
+
+# Two users PUT the same request path; each lands in its own subdirectory.
+exp http://oneuser:onepass@localhost:8450/peruser/f.txt \
+    -method PUT -reqbody "one-data" -status 201
+exp http://twouser:twopass@localhost:8450/peruser/f.txt \
+    -method PUT -reqbody "two-data" -status 201
+[ "$(cat .peruserdir/oneuser/f.txt)" = "one-data" ] || \
+    { echo "oneuser per-user file wrong"; exit 1; }
+[ "$(cat .peruserdir/twouser/f.txt)" = "two-data" ] || \
+    { echo "twouser per-user file wrong"; exit 1; }
+
+# Each user reads back their own copy through the same URL.
+exp http://oneuser:onepass@localhost:8450/peruser/f.txt -body "one-data"
+exp http://twouser:twopass@localhost:8450/peruser/f.txt -body "two-data"
+
+# Without auth -> 401 (auth layer runs first), nothing written.
+exp http://localhost:8450/peruser/anon.txt -method PUT -reqbody "x" -status 401
+[ ! -e .peruserdir/anon.txt ] || { echo "anon per-user write leaked"; exit 1; }
+
+# Depth: a deeper request path lands under the user's directory, with parent
+# directories auto-created.
+exp http://oneuser:onepass@localhost:8450/peruser/a/b/c.txt \
+    -method PUT -reqbody "deep" -status 201
+[ -f .peruserdir/oneuser/a/b/c.txt ] || \
+    { echo "deep per-user file missing"; exit 1; }
+
+# DELETE is per-user too: oneuser deleting does not affect twouser's copy.
+exp http://oneuser:onepass@localhost:8450/peruser/f.txt -method DELETE -status 204
+exp http://oneuser:onepass@localhost:8450/peruser/f.txt -status 404
+exp http://twouser:twopass@localhost:8450/peruser/f.txt -body "two-data"
+
+# Per-user PUT through the FE proxy works end-to-end (auth header forwarded).
+exp http://oneuser:onepass@localhost:8441/peruser/via-fe.txt \
+    -method PUT -reqbody "fe-data" -status 201
+[ "$(cat .peruserdir/oneuser/via-fe.txt)" = "fe-data" ] || \
+    { echo "per-user via-fe file wrong"; exit 1; }
+
+
 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 f0fe938..6615de7 100644
--- a/test/testdata/expected-printed-01-be-config
+++ b/test/testdata/expected-printed-01-be-config
@@ -22,6 +22,16 @@ http:
                 file: testdata/file
             /file/second:
                 file: testdata/dir/ñaca
+            /peruser/:
+                dir: .peruserdir
+                diropts:
+                    listing:
+                        /: true
+                    put:
+                        /: true
+                    delete:
+                        /: true
+                    per_user: true
             /slow/10ms:
                 cgi:
                     - testdata/sleep.sh
@@ -51,6 +61,7 @@ http:
         auth:
             /authdir/withoutindex/: testdata/authdb.yaml
             /authdir/ñaca: testdata/authdb.yaml
+            /peruser/: testdata/authdb.yaml
         setheader:
             /file:
                 X-My-Header: my lovely header