Accept user file uploads without introducing remote code execution, stored XSS, or polyglot attacks. Covers magic-byte validation, strict type allowlists, image re-encoding to defang embedded payloads, EXIF stripping, virus scanning, path-safe storage keys, and serving via a separate origin with Content-Disposition. Invoke when adding upload to a new endpoint or migrating from local-disk storage to object storage.
Scanned 9/9/2026
Install to Claude Code
npx -y skills add yanacuti1121/Yana-AI --skill gw360--file-upload-security --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Gw360 File Upload Security?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/yanacuti1121-gw360-file-upload-security)More formats (shields.io, HTML) on the badges page.
---
name: file-upload-security
description: Accept user file uploads without introducing remote code execution, stored XSS, or polyglot attacks. Covers magic-byte validation, strict type allowlists, image re-encoding to defang embedded payloads, EXIF stripping, virus scanning, path-safe storage keys, and serving via a separate origin with Content-Disposition. Invoke when adding upload to a new endpoint or migrating from local-disk storage to object storage.
---
# File Upload Security
User-uploaded files are one of the highest-leverage attack surfaces. A single endpoint that accepts `*` MIME types and writes to a server-served path is a path to RCE, XSS, SSRF, stored-XSS in PDFs, and a half-dozen other failure modes. The defaults of every web framework do **less** than they should — this skill is what to add on top.
Generic, not CMS-specific. For Payload-specific tuning see [`payload-cms-security`](../payload-cms-security/SKILL.md). For WordPress see [`wordpress-hardening`](../wordpress-hardening/SKILL.md). For storage choice / backend architecture see [`backend-architecture`](../backend-architecture/SKILL.md).
## When to invoke
- Adding file upload to an endpoint for the first time
- A user-uploaded file caused or contributed to an incident
- Migrating from local-disk uploads to object storage (R2 / S3 / Spaces)
- Reviewing an existing upload feature you inherited
- Adding new file types (e.g. previously images only, now PDFs too)
## The threat model
A user can upload anything labeled as anything. From your perspective:
- The **filename** is attacker-controlled — `../../etc/passwd`, `🔥.jpg.exe`, `index.html`
- The **`Content-Type` header** is attacker-controlled — JPG bytes claiming to be `application/json`, executable bytes claiming to be `image/png`
- The **file extension** is attacker-controlled
- The **file contents** may be a polyglot — bytes that are valid as image *and* valid as HTML / JavaScript / PHP
Defense: trust nothing the client says about the file. Inspect the bytes, re-derive the type, generate your own key for storage, serve from an origin that cannot execute anything.
## Step 1 — Validate at the boundary
### Size limits, server-side
```ts
import multer from 'multer';
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10 MB hard cap
files: 5,
fields: 20,
fieldSize: 1024,
fieldNameSize: 100,
},
});
app.post('/api/upload', requireAuth, upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'no file' });
// ... continue with content validation
});
```
Defaults from middleware libraries are too generous (50MB+). Set what you actually need. Different endpoints get different caps — avatar upload doesn't need 10MB.
### Magic-byte detection (not MIME)
`Content-Type` is a hint. Read the first bytes to determine the *actual* type.
```ts
import { fileTypeFromBuffer } from 'file-type';
const detected = await fileTypeFromBuffer(req.file.buffer);
if (!detected) {
return res.status(400).json({ error: 'unrecognized file type' });
}
const ALLOWED_MIMES = new Set([
'image/jpeg',
'image/png',
'image/webp',
'application/pdf',
]);
if (!ALLOWED_MIMES.has(detected.mime)) {
return res.status(400).json({ error: `type ${detected.mime} not allowed` });
}
```
For Python: `python-magic` (`libmagic` bindings). For Go: `mimetype`. For Ruby: `marcel`.
Allowlist, not denylist. "Anything except `.exe` and `.php`" misses `.phtml`, `.phar`, `.shtml`, `.cgi`, `.pl`, `.jsp`, `.aspx`, and tomorrow's bypass.
### Filename — never trust the client's
Never store with the client-supplied name. Generate one server-side.
```ts
import { randomUUID } from 'node:crypto';
const ext = detected.ext; // from file-type, not from filename
const storageKey = `uploads/${userId}/${randomUUID()}.${ext}`;
```
Patterns to enforce in the storage key:
- **Generated, not user-supplied** — UUID, ULID, or content-hash (SHA-256 of bytes — also gives you free dedup)
- **No path traversal possible** — no `..`, no leading `/`, no special characters. With a UUID this is automatic.
- **Scoped by owner** — `uploads/<userId>/<random>` makes per-user access policies trivial
- **No executable extensions** — extension comes from server-detected type, not client filename
If you must keep the original filename for display, store it as a separate database field, never as part of the storage key.
## Step 2 — Defang content
The bytes are the problem. After validation, transform the file so any embedded payload becomes inert.
### Images: re-encode
Run every image through `sharp` (or equivalent) to a known format. This:
- Strips polyglot bytes — the re-encoded image is just the visual content
- Removes EXIF metadata — including geotags (PII leak)
- Normalizes the format — your storage layer holds known-good images
```ts
import sharp from 'sharp';
const processed = await sharp(req.file.buffer)
.rotate() // honor EXIF orientation, then drop EXIF
.resize({ width: 4096, height: 4096, fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 85, progressive: true }) // or .webp() for modern
.withMetadata({ exif: {} }) // strip EXIF
.toBuffer();
// Upload `processed`, not `req.file.buffer`
```
A PHP webshell embedded in a JPEG file does not survive re-encoding. So does most ImageTragick-style abuse. The cost is a few hundred ms of CPU per upload.
### PDFs and documents
Re-encoding a PDF is harder than re-encoding an image, but you can:
- **Strip JavaScript** with `qpdf` / `pdfcpu` — removes embedded JS that some PDFs use for "interactive" forms (or for malware)
- **Convert to PDF/A** — a more restricted PDF subtype, no JS allowed
- **Render to image + new PDF** — destroys text but eliminates everything else; only when you really need it (legal redaction, etc.)
```bash
# Strip JavaScript and re-write the PDF
qpdf --decrypt --remove-restrictions input.pdf cleaned.pdf
# Or convert to PDF/A
qpdf --object-streams=disable --linearize input.pdf cleaned.pdf
```
### Office documents
Active content is the issue (VBA macros, OOXML scripting). Three options, increasingly strict:
1. **Scan for macros** — reject if present (LibreOffice headless can introspect)
2. **Convert to PDF** server-side and store the PDF — destroys macros and most other active content
3. **Don't accept Office docs** — accept PDF only, instruct users to convert client-side
### Generic / unknown types
If you really must accept "any file", treat the upload like a quarantine zone:
- Never serve from a domain that can execute
- Always with `Content-Disposition: attachment` (forces download, not inline render)
- Pass through a virus scanner (ClamAV is free, decent for known malware) — see Step 4
## Step 3 — Storage and serving
### Storage: object storage, not local disk
See [`backend-architecture`](../backend-architecture/SKILL.md) for the "images disappear on redeploy" trap. For uploads specifically:
- **S3-compatible storage** (S3, R2, B2, Spaces, MinIO if self-hosted)
- **Private bucket** with signed URLs for access — never make the bucket public unless the content really is public
- **Path scoped by owner** — `uploads/<userId>/<key>` so IAM policies can restrict access by prefix
### Serving: separate origin, no script execution
The cardinal sin: serving user uploads from the same origin as your app, with `Content-Type` derived from extension and no `Content-Disposition`. An attacker uploads `xss.html`, your server serves it as `text/html` from your app's origin, the attacker links to it, and now they have stored XSS with same-origin access to your auth cookies.
Patterns:
- **Serve from a separate domain** — `cdn.example.com` or `uploads.example.com`. Even if XSS lands here, your auth cookies are not in scope.
- **`Content-Disposition: attachment`** for non-image uploads — forces download instead of inline rendering for browsers that would otherwise sniff into something dangerous
- **Strict `Content-Type`** — match the server-detected type from Step 1, not the original
- **`X-Content-Type-Options: nosniff`** — disables browser MIME-sniffing
- **`Content-Security-Policy: default-src 'none'`** for the uploads origin — even if HTML lands there, the CSP refuses to render scripts/images/anything
- **No directory listing** on the bucket
```nginx
# Example serving config for the upload origin (nginx fronting R2/S3)
server {
listen 443 ssl;
server_name uploads.example.com;
add_header X-Content-Type-Options nosniff always;
add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'unsafe-inline';" always;
add_header Strict-Transport-Security "max-age=31536000" always;
# Force download for anything not in the image allowlist
location ~* \.(jpg|jpeg|png|webp|gif|svg)$ {
# served inline as images
}
location / {
add_header Content-Disposition "attachment" always;
}
}
```
### SVG is special
SVG is XML with `<script>` support. If a user uploads `evil.svg` and you serve it as `image/svg+xml`, browsers render the SVG **and execute its scripts**. SVG is effectively HTML in disguise.
Options:
- **Don't accept SVG** if you can avoid it (most "image upload" features don't need vector)
- **Sanitize SVG** with `DOMPurify`'s SVG mode or `svg-sanitizer` (PHP) before storing
- **Force download** with `Content-Disposition: attachment` even if served as `image/svg+xml`
### Signed URLs for private content
For per-user / per-tenant uploads (not public), generate short-lived signed URLs at access time:
```ts
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
app.get('/api/files/:id', requireAuth, async (req, res) => {
const file = await db.files.findFirst({
where: { id: req.params.id, userId: req.user.id } // BOLA check — see api-security
});
if (!file) return res.status(404).end();
const url = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: 'uploads', Key: file.storageKey }),
{ expiresIn: 300 } // 5 minutes
);
res.json({ url });
});
```
5-minute TTL is plenty for direct downloads; longer only if needed.
## Step 4 — Virus scanning (for non-image uploads)
For PDFs, Office docs, archives, and "any file" features, run a virus scan. Not perfect, but catches known-malicious files.
- **ClamAV** is free, self-hostable, decent for known malware. Run as a daemon, scan via socket.
- **VirusTotal API** for higher fidelity (paid) — also gives you reputation signals
- **Hosted scanners** — Cloudflare (with paid tier features), bundled with some CDN providers
```ts
// Sketch — clamav-client style
import { Clam } from 'clamav.js';
const clam = new Clam({ host: '127.0.0.1', port: 3310 });
const scanResult = await clam.scan(req.file.buffer);
if (scanResult.isInfected) {
await db.uploadAttempts.create({
data: { userId: req.user.id, reason: 'av-positive', signature: scanResult.viruses.join(',') }
});
return res.status(400).json({ error: 'file rejected' });
}
```
False positives happen — log them, allow operator review, don't punish the user with a confusing error.
## Step 5 — Pre-signed PUT for large uploads
For files > a few MB, having the user upload through your app server is wasteful. Pre-signed PUT URLs let the browser upload directly to S3/R2 while your app stays in control:
```ts
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
app.post('/api/uploads/request', requireAuth, async (req, res) => {
const { filename, size, mime } = req.body;
// Server-side validation BEFORE issuing the URL
if (size > 100 * 1024 * 1024) return res.status(400).json({ error: 'too large' });
if (!ALLOWED_MIMES.has(mime)) return res.status(400).json({ error: 'type not allowed' });
const storageKey = `uploads/${req.user.id}/${randomUUID()}`;
const url = await getSignedUrl(s3, new PutObjectCommand({
Bucket: 'uploads',
Key: storageKey,
ContentType: mime,
ContentLength: size,
}), { expiresIn: 300 });
// Record the pending upload so we know what to validate after
await db.pendingUploads.create({ data: { storageKey, userId: req.user.id, expectedSize: size, expectedMime: mime }});
res.json({ url, storageKey });
});
// Browser PUTs the file directly to `url`. After upload completes,
// the client calls /api/uploads/confirm with the storageKey.
app.post('/api/uploads/confirm', requireAuth, async (req, res) => {
const { storageKey } = req.body;
const pending = await db.pendingUploads.findFirst({
where: { storageKey, userId: req.user.id }
});
if (!pending) return res.status(404).json({ error: 'no pending upload' });
// Fetch the uploaded object, validate magic bytes server-side, then re-encode/scan
const obj = await s3.send(new GetObjectCommand({ Bucket: 'uploads', Key: storageKey }));
// ... validation, re-encoding, scanning as usual
// If validation fails, delete the object
});
```
The browser uploads big files efficiently; your app stays the gatekeeper for validation, scanning, and re-encoding (via a worker that picks up confirmed uploads).
## Step 6 — Operational hygiene
- **Orphan cleanup** — pending uploads that never confirmed, abandoned files when records are deleted. Daily cron.
- **Per-user quota** — soft cap with notification, hard cap with rejection. Prevents one user filling your bucket.
- **Per-IP / per-account rate limit** on upload endpoints — stops bulk abuse
- **Backup the bucket** — see [`backup-disaster-recovery`](../backup-disaster-recovery/SKILL.md). Object storage is durable but not deletion-proof.
- **Audit log of uploads** — who uploaded what, when, what was the validation result. See [`log-strategy`](../log-strategy/SKILL.md).
- **Periodic re-scan** — virus signatures update; what passed yesterday may be flagged today. Re-scan high-value buckets monthly.
## Checklist
For a file-upload feature going to production:
- [ ] Server-side size cap matches actual need (not framework default)
- [ ] Magic-byte detection used; MIME header from client ignored
- [ ] Strict type allowlist (not denylist)
- [ ] Storage key generated server-side (UUID / hash); no client filename in the path
- [ ] Images re-encoded through `sharp` / equivalent; EXIF stripped
- [ ] PDFs run through `qpdf` to strip JS, or converted to PDF/A
- [ ] SVG not accepted, or sanitized before storage
- [ ] Office docs converted to PDF server-side, or rejected
- [ ] Stored in object storage with a private bucket; signed URLs for access
- [ ] Served from a separate origin (`cdn.example.com`, not `app.example.com`)
- [ ] `X-Content-Type-Options: nosniff` set on the upload origin
- [ ] Strict CSP on the upload origin (`default-src 'none'` baseline)
- [ ] `Content-Disposition: attachment` for non-image types
- [ ] Virus scan in place for non-image uploads
- [ ] Per-user quota and per-IP rate limit
- [ ] BOLA-safe download: owner check on every fetch (see [`api-security`](../api-security/SKILL.md))
- [ ] Orphan / abandoned upload cleanup runs daily
- [ ] Bucket itself is backed up
## What this skill will not do
- Help build upload features for systems you do not own
- Endorse serving user uploads from the application's own origin without `Content-Disposition` and a strict CSP
- Recommend "trust the client's MIME header" for any production system
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!