Next.js Server Actions aman tidak cukup hanya dengan menaruh "use server" di fungsi. Semua input dari browser harus dianggap tidak tepercaya, bahkan jika form sudah divalidasi di client. Jika Anda menerima data pengguna, mengubah state, atau menyentuh database, maka Anda sedang melewati trust boundary dan harus memvalidasi, mengotorisasi, membatasi abuse, serta menangani error dengan hati-hati.
Di Next.js App Router, Server Actions memudahkan alur mutasi data tanpa endpoint API terpisah. Namun kemudahan ini juga membuat sebagian tim lupa bahwa action tetap merupakan permukaan serangan. Panduan ini fokus pada implementasi praktis: validasi schema di server, sanitasi seperlunya, proteksi auth sebelum action dijalankan, pengecekan otorisasi per resource, mitigasi CSRF untuk alur berbasis cookie, rate limit per user/IP, logging aman, dan checklist hardening yang bisa langsung diterapkan.
Threat model singkat: apa yang perlu dilindungi?
Sebelum menulis kode, definisikan ancamannya. Untuk Server Actions, ancaman yang paling umum biasanya bukan eksploit yang rumit, melainkan kombinasi input tak valid, akses tidak sah, dan penyalahgunaan volume request.
- Input berbahaya: tipe data tidak sesuai, field tambahan yang tidak diharapkan, string terlalu panjang, HTML/script, identifier palsu, atau nilai yang sengaja dibuat merusak logika bisnis.
- Bypass validasi client: validasi di browser hanya untuk UX. Penyerang bisa memanggil action dengan payload buatan sendiri.
- IDOR / Broken Authorization: user sah mengubah resource milik user lain karena server hanya mengecek bahwa user sudah login, bukan bahwa ia berhak mengakses resource tersebut.
- CSRF: jika autentikasi mengandalkan cookie, request lintas situs dapat memicu action tanpa persetujuan pengguna.
- Abuse dan spam: brute force, form spam, submit berulang, atau payload besar untuk menguras resource.
- Kebocoran informasi: pesan error, stack trace, atau log yang menampilkan token, cookie, email, atau query sensitif.
Prinsip utama: anggap Server Action sebagai endpoint mutasi data di server. Nama fiturnya boleh berbeda, tetapi model keamanannya tetap sama seperti handler API.
Trust boundary di Next.js App Router
Trust boundary terjadi saat data berpindah dari lingkungan yang bisa dimanipulasi pengguna ke lingkungan server yang dipercaya. Pada Server Actions, boundary ini terjadi ketika form atau event di browser mengirimkan data ke action.
Kesalahan umum adalah menganggap karena action ditulis di file server, maka data yang masuk juga aman. Tidak demikian. Yang aman adalah lokasi eksekusi-nya, bukan isi input-nya. Karena itu, semua hal berikut harus dilakukan di sisi server:
- validasi bentuk dan isi input,
- normalisasi nilai bila perlu,
- autentikasi identitas pengguna,
- otorisasi terhadap resource yang disentuh,
- pembatasan laju dan ukuran request,
- logging dan error handling yang aman.
Validasi schema sisi server adalah garis pertahanan pertama
Untuk Next.js Server Actions aman, mulai dari validasi schema di server. Tujuannya bukan hanya memastikan tipe data benar, tetapi juga menegakkan aturan bisnis dasar: panjang maksimum, enum yang diizinkan, format identifier, dan penolakan field yang tidak dikenal.
Contoh berikut menggunakan TypeScript dan library validasi schema. Anda bisa memakai library apa pun yang setara; yang penting, parsing dan validasi terjadi di server sebelum ada efek samping seperti query database.
// app/projects/actions.ts
'use server'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { rateLimitOrThrow } from '@/lib/rate-limit'
import { verifyCsrfOrThrow } from '@/lib/csrf'
const updateProjectSchema = z.object({
projectId: z.string().uuid(),
name: z.string().trim().min(3).max(80),
description: z.string().trim().max(2000).optional().default(''),
csrfToken: z.string().min(20)
}).strict()
type ActionResult = {
ok: boolean
message?: string
fieldErrors?: Record<string, string[]>
}
export async function updateProjectAction(
_prevState: ActionResult,
formData: FormData
): Promise<ActionResult> {
const session = await auth()
if (!session?.user?.id) {
return { ok: false, message: 'Unauthorized' }
}
await rateLimitOrThrow({
key: `update-project:${session.user.id}`,
fallbackIpKey: 'ip-based-key-if-needed'
})
const raw = {
projectId: formData.get('projectId'),
name: formData.get('name'),
description: formData.get('description'),
csrfToken: formData.get('csrfToken')
}
const parsed = updateProjectSchema.safeParse(raw)
if (!parsed.success) {
return {
ok: false,
message: 'Input tidak valid',
fieldErrors: parsed.error.flatten().fieldErrors
}
}
verifyCsrfOrThrow(parsed.data.csrfToken)
const project = await db.project.findUnique({
where: { id: parsed.data.projectId },
select: { id: true, ownerId: true }
})
if (!project) {
return { ok: false, message: 'Resource tidak ditemukan' }
}
if (project.ownerId !== session.user.id) {
return { ok: false, message: 'Forbidden' }
}
await db.project.update({
where: { id: project.id },
data: {
name: parsed.data.name,
description: sanitizePlainText(parsed.data.description)
}
})
return { ok: true, message: 'Project diperbarui' }
}
function sanitizePlainText(input: string) {
return input.replace(/\u0000/g, '').trim()
}Apa yang dilakukan contoh di atas?
- Schema ketat:
.strict()membantu menolak field tak dikenal yang kadang dipakai untuk menguji kelemahan parser atau logika server. - Batas panjang: mencegah penyimpanan data berlebihan, spam, dan beban proses yang tak perlu.
- Validasi sebelum efek samping: jangan sentuh database atau layanan lain sebelum payload lolos validasi.
- Field error terstruktur: aman untuk ditampilkan ke pengguna tanpa membuka detail internal server.
Kesalahan umum saat validasi
- Hanya mengandalkan atribut HTML seperti
requireddanmaxLength. - Memanggil
Number(),JSON.parse(), atau cast TypeScript tanpa validasi nyata. - Menerima identifier dari client lalu langsung melakukan update tanpa memeriksa kepemilikan resource.
- Menyimpan string mentah panjang tanpa batas ke database atau log.
Sanitasi: lakukan seperlunya, jangan menggantikan validasi
Sanitasi sering disalahpahami sebagai solusi utama keamanan input. Padahal sanitasi bukan pengganti validasi. Validasi menjawab pertanyaan "apakah input ini sesuai kontrak?", sedangkan sanitasi menjawab "bagaimana input ini dinormalisasi atau dibuat aman untuk konteks tertentu?".
Beberapa pedoman praktis:
- Untuk plain text, biasanya cukup trim, buang karakter kontrol tertentu, dan batasi panjang.
- Untuk HTML yang memang diizinkan, gunakan sanitizer berbasis allowlist di server, bukan regex buatan sendiri.
- Untuk output ke UI, utamakan escaping sesuai konteks render. Jangan menyimpan data "sudah di-escape untuk HTML" lalu dipakai ulang ke konteks lain.
- Untuk query database, gunakan ORM atau parameterized query. Sanitasi string bukan pengganti proteksi injection.
Aturan sederhana: validasi untuk menolak input buruk, sanitasi untuk menormalkan atau menyiapkan input sesuai konteks, escaping untuk output.
Batasi payload dan permukaan input
Semakin besar dan kompleks payload, semakin besar peluang abuse dan bug. Di Server Actions, ini berarti Anda sebaiknya hanya menerima field yang benar-benar dibutuhkan dan membatasi ukuran data sejak awal desain.
Praktik yang disarankan
- Gunakan schema minimal: jangan kirim seluruh objek jika hanya perlu
iddan satu field yang diubah. - Batas panjang per field: misalnya nama 80 karakter, deskripsi 2000 karakter.
- Batasi jumlah item: jika menerima array, tetapkan maksimum elemen.
- Hindari blob besar di action: untuk upload file, pertimbangkan alur upload yang terpisah dengan validasi ukuran, tipe MIME, dan penyimpanan yang sesuai.
- Tolak field tambahan: supaya server tidak diam-diam menerima data yang tidak dipakai hari ini tetapi berbahaya besok.
Kalau Anda menerima FormData, jangan lakukan Object.fromEntries(formData) lalu percaya begitu saja. Pilih field satu per satu atau validasi hasil ekstraksinya dengan schema ketat.
Autentikasi harus dicek sebelum action berjalan lebih jauh
Server Action yang mengubah data hampir selalu memerlukan autentikasi. Cek sesi pengguna sedini mungkin sebelum memproses input lebih jauh atau mengakses resource berat. Ini mengurangi peluang abuse anonim dan menghemat resource.
const session = await auth()
if (!session?.user?.id) {
return { ok: false, message: 'Unauthorized' }
}Beberapa catatan penting:
- Jangan percaya userId dari form: identitas harus berasal dari mekanisme auth server-side, bukan dari input client.
- Fail closed: jika status auth tidak jelas atau token tidak valid, tolak request.
- Pisahkan identitas dan input: user login menentukan siapa yang meminta, payload menentukan apa yang diminta.
Otorisasi per resource: jangan berhenti di "sudah login"
Ini area yang paling sering dilupakan. User yang sah belum tentu berhak mengubah semua data. Setelah validasi dan autentikasi, lakukan pengecekan otorisasi terhadap resource yang diminta.
Contoh pola yang benar:
- Ambil resource berdasarkan identifier yang valid.
- Baca atribut otorisasi minimum, misalnya
ownerId,organizationId, atau role yang relevan. - Bandingkan dengan identitas dari sesi.
- Tolak jika tidak cocok, sebelum update dilakukan.
Jika model Anda berbasis multi-tenant, jangan hanya memfilter berdasarkan id. Sertakan batas tenant atau organisasi pada query untuk mengurangi risiko salah akses.
const project = await db.project.findFirst({
where: {
id: parsed.data.projectId,
ownerId: session.user.id
},
select: { id: true }
})
if (!project) {
return { ok: false, message: 'Resource tidak ditemukan atau tidak dapat diakses' }
}Pendekatan ini sering lebih aman karena query hanya akan mengembalikan resource yang memang boleh diakses pengguna saat ini.
Mitigasi CSRF untuk alur berbasis cookie
Jika autentikasi menggunakan cookie, maka browser bisa otomatis mengirim cookie saat request ke domain Anda. Di sinilah CSRF menjadi relevan: situs lain dapat mencoba memicu aksi atas nama pengguna yang sedang login.
Mitigasi yang umum dan praktis:
- Gunakan cookie dengan atribut yang tepat, terutama SameSite, Secure, dan HttpOnly sesuai kebutuhan aplikasi.
- Gunakan token CSRF untuk aksi mutasi, lalu verifikasi token tersebut di server action.
- Batasi origin yang diizinkan bila arsitektur Anda memerlukannya.
- Hindari GET untuk mutasi; perubahan state harus melalui mekanisme yang memang diperlakukan sebagai mutasi.
Contoh sederhana pola token CSRF:
// lib/csrf.ts
import { cookies } from 'next/headers'
import crypto from 'node:crypto'
const CSRF_COOKIE = 'csrf-token'
export async function getCsrfToken() {
const store = await cookies()
let token = store.get(CSRF_COOKIE)?.value
if (!token) {
token = crypto.randomBytes(32).toString('hex')
store.set(CSRF_COOKIE, token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/'
})
}
return token
}
export async function verifyCsrfOrThrow(submittedToken: string) {
const store = await cookies()
const cookieToken = store.get(CSRF_COOKIE)?.value
if (!cookieToken || !submittedToken || cookieToken !== submittedToken) {
throw new Error('Invalid CSRF token')
}
}Lalu sertakan token itu di form sebagai hidden input dari komponen server:
// app/projects/[id]/edit/page.tsx
import { getCsrfToken } from '@/lib/csrf'
import { updateProjectAction } from '@/app/projects/actions'
export default async function EditProjectPage() {
const csrfToken = await getCsrfToken()
return (
<form action={updateProjectAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="projectId" value="..." />
<input name="name" />
<textarea name="description" />
<button type="submit">Simpan</button>
</form>
)
}Trade-off: implementasi CSRF bergantung pada cara auth dan deployment Anda diatur. Jika Anda tidak memakai cookie untuk auth, risikonya bisa berbeda. Namun untuk alur berbasis cookie, jangan anggap CSRF selesai hanya karena ada validasi input.
Rate limit per user dan per IP untuk mencegah abuse
Validasi tidak menghentikan spam. Anda tetap perlu membatasi frekuensi eksekusi action, terutama untuk login, reset password, komentar, kontak, pembuatan resource, atau aksi mahal yang memicu email dan integrasi eksternal.
Pola yang umum:
- Per user untuk aksi yang memerlukan login.
- Per IP untuk pengguna anonim atau sebagai fallback.
- Window pendek untuk spam burst, dan window lebih panjang untuk abuse yang lebih lambat tetapi konsisten.
// lib/rate-limit.ts
const memoryStore = new Map<string, { count: number; resetAt: number }>()
export async function rateLimitOrThrow({
key,
fallbackIpKey,
limit = 10,
windowMs = 60_000
}: {
key: string
fallbackIpKey?: string
limit?: number
windowMs?: number
}) {
const now = Date.now()
const bucketKey = key || fallbackIpKey || 'anonymous'
const current = memoryStore.get(bucketKey)
if (!current || current.resetAt < now) {
memoryStore.set(bucketKey, { count: 1, resetAt: now + windowMs })
return
}
if (current.count >= limit) {
throw new Error('Rate limit exceeded')
}
current.count += 1
memoryStore.set(bucketKey, current)
}Penting: contoh di atas hanya cocok sebagai ilustrasi atau development. Pada deployment multi-instance atau serverless, penyimpanan in-memory tidak konsisten antar instance. Untuk production, gunakan penyimpanan terpusat seperti Redis atau layanan rate limiting terkelola.
Trade-off rate limiting
- Terlalu ketat bisa mengganggu pengguna sah.
- Per IP dapat menghasilkan false positive di jaringan bersama.
- Per user tidak cukup untuk mencegah abuse anonim.
- Jika limit dilewati, respons harus tetap generik dan tidak memberi tahu terlalu banyak tentang aturan internal.
Logging aman: cukup untuk debugging, tidak membocorkan secret
Log yang terlalu lengkap bisa menjadi insiden keamanan. Tujuan logging adalah membantu investigasi tanpa menyimpan data yang seharusnya rahasia.
Apa yang sebaiknya dicatat
- nama action atau kategori operasi,
- timestamp,
- user id internal bila tersedia,
- identifier request atau trace id,
- hasil umum: sukses, validasi gagal, forbidden, rate limited, exception.
Apa yang sebaiknya tidak dicatat mentah
- password, token, cookie, authorization header,
- payload form penuh, terutama field sensitif,
- stack trace lengkap ke log client,
- PII yang tidak perlu seperti email atau nomor telepon dalam bentuk utuh.
function logActionEvent(event: {
action: string
userId?: string
status: 'ok' | 'validation_error' | 'forbidden' | 'rate_limited' | 'error'
requestId?: string
resourceId?: string
}) {
console.info(JSON.stringify(event))
}
function redact(value?: string) {
if (!value) return undefined
return value.slice(0, 3) + '***'
}Jika perlu mencatat nilai input untuk debugging, log hanya metadata yang aman, misalnya panjang string, jumlah item, atau hash sebagian, bukan nilai utuh.
Pola error message yang tidak membocorkan detail sensitif
Error yang baik harus membantu pengguna memperbaiki input tanpa mengungkap struktur internal aplikasi. Bedakan antara error yang aman ditampilkan ke UI dan detail yang hanya boleh masuk ke log server.
Rekomendasi pola
- Validasi input: tampilkan field error spesifik seperti "nama minimal 3 karakter".
- Autentikasi/otorisasi: gunakan pesan ringkas seperti "Unauthorized" atau "Forbidden".
- Resource sensitif: kadang lebih aman mengembalikan pesan netral seperti "Resource tidak ditemukan atau tidak dapat diakses".
- Error internal: tampilkan pesan umum seperti "Terjadi kesalahan, coba lagi nanti" lalu log detail exception di server.
try {
// proses action
} catch (error) {
logActionEvent({
action: 'updateProject',
userId: session?.user?.id,
status: 'error'
})
return {
ok: false,
message: 'Terjadi kesalahan saat memproses permintaan'
}
}Jangan mengirim pesan seperti nama tabel database, query gagal, path file, token tidak cocok secara rinci, atau stack trace ke client. Detail tersebut mempermudah enumerasi dan eksploitasi.
Contoh implementasi ringkas yang lebih utuh
Berikut contoh alur yang menggabungkan validasi, auth, otorisasi, CSRF, rate limit, dan logging dalam satu action yang realistis.
// app/projects/actions.ts
'use server'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { rateLimitOrThrow } from '@/lib/rate-limit'
import { verifyCsrfOrThrow } from '@/lib/csrf'
const schema = z.object({
projectId: z.string().uuid(),
name: z.string().trim().min(3).max(80),
description: z.string().trim().max(2000).optional().default(''),
csrfToken: z.string().min(20)
}).strict()
type State = {
ok: boolean
message?: string
fieldErrors?: Record<string, string[]>
}
export async function updateProjectAction(
_prevState: State,
formData: FormData
): Promise<State> {
let userId: string | undefined
try {
const session = await auth()
userId = session?.user?.id
if (!userId) {
return { ok: false, message: 'Unauthorized' }
}
await rateLimitOrThrow({ key: `project-update:${userId}`, limit: 10, windowMs: 60_000 })
const parsed = schema.safeParse({
projectId: formData.get('projectId'),
name: formData.get('name'),
description: formData.get('description'),
csrfToken: formData.get('csrfToken')
})
if (!parsed.success) {
return {
ok: false,
message: 'Input tidak valid',
fieldErrors: parsed.error.flatten().fieldErrors
}
}
await verifyCsrfOrThrow(parsed.data.csrfToken)
const project = await db.project.findFirst({
where: {
id: parsed.data.projectId,
ownerId: userId
},
select: { id: true }
})
if (!project) {
return { ok: false, message: 'Resource tidak ditemukan atau tidak dapat diakses' }
}
await db.project.update({
where: { id: project.id },
data: {
name: parsed.data.name,
description: parsed.data.description.replace(/\u0000/g, '')
}
})
console.info(JSON.stringify({
action: 'updateProject',
userId,
projectId: project.id,
status: 'ok'
}))
return { ok: true, message: 'Project diperbarui' }
} catch (_error) {
console.error(JSON.stringify({
action: 'updateProject',
userId,
status: 'error'
}))
return {
ok: false,
message: 'Terjadi kesalahan saat memproses permintaan'
}
}
}Contoh ini sengaja ringkas, tetapi sudah menunjukkan urutan yang benar:
- autentikasi,
- rate limit,
- validasi schema,
- verifikasi CSRF,
- otorisasi per resource,
- mutasi database,
- logging aman,
- respons error generik bila exception terjadi.
Debugging dan audit: tanda-tanda implementasi masih lemah
Jika salah satu gejala berikut ada di codebase Anda, biasanya hardening belum cukup:
- Action langsung memanggil ORM dengan nilai dari
formData.get(...)tanpa parsing schema. - Komponen client menyembunyikan tombol edit, tetapi server tidak mengecek izin edit.
- Log berisi dump
FormDatautuh saat error. - Semua error dilempar ke client apa adanya.
- Rate limit hanya ada di UI, misalnya menonaktifkan tombol submit beberapa detik.
- Action mutasi berbasis cookie tidak punya mitigasi CSRF yang jelas.
Saat mengaudit, coba jawab pertanyaan ini untuk setiap Server Action:
- Input apa yang diterima, dan siapa yang mengendalikan input itu?
- Apakah semua field divalidasi dan dibatasi panjangnya?
- Apakah user harus login?
- Apakah user ini berhak atas resource spesifik yang disentuh?
- Apakah ada mekanisme anti-CSRF jika cookie dipakai?
- Apakah action bisa dispam?
- Apakah log dan error aman jika terjadi exception?
Checklist hardening Server Actions
Gunakan checklist berikut agar Next.js Server Actions aman bisa diterapkan konsisten di seluruh proyek:
- Anggap semua input client tidak tepercaya, termasuk hidden field.
- Validasi payload di server dengan schema ketat, termasuk tipe, enum, panjang, dan field yang diizinkan.
- Tolak field tambahan yang tidak diperlukan.
- Lakukan validasi sebelum query database, email, atau integrasi eksternal.
- Normalisasi dan sanitasi seperlunya sesuai konteks; jangan gunakan sanitasi sebagai pengganti validasi.
- Ambil identitas user dari sesi server-side, bukan dari form.
- Cek autentikasi sedini mungkin.
- Lakukan otorisasi per resource, bukan hanya cek "sudah login".
- Untuk auth berbasis cookie, terapkan mitigasi CSRF yang jelas.
- Batasi payload: ukuran field, jumlah item, dan kompleksitas input.
- Terapkan rate limiting per user dan/atau IP pada action sensitif.
- Jangan log token, cookie, password, atau payload sensitif secara mentah.
- Gunakan error message yang aman untuk client, detail lengkap hanya di log server.
- Uji skenario bypass: field tambahan, resource milik user lain, submit berulang, dan token CSRF salah.
- Tinjau ulang semua action mutasi seperti Anda meninjau endpoint API publik.
Penutup
Server Actions membuat Next.js App Router lebih ergonomis, tetapi tidak menghapus kebutuhan akan disiplin keamanan backend. Jalur aman yang paling praktis adalah: validasi schema di server, autentikasi awal, otorisasi per resource, mitigasi CSRF untuk cookie-based flow, rate limit, logging aman, dan error yang tidak bocor. Jika urutan ini konsisten diterapkan, sebagian besar risiko umum pada Server Actions bisa dikurangi secara signifikan.
Mulailah dari satu action yang paling sensitif di aplikasi Anda, terapkan checklist di atas, lalu jadikan polanya sebagai standar internal tim. Itu biasanya jauh lebih efektif daripada menambah lapisan keamanan secara acak tanpa model ancaman yang jelas.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!