screentinker/server/middleware/upload.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

83 lines
3.8 KiB
JavaScript

const multer = require('multer');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const config = require('../config');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, config.contentDir);
},
filename: (req, file, cb) => {
// busboy decodes the Content-Disposition filename header as latin1 by
// default. Modern clients send raw UTF-8 bytes for non-ASCII filenames
// (e.g. browsers + curl on UTF-8 locales send "Begrussungsscreens.jpg"
// with c3 bc for u-umlaut). Reading those bytes as latin1 produces the
// string "A-tilde + quarter-mark" which JS then re-encodes as 4 UTF-8
// bytes on the way to the DB - classic double-encoding mojibake.
//
// The `defParamCharset: 'utf8'` option below only takes effect for
// RFC 5987 encoded `filename*=...` params, which most clients don't send.
// For the plain `filename="..."` case, re-decode here to recover the
// original UTF-8 byte sequence. Mutating originalname here propagates to
// every downstream consumer (route handlers reading req.file.originalname).
if (file.originalname) {
file.originalname = Buffer.from(file.originalname, 'latin1').toString('utf8');
}
// Deliberately NOT path.extname(file.originalname): the extension decides how a
// browser interprets the file, and these are served from the dashboard's own origin,
// so a caller must not choose it. multer picks the name before any bytes exist, so we
// land on a neutral `.part` and lib/upload-sniff.finalizeUpload() renames it to a
// content-derived extension once the bytes are on disk.
cb(null, `${uuidv4()}.part`);
}
});
const fileFilter = (req, file, cb) => {
const allowedTypes = [
'video/mp4', 'video/webm', 'video/avi', 'video/mkv', 'video/mov',
'video/x-msvideo', 'video/quicktime', 'video/x-matroska',
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'
];
if (allowedTypes.includes(file.mimetype) || file.mimetype.startsWith('video/') || file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Only video and image files are allowed'), false);
}
};
// `defParamCharset: 'utf8'` only takes effect for RFC 5987 encoded
// `filename*=utf-8''...` params. Most real clients (browsers, curl, programmatic
// HTTP) send the plain `filename="..."` form, where busboy still reads the bytes
// as latin1 regardless of this option. The actual UTF-8 recovery happens in the
// storage.filename callback above via Buffer.from(name,'latin1').toString('utf8').
// Kept here as defense-in-depth for the rare RFC 5987 case.
const upload = multer({
storage,
fileFilter,
limits: { fileSize: config.maxFileSize },
defParamCharset: 'utf8'
});
// #216: dedicated uploader for WebVTT subtitle files. The main `fileFilter` only allows
// video/image, so subtitles need their own instance. Written into the same content dir
// (served at /uploads/content/<file>) with a .vtt name; capped small — subtitles are tiny.
const subtitleStorage = multer.diskStorage({
destination: (req, file, cb) => cb(null, config.contentDir),
filename: (req, file, cb) => cb(null, `${uuidv4()}.vtt`),
});
const subtitleUpload = multer({
storage: subtitleStorage,
limits: { fileSize: 2 * 1024 * 1024 }, // 2MB — generous for a subtitle track
fileFilter: (req, file, cb) => {
// Browsers send .vtt as text/vtt; some send text/plain or application/octet-stream.
// Gate on the extension (authoritative here) plus those benign text mimetypes.
const okExt = /\.vtt$/i.test(file.originalname || '');
const okMime = ['text/vtt', 'text/plain', 'application/octet-stream'].includes(file.mimetype);
if (okExt && okMime) return cb(null, true);
cb(new Error('Only .vtt subtitle files are allowed'), false);
},
});
upload.subtitleUpload = subtitleUpload;
module.exports = upload;