wekan/server/routes/legacyAttachments.js
GitHub Copilot 2e564bd076
Fix attachment download error with non-ASCII filenames
Fixes #6055.

Signed-off-by: Buo-ren Lin (OSSII) <buoren.lin@ossii.com.tw>
2025-12-30 17:54:29 +08:00

128 lines
4.7 KiB
JavaScript

import { Meteor } from 'meteor/meteor';
import { WebApp } from 'meteor/webapp';
import { ReactiveCache } from '/imports/reactiveCache';
import { getAttachmentWithBackwardCompatibility, getOldAttachmentStream } from '/models/lib/attachmentBackwardCompatibility';
// Ensure this file is loaded
if (process.env.DEBUG === 'true') {
console.log('Legacy attachments route loaded');
}
/**
* Helper function to properly encode a filename for the Content-Disposition header
* Removes invalid characters (control chars, newlines, etc.) that would break HTTP headers.
* For non-ASCII filenames, uses RFC 5987 encoding to preserve the original filename.
* This prevents ERR_INVALID_CHAR errors when filenames contain control characters.
*/
function sanitizeFilenameForHeader(filename) {
if (!filename || typeof filename !== 'string') {
return 'download';
}
// First, remove any control characters (0x00-0x1F, 0x7F) that would break HTTP headers
// This includes newlines, carriage returns, tabs, and other control chars
let sanitized = filename.replace(/[\x00-\x1F\x7F]/g, '');
// If the filename is all ASCII printable characters (0x20-0x7E), use it directly
if (/^[\x20-\x7E]*$/.test(sanitized)) {
// Escape any quotes and backslashes in the filename
sanitized = sanitized.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return sanitized;
}
// For non-ASCII filenames, provide a fallback and RFC 5987 encoded version
const fallback = sanitized.replace(/[^\x20-\x7E]/g, '_').slice(0, 100) || 'download';
const encoded = encodeURIComponent(sanitized);
// Return special marker format that will be handled by buildContentDispositionHeader
// Format: "fallback|RFC5987:encoded"
return `${fallback}|RFC5987:${encoded}`;
}
/**
* Helper function to build a complete Content-Disposition header value with RFC 5987 support
* Handles the special format returned by sanitizeFilenameForHeader for non-ASCII filenames
*/
function buildContentDispositionHeader(disposition, sanitizedFilename) {
if (sanitizedFilename.includes('|RFC5987:')) {
const [fallback, encoded] = sanitizedFilename.split('|RFC5987:');
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encoded}`;
}
return `${disposition}; filename="${sanitizedFilename}"`;
}
/**
* Legacy attachment download route for CollectionFS compatibility
* Handles downloads from old CollectionFS structure
*/
if (Meteor.isServer) {
// Handle legacy attachment downloads
WebApp.connectHandlers.use('/cfs/files/attachments', (req, res, next) => {
const attachmentId = req.url.split('/').pop();
if (!attachmentId) {
res.writeHead(404);
res.end('Attachment not found');
return;
}
try {
// 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;
}
// Check if user has permission to download
const userId = Meteor.userId();
if (!board.isPublic() && (!userId || !board.hasMember(userId))) {
res.writeHead(403);
res.end('Access denied');
return;
}
// Set appropriate headers
res.setHeader('Content-Type', attachment.type || 'application/octet-stream');
res.setHeader('Content-Length', attachment.size || 0);
// Force attachment disposition for SVG files to prevent XSS attacks
const isSvgFile = attachment.name && attachment.name.toLowerCase().endsWith('.svg');
const disposition = isSvgFile ? 'attachment' : 'attachment'; // Always use attachment for legacy files
res.setHeader('Content-Disposition', buildContentDispositionHeader(disposition, sanitizeFilenameForHeader(attachment.name)));
// Add security headers for SVG files
if (isSvgFile) {
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');
}
// Get GridFS stream for legacy attachment
const fileStream = getOldAttachmentStream(attachmentId);
if (fileStream) {
res.writeHead(200);
fileStream.pipe(res);
} else {
res.writeHead(404);
res.end('File not found in GridFS');
}
} catch (error) {
console.error('Error serving legacy attachment:', error);
res.writeHead(500);
res.end('Internal server error');
}
});
}