IDOR dan Broken Access Control: Panduan Praktis
Bagaimana IDOR dan broken access control menyusup ke REST, GraphQL, dan route handler Next.js, cara mengujinya, serta perbaikan yang bertahan di kode nyata.
· 7 menit baca · Lina Source LLC
Broken access control adalah kelas bug yang selamat dari setiap upgrade framework. ORM Anda meng-escape SQL, template engine Anda meng-escape HTML, tetapi tidak ada satu pun bagian dari stack yang tahu bahwa invoice 4812 milik Alice dan bukan milik Bob. Pengetahuan itu hidup di dalam kode Anda, dan ketika satu handler lupa menerapkannya, pengguna mana pun yang sudah login bisa membaca atau mengubah data orang lain.
Bentuk yang paling umum adalah insecure direct object reference, atau IDOR: klien mengirim sebuah identifier, server memuat record dengan identifier tersebut, dan tidak ada yang memeriksa apakah pemanggil berhak melihatnya. Mengeksploitasinya tidak butuh perkakas khusus. Sebuah browser, akun kedua, dan satu angka yang diubah di URL sudah cukup. Scanner yang mencari pemanggilan fungsi berbahaya jarang menangkapnya, karena kode yang rentan tidak mengandung apa pun yang berbahaya: sebuah lookup database yang benar-benar biasa hanya kehilangan satu kondisi.
Tiga CWE yang akan Anda temui
- CWE-639, authorization bypass melalui kunci yang dikendalikan pengguna: IDOR klasik. Record dipilih dengan ID yang dikendalikan penyerang, dan kepemilikan tidak pernah diperiksa.
- CWE-862, otorisasi yang hilang: handler sama sekali tidak melakukan cek otorisasi. Sering kali berupa endpoint admin atau internal yang diasumsikan tidak bisa dijangkau.
- CWE-285, otorisasi yang tidak tepat: cek ada tetapi salah. Ia memeriksa field yang keliru, memeriksa izin baca untuk operasi tulis, atau mempercayai role yang dikirim klien.
Perbedaan ini penting saat Anda memperbaiki bug. Cek yang hilang berarti Anda tinggal menambahkannya; cek yang tidak tepat berarti model tentang siapa boleh melakukan apa memang salah, dan kesalahan yang sama kemungkinan besar terulang di tempat lain. Ketika Anda menemukan salah satunya, cari saudara-saudaranya sebelum menutup tiket. Bug kontrol akses jarang berdiri sendiri; ia mengikuti pola yang disalin sebuah tim dari handler ke handler.
Bagaimana ini terjadi di route handler Next.js
Berikut polanya dalam bentuk yang paling umum. Handler mengautentikasi pengguna, yang terasa seperti keamanan, lalu memuat record hanya berdasarkan ID-nya. Cek sesi menjawab siapa yang memanggil; tidak ada yang menjawab apakah pemanggil ini boleh melihat invoice tersebut.
// app/api/invoices/[id]/route.ts (rentan)
import { NextResponse } from "next/server";
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) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
// Pengguna mana pun yang sudah login bisa membaca invoice apa saja dengan mengubah ID
const invoice = await db.invoice.findUnique({ where: { id } });
return NextResponse.json(invoice);
}Perbaikannya adalah menjadikan kepemilikan bagian dari query itu sendiri, bukan langkah terpisah yang bisa terlupakan. Jika record tidak milik pemanggil, database tidak mengembalikan apa pun dan handler menjawab 404.
// app/api/invoices/[id]/route.ts (diperbaiki)
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const invoice = await db.invoice.findFirst({
where: { id, userId: session.user.id },
});
if (!invoice) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(invoice);
}Mengembalikan 404 alih-alih 403 adalah pilihan yang disengaja. Respons 403 mengonfirmasi bahwa record itu ada, sehingga penyerang bisa mengenumerasi ID yang valid meskipun ia tidak bisa membacanya. Hal yang sama berlaku untuk waktu respons dan pesan error: respons untuk record milik orang lain harus tidak bisa dibedakan dari respons untuk record yang memang tidak pernah ada.
REST: endpoint yang sering dilupakan
Tim biasanya melindungi GET by ID yang sudah jelas. Bug-nya bersembunyi di verb lain dan di sudut-sudut API:
- Handler PATCH dan DELETE yang disalin dari handler GET sebelum cek kepemilikan ditambahkan.
- Route bersarang seperti /projects/:projectId/tasks/:taskId, di mana project diperiksa tetapi task dimuat hanya berdasarkan taskId dan bisa saja milik project lain.
- Endpoint bulk yang menerima array berisi ID dan hanya memeriksa elemen pertama.
- Unduhan file dan job ekspor, yang sering berjalan lewat service terpisah dengan cek yang lebih lemah.
- Payload update yang menerima ownerId, organizationId, atau role dari body permintaan dan menulisnya langsung ke database (mass assignment).
GraphQL memperluas permukaannya
Di GraphQL, objek yang sama bisa dijangkau lewat banyak jalur. Cek di level query pada invoice(id) tidak menolong jika invoice yang sama juga bisa dicapai melalui customer { invoices }, lookup node(id), atau tipe kembalian sebuah mutation. Setiap resolver yang mengembalikan objek adalah pintu masuk. Lapisan batching seperti DataLoader menambah jebakan lain: loader yang hanya berkunci ID akan dengan senang hati mengembalikan record untuk penonton mana pun, dan cache-nya bisa menyajikan data satu pengguna ke permintaan berikutnya jika ia dibagi antar-permintaan.
Pendekatan yang andal adalah melakukan otorisasi di lapisan data yang dipanggil resolver, bukan di resolver itu sendiri. Jika setiap jalur menuju sebuah invoice melewati satu fungsi yang menerima penonton dan membatasi query-nya, menambahkan field atau relasi baru tidak bisa melewatinya. Periksa juga input mutation: field seperti ownerId di sebuah input type adalah undangan untuk mengalihkan kepemilikan record. Terakhir, ingat bahwa introspeksi dan pesan error mengungkap skema Anda, jadi asumsikan penyerang tahu setiap field dan relasi yang Anda paparkan.
Bug yang sama di Python
Bentuknya identik di FastAPI dengan SQLAlchemy. Versi yang rentan memanggil db.get(Document, doc_id); versi yang diperbaiki memfilter berdasarkan pemilik di statement yang sama.
from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
app = FastAPI()
@app.get("/documents/{doc_id}")
def get_document(
doc_id: int,
user: User = Depends(current_user),
db: Session = Depends(get_db),
):
# Rentan: doc = db.get(Document, doc_id)
doc = db.scalar(
select(Document).where(
Document.id == doc_id,
Document.owner_id == user.id,
)
)
if doc is None:
raise HTTPException(status_code=404, detail="Not found")
return docPerbaikan yang bertahan
Batasi setiap query berdasarkan pemilik atau tenant
Masukkan ID pengguna atau organisasi ke dalam klausa WHERE setiap operasi baca dan tulis. Ini mengubah otorisasi menjadi properti dari query, yang mudah terlihat saat review. Untuk aplikasi multi-tenant, row-level security di Postgres bisa menegakkan batas tenant sebagai lapisan kedua, sehingga filter yang terlupa mengembalikan nol baris, bukan baris milik pelanggan lain. Pembatasan yang sama berlaku untuk operasi tulis. Sebuah update sebaiknya berupa satu statement yang difilter dengan ID sekaligus pemilik, misalnya updateMany dengan kedua kondisi tersebut lalu diikuti pemeriksaan bahwa tepat satu baris berubah, bukan baca, cek, dan tulis terpisah yang bisa balapan.
Pusatkan keputusannya
Pernyataan if yang berserakan lama-lama saling menyimpang. Sekumpulan kecil helper, satu per resource, menjaga aturan tetap di satu tempat dan membuat handler tanpa pemanggilan helper langsung mencolok.
// lib/authz.ts
type Role = "owner" | "member" | "viewer";
type Action = "read" | "update" | "delete";
const policy: Record<Role, ReadonlySet<Action>> = {
owner: new Set<Action>(["read", "update", "delete"]),
member: new Set<Action>(["read", "update"]),
viewer: new Set<Action>(["read"]),
};
export class NotFoundError extends Error {}
export async function requireProject(
userId: string,
projectId: string,
action: Action
) {
const membership = await db.membership.findFirst({
where: { userId, projectId },
include: { project: true },
});
// Tolak secara default: tanpa keanggotaan dan tanpa izin terlihat sama saja
if (!membership || !policy[membership.role as Role]?.has(action)) {
throw new NotFoundError();
}
return membership.project;
}Tolak secara default
Role yang tidak dikenal, keanggotaan yang hilang, dan aksi yang tak terduga semuanya harus jatuh ke penolakan. Di framework yang punya middleware, wajibkan autentikasi untuk segalanya dan tandai route publik secara eksplisit, bukan sebaliknya. Route baru harus terkunci sampai seseorang memutuskan sebaliknya. Jangan pernah mengambil role, tenant, atau ID pengguna dari body permintaan atau header yang diset klien; turunkan semuanya dari sesi terverifikasi di server setiap kali.
Identifier acak seperti UUID layak dipakai, tetapi bukan perbaikan. ID bocor lewat URL, log, tautan berbagi, dan header referrer. Anggap ID tidak bisa ditebak hanya dalam arti memperlambat enumerasi, tidak pernah sebagai cek akses.
Cara mengujinya
Pengujian IDOR sederhana dan berulang, dan justru karena itu layak diotomatiskan begitu Anda pernah melakukannya secara manual. Mulailah dari inventaris: daftar setiap route, resolver, dan job latar belakang yang menerima identifier, termasuk ID yang tersembunyi di body permintaan, query string, dan header.
- Buat dua akun, A dan B, idealnya di dua organisasi terpisah. Buat sebuah record sebagai A dan catat ID-nya.
- Ulangi setiap permintaan yang merujuk ID tersebut dengan sesi B: GET, PATCH, DELETE, unduhan, ekspor, serta query atau mutation GraphQL apa pun yang menyentuhnya.
- Harapkan 404 untuk semuanya. Setiap 200, dan setiap 403 yang mengonfirmasi keberadaan record, adalah temuan.
- Ubah pemeriksaan manual itu menjadi satu tes integrasi per resource, sehingga handler baru tanpa query yang dibatasi akan gagal di CI.
- Grep untuk lookup yang hanya memakai primary key, seperti findUnique({ where: { id } }) atau db.get(Model, id), lalu berikan pembenaran untuk masing-masing.
Review kode menangkap apa yang luput dari tes, karena cek yang hilang terlihat di kode sumber bahkan ketika tidak ada yang menulis tes untuk route tersebut. CodeAuditAgent membaca repositori GitHub publik atau snippet yang ditempel dan melaporkan celah kontrol akses lengkap dengan CWE, baris yang dikutip, skenario eksploit, dan patch yang diusulkan, yang merupakan cara cepat untuk mendapatkan pemeriksaan kedua atas semua handler sekaligus.
Daftar periksa singkat
- Setiap query yang menerima ID dari klien juga memfilter berdasarkan pengguna atau tenant si pemanggil.
- Otorisasi berada di helper bersama atau di lapisan data, bukan di pernyataan if hasil salin-tempel.
- Kasus yang tidak dikenal ditolak; route publik adalah pengecualian yang eksplisit.
- Operasi tulis diperiksa secermat operasi baca, termasuk route bulk dan bersarang.
- Tes dua akun tersedia untuk setiap resource dan berjalan di CI.