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.

  • Backend & APIs
  • $file-uploads
  • 186 lines
  • 3 files
  • ~2,150 tokens
  • MIT
  • Updated

Install

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

Installs to ~/.agents/skills/file-uploads/ 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-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.

~/.agents/skills/file-uploads/SKILL.md

--- name: file-uploads description: Implements file uploads that hold up in production - streaming multipart parsing with busboy, multer or python-multipart, direct-to-storage uploads… license: MIT ---

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

  1. Read AGENTS.md, the framework and parser in use, and the storage target (local disk, S3, GCS, R2, Azure Blob).
  2. 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.
  3. Ask for the product limits if not stated: allowed types, max size, who can read the file afterwards, retention.

Choose the architecture

SituationPattern
Small files (under ~10 MB), simple appStream multipart through the server to storage
Large files, many uploads, serverless backendDirect-to-storage with a presigned POST policy or PUT URL
Files over ~100 MB or unreliable networksMultipart 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 exact ContentLength if 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 complete step must re-check the object with HeadObject (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);
});
StackLimit setting
multerlimits: { fileSize, files }; errors as MulterError LIMIT_FILE_SIZE
FastAPI / StarletteUploadFile spools to disk after 1 MB; enforce max size yourself while reading chunks
DjangoDATA_UPLOAD_MAX_MEMORY_SIZE, FILE_UPLOAD_MAX_MEMORY_SIZE; check file.size
Spring Bootspring.servlet.multipart.max-file-size, max-request-size
Gohttp.MaxBytesReader before r.MultipartReader() (stream) rather than ParseMultipartForm
nginxclient_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.

OptionHow
ClamAVclamd daemon; stream bytes with INSTREAM (port 3310) or run clamdscan on the object; keep signatures fresh with freshclam
Cloud-managedAmazon GuardDuty Malware Protection for S3, or the provider’s equivalent; consume its result tag or event
Commercial APISend 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.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 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.py108 lines, 3.8 KB
#!/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.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem