← 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 };