Mert Özen Her Satırda Daha İleri
Güvenlik Web Güvenliği (OWASP Top 10) — #11

OWASP A06: Insecure Design — İş Mantığı Açıkları, Race Condition ve Rate Limiting

Mert Özen 6 Kas 2026 11 dk 4 görüntülenme
OWASP A06: Insecure Design — İş Mantığı Açıkları, Race Condition ve Rate Limiting

Satır satır doğru çalışan bir sipariş endpoint'i negatif adetle para iade ediyor, tek kullanımlık kuponu iki kez kabul ediyor ve son kopyayı iki kişiye satıyor. Tasarım hatasını uygulama hatasından ayırıyor, STRIDE ve abuse case ile eksik gereksinimleri buluyor, koşullu UPDATE, transaction, .NET 10 doğrulaması ve rate limiting ile düzeltiyoruz.

Sürüm notu: Bu yazı OWASP Top 10:2025'e göre yazılmıştır; kodlar ASP.NET Core 10 (.NET 10) ile denenmiştir.

Önce Tahmin Et

Bookstore API'nin sipariş endpoint'i kitap id'sini, adedi ve isteğe bağlı bir kupon kodunu alıyor. Kodda SQL injection yok, bütün sorgular parametreli; tip hatası yok, derleyici tek bir uyarı bile vermiyor. Şimdi üç soru: "quantity": -3 gönderirsen toplam tutar ne çıkar? Tek kullanımlık WELCOME10 kuponunu ikinci kez denersen ne olur? Stokta son bir kopya kalmış kitaba iki kullanıcı aynı anda sipariş verirse kaç sipariş oluşur?

Üçünün de cevabı rahatsız edici ve hiçbiri "kodda bir satır yanlış yazılmış" türünden değil. Bu yazının konusu tam olarak bu: satır satır doğru çalışan ama baştan eksik düşünülmüş bir tasarım.

Tasarım Hatası mı, Uygulama Hatası mı?

A06:2025 Insecure Design (güvensiz tasarım), 2021'deki dördüncü sıradan altıncı sıraya indi ama altında 39 CWE ile hâlâ listenin en geniş kategorilerinden biri. Kapsadığı CWE'lere bakınca neden "tasarım" dendiği anlaşılıyor: parolanın düz metin saklanması (CWE-256), yanlış yetki yönetimi (CWE-269), kısıtsız dosya yükleme (CWE-434), güven sınırının ihlali (CWE-501). Bunların ortak noktası, hiçbirinin tek bir hatalı satırla açıklanamaması.

Ayrımı şöyle düşünmek işime yarıyor. Uygulama hatasında tasarım doğrudur, kod onu yanlış hayata geçirir: parametre kullanılması gereken yerde string birleştirilmiştir, bir kontrol unutulmuştur. Tasarım hatasında ise kod tasarımı birebir uygular; eksik olan, tasarımın kendisindeki gereksinimdir. "Kupon tek kullanımlıktır" kuralı veri modeline hiç girmemişse en temiz kod da kuponu ikinci kez kabul eder. Kusursuz bir uygulama eksik bir tasarımı kurtarmaz.

Bu yüzden A06 bir kod inceleme kontrol listesiyle yakalanmaz. Kod yazılmadan önce sorulan "bu özelliği kim, nasıl kötüye kullanır?" sorusuyla yakalanır. Yazıdaki bütün denemeler kendi makinendeki lab uygulamasına karşı yapılıyor; bu teknikleri yalnız sahibi olduğun ya da yazılı izin aldığın sistemlerde dene.

Zafiyetli Sipariş Endpoint'i

Lab'da üç tablo var: books (id, title, price, stock), coupons (code, percent) ve orders. Kimlik doğrulamayı bu yazıda konunun dışında tutmak için kullanıcıyı X-User-Id header'ından okuyoruz; gerçek uygulamada bu bilgi cookie'den ya da token'dan gelir, onu A07 yazılarında kuracağız. Endpoint şu:

app.MapPost("/orders", async (OrderRequest req, HttpContext http) =>
{
    int userId = int.Parse(http.Request.Headers["X-User-Id"]!);
    using SqliteConnection conn = Db.Open();

    Book? book = await conn.QuerySingleOrDefaultAsync<Book>(
        "SELECT id, title, price, stock FROM books WHERE id = @id", new { id = req.BookId });
    if (book is null) return Results.NotFound();
    if (book.Stock < req.Quantity) return Results.Conflict(new { error = "Out of stock" });

    decimal total = book.Price * req.Quantity;
    if (req.CouponCode is not null)
    {
        int? percent = await conn.QuerySingleOrDefaultAsync<int?>(
            "SELECT percent FROM coupons WHERE code = @code", new { code = req.CouponCode });
        if (percent is not null) total -= total * percent.Value / 100;
    }

    await Task.Delay(200); // ödeme sağlayıcısını çağırıyormuş gibi

    await conn.ExecuteAsync(
        "UPDATE books SET stock = stock - @q WHERE id = @id", new { q = req.Quantity, id = req.BookId });
    int orderId = await conn.ExecuteScalarAsync<int>(
        "INSERT INTO orders (user_id, book_id, quantity, total) VALUES (@userId, @bookId, @q, @total) RETURNING id",
        new { userId, bookId = req.BookId, q = req.Quantity, total });
    return Results.Created($"/orders/{orderId}", new { orderId, total });
});
...
public sealed record OrderRequest(int BookId, int Quantity, string? CouponCode);

Sorgular Dapper ile ve parametreli; Dapper serisinde anlattığım her şeye uygun. Fiyatı istemciden almıyor, veritabanından okuyor; bu da iyi. Task.Delay(200) satırı gerçek hayattaki bir ödeme servisi çağrısının yerini tutuyor. Uygulamayı dotnet run --urls http://localhost:5141 ile başlatıp deneyelim.

Üç Saldırı, Üç Eksik Gereksinim

İlki negatif adet. Clean Code'un fiyatı 45.90 ve stokta 10 adet var:

$ curl -i -X POST http://localhost:5141/orders -H "Content-Type: application/json" -H "X-User-Id: 7" -d '{"bookId":1,"quantity":-3}'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:02:00 GMT
Server: Kestrel
Location: /orders/1
Transfer-Encoding: chunked

{"orderId":1,"total":-137.7}

Toplam -137.7 oldu ve stok kontrolü de geçti, çünkü 10 < -3 yanlıştır. stock - (-3) stoku 13'e çıkardı. Ödeme tarafı bu tutarı iade olarak yorumlarsa sistem para dağıtan bir makineye döner. İkincisi kuponu tekrar kullanmak:

$ curl -X POST http://localhost:5141/orders -H "Content-Type: application/json" -H "X-User-Id: 7" -d '{"bookId":1,"quantity":1,"couponCode":"WELCOME10"}'
{"orderId":2,"total":41.31}
$ curl -X POST http://localhost:5141/orders -H "Content-Type: application/json" -H "X-User-Id: 7" -d '{"bookId":1,"quantity":1,"couponCode":"WELCOME10"}'
{"orderId":3,"total":41.31}

Kod kuponun var olup olmadığına bakıyor, kullanılıp kullanılmadığına bakamıyor; çünkü tabloda bu bilgiyi tutacak bir kolon yok. Üçüncüsü race condition. The Pragmatic Programmer'dan stokta tek kopya var ve iki kullanıcı aynı anda sipariş veriyor:

# İki isteği aynı anda gönder ve ikisini de bekle
$ curl -s -X POST http://localhost:5141/orders -H "Content-Type: application/json" -H "X-User-Id: 7" -d '{"bookId":2,"quantity":1}' & \
  curl -s -X POST http://localhost:5141/orders -H "Content-Type: application/json" -H "X-User-Id: 8" -d '{"bookId":2,"quantity":1}' & wait
{"orderId":4,"total":52}{"orderId":5,"total":52}
$ curl http://localhost:5141/books/2
{"id":2,"title":"The Pragmatic Programmer","price":52,"stock":-1}

İki istek de stoğu 1 olarak okudu, ikisi de kontrolü geçti, ikisi de stoğu bir azalttı. Okuma ile yazma arasındaki 200 milisaniye bu pencereyi görünür kılıyor ama pencere her zaman var; gecikme yalnızca onu büyütüyor. Bu türe "time-of-check to time-of-use" denir: kontrol ettiğin an ile kullandığın an arasında dünya değişir.

Üç sorunun da kökü aynı: tasarım aşamasında yazılmamış birer gereksinim. "Adet 1 ile 10 arasındadır", "kupon bir kez kullanılır", "stok sıfırın altına inmez". Bunlar kod incelemesinde değil, özellik konuşulurken ortaya çıkması gereken cümleler.

STRIDE ve Abuse Case: Tasarımı Kötü Niyetle Okumak

Threat modeling, bir sistemi kötü niyetli birinin gözüyle okumanın düzenli hâlidir. En bilinen çerçevelerden biri Microsoft'un STRIDE'ı; altı tehdit türünün baş harflerinden oluşur. Bookstore'un sipariş akışına uygulayınca kısa bir tablo değil, birkaç somut soru çıkıyor.

Spoofing (kimlik taklidi): X-User-Id header'ını kim gönderiyor, istemci mi? Tampering (kurcalama): istemciden gelen hangi alan tutarı etkiliyor? Repudiation (inkâr): kullanıcı "bu siparişi ben vermedim" derse elimizde ne var? Information disclosure (bilgi sızması): kupon kontrol endpoint'i geçerli kuponları tahmin etmeye izin veriyor mu? Denial of service: biri saniyede bin sipariş isteği atarsa ne olur? Elevation of privilege (yetki yükseltme): sıradan kullanıcı başkasının kuponunu ya da siparişini kendine bağlayabilir mi?

Abuse case, user story'nin tersidir. "Kullanıcı olarak sepete kitap eklemek istiyorum" cümlesinin yanına "saldırgan olarak negatif adetle bakiyemi artırmak istiyorum" ve "saldırgan olarak aynı kuponu yüz kez kullanmak istiyorum" cümlelerini yazarsın. Her abuse case bir kabul kriterine dönüşür; kabul kriteri de bir teste. Yarım saatlik bir oturum, yukarıdaki üç açığın üçünü de kod yazılmadan önce yakalardı.

Düzeltilmiş Tasarım

Önce veri modeli. Kuponun tek kullanımlık olması bir kolon olarak tabloya giriyor:

CREATE TABLE coupons (code TEXT PRIMARY KEY, percent INTEGER NOT NULL, redeemed_by INTEGER NULL);

Sonra input validation. .NET 10'daki yerleşik minimal API doğrulaması DataAnnotations attribute'larını okuyor; tek yapman gereken servisi kaydetmek:

builder.Services.AddValidation();
...
public sealed record OrderRequest(
    int BookId,
    [Range(1, 10)] int Quantity,
    [StringLength(32)] string? CouponCode);

En önemli değişiklik ise kontrol ile değişikliği aynı SQL cümlesine taşımak. Önce okuyup sonra yazmak yerine, koşulu WHERE'e koyup kaç satırın etkilendiğine bakıyoruz. Fiyatı okuyan sorgu ve 200 milisaniyelik gecikme aynen duruyor; değişen kısım ondan sonrası:

using SqliteTransaction tx = conn.BeginTransaction();
// Kontrol ve değişiklik tek cümlede: stok yetmiyorsa hiçbir satır güncellenmez
int reserved = await conn.ExecuteAsync(
    "UPDATE books SET stock = stock - @q WHERE id = @id AND stock >= @q",
    new { q = req.Quantity, id = req.BookId }, tx);
if (reserved == 0) return Results.Conflict(new { error = "Out of stock" });

decimal total = price.Value * req.Quantity;
if (req.CouponCode is not null)
{
    // Kupon yalnızca daha önce kullanılmamışsa bu kullanıcıya bağlanır
    int? percent = await conn.QuerySingleOrDefaultAsync<int?>(
        "UPDATE coupons SET redeemed_by = @userId WHERE code = @code AND redeemed_by IS NULL RETURNING percent",
        new { userId, code = req.CouponCode }, tx);
    if (percent is null) return Results.Conflict(new { error = "Coupon is invalid or already used" });
    total -= total * percent.Value / 100;
}

int orderId = await conn.ExecuteScalarAsync<int>(
    "INSERT INTO orders (user_id, book_id, quantity, total) VALUES (@userId, @bookId, @q, @total) RETURNING id",
    new { userId, bookId = req.BookId, q = req.Quantity, total }, tx);
tx.Commit();

Stok düşümü, kuponun bağlanması ve siparişin yazılması tek bir transaction içinde. Kupon reddedilirse Commit çağrılmadan method'dan çıkılıyor, using transaction'ı dispose ederken geri alıyor ve az önce düşülen stok yerine dönüyor. Aynı üç denemeyi tekrarlayalım:

$ curl -i -X POST http://localhost:5141/orders -H "Content-Type: application/json" -H "X-User-Id: 7" -d '{"bookId":1,"quantity":-3}'
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
...

{"title":"One or more validation errors occurred.","errors":{"Quantity":["The field Quantity must be between 1 and 10."]}}
$ curl -X POST http://localhost:5141/orders -H "Content-Type: application/json" -H "X-User-Id: 7" -d '{"bookId":1,"quantity":1,"couponCode":"WELCOME10"}'
{"orderId":1,"total":41.31}
$ curl -i -X POST http://localhost:5141/orders -H "Content-Type: application/json" -H "X-User-Id: 7" -d '{"bookId":1,"quantity":1,"couponCode":"WELCOME10"}'
HTTP/1.1 409 Conflict
...

{"error":"Coupon is invalid or already used"}
$ curl http://localhost:5141/books/1
{"id":1,"title":"Clean Code","price":45.9,"stock":9}

Son satıra dikkat et: iki sipariş denemesi oldu ama stok yalnızca bir azaldı. İkinci denemenin düştüğü stok, transaction geri alınınca geri geldi. Race condition denemesi de artık tek sipariş üretiyor:

$ curl -s -X POST http://localhost:5141/orders -H "Content-Type: application/json" -H "X-User-Id: 7" -d '{"bookId":2,"quantity":1}' & \
  curl -s -X POST http://localhost:5141/orders -H "Content-Type: application/json" -H "X-User-Id: 8" -d '{"bookId":2,"quantity":1}' & wait
{"orderId":2,"total":52}{"error":"Out of stock"}
$ curl http://localhost:5141/books/2
{"id":2,"title":"The Pragmatic Programmer","price":52,"stock":0}

Gecikme hâlâ yerinde ama artık zarar vermiyor, çünkü "stok yeter mi" sorusu ile "stoğu düş" işlemi arasında bir pencere kalmadı; veritabanı ikisini tek adımda yapıyor.

Rate Limiting: Deneme Yanılmaya Sınır

STRIDE'daki bilgi sızması sorusu bizi kupon kontrol endpoint'ine götürdü: GET /coupons/{code} bir kodun geçerli olup olmadığını söylüyor. Sınırsız istek atılabiliyorsa biri bütün olası kodları sırayla deneyebilir. ASP.NET Core 7'den beri framework'te yerleşik bir rate limiting middleware'i var:

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.AddPolicy("coupon-check", http =>
        RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: http.Request.Headers["X-User-Id"].ToString(),
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 5,
                Window = TimeSpan.FromMinutes(1),
                QueueLimit = 0
            }));
    options.OnRejected = (context, _) =>
    {
        if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan retryAfter))
            context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
        return ValueTask.CompletedTask;
    };
});
WebApplication app = builder.Build();
app.UseRateLimiter();
...
app.MapGet("/coupons/{code}", async (string code) =>
{
    using SqliteConnection conn = Db.Open();
    bool valid = await conn.ExecuteScalarAsync<bool>(
        "SELECT EXISTS (SELECT 1 FROM coupons WHERE code = @code AND redeemed_by IS NULL)", new { code });
    return Results.Ok(new { valid });
}).RequireRateLimiting("coupon-check");

Her kullanıcıya dakikada beş deneme hakkı veriyoruz. Altıncı istek reddediliyor:

$ for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "%{http_code} " http://localhost:5141/coupons/SPRING$i -H "X-User-Id: 9"; done
200 200 200 200 200 429
$ curl -i http://localhost:5141/coupons/SPRING7 -H "X-User-Id: 9"
HTTP/1.1 429 Too Many Requests
Content-Length: 0
Date: Fri, 06 Nov 2026 19:02:03 GMT
Server: Kestrel
Retry-After: 60

İki ayrıntı önemli. RejectionStatusCode'un varsayılanı 503'tür, yani ayarlamazsan reddedilen istemci "sunucu çöktü" mesajı alır; 429 doğru cevaptır. Retry-After header'ı da kendiliğinden gelmez, OnRejected içinde limiter'ın metadata'sından yazman gerekir.

Sık Düşülen Tuzaklar

İlk tuzak, iş kurallarını yalnızca arayüze koymak. Angular formundaki min="1" kullanıcıyı yönlendirir ama API'yi korumaz; curl formu hiç görmez. Kural API'de, mümkünse veritabanında da (bir CHECK constraint gibi) durmalı.

İkincisi, race condition'ı lock ile çözmeye çalışmak. lock yalnızca tek process içinde işe yarar; uygulaman iki instance'la çalıştığı an koruma kaybolur. Paylaşılan durumun sahibi veritabanıysa atomikliği de veritabanından iste: koşullu UPDATE, unique constraint ya da uygun izolasyon seviyesi.

Üçüncüsü, rate limiting'i yalnızca IP'ye göre yapmak. Aynı ofisten çıkan yüz kullanıcı tek IP'yi paylaşır, bir saldırgan ise yüz farklı IP kullanabilir. Partition key'i kullanıcıya, hesaba ya da kaynağa göre seçmek çoğu zaman daha anlamlıdır. Rate limiter'ın bellekte tuttuğu sayaçların her instance'ta ayrı olduğunu da unutma.

Dördüncüsü, threat modeling'i bir kerelik bir belge sanmak. Model, yeni bir özellik geldiğinde güncellenmezse ilk sprint'ten sonra gerçeği anlatmaz. En verimli hâli, her yeni özelliğin konuşulduğu toplantıda beş dakikalık bir "bunu nasıl kötüye kullanırım?" turudur.

Kontrol Listesi

Bir özelliği koda dökmeden önce şu soruları sor. Her girdinin geçerli aralığı tanımlı mı ve sunucuda doğrulanıyor mu? Tek kullanımlık, sınırlı ya da parasal her kaynak için "aynı anda iki istek gelirse" senaryosu düşünüldü mü? Kontrol ve değişiklik aynı atomik işlemde mi?

Deneme yanılmaya açık her endpoint'te (kupon, OTP, parola sıfırlama, davet kodu) rate limiting var mı ve reddedilen istek 429 dönüyor mu? Fiyat, indirim, rol gibi değerler yalnızca sunucuda mı hesaplanıyor? Her user story'nin yanında en az bir abuse case yazıldı mı ve o abuse case bir testle korunuyor mu?

Küçük Bir Deneme

★☆☆ Zafiyetli sürümü çalıştır ve "quantity": 0 gönder. Ne döndüğünü ve stokta ne değiştiğini gözle. Sonra düzeltilmiş sürümde aynı isteği dene ve Range attribute'unun mesajını oku.

★★☆ orders tablosuna CHECK (quantity BETWEEN 1 AND 10) ekle, AddValidation() satırını geçici olarak kaldır ve negatif adetle istek at. Hangi katmanın, hangi hata mesajıyla isteği durdurduğuna bak; kuralın iki yerde durmasının neden işe yaradığını düşün.

★★★ Sipariş endpoint'ine kullanıcı başına dakikada üç sipariş sınırı koyan ikinci bir policy ekle. Ardından STRIDE'ın altı sorusunu GET /orders/{id} endpoint'i için yaz ve en az iki abuse case çıkar.

Rate limiting bu yazıda kupon kodlarını korudu. Bir sonraki yazıda aynı deneme yanılma sorununun en tehlikeli hâline, giriş ekranına geçiyoruz: A07 Authentication Failures başlığı altında brute force ve credential stuffing, Identity'nin lockout özelliği, MFA ve NIST'in güncel parola kuralları.