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

OWASP Top 10'u Birleştirmek: ASP.NET Core ve Angular için Güvenli Varsayılanlar

Mert Özen 6 Kas 2026 13 dk 3 görüntülenme
OWASP Top 10'u Birleştirmek: ASP.NET Core ve Angular için Güvenli Varsayılanlar

On kategoriyi tek bir Program.cs'te birleştiriyoruz: fallback policy, cookie bayrakları, ProblemDetails, 429 dönen rate limiting, security header'lar ve bilinmeyen JSON alanlarını reddetmek. Hepsini curl ile doğruluyor, .NET analyzer'larıyla SAST, WebApplicationFactory ile güvenlik regresyon testi yazıyor, DAST'ı ve ASVS 5.0'a geçişi konuşuyoruz.

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'de fallback policy tanımlı: giriş yapmamış kimse hiçbir endpoint'e erişemiyor. Bir gün aceleyle şu satırı ekliyorsun: app.MapGet("/admin/stats", () => new { Books = books.Count });. Üzerinde ne RequireAuthorization var ne AllowAnonymous. Müşteri rolündeki Ada giriş yapıp bu adrese istek atarsa ne döner: 401 mi, 403 mü, yoksa 200 mü?

Cevabı birazdan gerçek bir çıktıyla göreceğiz ve bu cevap, bu yazının ana fikrini taşıyor. On kategoriyi tek tek inceledik; şimdi hepsini tek bir Program.cs'te, bir güvenlik testi projesinde ve bir kontrol listesinde birleştiriyoruz. Amaç şu: güvenli olan yol, ekstra kod yazmadan gidilen yol olsun. Unutulan bir satır açık kapı değil, kapalı kapı üretsin.

Seri boyunca olduğu gibi her şey kendi makinende, localhost üzerinde çalışıyor. Bu teknikleri yalnız sahibi olduğun ya da yazılı izin aldığın sistemlerde dene.

Güvenli Varsayılanlarla Bir Program.cs

Önce service kayıtları. Her bloğun üstündeki yorum, hangi Top 10 kategorisine karşılık geldiğini söylüyor:

using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(options => options.AddServerHeader = false);

// A01: varsayılan olarak reddet
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.Cookie.Name = "bookstore.auth";
        options.Cookie.HttpOnly = true;
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        options.Cookie.SameSite = SameSiteMode.Strict;
        options.ExpireTimeSpan = TimeSpan.FromHours(1);
    });
builder.Services.AddAuthorizationBuilder()
    .SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build())
    .AddPolicy("admin", policy => policy.RequireRole("Admin"));

// A10 + A02: tek tip hata biçimi, stack trace yok
builder.Services.AddProblemDetails();

// A06 + A07: istemci IP'si başına giriş denemesini sınırla
builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.AddPolicy("login", context => RateLimitPartition.GetFixedWindowLimiter(
        context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
        _ => new FixedWindowRateLimiterOptions { PermitLimit = 5, Window = TimeSpan.FromMinutes(1) }));
});

// A08: bilinmeyen JSON alanı sessizce yutulmaz, hata olur
builder.Services.ConfigureHttpJsonOptions(options =>
    options.SerializerOptions.UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow);
builder.Services.AddValidation();

builder.Services.AddSingleton<UserStore>();

Fallback policy, üzerinde hiçbir yetkilendirme bilgisi olmayan endpoint'lere uygulanan policy'dir. Minimal API'de endpoint'ler varsayılan olarak anonimdir; bu satır bunu tersine çevirir. Cookie için üç bayrak açıkça yazıldı: JavaScript okuyamasın (HttpOnly), yalnız HTTPS'te gitsin (Secure), başka sitelerden gelen isteklere eklenmesin (SameSite=Strict). Rate limiter'ın varsayılan ret kodu 503'tür; 429'a çekmezsen istemci "sunucu çöktü" sanır. UnmappedMemberHandling.Disallow ise mass assignment yazısındaki dersin JSON tarafındaki karşılığı: DTO'da olmayan bir alan gelirse istek 400 ile reddedilir. AddValidation .NET 10 ile gelen yerleşik minimal API doğrulamasını açar.

Sonra pipeline. Burada sıra, satırların kendisi kadar önemli:

WebApplication app = builder.Build();

app.UseSecurityHeaders();
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler();
    app.UseHsts();
}
app.UseStatusCodePages();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();

İlk denemede UseSecurityHeaders'ı UseExceptionHandler'ın arkasına koymuş, authentication ve authorization middleware'lerini de çağırmamıştım. Sonuç: 401 ve 500 yanıtları header'sız döndü. WebApplication, sen çağırmazsan authentication ve authorization middleware'lerini pipeline'ın en başına kendisi ekliyor; 401 benim middleware'ime hiç uğramadan üretiliyordu. Exception handler ise hata yakaladığında yanıtı, header'larıyla birlikte temizliyor. Çözüm iki parçalı: header middleware'i en başa al ve header'ları OnStarting ile yanıt gönderilmeden hemen önce yaz.

static class SecurityHeadersExtensions
{
    // A02: ASP.NET Core'da yerleşik bir CSP middleware'i yok
    public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app) =>
        app.Use(async (context, next) =>
        {
            context.Response.OnStarting(() =>
            {
                IHeaderDictionary headers = context.Response.Headers;
                headers.XContentTypeOptions = "nosniff";
                headers.ContentSecurityPolicy = "default-src 'none'; frame-ancestors 'none'";
                headers["Referrer-Policy"] = "no-referrer";
                return Task.CompletedTask;
            });
            await next();
        });
}

Bu bir JSON API olduğu için CSP en sıkı hâlinde: hiçbir kaynak yüklenemez, sayfa hiçbir frame'e gömülemez. Son olarak endpoint'ler ve iki dev-only kullanıcı tutan küçük bir UserStore:

List<Book> books = [new(1, "Clean Code", 35.00m), new(2, "Refactoring", 42.50m)];

app.MapGet("/books", () => books).AllowAnonymous();
app.MapGet("/books/{id:int}", (int id) => books.FirstOrDefault(b => b.Id == id) is Book book
    ? Results.Ok(book)
    : Results.NotFound()).AllowAnonymous();

RouteGroupBuilder admin = app.MapGroup("/admin").RequireAuthorization("admin");

admin.MapPost("/books", (BookInput input) =>
{
    Book book = new(books.Count + 1, input.Title, input.Price);
    books.Add(book);
    return TypedResults.Created($"/books/{book.Id}", book);
});
admin.MapGet("/stats", () => new { Books = books.Count });

app.MapPost("/login", async (LoginInput input, UserStore users, HttpContext http) =>
{
    ClaimsPrincipal? principal = users.Verify(input.Email, input.Password);
    if (principal is null)
    {
        return Results.Problem(statusCode: StatusCodes.Status401Unauthorized, title: "Invalid email or password.");
    }
    await http.SignInAsync(principal);
    return Results.NoContent();
}).AllowAnonymous().RequireRateLimiting("login");

// hiçbir attribute yok: fallback policy yine de giriş yapmış kullanıcı ister
app.MapGet("/orders/mine", (ClaimsPrincipal user) => new { User = user.Identity!.Name, Orders = Array.Empty<int>() });

app.MapGet("/boom", () =>
{
    throw new InvalidOperationException("Connection string 'Server=db;Password=dev-only-secret' failed.");
}).AllowAnonymous();

app.Run();

public record Book(int Id, string Title, decimal Price);
public record BookInput([Required, StringLength(200)] string Title, [Range(0.01, 10000)] decimal Price);
public record LoginInput(string Email, string Password);

sealed class UserStore
{
    private readonly PasswordHasher<string> _hasher = new();
    private readonly Dictionary<string, (string Hash, string Role)> _users = new();

    public UserStore()
    {
        // yalnız geliştirme için test kullanıcıları, asla gerçek parola değil
        _users["ada@example.com"] = (_hasher.HashPassword("ada@example.com", "dev-only-Correct-Horse-42"), "Customer");
        _users["grace@example.com"] = (_hasher.HashPassword("grace@example.com", "dev-only-Battery-Staple-7"), "Admin");
    }

    public ClaimsPrincipal? Verify(string email, string password)
    {
        if (!_users.TryGetValue(email, out (string Hash, string Role) user))
        {
            return null;
        }
        PasswordVerificationResult result = _hasher.VerifyHashedPassword(email, user.Hash, password);
        if (result == PasswordVerificationResult.Failed)
        {
            return null;
        }
        Claim[] claims = [new(ClaimTypes.Name, email), new(ClaimTypes.Role, user.Role)];
        return new ClaimsPrincipal(new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme));
    }
}

