[object Object]

← back to NationalPaperHangers

Validate booking-media uploads by magic bytes, not the spoofable mimetype

3e66967cca9792576960315274f3012c267d0a0f · 2026-05-18 17:30:41 -0700 · SteveStudio2

The unauthenticated /api/booking-media endpoint trusted the browser's
Content-Type to pick the stored extension, so a 40 MB blob mislabelled
image/png cleared multer's fileFilter and landed in public/uploads/.
New lib/media-sniff.js derives the extension from the file's leading
bytes (JPEG/PNG/WebP/WebM + ISO-BMFF brands for HEIC/AVIF/MP4/MOV);
content that isn't a real image/video is now rejected. e2e adds an
assertion proving a text blob with an image/png Content-Type fails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 3e66967cca9792576960315274f3012c267d0a0f
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Mon May 18 17:30:41 2026 -0700

    Validate booking-media uploads by magic bytes, not the spoofable mimetype
    
    The unauthenticated /api/booking-media endpoint trusted the browser's
    Content-Type to pick the stored extension, so a 40 MB blob mislabelled
    image/png cleared multer's fileFilter and landed in public/uploads/.
    New lib/media-sniff.js derives the extension from the file's leading
    bytes (JPEG/PNG/WebP/WebM + ISO-BMFF brands for HEIC/AVIF/MP4/MOV);
    content that isn't a real image/video is now rejected. e2e adds an
    assertion proving a text blob with an image/png Content-Type fails.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 lib/media-sniff.js        | 60 +++++++++++++++++++++++++++++++++++++++++++++++
 routes/api.js             | 20 ++++++++++++----
 tests/e2e-room-capture.js | 28 +++++++++++++++-------
 3 files changed, 95 insertions(+), 13 deletions(-)

diff --git a/lib/media-sniff.js b/lib/media-sniff.js
new file mode 100644
index 0000000..3cdb7a0
--- /dev/null
+++ b/lib/media-sniff.js
@@ -0,0 +1,60 @@
+// 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 };
diff --git a/routes/api.js b/routes/api.js
index 7bbb14d..ce80bc5 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -9,6 +9,7 @@ const slots = require('../lib/slots');
 const email = require('../lib/email');
 const bookingToken = require('../lib/booking-token');
 const roomCapturesLib = require('../lib/room-captures');
+const mediaSniff = require('../lib/media-sniff');
 const stripe = require('../lib/stripe');
 const { requireInstaller } = require('../lib/auth');
 const router = express.Router();
@@ -19,8 +20,10 @@ const router = express.Router();
 
 // ---- Customer booking-media uploads (room photos/video, wallpaper images) ---
 // Used by the /book room-capture flow. Unauthenticated by necessity — the
-// customer has no account — so it's IP-rate-limited and content-addressed
-// (sha256) storage means the client can never influence the path.
+// customer has no account — so it's IP-rate-limited, content-addressed
+// (sha256) so the client can never influence the path, and the stored
+// extension is derived from the file's magic bytes — never the spoofable
+// browser-supplied Content-Type.
 const BOOKING_UPLOAD_ROOT = path.join(__dirname, '..', 'public', 'uploads', 'bookings');
 const MEDIA_EXT = {
   'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp',
@@ -53,8 +56,17 @@ router.post('/booking-media', mediaLimiter, (req, res) => {
       return res.status(400).json({ ok: false, error: msg });
     }
     if (!req.file) return res.status(400).json({ ok: false, error: 'No file received.' });
+    // Authoritative gate: the extension comes from the actual file bytes, not
+    // the client's Content-Type. A text/binary blob mislabelled image/png
+    // clears multer's fileFilter but fails here.
+    const ext = mediaSniff.sniffExt(req.file.buffer);
+    if (!ext) {
+      return res.status(400).json({
+        ok: false,
+        error: 'File contents are not a supported image or video.'
+      });
+    }
     try {
-      const ext = MEDIA_EXT[req.file.mimetype];
       const hash = crypto.createHash('sha256').update(req.file.buffer).digest('hex').slice(0, 24);
       fs.mkdirSync(BOOKING_UPLOAD_ROOT, { recursive: true });
       const fpath = path.join(BOOKING_UPLOAD_ROOT, hash + ext);
@@ -62,7 +74,7 @@ router.post('/booking-media', mediaLimiter, (req, res) => {
       res.json({
         ok: true,
         url: `/uploads/bookings/${hash}${ext}`,
-        kind: req.file.mimetype.startsWith('video/') ? 'video' : 'image'
+        kind: mediaSniff.isVideoExt(ext) ? 'video' : 'image'
       });
     } catch (e) {
       res.status(500).json({ ok: false, error: 'Could not store file.' });
diff --git a/tests/e2e-room-capture.js b/tests/e2e-room-capture.js
index 72dcb1f..33551ff 100644
--- a/tests/e2e-room-capture.js
+++ b/tests/e2e-room-capture.js
@@ -127,17 +127,27 @@ function db() { if (!_db) _db = require('../lib/db'); return _db; }
 
     // ── Phase 2 — upload endpoint type-guard ────────────────────────
     console.log('\n[phase 2] upload endpoint type-guard');
-    const rejected = await page.evaluate(async () => {
-      const meta = document.querySelector('meta[name="csrf-token"]');
-      const fd = new FormData();
-      fd.append('file', new Blob(['not an image'], { type: 'text/plain' }), 'x.txt');
-      const r = await fetch('/api/booking-media', {
-        method: 'POST', headers: { 'X-CSRF-Token': meta.content }, body: fd
-      });
-      return r.json();
-    });
+    const postFile = async (bytes, type, name) => page.evaluate(
+      async ({ bytes, type, name }) => {
+        const meta = document.querySelector('meta[name="csrf-token"]');
+        const fd = new FormData();
+        fd.append('file', new Blob([new Uint8Array(bytes)], { type }), name);
+        const r = await fetch('/api/booking-media', {
+          method: 'POST', headers: { 'X-CSRF-Token': meta.content }, body: fd
+        });
+        return r.json();
+      }, { bytes, type, name });
+
+    const textBytes = Array.from('not an image').map(c => c.charCodeAt(0));
+    const rejected = await postFile(textBytes, 'text/plain', 'x.txt');
     ok(rejected && rejected.ok === false, 'non-image/video upload rejected');
 
+    // Content-sniff gate: text bytes mislabelled image/png clear multer's
+    // fileFilter but must fail the magic-byte check.
+    const spoofed = await postFile(textBytes, 'image/png', 'fake.png');
+    ok(spoofed && spoofed.ok === false,
+       'image/png mimetype with non-image bytes rejected by content sniff');
+
     // ── Phase 3 — room_captures DB round-trip + sanitiser ───────────
     console.log('\n[phase 3] room_captures DB round-trip + sanitiser');
     const { sanitize } = require('../lib/room-captures');

← d69c431 Render true half-drop in the on-wall wallpaper preview  ·  back to NationalPaperHangers  ·  Stop clobbering the customer's hand-entered square footage 515caa4 →