From 434d2437bf7ccd8791d12ad466163b670ab95f9d Mon Sep 17 00:00:00 2001 From: puttaalu Date: Sun, 18 Oct 2026 14:45:33 +0200 Subject: [PATCH] Move upload auth from nginx into the app Add HTTP Basic auth on /upload, reading an Apache htpasswd file (bcrypt only, `htpasswd -B`) via a new -htpasswd flag. This lets nginx drop to a plain TLS-terminating reverse proxy with no auth of its own; the existing bcrypt htpasswd file is reused unchanged. auth.go: - LoadHtpasswd parses user:hash lines, keeps bcrypt entries ($2a/$2b/$2y, so Apache's $2y$ works), skips MD5/SHA/crypt with a warning. - Unknown users still pay for one bcrypt comparison against a dummy hash, so response timing can't enumerate valid usernames. - Failed attempts draw from a token-bucket limiter (x/time/rate, ~1/s, burst 10) returning 429 once drained; successful and bulk uploads never touch it. server.go guards both /upload routes (nil authenticator = passthrough for local --serve). /health stays open. Empty -htpasswd disables auth and logs a warning. module.nix: new top-level services.photogallery.htpasswdFile, delivered to the service via systemd LoadCredential (so a root-owned 0400 secret works). Removed nginx.htpasswdFile and basic_auth from the vhost. Both uploader apps already send Basic credentials, so no client change. Deps: golang.org/x/crypto/bcrypt, golang.org/x/time/rate (vendorHash bump). --- CLAUDE.md | 32 +++++++++--- auth.go | 102 ++++++++++++++++++++++++++++++++++++ auth_test.go | 144 +++++++++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 2 +- go.mod | 6 ++- go.sum | 8 ++- main.go | 14 ++++- module.nix | 39 +++++++++----- server.go | 22 ++++++-- 9 files changed, 342 insertions(+), 27 deletions(-) create mode 100644 auth.go create mode 100644 auth_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 9d15173..8a1c76e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,12 +20,29 @@ The binary does three things at once: - In dev mode (`--serve`), also serves `public/` as static files Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart), `GET /health`. +Both `/upload` routes are gated by HTTP Basic auth when `-htpasswd` is set (see Auth); `/health` is always open. -In production nginx sits in front: serves `public/` directly, proxies `/upload` to localhost, htpasswd on the upload endpoint. Note `/health` is **not** proxied — nginx only forwards `location /upload`, so anything outside the box can only reach `/upload`. +In production nginx sits in front: serves `public/` directly, proxies `/upload` to localhost, terminates TLS. Auth is **no longer nginx's job** — the app checks credentials itself (`auth.go`), so the nginx vhost just proxies. Note `/health` is **not** proxied — nginx only forwards `location /upload`, so anything outside the box can only reach `/upload`. + +## Auth + +`-htpasswd ` points at an Apache htpasswd file — **bcrypt only**, generate +with `htpasswd -B`. Each `POST /upload` and `GET /upload` then needs HTTP Basic +credentials matching a line in that file; `GET /health` stays open. `$2y$` +(Apache) and `$2a$`/`$2b$` hashes are all accepted; non-bcrypt lines (MD5 +`$apr1$`, SHA, crypt) are skipped with a warning. Empty `-htpasswd` disables +auth entirely (dev default) and logs a warning — `/upload` is then open. + +Verification lives in `auth.go`: unknown users still pay for one bcrypt +comparison (against a dummy hash) so timing can't enumerate usernames, and +**failed** attempts draw from a token-bucket limiter (`golang.org/x/time/rate`, +~1/s, burst 10) that returns `429` once drained — successful and bulk uploads +never touch it. Both apps (Android, Qt desktop) already send Basic auth, so no +client change was needed. Deps: `golang.org/x/crypto/bcrypt`, `golang.org/x/time/rate`. ## Stack -- **Go** — stdlib + `BurntSushi/toml`, `fsnotify/fsnotify`, `golang.org/x/image` +- **Go** — stdlib + `BurntSushi/toml`, `fsnotify/fsnotify`, `golang.org/x/image`, `golang.org/x/crypto` (bcrypt), `golang.org/x/time` (rate) - **Templates** — `html/template`, embedded via `embed.FS` - **Images** — four renditions per photo: original copy, 600×600 centre-cropped square thumbnail, a 1600px-longest-side "medium" for the detail page, and a 1200px "og" for link previews. Catmull-Rom resize. Outputs are skipped when newer than the source. - **Feed** — Atom (`encoding/xml`) @@ -136,10 +153,12 @@ inputs.photogallery.nixosModules.default services.photogallery = { enable = true; baseURL = "https://photos.yourdomain.tld"; + # bcrypt htpasswd (`htpasswd -B`); handed to the service via LoadCredential, + # so a root-owned 0400 secret is fine. Omit to leave /upload open. + htpasswdFile = "/run/secrets/photogallery-htpasswd"; nginx = { - enable = true; - domain = "photos.yourdomain.tld"; - htpasswdFile = "/etc/nginx/.htpasswd-gallery"; + enable = true; + domain = "photos.yourdomain.tld"; }; }; ``` @@ -161,6 +180,7 @@ nix build # succeeds | `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs/mediums | | `albums.go` | Tag parsing/normalization, `groupByTag`, album pages + `/albums/` + `/tags.json` | | `server.go` | Upload API + optional static file serving | +| `auth.go` | htpasswd (bcrypt) HTTP Basic auth + failed-attempt rate limit for `/upload` | | `watcher.go` | fsnotify watcher with 500ms debounce | | `assets.go` | `//go:embed` for templates and static | | `templates/timeline.html` | Site root: photos grouped by day, paginated | @@ -183,6 +203,6 @@ nix build # succeeds - ActivityPub federation (planned later — start with RSS/Atom) - Multi-image upload - Delete/edit via UI -- Any auth beyond nginx htpasswd +- Any auth beyond the app's htpasswd Basic auth on `/upload` (no sessions, no per-user scopes) - The Android app has never been run on real hardware - Photo-page swipe has never been tested on a real touchscreen; the iOS Safari edge-gesture interaction is the untested part diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..3125bbd --- /dev/null +++ b/auth.go @@ -0,0 +1,102 @@ +package main + +import ( + "bufio" + "fmt" + "log" + "net/http" + "os" + "strings" + + "golang.org/x/crypto/bcrypt" + "golang.org/x/time/rate" +) + +// Authenticator checks HTTP Basic credentials against bcrypt hashes loaded from +// an Apache-style htpasswd file. Only bcrypt entries are supported — generate +// them with `htpasswd -B`. This replaces nginx's basic_auth so the app can run +// behind a plain TLS-terminating reverse proxy with no auth of its own. +type Authenticator struct { + creds map[string][]byte // username -> bcrypt hash + + // A single bcrypt comparison runs on every failed lookup so that a request + // for an unknown user costs the same as one for a known user with a wrong + // password, closing a user-enumeration timing side channel. + dummyHash []byte +} + +// LoadHtpasswd reads user:hash lines from path. Blank lines and #-comments are +// skipped, as is any entry whose hash is not bcrypt (with a warning). It is an +// error for the file to yield no usable entries. +func LoadHtpasswd(path string) (*Authenticator, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + a := &Authenticator{creds: make(map[string][]byte)} + + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + user, hash, ok := strings.Cut(line, ":") + if !ok || user == "" { + continue + } + // bcrypt hashes start $2a$, $2b$ or $2y$; everything else (MD5 $apr1$, + // SHA {SHA}, crypt) is unsupported here. + if !strings.HasPrefix(hash, "$2") { + log.Printf("auth: skipping non-bcrypt entry for %q — regenerate with `htpasswd -B`", user) + continue + } + a.creds[user] = []byte(hash) + } + if err := sc.Err(); err != nil { + return nil, err + } + if len(a.creds) == 0 { + return nil, fmt.Errorf("no bcrypt entries in %s (use `htpasswd -B`)", path) + } + + // Cost 10 matches htpasswd -B's default, keeping the dummy comparison the + // same price as a real one. + a.dummyHash, _ = bcrypt.GenerateFromPassword([]byte("dummy"), 10) + return a, nil +} + +// check reports whether user/pass matches a stored credential. +func (a *Authenticator) check(user, pass string) bool { + hash, ok := a.creds[user] + if !ok { + // Spend the same time as a real comparison to avoid leaking which + // usernames exist. + bcrypt.CompareHashAndPassword(a.dummyHash, []byte(pass)) + return false + } + return bcrypt.CompareHashAndPassword(hash, []byte(pass)) == nil +} + +// basicAuth wraps next, demanding valid HTTP Basic credentials. On a failed +// attempt it consumes a token from limiter; once the limiter is exhausted, +// further failures get 429 instead of 401, throttling brute-force guessing. +// The limiter is only touched on failure, so legitimate (even bursty, bulk) +// uploads are never throttled. +func (a *Authenticator) basicAuth(limiter *rate.Limiter, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if ok && a.check(user, pass) { + next(w, r) + return + } + if !limiter.Allow() { + http.Error(w, "too many failed attempts", http.StatusTooManyRequests) + return + } + w.Header().Set("WWW-Authenticate", `Basic realm="photog"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) + } +} diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..5b24835 --- /dev/null +++ b/auth_test.go @@ -0,0 +1,144 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/crypto/bcrypt" + "golang.org/x/time/rate" +) + +// bcryptHash returns a cost-10 hash of pw, matching `htpasswd -B`'s default. +func bcryptHash(t *testing.T, pw string) string { + t.Helper() + h, err := bcrypt.GenerateFromPassword([]byte(pw), 10) + if err != nil { + t.Fatalf("GenerateFromPassword: %v", err) + } + return string(h) +} + +func TestLoadHtpasswdAndCheck(t *testing.T) { + // alice's hash is rewritten to the $2y$ variant that Apache's htpasswd -B + // emits, to prove it is accepted. $2a$ and $2y$ are the same algorithm, so + // swapping the prefix on a valid hash yields a valid hash for the same + // password. bob keeps the $2a$ form. + aliceHash := bcryptHash(t, "s3cret") + if !strings.HasPrefix(aliceHash, "$2a$") { + t.Fatalf("expected $2a$ prefix, got %q", aliceHash[:4]) + } + aliceHashY := "$2y$" + strings.TrimPrefix(aliceHash, "$2a$") + bobHash := bcryptHash(t, "hunter2") + + file := filepath.Join(t.TempDir(), "htpasswd") + content := strings.Join([]string{ + "# a comment", + "", + "alice:" + aliceHashY, + "bob:" + bobHash, + "legacy:$apr1$abcd$0123456789", // MD5 — must be skipped + }, "\n") + "\n" + if err := os.WriteFile(file, []byte(content), 0600); err != nil { + t.Fatal(err) + } + + a, err := LoadHtpasswd(file) + if err != nil { + t.Fatalf("LoadHtpasswd: %v", err) + } + if _, ok := a.creds["legacy"]; ok { + t.Error("MD5 entry should have been skipped") + } + + cases := []struct { + user, pass string + want bool + }{ + {"alice", "s3cret", true}, // $2y$ accepted + {"bob", "hunter2", true}, // $2a$ accepted + {"alice", "wrong", false}, // wrong password + {"bob", "s3cret", false}, // right password, wrong user + {"nobody", "s3cret", false}, // unknown user + {"legacy", "whatever", false}, + } + for _, c := range cases { + if got := a.check(c.user, c.pass); got != c.want { + t.Errorf("check(%q, %q) = %v, want %v", c.user, c.pass, got, c.want) + } + } +} + +func TestBasicAuthMiddleware(t *testing.T) { + a := &Authenticator{creds: map[string][]byte{"alice": []byte(bcryptHash(t, "s3cret"))}} + a.dummyHash, _ = bcrypt.GenerateFromPassword([]byte("dummy"), 10) + + var reached bool + // Burst of 2 failures before throttling, so the 429 path is reachable. + limiter := rate.NewLimiter(rate.Every(time.Hour), 2) + h := a.basicAuth(limiter, func(w http.ResponseWriter, r *http.Request) { + reached = true + w.WriteHeader(http.StatusCreated) + }) + + do := func(user, pass string, withCreds bool) *httptest.ResponseRecorder { + reached = false + req := httptest.NewRequest(http.MethodPost, "/upload", nil) + if withCreds { + req.SetBasicAuth(user, pass) + } + rec := httptest.NewRecorder() + h(rec, req) + return rec + } + + // Good credentials pass through. + if rec := do("alice", "s3cret", true); rec.Code != http.StatusCreated || !reached { + t.Fatalf("valid creds: code=%d reached=%v", rec.Code, reached) + } + + // No credentials → 401 with a challenge. + rec := do("", "", false) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("missing creds: code=%d, want 401", rec.Code) + } + if !strings.HasPrefix(rec.Header().Get("WWW-Authenticate"), "Basic ") { + t.Errorf("missing WWW-Authenticate challenge: %q", rec.Header().Get("WWW-Authenticate")) + } + if reached { + t.Error("handler reached despite missing creds") + } + + // Wrong password burns the limiter (burst 2: one above + this one), then + // the next failure is throttled to 429. + if rec := do("alice", "nope", true); rec.Code != http.StatusUnauthorized { + t.Fatalf("wrong pass #2: code=%d, want 401", rec.Code) + } + if rec := do("alice", "nope", true); rec.Code != http.StatusTooManyRequests { + t.Fatalf("wrong pass #3: code=%d, want 429", rec.Code) + } + + // A valid request still succeeds while failures are throttled — the + // limiter only gates failures. + if rec := do("alice", "s3cret", true); rec.Code != http.StatusCreated { + t.Fatalf("valid creds under throttle: code=%d, want 201", rec.Code) + } +} + +func TestLoadHtpasswdErrors(t *testing.T) { + if _, err := LoadHtpasswd(filepath.Join(t.TempDir(), "does-not-exist")); err == nil { + t.Error("expected error for missing file") + } + + empty := filepath.Join(t.TempDir(), "htpasswd") + if err := os.WriteFile(empty, []byte("# only comments\n\nlegacy:$apr1$x$y\n"), 0600); err != nil { + t.Fatal(err) + } + if _, err := LoadHtpasswd(empty); err == nil { + t.Error("expected error when no bcrypt entries are usable") + } +} diff --git a/flake.nix b/flake.nix index c2467a2..5ab3e78 100644 --- a/flake.nix +++ b/flake.nix @@ -18,7 +18,7 @@ # Run `go mod tidy` to generate go.sum, then `nix build`. # Nix will fail and print the correct hash — paste it here. - vendorHash = "sha256-SxQDRProQtauWXPvPzbZZChLQd0iey9hnXFLiPYTS98="; + vendorHash = "sha256-Q+VeDZX9DlZtOZrYRwZo19eyob8jIu19IRhdvChmVL4="; meta = with pkgs.lib; { description = "Minimal static photo gallery"; diff --git a/go.mod b/go.mod index c3eb3f9..f950a3d 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,13 @@ module photogallery -go 1.22 +go 1.26.0 require ( github.com/BurntSushi/toml v1.4.0 github.com/fsnotify/fsnotify v1.7.0 + golang.org/x/crypto v0.58.0 golang.org/x/image v0.21.0 + golang.org/x/time v0.16.0 ) -require golang.org/x/sys v0.4.0 // indirect +require golang.org/x/sys v0.49.0 // indirect diff --git a/go.sum b/go.sum index b520222..e578e76 100644 --- a/go.sum +++ b/go.sum @@ -2,7 +2,11 @@ github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0 github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s= golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78= -golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= -golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= diff --git a/main.go b/main.go index e9d706b..5080078 100644 --- a/main.go +++ b/main.go @@ -20,6 +20,7 @@ func main() { serifFamily := flag.String("serif-family", "Crimson Pro", "CSS font-family for the primary serif text") mlFamily := flag.String("ml-family", "Manjari", "CSS font-family for the alt-script (Malayalam) bio line") pageSize := flag.Int("page-size", 60, "photos per page on the timeline and grid views (timeline packs whole days, so pages vary slightly around this)") + htpasswd := flag.String("htpasswd", "", "path to an htpasswd file (bcrypt, `htpasswd -B`) guarding /upload; empty disables auth") flag.Parse() for _, d := range []string{*contentDir, *outputDir} { @@ -41,6 +42,17 @@ func main() { defer w.Close() go w.Watch() - s := NewServer(*port, *contentDir, *outputDir, *serve, g) + var auth *Authenticator + if *htpasswd != "" { + auth, err = LoadHtpasswd(*htpasswd) + if err != nil { + log.Fatalf("htpasswd: %v", err) + } + log.Printf("upload auth: enabled (%s)", *htpasswd) + } else { + log.Print("upload auth: DISABLED (no -htpasswd); /upload is open") + } + + s := NewServer(*port, *contentDir, *outputDir, *serve, g, auth) log.Fatal(s.Listen()) } diff --git a/module.nix b/module.nix index 2080abe..0df9354 100644 --- a/module.nix +++ b/module.nix @@ -82,6 +82,20 @@ in { description = "Photos per page on the timeline and grid views. The timeline packs whole days, so pages vary slightly around this."; }; + htpasswdFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/run/secrets/photogallery-htpasswd"; + description = '' + Path to an Apache htpasswd file (bcrypt only — generate with + `htpasswd -B`) guarding the /upload endpoint. The app performs the + auth itself, so nginx no longer needs basic_auth. The file is handed + to the service via systemd LoadCredential, so it may be root-owned and + 0400 (e.g. an agenix/sops secret). Leave null to disable auth entirely + — then /upload is open and anyone who can reach it can post. + ''; + }; + nginx = { enable = lib.mkEnableOption "nginx virtual host for photogallery"; @@ -91,12 +105,6 @@ in { description = "Domain for the nginx virtual host."; }; - htpasswdFile = lib.mkOption { - type = lib.types.path; - example = "/etc/nginx/.htpasswd-gallery"; - description = "Path to htpasswd file protecting the /upload endpoint."; - }; - enableACME = lib.mkOption { type = lib.types.bool; default = true; @@ -134,7 +142,7 @@ in { User = "photogallery"; Group = "photogallery"; - ExecStart = lib.escapeShellArgs [ + ExecStart = lib.escapeShellArgs ([ "${pkg}/bin/photogallery" "--content" cfg.contentDir "--output" cfg.outputDir @@ -148,7 +156,14 @@ in { "--serif-family" cfg.serifFamily "--ml-family" cfg.mlFamily "--page-size" (toString cfg.pageSize) - ]; + ] ++ lib.optionals (cfg.htpasswdFile != null) [ + # %d is systemd's credentials directory; LoadCredential below drops + # the htpasswd file there, readable only by this service. + "--htpasswd" "%d/htpasswd" + ]); + + LoadCredential = lib.optional (cfg.htpasswdFile != null) + "htpasswd:${cfg.htpasswdFile}"; Restart = "on-failure"; RestartSec = "5s"; @@ -178,11 +193,11 @@ in { tryFiles = "$uri $uri/ =404"; }; + # Auth is handled by the app (see services.photogallery.htpasswdFile), + # so nginx just proxies the upload endpoint. locations."/upload" = { - proxyPass = "http://127.0.0.1:${toString cfg.port}"; - basicAuth = "gallery"; - basicAuthFile = cfg.nginx.htpasswdFile; - extraConfig = "client_max_body_size 20M;"; + proxyPass = "http://127.0.0.1:${toString cfg.port}"; + extraConfig = "client_max_body_size 20M;"; }; }; }; diff --git a/server.go b/server.go index 1cdd457..79ed9e7 100644 --- a/server.go +++ b/server.go @@ -11,6 +11,7 @@ import ( "time" "github.com/BurntSushi/toml" + "golang.org/x/time/rate" ) type Server struct { @@ -19,22 +20,37 @@ type Server struct { outputDir string serve bool generator *Generator + auth *Authenticator // nil disables auth (dev only) + authLimit *rate.Limiter } -func NewServer(port, contentDir, outputDir string, serve bool, g *Generator) *Server { +func NewServer(port, contentDir, outputDir string, serve bool, g *Generator, auth *Authenticator) *Server { return &Server{ port: port, contentDir: contentDir, outputDir: outputDir, serve: serve, generator: g, + auth: auth, + // Brute-force throttle: ~1 failed auth/sec steady, burst of 10. Only + // failures draw from it, so legitimate bulk uploads are never slowed. + authLimit: rate.NewLimiter(rate.Every(time.Second), 10), } } +// guard applies HTTP Basic auth to h when an Authenticator is configured, and +// passes it through untouched otherwise (local --serve development). +func (s *Server) guard(h http.HandlerFunc) http.HandlerFunc { + if s.auth == nil { + return h + } + return s.auth.basicAuth(s.authLimit, h) +} + func (s *Server) Listen() error { mux := http.NewServeMux() - mux.HandleFunc("GET /upload", s.handleUploadPage) - mux.HandleFunc("POST /upload", s.handleUpload) + mux.HandleFunc("GET /upload", s.guard(s.handleUploadPage)) + mux.HandleFunc("POST /upload", s.guard(s.handleUpload)) mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") })