Server Actions pada Next.js App Router menyederhanakan mutasi data dengan mengabstraksi komunikasi klien-server. Abstraksi ini kerap menimbulkan ilusi keamanan: developer memperlakukan Server Actions sebagai fungsi internal privat. Secara teknis, setiap Server Action dikompilasi menjadi endpoint HTTP POST publik dengan identifier unik (ID aksi). Siapa pun dapat mengirimkan payload kustom langsung ke endpoint ini tanpa melalui antarmuka pengguna.
Anatomi Celah: Unvalidated Payload dan IDOR
Tanpa validasi ketat dan otorisasi berlapis, Server Action rentan terhadap Insecure Direct Object Reference (IDOR) atau Broken Object Level Authorization (BOLA). Skenario umum terjadi saat fungsi menerima ID resource langsung dari argumen pemanggilan lalu menjalankan mutasi database tanpa memverifikasi relasi kepemilikan data dengan sesi pengguna aktif.
Perhatikan contoh implementasi rentan berikut:
// app/actions/notes.ts
'use server';
import { db } from '@/lib/db';
// RENTAN: Tanpa validasi skema, otentikasi sesi, dan pengecekan kepemilikan
export async function updateNoteAction(noteId: string, content: string) {
await db.note.update({
where: { id: noteId },
data: { content },
});
return { success: true };
}
Pada kode di atas, penyerang cukup memodifikasi noteId pada request POST untuk mengubah catatan milik pengguna lain. Kerusakan data atau injeksi konten berbahaya juga dapat terjadi karena nilai content diterima mentah tanpa validasi tipe, panjang, maupun sanitasi.
Langkah 1: Validasi Skema Input pada Boundary
Pertahanan pertama adalah memvalidasi seluruh payload pada layer boundary menggunakan Zod. Jangan mengasumsikan tipe data TypeScript di sisi server sudah menjamin integritas runtime data dari klien.
import { z } from 'zod';
export const UpdateNoteSchema = z.object({
noteId: z.string().uuid({ message: 'Format ID tidak valid' }),
content: z.string().trim().min(1, 'Konten tidak boleh kosong').max(5000, 'Konten melebihi batas 5000 karakter'),
});
export type UpdateNoteInput = z.infer<typeof UpdateNoteSchema>;
Langkah 2: Validasi Sesi dan Verifikasi Kepemilikan (Ownership Check)
Identitas pengguna tidak boleh diambil dari argumen input fungsi. Identitas harus diresolusi langsung di server melalui token sesi atau cookie terenkripsi. Setelah sesi terverifikasi, mutasi database wajib menyertakan constraint kepemilikan (misal: userId) langsung pada klausa query, bukan sekadar mengecek keberadaan data.
Prinsip Penting: Gabungkan filter ID resource dan ID pemilik ke dalam satu query operasi mutasi (misalnya WHERE id = :id AND user_id = :userId). Pendekatan ini mengeliminasi celah Time-of-Check to Time-of-Use (TOCTOU) race condition dibandingkan pemisahan query baca lalu tulis.
Langkah 3: Isolasi Logika Backend dengan 'server-only'
Next.js mengizinkan utilitas backend diimpor di berbagai tempat. Untuk mencegah kebocoran logika bisnis, query database langsung, atau environment variable rahasia ke bundle klien, pasang paket server-only pada modul data access layer (DAL).
npm install server-only
// lib/data-access/notes.ts
import 'server-only';
import { db } from '@/lib/db';
export async function updateNoteInDb(noteId: string, userId: string, content: string) {
// Constraint kepemilikan dieksekusi langsung pada layer database
const result = await db.note.updateMany({
where: {
id: noteId,
userId: userId,
},
data: {
content,
updatedAt: new Date(),
},
});
return result.count > 0;
}
Implementasi Lengkap: Server Action yang Aman
Berikut adalah implementasi Server Action standar produksi yang mengintegrasikan validasi boundary, proteksi otorisasi, dan error handling seragam tanpa mengekspos stack trace internal:
// app/actions/notes.ts
'use server';
import { auth } from '@/lib/auth';
import { UpdateNoteSchema } from '@/lib/validations/note';
import { updateNoteInDb } from '@/lib/data-access/notes';
type ActionResponse<T> =
| { success: true; data: T }
| { success: false; error: string; fieldErrors?: Record<string, string[]> };
export async function updateNoteAction(rawInput: unknown): Promise<ActionResponse<{ updated: boolean }>> {
try {
// 1. Ekstraksi sesi pengguna secara independen dari server context
const session = await auth();
if (!session?.user?.id) {
return {
success: false,
error: 'Akses ditolak: Sesi tidak ditemukan atau kedaluwarsa.',
};
}
// 2. Validasi input boundary dengan Zod
const parsed = UpdateNoteSchema.safeParse(rawInput);
if (!parsed.success) {
return {
success: false,
error: 'Validasi gagal.',
fieldErrors: parsed.error.flatten().fieldErrors,
};
}
const { noteId, content } = parsed.data;
// 3. Mutasi data dengan ownership enforcement
const isUpdated = await updateNoteInDb(noteId, session.user.id, content);
if (!isUpdated) {
return {
success: false,
error: 'Resource tidak ditemukan atau Anda tidak memiliki izin modifikasi.',
};
}
return {
success: true,
data: { updated: true },
};
} catch (err) {
// 4. Log detail error hanya di server console/monitoring
console.error('[SERVER_ACTION_ERROR] updateNoteAction failed:', err);
// Kembalikan pesan umum ke klien demi mencegah information disclosure
return {
success: false,
error: 'Terjadi kesalahan sistem internal. Silakan coba beberapa saat lagi.',
};
}
}
Perbandingan Pola: Rentan vs Aman
- Resolusi Identitas: Menerima
userIddari payload klien (Rentan) vs MengambiluserIddari verifikasi session cookie terenkripsi via backend runtime (Aman). - Verifikasi Objek: Mengasumsikan keberadaan resource tanpa matching relasi akun (Rentan) vs Memasukkan
userIdke klausaWHEREmutasi (Aman). - Penanganan Input: Menetapkan tipe data via casting TypeScript
as InputType(Rentan) vs Runtime parsing skema lewat Zod/Valibot (Aman). - Error Handling: Mengembalikan raw exception error database ke klien (Rentan) vs Mengabstraksi error message dan mencatat log detail di server (Aman).
Checklist Audit Keamanan Server Actions
- Eksposur Endpoint: Apakah disadari bahwa fungsi beranotasi
'use server'dapat dipanggil oleh HTTP client arbitrer tanpa melalui UI aplikasi? - Boundary Validation: Apakah semua parameter menggunakan parser runtime seperti
safeParse()sebelum diproses logika domain? - Session Extraction: Apakah identitas aktor diambil langsung dari auth provider server-side, bukan dari payload request?
- Direct Object Reference: Apakah mutasi data (UPDATE, DELETE) selalu menyertakan pengondisian hak milik aktor terhadap entitas target?
- Information Disclosure: Apakah blok
catchmenahan eksposur error driver database (misal: Prisma/Drizzle error code/stack trace) agar tidak terkirim ke klien? - Modul DAL: Apakah utilitas database langsung dilindungi dengan
import 'server-only'?
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!