Banyak developer menganggap Server Actions di Next.js berjalan di lingkungan tertutup yang otomatis aman karena dieksekusi di sisi server. Anggapan ini keliru. Setiap fungsi yang ditandai dengan direktif 'use server' diekspos oleh Next.js sebagai endpoint HTTP POST publik yang dapat dipanggil langsung via cURL atau Postman tanpa melalui antarmuka pengguna.
Ketika validasi sesi dan otorisasi dilewati di dalam fungsi tersebut, aplikasi terbuka lebar terhadap serangan Insecure Direct Object Reference (IDOR). Lebih buruk lagi, pada arsitektur database multi-tenant, query yang mengabaikan parameter penyewa (tenant) tidak hanya membocorkan data, tetapi juga merusak performa database akibat memicu Full Table Scan (Sequential Scan).
Anatomi Server Action dan Kerentanan IDOR
Saat Server Action dideklarasikan, Next.js menghasilkan action ID unik dan mendaftarkan rute internal. Klien memicu aksi ini dengan mengirimkan payload JSON melalui metode HTTP POST.
Perhatikan contoh implementasi yang rentan berikut:
// app/actions/document.ts
'use server';
import { db } from '@/lib/db';
export async function deleteDocument(documentId: string) {
// KERENTANAN: Mengabaikan sesi pengguna dan tenant_id
await db.query('DELETE FROM documents WHERE id = $1', [documentId]);
return { success: true };
}Penyerang yang terautentikasi dapat mengganti documentId dengan UUID dokumen milik organisasi atau pengguna lain. Database akan langsung mengeksekusi penghapusan karena query tidak membatasi konteks kepemilikan.
Dampak Arsitektur Database Multi-Tenant: Mengapa Terjadi Full Table Scan?
Dalam sistem multi-tenant berbasis shared database/shared schema, isolasi data umumnya dicapai melalui kolom tenant_id pada setiap tabel. Relasi data dan integritas referensial ditegakkan dengan composite primary key atau composite index.
Struktur Skema DDL dan Index Komposit
-- Skema tabel dokumen multi-tenant
CREATE TABLE documents (
tenant_id UUID NOT NULL,
id UUID NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT pk_documents PRIMARY KEY (tenant_id, id)
);
-- Membuat composite index eksplisit jika tidak menggunakan composite PK
-- CREATE INDEX idx_documents_tenant_id_id ON documents (tenant_id, id);Masalah Leftmost Prefix pada B-Tree Index
Indeks komposit (tenant_id, id) diurutkan secara hierarkis oleh PostgreSQL: data diurutkan berdasarkan tenant_id terlebih dahulu, baru kemudian berdasarkan id. Struktur pohon B-Tree ini memerlukan kolom paling kiri (leftmost prefix) agar pencarian cabang dapat berjalan.
Ketika query yang rentan IDOR dieksekusi:
SELECT * FROM documents WHERE id = 'b2383c07-b3ff-4b13-8fcb-d5a22839b23b';Database tidak dapat memanfaatkan indeks (tenant_id, id) karena kolom tenant_id tidak disediakan dalam klausa WHERE. Akibatnya, query engine terpaksa membaca seluruh tabel dari awal hingga akhir menggunakan Sequential Scan (Seq Scan).
Perbandingan EXPLAIN ANALYZE
Pengujian berikut dijalankan pada tabel documents yang berisi 1.000.000 baris data di PostgreSQL.
1. Query IDOR Tanpa Tenant Scoping (Unindexed Lookup)
EXPLAIN ANALYZE
SELECT * FROM documents
WHERE id = 'b2383c07-b3ff-4b13-8fcb-d5a22839b23b';QUERY PLAN
------------------------------------------------------------------------------------------------------------------------
Seq Scan on documents (cost=0.00..21926.00 rows=1 width=72) (actual time=48.210..48.211 rows=1 loops=1)
Filter: (id = 'b2383c07-b3ff-4b13-8fcb-d5a22839b23b'::uuid)
Rows Removed by Filter: 999999
Planning Time: 0.115 ms
Execution Time: 48.243 ms2. Query Berotorisasi dengan Tenant Scoping (Indexed Lookup)
EXPLAIN ANALYZE
SELECT * FROM documents
WHERE tenant_id = 'a1111111-1111-1111-1111-111111111111'
AND id = 'b2383c07-b3ff-4b13-8fcb-d5a22839b23b';QUERY PLAN
------------------------------------------------------------------------------------------------------------------------
Index Scan using pk_documents on documents (cost=0.42..8.44 rows=1 width=72) (actual time=0.028..0.029 rows=1 loops=1)
Index Cond: ((tenant_id = 'a1111111-1111-1111-1111-111111111111'::uuid) AND (id = 'b2383c07-b3ff-4b13-8fcb-d5a22839b23b'::uuid))
Planning Time: 0.088 ms
Execution Time: 0.045 msQuery dengan batasan tenant_id langsung mengeksekusi Index Scan dengan waktu eksekusi turun dari 48.243 ms menjadi 0.045 ms (peningkatan kecepatan lebih dari 1000x) dan menghilangkan konsumsi resource disk I/O yang masif.
Solusi: Pola Action Wrapper untuk Server Actions
Menulis pengecekan autentikasi dan ekstraksi tenant secara manual di setiap Server Action rawan kesalahan manusia. Gunakan pola higher-order function (action wrapper) untuk menegakkan verifikasi sesi dan injeksi konteks tenant sebelum handler bisnis dijalankan.
1. Membuat Wrapper Otorisasi
// lib/action-client.ts
import { getSession } from '@/lib/auth'; // Sesuaikan dengan provider auth Anda
interface AuthenticatedContext {
userId: string;
tenantId: string;
}
type ActionHandler<TInput, TOutput> = (
input: TInput,
ctx: AuthenticatedContext
) => Promise<TOutput>;
export function createAuthorizedAction<TInput, TOutput>(
handler: ActionHandler<TInput, TOutput>
) {
return async (input: TInput): Promise<TOutput> => {
const session = await getSession();
if (!session || !session.userId || !session.tenantId) {
throw new Error('Unauthorized: Akses ditolak.');
}
return handler(input, {
userId: session.userId,
tenantId: session.tenantId,
});
};
}2. Implementasi Server Action Aman
// app/actions/document.ts
'use server';
import { createAuthorizedAction } from '@/lib/action-client';
import { db } from '@/lib/db';
import { z } from 'zod';
const DeleteDocumentSchema = z.object({
documentId: z.string().uuid(),
});
export const deleteDocument = createAuthorizedAction(
async (rawInput: { documentId: string }, ctx) => {
const { documentId } = DeleteDocumentSchema.parse(rawInput);
// Query selalu mengunci tenant_id dari konteks sesi server
const result = await db.query(
'DELETE FROM documents WHERE tenant_id = $1 AND id = $2 RETURNING id',
[ctx.tenantId, documentId]
);
if (result.rowCount === 0) {
throw new Error('Dokumen tidak ditemukan atau akses tidak sah.');
}
return { success: true };
}
);Aturan Pengamanan Database Multi-Tenant di Server Actions
- Jangan pernah percaya input
tenant_iddari klien: Nilaitenant_idwajib diekstraksi langsung dari sesi server (JWT tervalidasi atau cookie HTTP-only terenkripsi). - Wajibkan klausul komposit: Setiap operasi
SELECT,UPDATE, danDELETEpada data spesifik harus menyertakantenant_idbersama identifier dokumen. - Gunakan Row-Level Security (RLS): Untuk pertahanan berlapis, aktifkan PostgreSQL RLS agar database secara otomatis menolak query lintas tenant sekalipun terjadi bug pada kode aplikasi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!