mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -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>
83 lines
3.8 KiB
JavaScript
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;
|