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.

  • Security
  • $file-upload-security
  • 191 lines
  • 2 files
  • ~2,200 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- file-upload-security

Installs to ~/.agents/skills/file-upload-security/ for every project on this machine.

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.

~/.agents/skills/file-upload-security/SKILL.md

--- name: file-upload-security description: Secures file upload features end to end - extension and magic-byte allowlists, size and pixel limits, random storage keys, isolated object storage or… license: MIT ---

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

ThreatControl
Web shell (.php, .jsp, .aspx) executed by the serverStore outside the web root or in object storage; never in a directory the app server executes
Stored XSS via HTML, SVG, XML served inlineServe 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 spoofingCheck magic bytes; never trust the Content-Type header or extension alone
Decompression bomb, pixel floodCap 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 usersAsync 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 laterPrefix cells starting with =, +, -, @, tab or CR with ' on export
Storage exhaustionPer-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

OptionWhen
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: attachmentSmall 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 exact key (server chosen), and Content-Type condition. Presigned PUT cannot enforce a size range.
  • Upload into a quarantine prefix or bucket; the app never serves from it.
  • An ObjectCreated event 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-s3 for 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 --fdpass or a TCP INSTREAM client) in a worker, not inline in the request. Signature updates via freshclam need 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 only clean. 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.yamlgenerated from this page

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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem