Lewati ke konten
CodeAuditAgent
Semua artikel

Daftar Periksa Keamanan Next.js App Router

Daftar periksa keamanan praktis untuk aplikasi Next.js App Router: server action, route handler, middleware, env var, CSRF, header, webhook, dan caching.

· 8 menit baca · Lina Source LLC

App Router memindahkan banyak kode dari browser kembali ke server. Itu sebagian besar adalah kemenangan bagi keamanan: query, secret, dan logika bisnis kini berjalan di tempat yang tidak bisa dibaca pengguna. Ia juga mengaburkan batas antara apa yang merupakan endpoint publik dan apa yang hanya terlihat seperti pemanggilan fungsi. Sebagian besar bug serius di aplikasi Next.js berasal dari keburaman itu.

Daftar periksa ini mencakup masalah yang paling sering kami temui di kode App Router, dalam urutan yang layak diperiksa. Tidak ada yang eksotis di sini; setiap butir adalah tempat di mana kemudahan framework menyembunyikan sebuah batas kepercayaan.

1. Server action adalah endpoint publik

Fungsi yang ditandai 'use server' dikompilasi menjadi sebuah endpoint HTTP. Siapa pun yang bisa memuat situs Anda bisa menemukan ID action-nya dan memanggilnya dengan argumen sembarang, terlepas apakah antarmuka Anda pernah merender tombol yang memakainya. Menyembunyikan sebuah form dari non-admin tidak melindungi action di baliknya.

Setiap action membutuhkan tiga langkah yang sama seperti handler API mana pun: autentikasi pemanggil, validasi input, dan otorisasi record spesifik yang disentuh. Tempatkan cek-cek ini di dalam badan action itu sendiri. Cek di komponen halaman yang merender form berjalan ketika halaman dimuat, bukan ketika action dipanggil, sehingga ia tidak melindungi apa pun.

'use server';

import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

const Input = z.object({
  projectId: z.string().uuid(),
  name: z.string().trim().min(1).max(100),
});

export async function renameProject(formData: FormData) {
  const session = await auth();
  if (!session?.user) throw new Error('Unauthorized');

  const { projectId, name } = Input.parse({
    projectId: formData.get('projectId'),
    name: formData.get('name'),
  });

  // Cek kepemilikan adalah bagian dari operasi tulis, bukan lookup terpisah
  const result = await db.project.updateMany({
    where: { id: projectId, ownerId: session.user.id },
    data: { name },
  });
  if (result.count === 0) throw new Error('Not found');

  revalidatePath('/projects');
}

Waspadai file helper yang mengekspor banyak action. Setiap fungsi yang diekspor di dalam modul 'use server' bisa dipanggil, termasuk yang ditambahkan seseorang untuk sebuah skrip internal lalu terlupakan.

2. Route handler butuh cek yang sama

Route handler di app/api lebih jelas terlihat sebagai endpoint, tetapi keduanya berbagi mode kegagalan yang sama: sebuah segmen dinamis dipakai untuk memuat record tanpa memeriksa siapa pemiliknya. Pada versi Next.js terkini, params berupa Promise; await dulu, validasi, lalu batasi query ke si pemanggil.

// app/api/documents/[id]/route.ts
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const session = await auth();
  if (!session?.user) {
    return Response.json({ error: 'unauthorized' }, { status: 401 });
  }

  const { id } = await params;
  const doc = await db.document.findFirst({
    where: { id, ownerId: session.user.id },
  });
  if (!doc) return Response.json({ error: 'not_found' }, { status: 404 });

  return Response.json(doc, {
    headers: { 'Cache-Control': 'private, no-store' },
  });
}

Kembalikan 404 alih-alih 403 untuk record yang bukan milik pemanggil, sehingga endpoint itu tidak mengonfirmasi ID mana yang ada. Jangan pernah memercayai params, searchParams, header, atau cookie untuk keputusan otorisasi; semuanya adalah input yang dikendalikan penyerang.

3. Middleware bukan batas autentikasi yang berdiri sendiri

