wekan/server/routes/universalFileServer.js
Lauri Ojansivu e9a727301d Fix SECURITY ISSUE 1: File Attachments enables stored XSS (High).
Thanks to Siam Thanat Hack (STH) !
2025-11-02 08:36:29 +02:00

507 lines
17 KiB
JavaScript

/**
* Universal File Server
* Ensures all attachments and avatars are always visible regardless of ROOT_URL and PORT settings
* Handles both new Meteor-Files and legacy CollectionFS file serving
*/
import { Meteor } from 'meteor/meteor';
import { WebApp } from 'meteor/webapp';
import { ReactiveCache } from '/imports/reactiveCache';
import Attachments, { fileStoreStrategyFactory as attachmentStoreFactory } from '/models/attachments';
import Avatars, { fileStoreStrategyFactory as avatarStoreFactory } from '/models/avatars';
import { getAttachmentWithBackwardCompatibility, getOldAttachmentStream } from '/models/lib/attachmentBackwardCompatibility';
import fs from 'fs';
import path from 'path';
if (Meteor.isServer) {
console.log('Universal file server initializing...');
/**
* Helper function to set appropriate headers for file serving
*/
function setFileHeaders(res, fileObj, isAttachment = false) {
// Decide safe serving strategy
const nameLower = (fileObj.name || '').toLowerCase();
const typeLower = (fileObj.type || '').toLowerCase();
const isPdfByExt = nameLower.endsWith('.pdf');
// Define dangerous types that must never be served inline
const dangerousTypes = new Set([
'text/html',
'application/xhtml+xml',
'image/svg+xml',
'text/xml',
'application/xml',
'application/javascript',
'text/javascript'
]);
// Define safe types that can be served inline for viewing
const safeInlineTypes = new Set([
'application/pdf',
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
'image/avif',
'image/bmp',
'video/mp4',
'video/webm',
'video/ogg',
'audio/mpeg',
'audio/mp3',
'audio/ogg',
'audio/wav',
'audio/webm',
'text/plain',
'application/json'
]);
const isSvg = nameLower.endsWith('.svg') || typeLower === 'image/svg+xml';
const isDangerous = dangerousTypes.has(typeLower) || isSvg;
// Consider PDF safe inline by extension if type is missing/mis-set
const isSafeInline = safeInlineTypes.has(typeLower) || (isAttachment && isPdfByExt);
// Always send strong caching and integrity headers
res.setHeader('Cache-Control', 'public, max-age=31536000'); // Cache for 1 year
res.setHeader('ETag', `"${fileObj._id}"`);
res.setHeader('X-Content-Type-Options', 'nosniff');
// Set content length when available
if (fileObj.size) {
res.setHeader('Content-Length', fileObj.size);
}
if (isAttachment) {
// Attachments: dangerous types forced to download, safe types can be inline
if (isDangerous) {
// SECURITY: Force download for dangerous types to prevent XSS
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${fileObj.name}"`);
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox;");
res.setHeader('X-Frame-Options', 'DENY');
} else if (isSafeInline) {
// Safe types: serve inline with proper type and restrictive CSP
// If the file is a PDF by extension but type is wrong/missing, correct it
const finalType = (isPdfByExt && typeLower !== 'application/pdf') ? 'application/pdf' : (typeLower || 'application/octet-stream');
res.setHeader('Content-Type', finalType);
res.setHeader('Content-Disposition', `inline; filename="${fileObj.name}"`);
// Restrictive CSP for safe types - allow media/img/object for viewer embeds, no scripts
res.setHeader('Content-Security-Policy', "default-src 'none'; object-src 'self'; media-src 'self'; img-src 'self'; style-src 'unsafe-inline';");
} else {
// Unknown types: force download as fallback
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${fileObj.name}"`);
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox;");
}
} else {
// Avatars: allow inline images, but never serve SVG inline
if (isSvg || isDangerous) {
// Serve potentially dangerous avatar types as downloads instead
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${fileObj.name}"`);
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox;");
res.setHeader('X-Frame-Options', 'DENY');
} else {
// For typical image avatars, use provided type if present, otherwise fall back to a safe generic image type
res.setHeader('Content-Type', typeLower || 'image/jpeg');
res.setHeader('Content-Disposition', `inline; filename="${fileObj.name}"`);
}
}
}
/**
* Helper function to handle conditional requests
*/
function handleConditionalRequest(req, res, fileObj) {
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch && ifNoneMatch === `"${fileObj._id}"`) {
res.writeHead(304);
res.end();
return true;
}
return false;
}
/**
* Extract first path segment (file id) from request URL.
* Works whether req.url is the full path or already trimmed by the mount path.
*/
function extractFirstIdFromUrl(req, mountPrefix) {
// Strip query string
let urlPath = (req.url || '').split('?')[0];
// If url still contains the mount prefix, remove it
if (mountPrefix && urlPath.startsWith(mountPrefix)) {
urlPath = urlPath.slice(mountPrefix.length);
}
// Ensure leading slash removed for splitting
if (urlPath.startsWith('/')) {
urlPath = urlPath.slice(1);
}
const parts = urlPath.split('/').filter(Boolean);
return parts[0] || null;
}
/**
* Check if the request explicitly asks to download the file
* Recognizes ?download=true or ?download=1 (case-insensitive for key)
*/
function isDownloadRequested(req) {
const q = (req.url || '').split('?')[1] || '';
if (!q) return false;
const pairs = q.split('&');
for (const p of pairs) {
const [rawK, rawV] = p.split('=');
const k = decodeURIComponent((rawK || '').trim()).toLowerCase();
const v = decodeURIComponent((rawV || '').trim());
if (k === 'download' && (v === '' || v === 'true' || v === '1')) {
return true;
}
}
return false;
}
/**
* Helper function to stream file with error handling
*/
function streamFile(res, readStream, fileObj) {
readStream.on('error', (error) => {
console.error('File stream error:', error);
if (!res.headersSent) {
res.writeHead(500);
res.end('Error reading file');
}
});
readStream.on('end', () => {
if (!res.headersSent) {
res.writeHead(200);
}
});
readStream.pipe(res);
}
// ============================================================================
// NEW METEOR-FILES ROUTES (URL-agnostic)
// ============================================================================
/**
* Serve attachments from new Meteor-Files structure
* Route: /cdn/storage/attachments/{fileId} or /cdn/storage/attachments/{fileId}/original/{filename}
*/
WebApp.connectHandlers.use('/cdn/storage/attachments', (req, res, next) => {
if (req.method !== 'GET') {
return next();
}
try {
const fileId = extractFirstIdFromUrl(req, '/cdn/storage/attachments');
if (!fileId) {
res.writeHead(400);
res.end('Invalid attachment file ID');
return;
}
// Get attachment from database
const attachment = ReactiveCache.getAttachment(fileId);
if (!attachment) {
res.writeHead(404);
res.end('Attachment not found');
return;
}
// Check permissions
const board = ReactiveCache.getBoard(attachment.meta.boardId);
if (!board) {
res.writeHead(404);
res.end('Board not found');
return;
}
// TODO: Implement proper authentication via cookies/headers
// Meteor.userId() returns undefined in WebApp.connectHandlers middleware
// For now, allow access - ostrio:files protected() method provides fallback auth
// const userId = null; // Need to extract from req.headers.cookie
// if (!board.isPublic() && (!userId || !board.hasMember(userId))) {
// res.writeHead(403);
// res.end('Access denied');
// return;
// }
// Handle conditional requests
if (handleConditionalRequest(req, res, attachment)) {
return;
}
// Get file strategy and stream
const strategy = attachmentStoreFactory.getFileStrategy(attachment, 'original');
const readStream = strategy.getReadStream();
if (!readStream) {
res.writeHead(404);
res.end('Attachment file not found in storage');
return;
}
// Set headers and stream file
if (isDownloadRequested(req)) {
// Force download if requested via query param
res.setHeader('Cache-Control', 'public, max-age=31536000');
res.setHeader('ETag', `"${attachment._id}"`);
if (attachment.size) res.setHeader('Content-Length', attachment.size);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${attachment.name}"`);
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox;");
} else {
setFileHeaders(res, attachment, true);
}
streamFile(res, readStream, attachment);
} catch (error) {
console.error('Attachment server error:', error);
if (!res.headersSent) {
res.writeHead(500);
res.end('Internal server error');
}
}
});
/**
* Serve avatars from new Meteor-Files structure
* Route: /cdn/storage/avatars/{fileId} or /cdn/storage/avatars/{fileId}/original/{filename}
*/
WebApp.connectHandlers.use('/cdn/storage/avatars', (req, res, next) => {
if (req.method !== 'GET') {
return next();
}
try {
const fileId = extractFirstIdFromUrl(req, '/cdn/storage/avatars');
if (!fileId) {
res.writeHead(400);
res.end('Invalid avatar file ID');
return;
}
// Get avatar from database
const avatar = ReactiveCache.getAvatar(fileId);
if (!avatar) {
res.writeHead(404);
res.end('Avatar not found');
return;
}
// TODO: Implement proper authentication for avatars
// Meteor.userId() returns undefined in WebApp.connectHandlers middleware
// For now, allow avatar viewing - they're typically public anyway
// Handle conditional requests
if (handleConditionalRequest(req, res, avatar)) {
return;
}
// Get file strategy and stream
const strategy = avatarStoreFactory.getFileStrategy(avatar, 'original');
const readStream = strategy.getReadStream();
if (!readStream) {
res.writeHead(404);
res.end('Avatar file not found in storage');
return;
}
// Set headers and stream file
setFileHeaders(res, avatar, false);
streamFile(res, readStream, avatar);
} catch (error) {
console.error('Avatar server error:', error);
if (!res.headersSent) {
res.writeHead(500);
res.end('Internal server error');
}
}
});
// ============================================================================
// LEGACY COLLECTIONFS ROUTES (Backward compatibility)
// ============================================================================
/**
* Serve legacy attachments from CollectionFS structure
* Route: /cfs/files/attachments/{attachmentId}
*/
WebApp.connectHandlers.use('/cfs/files/attachments', (req, res, next) => {
if (req.method !== 'GET') {
return next();
}
try {
const attachmentId = extractFirstIdFromUrl(req, '/cfs/files/attachments');
if (!attachmentId) {
res.writeHead(400);
res.end('Invalid attachment ID');
return;
}
// Try to get attachment with backward compatibility
const attachment = getAttachmentWithBackwardCompatibility(attachmentId);
if (!attachment) {
res.writeHead(404);
res.end('Attachment not found');
return;
}
// Check permissions
const board = ReactiveCache.getBoard(attachment.meta.boardId);
if (!board) {
res.writeHead(404);
res.end('Board not found');
return;
}
// TODO: Implement proper authentication via cookies/headers
// Meteor.userId() returns undefined in WebApp.connectHandlers middleware
// For now, allow access for compatibility
// Handle conditional requests
if (handleConditionalRequest(req, res, attachment)) {
return;
}
// For legacy attachments, try to get GridFS stream
const fileStream = getOldAttachmentStream(attachmentId);
if (fileStream) {
if (isDownloadRequested(req)) {
// Force download if requested
res.setHeader('Cache-Control', 'public, max-age=31536000');
res.setHeader('ETag', `"${attachment._id}"`);
if (attachment.size) res.setHeader('Content-Length', attachment.size);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${attachment.name}"`);
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox;");
} else {
setFileHeaders(res, attachment, true);
}
streamFile(res, fileStream, attachment);
} else {
res.writeHead(404);
res.end('Legacy attachment file not found in GridFS');
}
} catch (error) {
console.error('Legacy attachment server error:', error);
if (!res.headersSent) {
res.writeHead(500);
res.end('Internal server error');
}
}
});
/**
* Serve legacy avatars from CollectionFS structure
* Route: /cfs/files/avatars/{avatarId}
*/
WebApp.connectHandlers.use('/cfs/files/avatars', (req, res, next) => {
if (req.method !== 'GET') {
return next();
}
try {
const avatarId = extractFirstIdFromUrl(req, '/cfs/files/avatars');
if (!avatarId) {
res.writeHead(400);
res.end('Invalid avatar ID');
return;
}
// Try to get avatar from database (new structure first)
let avatar = ReactiveCache.getAvatar(avatarId);
// If not found in new structure, try to handle legacy format
if (!avatar) {
// For legacy avatars, we might need to handle different ID formats
// This is a fallback for old CollectionFS avatars
res.writeHead(404);
res.end('Avatar not found');
return;
}
// TODO: Implement proper authentication for legacy avatars
// Meteor.userId() returns undefined in WebApp.connectHandlers middleware
// For now, allow avatar viewing for compatibility
// Handle conditional requests
if (handleConditionalRequest(req, res, avatar)) {
return;
}
// Get file strategy and stream
const strategy = avatarStoreFactory.getFileStrategy(avatar, 'original');
const readStream = strategy.getReadStream();
if (!readStream) {
res.writeHead(404);
res.end('Avatar file not found in storage');
return;
}
// Set headers and stream file
setFileHeaders(res, avatar, false);
streamFile(res, readStream, avatar);
} catch (error) {
console.error('Legacy avatar server error:', error);
if (!res.headersSent) {
res.writeHead(500);
res.end('Internal server error');
}
}
});
// ============================================================================
// ALTERNATIVE ROUTES (For different URL patterns)
// ============================================================================
/**
* Alternative attachment route for different URL patterns
* Route: /attachments/{fileId}
*/
WebApp.connectHandlers.use('/attachments', (req, res, next) => {
if (req.method !== 'GET') {
return next();
}
// Redirect to standard route
const fileId = extractFirstIdFromUrl(req, '/attachments');
const newUrl = `/cdn/storage/attachments/${fileId}`;
res.writeHead(301, { 'Location': newUrl });
res.end();
});
/**
* Alternative avatar route for different URL patterns
* Route: /avatars/{fileId}
*/
WebApp.connectHandlers.use('/avatars', (req, res, next) => {
if (req.method !== 'GET') {
return next();
}
// Redirect to standard route
const fileId = extractFirstIdFromUrl(req, '/avatars');
const newUrl = `/cdn/storage/avatars/${fileId}`;
res.writeHead(301, { 'Location': newUrl });
res.end();
});
console.log('Universal file server initialized successfully');
}