public partial class Program;

Admin endpoint'leri bir route group'ta; policy'yi gruba bir kez verdik ve her yeni endpoint onu miras alır. En alttaki public partial class Program; satırı test projesinin uygulamayı ayağa kaldırabilmesi için orada.

Gerçek İsteklerle Doğrulamak

Uygulamayı Production ortamında başlatıp sırayla istek atıyoruz:

$ ASPNETCORE_ENVIRONMENT=Production dotnet run --no-launch-profile --urls http://localhost:5171
$ curl -i http://localhost:5171/books
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:02:53 GMT
Transfer-Encoding: chunked
X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
Referrer-Policy: no-referrer

[{"id":1,"title":"Clean Code","price":35.00},{"id":2,"title":"Refactoring","price":42.50}]

$ curl -i http://localhost:5171/orders/mine
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
Location: http://localhost:5171/Account/Login?ReturnUrl=%2Forders%2Fmine
...
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Unauthorized","status":401,"traceId":"00-3e7ad446...-00"}

Server: Kestrel header'ı yok, güvenlik header'ları her yanıtta var. Hiçbir attribute taşımayan /orders/mine anonim isteğe 401 döndü. Yanıtta bir Location header'ı görüyorsun ama durum kodu 302 değil 401: .NET 10'da cookie authentication, API endpoint'leri için login sayfasına yönlendirmek yerine 401/403 dönüyor. Şimdi iki kullanıcıyla giriş yapalım:

$ curl -i -c ada.txt -X POST http://localhost:5171/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"dev-only-Correct-Horse-42"}'
HTTP/1.1 204 No Content
Cache-Control: no-cache,no-store
Set-Cookie: bookstore.auth=CfDJ8EINq_wm4TFCv7guzy6SHC...; path=/; secure; samesite=strict; httponly
...

$ curl -i -b ada.txt http://localhost:5171/admin/stats
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
...
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.4","title":"Forbidden","status":403,"traceId":"00-e7190151...-00"}

$ curl -i -b grace.txt -X POST http://localhost:5171/admin/books -H "Content-Type: application/json" -d '{"title":"Dune","price":-5}'
HTTP/1.1 400 Bad Request
...
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Price":["The field Price must be between 0.01 and 10000."]},"traceId":"00-ffc6b7fa...-00"}

$ curl -i -b grace.txt -X POST http://localhost:5171/admin/books -H "Content-Type: application/json" -d '{"title":"Dune","price":20,"isFeatured":true}'
HTTP/1.1 400 Bad Request
...
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"traceId":"00-787581db...-00"}

Cookie'nin üç bayrağı Set-Cookie satırında. Müşteri Ada admin grubuna 403 aldı. Admin Grace bile negatif fiyatla ya da DTO'da olmayan isFeatured alanıyla kitap ekleyemedi. Sırada brute force denemesi ve hata yanıtı var:

$ for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:5171/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"wrong"}'; done
401 401 401 429 429 429

$ curl -i http://localhost:5171/boom
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
Cache-Control: no-cache,no-store
...
X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
Referrer-Policy: no-referrer

{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500,"traceId":"00-998e2b5e...-00"}

Neden üç 401'den sonra 429 geldi de beş değil? Çünkü aynı dakika içinde Ada ve Grace'in iki başarılı girişi de sayıldı: limiter, sonucuna bakmadan /login'e gelen her isteği sayar. 500 yanıtında connection string'den eser yok, ama sunucu log'una bak: exception mesajı, içindeki Password=dev-only-secret ile birlikte orada duruyor. İstemciye sızmayan bilgi log'a sızabilir; logging yazısındaki redaction konusu tam bu yüzden var.

Angular Tarafındaki Varsayılanlar

Angular'ın varsayılanları güvenli: interpolation ({{ }}) her zaman escape edilir, [innerHTML]'e verilen değer sanitize edilir, AOT derleyici varsayılandır. Senin işin bu varsayılanları bozmamak. DomSanitizer.bypassSecurityTrust* çağrılarını kod incelemesinde ayrıca işaretle; her birinin arkasında "bu değer kesinlikle bizden geliyor" diyebilmelisin.

import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withXsrfConfiguration } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    // cookie'den okunan token'ı aynı origin'e giden POST/PUT/DELETE isteklerine header olarak ekler
    provideHttpClient(withXsrfConfiguration({ cookieName: 'XSRF-TOKEN', headerName: 'X-XSRF-TOKEN' })),
  ],
};

HttpClient bu token'ı yalnız değiştiren isteklere ve yalnız aynı origin'e ekler; GET'e ve başka bir domain'e eklemez. Sunucu tarafında bunu IAntiforgery ile eşleştirmen gerekir; CSRF yazısında ayrıntısı var. Token'ı localStorage'da tutma: OWASP'ın HTML5 cheat sheet'i de session tanımlayıcılarının orada saklanmamasını söylüyor, çünkü tek bir XSS hepsini okur. HttpOnly cookie ve gerekirse bir BFF (backend for frontend) daha sağlam bir başlangıç.

Uygulamayı sunan sunucuda CSP'ye nonce ya da hash tabanlı, sıkı bir policy ile başla. Angular 16'dan beri ngCspNonce ile nonce verebiliyorsun, Angular CLI 19'dan beri autoCsp seçeneği inline script'ler için build sırasında hash üretiyor. Trusted Types için Angular'ın policy adı angular; bypassSecurityTrust* kullanıyorsan angular#unsafe-bypass'a da izin vermen gerekir. Trusted Types, MDN'e göre Şubat 2026'dan beri bütün büyük tarayıcılarda var.

Güvenlik Testleri: SAST, Regresyon ve DAST

SAST: Derleyicinin Gözüyle

Static analysis (SAST), kodu çalıştırmadan okur. .NET SDK'nın içindeki analyzer'lar bunun en ucuz başlangıcı ve ek araç gerektirmez. Ama güvenlik kurallarını açman gerekiyor; bunu tek bir property yapıyor. Şu eski kodu düşün:

using System.Security.Cryptography;
using System.Text;
using Microsoft.Data.Sqlite;

public static class LegacyQueries
{
    public static string HashPassword(string password) =>
        Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(password)));

    public static SqliteCommand FindBooks(SqliteConnection connection, string title)
    {
        SqliteCommand command = connection.CreateCommand();
        command.CommandText = "SELECT id, title FROM books WHERE title = '" + title + "'";
        return command;
    }
}

Varsayılan ayarlarla bu proje sıfır uyarıyla derlendi. csproj'a iki satır ekleyelim:

<PropertyGroup>
  <TargetFramework>net10.0</TargetFramework>
  <AnalysisModeSecurity>All</AnalysisModeSecurity>
  <WarningsAsErrors>$(WarningsAsErrors);CA2100;CA5351</WarningsAsErrors>
</PropertyGroup>
$ dotnet build
Bookstore.Legacy/LegacyQueries.cs(8,29): error CA5351: HashPassword uses a broken cryptographic algorithm MD5 (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca5351)
Bookstore.Legacy/LegacyQueries.cs(13,9): error CA2100: Review if the query string passed to 'string SqliteCommand.CommandText' in 'FindBooks', accepts any user input (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2100)
Build FAILED.
    0 Warning(s)
    2 Error(s)

Sorguyu $title parametresiyle yazıp MD5'li method'u silince (parola için Identity'nin PasswordHasher'ı zaten var) build yeniden sıfır hatayla geçti. CA2100 "gözden geçir" der, SQL injection olduğunu kanıtlamaz; ama CI'da build'i kıran bir kural, incelemede gözden kaçan bir satırdan iyidir. Daha derin analiz için GitHub'ın CodeQL tabanlı code scanning'i gibi araçlar bunun üstüne eklenir.

Regresyon Testi: Kuralları Teste Bağlamak

Yukarıda curl ile gördüğümüz her şey bir sonraki değişiklikte bozulabilir. O yüzden en kritik kuralları test olarak yazıyoruz. Microsoft.AspNetCore.Mvc.Testing paketi (10.0.14) uygulamayı bellekte ayağa kaldırır:

using System.Net;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;

public class SecurityBaselineTests : IClassFixture<WebApplicationFactory<Program>>
{
    // anonim erişime açık her endpoint burada bilinçli olarak listelenmeli
    private static readonly string[] PublicEndpoints = ["GET /books", "GET /books/{id:int}", "POST /login"];

    private readonly WebApplicationFactory<Program> _factory;

