IDOR yang Membuat Satu Cabang Membaca Pendapatan Cabang Lain

Foto oleh ninjaonelive via Openverse (CC BY 2.0)
IDOR, atau insecure direct object reference, adalah bug broken access control ketika pengguna terautentikasi membaca record yang bukan miliknya dengan menyediakan id-nya. Pada ERP multi-cabang artinya pengguna di satu cabang mengambil order, pembayaran, atau payroll cabang lain hanya dengan mengubah id di URL. OWASP menyebutnya Broken Object Level Authorization, peringkat satu di API Security Top 10 2023.
findUnique hanya menerima primary key dan tidak akan memfilter berdasarkan kolom non-unik seperti branchId, jadi ia tidak bisa menegakkan scope tenant. findFirst menerima klausa where bebas sehingga Anda bisa mewajibkan id dan branchId sekaligus. Memakai findFirst dengan kedua field berarti permintaan untuk id cabang lain tidak cocok dengan apa pun dan mengembalikan 404.
branchId harus diambil dari token terautentikasi, bukan dari query parameter, body request, atau header klien. Jika klien bisa mengirim branchId-nya sendiri, penyerang tinggal menyediakan nilai cabang lain dan kerentanan muncul kembali satu lapis di atas. Sematkan cabang ke request di dalam guard dan baca hanya dari sana.
Kembalikan 404, bukan 403. Respons 403 mengonfirmasi bahwa id itu ada di cabang lain, yang membantu penyerang mengenumerasi id valid. Memperlakukan record sebagai tidak ditemukan sesuai dengan scope otorisasi pemanggil dan tidak mengungkap apa pun di luar itu.
Dorong invariant ke dalam sistem tipe dengan menjadikan branchId argumen wajib pada setiap metode service, sehingga scope yang terlupa menggagalkan build. Lalu tambahkan tes otorisasi end-to-end per modul yang login sebagai satu cabang dan memastikan 404 untuk id cabang lain. Untuk jumlah tenant yang lebih besar, row-level security PostgreSQL menambah jaring pengaman di level database.

