file-upload-security — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited file-upload-security (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
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.
A user can upload anything labeled as anything. From your perspective:
../../etc/passwd, 🔥.jpg.exe, index.htmlapplication/json, executable bytes claiming to be image/pngDefense: 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.
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.
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.
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:
.., no leading /, no special characters. With a UUID this is automatic.uploads/<userId>/<random> makes per-user access policies trivialIf you must keep the original filename for display, store it as a separate database field, never as part of the storage key.
The bytes are the problem. After validation, transform the file so any embedded payload becomes inert.
Run every image through sharp (or equivalent) to a known format. This:
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.
Re-encoding a PDF is harder than re-encoding an image, but you can:
qpdf / pdfcpu — removes embedded JS that some PDFs use for "interactive" forms (or for malware)# 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.pdfActive content is the issue (VBA macros, OOXML scripting). Three options, increasingly strict:
If you really must accept "any file", treat the upload like a quarantine zone:
Content-Disposition: attachment (forces download, not inline render)See backend-architecture for the "images disappear on redeploy" trap. For uploads specifically:
uploads/<userId>/<key> so IAM policies can restrict access by prefixThe 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:
cdn.example.com or uploads.example.com. Even if XSS lands here, your auth cookies are not in scope.# 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 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:
DOMPurify's SVG mode or svg-sanitizer (PHP) before storingContent-Disposition: attachment even if served as image/svg+xmlFor 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.
For PDFs, Office docs, archives, and "any file" features, run a virus scan. Not perfect, but catches known-malicious files.
// 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.
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).
backup-disaster-recovery. Object storage is durable but not deletion-proof.log-strategy.For a file-upload feature going to production:
sharp / equivalent; EXIF strippedqpdf to strip JS, or converted to PDF/Acdn.example.com, not app.example.com)X-Content-Type-Options: nosniff set on the upload origindefault-src 'none' baseline)Content-Disposition: attachment for non-image typesapi-security)Content-Disposition and a strict CSP~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.