API7 ve API10: Dış Dünyayla Konuşan API'ler — Webhook'lar, Outbound URL Doğrulama ve Üçüncü Parti Yanıtlara Güvenmemek
Kullanıcının verdiği webhook URL'ini kayıtta biçim olarak, bağlantı anında ise SocketsHttpHandler.ConnectCallback ile çözülen her adres üzerinden doğrularsın; üçüncü parti yanıtlara da kullanıcı girdisi gibi davranırsın. DNS rebinding taklidi, redirect'siz teslimat, imzalı ve tek seferlik işlenen ödeme webhook'u, boyut limiti ve resilience ile doğrulanan kargo API yanıtı.
Sürüm notu: Bu yazıdaki kodlar .NET 10, EF (Entity Framework) Core 10.0.14 ve Microsoft.Extensions.Http.Resilience 10.11.0 ile derlenip denenmiştir; bütün dış istekler yerel lab'daki alıcılara yapılmıştır.
Önce Tahmin Et
Bookstore'a bir özellik ekliyoruz: tenant admin'i bir webhook URL'i (Uniform Resource Locator) kaydediyor, sipariş durumu değiştiğinde API (Application Programming Interface) o adrese bir POST atıyor. Kayıt formuna https://hooks.partner.test/bookstore yazılıyor, doğrulamadan geçiyor. Bir hafta sonra aynı isim, DNS (Domain Name System) kaydı değiştirilerek 127.0.0.1'e ya da bulut ortamındaki metadata servisine çözülüyor. Kayıtta yaptığın kontrol bu durumda ne işe yarar?
Aynı hafta kargo firmasının fiyat API'sinden gelen yanıtta servis adı <script>alert(1)</script> olarak geliyor ve sipariş ekranında aynen gösteriliyor. Kargo firması "güvenilir bir iş ortağı", o yüzden yanıtı kimse doğrulamamış.
İki olay iki ayrı OWASP (Open Worldwide Application Security Project) maddesine düşüyor: API7 (2023 listesinin yedinci maddesi), yani SSRF (Server Side Request Forgery), ve API10 (Unsafe Consumption of APIs). Bu yazıda ikisini birlikte ele alıyoruz, çünkü ikisi de API'nin dışarıyla konuştuğu noktalarda ortaya çıkıyor. SSRF'in temeli, yani URL allowlist'i ve IP (Internet Protocol) kontrolü, Web Güvenliği (OWASP Top 10) serisinin A01 Broken Access Control yazısının ikinci yarısında. Burada o temeli kullanıcının kaydettiği webhook'lara, gelen webhook'lara ve üçüncü parti API yanıtlarına uyguluyoruz.
İki Risk, Tek Konu: Dışarıyla Konuşan API
SSRF'te sunucu, saldırganın seçtiği bir adrese bizim ağımızın içinden istek atıyor. Webhook bunun en saf hâli: adresi kullanıcı seçiyor ve sunucu oraya düzenli olarak istek gönderiyor. Hedef iç ağdaki bir yönetim paneli, bir veritabanının HTTP (Hypertext Transfer Protocol) arayüzü ya da bulut sağlayıcısının metadata servisi olabilir.
API10'da yön tersine dönüyor: bizim çağırdığımız ya da bizi çağıran üçüncü taraf, bize kontrol edemediğimiz veri gönderiyor. OWASP'ın bu maddedeki gözlemi şu: geliştiriciler üçüncü parti API'lerden gelen veriye kullanıcı girdisinden daha çok güveniyor ve saldırganlar da tam bu yüzden doğrudan hedef yerine onun entegre olduğu servisleri hedefliyor. Gelen bir ödeme webhook'u ve çağırdığımız bir kargo API'si bu yazıdaki iki örnek.