Foto oleh ninjaonelive via Openverse (CC BY 2.0)
Ringkasan Utama
Pada ERP multi-cabang di atas PostgreSQL bersama, setiap query daftar dan detail harus difilter berdasarkan branchId, bukan hanya id record. Gunakan Prisma findFirst dengan klausa where yang mewajibkan id dan branchId sekaligus, ambil branchId dari token terautentikasi, dan tegakkan lewat guard agar scope yang terlupa tidak membocorkan data cabang lain.
JID Carwash ERP menjalankan tiga cabang di atas satu database PostgreSQL. Order, pembayaran, kasbon (uang muka karyawan), dan payroll semuanya berada di tabel bersama dengan kolom branchId. Desain itu murah dan sederhana sampai suatu hari Anda sadar seorang kasir di satu cabang bisa membaca pendapatan cabang lain hanya dengan mengubah angka di URL. Ini adalah kisah insecure direct object reference (IDOR) yang lolos di kode saya, bagaimana saya menemukannya, dan pola perbaikan sistematis yang saya terapkan ke seluruh modul.
OWASP menyebutnya Broken Object Level Authorization, peringkat satu di OWASP API Security Top 10 2023, dinilai mudah dieksploitasi dan tersebar luas. Bentuk klasiknya adalah pengguna terautentikasi menukar id dengan id milik rekan pada level hak akses yang sama, tanpa eskalasi. Persis itulah yang diizinkan ERP saya, dan perbaikannya menyentuh akses data, konteks request, dan penegakan guard sekaligus.
Ini bermula dari tiket support, bukan pentest. Seorang manajer cabang melaporkan melihat sebuah order di tampilan detail yang tidak cocok dengan apa pun di daftarnya sendiri. Daftar order sudah di-scope dengan benar, tetapi endpoint detail mengambil data hanya berdasarkan id. Ketika saya membuka service-nya, saya langsung mencium masalahnya: query daftar difilter berdasarkan branchId, sedangkan query detail tidak. Dua query terhadap tabel yang sama, dua aturan otorisasi yang berbeda. URL membawa id mirip integer berurutan, sehingga menaikkannya langsung masuk ke order cabang lain.
Saya mereproduksinya dalam sepuluh detik dengan token sesi asli. Login sebagai cabang A, saya memanggil endpoint detail dengan id order milik cabang B dan mendapatkan record lengkapnya: nama pelanggan, nomor plat, dan jumlah yang sudah disetel dalam IDR integer. Tanpa error, tanpa respons kosong, hanya uang cabang lain di layar. Itulah sifat berbahaya dari scope yang hilang. Ia tidak melempar error, ia diam-diam mengembalikan baris yang salah.
Akar masalahnya adalah Prisma memberi dua cara nyaman untuk mengambil satu baris, dan hanya salah satunya yang bisa di-scope. findUnique menerima primary key dan menolak klausa where gabungan pada kolom non-unik, jadi ia tidak bisa memfilter berdasarkan branchId. findFirst menerima klausa where bebas, sehingga bisa mewajibkan id dan branchId sekaligus. IDOR menyusup karena findUnique terlihat lebih rapi untuk endpoint detail, dan tak seorang pun menyadari filter tenant yang hilang sampai data bocor.
// BEFORE — the IDOR. Any authenticated user reaches any branch's order.
async findOne(id: string) {
return this.prisma.order.findUnique({
where: { id }, // primary key only — branch is ignored
});
}
// AFTER — scoped detail. id AND branchId must both match, or it is 404.
async findOne(id: string, branchId: string) {
const order = await this.prisma.order.findFirst({
where: { id, branchId },
});
if (!order) {
// Return 404, not 403: never confirm the row exists in another branch.
throw new NotFoundException('Order not found');
}
return order;
}
// LIST — the same rule. branchId is a required filter, never optional.
async findAll(branchId: string, params: ListOrdersDto) {
return this.prisma.order.findMany({
where: { branchId, status: params.status },
orderBy: { createdAt: 'desc' },
});
}Kembalikan 404 untuk baris yang ada di cabang lain, jangan pernah 403. Respons 403 mengonfirmasi id itu nyata dan memberi tahu penyerang persis id mana yang harus dienumerasi. Perlakukan akses lintas cabang seolah record itu memang tidak ada, karena dari scope otorisasi pemanggil memang tidak ada.
Query yang di-scope hanya seaman branchId yang Anda berikan. Aturan paling penting adalah branchId berasal dari JWT terautentikasi, bukan dari query parameter, body request, atau header yang dikendalikan klien. Jika klien bisa mengirim branchId-nya sendiri, Anda hanya memindahkan IDOR satu lapis ke atas. Di NestJS saya menempelkan cabang ke request saat autentikasi, dan sebuah decorator kecil menyerahkannya ke controller sehingga service selalu menerima nilai yang tepercaya.
// Guard runs after auth, before the handler. It asserts a branch is present
// and pins it onto the request so no handler can read data unscoped.
@Injectable()
export class BranchScopeGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const branchId = req.user?.branchId; // set by JwtStrategy from the token
if (!branchId) {
throw new ForbiddenException('No branch context on this session');
}
req.branchId = branchId; // the ONLY trusted source downstream
return true;
}
}
// Param decorator — controllers can only get branchId from the guarded request.
export const BranchId = createParamDecorator(
(_data, ctx: ExecutionContext): string => ctx.switchToHttp().getRequest().branchId,
);
// Controller — branchId is injected, not accepted from the client.
@UseGuards(JwtAuthGuard, BranchScopeGuard)
@Get(':id')
findOne(@Param('id') id: string, @BranchId() branchId: string) {
return this.ordersService.findOne(id, branchId);
}Guard adalah lapisan yang tepat untuk ini. Di NestJS pipeline berjalan middleware, lalu guard, lalu interceptor, lalu handler, sehingga guard yang gagal memutus request sebelum kode service mana pun dijalankan. Guard mengimplementasikan CanActivate dan memang dirancang untuk keputusan otorisasi, yang persis seperti scoping cabang. Saya menyimpan branchId turunan di objek request alih-alih membaca ulang token di setiap service, jadi ada satu sumber tepercaya dan satu tempat untuk diaudit.
Memperbaiki orders saja tidak cukup. Jebakan dua query yang sama ada di payments, kasbon, payroll, purchases, inventory, dan tampilan buku kas turunan. Saya memperlakukannya sebagai audit sistematis, bukan tambalan sekali jalan, karena satu modul yang terlewat membuka kembali seluruh kerentanan.
Menjadikan branchId argumen wajib pada setiap metode service mengubah bug keamanan runtime menjadi error saat kompilasi. Setelah refactor, lupa men-scope query tidak lagi membocorkan data pukul 2 pagi; ia menggagalkan build TypeScript di laptop saya. Dorong invariant ke dalam sistem tipe kapan pun Anda bisa.
Perbaikan keamanan tanpa tes regresi adalah perbaikan yang akan dibatalkan dalam enam bulan. Tes saya menyemai dua cabang, membuat satu order di masing-masing, lalu autentikasi sebagai cabang A dan memastikan bahwa meminta id order cabang B mengembalikan 404. Tes dengan bentuk yang sama berjalan terhadap setiap modul yang di-scope. Ini membosankan, berulang, dan justru itulah alasan sebuah refactor di masa depan tidak bisa diam-diam memunculkan kembali kebocoran itu.
// e2e authorization test — the regression net for the IDOR.
it('does not leak another branch order via the detail endpoint', async () => {
const orderB = await seedOrder({ branchId: 'branch-B' });
const res = await request(app.getHttpServer())
.get(`/orders/${orderB.id}`)
.set('Authorization', `Bearer ${branchAToken}`);
expect(res.status).toBe(404); // never 200, never 403
expect(res.body).not.toHaveProperty('amount');
});| Lapisan | Tugasnya | Yang tak bisa dilakukannya sendiri |
|---|---|---|
| Guard (CanActivate) | Memastikan cabang ada di token dan menyematkannya ke request | Ia tidak memfilter baris; query yang di-scope tetap harus berjalan |
| Query di-scope (findFirst id + branchId) | Mengembalikan baris hanya bila milik cabang pemanggil | Ia memercayai branchId apa pun yang diberikan, jadi sumbernya harus aman |
| Pengetikan argumen wajib | Menggagalkan build ketika pemanggilan lupa branchId | Ia memeriksa keberadaan, bukan kebenaran nilai saat runtime |
| Tes otorisasi e2e | Membuktikan akses lintas cabang mengembalikan 404 dan tetap begitu | Ia hanya mencakup jalur yang Anda ingat untuk ditulis tesnya |
Defense in depth adalah pelajaran sesungguhnya. Tidak ada satu lapisan pun yang dipercaya sempurna: guard menyediakan branchId yang aman, query menegakkannya, sistem tipe menangkap kelalaian, dan tes membuktikannya secara menyeluruh. Bagi tim di PostgreSQL yang ingin jaring pengaman terakhir, row-level security database menggunakan variabel sesi akan memfilter baris bahkan ketika kode aplikasi lupa klausa where. Saya tidak menambahkan RLS ke ERP ini, tetapi pada jumlah tenant yang lebih besar itulah lapisan yang akan saya pilih berikutnya.