BOLA Test Matrisini Otomasyona Bağlamak: Endpoint Büyürken Yetki Nasıl Korunur

IDOR test matrisini kod haline getirmek: rol ve tenant kombinasyonlarını otomatik test eden, yeni endpoint eklenirken çalışması zorunlu bir düzen.

14 dk okuma
ibrahimsql
2.779 kelime

BOLA Test Matrisini Otomasyona Bağlamak#

IDOR test planı yazısında matris vardı: aynı tenant farklı rol, farklı tenant, eski üyelik, read yetkisiyle write isteği. Bu matris bir dokümanda durduğu sürece ürün büyürken çalışmaz. Yeni endpoint ekleyen kişi dokümanı okumaz. Matrisin kodda yaşaması gerekir.

Buradaki fikir basit: yetki kombinasyonlarını test dosyasında sabit bir liste olarak tut, her endpoint bu listeden geçmeden CI'dan geçemesin.

Doküman matrisi neden ölür: kopyalanan endpoint sorunu#

Dokümandaki matris ilk gün doğru olur, ikinci ay bayatlar.

Sebep ekip disiplinsizliği değil, endpoint sayısının artış biçimidir.

Ana kaynak endpoint korunur, onun kopyaları korunmaz.

Somut senaryo şöyle gelişir: GET /api/invoices/:id ilk yazıldığında yetki kontrolü konur.

Kod requireInvoiceAccess çağırır, db.invoice sorgusuna session.tenantId filtresi ekler, testten geçer.

Birkaç sprint sonra üç yan yol eklenir.

Birincisi PDF dışa aktarım: GET /api/invoices/:id/pdf.

Fatura ekranına "PDF indir" butonu istenir, backend geliştiricisi mevcut handler fonksiyonunu kopyalar, PDF üretme kütüphanesine bağlar.

Kopyalama sırasında yetki satırı arada kaynar çünkü yeni dosya sıfırdan açılır ve örnek olarak eski dosyanın ortası değil, PDF kütüphanesinin örnek kodu alınır.

İkincisi webhook tekrarı: POST /api/invoices/:id/retry.

Ödeme sağlayıcısı bildirimi düşmeyince destek ekibi "tekrar gönder" butonu ister.

Bu endpoint faturayı döndürmez, sadece kuyruğa iş atar.

Yazan kişi "nasılsa veri dönmüyoruz" diye düşünür ve ID kontrolünü atlar.

Oysa kuyruktaki iş, fatura tutarı ve müşteri bilgisi gibi alanları aynen taşır.

Üçüncüsü admin önizleme: GET /api/admin/invoices/preview?invoiceId=....

Destek ekibi müşterinin faturasını görmek ister, hızlı bir iç ekran yapılır.

Sorgu parametresinden gelen ID doğrudan db.invoice.findUnique içine verilir.

Admin router altında olduğu için "zaten admin panelinden geçiyor" varsayılır, oysa panel oturumu ile obje sahipliği aynı şey değildir.

Üç yolun ortak noktası şudur: hiçbiri kötü niyetle yazılmaz, hepsi zaman baskısıyla ana handler fonksiyonundan ayrı bir dosyada doğar.

Doküman matrisi bu üçünü yakalayamaz çünkü matrisi koşturan kimse yoktur.

Matris ancak her yeni route dosyasının yanında koşan bir test olduğunda yaşar.

Bu yüzden aşağıda fabrika, istemci, koşucu ve CI kaydı tek bir bütün olarak kurulur.

Test kullanıcısı fabrikası#

Matrisin yakıtı, ilişkisi farklı dört kullanıcıdır:

KullanıcıTanım
OwnerObjeye tam yetkili, aynı tenant
MemberAynı tenant, kısıtlı rol
OutsiderFarklı tenant, geçerli token
ExpiredÜyeliği iptal edilmiş, token hâlâ canlı

Bu dördü seed script ile her test koşusunda sıfırdan kurulur. Elle açılmış iki kullanıcıyle yapılan IDOR testi bir kez çalışır; fabrika her PR'da çalışır.

