Security Advisory: SEC-30 — File Upload Content Type Validation
Security Advisory: SEC-30 — File Upload Content Type Validation
Severity: High
Component: Upload Router (src/lib/routers/upload.ts)
Version Identified: 1.0.59
Control ID: SEC-30
Category: API Security
Overview
The Calmony Pay upload router validates permitted file types by checking a client-supplied contentType value against a server-side allowlist (ALLOWED_IMAGE_TYPES). Because this value originates from the client, it cannot be trusted as proof of actual file content. A malicious client can declare any permitted MIME type while uploading a file of a completely different kind.
This advisory documents the vulnerability, explains the attack surface, and details the recommended mitigations.
Vulnerability Detail
How the Upload Flow Works
- The client calls the upload endpoint, providing a
contentTypestring (e.g.image/png). - The server checks
contentTypeagainstALLOWED_IMAGE_TYPES. - If allowed, the server issues a presigned S3
PUTURL. - The client uploads the file directly to S3 using the presigned URL. S3 enforces that the
Content-Typeheader on thePUTmatches what was declared — but it does not inspect file content.
The Trust Problem
Step 2 trusts a value provided by the client. Any HTTP client can set contentType to image/png and then upload an entirely different file. S3's enforcement at step 4 only checks that the header matches the string agreed at presign time — both values were supplied by the same attacker.
SVG as an Elevated Risk
image/svg+xml is included in the current ALLOWED_IMAGE_TYPES allowlist. SVG is an XML-based format that natively supports embedded JavaScript via <script> tags and event handler attributes. When such a file is served directly by S3 (or a CDN) with Content-Type: image/svg+xml, browsers will render and execute any embedded scripts — making this a viable stored cross-site scripting (XSS) vector.
Polyglot and Disguised Files
Beyond SVG, an attacker can upload polyglot files — for example, a file that is simultaneously a valid PNG and a valid ZIP archive, or a file whose declared type differs from its actual format. Without magic-byte inspection, the server has no way to detect this.
Attack Scenarios
| Scenario | Declared Type | Actual Content | Risk |
|---|---|---|---|
| SVG with embedded JS | image/svg+xml | SVG + <script> | Stored XSS when served to users |
| PNG-declared SVG | image/png | SVG with scripts | Bypasses SVG-specific restrictions |
| Polyglot file | image/jpeg | ZIP / HTML hybrid | Arbitrary content stored and served |
Recommended Mitigations
1. Remove or Restrict SVG from the Allowlist
The simplest immediate mitigation is to remove image/svg+xml from ALLOWED_IMAGE_TYPES in src/lib/routers/upload.ts.
If SVG uploads are a product requirement, ensure the S3 bucket and CDN override the response Content-Type to text/plain and add Content-Disposition: attachment for all SVG objects. This prevents browsers from rendering SVG inline and executing embedded scripts.
# Example CloudFront / S3 response headers for SVG objects
Content-Type: text/plain
Content-Disposition: attachment; filename="file.svg"
2. Server-Side Magic-Byte Validation
After a file is uploaded to S3, verify that the first bytes of the stored object match the expected magic bytes for the declared MIME type before making the file available.
This can be implemented as:
- An S3 Event Notification → Lambda trigger that reads the object head bytes, validates the signature, and deletes or quarantines the object if it fails.
- A post-upload verification step within the presign flow, if the architecture allows a synchronous read-after-write.
Example magic bytes for common image types:
| Format | Magic Bytes (hex) |
|---|---|
| JPEG | FF D8 FF |
| PNG | 89 50 4E 47 0D 0A 1A 0A |
| GIF | 47 49 46 38 |
| WebP | 52 49 46 46 … 57 45 42 50 |
SVG has no magic bytes (it is plain text XML), which further reinforces removing it from the allowlist or handling it as a distinct, restricted upload type.
3. Content Security Policy at the CDN Layer
Ensure the CloudFront distribution serving user-uploaded content includes a strict Content-Security-Policy response header:
Content-Security-Policy: default-src 'none'; script-src 'none';
This provides defence-in-depth: even if a malicious file is stored and served, the browser will refuse to execute any scripts it contains.
Affected File
src/lib/routers/upload.ts
No Breaking API Changes
This advisory does not introduce any changes to the public API contract. All recommended mitigations are internal to the upload infrastructure and transparent to well-behaved clients.