File Uploads skill for Codex
Size-capped, sniffed, scanned before anyone sees it. Codex designs uploads so the size limit agrees across proxy, parser and storage policy, sends large files straight to the bucket with a presigned POST, and decides file type from magic bytes rather than headers. Files land in a quarantine prefix under server-generated keys and stay unreadable until a scanner marks them clean. Includes a standard-library sniffing script for checking samples.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- file-uploads Installs to ~/.agents/skills/file-uploads/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install file-uploads 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-uploads Installs to .agents/skills/file-uploads/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/file-uploads.zipunzip -o file-uploads.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-uploads 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-uploads, 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
Implements file uploads that hold up in production - streaming multipart parsing with busboy, multer or python-multipart, direct-to-storage uploads with S3 presigned POST policies or presigned PUT, size limits at every layer, content sniffing by magic bytes, safe storage keys and download headers, and a quarantine-then-scan pipeline with ClamAV or a managed malware scanner. Use when adding an avatar, document or media upload, moving uploads off the app server to S3, GCS or R2, fixing 413 errors or memory spikes on large files, or reviewing upload code for security.
Use it when
- Adding user uploads of images, documents or media to an app
- Moving uploads off the app server to S3, GCS, R2 or Azure Blob
- Uploads fail with 413, spike memory or fill the disk
- Uploaded files need malware scanning or safe download handling
Use something else when
- You are pentesting someone else's upload feature: use file-upload-security
- You need bucket policy, lifecycle and storage-class design rather than upload flow: use aws-s3
How to check the result
Tests show oversized uploads get 413, a renamed executable gets rejected by content sniffing, a traversal filename is stored harmlessly, and a file is not downloadable until its scan reports clean.
The mistake it prevents
Trusting the Content-Type header or extension and serving the file inline from the main domain, which turns an upload form into stored XSS.
Done well means: the size limit is enforced before bytes pile up (proxy, parser and storage policy agree), the file type is decided from its content, the user-supplied name never becomes a path, nothing is served to other users until it has been scanned, and downloads cannot execute in the app’s origin.
Before changing anything
- Read
AGENTS.md, the framework and parser in use, and the storage target (local disk, S3, GCS, R2, Azure Blob). - Find every layer that limits body size: CDN/WAF, load balancer, nginx
(
client_max_body_size, default 1 MB), the framework, the parser, the storage policy. - Ask for the product limits if not stated: allowed types, max size, who can read the file afterwards, retention.
Choose the architecture
| Situation | Pattern |
|---|---|
| Small files (under ~10 MB), simple app | Stream multipart through the server to storage |
| Large files, many uploads, serverless backend | Direct-to-storage with a presigned POST policy or PUT URL |
| Files over ~100 MB or unreliable networks | Multipart upload (S3 CreateMultipartUpload with presigned part URLs) or tus/resumable protocol |
| Files must be transformed (resize, transcode) | Upload raw to a quarantine bucket, process asynchronously, write derived files elsewhere |
Default to direct-to-storage for anything user-generated and larger than a few MB: it keeps the app server out of the byte path and its memory flat.
Direct-to-storage flow
client --(1) POST /uploads {name, size, type}--> API: validate, create upload row (status=pending),
return presigned POST (key = uploads/<uuid>)
client --(2) POST file to bucket with policy fields--> storage enforces size and content-type
client --(3) POST /uploads/<id>/complete--> API: HEAD object, check size, enqueue scan
scanner --(4) result--> API: status=clean (move to public prefix) or status=infected (delete, alert)
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
import { randomUUID } from "node:crypto";
const MAX = 10 * 1024 * 1024;
const ALLOWED = new Set(["image/png", "image/jpeg", "application/pdf"]);
export async function startUpload(userId: string, { size, type }: { size: number; type: string }) {
if (!ALLOWED.has(type) || size <= 0 || size > MAX) throw new HttpError(422, "unsupported file");
const key = `quarantine/${userId}/${randomUUID()}`;
const post = await createPresignedPost(s3, {
Bucket: process.env.UPLOAD_BUCKET!,
Key: key,
Conditions: [
["content-length-range", 1, MAX],
["eq", "$Content-Type", type],
],
Fields: { "Content-Type": type },
Expires: 300, // seconds
});
await db.uploads.insert({ key, userId, declaredType: type, status: "pending" });
return { url: post.url, fields: post.fields, key };
}
- Prefer a presigned POST: its policy can enforce
content-length-range. A presigned PUT cannot enforce a range; it can only bind an exactContentLengthif you include it when signing, so the client must send that exact size. - Keep expiry short (5-15 minutes) and the key server-generated.
- The bucket must block public access; serve files through signed GET URLs or a CDN with origin access control.
- CORS on the bucket: allow only your app’s origin and the methods you use.
- The
completestep must re-check the object withHeadObject(size, content type) and verify ownership of the upload row; never trust the client’s claim that it finished.
Server-side multipart (when bytes pass through the app)
Stream; never buffer whole files in memory or let a parser write unbounded temp files.
import busboy from "busboy";
import { pipeline } from "node:stream/promises";
app.post("/avatar", (req, res, next) => {
const bb = busboy({ headers: req.headers, limits: { fileSize: 5 * 1024 * 1024, files: 1, fields: 5 } });
let handled = false;
bb.on("file", async (_field, file, info) => {
handled = true;
const key = `quarantine/${req.user.id}/${crypto.randomUUID()}`;
file.on("limit", () => file.destroy(new HttpError(413, "file too large")));
try {
await pipeline(file, storage.writeStream(key));
await enqueueScan({ key, declaredType: info.mimeType, userId: req.user.id });
res.status(202).json({ key });
} catch (err) {
await storage.delete(key).catch(() => {});
next(err);
}
});
bb.on("close", () => { if (!handled) res.status(400).json({ error: "no file" }); });
req.pipe(bb);
});
| Stack | Limit setting |
|---|---|
| multer | limits: { fileSize, files }; errors as MulterError LIMIT_FILE_SIZE |
| FastAPI / Starlette | UploadFile spools to disk after 1 MB; enforce max size yourself while reading chunks |
| Django | DATA_UPLOAD_MAX_MEMORY_SIZE, FILE_UPLOAD_MAX_MEMORY_SIZE; check file.size |
| Spring Boot | spring.servlet.multipart.max-file-size, max-request-size |
| Go | http.MaxBytesReader before r.MultipartReader() (stream) rather than ParseMultipartForm |
| nginx | client_max_body_size 10m; set slightly above the app limit |
Validate content, not claims
The Content-Type header and the extension are user input. Decide the type from magic
bytes, then check it against the allow-list and the declared type:
python3 scripts/sniff_upload.py path/to/file.pdf --allow png,jpeg,pdf --max-bytes 10485760
The script prints the detected type, size and a verdict per file and exits 1 on any
reject; use it to check fixtures and to decide which signatures your server-side check must
cover. In application code use a maintained sniffer (file-type in Node, python-magic
with libmagic, Go http.DetectContentType for the basics).
- Reject SVG and HTML for user content unless you sanitize them and serve them from a separate origin: both can carry script.
- For images, re-encode (sharp, Pillow, libvips) to strip metadata and neutralise polyglot files. Strip EXIF GPS data from photos by default.
- ZIP-based formats (docx, xlsx) can be zip bombs: cap the uncompressed size and entry count before extracting anything.
Storage and serving
- Key = server-generated UUID. Store the original filename only as metadata, sanitized
for display, and never use it in a filesystem path (
../../etc/passwd, NUL bytes, Windows reserved names). - Local disk: outside the web root, no execute permissions, never served by a static handler that guesses types.
- Serve downloads with
Content-Disposition: attachment; filename*=UTF-8''<encoded>,X-Content-Type-Options: nosniff, and the content type you detected. Inline display only for allow-listed image types. - Serve user files from a separate domain (
usercontent.example.net) so any script that slips through cannot read app cookies.
Malware scanning
States: pending -> scanning -> clean | infected | failed. Only clean files are
readable by anyone other than the uploader.
| Option | How |
|---|---|
| ClamAV | clamd daemon; stream bytes with INSTREAM (port 3310) or run clamdscan on the object; keep signatures fresh with freshclam |
| Cloud-managed | Amazon GuardDuty Malware Protection for S3, or the provider’s equivalent; consume its result tag or event |
| Commercial API | Send the object to the vendor; mind data residency |
Scan asynchronously from the queue, set a timeout, treat scanner failures as failed
(not clean), and alert on infected. Deleting user files or notifying users about
infections are destructive or outward actions: confirm the policy with the user before
automating them.
Checklist
- One documented max size, enforced at proxy, parser and storage policy
- Type from magic bytes against an allow-list; SVG/HTML refused or isolated
- Server-generated keys; original name only as sanitized metadata
- Quarantine prefix and scan before anyone else can read the file
- Downloads with attachment disposition, nosniff, separate origin
- Tests: oversize returns 413, wrong type returns 422, path-traversal name is harmless, unscanned file is not downloadable
Gotchas
- A 413 from nginx returns HTML before the app sees the request; clients expecting JSON break. Align limits and handle the proxy’s error page.
- Presigned URLs leak through logs, referrers and browser history; keep them short-lived and never log full URLs with signatures.
- Multipart parsers that write to temp files can fill the disk; set limits and clean up.
- Image dimension bombs (a tiny PNG declaring 50000x50000) exhaust memory on decode; check dimensions before resizing.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
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 Uploads"
short_description: "Size-capped, sniffed, scanned before anyone sees it"
default_prompt: "$file-uploads Add avatar uploads that go straight to S3 with a 5 MB limit" scripts/sniff_upload.py
#!/usr/bin/env python3
"""Identify uploaded files by their leading bytes and check them against an allow-list.
Usage:
python3 sniff_upload.py FILE [FILE ...] [--allow png,jpeg,pdf] [--max-bytes 10485760]
For each file prints: path, detected type, size, extension, verdict.
Exits 1 if any file is unknown, not allowed, too large, or its extension disagrees
with its content. Standard library only.
"""
import argparse
import os
import sys
# (type, offset, signature bytes)
SIGNATURES = [
("png", 0, b"\x89PNG\r\n\x1a\n"),
("jpeg", 0, b"\xff\xd8\xff"),
("gif", 0, b"GIF87a"),
("gif", 0, b"GIF89a"),
("pdf", 0, b"%PDF-"),
("zip", 0, b"PK\x03\x04"), # also docx, xlsx, pptx, jar, apk
("gzip", 0, b"\x1f\x8b"),
("exe", 0, b"MZ"), # Windows PE
("elf", 0, b"\x7fELF"),
("macho", 0, b"\xcf\xfa\xed\xfe"),
("macho", 0, b"\xfe\xed\xfa\xcf"),
]
EXTENSIONS = {
"png": {".png"},
"jpeg": {".jpg", ".jpeg"},
"gif": {".gif"},
"webp": {".webp"},
"pdf": {".pdf"},
"zip": {".zip", ".docx", ".xlsx", ".pptx"},
"gzip": {".gz", ".tgz"},
"mp4": {".mp4", ".m4v", ".mov"},
"heic": {".heic", ".heif"},
"svg": {".svg"},
"html": {".html", ".htm"},
}
# Types that execute code or script in a browser or OS: never accept by default.
DANGEROUS = {"exe", "elf", "macho", "html", "svg"}
def detect(head: bytes) -> str:
for kind, off, sig in SIGNATURES:
if head[off:off + len(sig)] == sig:
return kind
if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
return "webp"
if head[4:8] == b"ftyp":
brand = head[8:12]
if brand in (b"heic", b"heix", b"mif1", b"msf1", b"hevc"):
return "heic"
return "mp4"
text = head.lstrip(b"\xef\xbb\xbf \t\r\n").lower()
if text.startswith(b"<svg") or (text.startswith(b"<?xml") and b"<svg" in text):
return "svg"
if text.startswith((b"<!doctype html", b"<html", b"<script")):
return "html"
return "unknown"
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("files", nargs="+")
ap.add_argument("--allow", default="png,jpeg,gif,webp,pdf",
help="comma-separated detected types to accept (default: %(default)s)")
ap.add_argument("--max-bytes", type=int, default=10 * 1024 * 1024)
args = ap.parse_args()
allow = {a.strip() for a in args.allow.split(",") if a.strip()}
failed = False
for path in args.files:
try:
size = os.path.getsize(path)
with open(path, "rb") as fh:
head = fh.read(512)
except OSError as exc:
print(f"{path}\tERROR\t{exc}")
failed = True
continue
kind = detect(head)
ext = os.path.splitext(path)[1].lower()
problems = []
if kind == "unknown":
problems.append("unrecognised content")
elif kind not in allow:
problems.append(f"type {kind} not in allow-list")
if kind in DANGEROUS and kind in allow:
problems.append(f"{kind} is executable or scriptable; serve only as attachment from a separate origin")
if kind in EXTENSIONS and ext and ext not in EXTENSIONS[kind]:
problems.append(f"extension {ext} does not match content {kind}")
if size > args.max_bytes:
problems.append(f"size {size} exceeds {args.max_bytes}")
if size == 0:
problems.append("empty file")
verdict = "OK" if not problems else "REJECT: " + "; ".join(problems)
print(f"{path}\t{kind}\t{size}B\t{ext or '-'}\t{verdict}")
failed = failed or bool(problems)
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
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.