Fabrikanın işi dört kayıt açmaktan ibaret değildir.

Her koşuda izole iki tenant kurar, rolleri üyelik tablosuna yazar, hedef objeyi kurban tenant içine koyar ve koşu bitiminde hepsini temizler.

Temizlik olmazsa bir önceki koşunun verisi sonraki koşunun beklentisini bozar.

Aşağıdaki fabrika bu yüzden seedMatrixWorld ve cleanupMatrixWorld ikilisini birlikte verir.

import { db } from './db' import { createSessionToken } from './auth/sessions' export type MatrixRole = 'owner' | 'member' | 'outsider' | 'expired' export type MatrixWorld = { tenantVictim: { id: string } tenantOther: { id: string } users: Record<MatrixRole, { id: string; email: string; token: string; tenantId: string }> invoiceId: string victimMarker: string } const runTag = `mx-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6)}` export async function seedMatrixWorld(): Promise<MatrixWorld> { await cleanupMatrixWorld(runTag) const tenantVictim = await db.tenant.create({ data: { name: `victim-${runTag}` }, }) const tenantOther = await db.tenant.create({ data: { name: `other-${runTag}` }, }) const victimMarker = `victim-note-${runTag}` const owner = await db.user.create({ data: { email: `owner-${runTag}@test.local`, tenantId: tenantVictim.id }, }) await db.membership.create({ data: { userId: owner.id, tenantId: tenantVictim.id, role: 'owner', revokedAt: null }, }) const member = await db.user.create({ data: { email: `member-${runTag}@test.local`, tenantId: tenantVictim.id }, }) await db.membership.create({ data: { userId: member.id, tenantId: tenantVictim.id, role: 'member', revokedAt: null }, }) const outsider = await db.user.create({ data: { email: `outsider-${runTag}@test.local`, tenantId: tenantOther.id }, }) await db.membership.create({ data: { userId: outsider.id, tenantId: tenantOther.id, role: 'member', revokedAt: null }, }) const expired = await db.user.create({ data: { email: `expired-${runTag}@test.local`, tenantId: tenantVictim.id }, }) await db.membership.create({ data: { userId: expired.id, tenantId: tenantVictim.id, role: 'member', revokedAt: new Date(), }, }) const invoice = await db.invoice.create({ data: { tenantId: tenantVictim.id, createdById: owner.id, total: 1250, notes: victimMarker, lines: { create: [{ label: victimMarker, amount: 1250 }] }, }, }) const sessionFor = async (userId: string, tenantId: string) => createSessionToken({ userId, tenantId }) return { tenantVictim: { id: tenantVictim.id }, tenantOther: { id: tenantOther.id }, users: { owner: { id: owner.id, email: owner.email, tenantId: tenantVictim.id, token: await sessionFor(owner.id, tenantVictim.id) }, member: { id: member.id, email: member.email, tenantId: tenantVictim.id, token: await sessionFor(member.id, tenantVictim.id) }, outsider: { id: outsider.id, email: outsider.email, tenantId: tenantOther.id, token: await sessionFor(outsider.id, tenantOther.id) }, expired: { id: expired.id, email: expired.email, tenantId: tenantVictim.id, token: await sessionFor(expired.id, tenantVictim.id) }, }, invoiceId: invoice.id, victimMarker, } } export async function cleanupMatrixWorld(tag: string = runTag): Promise<void> { await db.invoiceLine.deleteMany({ where: { label: { contains: tag } } }) await db.invoice.deleteMany({ where: { notes: { contains: tag } } }) await db.membership.deleteMany({ where: { user: { email: { contains: tag } } } }) await db.user.deleteMany({ where: { email: { contains: tag } } }) await db.tenant.deleteMany({ where: { name: { contains: tag } } }) }

Bu kodda üç ayrıntı matrisin doğruluğunu taşır.

Birincisi, runTag her koşuya özel üretilir, böylece paralel koşular birbirinin faturasını görmez.

İkincisi, expired kullanıcı bilinçli olarak canlı token alır ama üyeliği revokedAt ile kapatılır; test edilen şey token formatı değil, üyelik durumudur.

