mirror of
https://github.com/wekan/wekan.git
synced 2026-11-07 11:02:33 -05:00
Every Hall of Fame vulnerability without a test naming it (24 names, 30 advisories) was checked against the current source. No fix had been reverted. Looking for the same mistake elsewhere found siblings: - FloppyBleed: the fs-path-heal upgrade step joined an attachment's stored name into candidate paths as-is; a record a client inserted named ../../../etc/passwd made the next upgrade copy that file into storage - an arbitrary file read. Only the name's last component is used now; the bulk move limits a legacy name's extension the same way. - FilePathBleed (GHSA-g6vm-7757-pr88): the insert guard looked only inside versions; with versions left out ostrio:files falls back to a top-level path, which removing the record deletes and validation reads. A client's top-level path is refused for attachments and avatars, recorded by the existing canaries. - MoveStorageBleed: validateAttachment, which deletes a file failing the current upload rules, was open to anybody who could see the board, a public one included. Only who may change the file, or a site admin, validates now; a refusal is recorded (authz.attachment-validate). - HookUrlBleed / IntegrationBleed: the REST reads of a board's integrations gave the webhook URL - whose path is its secret - to every member reading the board; non-admins get what the publication gives them. - AttachmentMigrationBleed: four migration methods answered any logged-in user about any board, deleteDuplicateEmptyLists.getStatus with a private board's list titles; they require a visible board and record refusals (authz.migration-board). - LegacyAttachBleed (GHSA-xcxp-hx9w-q5q9) and MimeStorageBleed (GHSA-rv5g-5p4g-3v89): the legacy route judged a name dangerous only for .svg, the response policy missed x-javascript, ecmascript, xsl and +xml types, and the sniffers missed <tag/onx= handlers. OIDCBleed refusals are now recorded in Admin Panel / Problems too. New suites name the vulnerabilities they guard, existing ones name the advisories they already covered, and tests/securityRegressionCoverage counts 128 of 137 published vulnerabilities with a named test (was 125). Thanks to xet7 !
213 lines
9.8 KiB
JavaScript
213 lines
9.8 KiB
JavaScript
'use strict';
|
|
|
|
// Upload-time filename hardening (server). Used by the attachment/avatar upload
|
|
// validation to (1) strip invisible characters from the stored filename, (2) give
|
|
// it a type-based name if that leaves it empty, (3) correct the extension to match
|
|
// the file's MIME type, and (4) reject filenames that look like an exploit.
|
|
//
|
|
// Pure functions (no Meteor) so they are unit-testable; wired in
|
|
// models/fileValidation.js and models/attachments.server.js / avatars.server.js.
|
|
|
|
const mime = require('mime-types');
|
|
const {
|
|
decodeFileNameSafe,
|
|
cleanFileName,
|
|
cleanFileNameUnbounded,
|
|
hasInvisibleChars,
|
|
normalizeConfusables,
|
|
classifyExploitKinds,
|
|
truncateAmigaFileName,
|
|
} = require('/imports/lib/fileNameDisplay');
|
|
|
|
// Maximum number of CHARACTERS (code points, not bytes) allowed in a stored
|
|
// filename. Chosen so the name fits any operating system and filesystem WeKan
|
|
// might be stored on or restored to — including classic Amiga OS FFS, whose
|
|
// filename limit is 30 characters. This is a logical/user-facing cap; the
|
|
// on-disk "<id>-<version>-<name>" component is capped separately in bytes by
|
|
// models/lib/filenameSanitizer.js. Not as tight as DOS 8.3, but portable.
|
|
const MAX_FILENAME_CHARS = 30;
|
|
|
|
// Truncate a filename to at most maxChars code points, preserving the extension
|
|
// and never splitting a Unicode code point. Falls back to clipping the whole
|
|
// name when the extension alone would not fit.
|
|
function truncateFilenameChars(name, maxChars = MAX_FILENAME_CHARS) {
|
|
return truncateAmigaFileName(name, maxChars);
|
|
}
|
|
|
|
// A base display name for a file whose sanitized name became empty, by MIME group.
|
|
function baseNameForMime(mimeType) {
|
|
const t = String(mimeType || '').toLowerCase();
|
|
if (t.startsWith('image/')) return 'image';
|
|
if (t.startsWith('video/')) return 'video';
|
|
if (t.startsWith('audio/')) return 'audio';
|
|
if (/(spreadsheet|ms-excel|excel|csv)/.test(t)) return 'spreadsheet';
|
|
if (/(word|pdf|presentation|powerpoint|opendocument|rtf|document)/.test(t)) return 'document';
|
|
if (t.startsWith('text/')) return 'document';
|
|
if (/(zip|tar|compressed|archive|rar|7z|gzip|bzip)/.test(t)) return 'archive';
|
|
return 'file';
|
|
}
|
|
|
|
// A MIME type that says NOTHING about the file. `application/octet-stream` is
|
|
// what a browser sends for a type it does not know and what `file` reports for
|
|
// anything it cannot identify - it is the absence of an answer, not an answer.
|
|
//
|
|
// #6589: a .drawio upload came in as application/octet-stream, and correcting
|
|
// the extension "to the type" turned it into "sso-proconnect-keycloak.drawio.bin"
|
|
// - unopenable, and the rename that would have repaired it failed too. Anything
|
|
// unrecognised - .drawio, .kdbx, .ova, a new format, an internal one - was
|
|
// renamed to .bin the same way. mime.extension('application/octet-stream') is
|
|
// 'bin', and that is the only reason.
|
|
const UNINFORMATIVE_MIME = new Set([
|
|
'application/octet-stream',
|
|
'binary/octet-stream',
|
|
'application/binary',
|
|
'application/x-binary',
|
|
'application/unknown',
|
|
'*/*',
|
|
]);
|
|
|
|
function mimeSaysNothing(mimeType) {
|
|
const t = String(mimeType || '').toLowerCase().trim();
|
|
return !t || UNINFORMATIVE_MIME.has(t);
|
|
}
|
|
|
|
// The correct extension (with leading dot) for a MIME type, or '' if unknown.
|
|
function extensionForMime(mimeType) {
|
|
if (mimeSaysNothing(mimeType)) return '';
|
|
const ext = mime.extension(String(mimeType || '').toLowerCase());
|
|
return ext ? '.' + ext : '';
|
|
}
|
|
|
|
// True when the filename looks like an attempted exploit: HTML/script markup, an
|
|
// XML doctype/entity (billion-laughs), a template-injection payload, php/asp tags,
|
|
// a javascript:/data: URI, an inline event handler, a null byte, or path traversal.
|
|
function filenameLooksLikeExploit(name) {
|
|
const n = decodeFileNameSafe(String(name == null ? '' : name));
|
|
if (/<\s*(script|svg|iframe|object|embed|img|html|meta|link|style|foreignobject|base)\b/i.test(n)) return true;
|
|
if (/<\?xml|<!doctype|<!entity/i.test(n)) return true; // XML billion-laughs / doctype
|
|
if (/javascript:|vbscript:|data:\s*text\/html/i.test(n)) return true;
|
|
if (/\son\w+\s*=/i.test(n) || /^on\w+\s*=/i.test(n)) return true; // onerror= etc.
|
|
// MimeStorageBleed (GHSA-rv5g-5p4g-3v89): `<a/onclick=...>` - a slash, not
|
|
// whitespace, between the tag name and the handler.
|
|
if (/<[^>]*\/on[a-z-]{1,40}\s*=/i.test(n)) return true;
|
|
if (/\{\{[\s\S]*\}\}|\$\{[\s\S]*\}|<%[\s\S]*%>/.test(n)) return true; // template injection
|
|
if (/<\?php|<\?=/i.test(n)) return true;
|
|
if (/\u0000/.test(n)) return true; // null byte
|
|
if (/(^|[\\/])\.\.([\\/]|$)/.test(n)) return true; // path traversal
|
|
return false;
|
|
}
|
|
|
|
// The name to STORE at upload: URL-decoded, invisible characters removed; if empty
|
|
// a name from the MIME type; and the extension corrected to the MIME type — e.g. a
|
|
// PNG named "foo" or "foo.txt" becomes "foo.png"; ".jpg" for image/jpeg is kept.
|
|
function sanitizeUploadFileName(name, mimeType) {
|
|
// cleanFileName: URL-decode, fold confusable homoglyphs (NFKC + typosquat
|
|
// characters), remove invisible characters, strip any exploit markup, collapse
|
|
// whitespace. This is the same normalization used to DISPLAY names, so the
|
|
// stored name matches what users see.
|
|
let n = cleanFileName(name);
|
|
let wantExt = extensionForMime(mimeType);
|
|
const wantMime = String(mimeType || '').toLowerCase();
|
|
const originalExt = /(?:^|[^.])(\.[^.]+)$/.exec(n);
|
|
// Online Gantt stores JSON under its application-owned .gantt extension.
|
|
// A generic JSON detector describes the serialization, not the program that
|
|
// opens it, so replacing .gantt with .json would recreate the exact
|
|
// double-click failure this subsystem prevents.
|
|
if (wantMime === 'application/json'
|
|
&& originalExt && originalExt[1].toLowerCase() === '.gantt') {
|
|
wantExt = '.gantt';
|
|
}
|
|
|
|
if (!n) {
|
|
return truncateFilenameChars(baseNameForMime(mimeType) + wantExt);
|
|
}
|
|
if (wantExt) {
|
|
const dot = n.lastIndexOf('.');
|
|
const curExt = dot > 0 ? n.slice(dot).toLowerCase() : '';
|
|
const curMime = mime.lookup(n); // false when there is no recognized extension
|
|
// JFIF is JPEG content, but many desktop file associations (and the
|
|
// mime-types database used here) do not recognize .jfif. Use the portable
|
|
// canonical extension instead of producing "photo.jfif.jpeg".
|
|
if (curExt === wantExt) {
|
|
// Custom/application-owned extensions may not exist in mime-types, but a
|
|
// content-aware override above has already established the right suffix.
|
|
} else if (wantMime === 'image/jpeg' && curExt === '.jfif') {
|
|
n = n.slice(0, dot) + wantExt;
|
|
} else if (curMime === wantMime) {
|
|
// extension already matches the type (handles .jpg vs .jpeg) — keep it
|
|
} else if (curMime) {
|
|
// recognized but WRONG extension -> replace it
|
|
n = n.slice(0, n.lastIndexOf('.')) + wantExt;
|
|
} else {
|
|
// no recognized extension -> append the correct one
|
|
n = n + wantExt;
|
|
}
|
|
}
|
|
// Cap the logical length last, so the extension is preserved within the budget.
|
|
return truncateFilenameChars(n);
|
|
}
|
|
|
|
// Append an increasing "-N" before the extension to disambiguate two DIFFERENT
|
|
// files that would otherwise share a name (e.g. during a migration where two
|
|
// stored files both sanitize to "document.pdf" but differ in size/date). index 0
|
|
// returns the name unchanged; 1 -> "document-1.pdf", 2 -> "document-2.pdf", ...
|
|
// The result is length-capped like every other stored name.
|
|
function numberedName(name, index) {
|
|
if (!index) return truncateFilenameChars(String(name == null ? '' : name));
|
|
const s = String(name == null ? '' : name);
|
|
const dot = s.lastIndexOf('.');
|
|
const ext = dot > 0 ? s.slice(dot) : '';
|
|
const base = dot > 0 ? s.slice(0, dot) : s;
|
|
return truncateFilenameChars(base + '-' + index + ext);
|
|
}
|
|
|
|
// Explain, in human-readable terms for the Admin Panel → Problems log, WHY a
|
|
// filename required sanitization: which reasons applied (URL-encoding, invisible
|
|
// characters, typosquatting look-alikes, a specific exploit KIND, the wrong file
|
|
// type/extension, an over-long name, or an empty name). Computed from the ORIGINAL
|
|
// name (+ the type-detected MIME and the corrected result). Returns [] when the
|
|
// name was already clean.
|
|
function sanitizationReasons(originalName, mimeType, correctedName) {
|
|
const reasons = [];
|
|
const raw = String(originalName == null ? '' : originalName);
|
|
const decoded = decodeFileNameSafe(raw);
|
|
const corrected = String(correctedName == null ? '' : correctedName);
|
|
|
|
if (decoded !== raw) reasons.push('URL-encoded name');
|
|
// Named in its own right, from the DECODED name, so a canary can tell a
|
|
// traversal from an ordinary percent-encoded (Cyrillic, CJK, Finnish) name:
|
|
// "URL-encoded name" alone used to be what flagged %2E%2E%2F.
|
|
if (/(^|[\\/])\.\.([\\/]|$)/.test(decoded)) reasons.push('path traversal');
|
|
if (/\u0000/.test(decoded)) reasons.push('null byte');
|
|
if (hasInvisibleChars(decoded)) reasons.push('invisible characters');
|
|
if (normalizeConfusables(decoded) !== decoded) reasons.push('typosquatting (look-alike characters)');
|
|
for (const kind of classifyExploitKinds(decoded)) reasons.push('exploit: ' + kind);
|
|
|
|
const cleaned = cleanFileNameUnbounded(decoded);
|
|
if (cleaned === '') {
|
|
reasons.push('empty name, generated from file type');
|
|
} else if (Array.from(cleaned).length > MAX_FILENAME_CHARS) {
|
|
reasons.push('filename too long');
|
|
}
|
|
|
|
const dot = s => { const i = String(s).lastIndexOf('.'); return i > 0 ? String(s).slice(i).toLowerCase() : ''; };
|
|
const oldExt = dot(decoded);
|
|
const newExt = dot(corrected);
|
|
if (oldExt !== newExt) {
|
|
reasons.push('wrong file type (' + (oldExt || '(none)') + ' → ' + (newExt || '(none)') + ')');
|
|
}
|
|
return reasons;
|
|
}
|
|
|
|
module.exports = {
|
|
MAX_FILENAME_CHARS,
|
|
truncateFilenameChars,
|
|
numberedName,
|
|
sanitizationReasons,
|
|
baseNameForMime,
|
|
extensionForMime,
|
|
mimeSaysNothing,
|
|
filenameLooksLikeExploit,
|
|
sanitizeUploadFileName,
|
|
};
|