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

@@ -1,18 +1,22 @@
package main
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httputil"
"net/url"
gopath "path"
"regexp"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// Scoped PhotoPrism-compatible API proxy.
@@ -28,17 +32,22 @@ import (
//
// - search endpoints (photos, geo) get their `path` filter rewritten so
// 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
// through — media URLs embed per-instance preview/download tokens and
// unguessable content hashes, the same protection PhotoPrism's own
// share links rely on;
// - album/label/subject reads pass through (names are visible across
// users; the photos inside stay path-scoped);
// - everything else (settings, users, index, import, batch, uploads,
// any mutation) answers 403.
// - album + label + subject reads and album/subject mutations pass
// through: PhotoPrism CE has no per-user albums or faces, so these are
// shared across users by design; the photos inside stay path-scoped;
// - 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
// 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")
}
// 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.
func handlePPProxy(cfg *Config) gin.HandlerFunc {
func handlePPProxy(cfg *Config, ppDb *gorm.DB) gin.HandlerFunc {
target, err := url.Parse(cfg.PhotoprismBaseURL)
if err != nil {
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) {
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
// Unauthenticated / token-in-URL surface: login+logout, client
@@ -222,8 +280,10 @@ func handlePPProxy(cfg *Config) gin.HandlerFunc {
return
}
base := strings.Trim(user.BasePath, "/")
if base == "" {
// Unscoped admin — full pass-through.
if base == "" || user.Role == "admin" {
// 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)
return
}
@@ -236,14 +296,37 @@ func handlePPProxy(cfg *Config) gin.HandlerFunc {
c.Request.URL.RawQuery = scopeSearchValues(q, base).Encode()
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/"):
parts := strings.Split(rest, "/")
uid := parts[1]
allowed := (isGet && len(parts) == 2) || // GET photos/:uid
(isGet && len(parts) == 3 && parts[2] == "dl") ||
(len(parts) == 3 && parts[2] == "like" &&
(method == http.MethodPost || method == http.MethodDelete))
var allowed bool
switch len(parts) {
case 2:
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 {
forbid(c)
return
@@ -254,12 +337,13 @@ func handlePPProxy(cfg *Config) gin.HandlerFunc {
}
proxy.ServeHTTP(c.Writer, c.Request)
// Collection reads: names are shared across users by design (CE has
// no per-user albums); their photo contents stay path-scoped above.
case isGet && (rest == "albums" || strings.HasPrefix(rest, "albums/") ||
// Albums (heaps), labels, subjects, faces: shared across users by
// design in CE — reads and mutations pass through; the photos inside
// any of them stay path-scoped by the rules above.
case rest == "albums" || strings.HasPrefix(rest, "albums/") ||
rest == "labels" || strings.HasPrefix(rest, "labels/") ||
rest == "subjects" || strings.HasPrefix(rest, "subjects/") ||
rest == "faces" || strings.HasPrefix(rest, "faces/")):
rest == "faces" || strings.HasPrefix(rest, "faces/"):
proxy.ServeHTTP(c.Writer, c.Request)
default: