agentleFS
GoldenWing-360GoldenWing-360/claude-security-skills/file-upload-security/SKILL.md

file-upload-security

Skill16 starsChanged 6 months agoMITView on GitHub

Accept user file uploads without introducing remote code execution, stored XSS, or polyglot attacks. Covers magic-byte validation, strict type allowlists, image re-encoding to defang embedded payloads, EXIF stripping, virus scanning, path-safe storage keys, and serving via a separate origin with Content-Disposition. Invoke when adding upload to a new endpoint or migrating from local-disk storage to object storage.

2 other projects use a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

7 rules. About 0 are common, 5 are distinctive, and 2 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈5

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Never store with the client-supplied name.

    line 87
  2. Distinctive:

    If you must keep the original filename for display, store it as a separate database field, never as part of the storage key.

    line 103
  3. Very specific:

    Run every image through sharp (or equivalent) to a known format.

    line 111
  4. Distinctive:

    If you really must accept "any file", treat the upload like a quarantine zone:

    line 158
  5. Distinctive:

    Never serve from a domain that can execute

    line 160
  6. Very specific:

    Always with Content-Disposition: attachment (forces download, not inline render)

    line 161
  7. Distinctive:

    Don't accept SVG: if you can avoid it (most "image upload" features don't need vector)

    line 213

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

File Upload Security

User-uploaded files are one of the highest-leverage attack surfaces. A single endpoint that accepts * MIME types and writes to a server-served path is a path to RCE, XSS, SSRF, stored-XSS in PDFs, and a half-dozen other failure modes. The defaults of every web framework do less than they should — this skill is what to add on top.

Generic, not CMS-specific. For Payload-specific tuning see payload-cms-security. For WordPress see wordpress-hardening. For storage choice / backend architecture see backend-architecture.

When to invoke

  • Adding file upload to an endpoint for the first time
  • A user-uploaded file caused or contributed to an incident
  • Migrating from local-disk uploads to object storage (R2 / S3 / Spaces)
  • Reviewing an existing upload feature you inherited
  • Adding new file types (e.g. previously images only, now PDFs too)

The threat model

A user can upload anything labeled as anything. From your perspective:

  • The filename is attacker-controlled — ../../etc/passwd, 🔥.jpg.exe, index.html
  • The Content-Type header is attacker-controlled — JPG bytes claiming to be application/json, executable bytes claiming to be image/png
  • The file extension is attacker-controlled
  • The file contents may be a polyglot — bytes that are valid as image and valid as HTML / JavaScript / PHP

Defense: trust nothing the client says about the file. Inspect the bytes, re-derive the type, generate your own key for storage, serve from an origin that cannot execute anything.

Step 1 — Validate at the boundary

Size limits, server-side

import multer from 'multer';

const upload = multer({
  storage: multer.memoryStorage(),
  limits: {
    fileSize: 10 * 1024 * 1024,    // 10 MB hard cap
    files: 5,
    fields: 20,
    fieldSize: 1024,
    fieldNameSize: 100,
  },
});

app.post('/api/upload', requireAuth, upload.single('file'), async (req, res) => {
  if (!req.file) return res.status(400).json({ error: 'no file' });
  // ... continue with content validation
});

Defaults from middleware libraries are too generous (50MB+). Set what you actually need. Different endpoints get different caps — avatar upload doesn't need 10MB.

Magic-byte detection (not MIME)

Content-Type is a hint. Read the first bytes to determine the actual type.

import { fileTypeFromBuffer } from 'file-type';

const detected = await fileTypeFromBuffer(req.file.buffer);
if (!detected) {
  return res.status(400).json({ error: 'unrecognized file type' });
}

const ALLOWED_MIMES = new Set([
  'image/jpeg',
  'image/png',
  'image/webp',
  'application/pdf',
]);

if (!ALLOWED_MIMES.has(detected.mime)) {
  return res.status(400).json({ error: `type ${detected.mime} not allowed` });
}

For Python: python-magic (libmagic bindings). For Go: mimetype. For Ruby: marcel.

Allowlist, not denylist. "Anything except .exe and .php" misses .phtml, .phar, .shtml, .cgi, .pl, .jsp, .aspx, and tomorrow's bypass.

Filename — never trust the client's

Never store with the client-supplied name. Generate one server-side.

import { randomUUID } from 'node:crypto';

const ext = detected.ext;                     // from file-type, not from filename
const storageKey = `uploads/${userId}/${randomUUID()}.${ext}`;

Patterns to enforce in the storage key:

  • Generated, not user-supplied — UUID, ULID, or content-hash (SHA-256 of bytes — also gives you free dedup)
  • No path traversal possible — no .., no leading /, no special characters. With a UUID this is automatic.
  • Scoped by owner — uploads/<userId>/<random> makes per-user access policies trivial
  • No executable extensions — extension comes from server-detected type, not client filename

If you must keep the original filename for display, store it as a separate database field, never as part of the storage key.

Step 2 — Defang content

The bytes are the problem. After validation, transform the file so any embedded payload becomes inert.

Images: re-encode

Run every image through sharp (or equivalent) to a known format. This:

  • Strips polyglot bytes — the re-encoded image is just the visual content
  • Removes EXIF metadata — including geotags (PII leak)
  • Normalizes the format — your storage layer holds known-good images
import sharp from 'sharp';

const processed = await sharp(req.file.buffer)
  .rotate()                              // honor EXIF orientation, then drop EXIF
  .resize({ width: 4096, height: 4096, fit: 'inside', withoutEnlargement: true })
  .jpeg({ quality: 85, progressive: true })  // or .webp() for modern
  .withMetadata({ exif: {} })            // strip EXIF
  .toBuffer();

// Upload `processed`, not `req.file.buffer`

A PHP webshell embedded in a JPEG file does not survive re-encoding. So does most ImageTragick-style abuse. The cost is a few hundred ms of CPU per upload.

PDFs and documents

Re-encoding a PDF is harder than re-encoding an image, but you can:

  • Strip JavaScript with qpdf / pdfcpu — removes embedded JS that some PDFs use for "interactive" forms (or for malware)
  • Convert to PDF/A — a more restricted PDF subtype, no JS allowed
  • Render to image + new PDF — destroys text but eliminates everything else; only when you really need it (legal redaction, etc.)
# Strip JavaScript and re-write the PDF
qpdf --decrypt --remove-restrictions input.pdf cleaned.pdf

# Or convert to PDF/A
qpdf --object-streams=disable --linearize input.pdf cleaned.pdf

Office documents

Active content is the issue (VBA macros, OOXML scripting). Three options, increasingly strict:

  1. Scan for macros — reject if present (LibreOffice headless can introspect)
  2. Convert to PDF server-side and store the PDF — destroys macros and most other active content
  3. Don't accept Office docs — accept PDF only, instruct users to convert client-side

Generic / unknown types

If you really must accept "any file", treat the upload like a quarantine zone:

  • Never serve from a domain that can execute
  • Always with Content-Disposition: attachment (forces download, not inline render)
  • Pass through a virus scanner (ClamAV is free, decent for known malware) — see Step 4

Step 3 — Storage and serving

Storage: object storage, not local disk

See backend-architecture for the "images disappear on redeploy" trap. For uploads specifically:

  • S3-compatible storage (S3, R2, B2, Spaces, MinIO if self-hosted)
  • Private bucket with signed URLs for access — never make the bucket public unless the content really is public
  • Path scoped by owner — uploads/<userId>/<key> so IAM policies can restrict access by prefix

Serving: separate origin, no script execution

The cardinal sin: serving user uploads from the same origin as your app, with Content-Type derived from extension and no Content-Disposition. An attacker uploads xss.html, your server serves it as text/html from your app's origin, the attacker links to it, and now they have stored XSS with same-origin access to your auth cookies.

Patterns:

  • Serve from a separate domain — cdn.example.com or uploads.example.com. Even if XSS lands here, your auth cookies are not in scope.
  • Content-Disposition: attachment for non-image uploads — forces download instead of inline rendering for browsers that would otherwise sniff into something dangerous
  • Strict Content-Type — match the server-detected type from Step 1, not the original
  • X-Content-Type-Options: nosniff — disables browser MIME-sniffing
  • Content-Security-Policy: default-src 'none' for the uploads origin — even if HTML lands there, the CSP refuses to render scripts/images/anything
  • No directory listing on the bucket
# Example serving config for the upload origin (nginx fronting R2/S3)
server {
    listen 443 ssl;
    server_name uploads.example.com;

    add_header X-Content-Type-Options nosniff always;
    add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'unsafe-inline';" always;
    add_header Strict-Transport-Security "max-age=31536000" always;

    # Force download for anything not in the image allowlist
    location ~* \.(jpg|jpeg|png|webp|gif|svg)$ {
        # served inline as images
    }
    location / {
        add_header Content-Disposition "attachment" always;
    }
}

SVG is special

SVG is XML with <script> support. If a user uploads evil.svg and you serve it as image/svg+xml, browsers render the SVG and execute its scripts. SVG is effectively HTML in disguise.

Options:

  • Don't accept SVG if you can avoid it (most "image upload" features don't need vector)
  • Sanitize SVG with DOMPurify's SVG mode or svg-sanitizer (PHP) before storing
  • Force download with Content-Disposition: attachment even if served as image/svg+xml

Signed URLs for private content

For per-user / per-tenant uploads (not public), generate short-lived signed URLs at access time:

import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

app.get('/api/files/:id', requireAuth, async (req, res) => {
  const file = await db.files.findFirst({
    where: { id: req.params.id, userId: req.user.id }      // BOLA check — see api-security
  });
  if (!file) return res.status(404).end();

  const url = await getSignedUrl(
    s3,
    new GetObjectCommand({ Bucket: 'uploads', Key: file.storageKey }),
    { expiresIn: 300 }  // 5 minutes
  );
  res.json({ url });
});

