fix(security): never serve a photo under its stored MIME, and stop trusting the chunked-upload type

chunked-upload/init stored the client-declared mimeType on the photo row and
the gallery, secure-image and protected-image routes echoed it as
Content-Type, so a JPEG/HTML polyglot declared as text/html rendered inline
on the app origin for every guest. The admin photo route already resolved
the type safely (#908 review); that logic now lives in
utils/photoContentType and every serving route uses it.

The chunked path derives the MIME from the filename extension and requires
that extension to be on the admin allow-list, matching what the multipart
path enforces through its multer fileFilter.
This commit is contained in:
Paul Nothaft
2026-09-03 10:48:56 +02:00
parent 3e46530072
commit 063977d97d
6 changed files with 139 additions and 78 deletions
+49
View File
@@ -0,0 +1,49 @@
/**
* Content-Type for a served photo row. Invariant: the header is ALWAYS
* image/* or video/*, never the stored value verbatim.
*
* photos.mime_type is client-influenced: the chunked-upload path used to
* store whatever MIME the browser (or a crafted request) declared, and the
* S3 auto-importer stores whatever mime-types derives. Echoing it inline
* under the app origin turned a JPEG/HTML polyglot with mime_type text/html
* into stored HTML injection for every gallery guest. The admin photo route
* (#908 + external review) already resolved this properly; this is that
* logic, shared so every serving route applies the same rule.
*
* - Images ignore the stored value unless it is a header-safe raster type:
* migration 039 backfilled image/jpeg onto every legacy row (PNGs
* included), so the extension is the more trustworthy signal, normalised
* via the shared map, jpeg fallback when unknown. The scriptable svg /
* *+xml family is never honoured.
* - Videos prefer a stored video/ type, then the extension map (.mov ->
* video/quicktime, .webm -> video/webm, ...), then video/mp4.
* - Full-token validation, not a prefix check: header-invalid characters
* (video/mp4\r\nX: y) would make setHeader throw -- a permanent 500 for
* that photo instead of a safe fallback.
*/
const path = require('path');
const { EXTENSION_TO_MIME } = require('../services/uploadSettings');
function resolvePhotoContentType(photo) {
const ext = path.extname(photo?.filename || '').slice(1).toLowerCase();
// Own-property lookup: a client-controlled filename ending in .constructor
// / .__proto__ would otherwise return an inherited Object.prototype member.
const extMime = Object.prototype.hasOwnProperty.call(EXTENSION_TO_MIME, ext)
? EXTENSION_TO_MIME[ext]
: null;
const stored = typeof photo?.mime_type === 'string' ? photo.mime_type : '';
const storedVideoMime = /^video\/[\w.+-]+$/.test(stored) ? stored : null;
const storedImageMime =
/^image\/[\w.+-]+$/.test(stored) && !/^image\/svg|xml/i.test(stored)
? stored
: null;
const isVideo = photo?.media_type === 'video' ||
Boolean(storedVideoMime) ||
Boolean(extMime && extMime.startsWith('video/'));
return isVideo
? storedVideoMime || (extMime && extMime.startsWith('video/') ? extMime : null) || 'video/mp4'
: (extMime && extMime.startsWith('image/') ? extMime : null) || storedImageMime || 'image/jpeg';
}
module.exports = { resolvePhotoContentType };