API6 Sensitive Business Flows: Hassas Akışları Belirlemek, Idempotency-Key ve Akış Bazlı Limitler
API6'ya karşı önce hangi iş akışlarının otomasyonla zarar göreceğini belirler, sonra her akışa kendi kuralını koyarsın. Idempotency-Key taslağına göre tekrarlanan POST'ları tek kez işleyen bir filter, eşzamanlı tekrar testi, kupon ve sınırlı ürün kurallarını veritabanı constraint'leri ve koşullu UPDATE ile uygulamak, RFC 9470 biçiminde step-up ve müşteri başına akış limiti.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ve EF (Entity Framework) Core 10.0.14 ile derlenip denenmiştir; Idempotency-Key header'ı için IETF (Internet Engineering Task Force) taslağının 22 Ekim 2026 tarihli -08 sürümü esas alınmıştır.
Önce Tahmin Et
North mağazası imzalı bir ilk baskıdan beş adet satacak. Duyuru yapıldıktan iki dakika sonra stok bitiyor ve beş siparişin dördü aynı müşteriden geliyor; o müşterinin hesabı da dün açılmış. Aynı hafta mobil uygulamadan şikâyet geliyor: zayıf bağlantıda "Sipariş ver"e basan kullanıcılar iki, bazen üç sipariş oluşturmuş. İki olayda da API (Application Programming Interface) teknik olarak doğru çalıştı. Hiçbir istek yetkisiz değildi, hiçbir endpoint açık değildi.
API6'nın (OWASP API Security Top 10 2023'teki adıyla Unrestricted Access to Sensitive Business Flows) konusu tam bu: tek tek bakınca meşru olan isteklerin, otomasyonla ya da tekrarla iş açısından zarara dönüşmesi. İş mantığı açıklarının ve race condition'ların temeli Web Güvenliği (OWASP Top 10) serisinin A06 Insecure Design yazısında. Burada API tarafına bakıyoruz: hangi akışların hassas olduğunu belirlemek, tekrarlanan POST'ları Idempotency-Key ile bir kez işlemek ve akışa özel limitler koymak.
Hangi Akış Hassas?
OWASP'ın (Open Worldwide Application Security Project) API6 sayfası, sorunun teknik değil iş tarafında başladığını söylüyor: önce hangi akışın kötüye kullanıldığında işe zarar vereceğini belirlemen gerekiyor. Bookstore için oturup bir liste yaptım. Her satırda akış, otomasyonla kötüye kullanıldığında iş etkisi ve bu yazıda kurduğumuz savunma var:
| Akış | Kötüye kullanılırsa | Savunma |
|---|---|---|
| Sınırlı stoklu ürün | Bot'lar stoğu toplar, gerçek müşteri alamaz | Müşteri başına günlük adet limiti, atomik stok düşümü |
| Kupon | Aynı kupon defalarca kullanılır, marj erir | Müşteri ve kupon için veritabanında unique index |
| Sipariş oluşturma | Çift sipariş, çift tahsilat, stok kayması | Idempotency-Key, müşteri başına akış limiti |
| Yüksek tutarlı sipariş | Ele geçirilmiş oturumla büyük harcama | Step-up: son 5 dakikada kimlik doğrulama |
| Kayıt ve davet | Sahte hesaplarla kampanya ve kupon toplama | E-posta doğrulama, cihaz ve IP (Internet Protocol) sinyalleri, gecikmeli aktivasyon |
| Yorum | Toplu sahte yorum, rakibe karalama | Yalnız satın alınmış ürüne yorum, yayın öncesi inceleme |
Son iki satırı lab'da kodlamadım; tabloya koymamın sebebi, hassas akış listesinin teknik bir envanter değil bir tehdit modeli parçası olması. Bu listeyi ürün sahibiyle birlikte yazmak gerekiyor, çünkü "kupon başına kaç kullanım normal?" sorusunun cevabı kodda değil.
Idempotency-Key: Aynı İsteği Bir Kez İşlemek
Mobil uygulamadaki çift sipariş sorununun klasik çözümü idempotency key. İstemci her mantıksal işlem için benzersiz bir anahtar üretir ve isteği tekrar ettiğinde aynı anahtarı gönderir; sunucu aynı anahtarla gelen ikinci isteği yeniden işlemez, ilk yanıtı döndürür. IETF HTTP (Hypertext Transfer Protocol) API çalışma grubunun Idempotency-Key taslağı bunu standartlaştırıyor. Taslak henüz RFC (Request for Comments) olmadı; benim baktığım en son sürüm -08. Taslaktan aldığım kurallar şunlar: header değeri bir structured field string, yani tırnak içinde yazılıyor; anahtar zorunluysa ve yoksa 400; aynı anahtar farklı bir payload ile gelirse 422; ilk istek hâlâ işleniyorsa 409. Payload'ı karşılaştırmak için bir parmak izi (fingerprint) kullanmak ve anahtarlara bir son kullanma süresi vermek taslakta isteğe bağlı.
public const string Header = "Idempotency-Key";
public static readonly TimeSpan Retention = TimeSpan.FromHours(24);
// Taslağa göre değer bir structured field string: tırnak içinde, en fazla 255 karakter
public static bool TryParse(string? raw, out string key)
{
key = "";
if (raw is not { Length: > 2 and <= 257 } || raw[0] != '"' || raw[^1] != '"')
{
return false;
}
key = raw[1..^1];
return key.All(c => c is >= ' ' and <= '~' and not '"' and not '\\');
}
public static string Fingerprint<T>(T request) =>
Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(request)));
255 karakter sınırı taslaktan değil, benim kararım: sınırsız uzunlukta bir anahtar API4 (Unrestricted Resource Consumption) yazısındaki kaynak tüketimi sorununu buraya taşırdı. Parmak izini ham body'den değil, model binding'in ürettiği CreateOrderRequest'ten hesaplıyorum. Böylece JSON (JavaScript Object Notation) içindeki boşluk ya da alan sırası farkı "farklı istek" sayılmıyor, ama quantity'nin 1'den 2'ye çıkması sayılıyor.
Filter: Kayıt, Parmak İzi ve Yanıtın Tekrarı
Anahtarlar bir tabloda tutuluyor. Tablonun (TenantId, Principal, Key) üzerinde unique index'i var: anahtar tenant'a ve kullanıcıya bağlı, yani Alan'ın anahtarı Grace'in aynı değerdeki anahtarıyla çakışmıyor ve bir kullanıcı başkasının yanıtını tekrar oynatamıyor. Mantık bir endpoint filter'da:
IdempotencyRecord? existing = await store.TryBeginAsync(principal, key, fingerprint, ct);
if (existing is not null)
{
if (existing.Fingerprint != fingerprint)
{
return Results.Problem(statusCode: StatusCodes.Status422UnprocessableEntity,
title: "This Idempotency-Key was used with a different request.");
}
if (existing.StatusCode is null)
{
return Results.Problem(statusCode: StatusCodes.Status409Conflict,
title: "A request with this Idempotency-Key is still being processed.");
}
// Aynı istek tekrar geldi: işlemi yeniden yapma, ilk yanıtı aynen döndür
http.Response.Headers["Idempotent-Replayed"] = "true";
if (existing.Location is not null)
{
http.Response.Headers.Location = existing.Location;
}
return Results.Text(existing.ResponseBody ?? "", "application/json", statusCode: existing.StatusCode);
}
object? result;
try
{
result = await next(context);
}
catch
{
await store.AbandonAsync(principal, key, CancellationToken.None);
throw;
}
// Yalnız başarılı sonuç saklanır: 401 step-up ya da "stok yok" gibi yanıtlar aynı key ile yeniden denenebilmeli
if (result is IStatusCodeHttpResult { StatusCode: >= 200 and < 300 } status)
{
string? body = result is IValueHttpResult { Value: { } value } ? JsonSerializer.Serialize(value,
JsonSerializerOptions.Web) : null;
string? location = result is Created<OrderSummary> created ? created.Location : null;
await store.CompleteAsync(principal, key, status.StatusCode!.Value, body, location, ct);
}
else
{
await store.AbandonAsync(principal, key, ct);
}
return result;
TryBeginAsync önce anahtarı arıyor, yoksa "işleniyor" durumunda bir kayıt ekliyor. Asıl garanti unique index: iki istek aynı anda "yok" görse bile ikincinin INSERT'i başarısız oluyor ve o istek mevcut kaydı okuyor. Kayıt endpoint'in kullandığından ayrı, kısa ömürlü bir context ile yazılıyor; böylece endpoint hata verirse kaydı silebiliyoruz ve istemci aynı anahtarla tekrar deneyebiliyor.
Hangi sonuçların saklanacağı bilinçli bir karar. İlk sürümde 500'den küçük her yanıtı saklıyordum ve curl denemesinde bir sorun gördüm: step-up'ın döndürdüğü 401 de saklanıyordu. Kullanıcı tekrar giriş yapıp aynı anahtarla denediğinde sunucu ona yine 401'i tekrar oynatıyordu. "Stok yok" 409'u da aynı durumdaydı. Şimdi yalnız 2xx yanıtlar saklanıyor; diğerleri kaydı siliyor. Idempotent-Replayed header'ı taslakta yok, Stripe'ın API'sinde gördüğüm bir uygulama; istemcinin ve log'ların tekrarı ayırt etmesine yarıyor. Curl ile:
$ curl -s -X POST http://localhost:19517/orders -H "Authorization: Bearer $ALAN" -H "Content-Type: application/json" -d "$BODY"
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"A valid Idempotency-Key header is required.","status":400,"traceId":"00-1dc4e47632135975bb59da5266598f31-ff66d4c81be9b574-00"}
$ curl -i -X POST http://localhost:19517/orders -H "Authorization: Bearer $ALAN" -H "Content-Type: application/json" -H 'Idempotency-Key: "6f1c2a52-6a3e-4c7e-9a43-1b2f6a0d9c11"' -d "$BODY"
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Sun, 08 Nov 2026 06:10:51 GMT
Server: Kestrel
Location: /orders/01a1b9c7-f77f-7a6d-a143-ca474cfff09d
Transfer-Encoding: chunked
{"id":"01a1b9c7-f77f-7a6d-a143-ca474cfff09d","status":"Pending","total":54.9,"createdAt":"2026-11-08T06:10:52.408381Z"}
$ curl -i -X POST http://localhost:19517/orders -H "Authorization: Bearer $ALAN" -H "Content-Type: application/json" -H 'Idempotency-Key: "6f1c2a52-6a3e-4c7e-9a43-1b2f6a0d9c11"' -d "$BODY"
HTTP/1.1 201 Created
Content-Length: 119
Content-Type: application/json
Date: Sun, 08 Nov 2026 06:10:51 GMT
Server: Kestrel
Location: /orders/01a1b9c7-f77f-7a6d-a143-ca474cfff09d
Idempotent-Replayed: true
{"id":"01a1b9c7-f77f-7a6d-a143-ca474cfff09d","status":"Pending","total":54.9,"createdAt":"2026-11-08T06:10:52.408381Z"}
İkinci yanıt ilkinin aynısı: aynı sipariş id'si, aynı Location. Aynı anahtarla quantity: 2 gönderdiğimde ise 422 geldi: "This Idempotency-Key was used with a different request.".
Eşzamanlı Tekrarlar
Asıl zor senaryo sıralı tekrar değil, eşzamanlı tekrar: zayıf bağlantıda istemcinin retry mantığı, ilk istek daha bitmeden ikincisini gönderir. Bunu bir testle zorluyoruz: aynı anahtarla on istek aynı anda.
[Fact]
public async Task Ten_concurrent_requests_with_one_key_create_exactly_one_order()
{
HttpClient grace = factory.CreateClientFor("grace");
CreateOrderRequest body = new(SeedData.NorthBook, 1, "1 Test Street");
string key = Guid.NewGuid().ToString();
int before = await CountOrdersAsync("grace");
HttpResponseMessage[] responses = await Task.WhenAll(
Enumerable.Range(0, 10).Select(_ => grace.SendAsync(Order(body, key), Ct)));
TestContext.Current.TestOutputHelper?.WriteLine(string.Join(" ", responses.Select(r => (int)r.StatusCode)));
Assert.All(responses, r => Assert.Contains((int)r.StatusCode, new[] { 201, 409 }));
Assert.Equal(before + 1, await CountOrdersAsync("grace"));
}
Test çıktısındaki durum kodları her koşuda biraz farklı. İki koşudan:
409 409 409 409 409 409 409 201 409 409
201 409 201 409 409 409 409 409 201 409
İkinci koşuda üç tane 201 var ama veritabanında yine tek sipariş oluştu: ilk 201 asıl işlem, diğer ikisi ilk istek tamamlandıktan sonra gelip saklanan yanıtı tekrar oynatan istekler. 409 alanlar ise ilk istek hâlâ işlenirken gelenler; taslağa göre istemci bunları değiştirmeden tekrar deneyebilir. Testin asıl iddiası durum kodlarının dağılımı değil, sipariş sayısının bir artması.
Akış Bazlı Kurallar: Kupon, Sınırlı Ürün ve Stok
Idempotency aynı isteğin tekrarını çözüyor, ama farklı anahtarlarla gelen farklı istekleri çözmüyor. Bot zaten her seferinde yeni bir anahtar üretir. Bunun için akışın kendi kuralları gerekiyor ve bu kuralların en güvenilir yeri veritabanı.
Kupon kuralı "her müşteri her kuponu bir kez" ve bunu uygulama kodundaki bir if yerine unique index ile koyuyorum. if (!await db.CouponRedemptions.AnyAsync(...)) kontrolü iki eşzamanlı istekte ikisinin de "kullanılmamış" görmesine izin verir; unique index vermez:
// Akış kuralları veritabanında: aynı müşteri aynı kuponu ikinci kez kullanamaz
modelBuilder.Entity<CouponRedemption>().HasIndex(c => new { c.TenantId, c.CustomerId, c.Code }).IsUnique();
Sınırlı ürün için iki kural var: müşteri başına son 24 saatte en fazla bir adet ve stoğun hiçbir zaman eksiye düşmemesi. İkincisi koşullu tek bir UPDATE ile yapılıyor; iki istek aynı son ürünü göremez, çünkü Stock >= quantity kontrolü ve düşüm aynı SQL (Structured Query Language) ifadesinde:
DateTime now = time.GetUtcNow().UtcDateTime;
if (book.MaxPerCustomerPerDay is int max)
{
// Sınırlı ürün: müşteri başına son 24 saatte en fazla "max" adet
DateTime since = now.AddDays(-1);
int bought = await db.Orders
.Where(o => o.CustomerId == user.Id && o.CreatedAt >= since && o.Status != OrderStatus.Cancelled)
.SelectMany(o => o.Items)
.Where(i => i.BookId == book.Id)
.SumAsync(i => i.Quantity, ct);
if (bought + request.Quantity > max)
{
return Results.Problem(statusCode: StatusCodes.Status409Conflict, title: "Purchase limit reached for this item.");
}
}
await using IDbContextTransaction transaction = await db.Database.BeginTransactionAsync(ct);
// Stok düşümü tek bir koşullu UPDATE: iki istek aynı son ürünü alamaz
int reserved = await db.Books
.Where(b => b.Id == book.Id && b.Stock >= request.Quantity)
.ExecuteUpdateAsync(s => s.SetProperty(b => b.Stock, b => b.Stock - request.Quantity), ct);
if (reserved == 0)
{
return Results.Problem(statusCode: StatusCodes.Status409Conflict, title: "Out of stock.");
}
Stok düşümü, sipariş ve kupon kaydı aynı transaction'da. Kupon unique index'e takılırsa SaveChangesAsync exception fırlatıyor, transaction commit edilmeden dispose ediliyor ve stok düşümü de geri alınıyor. Testler bunu kontrol ediyor: kuponu ikinci kez kullanmaya çalışan ve 409 alan siparişten sonra kitabın stoğu ilk siparişten sonraki değerde kalıyor; limit nedeniyle reddedilen imzalı kitap siparişinden sonra da stok 4, 3 değil. Müşteri başına limit kontrolü ise transaction dışında bir okuma ve teorik olarak iki eşzamanlı istekte aşılabilir; bunu da kesin istiyorsan limiti bir sayaç tablosunda koşullu UPDATE ile tutman gerekir. Lab'da bu riski, aşağıdaki müşteri başına akış limitiyle birlikte kabul edilebilir buldum.
Step-Up ve Akışa Özel Rate Limit
Yüksek tutarlı siparişlerde başka bir risk var: ele geçirilmiş bir oturum. Token geçerli, kullanıcı doğru, ama oturum saatlerdir açık ve belki de başka birinin elinde. Burada step-up authentication kullanıyoruz: 500'ün üstündeki siparişler için kullanıcının son beş dakika içinde kimlik doğrulamış olması gerekiyor. Token'daki auth_time claim'i bu zamanı taşıyor. Yanıt biçimi RFC 9470'ten (OAuth 2.0 Step Up Authentication Challenge Protocol) geliyor: insufficient_user_authentication hatası ve max_age parametresi, istemcinin kullanıcıyı yeniden giriş ekranına göndermesi için yeterli bilgi.
public static IResult Challenge(HttpContext context)
{
context.Response.Headers.WWWAuthenticate =
$"Bearer error=\"insufficient_user_authentication\", error_description=\"Recent sign-in required\", max_age={(int)MaxAge.TotalSeconds}";
return Results.Problem(statusCode: StatusCodes.Status401Unauthorized, title: "Please sign in again to place this order.");
}
$ ALAN_OLD=$(dotnet run --project tools/Bookstore.Orders.TokenTool -- alan 900)
$ curl -i -X POST http://localhost:19517/orders -H "Authorization: Bearer $ALAN_OLD" -H "Content-Type: application/json" -H 'Idempotency-Key: "big-1"' -d '{"bookId":"0197f003-0000-7000-8000-000000000001","quantity":10,"shippingAddress":"1 Test Street"}'
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
Date: Sun, 08 Nov 2026 06:11:21 GMT
Server: Kestrel
Transfer-Encoding: chunked
WWW-Authenticate: Bearer error="insufficient_user_authentication", error_description="Recent sign-in required", max_age=300
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Please sign in again to place this order.","status":401,"traceId":"00-94389b24536f3788c020396ef4652809-7a57d2f97bb56a3b-00"}
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:19517/orders -H "Authorization: Bearer $ALAN" -H "Content-Type: application/json" -H 'Idempotency-Key: "big-1"' -d '{"bookId":"0197f003-0000-7000-8000-000000000001","quantity":10,"shippingAddress":"1 Test Street"}'
201
Taze token'la aynı anahtar 201 aldı; 401'in saklanmamasının sebebi bu. Lab'da token'ı TokenTool basıyor ve auth_time'ı istediğimiz kadar geriye alabiliyoruz; gerçek sistemde yeniden giriş akışı ve MFA (Multi-Factor Authentication) Authentication ve Authorization serisinin MFA yazısında.
Son katman, akışa özel bir rate limit. API4 yazısındaki limitler pahalı kaynakları koruyordu; bu limit ise bir iş akışını koruyor: bir müşteri on dakikada en fazla yirmi sipariş denemesi yapabilir, tekrarlar dahil. Tüm API için tek bir limit, siparişi en çok etkileyen bu akışı diğer yüzlerce okuma isteğinin arasında kaybederdi.
// Akış bazlı limit: müşteri başına 10 dakikada en fazla 20 sipariş denemesi (tekrarlar dahil)
options.AddPolicy(PlaceOrderPolicy, context => RateLimitPartition.GetFixedWindowLimiter(
$"order:{context.User.FindFirstValue(LabClaims.Subject) ?? "anonymous"}",
_ => new FixedWindowRateLimiterOptions { PermitLimit = 20, Window = TimeSpan.FromMinutes(10), QueueLimit = 0 }));
Yedi testlik SensitiveFlowTests class'ı bu kuralların hepsini kapsıyor: tekrar ve tekrar oynatma, değişen payload, eksik anahtar, eşzamanlılık, kupon, sınırlı ürün ve stok, step-up ve akış limiti. Toplam 131 test yeşil.
Üretimde Dikkat
Idempotency kayıtları büyür. 24 saatlik saklama süresi geçen kayıtları silen bir scheduled job gerekiyor; taslak da anahtarların süresi doluyorsa bu politikanın yayımlanmasını istiyor. Süre, istemcinin en uzun retry penceresinden uzun olmalı.
Otomasyonu tespit etmek için sinyaller tek başına karar vermez ama birlikte anlamlıdır: hesabın yaşı, aynı cihaz ya da IP bloğundan gelen çok sayıda hesap, insan için gerçekçi olmayan hız ve düzenlilik, alışveriş sepetini hiç görüntülemeden doğrudan ödeme endpoint'ine gelmek. Bu sinyalleri sipariş anında bir skora çevirip eşiği aşan siparişleri hemen reddetmek yerine "inceleme bekliyor" durumuna almak, yanlış pozitiflerin maliyetini düşürür. Gecikmeli işleme de aynı mantık: sınırlı ürün satışında siparişleri birkaç dakika toplayıp sonra dağıtmak, en hızlı bot'un kazanma avantajını azaltır.
CAPTCHA (Completely Automated Public Turing test to tell Computers and Humans Apart) bir seçenek ama ucuz değil. Görme ya da motor engeli olan kullanıcılar için erişilebilirlik sorunu yaratır, dönüşüm oranını düşürür ve API'yi doğrudan çağıran bir mobil istemcide çoğu zaman uygulanamaz. Üstelik CAPTCHA çözme hizmetleri piyasada ucuz. CAPTCHA'yı herkese değil, risk skoru yüksek akışlara ve isteklere göstermek daha dengeli bir kullanım.
Rate limiter'ın bellek içi olduğunu API4 yazısında konuşmuştuk; akış limitleri için de aynı uyarı geçerli. Çok instance'lı bir dağıtımda kupon ve stok kuralları veritabanında olduğu için güvenli kalır, akış limiti ise yaklaşık olur.
Sık Düşülen Tuzaklar
Birinci tuzak, bütün API için tek bir rate limit koyup akış bazlı riski görmemek. Dakikada 100 istek, ürün listelemek için cömert, sınırlı stoklu bir ürünü satın almak için ise sınırsızla aynı şey.
İkincisi, idempotency anahtarını yalnız anahtara göre aramak. Anahtar kullanıcıya ve tenant'a bağlı olmazsa, bir kullanıcının anahtarını tahmin eden ya da gören biri onun yanıtını tekrar oynatarak sipariş ayrıntılarını okuyabilir. Bu da BOLA'nın (Broken Object Level Authorization) idempotency tablosundaki hâli.
Üçüncüsü, her yanıtı saklamak. Hata yanıtlarını saklamak, kullanıcının sorunu düzelttikten sonra aynı anahtarla yapacağı denemeyi kalıcı olarak bozar. Hangi sonuçların "kesin" olduğunu bilinçli seç.
Dördüncüsü, iş kuralını uygulama kodundaki bir okuma ve ardından gelen bir yazma ile uygulamak. "Kupon kullanılmış mı?" kontrolü ile kaydın eklenmesi arasındaki boşluk, Web Güvenliği serisinde gördüğümüz race condition'ın ta kendisi. Kural veritabanında bir constraint ya da koşullu bir UPDATE olarak durmalı.
Küçük Bir Deneme
★☆☆ Lab'da aynı Idempotency-Key ile önce geçerli bir sipariş ver, sonra aynı anahtarla couponCode ekleyerek tekrar dene. Hangi durum kodunu aldın ve neden?
★★☆ Süresi dolan idempotency kayıtlarını silen bir BackgroundService yaz ve TimeProvider kullanarak süresi dolan bir kaydın silindiğini test et.
★★★ Müşteri başına günlük limit kontrolünü eşzamanlı isteklere dayanıklı hâle getir: (TenantId, CustomerId, BookId, Day) üzerinde unique index'li bir sayaç tablosu ve koşullu bir UPDATE. Aynı müşteriden aynı anda gelen beş isteğin yalnız birinin başarılı olduğunu bir testle göster.
Bir sonraki yazıda dış dünyaya açılıyoruz: API7 (Server Side Request Forgery) ve API10 (Unsafe Consumption of APIs). Kullanıcının verdiği webhook URL'ini (Uniform Resource Locator) bağlantı anında doğrulamak, gelen webhook'ların imzasını kontrol etmek ve üçüncü parti API yanıtlarına kullanıcı girdisi kadar şüpheyle bakmak.