Secure File Handling
AI-generated file handling code accepts any upload, stores it in the web root, serves it with the original filename, and never validates the content. A user uploads `../../../etc/cron.d/backdoor` and AI's code writes it exactly where the path says. File handling is one of the most exploited surfaces in web applications, and AI gets it wrong every time.
skilldb get vibe-coding-security-skills/secure-file-handlingFull skill: 374 linesSecure File Handling
AI-generated file handling code accepts any upload, stores it in the web root, serves it with the original filename, and never validates the content. A user uploads ../../../etc/cron.d/backdoor and AI's code writes it exactly where the path says. File handling is one of the most exploited surfaces in web applications, and AI gets it wrong every time.
This skill covers upload validation, safe storage, path traversal prevention, and secure file serving.
Upload Validation
The Multi-Layer Approach
Never trust a single validation method. File extensions lie. MIME types lie. Only magic bytes (the actual file content) reveal what a file really is.
import { fileTypeFromBuffer } from 'file-type';
import path from 'path';
import crypto from 'crypto';
interface UploadConfig {
mime: string;
maxSize: number;
extensions: string[];
}
const ALLOWED_UPLOADS: Map<string, UploadConfig> = new Map([
['image/jpeg', { mime: 'image/jpeg', maxSize: 5 * 1024 * 1024, extensions: ['.jpg', '.jpeg'] }],
['image/png', { mime: 'image/png', maxSize: 5 * 1024 * 1024, extensions: ['.png'] }],
['image/webp', { mime: 'image/webp', maxSize: 5 * 1024 * 1024, extensions: ['.webp'] }],
['application/pdf', { mime: 'application/pdf', maxSize: 20 * 1024 * 1024, extensions: ['.pdf'] }],
]);
async function validateUpload(
buffer: Buffer,
originalName: string,
claimedMime: string
): Promise<{ safeName: string; mime: string; size: number }> {
// 1. Size check (cheapest check first)
if (buffer.length === 0) {
throw new Error('Empty file');
}
if (buffer.length > 20 * 1024 * 1024) {
throw new Error('File exceeds maximum size');
}
// 2. Magic byte detection (actual content type)
const detected = await fileTypeFromBuffer(buffer);
if (!detected) {
throw new Error('Unable to determine file type');
}
// 3. Check against allowlist
const config = ALLOWED_UPLOADS.get(detected.mime);
if (!config) {
throw new Error(`File type not allowed: ${detected.mime}`);
}
// 4. Verify extension matches content
const ext = path.extname(originalName).toLowerCase();
if (!config.extensions.includes(ext)) {
throw new Error(`Extension ${ext} does not match detected type ${detected.mime}`);
}
// 5. Type-specific size limit
if (buffer.length > config.maxSize) {
throw new Error(`File exceeds size limit for ${detected.mime}`);
}
// 6. Generate safe filename — NEVER use the original name for storage
const safeName = `${crypto.randomUUID()}${config.extensions[0]}`;
return { safeName, mime: detected.mime, size: buffer.length };
}
Image-Specific Validation
import sharp from 'sharp';
async function validateImage(buffer: Buffer): Promise<{ width: number; height: number }> {
try {
const metadata = await sharp(buffer).metadata();
// Dimension limits — prevent decompression bombs
if (!metadata.width || !metadata.height) {
throw new Error('Invalid image dimensions');
}
if (metadata.width > 8192 || metadata.height > 8192) {
throw new Error('Image dimensions exceed maximum (8192x8192)');
}
// Pixel count limit — a 1x100000 image is small in bytes but huge in memory
if (metadata.width * metadata.height > 25_000_000) {
throw new Error('Image pixel count exceeds maximum');
}
// Re-encode the image to strip metadata and potential exploits
// This also neutralizes polyglot files (images that are also valid HTML/JS)
const cleanBuffer = await sharp(buffer)
.resize(metadata.width, metadata.height) // Keep dimensions
.toBuffer();
return { width: metadata.width, height: metadata.height };
} catch (err) {
if (err.message.includes('exceed')) throw err;
throw new Error('File is not a valid image');
}
}
Storage Sandboxing
Never store uploads in the web root. Never store them where the application can execute them.
import { Storage } from '@google-cloud/storage';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
// Cloud storage (preferred) — files are never on the app server
class SecureFileStorage {
private s3: S3Client;
private bucket: string;
constructor() {
this.s3 = new S3Client({ region: process.env.AWS_REGION });
this.bucket = process.env.UPLOAD_BUCKET;
}
async store(buffer: Buffer, safeName: string, mime: string): Promise<string> {
const key = `uploads/${new Date().toISOString().slice(0, 10)}/${safeName}`;
await this.s3.send(new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: buffer,
ContentType: mime,
ContentDisposition: 'attachment', // Force download, never render inline
ServerSideEncryption: 'aws:kms',
// No public ACL — access only via presigned URLs
}));
return key; // Store this in your database, not the original filename
}
}
// Local storage (if you must) — outside web root, restricted permissions
class LocalFileStorage {
private basePath: string;
constructor() {
this.basePath = '/var/app/uploads'; // OUTSIDE web root
}
async store(buffer: Buffer, safeName: string): Promise<string> {
const datePath = new Date().toISOString().slice(0, 10);
const dir = path.join(this.basePath, datePath);
await fs.mkdir(dir, { recursive: true });
const filePath = path.join(dir, safeName);
await fs.writeFile(filePath, buffer, { mode: 0o444 }); // Read-only
return path.join(datePath, safeName);
}
}
Path Traversal Prevention
The most critical file handling vulnerability. AI-generated code that uses user input in file paths is almost always vulnerable.
// VULNERABLE: What AI generates
app.get('/api/files/:filename', (req, res) => {
const filePath = path.join('/uploads', req.params.filename);
res.sendFile(filePath);
// Attack: GET /api/files/../../etc/passwd
// path.join('/uploads', '../../etc/passwd') = '/etc/passwd'
});
// SAFE: Validate the resolved path stays within the allowed directory
function safePath(basePath: string, userInput: string): string {
// Resolve to absolute path
const resolved = path.resolve(basePath, userInput);
// Verify it's still within the base directory
if (!resolved.startsWith(path.resolve(basePath) + path.sep) &&
resolved !== path.resolve(basePath)) {
throw new Error('Path traversal detected');
}
return resolved;
}
app.get('/api/files/:filename', requireAuth, (req, res) => {
try {
const filePath = safePath('/var/app/uploads', req.params.filename);
res.sendFile(filePath);
} catch {
res.status(400).json({ error: 'Invalid file path' });
}
});
// Even safer: don't use filenames at all — use database IDs
app.get('/api/files/:id', requireAuth, async (req, res) => {
const file = await db.query(
'SELECT storage_path, mime_type, original_name FROM files WHERE id = $1 AND user_id = $2',
[req.params.id, req.user.id] // Also checks ownership
);
if (!file.rows.length) {
return res.status(404).json({ error: 'File not found' });
}
const { storage_path, mime_type, original_name } = file.rows[0];
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(original_name)}"`);
res.setHeader('Content-Type', mime_type);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.sendFile(path.join('/var/app/uploads', storage_path));
});
Serving Files Safely
// Security headers for file downloads
function setFileHeaders(res: Response, filename: string, mime: string) {
// Force download — never render user-uploaded content inline
res.setHeader(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(filename)}"`
);
// Prevent MIME type sniffing
res.setHeader('X-Content-Type-Options', 'nosniff');
// Prevent the file from accessing the page that linked to it
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
// Content Security Policy for the response
res.setHeader('Content-Security-Policy', "default-src 'none'");
// Set correct Content-Type
res.setHeader('Content-Type', mime);
}
Presigned URLs
The safest approach for serving files: never proxy file content through your server.
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
async function generateDownloadUrl(
fileKey: string,
originalName: string
): Promise<string> {
const command = new GetObjectCommand({
Bucket: process.env.UPLOAD_BUCKET,
Key: fileKey,
ResponseContentDisposition: `attachment; filename="${encodeURIComponent(originalName)}"`,
ResponseContentType: 'application/octet-stream',
});
return getSignedUrl(s3Client, command, {
expiresIn: 300, // 5 minutes — short-lived
});
}
// For uploads — client uploads directly to S3
import { PutObjectCommand } from '@aws-sdk/client-s3';
async function generateUploadUrl(
safeName: string,
mime: string,
maxSize: number
): Promise<string> {
const command = new PutObjectCommand({
Bucket: process.env.UPLOAD_BUCKET,
Key: `uploads/${safeName}`,
ContentType: mime,
ServerSideEncryption: 'aws:kms',
});
return getSignedUrl(s3Client, command, { expiresIn: 300 });
}
// API endpoint
app.post('/api/files/upload-url', requireAuth, async (req, res) => {
const { filename, mime } = UploadRequestSchema.parse(req.body);
// Validate the request
const safeName = `${crypto.randomUUID()}${path.extname(filename)}`;
const url = await generateUploadUrl(safeName, mime, 5 * 1024 * 1024);
// Record in database
await db.query(
'INSERT INTO files (id, user_id, storage_key, original_name, mime_type, status) VALUES ($1, $2, $3, $4, $5, $6)',
[crypto.randomUUID(), req.user.id, `uploads/${safeName}`, filename, mime, 'pending']
);
res.json({ uploadUrl: url, fileId: safeName });
});
Virus Scanning Integration
import NodeClam from 'clamscan';
const clam = await new NodeClam().init({
clamdscan: {
socket: '/var/run/clamav/clamd.ctl',
timeout: 30000,
},
});
async function scanFile(buffer: Buffer): Promise<boolean> {
// Write to temp file for scanning
const tmpPath = path.join('/tmp', crypto.randomUUID());
await fs.writeFile(tmpPath, buffer);
try {
const { isInfected, viruses } = await clam.isInfected(tmpPath);
if (isInfected) {
logger.warn({ msg: 'Malware detected', viruses });
return false;
}
return true;
} finally {
await fs.unlink(tmpPath).catch(() => {});
}
}
// In upload pipeline
app.post('/api/upload', upload.single('file'), async (req, res) => {
const { safeName, mime, size } = await validateUpload(
req.file.buffer, req.file.originalname, req.file.mimetype
);
const isClean = await scanFile(req.file.buffer);
if (!isClean) {
return res.status(400).json({ error: 'File failed security scan' });
}
await storage.store(req.file.buffer, safeName, mime);
res.json({ fileId: safeName });
});
File Handling Checklist
| Check | Risk if Missing |
|---|---|
| Magic byte validation | Disguised executables uploaded |
| Size limits enforced | Storage exhaustion, decompression bombs |
| Random filenames generated | Path prediction, overwrites |
| Storage outside web root | Direct execution of uploads |
| Path traversal checks | Arbitrary file read/write |
| Content-Disposition: attachment | XSS via uploaded HTML/SVG |
| X-Content-Type-Options: nosniff | MIME confusion attacks |
| Virus scanning | Malware distribution |
| Presigned URLs with expiry | Unauthorized permanent access |
| Database ID references (not filenames) | Path traversal, enumeration |
Every file a user uploads is untrusted input. It could be malware, a polyglot file, or a path traversal payload. Validate the content, store it safely, serve it carefully, and never use the original filename for anything except display.
Install this skill directly: skilldb add vibe-coding-security-skills