Upload MIME bypass plus missing nosniff enables stored XSS #63
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 the multipart part carries no
Content-Type,file.content_typeis falsy and the check isskipped 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 independentlycontrols the bytes and the recorded type.
3. Media is served with no protective headers.
backend/app/api/routes/photos.py:178,191return
FileResponse(path, media_type=photo.mime_type). Verified against the installed Starlettesource:
Content-Dispositionis set only when afilenameargument is passed (it is not), andnosniffappears nowhere in the package. There is no CSP anywhere in the app.Attack
A reviewer uploads a part named
payload.jpgwith noContent-Typeand a body containingSVG or HTML with an inline
<script>. Validation is skipped;_guess_mimerecordsimage/jpeg. Another user opens the photo, or follows a direct link to/api/photos/{id}/media/front. With nonosniff, a browser can render it as HTML/SVG andexecute 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
Content-Typeas invalid.Image.open(...).verify(), then derive thestored
mime_typefrom the verified format, never the filename.X-Content-Type-Options: nosniffand an explicit allowlistedmedia_type,plus
Content-Dispositionas appropriate.default-src 'self'; script-src 'self'; object-src 'none'.Done when
mime_typeis derived from verified contentnosniffand a safe content typeReferences
backend/app/api/routes/ingest.py:34-41backend/app/services/ingest.py:171-179backend/app/api/routes/photos.py:178,191Fixed 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 themultipart 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.pyopens the file withPillow, calls
verify()(structural integrity, without running the CVE-prone full decode), andmaps 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
nosniffand anallowlisted content type; anything outside
{image/jpeg, image/png, image/tiff}is served asapplication/octet-streamwithContent-Disposition: attachment. Plus an app-wide CSP withscript-src 'self'andobject-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.jpgis storedas
image/png, proving the type comes from content rather than extension.