mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
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>
141 lines
6 KiB
JavaScript
141 lines
6 KiB
JavaScript
'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,
|
||
};
|