5-minute TTL is plenty for direct downloads; longer only if needed.

Step 4 — Virus scanning (for non-image uploads)

For PDFs, Office docs, archives, and "any file" features, run a virus scan. Not perfect, but catches known-malicious files.

  • ClamAV is free, self-hostable, decent for known malware. Run as a daemon, scan via socket.
  • VirusTotal API for higher fidelity (paid) — also gives you reputation signals
  • Hosted scanners — Cloudflare (with paid tier features), bundled with some CDN providers
// Sketch — clamav-client style
import { Clam } from 'clamav.js';
const clam = new Clam({ host: '127.0.0.1', port: 3310 });

const scanResult = await clam.scan(req.file.buffer);
if (scanResult.isInfected) {
  await db.uploadAttempts.create({
    data: { userId: req.user.id, reason: 'av-positive', signature: scanResult.viruses.join(',') }
  });
  return res.status(400).json({ error: 'file rejected' });
}

False positives happen — log them, allow operator review, don't punish the user with a confusing error.

Step 5 — Pre-signed PUT for large uploads

For files > a few MB, having the user upload through your app server is wasteful. Pre-signed PUT URLs let the browser upload directly to S3/R2 while your app stays in control:

import { PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

app.post('/api/uploads/request', requireAuth, async (req, res) => {
  const { filename, size, mime } = req.body;

  // Server-side validation BEFORE issuing the URL
  if (size > 100 * 1024 * 1024) return res.status(400).json({ error: 'too large' });
  if (!ALLOWED_MIMES.has(mime)) return res.status(400).json({ error: 'type not allowed' });

  const storageKey = `uploads/${req.user.id}/${randomUUID()}`;
  const url = await getSignedUrl(s3, new PutObjectCommand({
    Bucket: 'uploads',
    Key: storageKey,
    ContentType: mime,
    ContentLength: size,
  }), { expiresIn: 300 });

  // Record the pending upload so we know what to validate after
  await db.pendingUploads.create({ data: { storageKey, userId: req.user.id, expectedSize: size, expectedMime: mime }});

  res.json({ url, storageKey });
});

// Browser PUTs the file directly to `url`. After upload completes,
// the client calls /api/uploads/confirm with the storageKey.

app.post('/api/uploads/confirm', requireAuth, async (req, res) => {
  const { storageKey } = req.body;
  const pending = await db.pendingUploads.findFirst({
    where: { storageKey, userId: req.user.id }
  });
  if (!pending) return res.status(404).json({ error: 'no pending upload' });

  // Fetch the uploaded object, validate magic bytes server-side, then re-encode/scan
  const obj = await s3.send(new GetObjectCommand({ Bucket: 'uploads', Key: storageKey }));
  // ... validation, re-encoding, scanning as usual
  // If validation fails, delete the object
});

The browser uploads big files efficiently; your app stays the gatekeeper for validation, scanning, and re-encoding (via a worker that picks up confirmed uploads).

Step 6 — Operational hygiene

  • Orphan cleanup — pending uploads that never confirmed, abandoned files when records are deleted. Daily cron.
  • Per-user quota — soft cap with notification, hard cap with rejection. Prevents one user filling your bucket.
  • Per-IP / per-account rate limit on upload endpoints — stops bulk abuse
  • Backup the bucket — see backup-disaster-recovery. Object storage is durable but not deletion-proof.
  • Audit log of uploads — who uploaded what, when, what was the validation result. See log-strategy.
  • Periodic re-scan — virus signatures update; what passed yesterday may be flagged today. Re-scan high-value buckets monthly.

Checklist

For a file-upload feature going to production:

  • Server-side size cap matches actual need (not framework default)
  • Magic-byte detection used; MIME header from client ignored
  • Strict type allowlist (not denylist)
  • Storage key generated server-side (UUID / hash); no client filename in the path
  • Images re-encoded through sharp / equivalent; EXIF stripped
  • PDFs run through qpdf to strip JS, or converted to PDF/A
  • SVG not accepted, or sanitized before storage
  • Office docs converted to PDF server-side, or rejected
  • Stored in object storage with a private bucket; signed URLs for access
  • Served from a separate origin (cdn.example.com, not app.example.com)
  • X-Content-Type-Options: nosniff set on the upload origin
  • Strict CSP on the upload origin (default-src 'none' baseline)
  • Content-Disposition: attachment for non-image types
  • Virus scan in place for non-image uploads
  • Per-user quota and per-IP rate limit
  • BOLA-safe download: owner check on every fetch (see api-security)
  • Orphan / abandoned upload cleanup runs daily
  • Bucket itself is backed up

What this skill will not do

  • Help build upload features for systems you do not own
  • Endorse serving user uploads from the application's own origin without Content-Disposition and a strict CSP
  • Recommend "trust the client's MIME header" for any production system

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.