Skip to main content
UncategorizedVibe Coding Security374 lines

Secure File Handling

Quick Summary3 lines
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 lines
Paste into your CLAUDE.md or agent config

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.

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

CheckRisk if Missing
Magic byte validationDisguised executables uploaded
Size limits enforcedStorage exhaustion, decompression bombs
Random filenames generatedPath prediction, overwrites
Storage outside web rootDirect execution of uploads
Path traversal checksArbitrary file read/write
Content-Disposition: attachmentXSS via uploaded HTML/SVG
X-Content-Type-Options: nosniffMIME confusion attacks
Virus scanningMalware distribution
Presigned URLs with expiryUnauthorized 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

Get CLI access →