screentinker/server/lib/upload-sniff.js
ScreenTinker 6b082cfad0 fix(uploads): derive stored type from file content, and never serve uploads as documents
Uploaded files are served from the SAME ORIGIN as the dashboard, so how a browser
interprets them is a security boundary. Two things decided that interpretation, and
both were caller-controlled: the stored extension came from
`path.extname(originalname)`, and the only type check read `file.mimetype` — a request
header. A caller could therefore choose to have their bytes served as an active
document from the app origin.

Two independent invariants now hold the boundary:

1. INGEST — lib/upload-sniff.js sniffs magic bytes after multer writes a neutral
   `.part` file (diskStorage names the file before any bytes exist, so the sniff cannot
   happen there), maps the result through a hardcoded mime->extension allowlist, renames
   accordingly, and stores the sniffed mime. Unsupported bytes are refused with a 400.

2. SERVING — upload responses carry `Content-Security-Policy: sandbox`, so if a response
   is ever treated as a document it lands in an opaque origin with scripts disabled.
   Anything outside the inline-safe extension set is additionally forced to download.
   This holds regardless of how a file reached disk, so a future gap in (1) is contained
   rather than exploitable.

Applied at every instance of the pattern, not just the first: lib/content-ingest.js,
the /replace route, the four content-serving paths across server.js and routes/content.js
(the latter pair currently shadowed by mount order, which is not a guarantee), and the
ZIP-import path in routes/status.js, which took its extension from the archive entry.

SVG stays accepted and stays inline: white-label logos are SVG, and octet-stream +
nosniff makes <img> fail. Scripts in an SVG never run in an image context, and the
sandbox CSP covers the one case where they would — a direct navigation. SVG is also no
longer handed to sharp, which removes the librsvg path where the open libvips CVEs live.

Existing rows are untouched — no migration. The four upload fixtures in agency.test.js
uploaded `Buffer.from('x')` declared as image/png; that is the exact "declared type is a
lie" case this closes, so the fixtures now use real PNG bytes. No assertion changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 11:47:09 -05:00

