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 via Transfer-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 RIFF pada byte 0-3 dan WEBP pada 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"