fix(security): close cross-event thumbnail leak, bulk-op ownership bypass, + hardening

Auth/access-control audit fixes (all pre-existing on main; none are
regressions). Verified end-to-end where noted.

HIGH
- Thumbnail enumeration: photoAuth granted any gallery token access to any
  flat /thumbnails/thumb_* file, so a visitor to one gallery could
  enumerate another (password-protected) gallery's entire thumbnail set.
  Scope thumbnail access to the token's event via photos.thumbnail_path.
  Live-verified: cross-event fetch now 404s, own-event still 200s.
- Bulk ownership bypass: bulk-archive/bulk-delete acted on body-supplied
  event ids with no owner filter (single-event routes enforce
  requireEventOwnership), letting admin/editor archive or cascade-delete
  any event. Add filterOwnedEventIds; also guard rename + import-external;
  tighten photo-retry to scope admin (not just editor). Fix misleading
  bulk-delete comment.

MED
- verifyGalleryAccess never checked decoded.type — assert 'gallery'
  instead of relying on other token types incidentally lacking eventId.
- secure-images generate-token/secure-download missing denySlideshowToken
  (#646 bypass): a leaked slideshow token could download originals.
- Frontend: AuthenticatedImage + api.ts attached the gallery bearer token
  to absolute/external URLs — only attach to relative same-app paths.

LOW hardening
- Pin algorithms:['HS256'] on all auth-boundary jwt.verify calls.
- crypto.timingSafeEqual for share-token + HMAC compares (utils/timingSafe).
- Remove dead photoAuth import in galleryFeedback.

Tests: new regression suites for thumbnail scoping + filterOwnedEventIds;
fixed verifyGalleryAccess.customerRevoke fixture (real customer tokens
carry type:'gallery'). Full backend suite at the pre-existing baseline
(5 suites/27 tests fail on main too), zero new failures.
This commit is contained in:
Paul Nothaft
2026-07-03 10:27:28 +02:00
parent e513e8345b
commit 081f3edcdf
22 changed files with 372 additions and 60 deletions
@@ -137,18 +137,26 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
throw new Error('No URL provided');
}
// Build full URL for the image
// Build full URL for the image. Only relative paths are app-owned;
// an absolute URL is passed through untouched.
const isRelative = rawUrl.startsWith('/');
const fullImageUrl = rawUrl.startsWith('/admin')
? buildResourceUrl(`/api${rawUrl}`)
: rawUrl.startsWith('/')
: isRelative
? buildResourceUrl(rawUrl)
: rawUrl;
const headers: Record<string, string> = {};
const slugForRequest = resolveSlug(rawUrl);
const token = getGalleryToken(slugForRequest);
if (token) {
headers.Authorization = `Bearer ${token}`;
// Attach the gallery bearer token ONLY to relative (same-app) image
// paths. Never send it to an absolute/external URL — that would leak
// gallery credentials cross-origin. AuthenticatedImage does not
// support external URLs by design.
if (isRelative) {
const slugForRequest = resolveSlug(rawUrl);
const token = getGalleryToken(slugForRequest);
if (token) {
headers.Authorization = `Bearer ${token}`;
}
}
const response = await fetch(fullImageUrl, {