File Upload Security skill for Codex
Uploads that never run, render or overflow. Codex maps the whole upload pipeline from proxy limit to serving headers and closes each gap: magic-byte allowlists, random storage keys, pixel and archive limits, image re-encoding that strips EXIF and appended payloads, and quarantine-then-scan for S3 presigned uploads. Files are served from an isolated domain or as sandboxed attachments so HTML and SVG never run under your origin.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- file-upload-security Installs to ~/.agents/skills/file-upload-security/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install file-upload-security The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project file-upload-security Installs to .agents/skills/file-upload-security/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/file-upload-security.zipunzip -o file-upload-security.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) file-upload-security PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $file-upload-security, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Secures file upload features end to end - extension and magic-byte allowlists, size and pixel limits, random storage keys, isolated object storage or a separate download domain, Content-Disposition and nosniff on serving, image re-encoding to strip payloads and EXIF, archive bomb and zip slip defence, and async malware scanning with ClamAV and a quarantine bucket. Use when building or reviewing avatar, document or attachment uploads, S3 presigned uploads, or after a report of uploaded HTML, SVG or web shells executing.
Use it when
- Building or reviewing avatar, document or attachment uploads
- Using S3 or GCS presigned uploads from the browser
- Processing user archives, images or office documents server side
- A report says an uploaded file executed or rendered as HTML
Use something else when
- You need upload mechanics like resumable or multipart transfers: use file-uploads
- You are reviewing bucket policies and public access across an account: use cloud-security-posture
How to check the result
Automated tests reject a renamed PHP file, a pixel-flood PNG, an SVG with script and a zip with ../ entries, re-encode a PNG with appended payload, and confirm served files carry nosniff plus attachment or sandbox CSP.
The mistake it prevents
Trusting the client's Content-Type header or file extension and serving the file inline from the main application origin.
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 another file, and is not served to anyone until it has passed validation (and scanning, where the risk warrants it).
Inspect first
rg -n -i "multer|busboy|formidable|UploadFile|request\.files|FileField|ActiveStorage|CarrierWave|Shrine|MultipartFile|IFormFile|presign|createPresignedPost|generate_presigned|PutObjectCommand" -g '!node_modules'
rg -n -i "originalname|filename|file\.name|secure_filename|getOriginalFilename" -g '!node_modules'
Map the pipeline: where bytes land first (memory, temp dir, bucket), who decides the stored name, what processes the file (image libraries, PDF tools, unzip, office converters), and how it is served back (same origin, CDN, presigned URL).
Threats to controls
| Threat | Control |
|---|---|
Web shell (.php, .jsp, .aspx) executed by the server | Store outside the web root or in object storage; never in a directory the app server executes |
| Stored XSS via HTML, SVG, XML served inline | Serve from a separate domain or with Content-Disposition: attachment, X-Content-Type-Options: nosniff, and a sandboxing CSP |
Path traversal / overwrite (../../app.py) | Ignore the client filename for storage; generate a random key |
| Content-type spoofing | Check magic bytes; never trust the Content-Type header or extension alone |
| Decompression bomb, pixel flood | Cap compressed size, decompressed size, file count and image pixels |
Zip slip (archive entries with ../) | Resolve each entry path and require it inside the target dir |
| Malware distributed to other users | Async scan before release; quarantine bucket |
| Parser exploits (ImageMagick, Ghostscript, LibreOffice) | Restrict formats, sandbox converters, keep them patched |
| Metadata leaks (GPS in EXIF) | Re-encode images, strip metadata |
| CSV / formula injection when exported later | Prefix cells starting with =, +, -, @, tab or CR with ' on export |
| Storage exhaustion | Per-user quotas and rate limits |
Validation pipeline
1. Enforce size limit at the proxy and framework (reject before buffering)
2. Extension allowlist on the client name (lowercased, last extension only)
3. Magic-byte check of the first bytes against the same allowlist
4. Type-specific parse: open image with pixel limit, parse PDF, etc.
5. Re-encode or normalise where possible (images -> re-encoded PNG/JPEG/WebP)
6. Store under a random key with the validated content type
7. Scan (async) -> move from quarantine to clean storage -> mark available
Python example for images:
import secrets
from io import BytesIO
from PIL import Image
Image.MAX_IMAGE_PIXELS = 40_000_000 # Pillow raises DecompressionBombError at 2x this
ALLOWED = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}
MAGIC = {b"\x89PNG\r\n\x1a\n": "image/png", b"\xff\xd8\xff": "image/jpeg"}
def sniff(head: bytes) -> str | None:
if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
return "image/webp"
return next((t for sig, t in MAGIC.items() if head.startswith(sig)), None)
def accept_image(stream, max_bytes=10 * 1024 * 1024) -> tuple[str, bytes]:
data = stream.read(max_bytes + 1)
if len(data) > max_bytes:
raise ValueError("too large")
ctype = sniff(data[:16])
if ctype not in ALLOWED:
raise ValueError("type not allowed")
with Image.open(BytesIO(data)) as im:
im.verify() # structural check
with Image.open(BytesIO(data)) as im: # reopen after verify()
out = BytesIO()
im.convert("RGB" if ctype == "image/jpeg" else "RGBA").save(out, format=ALLOWED[ctype].replace("jpg", "jpeg").upper())
key = f"uploads/{secrets.token_urlsafe(24)}.{ALLOWED[ctype]}"
return key, out.getvalue() # re-encoded bytes: EXIF and trailing payloads gone
Node: use file-type (fileTypeFromBuffer) for magic bytes and sharp for re-encoding
(sharp(buf, { limitInputPixels: 40_000_000 }).rotate().toFormat("webp")), and set
multer limits: { fileSize, files }.
Storage and serving
| Option | When |
|---|---|
| Private bucket + short-lived presigned GET (5-15 min) | Default for user documents |
Separate download domain (e.g. usercontent.example-cdn.net, not a subdomain of your app) | Files that must render inline (images, PDFs) |
Same origin with Content-Disposition: attachment | Small apps; acceptable for non-renderable types |
Headers when serving user files:
Content-Type: <validated type, never the client's>
Content-Disposition: attachment; filename="report.pdf"; filename*=UTF-8''report.pdf
X-Content-Type-Options: nosniff
Content-Security-Policy: sandbox; default-src 'none'
Cache-Control: private, no-store
Sanitise the display filename for the header (strip CR/LF, quotes, path separators) and keep the original name only as metadata.
Presigned uploads (S3 and compatible)
- Prefer presigned POST with a policy:
content-length-range, an exactkey(server chosen), andContent-Typecondition. Presigned PUT cannot enforce a size range. - Upload into a quarantine prefix or bucket; the app never serves from it.
- An
ObjectCreatedevent triggers validation and scanning; only the worker can write to the clean bucket. - Expire presigned URLs in minutes; block public ACLs on the bucket
(
BlockPublicAcls,IgnorePublicAcls,BlockPublicPolicy,RestrictPublicBuckets). - See
$aws-s3for bucket policy details.
Archives
import os, zipfile
def safe_extract(zpath, dest, max_files=1000, max_total=500 * 1024 * 1024):
dest = os.path.realpath(dest)
with zipfile.ZipFile(zpath) as z:
infos = z.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 = os.path.realpath(os.path.join(dest, i.filename))
if not target.startswith(dest + os.sep):
raise ValueError(f"unsafe path {i.filename!r}")
z.extractall(dest)
file_size comes from the archive headers and can lie; also cap bytes while streaming
if archives come from untrusted users. Reject symlinks in tar files
(Python 3.12+: tarfile.extractall(filter="data")).
Malware scanning
- ClamAV via
clamd(clamdscan --fdpassor a TCPINSTREAMclient) in a worker, not inline in the request. Signature updates viafreshclamneed network. - Cloud alternatives: GuardDuty Malware Protection for S3, Defender for Storage, or a vendor API. Decide by data sensitivity and volume.
- States:
pending->clean|infected|error. Serve onlyclean. Treat scan errors and timeouts as not clean. - Deleting an infected file is destructive: keep it in quarantine with restricted access and ask the user before purging, since it may be needed for investigation.
Verification
- [ ] Upload shell.php renamed to shell.jpg -> rejected by magic bytes
- [ ] Upload valid PNG with appended HTML/PHP -> stored file is re-encoded, payload gone
- [ ] Upload SVG with <script> -> rejected, or served as attachment with sandbox CSP
- [ ] Filename ../../etc/passwd -> stored under random key, display name sanitised
- [ ] 20000x20000 PNG (small on disk) -> rejected by pixel limit
- [ ] Zip with ../ entry and a 1 GB-expanding entry -> rejected
- [ ] Oversized body -> 413 from proxy before reaching the app
- [ ] EICAR test string -> marked infected, never served
- [ ] Served file response has nosniff, attachment or sandbox CSP
Write these as automated tests with fixture files generated in the test (do not commit real malware; EICAR is a harmless test string).
Output template
## Upload security review: <feature>
Pipeline: client -> <proxy limit> -> <app> -> <storage> -> <scan> -> <serve>
| Control | Status | Location |
|---|---|---|
| Size limit at proxy | 25 MB | nginx client_max_body_size |
| Magic-byte check | missing -> added | uploads/validate.py |
Residual risk: <converters, formats allowed, scan coverage>
Gotchas
client_max_body_size(nginx) and framework limits must agree, or users see confusing errors and large bodies still get buffered somewhere.- Image libraries trust embedded dimensions; always set pixel limits before decoding.
- SVG is an XML document with script support; treat it as HTML, not as an image.
- Antivirus finds known malware only; it is not a substitute for type validation and isolation.
- Upload endpoints are expensive: rate-limit them per user and per IP.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "File Upload Security"
short_description: "Uploads that never run, render or overflow"
default_prompt: "$file-upload-security Review our avatar upload endpoint for security issues" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.