[Security][High] Uploaded file extension is attacker-controlled → stored XSS #11

Closed
opened 2026-07-02 01:20:45 +00:00 by wtclaude · 0 comments
Member

Severity: High · Type: Security / stored XSS

Problem

In server/src/router/v1/admin/admin.routes.js:24-27 the multer filename keeps path.extname(file.originalname). The fileFilter (lines 32-35) only checks file.mimetype, which is the client-supplied multipart Content-Type and is spoofable.

So an attacker can send Content-Type: image/png (passes the filter) with originalname: x.html. The file is saved as <ts>-<rand>.html and served from /uploads/ by express.static, which sets Content-Type: text/html from the extension. With CSP disabled (app.js:26) and crossOriginResourcePolicy: cross-origin, that is same-origin stored HTML/JS execution.

Impact

Stored XSS on the site's own origin (cookie theft / admin session hijack). Requires an authenticated uploader — but note that with the missing role checks (see the authz issue) an editor can upload, and even an admin account is at risk if the token leaks.

Suggested fix

Derive the stored extension from the validated mimetype, never from originalname, and ideally validate magic bytes:

const crypto = require('crypto')
const EXT = { 'image/png':'.png','image/jpeg':'.jpg','image/gif':'.gif','image/webp':'.webp','image/avif':'.avif' }
filename: (req, file, cb) =>
  cb(null, `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${EXT[file.mimetype] || ''}`)

Also add X-Content-Type-Options: nosniff (and consider Content-Disposition: attachment or a restrictive CSP) when serving /uploads.

**Severity:** High · **Type:** Security / stored XSS ## Problem In `server/src/router/v1/admin/admin.routes.js:24-27` the multer `filename` keeps `path.extname(file.originalname)`. The `fileFilter` (lines 32-35) only checks `file.mimetype`, which is the client-supplied multipart Content-Type and is **spoofable**. So an attacker can send `Content-Type: image/png` (passes the filter) with `originalname: x.html`. The file is saved as `<ts>-<rand>.html` and served from `/uploads/` by `express.static`, which sets `Content-Type: text/html` from the extension. With CSP disabled (`app.js:26`) and `crossOriginResourcePolicy: cross-origin`, that is **same-origin stored HTML/JS execution**. ## Impact Stored XSS on the site's own origin (cookie theft / admin session hijack). Requires an authenticated uploader — but note that with the missing role checks (see the authz issue) an `editor` can upload, and even an admin account is at risk if the token leaks. ## Suggested fix Derive the stored extension from the validated mimetype, never from `originalname`, and ideally validate magic bytes: ```js const crypto = require('crypto') const EXT = { 'image/png':'.png','image/jpeg':'.jpg','image/gif':'.gif','image/webp':'.webp','image/avif':'.avif' } filename: (req, file, cb) => cb(null, `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${EXT[file.mimetype] || ''}`) ``` Also add `X-Content-Type-Options: nosniff` (and consider `Content-Disposition: attachment` or a restrictive CSP) when serving `/uploads`.
Sign in to join this conversation.
No description provided.