DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Secure File Upload with Claude Code: S3 Pre-signed URLs and Magic Byte Validation

File upload has more security surface than most features: size limits, MIME type validation, filename sanitization, virus scanning, and storage management. Claude Code generates the full secure upload pipeline.


CLAUDE.md for File Upload Standards

## File Upload Rules

### Security (required)
- File size limits: images 10MB, documents 25MB, videos 500MB
- File type: validate both MIME type AND magic bytes
- Filename: replace with random UUID (never use original filename in path)
- Virus scanning: ClamAV or AWS S3 Malware Scanning
- Storage: S3 only (no local storage in production)

### Allowed file types
- Images: image/jpeg, image/png, image/webp, image/gif
- Documents: application/pdf
- Spreadsheets: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet

### Upload flow
1. Client → API: send metadata → receive pre-signed URL
2. Client → S3: upload directly (bypass server)
3. S3 → API (webhook): upload completion notification
4. API: virus scan → DB registration → issue download URL

### Download URLs
- Pre-signed URL: expiry 1 hour
- No public ACL on sensitive files
Enter fullscreen mode Exit fullscreen mode

Generating Pre-signed URL Upload Flow

Generate an S3 pre-signed URL upload design.

Flow:
1. API issues pre-signed URL
2. Client uploads directly to S3
3. S3 event triggers post-upload processing
4. API records file in DB

Requirements:
- File size limit enforced at URL issuance
- MIME type validation
- Replace filename with UUID
- Download URL expires in 1 hour

Generate:
- src/services/fileUploadService.ts
- src/routes/upload.ts
Enter fullscreen mode Exit fullscreen mode

Generated Upload Service

// src/services/fileUploadService.ts
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { v4 as uuidv4 } from 'uuid';

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

const MAX_FILE_SIZES: Record<string, number> = {
  'image/jpeg': 10 * 1024 * 1024,
  'image/png': 10 * 1024 * 1024,
  'image/webp': 10 * 1024 * 1024,
  'image/gif': 10 * 1024 * 1024,
  'application/pdf': 25 * 1024 * 1024,
};

export class FileUploadService {
  private s3 = new S3Client({ region: process.env.AWS_REGION });
  private bucket = process.env.S3_BUCKET!;

  async createUploadUrl(
    userId: string,
    originalFilename: string,
    mimeType: string,
    fileSize: number
  ): Promise<{ uploadUrl: string; fileKey: string }> {
    // MIME type validation
    if (!ALLOWED_MIME_TYPES.has(mimeType)) {
      throw new BadRequestError(`Unsupported file type: ${mimeType}`);
    }

    // File size validation
    const maxSize = MAX_FILE_SIZES[mimeType];
    if (fileSize > maxSize) {
      throw new BadRequestError(
        `File too large (max: ${maxSize / 1024 / 1024}MB for ${mimeType})`
      );
    }

    // Generate safe path — never use original filename
    const ext = originalFilename.split('.').pop()?.toLowerCase() ?? 'bin';
    const fileKey = `uploads/${userId}/${uuidv4()}.${ext}`;

    const command = new PutObjectCommand({
      Bucket: this.bucket,
      Key: fileKey,
      ContentType: mimeType,
      // Store original name in metadata only (not in path)
      Metadata: {
        'original-filename': encodeURIComponent(originalFilename),
        'uploaded-by': userId,
      },
    });

    // 15-minute upload window
    const uploadUrl = await getSignedUrl(this.s3, command, { expiresIn: 900 });
    return { uploadUrl, fileKey };
  }

  async getDownloadUrl(fileKey: string): Promise<string> {
    const command = new GetObjectCommand({ Bucket: this.bucket, Key: fileKey });
    return getSignedUrl(this.s3, command, { expiresIn: 3600 }); // 1 hour
  }
}
Enter fullscreen mode Exit fullscreen mode

Magic Byte Validation

Generate a magic byte validator to prevent MIME type spoofing.

Validate actual file bytes, not just Content-Type header:
- JPEG, PNG, GIF, WebP, PDF
- Read only first few bytes (not entire file)
- Reject if bytes don't match claimed MIME type

Save to: src/utils/magicBytes.ts
Enter fullscreen mode Exit fullscreen mode
// src/utils/magicBytes.ts
const MAGIC_BYTES: Record<string, number[][]> = {
  'image/jpeg': [[0xff, 0xd8, 0xff]],
  'image/png': [[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]],
  'image/gif': [
    [0x47, 0x49, 0x46, 0x38, 0x37], // GIF87a
    [0x47, 0x49, 0x46, 0x38, 0x39], // GIF89a
  ],
  'image/webp': [[0x52, 0x49, 0x46, 0x46]], // RIFF header
  'application/pdf': [[0x25, 0x50, 0x44, 0x46]], // %PDF
};

export function validateMagicBytes(buffer: Buffer, mimeType: string): boolean {
  const patterns = MAGIC_BYTES[mimeType];
  if (!patterns) return false;

  return patterns.some((pattern) =>
    pattern.every((byte, index) => buffer[index] === byte)
  );
}
Enter fullscreen mode Exit fullscreen mode

Why magic bytes matter: a .php file renamed to .jpg passes MIME type checks but fails magic byte validation.


Image Resize Worker

Generate a BullMQ worker that resizes uploaded images to multiple sizes.

Trigger: S3 upload complete  add to BullMQ
Sizes: thumbnail (150x150), medium (800x600), original (unchanged)
Library: sharp
Output format: WebP (convert all inputs)
Output path: uploads/{userId}/{uuid}-{size}.webp
DB: save all size URLs to files table

Save to: src/workers/imageResizeWorker.ts
Enter fullscreen mode Exit fullscreen mode
import sharp from 'sharp';

const SIZES = {
  thumbnail: { width: 150, height: 150 },
  medium: { width: 800, height: 600 },
};

const imageResizeWorker = new Worker('image-resize', async (job) => {
  const { fileKey, userId, mimeType } = job.data;

  if (!mimeType.startsWith('image/')) return;

  // Download original from S3
  const original = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: fileKey }));
  const buffer = await streamToBuffer(original.Body);
  const baseKey = fileKey.replace(/\.[^.]+$/, '');

  const results: Record<string, string> = {};

  for (const [sizeName, dimensions] of Object.entries(SIZES)) {
    const resized = await sharp(buffer)
      .resize(dimensions.width, dimensions.height, { fit: 'cover' })
      .webp({ quality: 85 })
      .toBuffer();

    const sizeKey = `${baseKey}-${sizeName}.webp`;
    await s3.send(new PutObjectCommand({
      Bucket: bucket,
      Key: sizeKey,
      Body: resized,
      ContentType: 'image/webp',
    }));

    results[sizeName] = sizeKey;
  }

  await prisma.file.update({
    where: { s3Key: fileKey },
    data: { thumbnailKey: results.thumbnail, mediumKey: results.medium },
  });
});
Enter fullscreen mode Exit fullscreen mode

Summary

Design secure file upload with Claude Code:

  1. CLAUDE.md — Size limits, allowed types, UUID filenames, no local storage
  2. Pre-signed URLs — Client uploads directly to S3, server never touches the bytes
  3. Magic byte validation — Prevent MIME type spoofing
  4. Image resize worker — BullMQ handles async resizing to multiple sizes

Security Pack (¥1,480) includes /security-check for file upload security — missing type validation, path traversal risks, local storage usage.

👉 prompt-works.jp

Myouga (@myougatheaxo) — Claude Code engineer focused on secure file handling.

Top comments (0)