AgentStack
Browse Sign in
Browse Why AgentStack Sell Docs
Sign in
SKILL verified MIT Self-run

File Upload Security

skill-goldenwing-360-claude-security-skills-file-upload-security · by GoldenWing-360

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…

No reviews yet
0 installs
29 views
0.0% view→install

Install

$ agentstack add skill-goldenwing-360-claude-security-skills-file-upload-security

✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access Used
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets No
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

View the full security report →

Verified badge

Passed review? Show it. Paste this badge into your README, it links to the public security report.

AgentStack Verified badge Links to your public security report.
[![AgentStack Verified](https://agentstack.voostack.com/badges/verified.svg)](https://agentstack.voostack.com/security/report/skill-goldenwing-360-claude-security-skills-file-upload-security)

Reliability & compatibility

Security review passed
0 installs to date
no reviews yet
3mo ago

Declared compatibility

Claude CodeClaude Desktop

Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.

Preview Execution monitoring

We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.

How agent discovery & health will work →
Are you the author of File Upload Security? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

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](../payload-cms-security/SKILL.md). For WordPress see [wordpress-hardening](../wordpress-hardening/SKILL.md). For storage choice / backend architecture see [backend-architecture](../backend-architecture/SKILL.md).

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 owneruploads// 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](../backend-architecture/SKILL.md) 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 owneruploads// 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 domaincdn.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 ` 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](../backup-disaster-recovery/SKILL.md). Object storage is durable but not deletion-proof.
  • Audit log of uploads — who uploaded what, when, what was the validation result. See [log-strategy](../log-strategy/SKILL.md).
  • 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](../api-security/SKILL.md))
  • [ ] 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

Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet, be the first.

Versions

  • v0.1.0 Imported from the upstream source.