Files
Lauri Ojansivu e748559838 SVG of any size is sanitized whole, streaming: on upload, on validation and on every serve.
A streaming tokenizer (models/lib/svgSanitizer.js) replaces the regular
expressions that read an SVG whole: it removes script, foreignObject,
iframe/object/embed, SMIL set/animate, event handlers, javascript:/vbscript:
URIs after decoding character references, dangerous CSS, DOCTYPE/ENTITY XML
loops and processing instructions, wherever they are and however the file is
split into chunks, with bounded memory. UTF-16 is decoded and checked.

Upload sanitizing streams to a temporary file and renames it; validation
checks the whole SVG instead of rejecting any over its 1 MB scan; serving
pipes through the sanitizer with no 8 MB fallback to an unsanitized
download.

Thanks to xet7 !
2026-10-25 07:23:31 +02:00

98 lines
4.5 KiB
JavaScript

export const httpStreamOutput = function(readStream, name, http, downloadFlag, cacheControl, fileObj) {
const {
fileNameIsBrowserExecutable,
fileResponsePolicy,
} = require('./fileResponseSafety');
const storedType = fileObj?.versions?.original?.type || fileObj?.type;
// An SVG is streamed through the SVG sanitizer, whole, as an image - or as
// the download that was asked for (server/lib/svgResponse.js); the stream
// sanitizer below decides from the first bytes only, which is not enough.
const svgResponse = require('/server/lib/svgResponse');
if (svgResponse.isSvg(storedType, name)) {
require('./fileCacheHeaders').setPrivateFileCacheHeaders(http.response);
svgResponse.sendInlineSvg(http.response, readStream, {
download: downloadFlag === 'true',
fileName: name,
onSanitized: text => {
try {
const { classifyExploitKinds } = require('/imports/lib/fileNameDisplay');
require('/server/lib/filenameSanitizeLog').logContentSanitized({
fileObj: fileObj || { name }, source: 'fileView', kinds: classifyExploitKinds(text),
});
} catch (e) { /* best effort */ }
},
}).catch(() => { if (!http.response.headersSent) { http.response.statusCode = 500; http.response.end(); } });
return;
}
const policy = fileResponsePolicy(storedType, fileNameIsBrowserExecutable(name));
// Sanitize known exploits from EXISTING files on the fly, straight from the
// storage backend, without buffering whole files: the sanitizer sniffs the
// start of the stream and only rewrites documents that begin like dangerous
// markup (SVG/XML with <script>, an <!DOCTYPE/<!ENTITY XML loop, inline event
// handlers, ...); everything else streams through unchanged. See
// models/lib/serveFileSanitizer.js. When it DOES rewrite something, log it to
// Admin Panel / Problems with the uploader + location context (viewing path).
let outStream = readStream;
try {
const { createServeSanitizer } = require('./serveFileSanitizer');
const onSanitized = kinds => {
try {
require('/server/lib/filenameSanitizeLog').logContentSanitized({
fileObj: fileObj || { name }, source: 'fileView', kinds,
});
} catch (e) { /* best effort */ }
};
const sanitizer = createServeSanitizer(name, onSanitized);
readStream.on('error', err => sanitizer.destroy(err));
outStream = readStream.pipe(sanitizer);
} catch (e) {
console.error('serveFileSanitizer unavailable, streaming file as-is:', e);
outStream = readStream;
}
outStream.on('data', data => {
http.response.write(data);
});
outStream.on('end', () => {
// don't pass parameters to end() or it will be attached to the file's binary stream
http.response.end();
});
outStream.on('error', (err) => {
console.error(`Download stream error for file '${name}':`, err);
http.response.statusCode = 404;
http.response.end('not found');
});
// CacheBleed (GHSA-w3qg-pf27-g68r): whatever cacheControl the collection
// passes, a file behind an access check is never shared-cacheable.
require('./fileCacheHeaders').setPrivateFileCacheHeaders(http.response);
// This path is the original Meteor-Files /cdn/storage route. Apply the same
// fail-closed policy as WeKan's universal and legacy routes before any
// storage strategy writes bytes to the response.
http.response.setHeader('Content-Type', policy.contentType);
http.response.setHeader(
'Content-Disposition',
getContentDisposition(name, policy.forceDownload ? 'true' : downloadFlag),
);
for (const [header, value] of Object.entries(policy.headers)) {
http.response.setHeader(header, value);
}
};
/** will initiate download, if links are called with ?download="true" queryparam */
const getContentDisposition = (name, downloadFlag) => {
// Force attachment disposition for SVG files to prevent XSS attacks
const forceAttachment = downloadFlag === 'true';
const dispositionType = forceAttachment ? 'attachment;' : 'inline;';
const { sanitizeDownloadFileName } = require('/imports/lib/fileNameDisplay');
const encodedName = encodeURIComponent(sanitizeDownloadFileName(name));
const dispositionName = `filename="${encodedName}"; filename=*UTF-8"${encodedName}";`;
const dispositionEncoding = 'charset=utf-8';
return `${dispositionType} ${dispositionName} ${dispositionEncoding}`;
};