Secure File Uploads skill for Claude Code
Uploads that can't execute, escape, or exhaust you. Claude designs uploads from a per-type policy: size limits at proxy, app and storage, content-based type detection, generated storage keys, and private buckets. Images are re-encoded to strip EXIF and polyglots, archives are checked for zip slip and bombs, shared files pass through a quarantine bucket and ClamAV, and everything is served from a separate domain with attachment and nosniff headers.
- name
secure-file-uploads - size194 lines · ≈2,300 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds and reviews file upload handling that resists abuse — size limits at every layer, type checks by magic bytes against an allowlist, generated storage keys, isolated buckets and user-content domains, presigned upload policies, image re-encoding, SVG and HTML handling, zip bomb and zip slip defenses, quarantine with ClamAV malware scanning, and safe download headers. Use when adding avatar, document, or attachment uploads, presigned S3/R2/GCS uploads, processing user images or archives, or fixing an upload-related finding. Try asking
- Add secure avatar uploads to our Django app with S3 storage
- Users can share PDFs with each other — add malware scanning before others can download
- Review our upload endpoint; I think SVGs might be an XSS risk
Use it when
- Adding any user file upload: avatars, attachments, imports
- Moving to presigned direct-to-bucket uploads
- Processing user images, PDFs, or archives on the server
- Users can share uploaded files with other users
Skip it when
- You only need bucket policies, multipart uploads, or lifecycle setup — use s3-object-storage instead
- The work is image format and responsive delivery tuning — use image-optimization instead
How to check the result
Tests confirm a renamed HTML file, a scripted SVG, an oversized file and a zip slip archive are rejected or neutralized, the EICAR string never reaches the clean bucket, and downloads carry attachment and nosniff headers.
The common mistake it prevents
Trusting the client's Content-Type or file extension and serving uploads inline from the main app domain, which turns any HTML or SVG upload into stored XSS.
Done well means: an uploaded file can never execute on your servers or in your users’ browsers under your origin, cannot exhaust disk, memory, or CPU, cannot overwrite or escape into other paths, and is not served to anyone until it has been validated and, where the risk warrants it, scanned.
Threats to design against
| Threat | Example |
|---|---|
| Stored XSS via served content | .html, .svg, or a polyglot served inline from your main domain |
| Server-side execution | .php/.jsp dropped into a web-served directory; ImageMagick/Ghostscript exploits during processing |
| Path traversal / overwrite | Filename ../../app/config.py or avatar.png overwriting another user’s file |
| Resource exhaustion | 10 GB upload, decompression bombs (zip, gzip, PNG with huge dimensions) |
| Zip slip | Archive entry named ../../etc/cron.d/x |
| Malware distribution | Users share infected documents through your platform |
| Privacy leaks | EXIF GPS coordinates in photos |
| Cost abuse | Unlimited storage per account; public buckets used as free hosting |
Workflow
- [ ] 1. Define allowed types, max sizes, and who can read each file
- [ ] 2. Enforce size limits at edge, app, and storage policy
- [ ] 3. Validate type by content (magic bytes) against the allowlist
- [ ] 4. Store under a generated key in isolated storage; keep original name as metadata
- [ ] 5. Process safely (re-encode images, sandbox converters, guard archives)
- [ ] 6. Quarantine and scan where users share files with others
- [ ] 7. Serve with safe headers from an isolated origin, with authorization
1. Policy per upload type
Write it down; it drives every later check.
| Upload | Allowed | Max size | Processing | Visibility |
|---|---|---|---|---|
| Avatar | JPEG, PNG, WebP | 5 MB, 4096×4096 px | Re-encode, strip metadata, resize | Public via CDN |
| Invoice attachment | 20 MB | Scan | Owner + account admins | |
| Data import | CSV | 50 MB | Parse with row/column limits | Owner only, deleted after import |
Reject everything else. “Any file type” is a product decision that requires scanning and strictly attachment-only serving.
2. Size limits at every layer
- Edge/proxy: nginx
client_max_body_size 20m;, load balancer/API gateway limits. - App: framework limits (Express
multer({ limits: { fileSize: 20 * 1024 * 1024, files: 1 } }), DjangoDATA_UPLOAD_MAX_MEMORY_SIZE/FILE_UPLOAD_MAX_MEMORY_SIZE, FastAPI: check while streaming chunks). Stream to disk/object storage; never read whole files into memory. - Storage: presigned POST policies with
content-length-range; per-account quotas.
3. Type validation
- Detect type from content, not the
Content-Typeheader or extension (both are attacker-controlled). Python:python-magic(libmagic) orfiletype; Node:file-type. - Require the detected type, the extension, and the declared type to agree and be in the allowlist; then assign the extension yourself from the detected type.
- Magic bytes prove the header, not the whole file. Polyglots (valid JPEG that is also valid HTML/JS) exist; re-encoding (step 5) and safe serving (step 7) are what neutralize them.
import magic # pip install python-magic (needs libmagic: brew install libmagic / apt install libmagic1)
ALLOWED = {"image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", "application/pdf": "pdf"}
def detect(path: str) -> str:
mime = magic.from_file(path, mime=True)
if mime not in ALLOWED:
raise ValueError(f"type {mime} not allowed")
return ALLOWED[mime]
4. Storage
- Key: generated (
uuid4/uuid7or content hash) + assigned extension, under a per-tenant prefix:tenants/<tenant_id>/uploads/<uuid>.pdf. Never the user’s filename. - Keep the original filename in the database (sanitized for display; strip control characters and path separators, cap length).
- Never store uploads inside the application directory or any path the web server executes or serves directly.
- Object storage: private bucket, block public access, server-side encryption, lifecycle rules for abandoned/temporary uploads. Separate buckets for quarantine and clean files.
Presigned direct-to-storage uploads (S3 example, boto3):
s3.generate_presigned_post(
Bucket="uploads-quarantine",
Key=f"tenants/{tenant_id}/incoming/{uuid4()}",
Conditions=[["content-length-range", 1, 20 * 1024 * 1024],
{"Content-Type": "application/pdf"}],
Fields={"Content-Type": "application/pdf"},
ExpiresIn=300,
)
Presigned PUT URLs cannot enforce a size range; prefer POST policies, or verify size and type after upload before promoting the object.
5. Processing
-
Images: decode and re-encode with a maintained library (Pillow, sharp/libvips). This strips EXIF/GPS and most polyglot payloads. Set pixel limits before decoding: Pillow
Image.MAX_IMAGE_PIXELS(default ~89 MP warns, 2× raisesDecompressionBombError), sharp’slimitInputPixels. -
ImageMagick/Ghostscript: avoid on untrusted input if possible; otherwise lock
policy.xmlto the formats you need, disable PS/EPS/PDF/MVG/MSL coders, and run in a sandbox (container with no network, low memory, timeouts). -
SVG: it is an XML document that can contain script. Options in order of safety: rasterize to PNG; serve only as
attachmentfrom a separate domain; sanitize with DOMPurify (SVG profile) and still serve with CSPsandbox. -
Archives: check each entry before extracting — resolved path inside the target directory (zip slip), total uncompressed size cap, entry count cap, compression ratio cap (e.g. > 100:1 is suspicious), no symlinks. Python:
import zipfile from pathlib import Path MAX_TOTAL, MAX_FILES = 200 * 1024 * 1024, 1000 def safe_extract(zip_path: str, dest: str) -> None: dest_p = Path(dest).resolve() with zipfile.ZipFile(zip_path) as zf: infos = zf.infolist() if len(infos) > MAX_FILES or sum(i.file_size for i in infos) > MAX_TOTAL: raise ValueError("archive too large") for i in infos: target = (dest_p / i.filename).resolve() if not target.is_relative_to(dest_p): raise ValueError(f"unsafe path {i.filename}") zf.extractall(dest_p)file_sizecomes from the archive header and can lie; also enforce limits while streaming if archives come from untrusted users at scale. -
Office/PDF: do not render server-side with tools that execute macros or scripts. Convert in a sandbox (no network, timeouts) if previews are needed.
-
CSV: cap rows/columns; when exporting user data back to spreadsheets, prefix cells starting with
=,+,-,@, tab, or CR with'to prevent formula injection.
6. Quarantine and malware scanning
Pattern for user-to-user sharing:
- Upload lands in the quarantine bucket (not readable by the app’s serving path).
- Storage event triggers a scanner worker (ClamAV
clamd, or a managed service such as cloud-native malware protection for object storage). - Clean → copy to the clean bucket and mark
status=clean; infected → keep isolated, markstatus=rejected, notify; scanner error/timeout →status=failed, retry, never default to clean. - The app serves only
status=cleanobjects.
ClamAV: keep signatures fresh (freshclam daemon), scan via clamdscan --fdpass <file>
or the clamd socket protocol (INSTREAM), and treat results as one signal, not a
guarantee. Files larger than clamd’s StreamMaxLength are not fully scanned — align it
with your upload limit.
7. Serving
-
Serve user content from a separate registrable domain (e.g.
example-usercontent.com), never from the main app origin, so any script that slips through cannot read your cookies or call your APIs. -
Headers on every user file:
Content-Type: <assigned type, not user-declared> X-Content-Type-Options: nosniff Content-Disposition: attachment; filename="report.pdf"; filename*=UTF-8''r%C3%A9sum%C3%A9.pdf Content-Security-Policy: sandbox; default-src 'none' Cache-Control: private, no-store # for non-public filesUse
inlineonly for re-encoded images and PDFs you have decided to display. -
Authorize every download (owner/tenant check), then redirect to a short-lived signed URL (minutes). Signed URLs are bearer tokens; do not log them or make them long-lived.
Verification
Write tests (against local/staging) that upload and assert rejection or neutralization for:
- a
.htmlrenamed to.jpg, an SVG with<script>, a PHP file with image magic bytes; - a file 1 byte over the limit (rejected at the edge and in the app);
- a zip with
../evil.txtand a high-ratio zip bomb; - the EICAR test string (ClamAV must flag it; the file must never reach the clean bucket);
- a JPEG with GPS EXIF (metadata absent after processing);
- a download by another tenant’s user (404).
Check served responses with curl -sI for nosniff, Content-Disposition, and the
user-content domain.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.