Skip to main content
BlogSecurity

IDOR yang Membuat Satu Cabang Membaca Pendapatan Cabang Lain

IDOR nyata di ERP multi-cabang: bagaimana endpoint detail yang mengambil hanya berdasarkan id membocorkan pendapatan cabang lain, dan perbaikan scoping branchId yang saya sebarkan ke setiap modul Prisma.

Matthews Wong

Software Engineer

Juli 20269 menit baca
IDOR yang Membuat Satu Cabang Membaca Pendapatan Cabang Lain

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.

Bagaimana kerentanan itu muncul

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.

Jebakan dua query dan pola yang benar

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.

Dari mana branchId berasal: token, bukan request

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.

Menyebarkan perbaikan ke setiap modul

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.

  1. Grep codebase untuk setiap panggilan findUnique dan findMany, dan daftar setiap metode service yang mengembalikan data milik cabang.
  2. Ubah setiap ambil detail dari findUnique berdasarkan id menjadi findFirst dengan id dan branchId, mengembalikan 404 saat tidak cocok.
  3. Jadikan branchId parameter wajib pada setiap metode service, sehingga type checker menolak setiap pemanggilan yang melupakannya.
  4. Alirkan setiap branchId melalui request yang diisi guard, dan hapus jalur kode mana pun yang membaca branchId dari klien.
  5. Tulis satu tes otorisasi per modul yang login sebagai cabang A dan memastikan 404 untuk id cabang B.

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.

Menguji bahwa lubangnya tetap tertutup

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');
});
LapisanTugasnyaYang tak bisa dilakukannya sendiri
Guard (CanActivate)Memastikan cabang ada di token dan menyematkannya ke requestIa tidak memfilter baris; query yang di-scope tetap harus berjalan
Query di-scope (findFirst id + branchId)Mengembalikan baris hanya bila milik cabang pemanggilIa memercayai branchId apa pun yang diberikan, jadi sumbernya harus aman
Pengetikan argumen wajibMenggagalkan build ketika pemanggilan lupa branchIdIa memeriksa keberadaan, bukan kebenaran nilai saat runtime
Tes otorisasi e2eMembuktikan akses lintas cabang mengembalikan 404 dan tetap begituIa 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.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Apa itu IDOR pada ERP multi-cabang?

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.

Mengapa memakai Prisma findFirst dan bukan findUnique untuk endpoint detail?

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.

Dari mana branchId harus berasal untuk mencegah IDOR?

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.

Sebaiknya akses lintas cabang mengembalikan 403 atau 404?

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.

Bagaimana mencegah IDOR muncul kembali setelah diperbaiki?

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.