Middleware (dinamai ulang menjadi proxy di Next.js 16) berguna untuk mengalihkan pengguna yang belum masuk dan menyetel header. Ia tidak boleh menjadi satu-satunya tempat otorisasi terjadi. Matcher mudah salah tulis, route baru ditambahkan di luar cakupannya, dan server action melakukan POST ke path halaman, yang mungkin tidak cocok dengan pola yang Anda bayangkan. Pada 2025, CVE-2025-29927 menunjukkan bahwa sebuah header internal yang dirancang khusus bisa membuat sebagian versi Next.js melewati middleware sepenuhnya.

Perlakukan middleware sebagai lapisan kemudahan. Cek yang sebenarnya berada di dekat data: di setiap action dan handler, atau lebih baik lagi, di sebuah lapisan akses data yang dilewati setiap pembacaan di sisi server. Lapisan akses data adalah satu modul khusus server yang memaparkan fungsi seperti getProjectForUser(projectId) dan melakukan cek sesi serta filter kepemilikan di dalamnya. Halaman, action, dan route handler memanggilnya alih-alih memanggil klien database secara langsung, sehingga route baru tidak mungkin lupa melakukan cek: memang tidak ada jalur tanpa cek yang bisa dilupakan.

4. Jaga kode server tetap di server

Variabel lingkungan apa pun yang berawalan NEXT_PUBLIC_ disisipkan ke dalam bundel JavaScript saat build dan terlihat oleh setiap pengunjung. Itu benar untuk publishable key Stripe atau ID analytics, dan merupakan kebocoran untuk hal lainnya. Cari NEXT_PUBLIC_ di seluruh kode dan pastikan masing-masing aman seandainya dipasang di papan reklame. Kesalahan sebaliknya juga terjadi: sebuah variabel tanpa awalan itu dibaca di komponen klien, kembali sebagai undefined di browser, lalu seseorang mengganti namanya menjadi NEXT_PUBLIC_ agar errornya hilang. Jika sebuah nilai dibutuhkan di browser, tanyakan lebih dulu apakah browser memang layak memilikinya.

// lib/dal.ts
import 'server-only';
import { cache } from 'react';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export const getCurrentUser = cache(async () => {
  const session = await auth();
  if (!session?.user) return null;
  // Kembalikan hanya field yang dibutuhkan UI, jangan pernah seluruh barisnya
  return db.user.findUnique({
    where: { id: session.user.id },
    select: { id: true, name: true, plan: true },
  });
});

Paket server-only membuat build gagal jika sebuah komponen klien mengimpor modul tersebut, yang melindungi klien database dan helper pembaca secret agar tidak berakhir di browser. Perhatikan juga apa yang dilewatkan server component sebagai props ke komponen klien: semua yang melewati batas itu diserialisasi ke dalam halaman, sehingga melewatkan satu baris user yang utuh berarti mengirim hash kata sandi dan flag internalnya ke browser.

5. CSRF: pahami apa yang sudah ditangani framework

Server action hanya menerima POST, dan Next.js membandingkan header Origin dengan host sebelum menjalankannya. Jika Anda men-deploy di balik proksi atau di beberapa domain, konfigurasikan serverActions.allowedOrigins secara sadar alih-alih melebarkannya sampai errornya hilang.

Route handler tidak mendapat perlindungan semacam itu. Jika sebuah handler POST, PUT, atau DELETE melakukan autentikasi dengan cookie, form di situs lain tetap bisa mengirim ke sana. Memeriksa content type saja tidak cukup: sebuah form HTML bisa mengirim body urlencoded, multipart, dan text/plain tanpa preflight, dan handler yang mem-parsing body secara longgar akan menerimanya. Setel cookie sesi dengan SameSite=Lax atau Strict, jangan pernah melakukan perubahan state pada GET, dan periksa header Origin pada mutasi yang diautentikasi dengan cookie. Handler yang hanya menerima bearer token di header Authorization tidak terpapar CSRF klasik.

6. Header keamanan dan CSP

