Files
wekan/models/lib/uploadFileName.js
Lauri Ojansivu ebcd02be6f Hall of Fame regression audit: every fixed vulnerability still fixed, siblings of six of them closed.
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 !
2026-10-27 16:28:12 +02:00

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,
};