Menangani file upload secara konvensional pada Next.js App Router melalui Route Handler menimbulkan dua risiko fatal: ancaman keamanan data dan kehabisan memori serverless runtime. Membaca multipart stream langsung ke dalam memori Node.js pada lingkungan stateless (seperti Vercel Functions atau AWS Lambda) menyebabkan memory exhaustion seketika saat menerima berkas besar atau serangan denial-of-service (DoS).
Hardening upload membutuhkan pemindahan beban I/O langsung ke object storage (AWS S3) via Presigned URL, pembatasan policy pada level storage engine, serta verifikasi integritas biner (magic bytes) untuk mencegah bypass validasi tipe file.
Vulnerabilitas Utama Upload pada Serverless
Tiga celah krusial yang kerap luput saat membangun endpoint upload di Next.js:
- Memory Exhaustion via Multipart Buffering: Route Handler yang mengeksekusi
request.formData()atau mengumpulkan buffer di memori akan menghabiskan batas RAM serverless (default 1024MB). Konkurensi tinggi pada payload puluhan megabyte memicu crashOut of Memory (OOM). - Client MIME Type Spoofing: Properti
file.typedari browser membaca metadata header HTTPContent-Typeatau ekstensi file yang dikirimkan client. Penyerang dapat mengunggah skrip PHP/Node/HTML berbahaya dengan header palsuimage/pngmenggunakan HTTP client seperti cURL atau Burp Suite. - Stored XSS via File SVG: Format SVG adalah dokumen XML berbasis teks yang mendukung tag
<script>dan inline handler sepertionload="alert(document.cookie)". Jika file SVG disajikan langsung dari domain aplikasi tanpa header sanitasi yang ketat, browser akan mengeksekusi JavaScript di dalam origin sesi pengguna.
Arsitektur Direct Upload: S3 Presigned POST
Solusi standar industri untuk menghindari pemrosesan buffer file pada server Next.js adalah Direct Upload. Server aplikasi hanya bertugas mengotentikasi request dan menerbitkan S3 Presigned POST URL dengan policy terbatas. Browser kemudian mengunggah file langsung ke AWS S3.
Keunggulan createPresignedPost dibanding Presigned PUT biasa adalah kemampuannya menegakkan batasan ukuran via kondisi content-length-range langsung di level gateway AWS S3, sehingga transfer file yang melebihi kuota langsung dibatalkan sebelum storage terbebani.
1. Endpoint Pembuat Presigned POST
// app/api/upload/presign/route.ts
import { NextRequest, NextResponse } from "next/server";
import { S3Client } from "@aws-sdk/client-s3";
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
import crypto from "crypto";
const s3Client = new S3Client({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
const ALLOWED_MIME_TYPES = new Set([
"image/jpeg",
"image/png",
"image/webp",
]);
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { contentType } = body;
if (!contentType || !ALLOWED_MIME_TYPES.has(contentType)) {
return NextResponse.json(
{ error: "Tipe file tidak diizinkan atau tidak valid." },
{ status: 400 }
);
}
// Hindari nama file asli dari user. Gunakan random UUID.
const fileId = crypto.randomUUID();
const key = `uploads/${fileId}`;
const presignedPost = await createPresignedPost(s3Client, {
Bucket: process.env.AWS_S3_BUCKET_NAME!,
Key: key,
Conditions: [
["content-length-range", 1024, MAX_FILE_SIZE_BYTES],
["eq", "$Content-Type", contentType],
],
Fields: {
"Content-Type": contentType,
// Isolasi eksekusi: paksa download jika format berisiko
"Content-Disposition": "attachment",
},
Expires: 300, // 5 menit
});
return NextResponse.json({ data: presignedPost, fileKey: key });
} catch (err) {
return NextResponse.json(
{ error: "Gagal menerbitkan signature upload." },
{ status: 500 }
);
}
}
Validasi Magic Byte (File Signature)
Membatasi ekstensi atau HTTP MIME type saja tidak cukup. Validasi biner sesungguhnya dilakukan dengan memeriksa magic bytes (urutan byte unik di awal file). Contoh signature:
- JPEG:
FF D8 FF - PNG:
89 50 4E 47 0D 0A 1A 0A - WebP:
52 49 46 46(offset 0..3) dan57 45 42 50(offset 8..11)
Verifikasi di Sisi Klien Sebelum Upload
Klien membaca chunk pertama file menggunakan File.slice() dan FileReader tanpa memuat seluruh file ke RAM:
// lib/validate-magic-bytes.ts
export async function validateImageMagicBytes(file: File): Promise<boolean> {
const slice = file.slice(0, 12);
const buffer = await slice.arrayBuffer();
const bytes = new Uint8Array(buffer);
// JPEG: FF D8 FF
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return true;
}
// PNG: 89 50 4E 47 0D 0A 1A 0A
if (
bytes[0] === 0x89 &&
bytes[1] === 0x50 &&
bytes[2] === 0x4e &&
bytes[3] === 0x47 &&
bytes[4] === 0x0d &&
bytes[5] === 0x0a &&
bytes[6] === 0x1a &&
bytes[7] === 0x0a
) {
return true;
}
// WebP: RIFF....WEBP
const isRiff = bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46;
const isWebp = bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
if (isRiff && isWebp) {
return true;
}
return false;
}
Verifikasi Server-Side via Byte-Range Fetch
Validasi di sisi klien dapat dilewati dengan memodifikasi skrip. Jangan pernah menyimpan metadata file ke database utama sebelum server Next.js memvalidasi integritas biner file yang sudah terunggah ke S3.
Gunakan HTTP Range Request (mengambil hanya 16 byte pertama dari S3 via GetObjectCommand) agar beban komputasi dan transfer bandwidth serverless tetap mendekati nol.
// app/api/upload/verify/route.ts
import { NextRequest, NextResponse } from "next/server";
import { S3Client, GetObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
const s3Client = new S3Client({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
function matchesMagicBytes(header: Uint8Array): boolean {
// Check PNG
if (
header[0] === 0x89 &&
header[1] === 0x50 &&
header[2] === 0x4e &&
header[3] === 0x47
) return true;
// Check JPEG
if (header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff) {
return true;
}
// Check WebP
if (
header[0] === 0x52 &&
header[1] === 0x49 &&
header[2] === 0x46 &&
header[3] === 0x46 &&
header[8] === 0x57 &&
header[9] === 0x45 &&
header[10] === 0x42 &&
header[11] === 0x50
) return true;
return false;
}
export async function POST(req: NextRequest) {
try {
const { fileKey } = await req.json();
if (!fileKey || typeof fileKey !== "string" || !fileKey.startsWith("uploads/")) {
return NextResponse.json({ error: "Format key tidak valid." }, { status: 400 });
}
// Ambil hanya 16 byte pertama menggunakan HTTP Range
const response = await s3Client.send(
new GetObjectCommand({
Bucket: process.env.AWS_S3_BUCKET_NAME!,
Key: fileKey,
Range: "bytes=0-15",
})
);
if (!response.Body) {
return NextResponse.json({ error: "Berkas tidak ditemukan." }, { status: 404 });
}
const chunk = await response.Body.transformToByteArray();
if (!matchesMagicBytes(chunk)) {
// Hapus file berbahaya segera
await s3Client.send(
new DeleteObjectCommand({
Bucket: process.env.AWS_S3_BUCKET_NAME!,
Key: fileKey,
})
);
return NextResponse.json(
{ error: "Integritas file ditolak: signature biner tidak cocok." },
{ status: 403 }
);
}
// ponytail: Simpan status aktif ke DB di sini setelah validasi lolos
return NextResponse.json({ success: true, fileKey });
} catch (err) {
return NextResponse.json(
{ error: "Terjadi kegagalan saat verifikasi payload." },
{ status: 500 }
);
}
}
Catatan Arsitektur: Jalur Asinkron & Anti-Malware
Validasi magic bytes menangkal file masking dasar, tetapi tidak memindai embedded malware pada payload yang valid (misal: stegano-shell dalam JPEG valid). Melakukan scanning antivirus sinkron di dalam Route Handler akan melanggar prinsip runtime serverless (timeout 10-30 detik).
Pola Lazy Developer: Jangan jalankan scanning di server Next.js. Terbitkan file dengan statusPENDINGdi database. Gunakan S3 Event Notifications (ObjectCreated) yang memicu AWS Lambda terpisah atau AWS EventBridge menuju ClamAV/AWS GuardDuty Malware Protection secara asinkron. Callback Lambda akan mengupdate status file menjadiACTIVEatau menghapusnya jika terinfeksi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!