mirror of
https://github.com/wekan/wekan.git
synced 2026-01-29 04:36:10 +01:00
Fix Filebleed of Floppybleed.
Thanks to Luke Hebenstreit Twitter lheben_ and xet7 !
This commit is contained in:
parent
7ad04f4535
commit
a419d831a4
7 changed files with 234 additions and 33 deletions
|
|
@ -5,6 +5,8 @@ const passwordField = AccountsTemplates.removeField('password');
|
|||
passwordField.autocomplete = 'current-password';
|
||||
passwordField.template = 'passwordInput';
|
||||
const emailField = AccountsTemplates.removeField('email');
|
||||
|
||||
// Don't add current_password to global fields - it should only be used for change password
|
||||
let disableRegistration = false;
|
||||
let disableForgotPassword = false;
|
||||
let passwordLoginEnabled = false;
|
||||
|
|
@ -40,16 +42,16 @@ Meteor.call('getOauthDashboardUrl', (_, result) => {
|
|||
Meteor.call('isDisableRegistration', (_, result) => {
|
||||
if (result) {
|
||||
disableRegistration = true;
|
||||
//console.log('disableRegistration');
|
||||
//console.log(result);
|
||||
// Reconfigure to apply the new setting
|
||||
AccountsTemplates.configure({
|
||||
forbidClientAccountCreation: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Meteor.call('isDisableForgotPassword', (_, result) => {
|
||||
if (result) {
|
||||
disableForgotPassword = true;
|
||||
//console.log('disableForgotPassword');
|
||||
//console.log(result);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -91,6 +93,30 @@ AccountsTemplates.configure({
|
|||
sendVerificationEmail: true,
|
||||
showForgotPasswordLink: !disableForgotPassword,
|
||||
forbidClientAccountCreation: disableRegistration,
|
||||
onSubmitHook(error, state) {
|
||||
if (error) {
|
||||
// Display error to user
|
||||
const errorDiv = document.getElementById('login-error-message');
|
||||
if (errorDiv) {
|
||||
let errorMessage = error.reason || error.message || 'Registration failed. Please try again.';
|
||||
// If there are validation details, show them
|
||||
if (error.details && typeof error.details === 'object') {
|
||||
const detailMessages = [];
|
||||
for (let field in error.details) {
|
||||
const errorMsg = error.details[field];
|
||||
if (errorMsg) {
|
||||
const message = Array.isArray(errorMsg) ? errorMsg.join(', ') : errorMsg;
|
||||
detailMessages.push(`${field}: ${message}`);
|
||||
}
|
||||
}
|
||||
if (detailMessages.length > 0) {
|
||||
errorMessage += '<br>' + detailMessages.join('<br>');
|
||||
}
|
||||
}
|
||||
errorDiv.innerHTML = errorMessage;
|
||||
}
|
||||
}
|
||||
},
|
||||
onLogoutHook() {
|
||||
// here comeslogic for redirect
|
||||
if(oidcRedirectionEnabled)
|
||||
|
|
|
|||
|
|
@ -12,12 +12,17 @@ FlowRouter.route('/', {
|
|||
name: 'home',
|
||||
triggersEnter: [AccountsTemplates.ensureSignedIn],
|
||||
action() {
|
||||
// Redirect to sign-in immediately if user is not logged in
|
||||
if (!Meteor.userId()) {
|
||||
FlowRouter.go('atSignIn');
|
||||
return;
|
||||
}
|
||||
|
||||
Session.set('currentBoard', null);
|
||||
Session.set('currentList', null);
|
||||
Session.set('currentCard', null);
|
||||
Session.set('popupCardId', null);
|
||||
Session.set('popupCardBoardId', null);
|
||||
|
||||
Filter.reset();
|
||||
Session.set('sortBy', '');
|
||||
EscapeActions.executeAll();
|
||||
|
|
@ -137,7 +142,7 @@ FlowRouter.route('/b/:boardId/:slug/:cardId', {
|
|||
Session.set('currentCard', params.cardId);
|
||||
Session.set('popupCardId', null);
|
||||
Session.set('popupCardBoardId', null);
|
||||
|
||||
|
||||
// In desktop mode, add to openCards array to support multiple cards
|
||||
const isMobile = Utils.getMobileMode();
|
||||
if (!isMobile) {
|
||||
|
|
@ -162,17 +167,15 @@ FlowRouter.route('/b/:id/:slug', {
|
|||
name: 'board',
|
||||
action(params) {
|
||||
const pathSegments = FlowRouter.current().path.split('/').filter(s => s);
|
||||
|
||||
|
||||
// If we have 4+ segments (b, boardId, slug, cardId), this is a card view
|
||||
if (pathSegments.length >= 4) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If slug contains "/" it means a cardId was matched by this greedy pattern
|
||||
if (params.slug && params.slug.includes('/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentBoard = params.id;
|
||||
const previousBoard = Session.get('currentBoard');
|
||||
Session.set('currentBoard', currentBoard);
|
||||
|
|
|
|||
|
|
@ -98,6 +98,27 @@ Attachments = new FilesCollection({
|
|||
return ret;
|
||||
},
|
||||
onBeforeUpload(file) {
|
||||
// SECURITY: Sanitize filename to prevent path traversal attacks
|
||||
// Import sanitizeFilename from fileStoreStrategy - but since we can't import here,
|
||||
// we'll implement a minimal inline version to be safe
|
||||
if (file.name && typeof file.name === 'string') {
|
||||
// Use path.basename to strip directory components and prevent path traversal
|
||||
let safeName = path.basename(file.name);
|
||||
// Remove null bytes
|
||||
safeName = safeName.replace(/\0/g, '');
|
||||
// Remove path traversal sequences
|
||||
safeName = safeName.replace(/\.\.[\\/\\]/g, '');
|
||||
safeName = safeName.replace(/^\.\.$/g, '');
|
||||
safeName = safeName.trim();
|
||||
|
||||
// If sanitization changed the name, update it
|
||||
if (safeName && safeName !== '.' && safeName !== '..' && safeName !== file.name) {
|
||||
file.name = safeName;
|
||||
} else if (!safeName || safeName === '.' || safeName === '..') {
|
||||
file.name = 'unnamed';
|
||||
}
|
||||
}
|
||||
|
||||
// Block SVG files for attachments to prevent XSS attacks
|
||||
if (file.name && file.name.toLowerCase().endsWith('.svg')) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
|
|
@ -138,7 +159,7 @@ Attachments = new FilesCollection({
|
|||
|
||||
// Use selected storage backend or copy storage if specified
|
||||
let storageDestination = fileObj.meta.copyStorage || defaultStorage;
|
||||
|
||||
|
||||
// Only migrate if the destination is different from filesystem
|
||||
if (storageDestination !== STORAGE_NAME_FILESYSTEM) {
|
||||
Meteor.defer(() => Meteor.call('validateAttachmentAndMoveToStorage', fileObj._id, storageDestination));
|
||||
|
|
@ -180,9 +201,26 @@ if (Meteor.isServer) {
|
|||
return allowIsBoardMemberWithWriteAccess(userId, ReactiveCache.getBoard(fileObj.boardId));
|
||||
},
|
||||
update(userId, fileObj, fields) {
|
||||
// Only allow updates to specific fields that don't affect security
|
||||
// SECURITY: The 'name' field is sanitized in onBeforeUpload and server-side methods,
|
||||
// but we block direct client-side $set operations on 'versions.*.path' to prevent
|
||||
// path traversal attacks via storage migration exploits.
|
||||
|
||||
// Block direct updates to version paths (the attack vector)
|
||||
const hasPathUpdate = fields.some(field => field.includes('versions') && field.includes('path'));
|
||||
if (hasPathUpdate) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Blocked attempt to update attachment version paths:', fields);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow normal updates for file upload/management
|
||||
const allowedFields = ['name', 'size', 'type', 'extension', 'extensionWithDot', 'meta', 'versions'];
|
||||
const isAllowedField = fields.every(field => allowedFields.includes(field));
|
||||
const isAllowedField = fields.every(field => {
|
||||
// Allow field itself or nested properties like 'versions.original'
|
||||
const baseField = field.split('.')[0];
|
||||
return allowedFields.includes(baseField);
|
||||
});
|
||||
|
||||
if (!isAllowedField) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
|
|
@ -390,7 +428,7 @@ if (Meteor.isClient) {
|
|||
// 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') {
|
||||
|
|
@ -401,7 +439,7 @@ if (Meteor.isClient) {
|
|||
// 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({
|
||||
link(version) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { MongoInternals } from 'meteor/mongo';
|
||||
|
||||
|
|
@ -18,7 +17,10 @@ const OldAttachmentsFileRecord = new Mongo.Collection('cfs.attachments.filerecor
|
|||
*/
|
||||
export function isNewAttachmentStructure(attachmentId) {
|
||||
if (Meteor.isServer) {
|
||||
return !!ReactiveCache.getAttachment(attachmentId);
|
||||
// Access global Attachments variable to avoid circular dependency
|
||||
if (typeof Attachments !== 'undefined' && Attachments.collection) {
|
||||
return !!Attachments.collection.findOne({ _id: attachmentId });
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -174,13 +176,19 @@ function isPDFFile(mimeType) {
|
|||
* @returns {Object|null} - Attachment data or null if not found
|
||||
*/
|
||||
export function getAttachmentWithBackwardCompatibility(attachmentId) {
|
||||
// First try new structure
|
||||
if (isNewAttachmentStructure(attachmentId)) {
|
||||
return ReactiveCache.getAttachment(attachmentId);
|
||||
// First try new structure - access global to avoid circular dependency
|
||||
if (Meteor.isServer) {
|
||||
if (typeof Attachments !== 'undefined' && Attachments.collection) {
|
||||
const newAttachment = Attachments.collection.findOne({ _id: attachmentId });
|
||||
if (newAttachment) {
|
||||
return newAttachment;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to old structure
|
||||
return getOldAttachmentData(attachmentId);
|
||||
const oldAttachment = getOldAttachmentData(attachmentId);
|
||||
return oldAttachment;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -189,7 +197,15 @@ export function getAttachmentWithBackwardCompatibility(attachmentId) {
|
|||
* @returns {Array} - Array of attachments
|
||||
*/
|
||||
export function getAttachmentsWithBackwardCompatibility(query) {
|
||||
const newAttachments = ReactiveCache.getAttachments(query);
|
||||
let newAttachments = [];
|
||||
|
||||
// Get new attachments - access global to avoid circular dependency
|
||||
if (Meteor.isServer) {
|
||||
if (typeof Attachments !== 'undefined' && Attachments.collection) {
|
||||
newAttachments = Attachments.collection.find(query).fetch();
|
||||
}
|
||||
}
|
||||
|
||||
const oldAttachments = [];
|
||||
|
||||
if (Meteor.isServer) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,68 @@ export const STORAGE_NAME_FILESYSTEM = "fs";
|
|||
export const STORAGE_NAME_GRIDFS = "gridfs";
|
||||
export const STORAGE_NAME_S3 = "s3";
|
||||
|
||||
/**
|
||||
* Sanitize filename to prevent path traversal attacks
|
||||
* @param {string} filename - User-provided filename
|
||||
* @return {string} Sanitized filename safe for filesystem operations
|
||||
*/
|
||||
function sanitizeFilename(filename) {
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
return 'unnamed';
|
||||
}
|
||||
|
||||
// Use path.basename to strip any directory components
|
||||
let safe = path.basename(filename);
|
||||
|
||||
// Remove null bytes
|
||||
safe = safe.replace(/\0/g, '');
|
||||
|
||||
// Remove any remaining path traversal sequences
|
||||
safe = safe.replace(/\.\.[\\/\\]/g, '');
|
||||
safe = safe.replace(/^\.\.$/, '');
|
||||
|
||||
// Trim whitespace
|
||||
safe = safe.trim();
|
||||
|
||||
// If empty after sanitization, use default
|
||||
if (!safe || safe === '.' || safe === '..') {
|
||||
return 'unnamed';
|
||||
}
|
||||
|
||||
return safe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filename to prevent path traversal attacks
|
||||
* @param {string} filename - User-provided filename
|
||||
* @return {string} Sanitized filename safe for filesystem operations
|
||||
*/
|
||||
function sanitizeFilename(filename) {
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
return 'unnamed';
|
||||
}
|
||||
|
||||
// Use path.basename to strip any directory components
|
||||
let safe = path.basename(filename);
|
||||
|
||||
// Remove null bytes
|
||||
safe = safe.replace(/\0/g, '');
|
||||
|
||||
// Remove any remaining path traversal sequences
|
||||
safe = safe.replace(/\.\.[\/\\]/g, '');
|
||||
safe = safe.replace(/^\.\.$/g, '');
|
||||
|
||||
// Trim whitespace
|
||||
safe = safe.trim();
|
||||
|
||||
// If empty after sanitization, use default
|
||||
if (!safe || safe === '.' || safe === '..') {
|
||||
return 'unnamed';
|
||||
}
|
||||
|
||||
return safe;
|
||||
}
|
||||
|
||||
/** Factory for FileStoreStrategy */
|
||||
export default class FileStoreStrategyFactory {
|
||||
|
||||
|
|
@ -123,7 +185,9 @@ class FileStoreStrategy {
|
|||
if (!_.isString(name)) {
|
||||
name = this.fileObj.name;
|
||||
}
|
||||
const ret = path.join(storagePath, this.fileObj._id + "-" + this.versionName + "-" + name);
|
||||
// Sanitize filename to prevent path traversal attacks
|
||||
const safeName = sanitizeFilename(name);
|
||||
const ret = path.join(storagePath, this.fileObj._id + "-" + this.versionName + "-" + safeName);
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
|
@ -292,6 +356,42 @@ export class FileStoreStrategyFilesystem extends FileStoreStrategy {
|
|||
|
||||
// Build candidate list in priority order
|
||||
const candidates = [];
|
||||
|
||||
// 0) Try to find project root and resolve from there
|
||||
let projectRoot = null;
|
||||
if (originalPath) {
|
||||
// Find project root by looking for .meteor directory
|
||||
let current = process.cwd();
|
||||
let maxLevels = 10; // Safety limit
|
||||
|
||||
while (maxLevels-- > 0) {
|
||||
const meteorPath = path.join(current, '.meteor');
|
||||
const packagePath = path.join(current, 'package.json');
|
||||
|
||||
if (fs.existsSync(meteorPath) || fs.existsSync(packagePath)) {
|
||||
projectRoot = current;
|
||||
break;
|
||||
}
|
||||
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break; // Reached filesystem root
|
||||
current = parent;
|
||||
}
|
||||
|
||||
if (projectRoot) {
|
||||
// Try resolving originalPath from project root
|
||||
const fromProjectRoot = path.resolve(projectRoot, originalPath);
|
||||
candidates.push(fromProjectRoot);
|
||||
|
||||
// Also try direct path: projectRoot/attachments/filename
|
||||
const baseName = path.basename(normalized || this.fileObj._id || '');
|
||||
if (baseName) {
|
||||
const directPath = path.join(projectRoot, baseDir, baseName);
|
||||
candidates.push(directPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1) Original as-is (absolute or relative resolved to CWD)
|
||||
if (originalPath) {
|
||||
candidates.push(originalPath);
|
||||
|
|
@ -308,20 +408,24 @@ export class FileStoreStrategyFilesystem extends FileStoreStrategy {
|
|||
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}`));
|
||||
// 3) Old naming: {id}-{version}-{originalName}
|
||||
if (this.fileObj.name) {
|
||||
const safeName = sanitizeFilename(this.fileObj.name);
|
||||
candidates.push(path.join(storageRoot, `${this.fileObj._id}-${this.versionName}-${safeName}`));
|
||||
}
|
||||
|
||||
// Pick first existing candidate
|
||||
let chosen;
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
if (c && fs.existsSync(c)) {
|
||||
const exists = c && fs.existsSync(c);
|
||||
if (exists) {
|
||||
chosen = c;
|
||||
break;
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (err) {
|
||||
// Continue to next candidate
|
||||
}
|
||||
}
|
||||
|
||||
if (!chosen) {
|
||||
|
|
@ -430,6 +534,16 @@ export class FileStoreStrategyS3 extends FileStoreStrategy {
|
|||
* @param fileStoreStrategyFactory get FileStoreStrategy from this factory
|
||||
*/
|
||||
export const moveToStorage = function(fileObj, storageDestination, fileStoreStrategyFactory) {
|
||||
// SECURITY: Sanitize filename to prevent path traversal attacks
|
||||
// This ensures any malicious names already in the database are cleaned up
|
||||
const safeName = sanitizeFilename(fileObj.name);
|
||||
if (safeName !== fileObj.name) {
|
||||
// Update the database with the sanitized name
|
||||
Attachments.update({ _id: fileObj._id }, { $set: { name: safeName } });
|
||||
// Update the local object for use in this function
|
||||
fileObj.name = safeName;
|
||||
}
|
||||
|
||||
Object.keys(fileObj.versions).forEach(versionName => {
|
||||
const strategyRead = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName);
|
||||
const strategyWrite = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName, storageDestination);
|
||||
|
|
@ -473,7 +587,8 @@ export const copyFile = function(fileObj, newCardId, fileStoreStrategyFactory) {
|
|||
const readStream = strategyRead.getReadStream();
|
||||
const strategyWrite = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName, STORAGE_NAME_FILESYSTEM);
|
||||
|
||||
const tempPath = path.join(fileStoreStrategyFactory.storagePath, Random.id() + "-" + versionName + "-" + fileObj.name);
|
||||
const safeName = sanitizeFilename(fileObj.name);
|
||||
const tempPath = path.join(fileStoreStrategyFactory.storagePath, Random.id() + "-" + versionName + "-" + safeName);
|
||||
const writeStream = strategyWrite.getWriteStream(tempPath);
|
||||
|
||||
writeStream.on('error', error => {
|
||||
|
|
@ -522,14 +637,17 @@ export const copyFile = function(fileObj, newCardId, fileStoreStrategyFactory) {
|
|||
};
|
||||
|
||||
export const rename = function(fileObj, newName, fileStoreStrategyFactory) {
|
||||
// Sanitize the new name to prevent path traversal
|
||||
const safeName = sanitizeFilename(newName);
|
||||
|
||||
Object.keys(fileObj.versions).forEach(versionName => {
|
||||
const strategy = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName);
|
||||
const newFilePath = strategy.getNewPath(fileStoreStrategyFactory.storagePath, newName);
|
||||
const newFilePath = strategy.getNewPath(fileStoreStrategyFactory.storagePath, safeName);
|
||||
strategy.rename(newFilePath);
|
||||
|
||||
Attachments.update({ _id: fileObj._id }, { $set: {
|
||||
"name": newName,
|
||||
"name": safeName,
|
||||
[`versions.${versionName}.path`]: newFilePath,
|
||||
} });
|
||||
});
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import Boards from '/models/boards';
|
|||
import Lists from '/models/lists';
|
||||
import Swimlanes from '/models/swimlanes';
|
||||
import Cards from '/models/cards';
|
||||
import ReactiveCache from '/imports/reactiveCache';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
|
||||
/**
|
||||
* Fix duplicate lists and swimlanes created by WeKan 8.10
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import Boards from '/models/boards';
|
|||
import Actions from '/models/actions';
|
||||
import Triggers from '/models/triggers';
|
||||
import Rules from '/models/rules';
|
||||
import ReactiveCache from '/imports/reactiveCache';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
|
||||
Meteor.publish('rules', function(ruleId) {
|
||||
check(ruleId, String);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue