Derive uploaded file extension from mimetype, not originalname (fixes #11) #18
Reference in New Issue
Block a user
No description provided.
Delete Branch "fix/upload-extension-xss"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Fixes #11. The multer
filenamecallback stored the file underpath.extname(file.originalname), while thefileFilteronly checkedfile.mimetype— the client-supplied multipartContent-Type, which is spoofable.An authenticated uploader could therefore send
Content-Type: image/png(passes the filter) withoriginalname: x.html. The file was saved as<ts>-<rand>.htmland served from/uploads/byexpress.static, which setsContent-Type: text/htmlfrom the extension. With CSP disabled andcrossOriginResourcePolicy: cross-origin, that is same-origin stored HTML/JS execution — cookie theft / admin session hijack.What changed
server/src/router/v1/admin/admin.routes.jsMIME_EXTwhitelist mapping each accepted image mimetype to the extension the file is stored under.filenamenow derives the extension fromMIME_EXT[file.mimetype], never fromoriginalname. The random component usescrypto.randomBytes(8)instead ofMath.random().fileFilternow keys off the sameMIME_EXTmap, so the filter and the stored extension share a single source of truth — only a mimetype we can map to a safe extension is accepted.server/src/app.js/uploadsis now served with an explicitX-Content-Type-Options: nosniffviaexpress.static(..., { setHeaders }).Why this approach
originalname— this is the core fix. Even though the mimetype itself is spoofable, the worst an attacker can achieve is picking which image extension in the whitelist their file is stored as. There is no path to an.html/.svg/.jsextension, soexpress.staticcan never serve the file as an executable type. This neutralizes the vulnerability at the point where the dangerous value (the extension) is chosen.MIME_EXTsource of truth — folding the filter and the extension mapping into one object removes the mismatch that made the original code fragile (filter regex vs.originalnameextension) and guarantees every stored file has a known-safe extension. An unmapped mimetype is rejected outright rather than being stored with an empty extension.crypto.randomBytesoverMath.random()—Math.random()is not cryptographically random; predictable upload names are a (minor) information-leak / overwrite risk.randomBytes(8)is the standard fix and matches the issue's suggestion.nosniffon/uploads(defense in depth) — helmet already setsX-Content-Type-Options: nosniffglobally, so this is redundant today, but setting it directly on the untrusted-file handler keeps the protection in place even if the global helmet config is later changed, and documents intent at the point it matters. I did not addContent-Disposition: attachment, since uploaded images are embedded inline in posts/wiki via<img>and forcing downloads would be a behavior regression; the extension fix already closes the execution vector.Testing
node -csyntax check on both changed files.image/png+originalname x.htmlnow stores<ts>-<hex>.png; a non-image mimetype is rejected with 400; legitimate png/jpeg/gif/webp/avif uploads store with their correct extension.Notes
Backend only, no schema or client changes. Existing upload API responses (
{ image_url }/{ url }) are unchanged. Branched from currentmain.