Build1 publisher2 min readPublished
An <object> tag turns an uploaded SVG into stored XSS for every visitor
OopsSec Store, a deliberately vulnerable Next.js app, lets an admin-uploaded SVG run JavaScript in every visitor's browser. It works because the server trusts the client-set Content-Type and the product page renders SVGs through an <object> tag.
The Engineer · Build desk

What happened
- OopsSec Store's admin product editor accepts image uploads, and SVG is one of the allowed formats.
- An SVG is XML, so it can carry a script tag, and that script runs in the browser of anyone who views the product.
- The upload endpoint validates only file.type, which is the Content-Type header the client fully controls.
- The page renders .svg files through an object tag, which runs embedded scripts, while an img tag would render the image and block them.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint An upload gate that trusts a client-supplied Content-Type validates nothing, because the uploader writes that header; the check has to read the file's actual bytes.
- exposure The same file executes at its direct /api/uploads URL and in the admin preview, so hardening only the product page leaves the payload reachable.
- decision The choice between object and img decides whether an uploaded SVG can run scripts at all, so it is a security decision on any page that renders user SVGs.
- precedent The writeup treats content inspection, server-side sanitizing and restrictive response headers as expected layers on user uploads, even though one of them suffices here.
Two lines of ordinary-looking code carry the whole vulnerability. Start with the upload check. OopsSec Store validates an upload by comparing `file.type` against an allowlist, and `file.type` is just the Content-Type header the client put in the request [3]. The server reads no magic bytes and scans no content, so it accepts whatever the request claims the file to be [4]. The allowlist includes `image/svg+xml` [5]. A script-bearing SVG that declares itself image/svg+xml passes the check.
Now the render. For any image whose URL ends in `.svg`, the product page emits an `<object>` element pointing at the file, with a plain `<img>` as fallback inside it [6]. An `<object>` loads the SVG as a full document and runs any script it contains; an `<img>` would paint the same SVG and refuse to run the script [7].
Because the file is served as a document, the script runs in more than one place. It fires on the product page at /products/[id], in the admin preview, and at the raw file URL /api/uploads/[filename].svg [8]. That last location matters for anyone writing the fix. Patching the React component protects the component; it does nothing for a request that opens the file URL directly.
The proof-of-concept is a 100-by-100 SVG filled `#4ade80` with a `<script>` block that calls `alert('XSS executed!')` [9]. It renders as an ordinary green square. It also runs whatever the author put in the script tag, for every visitor who loads the page [2].
One caveat before anyone reads this as a live incident. OopsSec Store is a deliberately vulnerable app you run locally and exploit for a flag, and the upload step needs admin access, which the writeup reaches through two separate attack chains covered first [10][11].
The recommended fix has three parts, and the writeup's own note is that any one of them stops this exploit while defense in depth is the reason to apply all three [12]. Read the bytes first: the `file-type` library detects the real MIME from the buffer, and with a safe list of JPEG, PNG, GIF and WebP, SVG is absent, so the upload is rejected [13]. If the app genuinely needs SVG, sanitize it server-side with DOMPurify before saving, which strips `<script>` tags and event handlers [14]. Then serve uploads with `Content-Security-Policy: script-src 'none'` and `X-Content-Type-Options: nosniff`, which blocks script execution and stops the browser guessing a different content type [15].
What to watch
- Whether OopsSec Store ships the file-type, DOMPurify and CSP fixes so the upload gate stops trusting the client-set Content-Type.
- Whether other Next.js templates that accept SVG uploads render them through <object> rather than <img>.
- Whether the suggested DOMPurify SVG profile leaves enough SVG features usable for real product artwork.