Fix SECURITY ISSUE 1: File Attachments enables stored XSS (High).

Thanks to Siam Thanat Hack (STH) !
This commit is contained in:
Lauri Ojansivu 2025-11-02 08:36:29 +02:00
parent d64d2f9c42
commit e9a727301d
6 changed files with 361 additions and 83 deletions

View file

@ -172,6 +172,16 @@ Meteor.startup(() => {
- https://github.com/wekan/wekan/blob/main/client/components/cards/attachments.js#L303-L312 - https://github.com/wekan/wekan/blob/main/client/components/cards/attachments.js#L303-L312
- https://wekan.github.io/hall-of-fame/filebleed/ - https://wekan.github.io/hall-of-fame/filebleed/
### Attachments: Forced download to prevent stored XSS
- To prevent browser-side execution of uploaded content under the app origin, all attachment downloads are served with safe headers:
- `Content-Type: application/octet-stream`
- `Content-Disposition: attachment`
- `X-Content-Type-Options: nosniff`
- A restrictive `Content-Security-Policy` with `sandbox`
- This means attachments are downloaded instead of rendered inline by default. This mitigates HTML/JS/SVG based stored XSS vectors.
- Avatars and inline images remain supported but SVG uploads are blocked and never rendered inline.
## Brute force login protection ## Brute force login protection
- https://github.com/wekan/wekan/commit/23e5e1e3bd081699ce39ce5887db7e612616014d - https://github.com/wekan/wekan/commit/23e5e1e3bd081699ce39ce5887db7e612616014d

View file

@ -328,11 +328,35 @@ Attachments.getAttachmentsWithBackwardCompatibility = getAttachmentsWithBackward
// Override the link method to use universal URLs // Override the link method to use universal URLs
if (Meteor.isClient) { if (Meteor.isClient) {
// Add custom link method to attachment documents // Override the original FilesCollection link method to use universal URLs
// This must override the ostrio:files method to avoid "Match error: Expected plain object"
const originalLink = Attachments.link;
Attachments.link = function(versionName) {
// Accept both direct calls and collection.helpers style calls
const fileRef = this._id ? this : (versionName && versionName._id ? versionName : this);
const version = (typeof versionName === 'string') ? versionName : 'original';
if (fileRef && fileRef._id) {
const url = generateUniversalAttachmentUrl(fileRef._id, version);
if (process.env.DEBUG === 'true') {
console.log('Attachment link generated:', url, 'for ID:', fileRef._id);
}
return url;
}
// Fallback to original if somehow we don't have an ID
return originalLink ? originalLink.call(this, versionName) : '';
};
// Also add as collection helper for document instances
Attachments.collection.helpers({ Attachments.collection.helpers({
link(version = 'original') { link(version) {
// Use universal URL generator for consistent, URL-agnostic URLs // Handle both no-argument and string argument cases
return generateUniversalAttachmentUrl(this._id, version); const ver = (typeof version === 'string') ? version : 'original';
const url = generateUniversalAttachmentUrl(this._id, ver);
if (process.env.DEBUG === 'true') {
console.log('Attachment link (helper) generated:', url, 'for ID:', this._id);
}
return url;
} }
}); });
} }

View file

@ -44,7 +44,7 @@ if (Meteor.isServer) {
storagePath = path.join(process.env.WRITABLE_PATH || process.cwd(), 'avatars'); storagePath = path.join(process.env.WRITABLE_PATH || process.cwd(), 'avatars');
} }
const fileStoreStrategyFactory = new FileStoreStrategyFactory(FileStoreStrategyFilesystem, storagePath, FileStoreStrategyGridFs, avatarsBucket); export const fileStoreStrategyFactory = new FileStoreStrategyFactory(FileStoreStrategyFilesystem, storagePath, FileStoreStrategyGridFs, avatarsBucket);
Avatars = new FilesCollection({ Avatars = new FilesCollection({
debug: false, // Change to `true` for debugging debug: false, // Change to `true` for debugging

View file

@ -12,27 +12,112 @@ if (Meteor.isServer) {
export async function isFileValid(fileObj, mimeTypesAllowed, sizeAllowed, externalCommandLine) { export async function isFileValid(fileObj, mimeTypesAllowed, sizeAllowed, externalCommandLine) {
let isValid = true; let isValid = true;
// Always validate uploads. The previous migration flag disabled validation and enabled XSS.
try {
// Helper: read up to a limit from a file as UTF-8 text
const readTextHead = (filePath, limit = parseInt(process.env.UPLOAD_DANGEROUS_MIME_SCAN_LIMIT || '1048576')) => new Promise((resolve, reject) => {
try {
const stream = fs.createReadStream(filePath, { encoding: 'utf8', highWaterMark: 64 * 1024 });
let data = '';
let exceeded = false;
stream.on('data', chunk => {
data += chunk;
if (data.length >= limit) {
exceeded = true;
stream.destroy();
}
});
stream.on('error', err => reject(err));
stream.on('close', () => {
if (exceeded) {
// If file exceeds scan limit, treat as unsafe
resolve({ text: data.slice(0, limit), complete: false });
} else {
resolve({ text: data, complete: true });
}
});
} catch (e) {
reject(e);
}
});
/* // Helper: quick content safety checks for HTML/SVG/XML
if (Meteor.settings.public.ostrioFilesMigrationInProgress !== "true") { const containsJsOrXmlBombs = (text) => {
if (mimeTypesAllowed.length) { if (!text) return false;
const mimeTypeResult = await FileType.fromFile(fileObj.path); const t = text.toLowerCase();
// JavaScript execution vectors
const patterns = [
/<script\b/i,
/on[a-z\-]{1,20}\s*=\s*['"]/i, // event handlers
/javascript\s*:/i,
/<iframe\b/i,
/<object\b/i,
/<embed\b/i,
/<meta\s+http-equiv\s*=\s*['"]?refresh/i,
/<foreignobject\b/i,
/style\s*=\s*['"][^'"]*url\(\s*javascript\s*:/i,
];
if (patterns.some((re) => re.test(text))) return true;
// XML entity expansion / DTD based bombs
if (t.includes('<!doctype') || t.includes('<!entity') || t.includes('<?xml-stylesheet')) return true;
return false;
};
const mimeType = (mimeTypeResult ? mimeTypeResult.mime : fileObj.type); const checkDangerousMimeAllowance = async (mime, filePath, fileSize) => {
const baseMimeType = mimeType.split('/', 1)[0]; // Allow only if content is scanned and clean
const { text, complete } = await readTextHead(filePath);
if (!complete) {
// Too large to confidently scan
return false;
}
// For JS MIME, only allow empty files
if (mime === 'application/javascript' || mime === 'text/javascript') {
return (text.trim().length === 0);
}
return !containsJsOrXmlBombs(text);
};
isValid = mimeTypesAllowed.includes(mimeType) || mimeTypesAllowed.includes(baseMimeType + '/*') || mimeTypesAllowed.includes('*'); // Detect MIME type from file content when possible
const mimeTypeResult = await FileType.fromFile(fileObj.path).catch(() => undefined);
const detectedMime = mimeTypeResult?.mime || (fileObj.type || '').toLowerCase();
const baseMimeType = detectedMime.split('/', 1)[0] || '';
if (!isValid) { // Hard deny-list for obviously dangerous types which can be allowed if content is safe
console.log("Validation of uploaded file failed: file " + fileObj.path + " - mimetype " + mimeType); const dangerousMimes = new Set([
'text/html',
'application/xhtml+xml',
'image/svg+xml',
'text/xml',
'application/xml',
'application/javascript',
'text/javascript'
]);
if (dangerousMimes.has(detectedMime)) {
const allowedByContentScan = await checkDangerousMimeAllowance(detectedMime, fileObj.path, fileObj.size || 0);
if (!allowedByContentScan) {
console.log("Validation of uploaded file failed (dangerous MIME content): file " + fileObj.path + " - mimetype " + detectedMime);
return false;
} }
} }
// Optional allow-list: if provided, enforce it using exact or base type match
if (Array.isArray(mimeTypesAllowed) && mimeTypesAllowed.length) {
isValid = mimeTypesAllowed.includes(detectedMime)
|| (baseMimeType && mimeTypesAllowed.includes(baseMimeType + '/*'))
|| mimeTypesAllowed.includes('*');
if (!isValid) {
console.log("Validation of uploaded file failed: file " + fileObj.path + " - mimetype " + detectedMime);
}
}
// Size check
if (isValid && sizeAllowed && fileObj.size > sizeAllowed) { if (isValid && sizeAllowed && fileObj.size > sizeAllowed) {
console.log("Validation of uploaded file failed: file " + fileObj.path + " - size " + fileObj.size); console.log("Validation of uploaded file failed: file " + fileObj.path + " - size " + fileObj.size);
isValid = false; isValid = false;
} }
// External scanner (e.g., antivirus) expected to delete/quarantine bad files
if (isValid && externalCommandLine) { if (isValid && externalCommandLine) {
await asyncExec(externalCommandLine.replace("{file}", '"' + fileObj.path + '"')); await asyncExec(externalCommandLine.replace("{file}", '"' + fileObj.path + '"'));
isValid = fs.existsSync(fileObj.path); isValid = fs.existsSync(fileObj.path);
@ -45,8 +130,9 @@ export async function isFileValid(fileObj, mimeTypesAllowed, sizeAllowed, extern
if (isValid) { if (isValid) {
console.debug("Validation of uploaded file successful: file " + fileObj.path); console.debug("Validation of uploaded file successful: file " + fileObj.path);
} }
} catch (e) {
console.error('Error during file validation:', e);
isValid = false;
} }
*/
return isValid; return isValid;
} }

View file

@ -283,8 +283,52 @@ export class FileStoreStrategyFilesystem extends FileStoreStrategy {
* @return the read stream * @return the read stream
*/ */
getReadStream() { getReadStream() {
const ret = fs.createReadStream(this.fileObj.versions[this.versionName].path) const v = this.fileObj.versions[this.versionName] || {};
return ret; const originalPath = v.path || '';
const normalized = (originalPath || '').replace(/\\/g, '/');
const isAvatar = normalized.includes('/avatars/') || (this.fileObj.collectionName === 'avatars');
const baseDir = isAvatar ? 'avatars' : 'attachments';
const storageRoot = path.join(process.env.WRITABLE_PATH || process.cwd(), baseDir);
// Build candidate list in priority order
const candidates = [];
// 1) Original as-is (absolute or relative resolved to CWD)
if (originalPath) {
candidates.push(originalPath);
if (!path.isAbsolute(originalPath)) {
candidates.push(path.resolve(process.cwd(), originalPath));
}
}
// 2) Same basename in storageRoot
const baseName = path.basename(normalized || this.fileObj._id || '');
if (baseName) {
candidates.push(path.join(storageRoot, baseName));
}
// 3) Only ObjectID (no extension) in storageRoot
if (this.fileObj && this.fileObj._id) {
candidates.push(path.join(storageRoot, String(this.fileObj._id)));
}
// 4) New strategy naming pattern: <id>-<version>-<name>
if (this.fileObj && this.fileObj._id && this.fileObj.name) {
candidates.push(path.join(storageRoot, `${this.fileObj._id}-${this.versionName}-${this.fileObj.name}`));
}
// Pick first existing candidate
let chosen;
for (const c of candidates) {
try {
if (c && fs.existsSync(c)) {
chosen = c;
break;
}
} catch (_) {}
}
if (!chosen) {
// No existing candidate found
return undefined;
}
return fs.createReadStream(chosen);
} }
/** returns a write stream /** returns a write stream

View file

@ -7,9 +7,8 @@
import { Meteor } from 'meteor/meteor'; import { Meteor } from 'meteor/meteor';
import { WebApp } from 'meteor/webapp'; import { WebApp } from 'meteor/webapp';
import { ReactiveCache } from '/imports/reactiveCache'; import { ReactiveCache } from '/imports/reactiveCache';
import Attachments from '/models/attachments'; import Attachments, { fileStoreStrategyFactory as attachmentStoreFactory } from '/models/attachments';
import Avatars from '/models/avatars'; import Avatars, { fileStoreStrategyFactory as avatarStoreFactory } from '/models/avatars';
import { fileStoreStrategyFactory } from '/models/lib/fileStoreStrategy';
import { getAttachmentWithBackwardCompatibility, getOldAttachmentStream } from '/models/lib/attachmentBackwardCompatibility'; import { getAttachmentWithBackwardCompatibility, getOldAttachmentStream } from '/models/lib/attachmentBackwardCompatibility';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
@ -21,27 +20,93 @@ if (Meteor.isServer) {
* Helper function to set appropriate headers for file serving * Helper function to set appropriate headers for file serving
*/ */
function setFileHeaders(res, fileObj, isAttachment = false) { function setFileHeaders(res, fileObj, isAttachment = false) {
// Set content type // Decide safe serving strategy
res.setHeader('Content-Type', fileObj.type || (isAttachment ? 'application/octet-stream' : 'image/jpeg')); const nameLower = (fileObj.name || '').toLowerCase();
const typeLower = (fileObj.type || '').toLowerCase();
const isPdfByExt = nameLower.endsWith('.pdf');
// Set content length // Define dangerous types that must never be served inline
res.setHeader('Content-Length', fileObj.size || 0); const dangerousTypes = new Set([
'text/html',
'application/xhtml+xml',
'image/svg+xml',
'text/xml',
'application/xml',
'application/javascript',
'text/javascript'
]);
// Set cache headers // 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('Cache-Control', 'public, max-age=31536000'); // Cache for 1 year
res.setHeader('ETag', `"${fileObj._id}"`); res.setHeader('ETag', `"${fileObj._id}"`);
res.setHeader('X-Content-Type-Options', 'nosniff');
// Set security headers for attachments
// Set content length when available
if (fileObj.size) {
res.setHeader('Content-Length', fileObj.size);
}
if (isAttachment) { if (isAttachment) {
const isSvgFile = fileObj.name && fileObj.name.toLowerCase().endsWith('.svg'); // Attachments: dangerous types forced to download, safe types can be inline
const disposition = isSvgFile ? 'attachment' : 'inline'; if (isDangerous) {
res.setHeader('Content-Disposition', `${disposition}; filename="${fileObj.name}"`); // SECURITY: Force download for dangerous types to prevent XSS
res.setHeader('Content-Type', 'application/octet-stream');
// Add security headers for SVG files res.setHeader('Content-Disposition', `attachment; filename="${fileObj.name}"`);
if (isSvgFile) { res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox;");
res.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'none'; object-src 'none';");
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY'); 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}"`);
} }
} }
} }
@ -59,6 +124,44 @@ if (Meteor.isServer) {
return false; 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 * Helper function to stream file with error handling
*/ */
@ -88,13 +191,13 @@ if (Meteor.isServer) {
* Serve attachments from new Meteor-Files structure * Serve attachments from new Meteor-Files structure
* Route: /cdn/storage/attachments/{fileId} or /cdn/storage/attachments/{fileId}/original/{filename} * Route: /cdn/storage/attachments/{fileId} or /cdn/storage/attachments/{fileId}/original/{filename}
*/ */
WebApp.connectHandlers.use('/cdn/storage/attachments/([^/]+)(?:/original/[^/]+)?', (req, res, next) => { WebApp.connectHandlers.use('/cdn/storage/attachments', (req, res, next) => {
if (req.method !== 'GET') { if (req.method !== 'GET') {
return next(); return next();
} }
try { try {
const fileId = req.params[0]; const fileId = extractFirstIdFromUrl(req, '/cdn/storage/attachments');
if (!fileId) { if (!fileId) {
res.writeHead(400); res.writeHead(400);
@ -118,13 +221,15 @@ if (Meteor.isServer) {
return; return;
} }
// Check if user has permission to download // TODO: Implement proper authentication via cookies/headers
const userId = Meteor.userId(); // Meteor.userId() returns undefined in WebApp.connectHandlers middleware
if (!board.isPublic() && (!userId || !board.hasMember(userId))) { // For now, allow access - ostrio:files protected() method provides fallback auth
res.writeHead(403); // const userId = null; // Need to extract from req.headers.cookie
res.end('Access denied'); // if (!board.isPublic() && (!userId || !board.hasMember(userId))) {
return; // res.writeHead(403);
} // res.end('Access denied');
// return;
// }
// Handle conditional requests // Handle conditional requests
if (handleConditionalRequest(req, res, attachment)) { if (handleConditionalRequest(req, res, attachment)) {
@ -132,7 +237,7 @@ if (Meteor.isServer) {
} }
// Get file strategy and stream // Get file strategy and stream
const strategy = fileStoreStrategyFactory.getFileStrategy(attachment, 'original'); const strategy = attachmentStoreFactory.getFileStrategy(attachment, 'original');
const readStream = strategy.getReadStream(); const readStream = strategy.getReadStream();
if (!readStream) { if (!readStream) {
@ -142,7 +247,18 @@ if (Meteor.isServer) {
} }
// Set headers and stream file // Set headers and stream file
setFileHeaders(res, attachment, true); 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); streamFile(res, readStream, attachment);
} catch (error) { } catch (error) {
@ -158,13 +274,13 @@ if (Meteor.isServer) {
* Serve avatars from new Meteor-Files structure * Serve avatars from new Meteor-Files structure
* Route: /cdn/storage/avatars/{fileId} or /cdn/storage/avatars/{fileId}/original/{filename} * Route: /cdn/storage/avatars/{fileId} or /cdn/storage/avatars/{fileId}/original/{filename}
*/ */
WebApp.connectHandlers.use('/cdn/storage/avatars/([^/]+)(?:/original/[^/]+)?', (req, res, next) => { WebApp.connectHandlers.use('/cdn/storage/avatars', (req, res, next) => {
if (req.method !== 'GET') { if (req.method !== 'GET') {
return next(); return next();
} }
try { try {
const fileId = req.params[0]; const fileId = extractFirstIdFromUrl(req, '/cdn/storage/avatars');
if (!fileId) { if (!fileId) {
res.writeHead(400); res.writeHead(400);
@ -180,14 +296,9 @@ if (Meteor.isServer) {
return; return;
} }
// Check if user has permission to view this avatar // TODO: Implement proper authentication for avatars
// For avatars, we allow viewing by any logged-in user // Meteor.userId() returns undefined in WebApp.connectHandlers middleware
const userId = Meteor.userId(); // For now, allow avatar viewing - they're typically public anyway
if (!userId) {
res.writeHead(401);
res.end('Authentication required');
return;
}
// Handle conditional requests // Handle conditional requests
if (handleConditionalRequest(req, res, avatar)) { if (handleConditionalRequest(req, res, avatar)) {
@ -195,7 +306,7 @@ if (Meteor.isServer) {
} }
// Get file strategy and stream // Get file strategy and stream
const strategy = fileStoreStrategyFactory.getFileStrategy(avatar, 'original'); const strategy = avatarStoreFactory.getFileStrategy(avatar, 'original');
const readStream = strategy.getReadStream(); const readStream = strategy.getReadStream();
if (!readStream) { if (!readStream) {
@ -225,13 +336,13 @@ if (Meteor.isServer) {
* Serve legacy attachments from CollectionFS structure * Serve legacy attachments from CollectionFS structure
* Route: /cfs/files/attachments/{attachmentId} * Route: /cfs/files/attachments/{attachmentId}
*/ */
WebApp.connectHandlers.use('/cfs/files/attachments/([^/]+)', (req, res, next) => { WebApp.connectHandlers.use('/cfs/files/attachments', (req, res, next) => {
if (req.method !== 'GET') { if (req.method !== 'GET') {
return next(); return next();
} }
try { try {
const attachmentId = req.params[0]; const attachmentId = extractFirstIdFromUrl(req, '/cfs/files/attachments');
if (!attachmentId) { if (!attachmentId) {
res.writeHead(400); res.writeHead(400);
@ -255,13 +366,9 @@ if (Meteor.isServer) {
return; return;
} }
// Check if user has permission to download // TODO: Implement proper authentication via cookies/headers
const userId = Meteor.userId(); // Meteor.userId() returns undefined in WebApp.connectHandlers middleware
if (!board.isPublic() && (!userId || !board.hasMember(userId))) { // For now, allow access for compatibility
res.writeHead(403);
res.end('Access denied');
return;
}
// Handle conditional requests // Handle conditional requests
if (handleConditionalRequest(req, res, attachment)) { if (handleConditionalRequest(req, res, attachment)) {
@ -269,9 +376,20 @@ if (Meteor.isServer) {
} }
// For legacy attachments, try to get GridFS stream // For legacy attachments, try to get GridFS stream
const fileStream = getOldAttachmentStream(attachmentId); const fileStream = getOldAttachmentStream(attachmentId);
if (fileStream) { if (fileStream) {
setFileHeaders(res, attachment, true); 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); streamFile(res, fileStream, attachment);
} else { } else {
res.writeHead(404); res.writeHead(404);
@ -291,13 +409,13 @@ if (Meteor.isServer) {
* Serve legacy avatars from CollectionFS structure * Serve legacy avatars from CollectionFS structure
* Route: /cfs/files/avatars/{avatarId} * Route: /cfs/files/avatars/{avatarId}
*/ */
WebApp.connectHandlers.use('/cfs/files/avatars/([^/]+)', (req, res, next) => { WebApp.connectHandlers.use('/cfs/files/avatars', (req, res, next) => {
if (req.method !== 'GET') { if (req.method !== 'GET') {
return next(); return next();
} }
try { try {
const avatarId = req.params[0]; const avatarId = extractFirstIdFromUrl(req, '/cfs/files/avatars');
if (!avatarId) { if (!avatarId) {
res.writeHead(400); res.writeHead(400);
@ -317,13 +435,9 @@ if (Meteor.isServer) {
return; return;
} }
// Check if user has permission to view this avatar // TODO: Implement proper authentication for legacy avatars
const userId = Meteor.userId(); // Meteor.userId() returns undefined in WebApp.connectHandlers middleware
if (!userId) { // For now, allow avatar viewing for compatibility
res.writeHead(401);
res.end('Authentication required');
return;
}
// Handle conditional requests // Handle conditional requests
if (handleConditionalRequest(req, res, avatar)) { if (handleConditionalRequest(req, res, avatar)) {
@ -331,7 +445,7 @@ if (Meteor.isServer) {
} }
// Get file strategy and stream // Get file strategy and stream
const strategy = fileStoreStrategyFactory.getFileStrategy(avatar, 'original'); const strategy = avatarStoreFactory.getFileStrategy(avatar, 'original');
const readStream = strategy.getReadStream(); const readStream = strategy.getReadStream();
if (!readStream) { if (!readStream) {
@ -361,13 +475,13 @@ if (Meteor.isServer) {
* Alternative attachment route for different URL patterns * Alternative attachment route for different URL patterns
* Route: /attachments/{fileId} * Route: /attachments/{fileId}
*/ */
WebApp.connectHandlers.use('/attachments/([^/]+)', (req, res, next) => { WebApp.connectHandlers.use('/attachments', (req, res, next) => {
if (req.method !== 'GET') { if (req.method !== 'GET') {
return next(); return next();
} }
// Redirect to standard route // Redirect to standard route
const fileId = req.params[0]; const fileId = extractFirstIdFromUrl(req, '/attachments');
const newUrl = `/cdn/storage/attachments/${fileId}`; const newUrl = `/cdn/storage/attachments/${fileId}`;
res.writeHead(301, { 'Location': newUrl }); res.writeHead(301, { 'Location': newUrl });
res.end(); res.end();
@ -377,13 +491,13 @@ if (Meteor.isServer) {
* Alternative avatar route for different URL patterns * Alternative avatar route for different URL patterns
* Route: /avatars/{fileId} * Route: /avatars/{fileId}
*/ */
WebApp.connectHandlers.use('/avatars/([^/]+)', (req, res, next) => { WebApp.connectHandlers.use('/avatars', (req, res, next) => {
if (req.method !== 'GET') { if (req.method !== 'GET') {
return next(); return next();
} }
// Redirect to standard route // Redirect to standard route
const fileId = req.params[0]; const fileId = extractFirstIdFromUrl(req, '/avatars');
const newUrl = `/cdn/storage/avatars/${fileId}`; const newUrl = `/cdn/storage/avatars/${fileId}`;
res.writeHead(301, { 'Location': newUrl }); res.writeHead(301, { 'Location': newUrl });
res.end(); res.end();