feat(sidecar): extend scoped proxy to web-client mutations, harden path classification

Batch archive/restore/delete/approve/private validate every UID against
the PhotoPrism DB in one query. Per-photo PUT/approve/like/stack-file
ops are ownership-checked. Admin-role sessions pass through fully so
settings/users/index dialogs keep working. Paths are unescaped+cleaned
before classification so encoded dot-segments can't smuggle past the
allowlist. Full httptest coverage of the routing decisions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 13:05:12 +02:00
parent e578e1ce75
commit 312a4c1ee4
7 changed files with 307 additions and 72 deletions

View File

@@ -25,10 +25,10 @@ type dupFileLite struct {
} }
type dupGroup struct { type dupGroup struct {
Hash string `json:"hash"` Hash string `json:"hash"`
Size int64 `json:"size"` Size int64 `json:"size"`
IndexedPath *string `json:"indexedPath"` IndexedPath *string `json:"indexedPath"`
Files []dupFileLite `json:"files"` Files []dupFileLite `json:"files"`
} }
// dupListPhoto is the partial photo shape we pull from PhotoPrism when // dupListPhoto is the partial photo shape we pull from PhotoPrism when

View File

@@ -67,4 +67,4 @@ func handleFoldersProxy(pp *ppClient) gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{"folders": filtered}) c.JSON(http.StatusOK, gin.H{"folders": filtered})
} }
} }

View File

@@ -115,14 +115,14 @@ func handleLabels(pp *ppClient, ppDb *gorm.DB) gin.HandlerFunc {
// PpCounts mirrors PhotoPrism's session config.count block that drives // PpCounts mirrors PhotoPrism's session config.count block that drives
// the sidebar badges (review, archive, all, etc.). // the sidebar badges (review, archive, all, etc.).
type PpCounts struct { type PpCounts struct {
All int `json:"all"` All int `json:"all"`
Photos int `json:"photos"` Photos int `json:"photos"`
Media int `json:"media"` Media int `json:"media"`
Videos int `json:"videos"` Videos int `json:"videos"`
Review int `json:"review"` Review int `json:"review"`
Archived int `json:"archived"` Archived int `json:"archived"`
Hidden int `json:"hidden"` Hidden int `json:"hidden"`
Private int `json:"private"` Private int `json:"private"`
Favorites int `json:"favorites"` Favorites int `json:"favorites"`
} }
@@ -168,4 +168,4 @@ func handleScopedCounts(ppDb *gorm.DB) gin.HandlerFunc {
c.JSON(http.StatusOK, counts) c.JSON(http.StatusOK, counts)
} }
} }

View File

