Presigned S3 upload policies are supposed to be one of the safer ways to let users upload files straight to a bucket without routing the bytes through your own servers. Most of the time they are — right up until the policy generator forgets to actually restrict what's being uploaded.
That's what this post is about: a SaaS platform's presigned-upload endpoint returned a policy that let the client set an arbitrary Content-Type and Content-Disposition, on an object served with acl: public-read. That combination is enough to turn a "upload your company logo" field into a way to host stored XSS on the platform's own official CDN domain — no authentication required for anyone who clicks the resulting link.
A note before anything else: the vulnerability described here is still unpatched and has not been publicly disclosed. It was reported through the platform's official VDP with no permission to publish target details, so the company name, domain, bucket name, exact endpoint paths and any account identifiers are redacted or replaced with placeholders throughout. Everything that matters for understanding — and for checking your own upload flows — is left in.
The feature
The platform is a multi-tenant B2B SaaS product. New customers go through a short onboarding flow, and step 3 of that flow asks them to upload a company logo. Like a lot of apps, it doesn't proxy the upload through the app server — it asks the backend for a presigned S3 POST policy first, then uploads the file directly to S3 from the browser.
Anything that hands a client the ability to write directly into your infrastructure is worth a close look, so before uploading anything real I just requested the policy and read it.
The policy that didn't restrict anything
The presigning endpoint (I'll call it POST /api/uploads/presign — the real path is redacted) takes a JSON body describing the intended upload and returns a standard S3 POST policy: a target bucket URL, a set of form fields to submit, and a signature over the whole thing.
The two conditions that matter are these:
{
"conditions": [
["starts-with", "$Content-Type", ""],
["starts-with", "$Content-Disposition", ""],
{ "acl": "public-read" },
{ "bucket": "<redacted-bucket>" },
{ "key": "<company-id>/onboarding/<hash>/image/png/<filename>_<uuid>.png" }
]
}
["starts-with", "$Content-Type", ""] looks like a restriction, but an empty-string prefix matches any value — it isn't a whitelist, it's a no-op. Same for Content-Disposition. The client-side form only ever sends image/png and attachment by default, so nothing about normal usage would ever surface this. But the policy itself puts no constraint on either field, and Content-Type/Content-Disposition are exactly the two headers that decide whether a browser downloads a file or renders it inline.
On top of that, acl: public-read means whatever gets uploaded is served with zero authentication, straight from the platform's own CDN domain.
Put those two things together and you get: attacker-controlled MIME type, attacker-controlled disposition, publicly served, on a domain the platform's own users (and their employees, and anyone they share a link with) already trust.
How it worked
- Request a legitimate presigned policy, authenticated as a normal, freshly-signed-up account — no elevated privileges of any kind, just the default role every new signup gets.
POST /api/uploads/presign Content-Type: application/json {"path":"onboarding/","type":"image/png","file_name":"logo.png"} - Read the policy back. The server returns a valid, signed policy — bucket URL, key, ACL, and a
Content-Type/Content-Dispositionpair that looks locked toimage/png/attachment, but isn't enforced by the signature. - Submit the upload directly to S3, keeping every signed field exactly as returned (the signature covers them, so they can't be touched), but overriding the two unrestricted ones:
curl -X POST https://<redacted-bucket>.s3.<region>.amazonaws.com/ \ -F "key=<company-id>/onboarding/<hash>/image/png/logo.png_<uuid>.png" \ -F "Content-Type=text/html" \ -F "Content-Disposition=inline" \ -F "acl=public-read" \ -F "x-amz-algorithm=AWS4-HMAC-SHA256" \ -F "x-amz-credential=<redacted>" \ -F "x-amz-date=<redacted>" \ -F "policy=<redacted>" \ -F "x-amz-signature=<redacted>" \ -F "file=@xss.html;type=text/html"
wherexss.htmlis nothing more than:<script>alert(document.domain)</script> - S3 answers
204 No Content. The upload succeeds, and the object is written with the attacker-chosenContent-TypeandContent-Dispositionintact — the signature only ever validated that the values sent matched what was declared, never that those values were sane. - Visit the resulting URL. The browser fetches
text/htmlwithContent-Disposition: inline, renders it as a page instead of offering a download, and the script runs —alert(document.domain)fires on the platform's own trusted domain, with the victim needing no session, login, or app access at all. The object stayed live and reproducible for as long as I needed it, which is normally a good sign you've found the real bug and not a fluke.
The chain end to end looks like this:
Authenticated low-priv account
→ Request presigned S3 POST policy
→ Policy allows any Content-Type / Content-Disposition
→ Upload HTML payload, override both headers
→ S3 stores object (acl: public-read)
→ Victim opens the link on the trusted CDN domain
→ Browser renders inline → script executes
Confirming the blast radius
One finding on one upload field is worth reporting. Before writing it up, though, it's worth asking the obvious next question: is this endpoint's presigning logic reused anywhere else?
It was — on at least four separate upload surfaces across the product, all generating the same unrestricted policy:
- Company logo upload during onboarding (one-shot per account)
- A settings-page image, changeable at will
- A group chat profile photo — repeatable without any limit
- A profile picture upload in a completely unrelated part of the app, behind a distinct endpoint with its own request schema
The chat group photo path is the one that raises the severity in practice: because it has no one-shot restriction, a single low-privileged account can mint new public, attacker-controlled HTML/JS objects on the trusted domain indefinitely, without ever needing a second signup. Four independent features sharing the exact same flaw is also a strong signal that the root cause lives in shared presigning logic, not in one isolated code path — which matters for how the fix gets scoped.
What didn't hold up: testing the tenant boundary
The object key returned by the policy is built as <company-id>/onboarding/<hash>/..., with the company ID coming from the server side (derived from the session), not from anything the client sends directly. That's the right way to do it — but the path parameter in the request body that gets folded into that key wasn't validated at all, so I tried the obvious thing: prefixing it with ../ to see if the server-derived company ID could be walked out of.
{"path":"../<other-company-id>/onboarding/","type":"image/png","file_name":"logo.png"}
The server accepted it without complaint and concatenated it straight into the key — no path-traversal filtering on the input. That's a real input-validation gap, worth flagging on its own. But S3 object keys are flat strings, not a filesystem: there's no directory structure for ../ to walk up out of. I confirmed this properly rather than assuming it:
- Requesting the object at the literal key containing
../→200 OK, returns the test marker. - Requesting the object at what would be the "resolved" path (the other tenant's real namespace) →
403 AccessDenied— nothing was ever written there. - Same result with the
..URL-encoded.
The 403 on the clean path is the decisive check: the object only ever exists under a key that still starts with the attacker's own company ID, just with an ugly ../ sitting inside it. No cross-tenant read or write actually happened. I reported the input-validation gap as a minor hardening note alongside the main finding, but didn't claim cross-tenant impact I couldn't demonstrate — a negative result you've actually verified is worth more in a report than a plausible-sounding claim you haven't.
Impact
- Arbitrary content hosting — phishing pages, malware droppers, XSS payloads — on an official, trusted domain that's likely to sail through URL/domain allowlists precisely because it belongs to a product people already trust.
- Stored XSS reachable by anyone who clicks the link, with zero authentication required on the victim's side.
- Unlimited, repeatable exploitation from a single low-privilege account via the chat-group-photo surface — no need to keep creating new accounts.
- The flaw reproduced identically across four independent upload features, pointing to a shared root cause in the presigning logic rather than a one-off.
Fix it like this
- Replace
["starts-with", "$Content-Type", ""]with an actual allow-list: an exact match (["eq", "$Content-Type", "image/png"]) or a real prefix restriction (["starts-with", "$Content-Type", "image/"]) combined with server-side validation of the file's actual magic bytes — a header is a claim, not a fact. - Force
Content-Dispositiontoattachmentas an exact-match condition, not an openstarts-with "". Nothing that goes through this pipeline should ever be renderable inline. - Validate uploaded content server-side after the fact (a post-upload event hook, a signature-based image check) so a spoofed
Content-Typestill can't result in an executable payload being served. - Reconsider whether
acl: public-readis actually necessary for these asset classes, or whether short-lived signed URLs would do the job just as well — the same platform already does this correctly for other file types elsewhere. - Apply the fix everywhere the presigning logic is shared, not just on the endpoint you happened to test first.
- Add input validation on any path-like parameter that gets folded into an object key, even where you can't currently demonstrate impact — closing it now is cheaper than re-deriving this exact confirmation later.
Where this stands
This was reported through the platform's VDP and is currently sitting with the team, unpatched, which is why the specifics stay redacted here. If that changes, I'll follow up with the parts that had to be left out.
Takeaway
None of this required anything exotic — reading a JSON policy carefully, noticing that a "restriction" wasn't actually one, and following the same request through three or four other features to see how far the bug travelled. If you're testing (or building) anything that hands a presigned S3 POST policy to a client, the two fields to check first are always Content-Type and Content-Disposition — "starts-with an empty string" is a restriction in name only, and it's an easy thing to miss in a review because the client-side form never sends anything that would expose it.