    public SecurityBaselineTests(WebApplicationFactory<Program> factory) =>
        _factory = factory.WithWebHostBuilder(builder => builder.UseEnvironment("Production"));

    [Fact]
    public void Only_listed_endpoints_allow_anonymous_access()
    {
        EndpointDataSource source = _factory.Services.GetRequiredService<EndpointDataSource>();
        string[] anonymous = source.Endpoints
            .OfType<RouteEndpoint>()
            .Where(e => e.Metadata.GetMetadata<IAllowAnonymous>() is not null)
            .Select(e => $"{e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods[0]} {e.RoutePattern.RawText}")
            .ToArray();

        Assert.Empty(anonymous.Except(PublicEndpoints));
    }

    [Fact]
    public async Task Protected_endpoint_returns_401_for_anonymous_user()
    {
        HttpClient client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
        HttpResponseMessage response = await client.GetAsync("/orders/mine");
        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }

    [Fact]
    public async Task Error_response_keeps_security_headers_and_hides_details()
    {
        HttpClient client = _factory.CreateClient();
        HttpResponseMessage response = await client.GetAsync("/boom");
        string body = await response.Content.ReadAsStringAsync();

        Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
        Assert.Equal("nosniff", response.Headers.GetValues("X-Content-Type-Options").Single());
        Assert.DoesNotContain("Password", body);
    }
}
$ dotnet test
  Failed SecurityBaselineTests.Only_listed_endpoints_allow_anonymous_access [44 ms]
  Error Message:
   Assert.Empty() Failure: Collection was not empty
Collection: ["GET /boom"]
...
Failed!  - Failed:     1, Passed:     2, Skipped:     0, Total:     3, Duration: 273 ms - Bookstore.SecurityTests.dll (net10.0)

İlk test işini yaptı: /boom'u AllowAnonymous ile açmıştık ve kimse listeye yazmamıştı. Lab'da bu endpoint'i bilerek tutuyoruz, o yüzden listeye "GET /boom"'u bir yorumla ekleyince üç test de geçti. Gerçek projede bu test, "geçici olarak" anonim yapılıp unutulan endpoint'i pull request'te yakalar.

DAST: Çalışan Uygulamaya Dışarıdan Bakmak

Dynamic analysis (DAST), çalışan uygulamaya bir istemci gibi istek atıp yanıtlara bakar. En bilinen açık kaynak aracı ZAP; çoğu kaynakta hâlâ OWASP ZAP adıyla geçer ama 2023'te OWASP'tan ayrıldı ve bugün "ZAP by Checkmarx" adıyla geliştiriliyor. "Baseline" taraması uygulamayı gezip yanıtları pasif olarak inceler; eksik header'ları, cookie bayraklarını, bilgi sızdıran hata sayfalarını raporlar, aktif saldırı denemesi yapmaz. Bu yazıda ZAP'ı çalıştırmıyoruz. Çalıştıracaksan hedef yalnız kendi makinendeki uygulama ya da sahibi olduğun bir test ortamı olmalı; başkasının sistemini taramak, "pasif" bile olsa izin gerektirir. DAST, yapılandırma hatalarını (A02) yakalar ama iş mantığını bilmez.

Top 10'u Doğru Kullanmak ve ASVS'ye Geçiş

Top 10 bir standart değil, bir farkındalık belgesi. "Top 10 uyumlu" diye bir sertifika yok. Listeyi en iyi üç işte kullanırsın: ekibe ortak bir dil vermek, kod incelemesinde bakılacak yerleri hatırlatmak ve risk konuşurken öncelik sırası kurmak. Belgelerde ve issue'larda kategoriyi yılıyla yaz (A01:2025); numaralar her sürümde kayıyor, bunu 20. yazıda bir araçla göreceğiz.

"Neyi doğrulamalıyım?" sorusunun cevabı ise OWASP Application Security Verification Standard, yani ASVS. Güncel kararlı sürüm 30 Mayıs 2025'te yayımlanan 5.0.0. ASVS, kategoriler yerine doğrulanabilir gereksinimler sıralar ve bunları seviyelere ayırır; uygulamanın riskine göre bir seviye seçip o seviyenin gereksinimlerini kontrol edersin. Pratik yol: geçerli gereksinimleri seç, her birini bir teste ya da CI kuralına bağla, kalanını kod inceleme listesine yaz. "Nasıl yapılır" kısmı için OWASP Cheat Sheet Series'e git; serinin son yazısında bu projelere ayrıca bakacağız.

