Files
Lauri Ojansivu a4c74a5980 Fix avatars rce.
Thanks to Trung Nguyen from CyStack Security and xet7 !
2026-05-03 18:36:15 +03:00

141 lines
4.6 KiB
JavaScript

import { Meteor } from 'meteor/meteor';
import { FilesCollection } from 'meteor/ostrio:files';
import { generateUniversalAvatarUrl } from '/models/lib/universalUrlGenerator';
const { filesize } = require('filesize');
const getTAPi18n = () => require('/imports/i18n').TAPi18n;
let avatarsUploadSize = 72000;
// Compute storage path:
// - Docker (WRITABLE_PATH=/data): /data/files/avatars
// - Snap (WRITABLE_PATH=$SNAP_COMMON/files): $SNAP_COMMON/files/avatars
const computeAvatarStoragePath = () => {
const basePath = process.env.WRITABLE_PATH || process.cwd();
const endsWithFiles = basePath.endsWith('/files') || basePath.endsWith('\\files');
if (endsWithFiles) {
// Snap: WRITABLE_PATH already includes /files
return basePath + '/avatars';
} else {
// Docker & Dev: append /files/avatars
return basePath + '/files/avatars';
}
};
const storagePath = Meteor.isServer ? computeAvatarStoragePath() : 'assets/app/uploads/avatars';
const Avatars = new FilesCollection({
debug: false, // Change to `true` for debugging
collectionName: 'avatars',
allowClientCode: true,
storagePath: storagePath,
namingFunction(opts) {
let filenameWithoutExtension = ""
let fileId = "";
if (opts?.name) {
// Client
filenameWithoutExtension = opts.name.replace(/(.+)\..+/, "$1");
fileId = opts.meta.fileId;
delete opts.meta.fileId;
} else if (opts?.file?.name) {
// Server
if (opts.file.extension) {
filenameWithoutExtension = opts.file.name.replace(new RegExp(opts.file.extensionWithDot + "$"), "")
} else {
// file has no extension, so don't replace anything, otherwise the last character is removed (because extensionWithDot = '.')
filenameWithoutExtension = opts.file.name;
}
fileId = opts.fileId;
}
else {
// should never reach here
filenameWithoutExtension = Math.random().toString(36).slice(2);
fileId = Math.random().toString(36).slice(2);
}
// Strip shell metacharacters and path-traversal sequences from the
// user-supplied portion of the filename. The result is used as part of
// the on-disk path passed to external commands, so only safe characters
// (alphanumerics, hyphens, underscores, and dots) are kept.
const safeName = filenameWithoutExtension.replace(/[^a-zA-Z0-9_.\-]/g, '_');
const ret = fileId + "-original-" + safeName;
return ret;
},
sanitize(str, max, replacement) {
// Strip characters that are dangerous in shell contexts and filesystem paths.
return str.replace(/[^a-zA-Z0-9_.\-]/g, '_');
},
onBeforeUpload(file) {
// Block SVG files for avatars to prevent XSS attacks
if (file.name && file.name.toLowerCase().endsWith('.svg')) {
if (process.env.DEBUG === 'true') {
console.warn('Blocked SVG file upload for avatar:', file.name);
}
return 'SVG files are not allowed for avatars due to security reasons. Please use PNG, JPG, or GIF format.';
}
if (file.type === 'image/svg+xml') {
if (process.env.DEBUG === 'true') {
console.warn('Blocked SVG MIME type upload for avatar:', file.type);
}
return 'SVG files are not allowed for avatars due to security reasons. Please use PNG, JPG, or GIF format.';
}
if (file.size <= avatarsUploadSize && file.type.startsWith('image/')) {
return true;
}
return getTAPi18n().__('avatar-too-big', {size: filesize(avatarsUploadSize)});
},
});
function normalizeRemovedFiles(filesInput) {
if (!filesInput) {
return [];
}
if (Array.isArray(filesInput)) {
return filesInput;
}
if (typeof filesInput.fetch === 'function') {
return filesInput.fetch();
}
if (Array.isArray(filesInput.files)) {
return filesInput.files;
}
if (typeof filesInput === 'string') {
return Avatars.find({ _id: filesInput }).fetch();
}
if (filesInput && typeof filesInput === 'object') {
if (filesInput._id && (filesInput.versions || filesInput.userId)) {
return [filesInput];
}
return Avatars.find(filesInput).fetch();
}
return [];
}
// Export normalizeRemovedFiles and avatarsUploadSize setter for use in server file
export { normalizeRemovedFiles };
export function setAvatarsUploadSize(size) {
avatarsUploadSize = size;
}
// Override the link method to use universal URLs
if (Meteor.isClient) {
// Add custom link method to avatar documents
Avatars.collection.helpers({
link(version = 'original') {
// Use universal URL generator for consistent, URL-agnostic URLs
return generateUniversalAvatarUrl(this._id, version);
}
});
}
export default Avatars;