fix(security): block guest access to hidden/client-only photos across bulk + secure routes (#939)

* fix(security): block guest access to hidden/client-only photos across bulk + secure routes

* fix(security): harden hidden-photo fix per review (stale ZIP cache, legacy token mint, SQLite bool, client rebuild)

* fix(security): invalidate ZIP cache on photo visibility/category change (codex r2)

* fix(security): recheck photo visibility at signed/secure serve time (TOCTOU) + invalidate ZIP on client visibility change (codex r3)

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-08-01 17:36:15 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent fe615c82e4
commit 8a87c9274b
9 changed files with 505 additions and 42 deletions
@@ -112,8 +112,15 @@ class DownloadZipService {
const event = await db('events').where({ id: eventId }).first();
if (!event) return { success: false, error: 'Event not found' };
// The prebuilt zip is served to ordinary gallery guests (the
// download-all fast path), so it must exclude hidden/client-only
// photos — NULL visibility counts as visible (pre-migration rows).
// PIN-clients bypass this cache and stream a full archive instead.
const photos = await db('photos')
.where({ event_id: eventId })
.where(function () {
this.where('visibility', 'visible').orWhereNull('visibility');
})
.select('*')
.orderBy('type', 'asc')
.orderBy('uploaded_at', 'desc');
+6 -1
View File
@@ -25,7 +25,11 @@ class SecureImageService {
// Reveal mode (#838): whether the minting context bypasses the
// hidden-gallery gate — re-checked at SERVE time so a re-hide
// invalidates in-flight guest tokens without breaking the slideshow.
revealBypass = false
revealBypass = false,
// Whether the minter was a PIN-client — lets the serve route keep
// delivering a photo hidden AFTER minting (TOCTOU). A guest's token
// carries false, so it stops the moment the photo is hidden.
clientBypass = false
} = options;
const tokenData = {
@@ -37,6 +41,7 @@ class SecureImageService {
usedCount: 0,
protectionLevel,
revealBypass,
clientBypass,
createdAt: Date.now()
};