Disclosure · Arbitrary writes in the cloud
When the client picks where to store: arbitrary uploads to a bucket
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 sent | Signed key |
|---|---|
<carpeta> | <carpeta>/<ts>-test.html |
compressed/<another-user-listing> | another seller's folder |
<folder>/<someone-else-item> | someone else's item folder |
../../static | signed 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
PUTwas 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:
- Decide the prefix. Drop
bucketPathfrom the contract, or map it server-side to a closed enum based on the resource and the session. - Verify ownership of the resource before signing.
- Restrict
Content-Typeto images and videos; rejecttext/htmlandimage/svg+xml. - Sanitize the filename and serve user uploads from a
separate domain with
Content-Disposition: attachment.