| author | Alberto Bertogli
<albertito@blitiri.com.ar> 2026-06-22 11:23:36 UTC |
| committer | Alberto Bertogli
<albertito@blitiri.com.ar> 2026-07-10 09:18:33 UTC |
| parent | ad8ee97548beaed9d1c7977520148d939df09c8a |
| 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