Üçüncüsü, victimMarker hem fatura notuna hem satır açıklamasına yazılır; koşucu hangi görünüm dönerse dönsün sızıntıyı yakalar.

API test istemcisi: token ve tenant başlığı#

Matris, ham fetch çağrılarıyla yazılmaz.

Her istek aynı iki başlığı taşımak zorundadır: kullanıcı tokenı ve tenant kimliği.

Bunlar her satırda elle yazılırsa bir süre sonra bir satır başlıksız kalır ve test yanlış pozitif üretir.

O yüzden fabrikanın üstüne ince bir istemci konur.

export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' export type ApiResponse = { status: number bodyText: string bodyJson: unknown } const baseUrl = process.env.TEST_BASE_URL ?? 'http://localhost:3000' export async function apiAs( world: MatrixWorld, role: MatrixRole, method: HttpMethod, path: string, body?: unknown, ): Promise<ApiResponse> { const user = world.users[role] const res = await fetch(`${baseUrl}${path}`, { method, headers: { 'content-type': 'application/json', authorization: `Bearer ${user.token}`, 'x-tenant-id': user.tenantId, }, body: body === undefined ? undefined : JSON.stringify(body), }) const bodyText = await res.text() let bodyJson: unknown = null try { bodyJson = bodyText ? JSON.parse(bodyText) : null } catch { bodyJson = null } return { status: res.status, bodyText, bodyJson } } export function invoicePaths(invoiceId: string) { return { read: `/api/invoices/${invoiceId}`, write: `/api/invoices/${invoiceId}`, pdf: `/api/invoices/${invoiceId}/pdf`, retry: `/api/invoices/${invoiceId}/retry`, preview: `/api/admin/invoices/preview?invoiceId=${invoiceId}`, remove: `/api/invoices/${invoiceId}`, } }

İstemci bilerek ince tutulur.

Token üretimi fabrikanın işidir, istemci sadece taşır.

x-tenant-id başlığı her istekte kullanıcının kendi tenant değerinden gelir; test, başlığı elle değiştiren senaryoları ayrı satırlarda yazar.

bodyText ham metin olarak saklanır çünkü sızıntı kontrolü JSON ayrıştırmasından önce yapılır; bazı yan yollar PDF ya da HTML döndürür.

Yolları üreten invoicePaths yardımcısı, koşucudaki her satırın aynı ID ile çalışmasını sağlar.

Matris testinin iskeleti#

const matrix = [ { user: 'owner', action: 'read', expect: 200 }, { user: 'member', action: 'read', expect: [403, 404] }, { user: 'outsider', action: 'read', expect: [403, 404] }, { user: 'expired', action: 'read', expect: [401, 403, 404] }, { user: 'member', action: 'write', expect: [403, 404] }, ] for (const row of matrix) { const res = await api(row.user, row.action, targetObjectId) expect(row.expect).toContain(res.status) if (res.status === 200) { expect(res.body).not.toContain(victimMarker) } }

Burada kritik nokta oracle seçimidir: yetkisiz istek 200 dönmemeli, ayrıca dönen gövdede kurban verisine ait ayırt edici bir iz (victimMarker) bulunmamalı. 403 ile 404 arasındaki farkı test dayatmaz; ikisi de kabul edilir çünkü dışarıdan aynı anlama gelir: erişim yok.

İskelet beş satırla başlar ama üretim koruması için yetmez.

Aşağıdaki tam tablo, iskeleti on satıra çıkarır ve yan yolları ana yol ile aynı ciddiyetle yazar.

On satırlık tam matris#

Tablo, rol ile işlem kesişimini tek bakışta gösterir.

Satır sayısının on olması keyfi değildir: dört rol ile beş işlem türü (read, write, admin-copy, export, soft-delete) matrisin alt sınırını verir.

#RolİşlemİstekBeklenen durum
1ownerreadGET /api/invoices/:id200
2ownerwritePUT /api/invoices/:id200
3memberreadGET /api/invoices/:id403, 404
4memberwritePUT /api/invoices/:id403, 404
5outsiderreadGET /api/invoices/:id403, 404
6outsiderexportGET /api/invoices/:id/pdf403, 404
7outsideradmin-copyGET /api/admin/invoices/preview?invoiceId=403, 404
8outsidersoft-deleteDELETE /api/invoices/:id403, 404
9expiredreadGET /api/invoices/:id401, 403, 404
10expiredwritePOST /api/invoices/:id/retry401, 403, 404

Satır 1 ve 2 pozitif kontrol satırlarıdır.

Bunlar olmazsa matris, her şeyi kapatan kırık bir yetki katmanını da geçirir.

Satır 3 ve 4 aynı tenant içindeki rol ayrımını test eder; member okuyabilir ama yazamaz kurgusu burada doğrulanır.

Satır 5 ile 8 dış tenantın dört farklı kapıdan girişini dener.

Export, admin önizleme ve soft-delete satırlarının ayrı yazılmasının sebebi, bu yolların arkada aynı requireInvoiceAccess yardımcısını çağırmasının garanti olmamasıdır.

Satır 9 ve 10 üyeliği düşmüş kullanıcıyı yakalar.

Expired için 401 de kabul edilir çünkü bazı oturum katmanları üyelik düşünce tokenı baştan saymaz.

Tabloya yeni işlem türü eklendiğinde satır eklemek yetmez; koşucudaki istek üretecine de aynı isimle dal eklenir.

Matris koşucu kodu#

Koşucu, tabloyu çalıştıran döngüdür.

Görevi üç adımdır: isteği doğru kimlikle at, durumu beklenen kümeyle karşılaştır, 200 dönerse gövdede sızıntı izi ara.

import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { cleanupMatrixWorld, seedMatrixWorld, type MatrixWorld } from './matrix-factory' import { apiAs, invoicePaths, type HttpMethod, type MatrixRole } from './matrix-client' type MatrixRow = { name: string role: MatrixRole method: HttpMethod path: (paths: ReturnType<typeof invoicePaths>) => string body?: unknown expect: number[] } function buildRows(invoiceId: string): MatrixRow[] { const paths = invoicePaths(invoiceId) return [ { name: 'owner read', role: 'owner', method: 'GET', path: () => paths.read, expect: [200] }, { name: 'owner write', role: 'owner', method: 'PUT', path: () => paths.write, body: { notes: 'owner edit' }, expect: [200] }, { name: 'member read denied', role: 'member', method: 'GET', path: () => paths.read, expect: [403, 404] }, { name: 'member write denied', role: 'member', method: 'PUT', path: () => paths.write, body: { notes: 'x' }, expect: [403, 404] }, { name: 'outsider read denied', role: 'outsider', method: 'GET', path: () => paths.read, expect: [403, 404] }, { name: 'outsider export denied', role: 'outsider', method: 'GET', path: () => paths.pdf, expect: [403, 404] }, { name: 'outsider admin preview denied', role: 'outsider', method: 'GET', path: () => paths.preview, expect: [403, 404] }, { name: 'outsider delete denied', role: 'outsider', method: 'DELETE', path: () => paths.remove, expect: [403, 404] }, { name: 'expired read denied', role: 'expired', method: 'GET', path: () => paths.read, expect: [401, 403, 404] }, { name: 'expired retry denied', role: 'expired', method: 'POST', path: () => paths.retry, body: {}, expect: [401, 403, 404] }, ] } describe('invoice BOLA matrix', () => { let world: MatrixWorld beforeAll(async () => { world = await seedMatrixWorld() }) afterAll(async () => { await cleanupMatrixWorld() }) for (const row of buildRows(':id')) { it(row.name, async () => { const paths = invoicePaths(world.invoiceId) const res = await apiAs(world, row.role, row.method, row.path(paths), row.body) expect(row.expect).toContain(res.status) if (res.status === 200) { expect(res.bodyText).not.toContain(world.victimMarker) } }) } })

Koşucudaki döngü değişkeni test başlığına taşınır, böylece CI çıktısında hangi satırın kırıldığı doğrudan okunur.

Durum karşılaştırması toContain ile yapılır; tek kod dayatılmaz.

200 dönen her satırda gövde taraması çalışır, beklenti 200 olan satırlarda bile iz kontrolü vardır.

Bu, owner satırının yanlışlıkla başka tenant verisini döndürmesini de yakalar.

beforeAll ve afterAll ikilisi fabrikanın kurulum ve temizliğini teste bağlar.

Oracle derinliği: 403 / 404 ayrımı, iz yerleşimi, zamanlama neden yok#

Oracle, testin "geçti" kararını veren kuraldır.

Burada iki katmanlı oracle kullanılır: durum kodu kümesi ve gövde izi.

403 ile 404 ikisi de kabul edilir.

Sebep, ürünün ID gizleme tercihidir.

Bazı ekipler yetkisiz objede 403, bazıları 404 döner; ikisi de dışarıdan "erişim yok" demektir.

Test tek birini dayatırsa ekip numaralandırma koruması için 404'e geçtiğinde matris kırmızıya döner ve kimse hatayı ciddiye almaz.

401 yalnızca expired satırlarında kabul edilir.

Oturum katmanı düşmüş üyelikte jetonu geçersiz sayabilir, bu durumda 401 doğru cevaptır.

Aktif üyelerde 401 kabul edilmez; çünkü geçerli jetonla 401 dönmesi oturum sorunu demektir, yetki sorunu değil.

victimMarker yerleşimi ikinci kritik karardır.

İşaret, her görünümde dönen bir alana konur.

Yukarıdaki fabrikada hem notes hem satır label alanına yazılmasının sebebi budur.

Liste görünümü notu kısaltabilir, PDF satırları ayrı çekebilir; işaret iki yerde olunca tek görünüm farkıyla kaçmaz.

İşaret her koşuda tekil üretilir, sabit bir metin kullanılmaz.

Sabit metin paralel koşularda ve tekrar koşularında başka testin verisiyle çakışır.

Zamanlama oracle olarak kullanılmaz.

Yanıt süresine bakarak "kayıt var ama yetkisiz" çıkarımı CI ortamında güven vermez.

Yük altındaki koşucuda süreler oynar, test kararsız hale gelir.

Kararsız testin sonu, ekibin testi sessize almasıdır.

Gerekçe açık: durum kodu ve gövde izi her koşuda aynı cevabı verir, süre vermez.

Elle doğrulama gereken satırlar için Burp lab rehberi içindeki istek tekrarı düzeni, matrisin otomatik bulgusunu teyit etmekte kullanılır.

CI entegrasyonu: kapsam kaydı ve zorunlu koşu#

Matris CI'da koşmuyorsa dokümandan farkı kalmaz.

İki parça gerekir: kayıt deseni ve iş akışı.

Kayıt deseni, her endpointin matrise girdiğini derleme anında denetler.

type FixtureRef = { invoiceId: string; victimMarker: string } type CoveredRoute = { route: string run: (fixture: FixtureRef) => Promise<void> } const coveredRoutes: CoveredRoute[] = [] export function coverEndpoint(route: string, run: (fixture: FixtureRef) => Promise<void>) { coveredRoutes.push({ route, run }) } export function coveredRouteNames(): string[] { return coveredRoutes.map((r) => r.route) } coverEndpoint('GET /api/invoices/:id', async () => {}) coverEndpoint('GET /api/invoices/:id/pdf', async () => {}) coverEndpoint('POST /api/invoices/:id/retry', async () => {}) coverEndpoint('GET /api/admin/invoices/preview', async () => {}) coverEndpoint('DELETE /api/invoices/:id', async () => {})

Kayıt tek başına yetmez; route listesiyle kayıt listesini karşılaştıran bir test yazılır.

import { expect, it } from 'vitest' import { appRoutes } from './app-routes' import { coveredRouteNames } from './matrix-registry' it('every invoice route has matrix coverage', () => { const missing = appRoutes.filter((r) => !coveredRouteNames().includes(r)) expect(missing).toEqual([]) })

Yeni endpoint ekleyen kişi kayıt satırını eklemezse bu test kırmızıya döner.

