Upload MIME bypass plus missing nosniff enables stored XSS #63

Closed
opened 2026-07-28 05:57:12 +00:00 by claude-bot · 1 comment

Severity: HIGH

The bug

Three weaknesses compose into script execution in the application's own origin.

1. MIME validation is skipped when the client omits the header.
backend/app/api/routes/ingest.py:34-41:

if file.content_type and not any(file.content_type.startswith(p) for p in ALLOWED_MIME_PREFIXES):
    raise HTTPException(415, ...)

If the multipart part carries no Content-Type, file.content_type is falsy and the check is
skipped entirely. Nothing inspects magic bytes; nothing calls Image.verify().

2. The stored MIME type comes from the filename extension, not the content
(backend/app/services/ingest.py:171-179, _guess_mime). So the attacker independently
controls the bytes and the recorded type.

3. Media is served with no protective headers. backend/app/api/routes/photos.py:178,191
return FileResponse(path, media_type=photo.mime_type). Verified against the installed Starlette
source: Content-Disposition is set only when a filename argument is passed (it is not), and
nosniff appears nowhere in the package. There is no CSP anywhere in the app.

Attack

A reviewer uploads a part named payload.jpg with no Content-Type and a body containing
SVG or HTML with an inline <script>. Validation is skipped; _guess_mime records
image/jpeg. Another user opens the photo, or follows a direct link to
/api/photos/{id}/media/front. With no nosniff, a browser can render it as HTML/SVG and
execute script in the app's origin — inheriting the victim's session, and able to call the
ingest endpoint to reach the arbitrary-write RCE.

"Another user opens the photo I uploaded" is the normal collaborative workflow, not an unusual
lure.

Fix

  • Make the MIME check unconditional — treat an absent Content-Type as invalid.
  • Validate magic bytes and confirm decodability with Image.open(...).verify(), then derive the
    stored mime_type from the verified format, never the filename.
  • Serve media with X-Content-Type-Options: nosniff and an explicit allowlisted media_type,
    plus Content-Disposition as appropriate.
  • Add a restrictive CSP: default-src 'self'; script-src 'self'; object-src 'none'.

Done when

  • Uploads with a missing or non-image content type are rejected
  • Stored mime_type is derived from verified content
  • Media responses carry nosniff and a safe content type
  • A CSP is set on all responses
  • Tests cover the missing-header bypass and an HTML-bearing "image"

References

  • backend/app/api/routes/ingest.py:34-41
  • backend/app/services/ingest.py:171-179
  • backend/app/api/routes/photos.py:178,191
## Severity: HIGH ## The bug Three weaknesses compose into script execution in the application's own origin. **1. MIME validation is skipped when the client omits the header.** `backend/app/api/routes/ingest.py:34-41`: ```python if file.content_type and not any(file.content_type.startswith(p) for p in ALLOWED_MIME_PREFIXES): raise HTTPException(415, ...) ``` If the multipart part carries no `Content-Type`, `file.content_type` is falsy and the check is skipped entirely. Nothing inspects magic bytes; nothing calls `Image.verify()`. **2. The stored MIME type comes from the filename extension**, not the content (`backend/app/services/ingest.py:171-179`, `_guess_mime`). So the attacker independently controls the bytes and the recorded type. **3. Media is served with no protective headers.** `backend/app/api/routes/photos.py:178,191` return `FileResponse(path, media_type=photo.mime_type)`. Verified against the installed Starlette source: `Content-Disposition` is set only when a `filename` argument is passed (it is not), and `nosniff` appears nowhere in the package. There is no CSP anywhere in the app. ## Attack A reviewer uploads a part named `payload.jpg` with **no** `Content-Type` and a body containing SVG or HTML with an inline `<script>`. Validation is skipped; `_guess_mime` records `image/jpeg`. Another user opens the photo, or follows a direct link to `/api/photos/{id}/media/front`. With no `nosniff`, a browser can render it as HTML/SVG and execute script **in the app's origin** — inheriting the victim's session, and able to call the ingest endpoint to reach the arbitrary-write RCE. "Another user opens the photo I uploaded" is the normal collaborative workflow, not an unusual lure. ## Fix - Make the MIME check unconditional — treat an absent `Content-Type` as invalid. - Validate magic bytes and confirm decodability with `Image.open(...).verify()`, then derive the stored `mime_type` from the **verified** format, never the filename. - Serve media with `X-Content-Type-Options: nosniff` and an explicit allowlisted `media_type`, plus `Content-Disposition` as appropriate. - Add a restrictive CSP: `default-src 'self'; script-src 'self'; object-src 'none'`. ## Done when - [ ] Uploads with a missing or non-image content type are rejected - [ ] Stored `mime_type` is derived from verified content - [ ] Media responses carry `nosniff` and a safe content type - [ ] A CSP is set on all responses - [ ] Tests cover the missing-header bypass and an HTML-bearing "image" ## References - `backend/app/api/routes/ingest.py:34-41` - `backend/app/services/ingest.py:171-179` - `backend/app/api/routes/photos.py:178,191`
claude-bot added this to the v0.1.1 milestone 2026-07-28 05:57:12 +00:00
Author

Fixed in 791d117. CI green.

All three weaknesses in the chain:

1. Validation was opt-out. if file.content_type and ... meant omitting the header on the
multipart part skipped the check entirely. An absent declaration is now treated as invalid rather
than as permission.

2. Content is verified, not claimed. New app/services/image_validation.py opens the file with
Pillow, calls verify() (structural integrity, without running the CVE-prone full decode), and
maps the reported format to the MIME type that gets stored. SVG is refused because Pillow does not
decode it. The check lives in the ingest service, not just the route, so a future
watch-directory worker gets the same guarantee.

3. Media serving no longer reflects a stored string. Responses carry nosniff and an
allowlisted content type; anything outside {image/jpeg, image/png, image/tiff} is served as
application/octet-stream with Content-Disposition: attachment. Plus an app-wide CSP with
script-src 'self' and object-src 'none'.

Tested with real Pillow-generated images and with the actual attack payloads — HTML, SVG-with-script,
a shell script, and an empty file, all named payload.jpg. Also asserts a PNG named .jpg is stored
as image/png, proving the type comes from content rather than extension.

**Fixed** in 791d117. CI green. All three weaknesses in the chain: **1. Validation was opt-out.** `if file.content_type and ...` meant omitting the header on the multipart part skipped the check entirely. An absent declaration is now treated as invalid rather than as permission. **2. Content is verified, not claimed.** New `app/services/image_validation.py` opens the file with Pillow, calls `verify()` (structural integrity, without running the CVE-prone full decode), and maps the *reported format* to the MIME type that gets stored. SVG is refused because Pillow does not decode it. The check lives in the **ingest service**, not just the route, so a future watch-directory worker gets the same guarantee. **3. Media serving no longer reflects a stored string.** Responses carry `nosniff` and an allowlisted content type; anything outside `{image/jpeg, image/png, image/tiff}` is served as `application/octet-stream` with `Content-Disposition: attachment`. Plus an app-wide CSP with `script-src 'self'` and `object-src 'none'`. Tested with real Pillow-generated images and with the actual attack payloads — HTML, SVG-with-script, a shell script, and an empty file, all named `payload.jpg`. Also asserts a PNG named `.jpg` is stored as `image/png`, proving the type comes from content rather than extension.
Sign in to join this conversation.
No description provided.