Presigned Upload Content-Type Bypass to Stored XSS on First-Party CDN
The Risk
Anyone with a free account on a popular creator and link-in-bio platform could upload a file that ran their own code on the company's official storage domain. The page would have a valid certificate, the company's name, and would pass any corporate or security tool that trusts the brand. From there an attacker could host convincing fake login pages, steal data through service workers, or push malware behind the company's reputation. The same uploaded file kept working forever because the storage layer cached it for a year.
The Vulnerability
A GraphQL mutation that issued presigned upload URLs to authenticated users wrote the client-supplied Content-Type field verbatim into the resulting S3 POST policy. Any logged-in user could request a signed policy for image/svg+xml, text/html, or application/javascript, upload matching bytes, and have the object served back from a first-party user-generated-content subdomain with that exact Content-Type.
In the resolver, the user-controlled input.type value flowed into all three of:
- the returned
fields["Content-Type"]value, - the POST policy condition
["eq", "$Content-Type", "<input.type>"], - the conditions object
{"Content-Type": "<input.type>"}.
No allowlist enforcement existed. Other policy fields (the object key, user UUID metadata, and a content-length range) were properly constrained. Only the type field was open.
Confirmed primitives
| Requested Type | Served As | Script Executes? |
|---|---|---|
image/svg+xml | image/svg+xml | Yes, on top-level navigation |
text/html | text/html | Yes, full HTML page |
application/javascript | application/javascript | Loadable cross-origin via <script src> |
The Attack
- The attacker creates a free account and obtains a bearer token.
- They call the presigned upload mutation with
name: "xss.svg"andtype: "image/svg+xml". The response includes a signed S3 policy withContent-Type: image/svg+xmlbaked in. - They POST an SVG file containing
<script>tags to the signed S3 URL. S3 returns HTTP 204. - The object is served from the platform's first-party UGC subdomain with valid TLS, the platform's certificate, and
Content-Type: image/svg+xml. - Any browser that opens the URL executes the embedded JavaScript in the platform's first-party origin.
The HTML variant served a full attacker-controlled web page from the same first-party origin. The JavaScript variant was loadable cross-origin via <script src> from any other site, turning the CDN into a hosted payload service.
The Impact
- Branded phishing on a first-party subdomain with valid TLS. Corporate allowlists and URL-reputation services that trust the platform pass attacker pages without challenge.
- Same-origin compromise of the user-generated-content subdomain. Service workers, WebAuthn or Notification permission prompts, clipboard hijack, form grabbing, and browser exploit delivery all become available.
- Durable hosting. Uploaded objects were served with
Cache-Control: public, max-age=31536000, immutable. Removing a malicious object requires both an S3 delete and a CDN cache invalidation. - Available to any free account. No paid tier, no special role, no invite needed.
Remediation
- Enforce a strict allowlist on
input.typein the resolver. A four-entry set coveringimage/jpeg,image/png,image/webp, andimage/gifmatches the actual product use case. - Serve every object on the user-generated-content subdomain with
Content-Disposition: attachmentandX-Content-Type-Options: nosniff. This neutralises script execution if the type check ever regresses. - Move user-generated content to a sandbox domain outside the main brand suffix. This breaks the ambient trust the current setup grants to attacker-controlled uploads.
- Audit other resolvers for the same pattern. Any field that flows into a signed policy or response header must be allowlisted at the boundary.