Derive uploaded file extension from mimetype, not originalname (#11)

The multer filename kept path.extname(file.originalname), while the
fileFilter only checked the spoofable client-supplied mimetype. An
attacker could send Content-Type: image/png with originalname x.html,
landing an .html file in /uploads that express.static serves as
text/html — same-origin stored XSS.

- Store the extension from a whitelist keyed by the accepted mimetype
  (MIME_EXT), never from originalname. The fileFilter uses the same map
  as its single source of truth, so only mimetypes with a safe mapped
  extension pass.
- Use crypto.randomBytes for the random filename component.
- Serve /uploads with an explicit X-Content-Type-Options: nosniff
  (defense in depth alongside helmet's global setting).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 21:41:56 -05:00
parent d89cc7e691
commit e84835a0fb
2 changed files with 27 additions and 5 deletions

View File

@@ -51,8 +51,16 @@ const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(SERVER_ROOT, 'uploads')
const CLIENT_DIST = path.join(REPO_ROOT, 'client', 'dist')
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
// Uploaded images — always served, even during maintenance.
app.use('/uploads', express.static(UPLOAD_DIR))
// Uploaded images — always served, even during maintenance. Force nosniff so a
// stored file is never interpreted as anything other than its declared type
// (defense in depth alongside helmet's global X-Content-Type-Options, and in
// case that global config is ever changed).
app.use(
'/uploads',
express.static(UPLOAD_DIR, {
setHeaders: (res) => res.set('X-Content-Type-Options', 'nosniff'),
}),
)
// ── API ───────────────────────────────────────────────────────────────
app.get('/api/health', (req, res) => res.json({ status: 'ok' }))