Disclosure · Arbitrary writes in the cloud

When the client picks where to store: arbitrary uploads to a bucket

Leer en español

A client had a design flaw that shows up again and again in any site that lets users upload files: who decides where the file gets stored? If the answer is "the browser", anyone can write where they shouldn't.

The entry point

The frontend uploaded images to a cloud storage bucket, as usual, using presigned URLs: the browser asked the backend for a time-limited upload URL and then issued a PUT straight to the bucket. The endpoint was:

POST https://www.<domain>/api/upload-media/get-presigned-urls
Content-Type: application/json

{"fileNames":["photo.jpg"],"contentTypes":["image/jpeg"],"bucketPath":"..."}

There's the catch: the client sent bucketPath, the destination folder. The server used it verbatim to build the object key, validating nothing: not that the folder existed, not that it belonged to the user, not even that it lacked ../.

What the server kept signing

With my own account (free sign-up) the server signed whatever path I asked for:

bucketPath sentSigned key
<carpeta><carpeta>/<ts>-test.html
compressed/<another-user-listing>another seller's folder
<folder>/<someone-else-item>someone else's item folder
../../staticsigned without sanitizing

It didn't restrict Content-Type either: I could request a signature for text/html. Listing IDs are public (they appear on the site itself), so targetting third-party folders required no guessing.

The proof of concept

The test was deliberately minimal and non-destructive: a single new 96-byte HTML file, at a key that didn't exist, overwriting nothing.

# 1) Request a presigned URL for an arbitrary folder as text/html
curl -sk -b cookies.txt -H "Content-Type: application/json" \
  -d '{"fileNames":["test.html"],"contentTypes":["text/html"],"bucketPath":"<carpeta>"}' \
  "https://www.<domain>/api/upload-media/get-presigned-urls"
# -> [{ "url": "https://<bucket>.s3.../<carpeta>/...-test.html?...", ... }]

# 2) Upload the file
curl -sk -X PUT -H "Content-Type: text/html" --data-binary @poc.html "PRESIGNED_URL"
# -> HTTP 200

# 3) Check how it is served
curl -sk -D - "https://assets.<domain>/<carpeta>/...-test.html"
# -> HTTP/2 200
#    content-type: text/html        <-- rendered as a page

The file was served as executable HTML from the assets subdomain, the same one that serves all the site's images.

Real impact

  • Phishing on the official brand: a login clone served from the legitimate domain, with a valid certificate, is indistinguishable.
  • Injection into others' folders: planting files in other users' listings or profiles (public IDs).
  • Hosting arbitrary content (malware, fake invoices, fraud QR codes) under brand-looking URLs.
  • Cost abuse: mass uploads billed to the bucket and CDN.

What it did not allow

A good report never inflates impact. I verified the real limits:

  • Existing objects couldn't be overwritten: the server prepended a <timestamp>- to the key, and the bucket had versioning on.
  • No session theft via XSS: cookies were host-only and HttpOnly, on a different origin than the assets domain.
  • Only PUT was signed: no read, delete or list.
  • The compressed/ folder went through a re-encoding pipeline that neutralized HTML (that folder only).

How to fix it

There is a single root cause: the client must not choose the path. The server should:

  1. Decide the prefix. Drop bucketPath from the contract, or map it server-side to a closed enum based on the resource and the session.
  2. Verify ownership of the resource before signing.
  3. Restrict Content-Type to images and videos; reject text/html and image/svg+xml.
  4. Sanitize the filename and serve user uploads from a separate domain with Content-Disposition: attachment.
← Back to Security