Webhook URL'i: Kayıtta Biçim
Kayıt anında yapılabilecek kontroller URL'in biçimiyle ilgili. Adresin nereye çözüleceğini burada bilemeyiz, ama açıkça kötü olanları erkenden reddedebiliriz:
// Kayıt anında: URL'in biçimi; adres kontrolü bağlantı anında yapılır
public string? ValidateUrl(string raw)
{
OutboundOptions o = options.Value;
if (!Uri.TryCreate(raw, UriKind.Absolute, out Uri? uri))
{
return "Must be an absolute URL.";
}
if (uri.Scheme != Uri.UriSchemeHttps && !(o.AllowHttp && uri.Scheme == Uri.UriSchemeHttp))
{
return "Only https URLs are allowed.";
}
if (uri.UserInfo.Length > 0)
{
return "Credentials in the URL are not allowed.";
}
if (uri.HostNameType is not UriHostNameType.Dns)
{
return "Use a host name, not an IP address.";
}
if (uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|| uri.Host.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase)
|| uri.Host.EndsWith(".internal", StringComparison.OrdinalIgnoreCase))
{
return "This host is not allowed.";
}
if (!o.AllowedPorts.Contains(uri.Port) && !o.AllowLoopbackPorts.Contains(uri.Port))
{
return "This port is not allowed.";
}
return null;
}
IP literal'ı tamamen yasaklamak, 0x7f000001, 2130706433 ya da [::ffff:127.0.0.1] gibi bir IP adresinin onlarca farklı yazımıyla uğraşmaktan kurtarıyor: Uri bunları IP olarak tanıyor ve HostNameType Dns değil. Port allowlist'i, 443 dışındaki port'larda çalışan iç servisleri baştan dışarıda bırakıyor. AllowHttp ve AllowLoopbackPorts yalnız lab ve test için var; production'da varsayılan değerleri, yani yalnız https ve 443, geçerli.
$ curl -s -X POST http://localhost:19528/admin/webhooks -H "Authorization: Bearer $ADA" -H "Content-Type: application/json" -d '{"url":"https://169.254.169.254/latest/meta-data/"}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"url":["Use a host name, not an IP address."]},"traceId":"00-9deb2491b97f6f3fcd8988ee6d88f634-9d57ec0941c662f5-00"}
$ curl -s -X POST http://localhost:19528/admin/webhooks -H "Authorization: Bearer $ADA" -H "Content-Type: application/json" -d '{"url":"https://user:secret@hooks.partner.test/bookstore"}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"url":["Credentials in the URL are not allowed."]},"traceId":"00-0ee42659ec72c63bd3a0a0445a4664e0-a20e564168b818d6-00"}
$ curl -s -X POST http://localhost:19528/admin/webhooks -H "Authorization: Bearer $ADA" -H "Content-Type: application/json" -d '{"url":"https://hooks.partner.test/bookstore"}'
{"id":"01a1b9d0-060c-7ac6-820f-153abde6d2f4","url":"https://hooks.partner.test/bookstore","signingSecret":"NGZY5LTA..."}
Webhook endpoint'leri bir önceki yazıda ayırdığımız admin port'unda (bu koşuda 19528) ve tenant admin policy'si arkasında. Kayıt yanıtındaki imza secret'ını yazıda kısalttım; üçüncü yazıdaki gibi sunucudaki master key'den webhook id'siyle türetiliyor.
Bağlantı Anında Adres: ConnectCallback
Kayıtta "hooks.partner.test" geçerli bir isimdi. Ama bir ismin hangi adrese çözüleceğini ismin sahibi belirler ve bu her an değişebilir. Kayıtta DNS'e bir kez bakıp sonucu doğrulamak, DNS rebinding denen tekniğe karşı korumaz: doğrulama anında isim zararsız bir public adrese, istek anında ise 127.0.0.1'e çözülür. Doğru yer, bağlantının kurulduğu an. SocketsHttpHandler.ConnectCallback tam bunu sağlıyor: HttpClient TCP (Transmission Control Protocol) bağlantısını açmadan önce bizim kodumuzu çağırıyor ve bağlantıyı biz kuruyoruz.
// DNS'e kayıtta bir kez bakıp güvenmek yerine her bağlantıda çözümle, kontrol et ve doğrudan o IP'ye bağlan
public static SocketsHttpHandler Create(OutboundPolicy policy) => new()
{
AllowAutoRedirect = false,
ConnectTimeout = TimeSpan.FromSeconds(3),
UseProxy = false,
ConnectCallback = async (context, ct) =>
{
int port = context.DnsEndPoint.Port;
IPAddress[] addresses = await policy.ResolveAsync(context.DnsEndPoint.Host, ct);
if (addresses.Length == 0 || addresses.Any(a => !policy.IsAllowed(a, port)))
{
throw new OutboundBlockedException(
$"Blocked destination {context.DnsEndPoint.Host} ({string.Join(", ", addresses.Select(a => a.ToString()))}).");
}
Socket socket = new(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
try
{
await socket.ConnectAsync(new IPEndPoint(addresses[0], port), ct);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
}
};
Üç ayrıntı var. Çözülen adreslerden biri bile engelliyse bağlanmıyoruz; "ilk izinli olanı seç" yaklaşımı, bir public ve bir private adres döndüren bir DNS kaydına kapı açar. Kontrol ettiğimiz adresle bağlandığımız adres aynı: ConnectAsync'e isim değil, kontrol edilmiş IP veriliyor, yani arada ikinci bir DNS sorgusu yok. UseProxy = false ise ortamdaki bir HTTP_PROXY ayarının bağlantıyı proxy'ye yönlendirmesini, dolayısıyla bizim kontrolümüzün proxy'nin adresine uygulanmasını engelliyor.
Adres kontrolü .NET 8 ile gelen IPNetwork tipiyle yazıldı. Listede RFC (Request for Comments) 1918 özel blokları, loopback, link-local (bulut metadata servislerinin bulunduğu 169.254.0.0/16), CGNAT (Carrier-Grade Network Address Translation) bloğu 100.64.0.0/10, multicast ve IPv6 karşılıkları var. IPv4-mapped IPv6 adresler (::ffff:127.0.0.1) kontrolden önce IPv4'e çevriliyor; bu adım unutulursa ::ffff: önekli bir adres bütün IPv4 kurallarını atlar.
Lab'da gerçek bir DNS sorgusu yapmadan rebinding'i taklit etmek için Outbound:DnsOverrides ayarını kullandım: "hooks.partner.test" ismini 127.0.0.1'e çözdürüyorum. Kayıt az önce geçmişti; şimdi test teslimatı:
$ curl -s -X POST http://localhost:19528/admin/webhooks/01a1b9d0-060c-7ac6-820f-153abde6d2f4/ping -H "Authorization: Bearer $ADA"
{"delivered":false,"statusCode":null,"error":"Destination is not allowed."}
warn: Bookstore.Orders.Api.Outbound.WebhookSender[0]
Webhook 01a1b9d0-060c-7ac6-820f-153abde6d2f4 blocked: Blocked destination hooks.partner.test (127.0.0.1).
Kullanıcıya dönen mesaj ile log'daki mesaj farklı. Kullanıcı yalnız "hedefe izin verilmiyor" görüyor; hangi adrese çözüldüğünü, bağlantı hatasının türünü ya da karşı tarafın yanıt gövdesini görmüyor. Bunların her biri, SSRF'i iç ağı taramak için bir port tarayıcıya çeviren bilgiler. Başarılı teslimatta da yanıt gövdesi okunmuyor; kullanıcıya yalnız durum kodu dönüyor.
Doğru Yere Gidiyor mu? Pozitif Test ve Redirect
Yalnız "engellendi" senaryolarını test eden bir test seti, her şeyi engelleyen bozuk bir handler'ı da geçirir. Bu yüzden testlerden biri gerçek bir alıcı başlatıyor: test içinde 127.0.0.1 üzerinde rastgele bir port'ta dinleyen küçük bir WebApplication. O port'a lab ayarlarıyla izin verip iki webhook kaydediyoruz: biri 200 dönen /hook, diğeri /internal-only'ye redirect eden /moved.
Assert.Equal(new DeliveryResult(true, 200, null), ok);
Assert.Equal(302, redirected!.StatusCode);
Assert.False(redirected.Delivered);
Assert.Equal(["/hook v1=", "/moved"], hits);
Alıcıya iki istek geldi: imzalı ilk istek (X-Bks-Signature başlığı v1= ile başlıyor) ve redirect dönen ikinci istek. /internal-only'ye hiç istek gelmedi. AllowAutoRedirect varsayılan olarak açık; kapatmazsan dış bir adres 302 ile sunucunu iç bir adrese yönlendirebilir ve redirect hedefi için ConnectCallback yine çalışsa da kayıt anındaki bütün biçim kontrollerini atlamış olur.
Gelen Webhook'lar: İmza, Zaman ve Tek Seferlik İşleme
Ters yönde, ödeme sağlayıcısı bize "ödeme başarılı" event'i gönderiyor. Bu endpoint anonim olmak zorunda, çünkü çağıran bir kullanıcı değil. Kimliği imza kanıtlıyor:
// 1) İmza ve zaman: "t=<unix>,v1=<hex>" ve HMAC(secret, "<t>.<body>")
byte[] secret = Encoding.UTF8.GetBytes(File.ReadAllText(configuration["Payments:WebhookSecretFile"]!).Trim());
if (!TryVerify(request.Headers[SignatureHeader].ToString(), body, secret, time))
{
logger.LogWarning("Rejected payment webhook: bad signature or timestamp");
return Results.Unauthorized();
}
// 2) İmzalı olsa bile içerik doğrulanır: şema, tip, tutar ve para birimi
PaymentEvent? evt;
try
{
evt = JsonSerializer.Deserialize<PaymentEvent>(body, Strict);
}
catch (JsonException)
{
return Results.UnprocessableEntity();
}
if (evt is null || evt.Type != "payment.succeeded" || evt.Currency != "EUR")
{
return Results.UnprocessableEntity();
}
// 3) Kapsam imzalı event'teki tenant'a kurulur; sipariş başka tenant'taysa görünmez
await using OrdersDbContext db = new(options, new DataScope(evt.TenantId, null));
if (await db.ProcessedWebhookEvents.AnyAsync(e => e.Id == evt.Id, ct))
{
return Results.Ok(new { status = "already processed" });
}
Order? order = await db.Orders.FirstOrDefaultAsync(o => o.Id == evt.OrderId, ct);
if (order is null || order.Total != evt.Amount || order.Status != OrderStatus.Pending)
{
logger.LogWarning("Payment event {EventId} does not match order {OrderId}", evt.Id, evt.OrderId);
return Results.UnprocessableEntity();
}
İmza şeması birçok ödeme sağlayıcısının kullandığı biçimde: header'da bir timestamp ve imza, imzalanan metin timestamp ile body'nin birleşimi. Timestamp, üçüncü yazıdaki gibi beş dakikalık bir pencereyle replay'i sınırlıyor. Pencere içindeki tekrarları ise event id'si yakalıyor: ProcessedWebhookEvents tablosunda id primary key ve sipariş güncellemesiyle aynı SaveChanges'ta yazılıyor. Sağlayıcılar teslimatı garanti etmek için aynı event'i birden fazla kez gönderebilir; bu durumda 200 dönüp işlemi tekrarlamamak doğru davranış.
İkinci ve üçüncü adım API10'un özü. İmza "bu mesajı sağlayıcı gönderdi" der, "bu mesaj doğrudur" demez. Sağlayıcının hesabı ele geçirilmiş olabilir, bir entegrasyon hatası yanlış sipariş id'si gönderebilir. Bu yüzden event'teki tutarı kendi veritabanımızdaki sipariş tutarıyla karşılaştırıyoruz ve kapsamı event'teki tenant'a kuruyoruz: North adına imzalanmış bir event South'un siparişini göremez. Deserialization için .NET 9 ile gelen RespectRequiredConstructorParameters ve RespectNullableAnnotations seçeneklerini açtım; eksik bir alan ya da beklenmeyen bir null exception'a dönüşüyor.
$ curl -s -X POST http://localhost:19518/webhooks/payments -H "X-Payments-Signature: t=$T,v1=$SIG" -H "Content-Type: application/json" -d "$EVT"
{"status":"processed"}
$ curl -s -X POST http://localhost:19518/webhooks/payments -H "X-Payments-Signature: t=$T,v1=$SIG" -H "Content-Type: application/json" -d "$EVT"
{"status":"already processed"}
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:19518/webhooks/payments -H "X-Payments-Signature: t=$T,v1=$SIG" -H "Content-Type: application/json" -d "${EVT/54.90/0.01}"
401
Son istekte tutarı değiştirdim ama imzayı değiştirmedim: imza tutmadığı için 401. Testlerde imzası doğru ama tutarı yanlış ve başka tenant'ın siparişine işaret eden event'ler de var; ikisi de 422 alıyor.
API10: Üçüncü Parti Yanıtı da Girdi
Kargo fiyatı için çağırdığımız API'nin yanıtına aynı şüpheyle bakıyoruz. Typed client'ın kaydı iki savunmayı birden kuruyor:
// Üçüncü parti API: boyut limiti ve standart resilience (retry, timeout, circuit breaker)
builder.Services.AddHttpClient<CarrierRatesClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["Carrier:BaseUrl"] ?? "https://rates.carrier.test");
client.MaxResponseContentBufferSize = 32 * 1024;
})
.AddStandardResilienceHandler(options =>
{
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(6);
options.Retry.MaxRetryAttempts = 2;
});
MaxResponseContentBufferSize'ın varsayılanı 2 GB; bir fiyat yanıtı için 32 KB fazlasıyla yeterli. Standart resilience handler retry, deneme başına timeout, toplam timeout ve circuit breaker ekliyor; kargo API'si yavaşladığında bizim sipariş ekranımız da onunla birlikte yavaşlamıyor. Client'ın kendisi ise yanıtı kullanıcı girdisi gibi doğruluyor:
// Üçüncü parti yanıtı da bir girdi: tip, boyut, şema ve değer aralığı doğrulanmadan kullanılmaz
public sealed partial class CarrierRatesClient(HttpClient http, ILogger<CarrierRatesClient> logger)
{
private static readonly string[] Currencies = ["EUR", "USD"];
private static readonly JsonSerializerOptions Strict = new(JsonSerializerDefaults.Web)
{
RespectRequiredConstructorParameters = true,
RespectNullableAnnotations = true
};
public async Task<ShippingQuote?> GetQuoteAsync(int items, CancellationToken ct)
{
try
{
using HttpResponseMessage response = await http.GetAsync($"/rates?items={items}", ct);
if (!response.IsSuccessStatusCode || response.Content.Headers.ContentType?.MediaType != "application/json")
{
return Reject("unexpected status or content type");
}
CarrierQuoteDto? dto = JsonSerializer.Deserialize<CarrierQuoteDto>(
await response.Content.ReadAsStringAsync(ct), Strict);
if (dto is null || !ServiceName().IsMatch(dto.Service) || dto.Price is <= 0 or > 500
|| !Currencies.Contains(dto.Currency) || dto.EstimatedDays is < 1 or > 30)
{
return Reject("response outside the expected schema or ranges");
}
return new ShippingQuote(dto.Service, dto.Price, dto.Currency, dto.EstimatedDays);
}
catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException
or Polly.Timeout.TimeoutRejectedException
or Polly.CircuitBreaker.BrokenCircuitException)
{
return Reject(ex.GetType().Name);
}
ShippingQuote? Reject(string reason)
{
logger.LogWarning("Carrier quote rejected: {Reason}", reason);
return null;
}
}
[GeneratedRegex("^[A-Za-z0-9 -]{1,40}$")]
private static partial Regex ServiceName();
}
Doğrulanamayan yanıt null dönüyor ve endpoint bunu "şu an fiyat yok" anlamında 503'e çeviriyor. Bu bir fail-closed kararı: yanlış bir fiyatı göstermektense hiç göstermemek. Lab'da kargo API'sini taklit eden küçük bir Python sunucusuyla üç yanıtı denedim: geçerli bir fiyat, servis adında script ve 40 KB'lık bir dolgu alanı.
MODE ok
{"service":"Express","price":7.5,"currency":"EUR","estimatedDays":2}
MODE script
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.4","title":"Shipping quotes are temporarily unavailable.","status":503,"traceId":"00-9ed1e581e9092a664c2c1bfc9b866a15-83397990a44e0b9d-00"}
MODE huge
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.4","title":"Shipping quotes are temporarily unavailable.","status":503,"traceId":"00-669e3b77c49d8b3fcecdd626f007d495-bc1c9e5b896001d4-00"}
warn: Bookstore.Orders.Api.Outbound.CarrierRatesClient[0]
Carrier quote rejected: response outside the expected schema or ranges
warn: Bookstore.Orders.Api.Outbound.CarrierRatesClient[0]
Carrier quote rejected: HttpRequestException
Testler aynı şeyi sahte bir HttpMessageHandler ile yapıyor: negatif fiyat, desteklenmeyen para birimi, eksik alan, null servis adı, JSON (JavaScript Object Notation) olmayan gövde ve 32 KB'ı aşan yanıt. Yedi satırlık bir Theory ve ayrı bir boyut testi; OutboundTests class'ının bütün testleriyle birlikte toplam 163 test yeşil.
Üretimde Dikkat
Uygulama seviyesindeki kontrol tek katman olmamalı. Webhook gönderen process'i ayrı bir servis ya da container olarak, yalnız internete çıkışı olan ve iç ağa erişimi network policy ile kesilmiş bir ağda çalıştırmak, kodundaki bir hatayı telafi eder. Bulut ortamlarında metadata servisinin token gerektiren sürümünü (örneğin AWS (Amazon Web Services) ortamında IMDSv2, yani Instance Metadata Service sürüm 2) zorunlu tutmak da ayrı bir kilit.
Webhook teslimatını isteğin içinde, senkron yapmak lab için uygun ama production'da değil. Gerçek sistemde teslimat bir outbox ve background worker üzerinden, retry ve backoff ile yapılır; alıcı sürekli hata veriyorsa webhook devre dışı bırakılır ve tenant admin'e bildirilir. Bu yapıyı ayrıntısıyla Messaging ve Event-Driven Architecture serisinde konuşmuştuk.
Üçüncü parti TLS (Transport Layer Security) doğrulamasını asla kapatma. ServerCertificateCustomValidationCallback = (_, _, _, _) => true satırı genelde "test ortamında sertifika hatası veriyordu" diye eklenir ve production'a taşınır. Sertifika sorunu varsa çözüm güvenilen CA (Certificate Authority) listesini düzeltmek, doğrulamayı kapatmak değil.
Gelen webhook secret'ları da bir key yönetimi konusu. Sağlayıcılar genelde rotation sırasında iki secret'ı birlikte kabul etmeni önerir; TryVerify'ı bir secret listesiyle çalışacak şekilde yazmak bunu kolaylaştırır.
Sık Düşülen Tuzaklar
Birinci tuzak, URL'i bir kez, kayıt anında doğrulamak. DNS cevabı değişir; kontrol bağlantı anında ve bağlanılan adres üzerinde yapılmalı.
İkincisi, "ilk izinli adresi seç" mantığı ya da yalnız ilk adresi kontrol etmek. Bir isim birden fazla adrese çözülebilir ve HttpClient'ın hangisine bağlanacağını sen belirlemiyorsan kontrolün anlamı kalmaz. Bağlantıyı kontrol ettiğin adrese kendin kur.
Üçüncüsü, dış isteğin yanıtını kullanıcıya göstermek. "Webhook test et" butonunun karşı tarafın yanıt gövdesini ya da bağlantı hatasının ayrıntısını göstermesi, SSRF'i kör olmaktan çıkarır ve iç ağ hakkında bilgi sızdırır.
Dördüncüsü, imzalı ya da "güvenilir" kaynaktan geldi diye veriyi doğrulamamak. İmza kaynağı kanıtlar, içeriği değil. Tutar, para birimi, durum geçişi ve tenant kendi verinle karşılaştırılmalı.
Küçük Bir Deneme
★☆☆ Lab'da Outbound:DnsOverrides ile "hooks.partner.test" ismini 10.0.0.5'e çözdür ve ping'i tekrarla. Log'da ne görüyorsun? Aynı denemeyi fd00::1 ile yap.
★★☆ TryVerify'ı iki secret kabul edecek şekilde değiştir ve rotation senaryosunu test et: eski secret'la imzalanmış event, geçiş süresi bitene kadar kabul edilsin.
★★★ Webhook teslimatını bir outbox tablosuna ve bir BackgroundService'e taşı. Art arda beş başarısız teslimattan sonra webhook'u devre dışı bırak; bunun bir saldırganın iç adresleri tek tek denemesini nasıl yavaşlattığını düşün.
Bir sonraki yazıda API8 (Security Misconfiguration) ve API9 (Improper Inventory Management) var: API'ye özgü bir yapılandırma kontrol listesi, build sırasında üretilen OpenAPI dokümanını envanter olarak saklamak, sürümler arası farkla habersiz endpoint'leri yakalamak ve Deprecation ile Sunset header'larıyla eski sürümleri emekliye ayırmak.