Böylece "PDF yolunu unutmuşuz" durumu PR aşamasında yakalanır.

İş akışı dosyası matris testini her PR'da çalıştırır:

name: bola-matrix on: pull_request: push: branches: [main] jobs: matrix: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '22' cache: 'npm' - run: npm ci - run: npm run db:migrate:test - run: npm run test:matrix -- --run

İş akışında test veritabanı migrasyonu ayrı adımdır.

Matris asla geliştirme veritabanına karşı koşmaz; bağlantı dizesi test veritabanını gösterir.

test:matrix komutu yalnızca matris dosyalarını çalıştırır, böylece sonuç hızlı okunur.

Kapsam testi ile matris testi aynı işte koşar; biri kapsamı, diğeri davranışı denetler.

Beş yaygın hata#

Birinci hata: yalnızca mutlu yolu test etmek.

Owner 200 alıyor diye matrisin geçtiği sanılır.

Oysa BOLA reddetme mantığındadır; inkar satırları olmadan matris boştur.

İkinci hata: gövdesi boş 200 yanıtını geçiş saymak.

Bazı yetki katmanları isteği geçirir ama gövdeyi boşaltır.

Durum kodu 200 görünür, test yeşil yanar, oysa karar yanlıştır.

Koşucudaki iz kontrolü ve pozitif satırların gövde doğrulaması bu yüzden vardır.

Üçüncü hata: tek tenant ile test etmek.

Tek tenant varsa outsider satırı yazılamaz, test(exportsuz kalır.

İki tenant fabrikanın zorunlu parçasıdır, opsiyonel değil.

Dördüncü hata: kapsamı dar jetonla test etmek.

Jeton üretimi ile istek başlığı farklı tenant taşırsa red kararı yanlış sebeple gelir.

Fabrika jetonu ve başlığı aynı kaynaktan üretir, böylece red kararının sebebi üyelik olur.

Beşinci hata: matrisi dokümanda bırakmak.

Matris wiki sayfasında yaşarsa yeni endpoint listeye girmez.

Kayıt deseni ve kapsam testi, listeyi kodun parçası yapar.

Sınırlar#

Bu matris her şeyi yakalamaz.

Hız sınırları testin dışındadır.

Dakikadaki istek eşiği aşılınca API 429 döner, matris bunu red kararı sanabilir.

Bu yüzden matris koşusu hız sınırından muaf test anahtarıyla ya da düşük paralellikle çalışır.

Paralel koşu izolasyonu ikinci sınırdır.

Aynı veritabanına karşı iki matris koşusu aynı anda çalışırsa etiket çakışması olur.

runTag bunu azaltır ama tam garanti için her CI işi kendi veritabanı şemasını kullanır.

Üçüncü sınır veri ortamıdır.

Matris yalnızca test veritabanında koşar, üretim verisine karşı asla çalıştırılmaz.

Üretimde koşan üyelik iptal senaryosu gerçek kullanıcı jetonuna dokunur, geri dönüşü zor kirlenme yaratır.

Dördüncü sınır iş mantığı yetkisidir.

Matris obje sahipliğini dener, tutar onay zinciri gibi akış kurallarını denemez.

"Üye kendi faturasını okuyabilir ama onaylayamaz" gibi kurallar ayrı test ister.

Kısa sonuç#

BOLA'yı kapatmak tek endpoint düzeltmek değildir; matrisin her yeni endpoint'e otomatik uygulanmasıdır. Dört kullanıcı, bir beklenti tablosu, sızıntı izi kontrolü. Bu üçü CI'da çalıştığı sürece yetki kararı kişilere değil, koda emanet edilmiş olur.

Kalan iş küçüktür: yeni route açıldığında kayıt satırını ekle, koşucunun on satırını o route için üret, CI çıktısında kırmızı satır varsa yetki yardımcısını ve session.tenantId filtresini denetle.

---
Bu yazıyı paylaş:
TwitterLinkedInFacebook

Ne düşünüyorsun?

Tepki bırakarak geri bildirim ver

İlgili Yazılar