Stored XXE/SSRF/XSS via SVG upload sanitization bypass
GHSA-9c7h-xjrp-8gh3 · CVE assignment pending (GitHub CNA)
Summary
Both upload routes — the standalone POST /api/v1/upload and the library POST /api/v1/files/upload — ran the SVG sanitizer only after the image validator had decoded the file:
// apps/api/src/routes/files.ts:117 (same shape at user-files.ts:337)
const safeBuffer = isValidImage && isSvgBuffer(buffer)
? sanitizeSvg(buffer)
: buffer;
Because the routes accept any file type, an SVG that librsvg (via Sharp) cannot parse is stored anyway: a DOCTYPE declaring an external entity makes the parser fail, isValidImage is false, and the && short-circuit skips sanitizeSvg() entirely. The raw file is later served as image/svg+xml with its DOCTYPE, entity declarations, and any scriptable markup intact.
Two related weaknesses inside the sanitizer itself:
- It stripped only unprefixed dangerous elements, so a namespace-prefixed
survived even on SVGs the decoder accepted. - Its DOCTYPE-removal regex broke when a
]appeared inside a quoted entity value.
Impact
- Stored XSS — an authenticated user who can upload files stores an SVG carrying active markup; a viewer who opens the stored SVG directly in a browser (or any surface that renders it inline) runs the embedded script.
- Unauthenticated retrieval — the staged-upload download URL
/api/v1/download/:jobId/:filenamerequires no authentication, so anyone with the job id can fetch the file. - Attack vector: network — upload as authenticated user, then victim interaction.
- The chained file-read and request-forgery vectors from the original report did not reproduce in the shipped app: librsvg refuses to resolve the external entity (that refusal is exactly why validation fails), the tool routes reject invalid SVGs before processing, downloads carry
Content-Disposition: attachment+X-Content-Type-Options: nosniff, and the response CSP allows no inline script — so a stored SVG does not execute as a document served from the app origin.
Root Cause
Sanitization was coupled to successful image validation instead of running on every SVG-bearing upload. The fix inverts the responsibility: both upload routes now sanitize SVG content regardless of whether the image decoder accepts the file, the sanitizer strips namespace-prefixed dangerous elements, and SVG detection plus DOCTYPE removal handle a prolog and quoted values.
Proof of Concept
Crafted SVG whose DOCTYPE makes the decoder fail, bypassing the sanitizer:
]>
The reproduction used four payloads — xss-event.svg (onload execution), xss-cookie.svg (cookie exfiltration), xxe-file-read.svg and ssrf-metadata.svg (the two vectors later ruled out by the advisory impact analysis) — uploaded with an authenticated session, then fetched via the unauthenticated download URL and opened in a browser to confirm script execution.
Proof-of-Concept Demos
Full exploit walkthrough (1:41)
End-to-end exploitation: craft malicious URL payload, trigger via API, demonstrate JavaScript execution in victim context, exfiltrate session token, achieve account takeover.
Minimal XSS payload execution (7 s)
Short clip showing the bare XSS payload javascript:alert(1) executing in the response context — confirms the reflection point without the full exploit chain.
Videos: SnapOtter SVG sanitization bypass (GHSA-9c7h-xjrp-8gh3). Full demos on PoC Demos page.
Disclosure Timeline
| Date | Event |
|---|---|
| 2026-09-11 | Vulnerability discovered during authorized audit |
| 2026-09-12 | PoC and demo videos created; report sent to contact@snapotter.com |
| 2026-09-14 | No acknowledgment after 48h — follow-up sent |
| 2026-09-19 | Maintainer confirmed the vulnerability, classified it High, credit to xaferima |
| 2026-09-20 | Downgrade to HIGH accepted (librsvg blocks XXE); CVE to be requested via GitHub CNA |
| 2026-11-03 | Advisory published — GHSA-9c7h-xjrp-8gh3 — and fixed in SnapOtter v2.3.0 |
| Pending | CVE assignment via GitHub CNA |
Fix
Sanitize SVG uploads unconditionally — independent of the image validator — strip namespace-prefixed dangerous elements, and harden SVG detection and DOCTYPE removal:
// Both upload routes, after the fix:
const safeBuffer = isSvgBuffer(buffer) ? sanitizeSvg(buffer) : buffer;
Fixed in SnapOtter v2.3.0 (2026-11-03, also the project's one-million-pull thank-you release, which credits this report in the release notes). Advisory published 2026-11-03; CVE assignment pending.