Skip to content

Commit e9a7273

Browse files
committed
Fix SECURITY ISSUE 1: File Attachments enables stored XSS (High).
Thanks to Siam Thanat Hack (STH) !
1 parent d64d2f9 commit e9a7273

File tree

6 files changed

+360
-82
lines changed

6 files changed

+360
-82
lines changed

SECURITY.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,16 @@ Meteor.startup(() => {
172172
- https://github.com/wekan/wekan/blob/main/client/components/cards/attachments.js#L303-L312
173173
- https://wekan.github.io/hall-of-fame/filebleed/
174174

175+
### Attachments: Forced download to prevent stored XSS
176+
177+
- To prevent browser-side execution of uploaded content under the app origin, all attachment downloads are served with safe headers:
178+
- `Content-Type: application/octet-stream`
179+
- `Content-Disposition: attachment`
180+
- `X-Content-Type-Options: nosniff`
181+
- A restrictive `Content-Security-Policy` with `sandbox`
182+
- This means attachments are downloaded instead of rendered inline by default. This mitigates HTML/JS/SVG based stored XSS vectors.
183+
- Avatars and inline images remain supported but SVG uploads are blocked and never rendered inline.
184+
175185
## Brute force login protection
176186

177187
- https://github.com/wekan/wekan/commit/23e5e1e3bd081699ce39ce5887db7e612616014d

models/attachments.js

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -328,11 +328,35 @@ Attachments.getAttachmentsWithBackwardCompatibility = getAttachmentsWithBackward
328328

329329
// Override the link method to use universal URLs
330330
if (Meteor.isClient) {
331-
// Add custom link method to attachment documents
331+
// Override the original FilesCollection link method to use universal URLs
332+
// This must override the ostrio:files method to avoid "Match error: Expected plain object"
333+
const originalLink = Attachments.link;
334+
Attachments.link = function(versionName) {
335+
// Accept both direct calls and collection.helpers style calls
336+
const fileRef = this._id ? this : (versionName && versionName._id ? versionName : this);
337+
const version = (typeof versionName === 'string') ? versionName : 'original';
338+
339+
if (fileRef && fileRef._id) {
340+
const url = generateUniversalAttachmentUrl(fileRef._id, version);
341+
if (process.env.DEBUG === 'true') {
342+
console.log('Attachment link generated:', url, 'for ID:', fileRef._id);
343+
}
344+
return url;
345+
}
346+
// Fallback to original if somehow we don't have an ID
347+
return originalLink ? originalLink.call(this, versionName) : '';
348+
};
349+
350+
// Also add as collection helper for document instances
332351
Attachments.collection.helpers({
333-
link(version = 'original') {
334-
// Use universal URL generator for consistent, URL-agnostic URLs
335-
return generateUniversalAttachmentUrl(this._id, version);
352+
link(version) {
353+
// Handle both no-argument and string argument cases
354+
const ver = (typeof version === 'string') ? version : 'original';
355+
const url = generateUniversalAttachmentUrl(this._id, ver);
356+
if (process.env.DEBUG === 'true') {
357+
console.log('Attachment link (helper) generated:', url, 'for ID:', this._id);
358+
}
359+
return url;
336360
}
337361
});
338362
}

models/avatars.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ if (Meteor.isServer) {
4444
storagePath = path.join(process.env.WRITABLE_PATH || process.cwd(), 'avatars');
4545
}
4646

47-
const fileStoreStrategyFactory = new FileStoreStrategyFactory(FileStoreStrategyFilesystem, storagePath, FileStoreStrategyGridFs, avatarsBucket);
47+
export const fileStoreStrategyFactory = new FileStoreStrategyFactory(FileStoreStrategyFilesystem, storagePath, FileStoreStrategyGridFs, avatarsBucket);
4848

4949
Avatars = new FilesCollection({
5050
debug: false, // Change to `true` for debugging

models/fileValidation.js

Lines changed: 96 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,27 +12,112 @@ if (Meteor.isServer) {
1212

1313
export async function isFileValid(fileObj, mimeTypesAllowed, sizeAllowed, externalCommandLine) {
1414
let isValid = true;
15+
// Always validate uploads. The previous migration flag disabled validation and enabled XSS.
16+
try {
17+
// Helper: read up to a limit from a file as UTF-8 text
18+
const readTextHead = (filePath, limit = parseInt(process.env.UPLOAD_DANGEROUS_MIME_SCAN_LIMIT || '1048576')) => new Promise((resolve, reject) => {
19+
try {
20+
const stream = fs.createReadStream(filePath, { encoding: 'utf8', highWaterMark: 64 * 1024 });
21+
let data = '';
22+
let exceeded = false;
23+
stream.on('data', chunk => {
24+
data += chunk;
25+
if (data.length >= limit) {
26+
exceeded = true;
27+
stream.destroy();
28+
}
29+
});
30+
stream.on('error', err => reject(err));
31+
stream.on('close', () => {
32+
if (exceeded) {
33+
// If file exceeds scan limit, treat as unsafe
34+
resolve({ text: data.slice(0, limit), complete: false });
35+
} else {
36+
resolve({ text: data, complete: true });
37+
}
38+
});
39+
} catch (e) {
40+
reject(e);
41+
}
42+
});
43+
44+
// Helper: quick content safety checks for HTML/SVG/XML
45+
const containsJsOrXmlBombs = (text) => {
46+
if (!text) return false;
47+
const t = text.toLowerCase();
48+
// JavaScript execution vectors
49+
const patterns = [
50+
/<script\b/i,
51+
/on[a-z\-]{1,20}\s*=\s*['"]/i, // event handlers
52+
/javascript\s*:/i,
53+
/<iframe\b/i,
54+
/<object\b/i,
55+
/<embed\b/i,
56+
/<meta\s+http-equiv\s*=\s*['"]?refresh/i,
57+
/<foreignobject\b/i,
58+
/style\s*=\s*['"][^'"]*url\(\s*javascript\s*:/i,
59+
];
60+
if (patterns.some((re) => re.test(text))) return true;
61+
// XML entity expansion / DTD based bombs
62+
if (t.includes('<!doctype') || t.includes('<!entity') || t.includes('<?xml-stylesheet')) return true;
63+
return false;
64+
};
65+
66+
const checkDangerousMimeAllowance = async (mime, filePath, fileSize) => {
67+
// Allow only if content is scanned and clean
68+
const { text, complete } = await readTextHead(filePath);
69+
if (!complete) {
70+
// Too large to confidently scan
71+
return false;
72+
}
73+
// For JS MIME, only allow empty files
74+
if (mime === 'application/javascript' || mime === 'text/javascript') {
75+
return (text.trim().length === 0);
76+
}
77+
return !containsJsOrXmlBombs(text);
78+
};
1579

16-
/*
17-
if (Meteor.settings.public.ostrioFilesMigrationInProgress !== "true") {
18-
if (mimeTypesAllowed.length) {
19-
const mimeTypeResult = await FileType.fromFile(fileObj.path);
80+
// Detect MIME type from file content when possible
81+
const mimeTypeResult = await FileType.fromFile(fileObj.path).catch(() => undefined);
82+
const detectedMime = mimeTypeResult?.mime || (fileObj.type || '').toLowerCase();
83+
const baseMimeType = detectedMime.split('/', 1)[0] || '';
2084

21-
const mimeType = (mimeTypeResult ? mimeTypeResult.mime : fileObj.type);
22-
const baseMimeType = mimeType.split('/', 1)[0];
85+
// Hard deny-list for obviously dangerous types which can be allowed if content is safe
86+
const dangerousMimes = new Set([
87+
'text/html',
88+
'application/xhtml+xml',
89+
'image/svg+xml',
90+
'text/xml',
91+
'application/xml',
92+
'application/javascript',
93+
'text/javascript'
94+
]);
95+
if (dangerousMimes.has(detectedMime)) {
96+
const allowedByContentScan = await checkDangerousMimeAllowance(detectedMime, fileObj.path, fileObj.size || 0);
97+
if (!allowedByContentScan) {
98+
console.log("Validation of uploaded file failed (dangerous MIME content): file " + fileObj.path + " - mimetype " + detectedMime);
99+
return false;
100+
}
101+
}
23102

24-
isValid = mimeTypesAllowed.includes(mimeType) || mimeTypesAllowed.includes(baseMimeType + '/*') || mimeTypesAllowed.includes('*');
103+
// Optional allow-list: if provided, enforce it using exact or base type match
104+
if (Array.isArray(mimeTypesAllowed) && mimeTypesAllowed.length) {
105+
isValid = mimeTypesAllowed.includes(detectedMime)
106+
|| (baseMimeType && mimeTypesAllowed.includes(baseMimeType + '/*'))
107+
|| mimeTypesAllowed.includes('*');
25108

26109
if (!isValid) {
27-
console.log("Validation of uploaded file failed: file " + fileObj.path + " - mimetype " + mimeType);
110+
console.log("Validation of uploaded file failed: file " + fileObj.path + " - mimetype " + detectedMime);
28111
}
29112
}
30113

114+
// Size check
31115
if (isValid && sizeAllowed && fileObj.size > sizeAllowed) {
32116
console.log("Validation of uploaded file failed: file " + fileObj.path + " - size " + fileObj.size);
33117
isValid = false;
34118
}
35119

120+
// External scanner (e.g., antivirus) – expected to delete/quarantine bad files
36121
if (isValid && externalCommandLine) {
37122
await asyncExec(externalCommandLine.replace("{file}", '"' + fileObj.path + '"'));
38123
isValid = fs.existsSync(fileObj.path);
@@ -45,8 +130,9 @@ export async function isFileValid(fileObj, mimeTypesAllowed, sizeAllowed, extern
45130
if (isValid) {
46131
console.debug("Validation of uploaded file successful: file " + fileObj.path);
47132
}
133+
} catch (e) {
134+
console.error('Error during file validation:', e);
135+
isValid = false;
48136
}
49-
*/
50-
51137
return isValid;
52138
}

models/lib/fileStoreStrategy.js

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,8 +283,52 @@ export class FileStoreStrategyFilesystem extends FileStoreStrategy {
283283
* @return the read stream
284284
*/
285285
getReadStream() {
286-
const ret = fs.createReadStream(this.fileObj.versions[this.versionName].path)
287-
return ret;
286+
const v = this.fileObj.versions[this.versionName] || {};
287+
const originalPath = v.path || '';
288+
const normalized = (originalPath || '').replace(/\\/g, '/');
289+
const isAvatar = normalized.includes('/avatars/') || (this.fileObj.collectionName === 'avatars');
290+
const baseDir = isAvatar ? 'avatars' : 'attachments';
291+
const storageRoot = path.join(process.env.WRITABLE_PATH || process.cwd(), baseDir);
292+
293+
// Build candidate list in priority order
294+
const candidates = [];
295+
// 1) Original as-is (absolute or relative resolved to CWD)
296+
if (originalPath) {
297+
candidates.push(originalPath);
298+
if (!path.isAbsolute(originalPath)) {
299+
candidates.push(path.resolve(process.cwd(), originalPath));
300+
}
301+
}
302+
// 2) Same basename in storageRoot
303+
const baseName = path.basename(normalized || this.fileObj._id || '');
304+
if (baseName) {
305+
candidates.push(path.join(storageRoot, baseName));
306+
}
307+
// 3) Only ObjectID (no extension) in storageRoot
308+
if (this.fileObj && this.fileObj._id) {
309+
candidates.push(path.join(storageRoot, String(this.fileObj._id)));
310+
}
311+
// 4) New strategy naming pattern: <id>-<version>-<name>
312+
if (this.fileObj && this.fileObj._id && this.fileObj.name) {
313+
candidates.push(path.join(storageRoot, `${this.fileObj._id}-${this.versionName}-${this.fileObj.name}`));
314+
}
315+
316+
// Pick first existing candidate
317+
let chosen;
318+
for (const c of candidates) {
319+
try {
320+
if (c && fs.existsSync(c)) {
321+
chosen = c;
322+
break;
323+
}
324+
} catch (_) {}
325+
}
326+
327+
if (!chosen) {
328+
// No existing candidate found
329+
return undefined;
330+
}
331+
return fs.createReadStream(chosen);
288332
}
289333

290334
/** returns a write stream

0 commit comments

Comments
 (0)