@@ -1,18 +1,22 @@
package main package main
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
gopath "path"
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
// Scoped PhotoPrism-compatible API proxy. // Scoped PhotoPrism-compatible API proxy.
@@ -28,17 +32,22 @@ import (
// //
// - search endpoints (photos, geo) get their `path` filter rewritten so // - search endpoints (photos, geo) get their `path` filter rewritten so
// results stay inside the caller's BasePath subtree; // results stay inside the caller's BasePath subtree;
// - single-photo reads and like/unlike are ownership-checked per UID; // - per-photo reads and mutations the web client needs (metadata PUT,
// approve, like, stack file ops) are ownership-checked per UID;
// - batch archive/restore/delete validates every UID in the body against
// the PhotoPrism DB before forwarding;
// - hash-addressed media (t/, dl/, videos/) and session/config pass // - hash-addressed media (t/, dl/, videos/) and session/config pass
// through — media URLs embed per-instance preview/download tokens and // through — media URLs embed per-instance preview/download tokens and
// unguessable content hashes, the same protection PhotoPrism's own // unguessable content hashes, the same protection PhotoPrism's own
// share links rely on; // share links rely on;
// - album/label/subject reads pass through (names are visible across // - album + label + subject reads and album/subject mutations pass
// users; the photos inside stay path-scoped); // through: PhotoPrism CE has no per-user albums or faces, so these are
// - everything else (settings, users, index, import, batch, uploads, // shared across users by design; the photos inside stay path-scoped;
// any mutation) answers 403. // - everything else (settings, users, index, import, uploads) answers 403.
// //
// Sessions with an empty BasePath (unscoped admins) pass through fully. // Admin-role sessions (and any session without a BasePath) pass through
// fully — the web client's admin dialogs (settings, users, indexing) need
// the raw API.
// ppQPathTerm matches a `path:` filter inside PhotoPrism's q-DSL — either // ppQPathTerm matches a `path:` filter inside PhotoPrism's q-DSL — either
// quoted (path:"a b/*") or bare (path:a/*). // quoted (path:"a b/*") or bare (path:a/*).
@@ -176,8 +185,46 @@ func proxyToken(r *http.Request) string {
return r.Header.Get("X-Session-ID") return r.Header.Get("X-Session-ID")
} }
// batchUIDsWithinBase validates that every photo UID in a batch body lives
// under base, using one SQL query against PhotoPrism's photos table. Fails
// closed: no DB handle, unknown UIDs, or any path outside base → false.
func batchUIDsWithinBase(ppDb *gorm.DB, uids []string, base string) bool {
if ppDb == nil || len(uids) == 0 {
return false
}
var n int64
err := ppDb.Table("photos").
Where("photo_uid IN ?", uids).
Where("photo_path = ? OR photo_path LIKE ?", base, base+"/%").
Count(&n).Error
if err != nil {
slog.Warn("pp-proxy: batch ownership query failed", "err", err)
return false
}
return n == int64(len(uids))
}
// readBatchBody consumes the request body, extracts the `photos` UID list,
// and reinstates the body so the proxy can still forward it.
func readBatchBody(r *http.Request) ([]string, bool) {
buf, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
r.Body.Close()
r.Body = io.NopCloser(bytes.NewReader(buf))
r.ContentLength = int64(len(buf))
if err != nil {
return nil, false
}
var body struct {
Photos []string `json:"photos"`
}
if err := json.Unmarshal(buf, &body); err != nil {
return nil, false
}
return body.Photos, true
}
// handlePPProxy returns the gin handler mounted at /api/v1/*rest. // handlePPProxy returns the gin handler mounted at /api/v1/*rest.
func handlePPProxy(cfg *Config) gin.HandlerFunc { func handlePPProxy(cfg *Config, ppDb *gorm.DB) gin.HandlerFunc {
target, err := url.Parse(cfg.PhotoprismBaseURL) target, err := url.Parse(cfg.PhotoprismBaseURL)
if err != nil { if err != nil {
slog.Error("pp-proxy: bad PHOTOPRISM_BASE_URL", "err", err) slog.Error("pp-proxy: bad PHOTOPRISM_BASE_URL", "err", err)
@@ -199,7 +246,18 @@ func handlePPProxy(cfg *Config) gin.HandlerFunc {
} }
return func(c *gin.Context) { return func(c *gin.Context) {
rest := strings.TrimPrefix(strings.TrimPrefix(c.Param("rest"), "/"), "") // The router keeps raw escapes (UseRawPath). Unescape and clean
// before classifying, then forward exactly the cleaned path — so
// `t%2F..%2Fsettings` can't be classified as media here yet reach
// /settings after PhotoPrism's own router cleans it.
unesc, err := url.PathUnescape(c.Param("rest"))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "bad path"})
return
}
rest := strings.TrimPrefix(gopath.Clean("/"+unesc), "/")
c.Request.URL.Path = "/api/v1/" + rest
c.Request.URL.RawPath = ""
method := c.Request.Method method := c.Request.Method
// Unauthenticated / token-in-URL surface: login+logout, client // Unauthenticated / token-in-URL surface: login+logout, client
@@ -222,8 +280,10 @@ func handlePPProxy(cfg *Config) gin.HandlerFunc {
return return
} }
base := strings.Trim(user.BasePath, "/") base := strings.Trim(user.BasePath, "/")
if base == "" { if base == "" || user.Role == "admin" {
// Unscoped admin — full pass-through. // Admins keep the raw API — the web client's settings, users,
// and indexing dialogs need it. (On this deployment the admin
// account carries a BasePath purely to default its web view.)
proxy.ServeHTTP(c.Writer, c.Request) proxy.ServeHTTP(c.Writer, c.Request)
return return
} }
@@ -236,14 +296,37 @@ func handlePPProxy(cfg *Config) gin.HandlerFunc {
c.Request.URL.RawQuery = scopeSearchValues(q, base).Encode() c.Request.URL.RawQuery = scopeSearchValues(q, base).Encode()
proxy.ServeHTTP(c.Writer, c.Request) proxy.ServeHTTP(c.Writer, c.Request)
// Single-photo reads + like/unlike: ownership-checked per UID. // Batch mutations: every UID in the body must be the caller's.
case method == http.MethodPost && (rest == "batch/photos/archive" ||
rest == "batch/photos/restore" || rest == "batch/photos/delete" ||
rest == "batch/photos/approve" || rest == "batch/photos/private"):
uids, ok := readBatchBody(c.Request)
if !ok || !batchUIDsWithinBase(ppDb, uids, base) {
forbid(c)
return
}
proxy.ServeHTTP(c.Writer, c.Request)
// Per-photo operations: ownership-checked per UID. Covers reads,
// metadata PUT, approve, like/unlike, download, and the stack file
// ops (set primary / unstack / delete file) the review UI uses.
case strings.HasPrefix(rest, "photos/"): case strings.HasPrefix(rest, "photos/"):
parts := strings.Split(rest, "/") parts := strings.Split(rest, "/")
uid := parts[1] uid := parts[1]
allowed := (isGet && len(parts) == 2) || // GET photos/:uid var allowed bool
(isGet && len(parts) == 3 && parts[2] == "dl") || switch len(parts) {
(len(parts) == 3 && parts[2] == "like" && case 2:
(method == http.MethodPost || method == http.MethodDelete)) allowed = isGet || method == http.MethodPut
case 3:
allowed = (isGet && parts[2] == "dl") ||
(method == http.MethodPost && parts[2] == "approve") ||
(parts[2] == "like" && (method == http.MethodPost || method == http.MethodDelete))
case 4:
allowed = method == http.MethodDelete && parts[2] == "files"
case 5:
allowed = method == http.MethodPost && parts[2] == "files" &&
(parts[4] == "primary" || parts[4] == "unstack")
}
if !allowed { if !allowed {
forbid(c) forbid(c)
return return
@@ -254,12 +337,13 @@ func handlePPProxy(cfg *Config) gin.HandlerFunc {
} }
proxy.ServeHTTP(c.Writer, c.Request) proxy.ServeHTTP(c.Writer, c.Request)
// Collection reads: names are shared across users by design (CE has // Albums (heaps), labels, subjects, faces: shared across users by
// no per-user albums); their photo contents stay path-scoped above. // design in CE — reads and mutations pass through; the photos inside
case isGet && (rest == "albums" || strings.HasPrefix(rest, "albums/") || // any of them stay path-scoped by the rules above.
case rest == "albums" || strings.HasPrefix(rest, "albums/") ||
rest == "labels" || strings.HasPrefix(rest, "labels/") || rest == "labels" || strings.HasPrefix(rest, "labels/") ||
rest == "subjects" || strings.HasPrefix(rest, "subjects/") || rest == "subjects" || strings.HasPrefix(rest, "subjects/") ||
rest == "faces" || strings.HasPrefix(rest, "faces/")): rest == "faces" || strings.HasPrefix(rest, "faces/"):
proxy.ServeHTTP(c.Writer, c.Request) proxy.ServeHTTP(c.Writer, c.Request)
default: default:

View File

@@ -1,8 +1,14 @@
package main package main
import ( import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url" "net/url"
"strings"
"testing" "testing"
"github.com/gin-gonic/gin"
) )
func TestScopeQ(t *testing.T) { func TestScopeQ(t *testing.T) {
@@ -59,6 +65,150 @@ func TestScopeSearchValues(t *testing.T) {
} }
} }
// fakePP stands in for PhotoPrism: answers /session per token, echoes
// every other request's method+path+query back as JSON.
func fakePP(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/session" && r.Method == http.MethodGet {
var user map[string]any
switch r.Header.Get("X-Auth-Token") {
case "tok-scoped":
user = map[string]any{"UID": "u1", "Name": "alice", "Role": "user", "BasePath": "alice"}
case "tok-admin":
user = map[string]any{"UID": "u0", "Name": "root", "Role": "admin", "BasePath": "alice"}
default:
w.WriteHeader(http.StatusUnauthorized)
return
}
json.NewEncoder(w).Encode(map[string]any{"user": user})
return
}
if strings.HasPrefix(r.URL.Path, "/api/v1/photos/") && r.Method == http.MethodGet &&
strings.Count(r.URL.Path, "/") == 4 {
uid := strings.TrimPrefix(r.URL.Path, "/api/v1/photos/")
path := "alice/2024"
if strings.HasPrefix(uid, "foreign") {
path = "bob/2024"
}
json.NewEncoder(w).Encode(map[string]any{"Path": path})
return
}
json.NewEncoder(w).Encode(map[string]any{
"echo": r.Method + " " + r.URL.Path,
"query": r.URL.RawQuery,
"handled": true,
})
}))
}
func proxyRig(t *testing.T) (*httptest.Server, func()) {
t.Helper()
up := fakePP(t)
cfg := &Config{PhotoprismBaseURL: up.URL}
gin.SetMode(gin.TestMode)
r := gin.New()
r.UseRawPath = true
r.UnescapePathValues = false
r.Any("/api/v1/*rest", handlePPProxy(cfg, nil))
front := httptest.NewServer(r)
return front, func() { front.Close(); up.Close() }
}
func proxyReq(t *testing.T, front, method, path, token string) (int, string) {
t.Helper()
req, _ := http.NewRequest(method, front+path, nil)
if token != "" {
req.Header.Set("X-Auth-Token", token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var sb strings.Builder
buf := make([]byte, 4096)
for {
n, err := resp.Body.Read(buf)
sb.Write(buf[:n])
if err != nil {
break
}
}
return resp.StatusCode, sb.String()
}
func TestProxyRouting(t *testing.T) {
front, done := proxyRig(t)
defer done()
t.Run("media passes unauthenticated", func(t *testing.T) {
code, body := proxyReq(t, front.URL, "GET", "/api/v1/t/abc/tok/tile_224", "")
if code != 200 || !strings.Contains(body, "/api/v1/t/abc/tok/tile_224") {
t.Errorf("thumb should pass through, got %d %s", code, body)
}
})
t.Run("scoped search gains path scope", func(t *testing.T) {
code, body := proxyReq(t, front.URL, "GET", "/api/v1/photos?count=3&q="+url.QueryEscape(`path:"bob/*"`), "tok-scoped")
if code != 200 {
t.Fatalf("got %d", code)
}
var out struct{ Query string }
json.Unmarshal([]byte(body), &out)
q, _ := url.ParseQuery(out.Query)
if q.Get("q") != `path:"alice/*"` {
t.Errorf("escaping q must collapse to own scope, got %q", q.Get("q"))
}
})
t.Run("scoped settings blocked", func(t *testing.T) {
code, _ := proxyReq(t, front.URL, "GET", "/api/v1/settings", "tok-scoped")
if code != http.StatusForbidden {
t.Errorf("settings should 403 for scoped user, got %d", code)
}
})
t.Run("admin settings passes", func(t *testing.T) {
code, body := proxyReq(t, front.URL, "GET", "/api/v1/settings", "tok-admin")
if code != 200 || !strings.Contains(body, "/api/v1/settings") {
t.Errorf("admin should pass through, got %d %s", code, body)
}
})
t.Run("no token unauthorized", func(t *testing.T) {
code, _ := proxyReq(t, front.URL, "GET", "/api/v1/photos", "")
if code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", code)
}
})
t.Run("own photo readable, foreign 404", func(t *testing.T) {
code, _ := proxyReq(t, front.URL, "GET", "/api/v1/photos/mine123", "tok-scoped")
if code != 200 {
t.Errorf("own photo should pass, got %d", code)
}
code, _ = proxyReq(t, front.URL, "GET", "/api/v1/photos/foreign9", "tok-scoped")
if code != http.StatusNotFound {
t.Errorf("foreign photo should 404, got %d", code)
}
})
t.Run("encoded traversal cannot reach settings as media", func(t *testing.T) {
code, _ := proxyReq(t, front.URL, "GET", "/api/v1/t%2F..%2Fsettings", "tok-scoped")
if code != http.StatusForbidden {
t.Errorf("traversal should classify as settings and 403, got %d", code)
}
})
t.Run("batch without db fails closed", func(t *testing.T) {
req, _ := http.NewRequest("POST", front.URL+"/api/v1/batch/photos/archive",
strings.NewReader(`{"photos":["p1"]}`))
req.Header.Set("X-Auth-Token", "tok-scoped")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("batch with nil ppDb must 403, got %d", resp.StatusCode)
}
})
}
func TestPathValueAllowed(t *testing.T) { func TestPathValueAllowed(t *testing.T) {
if pathValueAllowed(`"muli/*"`, "dtoro") { if pathValueAllowed(`"muli/*"`, "dtoro") {
t.Error("outside value must be rejected") t.Error("outside value must be rejected")

View File

@@ -79,54 +79,55 @@ func main() {
// Every other endpoint runs behind the session gate. Mounting them // Every other endpoint runs behind the session gate. Mounting them
// under one group keeps the middleware wiring obvious. // under one group keeps the middleware wiring obvious.
auth := r.Group("/api/sidecar", requireSession(pp)) auth := r.Group("/api/sidecar", requireSession(pp))
{ {
auth.GET("/prefs", handlePrefsGet(db)) auth.GET("/prefs", handlePrefsGet(db))
auth.PUT("/prefs", handlePrefsPut(cfg, db)) auth.PUT("/prefs", handlePrefsPut(cfg, db))
auth.GET("/photos/marks", handleMarksAll(db)) auth.GET("/photos/marks", handleMarksAll(db))
auth.GET("/photos/:uid/marks", handleMarkGet(db)) auth.GET("/photos/:uid/marks", handleMarkGet(db))
auth.PUT("/photos/:uid/marks", handleMarkPut(db)) auth.PUT("/photos/:uid/marks", handleMarkPut(db))
auth.POST("/photos/marks/bulk", handleMarkBulk(db)) auth.POST("/photos/marks/bulk", handleMarkBulk(db))
auth.POST("/files/:uid/rename", handleRename(cfg, pp)) auth.POST("/files/:uid/rename", handleRename(cfg, pp))
auth.POST("/folders", handleFolderCreate(cfg, pp)) auth.POST("/folders", handleFolderCreate(cfg, pp))
auth.POST("/folders/counts", handleFolderCounts(pp)) auth.POST("/folders/counts", handleFolderCounts(pp))
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp)) auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
auth.POST("/folders/:rel/move", handleFolderMove(cfg, pp)) auth.POST("/folders/:rel/move", handleFolderMove(cfg, pp))
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp)) auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp)) auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
auth.POST("/photos/move", handlePhotosMove(cfg, pp)) auth.POST("/photos/move", handlePhotosMove(cfg, pp))
auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db)) auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db))
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db)) auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db))
// User-scoped proxies — require PpDSN connection. // User-scoped proxies — require PpDSN connection.
if ppDb != nil { if ppDb != nil {
auth.GET("/labels", handleLabels(pp, ppDb)) auth.GET("/labels", handleLabels(pp, ppDb))
auth.GET("/counts", handleScopedCounts(ppDb)) auth.GET("/counts", handleScopedCounts(ppDb))
auth.GET("/countries", handleCountries(ppDb)) auth.GET("/countries", handleCountries(ppDb))
} }
// User-scoped photos — post-filters by BasePath so review/archive // User-scoped photos — post-filters by BasePath so review/archive
// tabs only show photos the user owns. // tabs only show photos the user owns.
auth.GET("/timeline", handlePhotos(pp)) auth.GET("/timeline", handlePhotos(pp))
// Photos carrying a Note (Caption) — pages PhotoPrism fully so // Photos carrying a Note (Caption) — pages PhotoPrism fully so
// the /notes view isn't capped to the newest slice. // the /notes view isn't capped to the newest slice.
auth.GET("/notes", handleNotes(pp)) auth.GET("/notes", handleNotes(pp))
// User-scoped folders — post-filters the folder tree by BasePath // User-scoped folders — post-filters the folder tree by BasePath
// so the sidebar shows only folders under the user's library root. // so the sidebar shows only folders under the user's library root.
auth.GET("/folders", handleFoldersProxy(pp)) auth.GET("/folders", handleFoldersProxy(pp))
} }
// PhotoPrism-compatible scoped proxy — the public surface for third- // PhotoPrism-compatible scoped proxy — the public /api/v1 surface for
// party PhotoPrism apps (prism.hubris.network routes here instead of // both the web client and third-party PhotoPrism apps (Caddy routes
// straight to PhotoPrism). See handlers_ppproxy.go for the rules. // /api/v1 here instead of straight to PhotoPrism, which does not
r.Any("/api/v1/*rest", handlePPProxy(cfg)) // enforce base_path in CE). See handlers_ppproxy.go for the rules.
r.Any("/api/v1/*rest", handlePPProxy(cfg, ppDb))
addr := cfg.ListenAddr + ":" + itoa(cfg.Port) addr := cfg.ListenAddr + ":" + itoa(cfg.Port)
srv := &http.Server{ srv := &http.Server{
@@ -159,4 +160,3 @@ func main() {
} }
<-idleClosed <-idleClosed
} }

View File

@@ -90,6 +90,7 @@ func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body
type ppSessionUser struct { type ppSessionUser struct {
UserUID string `json:"UID"` UserUID string `json:"UID"`
UserName string `json:"Name"` UserName string `json:"Name"`
Role string `json:"Role"`
BasePath string `json:"BasePath"` BasePath string `json:"BasePath"`
} }