141 lines
6 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// Uploaded files are served from the SAME ORIGIN as the dashboard, so the extension they
// land on disk with decides how a browser will interpret them. That decision must come
// from the file's CONTENT, never from the caller.
//
// Previously multer named the file `<uuid><path.extname(originalname)>` — a caller-chosen
// extension — while the only type check read `file.mimetype`, a caller-supplied header.
// A caller could therefore choose to have their bytes served as text/html or
// application/javascript from the app origin.
//
// multer's diskStorage picks the filename BEFORE any bytes are written, so the sniff
// cannot happen there. Instead multer writes a neutral `<uuid>.part` and finalizeUpload()
// below sniffs the written file, renames it to a content-derived extension, and reports
// the content-derived mime. Both ingest paths (lib/content-ingest.js and the /replace
// route) call this one function so they cannot drift.
const fs = require('fs');
const path = require('path');
// The ONLY extensions an upload may land on. Keyed by the mime the sniffer reports.
// Anything not here is refused — an allowlist, so a new container format is a deliberate
// addition rather than something that slips through.
const MIME_TO_EXT = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/bmp': '.bmp',
'image/webp': '.webp',
'image/avif': '.avif',
'image/heic': '.heic',
'image/svg+xml': '.svg',
'video/mp4': '.mp4',
'video/webm': '.webm',
'video/ogg': '.ogv',
'video/x-msvideo': '.avi',
'video/quicktime': '.mov',
};
// Extensions served INLINE with their real media type. Anything outside this set is
// forced to download as opaque bytes.
//
// SVG is included DELIBERATELY. It has to be: white-label branding stores logos as SVG,
// and serving them as application/octet-stream with nosniff makes <img> fail to render.
// It is safe here because scripts inside an SVG never execute in an <img>/image context,
// and the `Content-Security-Policy: sandbox` the serving layer adds neutralises the only
// case where they would — a direct navigation, which is a document. So the logo renders
// and the script does not run. Dropping SVG from this set silently breaks white-label.
const INLINE_SAFE_EXTS = new Set([
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.avif', '.heic', '.jfif', '.svg',
'.mp4', '.webm', '.ogv', '.avi', '.mov', '.mkv', '.vtt',
]);
const SNIFF_BYTES = 4096; // enough for every magic below, plus a leading XML prolog
// Return a mime string from magic bytes, or null when nothing matches.
// Mirrors routes/media.js sniffMedia (same magic set) and adds the containers this
// product actually accepts on upload.
function sniffMime(buf) {
if (!buf || buf.length < 4) return null;
const b = buf;
const a4 = b.toString('latin1', 0, 4);
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg';
if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png';
if (a4 === 'GIF8') return 'image/gif';
if (b[0] === 0x42 && b[1] === 0x4d) return 'image/bmp';
if (a4 === 'RIFF' && b.length >= 12) {
const kind = b.toString('latin1', 8, 12);
if (kind === 'WEBP') return 'image/webp';
if (kind === 'AVI ') return 'video/x-msvideo';
}
// ISO-BMFF: '....ftyp<brand>' — mp4 / mov / avif / heic all share this container.
if (b.length >= 12 && b.toString('latin1', 4, 8) === 'ftyp') {
const brand = b.toString('latin1', 8, 12);
if (brand === 'avif' || brand === 'avis') return 'image/avif';
if (brand === 'heic' || brand === 'heix' || brand === 'mif1') return 'image/heic';
if (brand === 'qt ') return 'video/quicktime';
return 'video/mp4';
}
if (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) return 'video/webm'; // EBML (webm/mkv)
if (a4 === 'OggS') return 'video/ogg';
// SVG has no magic number — it is XML. Accept only when the document really opens with
// an XML prolog or an <svg> root, so arbitrary text/HTML cannot masquerade as one.
const head = b.toString('utf8', 0, Math.min(b.length, 1024)).replace(/^/, '').trimStart();
if (/^<\?xml[\s>]/i.test(head) || /^<!DOCTYPE\s+svg/i.test(head)) {
if (/<svg[\s>]/i.test(b.toString('utf8', 0, Math.min(b.length, SNIFF_BYTES)))) return 'image/svg+xml';
return null;
}
if (/^<svg[\s>]/i.test(head)) return 'image/svg+xml';
return null;
}
function readHead(filePath) {
const fd = fs.openSync(filePath, 'r');
try {
const buf = Buffer.alloc(SNIFF_BYTES);
const n = fs.readSync(fd, buf, 0, SNIFF_BYTES, 0);
return buf.subarray(0, n);
} finally {
fs.closeSync(fd);
}
}
class UnsupportedUploadError extends Error {
constructor(msg) { super(msg); this.name = 'UnsupportedUploadError'; this.status = 400; }
}
// Sniff a multer-written temp file, rename it to a content-derived name, and report what
// it actually is. Throws UnsupportedUploadError (and removes the file) when the bytes are
// not a supported media type — the caller maps that to a 400.
//
// Returns { filepath, mime, ext } where filepath is the basename now on disk.
function finalizeUpload(file) {
const mime = (() => {
try { return sniffMime(readHead(file.path)); } catch { return null; }
})();
const ext = mime ? MIME_TO_EXT[mime] : null;
if (!mime || !ext) {
try { fs.unlinkSync(file.path); } catch { /* best effort */ }
throw new UnsupportedUploadError('Unsupported file type — only image and video files are accepted');
}
// `<uuid>.part` -> `<uuid><ext>`; keep the uuid multer already generated.
const base = path.basename(file.path).replace(/\.part$/i, '');
const finalName = base + ext;
const finalPath = path.join(path.dirname(file.path), finalName);
fs.renameSync(file.path, finalPath);
// Keep the multer object consistent for any downstream reader.
file.path = finalPath;
file.filename = finalName;
file.mimetype = mime;
return { filepath: finalName, mime, ext };
}
module.exports = {
finalizeUpload, sniffMime, UnsupportedUploadError,
MIME_TO_EXT, INLINE_SAFE_EXTS, SNIFF_BYTES,
};