Extend the access token to every endpoint, not just the web UI
Build and push server image / build-and-push (push) Successful in 33s

The management token only gated / and /api/* -- every device-facing
/frame/* endpoint (including /frame/image, which serves the actual
photo bytes) stayed open regardless. That was fine while the server
was assumed LAN-only, but defeats the point now that HTTPS exists
specifically to let this sit behind a public hostname.

build_url() (frame_client.c) is the one chokepoint all firmware-side
URL construction already went through, so it now appends ?token= to
every request it builds -- device fetches and QR-embedded links alike
-- instead of that being bolted on per-callsite. Server-side, the
former require_management_token dependency (renamed require_access_token)
is applied to /frame/config, /frame/image, /frame/advance,
/frame/photo-info, /frame/face-labels, and /frame/share/{asset_id} too.
/health stays open -- pure liveness, nothing sensitive to protect.
This commit is contained in:
2026-07-19 09:52:01 -04:00
parent 6c7468a36e
commit 62cf907d88
4 changed files with 109 additions and 81 deletions
+37 -29
View File
@@ -34,25 +34,32 @@ MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
def _token_valid(request: Request, cfg: config.FrameConfig) -> bool:
"""No management_token configured (MANAGEMENT_TOKEN env var, see
docker-compose.yml.example) means the management page stays open on a
trusted LAN, matching this project's existing default. Once one's
docker-compose.yml.example) means the whole server stays open on a
trusted LAN, matching this project's original default. Once one's
set, a request is authorized by either a ?token= query param (what
the manage-menu QR code embeds) or the cookie index() sets after a
valid query-param hit (so the page's own fetch()/<img> calls, which
carry no query string, stay authorized for the rest of the visit)."""
the ESP32 sends on every device request, and what the manage-menu/
share QR codes embed for a human scanning them) or the cookie
index() sets after a valid query-param hit (so the web UI's own
fetch()/<img> calls, which carry no query string, stay authorized
for the rest of that browsing visit)."""
if not cfg.management_token:
return True
supplied = request.query_params.get("token") or request.cookies.get(MANAGEMENT_TOKEN_COOKIE)
return supplied is not None and supplied == cfg.management_token
def require_management_token(request: Request) -> None:
"""Dependency for the /api/* routes behind the management page. index()
below handles the unauthorized case itself (a friendlier HTML prompt,
not a bare 401) since that's the one route an unauthorized visitor is
actually meant to land on."""
def require_access_token(request: Request) -> None:
"""Dependency for every route except / and /health: the web UI's
/api/* and every device-facing /frame/*. index() handles the
unauthorized case itself (a friendlier HTML prompt, not a bare 401)
since that's the one route a human is actually meant to land on with
no token yet; the ESP32 sends its token as ?token= on every request
it makes (see frame_client.c's build_url()), so device endpoints
just 401 outright on a missing/wrong one. /health stays open -- it
reveals nothing but process liveness, and gating it would break
plain infra/uptime monitoring for no real security benefit."""
if not _token_valid(request, config.load()):
raise HTTPException(401, "Missing or invalid management token")
raise HTTPException(401, "Missing or invalid access token")
@app.get("/health")
@@ -60,7 +67,7 @@ def health() -> dict:
return {"status": "ok"}
@app.get("/frame/config")
@app.get("/frame/config", dependencies=[Depends(require_access_token)])
def frame_config():
"""Device-facing settings, polled by the frame alongside its
reachability check. Always returns 200 with current settings
@@ -91,7 +98,7 @@ def index(request: Request):
return response
@app.get("/api/albums", dependencies=[Depends(require_management_token)])
@app.get("/api/albums", dependencies=[Depends(require_access_token)])
def api_albums():
cfg = config.load()
if not cfg.immich_url or not cfg.immich_api_key:
@@ -103,7 +110,7 @@ def api_albums():
return [{"id": a["id"], "name": a["albumName"], "count": a.get("assetCount", 0)} for a in albums]
@app.post("/api/config", dependencies=[Depends(require_management_token)])
@app.post("/api/config", dependencies=[Depends(require_access_token)])
def api_config_save(
album_id: str = Form(""),
order: str = Form("sequential"),
@@ -168,7 +175,7 @@ def _render_asset(client: ImmichClient, cfg: config.FrameConfig, asset_id: str)
return render_frame(source, faces=faces)
@app.get("/frame/image")
@app.get("/frame/image", dependencies=[Depends(require_access_token)])
def frame_image():
"""Returns the current photo. Idempotent: only actually advances to
the next photo once refresh_interval_s has elapsed since the current
@@ -187,7 +194,7 @@ def frame_image():
return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream")
@app.post("/frame/advance")
@app.post("/frame/advance", dependencies=[Depends(require_access_token)])
def frame_advance():
"""Forces an immediate advance to the next photo, ignoring
refresh_interval_s, and resets the interval clock from now. Used by
@@ -274,7 +281,7 @@ def _format_taken_at(exif: dict) -> str | None:
return None
@app.get("/frame/photo-info")
@app.get("/frame/photo-info", dependencies=[Depends(require_access_token)])
def frame_photo_info():
"""Location/date-taken text for the manage-button overlay, plus the
asset id used to build the share-QR's target URL. Read-only, same
@@ -307,16 +314,17 @@ def frame_photo_info():
}
@app.get("/frame/share/{asset_id}")
@app.get("/frame/share/{asset_id}", dependencies=[Depends(require_access_token)])
def frame_share(asset_id: str):
"""Creates a 30-minute public Immich share link for asset_id and
redirects to it -- what the manage overlay's bottom-left QR code
points to. The link is created lazily, when this actually gets hit
(i.e. when someone scans it), not when the manage button was
pressed, so the 30-minute window starts when it's actually used.
Scoped to the photo currently showing or queued -- not any arbitrary
Immich asset id -- since this is otherwise an unauthenticated
endpoint (see server/README.md)."""
points to (the firmware bakes ?token= into that QR the same way it
does for the management QR, see frame_client.c's build_url()). The
link is created lazily, when this actually gets hit (i.e. when
someone scans it), not when the manage button was pressed, so the
30-minute window starts when it's actually used. Also scoped to the
photo currently showing or queued -- not any arbitrary Immich asset
id -- as a second layer even a leaked token wouldn't bypass."""
cfg = config.load()
_require_configured(cfg)
@@ -332,7 +340,7 @@ def frame_share(asset_id: str):
return RedirectResponse(share_url)
@app.get("/frame/face-labels")
@app.get("/frame/face-labels", dependencies=[Depends(require_access_token)])
def frame_face_labels():
"""Named-face positions for the manage button's escalated "level 2"
menu -- who's in the current photo, per Immich's own face
@@ -381,7 +389,7 @@ def frame_face_labels():
return result
@app.get("/api/queue", dependencies=[Depends(require_management_token)])
@app.get("/api/queue", dependencies=[Depends(require_access_token)])
def api_queue():
cfg = config.load()
_require_configured(cfg)
@@ -408,7 +416,7 @@ class QueueReorderRequest(BaseModel):
queue: list[str]
@app.post("/api/queue/reorder", dependencies=[Depends(require_management_token)])
@app.post("/api/queue/reorder", dependencies=[Depends(require_access_token)])
def api_queue_reorder(body: QueueReorderRequest):
"""Applies the client's requested order, tolerating drift between the
browser's last-fetched snapshot and the server's current queue (e.g.
@@ -429,7 +437,7 @@ class QueuePromoteRequest(BaseModel):
asset_id: str
@app.post("/api/queue/promote", dependencies=[Depends(require_management_token)])
@app.post("/api/queue/promote", dependencies=[Depends(require_access_token)])
def api_queue_promote(body: QueuePromoteRequest):
"""Moves a single photo to the front of the queue -- "Show next" in
the web UI. Unlike /api/queue/reorder, this doesn't depend on the
@@ -444,7 +452,7 @@ def api_queue_promote(body: QueuePromoteRequest):
return {"status": "saved"}
@app.get("/api/photo-thumbnail/{asset_id}", dependencies=[Depends(require_management_token)])
@app.get("/api/photo-thumbnail/{asset_id}", dependencies=[Depends(require_access_token)])
def api_photo_thumbnail(asset_id: str):
cfg = config.load()
_require_configured(cfg)