← back to NationalPaperHangers
lib/media-sniff.js
61 lines
// Magic-byte sniffer for the /api/booking-media upload endpoint.
//
// That endpoint is unauthenticated (the customer has no account), and the
// browser-supplied Content-Type is trivially spoofable. multer's fileFilter
// trusts that header — so on its own it lets an attacker store arbitrary
// 40 MB blobs under public/uploads/bookings/ simply by labelling them
// image/png. This module re-derives the stored extension from the actual
// file bytes, making the client's claimed mimetype non-authoritative.
// ISO base-media-format brands → extension. HEIC/AVIF/MP4/MOV all share the
// `ftyp` box at offset 4; the major brand (offset 8) disambiguates them.
const FTYP_BRANDS = {
avif: '.avif', avis: '.avif',
heic: '.heic', heix: '.heic', heim: '.heic', heis: '.heic',
hevc: '.heic', hevx: '.heic', mif1: '.heic', msf1: '.heic',
isom: '.mp4', iso2: '.mp4', mp41: '.mp4', mp42: '.mp4',
avc1: '.mp4', dash: '.mp4', m4v: '.mp4',
'qt ': '.mov',
};
const VIDEO_EXT = new Set(['.mp4', '.mov', '.webm']);
// Returns the canonical extension implied by the file's leading bytes, or
// null when the content is not a supported image/video. Never throws.
function sniffExt(buf) {
if (!Buffer.isBuffer(buf) || buf.length < 12) return null;
// JPEG — FF D8 FF
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return '.jpg';
// PNG — 89 50 4E 47 0D 0A 1A 0A
if (buf.slice(0, 8).equals(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
return '.png';
}
// WebP — "RIFF"....{4 byte size}...."WEBP"
if (buf.toString('latin1', 0, 4) === 'RIFF'
&& buf.toString('latin1', 8, 12) === 'WEBP') {
return '.webp';
}
// WebM / Matroska — EBML header 1A 45 DF A3
if (buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3) {
return '.webm';
}
// ISO-BMFF (HEIC / AVIF / MP4 / MOV) — `ftyp` box at offset 4.
if (buf.toString('latin1', 4, 8) === 'ftyp') {
return FTYP_BRANDS[buf.toString('latin1', 8, 12).toLowerCase()] || null;
}
return null;
}
function isVideoExt(ext) {
return VIDEO_EXT.has(ext);
}
module.exports = { sniffExt, isVideoExt };