Validating Uploads
Validate before saving
Every upload must be validated before any bytes touch the disk. Three checks: is the file small enough? Is it the right type? Is the filename safe?
File size limits
Set a maximum file size. Reject files that exceed it before they finish uploading:
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
export function parseMultipartWithLimits(request: Request): Promise<ParsedUpload> {
return new Promise(async (resolve, reject) => {
const contentType = request.headers.get("content-type");
if (!contentType?.includes("multipart/form-data")) {
reject(new Error("Expected multipart/form-data"));
return;
}
const busboy = Busboy({
headers: { "content-type": contentType },
limits: {
fileSize: MAX_FILE_SIZE, // busboy enforces this per-file
files: 1, // only accept one file
},
});
let fileTruncated = false;
busboy.on("file", (fieldName, stream, info) => {
stream.on("limit", () => {
fileTruncated = true;
// The stream will end — busboy truncates it
});
// ... handle file
});
busboy.on("close", () => {
if (fileTruncated) {
reject(new Error(`File exceeds ${MAX_FILE_SIZE / 1024 / 1024} MB limit`));
return;
}
resolve({ fields, file: fileResult });
});
// ... pipe request body
});
} Busboy’s limits.fileSize stops reading after the limit is reached. The limit event fires on the file stream, and we reject with an error.
[!WARNING] Always check the
Content-Lengthheader too, as a first line of defense. But do not trust it alone — the header can lie. Busboy’s streaming limit is the authoritative check.
MIME type validation
Do not trust the client’s declared MIME type. Validate by checking the file’s magic bytes (the first few bytes that identify the format):
import { fileTypeFromBuffer } from "file-type";
const ALLOWED_TYPES = new Set([
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"application/pdf",
]);
async function validateMimeType(firstChunk: Buffer, declaredType: string): Promise<string | null> {
// Check magic bytes
const detected = await fileTypeFromBuffer(firstChunk);
const actualType = detected?.mime ?? declaredType;
if (!ALLOWED_TYPES.has(actualType)) {
return null; // Rejected
}
return actualType;
} [!NOTE] The
file-typepackage reads the first bytes of a file to detect its actual type, regardless of the extension or declared Content-Type. Install it withnpm install file-type.
The declared MIME type (image/jpeg) can be faked. The magic bytes cannot — a JPEG always starts with FF D8 FF, a PNG with 89 50 4E 47. Checking magic bytes prevents an attacker from uploading a script disguised as an image.
Filename sanitization
Never use the original filename as-is. It could contain path traversal characters (../../etc/passwd), special characters that break file systems, or be unreasonably long.
import { extname } from "node:path";
function sanitizeFilename(original: string): string {
// Extract extension
const ext = extname(original).toLowerCase();
// Generate a safe, unique name
return `${crypto.randomUUID()}${ext}`;
} The simplest approach: ignore the original filename entirely and generate a UUID. Store the original name in the database (for display) and use the UUID on disk.
If you need to preserve the original name for some reason, strip dangerous characters:
function sanitizePreserveName(original: string): string {
return original
.replace(/[^a-zA-Z0-9._-]/g, "_") // Replace special chars with underscore
.replace(/\.{2,}/g, ".") // Collapse multiple dots
.replace(/^\./, "_") // No leading dot (hidden files)
.slice(0, 200); // Limit length
} Putting it together
route.post("/upload", {
resolve: async (c) => {
try {
const { fields, file } = await parseMultipartWithLimits(c.request);
if (!file) {
return Response.json({ error: "No file uploaded" }, { status: 400 });
}
// Validate MIME type (check first chunk)
const chunks: Buffer[] = [];
for await (const chunk of file.stream) {
chunks.push(Buffer.from(chunk));
}
const buffer = Buffer.concat(chunks);
const validType = await validateMimeType(buffer, file.mimeType);
if (!validType) {
return Response.json(
{ error: `File type not allowed. Allowed: ${[...ALLOWED_TYPES].join(", ")}` },
{ status: 400 },
);
}
// Sanitize filename
const storedName = sanitizeFilename(file.filename);
// Next lesson: save to disk
return Response.json({
originalName: file.filename,
storedName,
mimeType: validType,
size: buffer.length,
});
} catch (err: any) {
if (err.message?.includes("limit")) {
return Response.json({ error: err.message }, { status: 413 });
}
return Response.json({ error: "Upload failed" }, { status: 500 });
}
},
}); Status 413 (Payload Too Large) for files that exceed the size limit.
Exercises
Exercise 1: Upload a file larger than the limit. Verify you get 413.
Exercise 2: Upload a .txt file when only images are allowed. Verify it is rejected.
Exercise 3: Upload a file named ../../etc/passwd.jpg. Verify the stored name is a UUID, not the malicious path.
Exercise 4: Rename a .txt file to .jpg and upload it. Verify the magic byte check catches the mismatch.
Why do we check magic bytes instead of trusting the declared MIME type?