Sık Düşülen Tuzaklar

Birincisi, açılıştaki sorunun cevabı. İlk sürümde /admin/stats grupta değildi, üzerinde hiçbir şey yoktu ve Ada'nın cookie'siyle şu döndü:

$ curl -i -b ada.txt http://localhost:5171/admin/stats
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
...
{"books":2}

Fallback policy yalnız "giriş yapmış olsun" der; rolü senin yerine bilmez. Deny by default, authentication için bir güvenlik ağıdır; authorization kararını yine sen verirsin. Bu yüzden admin endpoint'leri policy'li bir grupta duruyor.

İkincisi, AddValidation'ın tipleri görmemesi. İlk denemede DTO'larım public değildi (record BookInput(...)) ve negatif fiyat sessizce kabul edildi: 201 Created, {"id":3,"title":"Dune","price":-5}. .NET 10'un validation source generator'ı bu denemede yalnız public tipler için doğrulama kodu üretti.

Üçüncüsü, middleware sırası. Yukarıda anlattığım gibi header'lar 401 ve 500 yanıtlarında kayboluyordu. Header'ları hata yanıtlarında da kontrol et; testteki üçüncü method bunu yapıyor.

Dördüncüsü, araçların yeşil ışığını güvenlik sanmak. Analyzer sıfır uyarı verdi, ZAP raporu temiz çıktı; bu, IDOR olmadığı anlamına gelmez.

Kontrol Listesi

Sunucu tarafında: fallback policy açık ve admin işlemleri policy'li gruplarda (A01). Production'da developer exception page yok, ProblemDetails var, Server header'ı kapalı, CSP, nosniff ve frame-ancestors her yanıtta, hata yanıtları dahil (A02). Bağımlılıklar NuGetAudit ve dotnet package list --vulnerable ile izleniyor, lock dosyaları commit'li (A03). Parolalar PasswordHasher ile, iterasyon sayısı OWASP önerisine çekilmiş; secret'lar kodda değil (A04). Sorgular parametreli, çıktılar encode edilmiş (A05). Giriş ve pahalı endpoint'lerde 429 dönen rate limiting var (A06, A07). Cookie'ler HttpOnly, Secure ve SameSite bayraklı (A07). Bilinmeyen JSON alanları reddediliyor, DTO'lar ayrı (A08). Başarısız girişler ve yetki retleri log'lanıyor, secret'lar log'a düşmüyor (A09). Global exception handler var ve hata durumunda sistem kapalı kalıyor (A10).

Angular tarafında: bypassSecurityTrust* yok ya da her biri gerekçeli; sıkı bir CSP ve Trusted Types; XSRF yapılandırılmış; token localStorage'da değil. Süreçte: güvenlik analyzer'ları CI'da hata seviyesinde, anonim endpoint listesi gibi kurallar testte, DAST yalnız kendi ortamına karşı ve yeni bir özellikte threat modeling sorusu soruluyor.

Küçük Bir Deneme

★☆☆ UseSecurityHeaders'a Permissions-Policy: camera=(), microphone=(), geolocation=() header'ını ekle. Sonra Error_response_keeps_security_headers_and_hides_details testine bu header için bir assert yaz ve önce header'ı silip testin kırıldığını gör.

★★☆ LoginInput'a [EmailAddress] ve [StringLength(128)] ekle. Record'u public olmaktan çıkarıp geçersiz bir e-postayla istek at, sonra yeniden public yap. Farkı gösteren bir integration test yaz.

★★★ Cookie ile korunan POST /admin/books için antiforgery ekle: AddAntiforgery ile header adını X-XSRF-TOKEN yap, token'ı XSRF-TOKEN cookie'siyle veren bir endpoint yaz ve token'sız isteğin reddedildiğini bir testle kanıtla. JSON body'li minimal API endpoint'lerinde doğrulamanın otomatik olmadığını unutma; IAntiforgery.ValidateRequestAsync'i kendin çağırman gerekecek.

Kemik yapı burada bitiyor. Bir sonraki yazıda zamanda geriye gidiyoruz: OWASP Top 10:2017 listesine, o yılların web'ine ve XXE ile insecure deserialization gibi bugün hâlâ geçerli derslere bakacağız.