Next.js mengirim sangat sedikit header keamanan secara bawaan. Tambahkan di next.config melalui fungsi headers(), atau di middleware ketika Anda membutuhkan nonce per permintaan untuk Content-Security-Policy yang ketat.

  • Content-Security-Policy, idealnya berbasis nonce, dengan object-src 'none' dan base-uri 'self'.
  • Strict-Transport-Security dengan max-age panjang begitu HTTPS sudah mantap di mana-mana.
  • X-Content-Type-Options: nosniff.
  • Referrer-Policy: strict-origin-when-cross-origin.
  • frame-ancestors di dalam CSP, atau X-Frame-Options, untuk mencegah clickjacking.
  • poweredByHeader: false di next.config, untuk menghilangkan header X-Powered-By.

7. Rate limiting

Tidak ada rate limiter bawaan. Masuk, daftar, reset kata sandi, verifikasi OTP, dan apa pun yang berbiaya uang per pemanggilan (email, SMS, permintaan AI) membutuhkan batas per pengguna dan per IP. Penghitung di memori tidak berfungsi pada deployment serverless atau multi-instance; gunakan penyimpanan bersama seperti Redis atau database Anda. Terapkan batas di dalam action atau handler, tempat pengguna yang terautentikasi sudah diketahui, bukan hanya di middleware. Kunci batas itu pada sesuatu yang tidak bisa dirotasi penyerang dengan murah: akun untuk route yang terautentikasi, serta email atau nomor telepon tujuan untuk alur reset dan OTP, selain IP. Kembalikan 429 dengan header Retry-After agar klien yang sah mundur dengan benar.

8. Verifikasi webhook terhadap body mentah

Signature webhook dihitung atas byte persis yang dikirim penyedia. Mem-parsing body sebagai JSON lalu men-serialisasi ulang mengubah byte tersebut dan merusak verifikasi, yang menggoda orang untuk melewatinya. Di route handler, baca body dengan req.text() lalu verifikasi sebelum melakukan apa pun.

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get('stripe-signature');
  if (!signature) return new Response('Missing signature', { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }

  // Handler harus idempoten: penyedia melakukan retry dan bisa mengirim dua kali
  await handleStripeEvent(event);
  return new Response('ok');
}

Simpan ID event yang sudah diproses dengan unique constraint agar pengiriman yang di-retry atau diulang tidak memberikan langganan dua kali, dan tangani event yang tiba tidak berurutan, karena penyedia tidak menjamin urutan pengiriman.

9. Jangan menyimpan data per pengguna di cache global

App Router melakukan caching secara agresif, dan nilai default-nya berubah antar-versi. Fungsi yang dibungkus unstable_cache atau 'use cache' yang mengembalikan data untuk 'pengguna saat ini' tetapi tidak menyertakan ID pengguna di kunci cache-nya akan menyajikan data satu pengguna kepada pengguna berikutnya. Hal yang sama berlaku untuk halaman yang dirender statis padahal seharusnya dinamis, dan untuk caching respons API di CDN. Ujilah secara langsung: masuk sebagai dua pengguna berbeda di dua browser lalu muat halaman yang sama. Apa pun yang menunjukkan data pengguna pertama kepada pengguna kedua adalah bug cache, dan biasanya bug yang serius.

  • Lewatkan ID pengguna atau tenant secara eksplisit ke dalam fungsi yang di-cache agar ia menjadi bagian dari kuncinya.
  • Jangan membaca cookie atau header di dalam fungsi yang di-cache; baca di luar lalu kirimkan nilainya masuk.
  • Kirim Cache-Control: private, no-store pada respons yang berisi data per pengguna.
  • Periksa keluaran build: route yang Anda harapkan dinamis tidak boleh tercantum sebagai statis.

Menjalankan daftar periksa ini

Telusuri daftarnya per route, bukan per file: untuk setiap action dan handler, siapa yang boleh memanggilnya, input apa yang dipercayainya, apa yang di-cache, dan apa yang dikembalikannya. CodeAuditAgent bisa melakukan pemeriksaan awal pada repositori GitHub publik atau snippet yang ditempel, melaporkan setiap temuan dengan tingkat keparahan, CWE, baris yang dikutip, dan usulan patch; pertanyaan desainnya, seperti route mana yang memang seharusnya publik, tetap membutuhkan penilaian tim Anda.