* fix(admin): serve videos with their real MIME type in the admin photo view (#908) (stable) The admin view route built Content-Type from the filename extension — image/<ext> — which is invalid for videos (image/mp4). The admin player fetches this URL into a blob that inherits the type, and browsers refuse to play a <video> blob labeled image/*: blank/grey preview, while download (which already uses photo.mime_type) worked fine. Stored mime_type now wins; videos without one fall back to video/mp4, images to the extension, and extensionless files to image/jpeg instead of the equally invalid bare 'image/'. Also unrefs chunkedUploadService's module-level hourly cleanup interval: it kept Jest from exiting for any suite requiring adminPhotos (it's why adminPhotos.reference sits on the CI ignore list). Production behavior unchanged — the HTTP listener keeps the process alive. New adminPhotoContentType suite pins all four MIME cases. * fix(admin): harden admin photo Content-Type resolution (#908 review round) External review findings, all verified: - The header is now ALWAYS image/* or video/*. photos.mime_type is never echoed verbatim unless it is a video/ type — the chunked-upload path stores the client-sent MIME unvalidated, so a stored text/html served inline under the app origin was a same-origin XSS hazard. - MIME-less videos map from the extension via the shared EXTENSION_TO_MIME (.mov → video/quicktime, .webm → video/webm) instead of a blanket video/mp4 that would mislabel them. - Images ignore the stored MIME entirely: migration 039 backfilled image/jpeg onto every legacy row (PNGs included), so trusting it would regress previously-correct extension-derived types. Extension wins, normalized (jpg → image/jpeg). Suite extended to 8 MIME cases including the XSS guard and the 039-backfill immunity. * fix(admin): validate stored video MIME as a full header-safe token (#908 review round 2) A prefix check let malformed client-stored values through: 'video/mp4\r\nX: y' makes res.setHeader throw ERR_INVALID_CHAR — a permanent 500 for that photo — and a bare 'video/' is an invalid type. Strict /^video\/[\w.+-]+$/ now gates the stored value; anything else falls back to the extension map. Two new tests pin both shapes. * fix(admin): map-only image Content-Type — no raw extension interpolation (#908 review round 3) image/${ext} could synthesize image/svg+xml (scriptable when served inline) or header-invalid values from client-controlled chunked-upload filenames. The shared EXTENSION_TO_MIME map is now the allowlist on the image side too; unmapped extensions serve as image/jpeg — browsers sniff image bytes in img/blob contexts, so a mislabel is harmless where an injected type is not. * fix(admin): own-property lookup in the extension MIME map (#908 review round) A client-controlled filename ending in .constructor / .__proto__ / .toString made EXTENSION_TO_MIME[ext] return an inherited Object.prototype member (truthy), and the downstream extMime.startsWith threw — a permanent 500 on the admin view for that photo instead of the JPEG / mp4 fallback. hasOwnProperty-gated now; test pins both a .constructor image and a .__proto__ video. * fix(admin): honor safe stored image MIME for auto-imported formats (#908 review round 2) My previous round made the image side map-only to dodge the migration 039 image/jpeg backfill and image/svg+xml — but that regressed the S3 auto-importer (STORAGE_AUTO_IMPORT), which stores correct types for avif/bmp/tiff/heic whose extensions aren't in EXTENSION_TO_MIME. Those now served as image/jpeg (JPEG-labelled non-JPEG bytes). Precedence is now mapped-extension (still corrects the 039 backfill on PNGs) -> stored MIME IF in a safe raster allowlist (avif/bmp/tiff/heic + the mapped ones) -> image/jpeg. Allowlist, not a regex: image/svg+xml stays excluded (scriptable inline). Tests pin avif preserved and svg degraded to jpeg. * fix(admin): allow any header-safe raster MIME, deny svg/xml (#908 review round 3) The round-2 hand-listed Set kept missing formats the S3 auto-importer stores (apng/ico/jxl beyond avif/bmp/tiff). Replace it with a regex: honor image/<token> EXCEPT the scriptable svg / *+xml family. Covers every current and future raster type in one rule while still blocking inline-scriptable svg and header injection. Tests pin apng + x-icon preserved, svg still degraded to jpeg. --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
6891769124
commit
d68d84e5c8
@@ -1126,7 +1126,65 @@ router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view
|
||||
const event = await db('events').where('id', eventId).first();
|
||||
const storageKey = resolvePhotoStorageKey(event, photo);
|
||||
|
||||
res.setHeader('Content-Type', `image/${path.extname(photo.filename).slice(1)}`);
|
||||
// Content-Type resolution (#908 + external review). Invariant: the
|
||||
// header is ALWAYS image/* or video/*.
|
||||
// - photos.mime_type is never echoed verbatim unless it is a video/
|
||||
// type: the chunked-upload path stores the client-sent MIME
|
||||
// unvalidated, so a stored text/html served inline under the app
|
||||
// origin would be a same-origin XSS gift.
|
||||
// - Images ignore the stored value entirely — migration 039
|
||||
// backfilled image/jpeg onto every legacy row (PNGs included), so
|
||||
// the extension is the more trustworthy signal; normalized via the
|
||||
// shared map (image/jpg → image/jpeg), jpeg fallback when unknown.
|
||||
// - Videos prefer a stored video/ type, then the extension map
|
||||
// (.mov → video/quicktime, .webm → video/webm, …), then video/mp4.
|
||||
// The old ext-derived image/<ext> (image/mp4) is what made the
|
||||
// admin player's blob unplayable (#908).
|
||||
const { EXTENSION_TO_MIME } = require('../services/uploadSettings');
|
||||
const ext = path.extname(photo.filename).slice(1).toLowerCase();
|
||||
// Own-property lookup (review): a client-controlled filename ending in
|
||||
// .constructor / .__proto__ / .toString would otherwise return an
|
||||
// inherited Object.prototype member, and the extMime.startsWith below
|
||||
// would throw — a permanent 500 for that photo instead of the fallback.
|
||||
const extMime = Object.prototype.hasOwnProperty.call(EXTENSION_TO_MIME, ext)
|
||||
? EXTENSION_TO_MIME[ext]
|
||||
: null;
|
||||
// Full-token validation, not just a prefix check: the stored value is
|
||||
// client-controlled, and header-invalid characters (video/mp4\r\nX: y)
|
||||
// would make setHeader throw — a permanent 500 for that photo. Bare
|
||||
// 'video/' is equally invalid; both fall back to the extension map.
|
||||
const storedVideoMime = photo.mime_type && /^video\/[\w.+-]+$/.test(photo.mime_type)
|
||||
? photo.mime_type
|
||||
: null;
|
||||
// Honor a stored image MIME for any header-safe RASTER type (#908
|
||||
// review): the S3 auto-importer accepts arbitrary image/* from
|
||||
// mime-types and stores it (avif/bmp/tiff/heic/apng/ico/jxl/…), and a
|
||||
// hand-listed allowlist kept missing formats. Allow image/<token> but
|
||||
// NEVER the scriptable svg / *+xml family (image/svg+xml executes
|
||||
// inline). The strict token + anchors also block header injection
|
||||
// (image/x\r\nY:). Migration 039's blanket image/jpeg backfill on
|
||||
// legacy rows is why the mapped extension still wins ahead of this.
|
||||
const storedImageMime =
|
||||
photo.mime_type &&
|
||||
/^image\/[\w.+-]+$/.test(photo.mime_type) &&
|
||||
!/^image\/svg|xml/i.test(photo.mime_type)
|
||||
? photo.mime_type
|
||||
: null;
|
||||
const isVideo = photo.media_type === 'video' ||
|
||||
Boolean(storedVideoMime) ||
|
||||
Boolean(extMime && extMime.startsWith('video/'));
|
||||
// Never interpolate the raw extension on the image side: it would
|
||||
// synthesize image/svg+xml (scriptable inline) or header-invalid values
|
||||
// from client-controlled chunked-upload filenames. Precedence is
|
||||
// mapped-extension (also corrects the 039 legacy-jpeg backfill on PNGs)
|
||||
// -> safe stored raster MIME (auto-imported avif/bmp/tiff) -> image/jpeg.
|
||||
// A stored type outside the allowlist degrades to image/jpeg; browsers
|
||||
// sniff image bytes in <img>/blob contexts, so a mislabel is harmless
|
||||
// where an injected type is not.
|
||||
const contentType = isVideo
|
||||
? storedVideoMime || (extMime && extMime.startsWith('video/') ? extMime : null) || 'video/mp4'
|
||||
: (extMime && extMime.startsWith('image/') ? extMime : null) || storedImageMime || 'image/jpeg';
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user