Fungsi readMultipartFormData bawaan H3 pada Nuxt 3 memuat seluruh payload multipart langsung ke memori RAM server sebagai buffer. Pola ini memicu kerentanan Denial of Service (DoS) berbasis Out-of-Memory (OOM) jika klien mengirimkan file berukuran masif tanpa filtrasi awal. Celah keamanan lain muncul ketika aplikasi mengandalkan header Content-Type kiriman klien dan atribut filename mentah, yang membuka risiko MIME spoofing serta directory traversal.
Alternatif paling ringkas: Gunakan S3 Presigned URL langsung dari klien ke object storage; backend Nuxt tidak perlu menyentuh buffer file sama sekali.
1. Batasi Ukuran Payload Sebelum Buffering
Memanggil readMultipartFormData(event) tanpa pemeriksaan awal membuat Node.js mengalokasikan memori untuk seluruh payload. Lakukan mitigasi dini dengan membaca header Content-Length pada HTTP handler sebelum parser bekerja. Jika request melebihi batas, hentikan koneksi segera dengan status HTTP 413.
// server/api/upload.post.ts
import { getHeader, createError } from 'h3'
const MAX_FILE_SIZE = 5 * 1024 * 1024 // Batas 5MB
export default defineEventHandler(async (event) => {
const contentLength = Number(getHeader(event, 'content-length') || 0)
if (contentLength > MAX_FILE_SIZE) {
throw createError({
statusCode: 413,
statusMessage: 'Payload Too Large: Maksimal 5MB'
})
}
// Lanjutkan parsing...
})Catatan: Klien dapat memalsukan atau menghilangkan header
Content-Length(misalnya viaTransfer-Encoding: chunked). Terapkan batas absolut pada reverse proxy seperti Nginx (client_max_body_size 5M;) atau Cloudflare sebelum request mencapai Nitro engine.
2. Deteksi Tipe File Menggunakan Magic Byte
Header part.type yang diekstrak dari multipart form didasarkan pada deklarasi browser klien. Penyerang dapat mengirimkan skrip executable (PHP, ELF, shell script) dengan header image/png palsu. Validasi wajib dilakukan pada level byte signature (magic byte) langsung dari buffer.
Pemeriksaan signature format umum menggunakan standard library Node.js:
- PNG: 8 byte pertama adalah
89 50 4E 47 0D 0A 1A 0A. - JPEG: 3 byte pertama adalah
FF D8 FF. - WebP: Karakter ASCII
RIFFpada byte 0-3 danWEBPpada byte 8-11. - PDF: 4 byte pertama adalah
%PDF(hex:25 50 44 46).
interface FileSignature {
ext: string
mime: string
}
function validateMagicBytes(buffer: Buffer): FileSignature | null {
if (buffer.length < 12) return null
// PNG signature
if (
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47 &&
buffer[4] === 0x0d &&
buffer[5] === 0x0a &&
buffer[6] === 0x1a &&
buffer[7] === 0x0a
) {
return { ext: 'png', mime: 'image/png' }
}
// JPEG signature
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return { ext: 'jpg', mime: 'image/jpeg' }
}
// WebP signature (RIFF....WEBP)
const isRiff = buffer.subarray(0, 4).toString('ascii') === 'RIFF'
const isWebp = buffer.subarray(8, 12).toString('ascii') === 'WEBP'
if (isRiff && isWebp) {
return { ext: 'webp', mime: 'image/webp' }
}
return null
}3. Sanitasi Filename dan Pencegahan Path Traversal
Nilai part.filename dapat mengandung pola path traversal seperti ../../etc/cron.d/malicious atau ekstensi ganda seperti payload.php.png. Jangan gunakan nama file bawaan klien. Generate string acak menggunakan crypto.randomUUID() dan gabungkan hanya dengan ekstensi yang divalidasi dari hasil inspeksi magic byte.
4. Implementasi Penuh Handler Nitro
Struktur kode di server/api/upload.post.ts yang menggabungkan seluruh lapisan pertahanan:
import { defineEventHandler, readMultipartFormData, createError, getHeader } from 'h3'
import { randomUUID } from 'node:crypto'
import { writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
const UPLOAD_DIR = resolve(process.cwd(), 'uploads')
export default defineEventHandler(async (event) => {
// 1. Cek Content-Length
const contentLength = Number(getHeader(event, 'content-length') || 0)
if (contentLength > MAX_FILE_SIZE) {
throw createError({
statusCode: 413,
statusMessage: 'Payload Too Large: Maksimal 5MB'
})
}
// 2. Parse multipart data
const parts = await readMultipartFormData(event)
if (!parts || parts.length === 0) {
throw createError({
statusCode: 400,
statusMessage: 'Bad Request: Form data kosong'
})
}
const filePart = parts.find((p) => p.name === 'file' && p.data)
if (!filePart || !filePart.data) {
throw createError({
statusCode: 400,
statusMessage: 'Bad Request: Field file wajib disertakan'
})
}
// Double check ukuran buffer aktual
if (filePart.data.length > MAX_FILE_SIZE) {
throw createError({
statusCode: 413,
statusMessage: 'Payload Too Large: File melebihi 5MB'
})
}
// 3. Inspeksi Magic Byte
const detected = validateMagicBytes(filePart.data)
if (!detected) {
throw createError({
statusCode: 415,
statusMessage: 'Unsupported Media Type: Hanya menerima JPEG, PNG, dan WebP valid'
})
}
// 4. Sanitasi nama file & simpan
// ponytail: Local filesystem write. Ganti ke Object Storage (S3/R2) saat butuh horizontal scaling.
const safeFilename = `${randomUUID()}.${detected.ext}`
const destinationPath = resolve(UPLOAD_DIR, safeFilename)
try {
await writeFile(destinationPath, filePart.data)
} catch (error) {
throw createError({
statusCode: 500,
statusMessage: 'Internal Server Error: Gagal menyimpan file'
})
}
setResponseStatus(event, 201)
return {
success: true,
filename: safeFilename,
mime: detected.mime
}
})5. Verifikasi Keamanan Menggunakan cURL
Jalankan pengujian endpoint untuk memverifikasi respon status code HTTP sesuai skenario mitigasi:
Uji Format File Valid (201 Created)
curl -i -X POST http://localhost:3000/api/upload \
-F "[email protected];type=image/png"Uji Payload Melebihi Batas (413 Payload Too Large)
curl -i -X POST http://localhost:3000/api/upload \
-F "file=@large_video.mp4"Uji MIME Spoofing (415 Unsupported Media Type)
Kirimkan file teks/skrip dengan header palsu image/png. Buffer inspeksi akan menolak payload karena signature tidak cocok:
curl -i -X POST http://localhost:3000/api/upload \
-F "[email protected];type=image/png"
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!