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

OWASP A01: Broken Access Control (1) — IDOR, Admin Endpoint'leri ve ASP.NET Core'da Yetkilendirme

Mert Özen 6 Kas 2026 11 dk 4 görüntülenme
OWASP A01: Broken Access Control (1) — IDOR, Admin Endpoint'leri ve ASP.NET Core'da Yetkilendirme

Giriş yapmış Alan, sipariş numarasını değiştirerek Grace'in siparişini okuyor ve rol kontrolü unutulmuş admin endpoint'inden bütün kullanıcıları alıyor. İki açığı lab'da gösterip policy'ler, IAuthorizationService ile resource-based authorization ve fallback policy ile deny by default yaklaşımıyla kapatıyor, 403 ile 404 arasındaki seçimi ve .NET 10'un cookie yönlendirme değişikliğini 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

Lab'a giriş ekledik ve sipariş endpoint'ine .RequireAuthorization() yazdık. Alan giriş yaptı; kendi siparişinin numarası 1. Alan aynı cookie'yle /orders/2 adresine istek atarsa ne döner? Peki yalnız admin için yazılmış /admin/users adresine? İki cevabı da kafanda netleştir, sonra devam et.

Bu yazı OWASP Top 10:2025'in birinci kategorisi A01:2025 Broken Access Control'ün (bozuk erişim kontrolü) ilk yarısı. OWASP bu kategoriyi, kullanıcının yetkisi dışındaki veriye ya da işleve ulaşabilmesi olarak tanımlıyor ve 40 CWE'yi altında topluyor. Bugün ikisini göreceğiz: başka bir kullanıcının kaydına ulaşmak (IDOR) ve rol kontrolü unutulmuş bir yönetim endpoint'i. Path traversal, CORS ve SSRF bir sonraki yazının konusu.

Lab'a Giriş Eklemek

Bir önceki yazıdaki Bookstore API'den devam ediyoruz. users tablosuna bir password kolonu ekledim. Parolalar düz metin duruyor ve bu bilerek yapılmış bir lab kısayolu; parolanın nasıl saklanması gerektiği A04 Cryptographic Failures yazısının konusu. Tablo yapısı değiştiği için eski bookstore.db dosyasını silmeyi unutma:

            CREATE TABLE IF NOT EXISTS users (
                id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE,
                email TEXT NOT NULL, role TEXT NOT NULL, password TEXT NOT NULL);
...
            INSERT OR IGNORE INTO users VALUES
                (1, 'ada', 'ada@bookstore.test', 'admin', 'dev-only-ada'),
                (2, 'alan', 'alan@bookstore.test', 'customer', 'dev-only-alan'),
                (3, 'grace', 'grace@bookstore.test', 'customer', 'dev-only-grace');

BookstoreDb class'ına üç method ve üç record ekliyoruz. Hepsi bir önceki yazıdaki kalıpta, parameter'lı sorgularla:

    public async Task<User?> FindUserAsync(string username, string password)
    {
        await using SqliteConnection connection = Open();
        await using SqliteCommand command = connection.CreateCommand();
        command.CommandText = "SELECT id, username, email, role FROM users WHERE username = $username AND password = $password";
        command.Parameters.AddWithValue("$username", username);
        command.Parameters.AddWithValue("$password", password);
        await using SqliteDataReader reader = await command.ExecuteReaderAsync();
        return await reader.ReadAsync()
            ? new User(reader.GetInt32(0), reader.GetString(1), reader.GetString(2), reader.GetString(3))
            : null;
    }

    public async Task<List<User>> GetUsersAsync()
    {
        await using SqliteConnection connection = Open();
        await using SqliteCommand command = connection.CreateCommand();
        command.CommandText = "SELECT id, username, email, role FROM users ORDER BY id";
        await using SqliteDataReader reader = await command.ExecuteReaderAsync();
        List<User> users = [];
        while (await reader.ReadAsync())
        {
            users.Add(new User(reader.GetInt32(0), reader.GetString(1), reader.GetString(2), reader.GetString(3)));
        }
        return users;
    }

    public async Task<Order?> GetOrderAsync(int id)
    {
        await using SqliteConnection connection = Open();
        await using SqliteCommand command = connection.CreateCommand();
        command.CommandText = "SELECT id, user_id, book_id, quantity FROM orders WHERE id = $id";
        command.Parameters.AddWithValue("$id", id);
        await using SqliteDataReader reader = await command.ExecuteReaderAsync();
        return await reader.ReadAsync()
            ? new Order(reader.GetInt32(0), reader.GetInt32(1), reader.GetInt32(2), reader.GetInt32(3))
            : null;
    }

public record User(int Id, string Username, string Email, string Role);
public record Order(int Id, int UserId, int BookId, int Quantity);
public record LoginRequest(string Username, string Password);

Program.cs'i bu yazının konusuna göre sadeleştirdim. Kimlik doğrulama için ASP.NET Core'un yerleşik cookie authentication'ını kullanıyoruz; ek paket gerekmiyor. Login başarılıysa kullanıcının id'si, adı ve rolü claim olarak şifreli bir cookie'ye yazılıyor. İlk hâli, yani açıkları olan hâli şöyle:

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;

var builder = WebApplication.CreateBuilder(args);

string connectionString = builder.Configuration.GetConnectionString("Bookstore")
    ?? "Data Source=bookstore.db";
builder.Services.AddSingleton(new BookstoreDb(connectionString));

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options => options.Cookie.Name = "bookstore.auth");
builder.Services.AddAuthorization();

var app = builder.Build();

app.Services.GetRequiredService<BookstoreDb>().Initialize();

app.MapPost("/login", async (LoginRequest request, BookstoreDb db, HttpContext context) =>
{
    User? user = await db.FindUserAsync(request.Username, request.Password);
    if (user is null)
    {
        return Results.Unauthorized();
    }

    List<Claim> claims =
    [
        new(ClaimTypes.NameIdentifier, user.Id.ToString()),
        new(ClaimTypes.Name, user.Username),
        new(ClaimTypes.Role, user.Role)
    ];
    ClaimsPrincipal principal = new(new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme));
    await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
    return Results.NoContent();
});

app.MapGet("/books", async (BookstoreDb db) => await db.GetBooksAsync());

// Giriş yapmış olmak yeterli sayılıyor: hangi sipariş olduğu kontrol edilmiyor
app.MapGet("/orders/{id:int}", async (int id, BookstoreDb db) =>
    await db.GetOrderAsync(id) is Order order ? Results.Ok(order) : Results.NotFound())
    .RequireAuthorization();

// Admin ekranı için yazıldı ama rol kontrolü yok
app.MapGet("/admin/users", async (BookstoreDb db) => await db.GetUsersAsync())
    .RequireAuthorization();

app.Run();

app.UseAuthentication() ve app.UseAuthorization() satırları yok, çünkü WebApplication bu service'ler kayıtlıysa iki middleware'i kendisi ekliyor. Uygulamayı http profiliyle 5104 port'unda çalıştırıp Alan olarak giriş yapalım. -c cookie'yi bir dosyaya kaydeder, sonraki isteklerde -b onu geri gönderir:

$ curl -i -c alan.txt -X POST http://localhost:5104/login -H "Content-Type: application/json" -d '{"username":"alan","password":"dev-only-alan"}'
HTTP/1.1 204 No Content
Date: Fri, 06 Nov 2026 19:08:01 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Pragma: no-cache
Set-Cookie: bookstore.auth=CfDJ8EINq_wm4TFCv7guzy6SHCeyv9mL8WCX...; path=/; samesite=lax; httponly

Cookie'nin değeri Data Protection API ile şifrelenmiş bir bilet; istemci içini okuyamaz, değiştirirse sunucu kabul etmez. Varsayılan bayraklar samesite=lax ve httponly. secure yok, çünkü varsayılan SecurePolicy isteğin kendisine bakıyor ve biz düz HTTP kullanıyoruz; Production'da bunu CookieSecurePolicy.Always yapmak gerekir.

IDOR: Başkasının Siparişini Okumak

Alan önce kendi siparişini, sonra numarayı bir artırıp Grace'inkini istiyor:

$ curl -i -b alan.txt http://localhost:5104/orders/1
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:08:01 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"id":1,"userId":2,"bookId":1,"quantity":1}

$ curl -i -b alan.txt http://localhost:5104/orders/2
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:08:01 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"id":2,"userId":3,"bookId":2,"quantity":2}

userId 3, yani Grace. Bu açığın adı IDOR (Insecure Direct Object Reference): istemcinin gönderdiği bir key, sahiplik kontrolü yapılmadan doğrudan veritabanı kaydına bağlanıyor. API dünyasında aynı soruna BOLA (Broken Object Level Authorization) da deniyor. CWE karşılığı CWE-639, kullanıcının kontrol ettiği key ile yetkilendirmenin atlatılması.

Buradaki yanılgı, .RequireAuthorization()'ın yetkilendirme yaptığını sanmak. O satır yalnız "giriş yapmış biri mi?" diye sorar; bu kimlik doğrulamanın sonucudur. "Bu kişi bu siparişi görebilir mi?" sorusunu ise kimse sormuyor. İki soru farklı ve ikincisini framework senin yerine soramaz, çünkü siparişin kime ait olduğunu yalnız senin veri modelin biliyor.

Eksik Function-Level Kontrol: Admin Endpoint'i

Aynı cookie'yle yönetim endpoint'ine gidelim:

$ curl -i -b alan.txt http://localhost:5104/admin/users
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:08:01 GMT
Server: Kestrel
Transfer-Encoding: chunked

[{"id":1,"username":"ada","email":"ada@bookstore.test","role":"admin"},{"id":2,"username":"alan","email":"alan@bookstore.test","role":"customer"},{"id":3,"username":"grace","email":"grace@bookstore.test","role":"customer"}]

Sıradan bir müşteri bütün kullanıcıların e-posta adreslerini ve rollerini aldı. Bu, IDOR'un kardeşi: kayıt düzeyinde değil, işlev (function) düzeyinde eksik kontrol. Genelde şöyle olur: Angular tarafında admin menüsü yalnız admin'e gösterilir, ekip de endpoint'in "zaten görünmediğini" düşünür. Oysa adres bir JavaScript bundle'ında, bir log'da ya da tahminle kolayca bulunur. /admin ön ekli bir adres saldırganın ilk deneyeceği şeylerden biri.

Policy ile Rol Kontrolü

ASP.NET Core'da yetkilendirme kuralları policy olarak tanımlanır. Rol kontrolü için tek satır yeter:

builder.Services.AddAuthorizationBuilder()
    .AddPolicy("AdminOnly", policy => policy.RequireRole("admin"));

app.MapGet("/admin/users", async (BookstoreDb db) => await db.GetUsersAsync())
    .RequireAuthorization("AdminOnly");

RequireRole, login sırasında yazdığımız ClaimTypes.Role claim'ine bakıyor. Alan ve Ada ile deneyelim (ada.txt, Ada'nın girişinden kalan cookie dosyası):

$ curl -i -b alan.txt http://localhost:5104/admin/users
HTTP/1.1 403 Forbidden
Content-Length: 0
Date: Fri, 06 Nov 2026 19:08:50 GMT
Server: Kestrel

$ curl -i -b ada.txt http://localhost:5104/admin/users
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:08:50 GMT
Server: Kestrel
Transfer-Encoding: chunked

[{"id":1,"username":"ada","email":"ada@bookstore.test","role":"admin"},{"id":2,"username":"alan","email":"alan@bookstore.test","role":"customer"},{"id":3,"username":"grace","email":"grace@bookstore.test","role":"customer"}]

Policy'ye bir ad vermenin faydası, kuralın tek yerde yaşaması. Yarın "admin ya da support" demek istediğinde yüz endpoint'i değil, bir policy'yi değiştirirsin. Rol yerine bir claim'e ya da kendi requirement'ına bakan policy'ler de aynı şekilde yazılır.

Resource-Based Authorization

Sipariş için rol yetmez: Alan da Grace de müşteri. Karar, kaydın kendisine bakarak verilmeli. Bunun için ASP.NET Core'un resource-based authorization modelini kullanıyoruz: bir requirement ve o requirement'ı belirli bir kaynak tipi için değerlendiren bir handler.

using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;

public sealed class OrderOwnerRequirement : IAuthorizationRequirement;

public sealed class OrderOwnerHandler : AuthorizationHandler<OrderOwnerRequirement, Order>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, OrderOwnerRequirement requirement, Order resource)
    {
        string? userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
        if (context.User.IsInRole("admin") || resource.UserId.ToString() == userId)
        {
            context.Succeed(requirement);
        }
        return Task.CompletedTask;
    }
}

Handler'ı builder.Services.AddSingleton<IAuthorizationHandler, OrderOwnerHandler>() ile kaydediyoruz. Endpoint önce kaydı okur, sonra IAuthorizationService'e "bu kullanıcı bu sipariş için bu requirement'ı karşılıyor mu?" diye sorar:

app.MapGet("/orders/{id:int}", async (int id, BookstoreDb db, ClaimsPrincipal user,
    IAuthorizationService authorization) =>
{
    Order? order = await db.GetOrderAsync(id);
    if (order is null)
    {
        return Results.NotFound();
    }

    AuthorizationResult result = await authorization.AuthorizeAsync(user, order, new OrderOwnerRequirement());
    // Başkasının siparişine 403 değil 404: kaydın var olduğunu da söyleme
    return result.Succeeded ? Results.Ok(order) : Results.NotFound();
});
$ curl -i -b alan.txt http://localhost:5104/orders/2
HTTP/1.1 404 Not Found
Content-Length: 0
Date: Fri, 06 Nov 2026 19:08:50 GMT
Server: Kestrel

$ curl -i -b alan.txt http://localhost:5104/orders/99
HTTP/1.1 404 Not Found
Content-Length: 0
Date: Fri, 06 Nov 2026 19:08:50 GMT
Server: Kestrel

$ curl -i -b ada.txt http://localhost:5104/orders/2
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:08:50 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"id":2,"userId":3,"bookId":2,"quantity":2}

Alan için Grace'in siparişi ile hiç var olmayan 99 numaralı sipariş artık ayırt edilemiyor. 403 dönseydik "bu numarada bir sipariş var ama senin değil" demiş olurduk; numaraları tek tek deneyen biri hangi kayıtların var olduğunu çıkarabilirdi. 403 yanlış değil, ama kaydın varlığı da hassassa 404 daha az şey söyler. Bu kararı her kaynak için bilinçli ver ve API boyunca tutarlı uygula. Ada admin olduğu için handler onu geçiriyor.

Daha basit bir yol da var: sorguya sahiplik koşulunu eklemek, yani WHERE id = $id AND user_id = $userId. Liste endpoint'lerinde bu zaten zorunlu. Handler'ın avantajı, kuralın (sahibi ya da admin) tek bir yerde, test edilebilir bir class'ta durması.

Deny by Default: Fallback Policy

Minimal API'de bir endpoint'e hiçbir şey yazmazsan o endpoint anonimdir. Yani güvenlik, her endpoint'e .RequireAuthorization() yazmayı hatırlamaya kalıyor. OWASP'ın önerisi tersini yapmak: varsayılan "hayır" olsun, herkese açık olacak endpoint'leri tek tek işaretle. ASP.NET Core'da bunun adı fallback policy:

builder.Services.AddAuthorizationBuilder()
    .SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build())
    .AddPolicy("AdminOnly", policy => policy.RequireRole("admin"));

Fallback policy, kendi yetkilendirme metadata'sı olmayan her endpoint'e uygulanır. Login ve kitap listesi herkese açık kalmalı; onlara .AllowAnonymous() ekliyoruz. Kontrolü denemek için hiçbir şey işaretlemeden küçük bir endpoint yazdım:

app.MapGet("/me", (ClaimsPrincipal user) => new
{
    id = user.FindFirstValue(ClaimTypes.NameIdentifier),
    name = user.Identity?.Name
});
$ curl -i http://localhost:5104/me
HTTP/1.1 401 Unauthorized
Content-Length: 0
Date: Fri, 06 Nov 2026 19:08:50 GMT
Server: Kestrel

$ curl -i -b alan.txt http://localhost:5104/me
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:08:50 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"id":"2","name":"alan"}

Unutulan endpoint artık kapalı başlıyor. Unutursan en kötü ihtimalle bir kullanıcı "neden giriş istiyor?" diye şikâyet eder; tersinde ise veri sızar.

Bir ayrıntı daha: cookie authentication bir web sitesi için tasarlandığından, giriş yapmamış kullanıcıyı /Account/Login sayfasına yönlendirmeye çalışır. .NET 10 bunu API endpoint'leri için değiştirdi: JSON okuyup yazan minimal API'ler gibi bilinen API endpoint'lerinde yönlendirme yerine 401 ve 403 dönüyor. Ama bu ayrımı framework endpoint metadata'sına bakarak yapıyor ve ilk hâldeki iki endpoint farklı davrandı. Doğrudan List<User> dönen /admin/users 401 döndü, IResult dönen /orders/{id} ise login sayfasına yönlendirdi:

$ curl -i http://localhost:5104/orders/2
HTTP/1.1 302 Found
Content-Length: 0
Date: Fri, 06 Nov 2026 19:08:01 GMT
Server: Kestrel
Location: http://localhost:5104/Account/Login?ReturnUrl=%2Forders%2F2

Var olmayan bir sayfaya 302, bir API istemcisi için anlamsız. Lab'da login sayfası olmadığı için iki event'i ezip her durumda düz 401 ve 403 dönüyoruz:

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.Cookie.Name = "bookstore.auth";
        // API'de login sayfası yok: yönlendirme yerine 401 ve 403 dön
        options.Events.OnRedirectToLogin = context =>
        {
            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
            return Task.CompletedTask;
        };
        options.Events.OnRedirectToAccessDenied = context =>
        {
            context.Response.StatusCode = StatusCodes.Status403Forbidden;
            return Task.CompletedTask;
        };
    });

Bu yazıdaki düzeltilmiş çıktıların hepsi bu ayarla alındı. Policy'lerin ve rollerin ayrıntısını merak edersen ASP.NET Core Web API serisindeki authorization yazısına da bakabilirsin; orada "nasıl", burada "neden" üzerinde duruyoruz.

Sık Düşülen Tuzaklar

İlk tuzak, kimlik doğrulama ile yetkilendirmeyi aynı şey sanmak. .RequireAuthorization() ya da [Authorize] görünce endpoint'in korunduğunu düşünmek kolay; ama parametresiz hâli yalnız "giriş yapmış mı?" diye sorar.

İkincisi, kullanıcı kimliğini istemciden almak. /users/2/orders gibi bir route'ta ya da body'deki userId alanında gelen değere güvenmek IDOR'un ta kendisi. Kullanıcının kim olduğunu her zaman sunucunun doğruladığı claim'den oku; handler'da yaptığımız gibi.

Üçüncüsü, yalnız okuma endpoint'ini korumak. GET düzeltildiğinde PUT, DELETE ve liste endpoint'leri çoğu zaman unutulur. Aynı kaynağa dokunan her endpoint aynı kontrolden geçmeli.

Dördüncüsü, gizliliği frontend'e bırakmak. Menüyü gizlemek, butonu disable etmek ya da route guard yazmak kullanıcı deneyimidir. Kural sunucuda yoksa yoktur.

Kontrol Listesi

Fallback policy açık ve herkese açık endpoint'ler .AllowAnonymous() ile bilinçli olarak işaretlenmiş mi? Yönetim işlevleri isimlendirilmiş bir policy ile korunuyor mu? Kullanıcıya ait bir kayda dokunan her endpoint, kaydın sahibini sunucudaki claim'le karşılaştırıyor mu; liste sorgularında sahiplik koşulu WHERE'de mi? Başkasının kaydı için 403 mü, 404 mü döneceğine karar verdin ve bunu her yerde aynı uyguluyor musun? Bir API'de giriş yapmamış istek login sayfasına değil, 401'e düşüyor mu?

Bunları elle kontrol etmek yerine bir integration testine dönüştürmek en sağlamı: Alan'ın cookie'siyle Grace'in her kaydına istek atıp 404 beklemek, birkaç satırlık bir testtir ve bir daha bu hatayı sessizce geri getirmeni engeller.

Küçük Bir Deneme

★☆☆ Lab'ı ilk hâliyle çalıştır, Alan olarak giriş yap ve /orders/3 adresine istek at. Kimin siparişi? Sonra düzeltilmiş hâle geçip aynı isteği tekrarla.

★★☆ GET /orders adında, giriş yapan kullanıcının bütün siparişlerini dönen bir endpoint yaz. Sahiplik koşulunu bu kez handler'la değil, doğrudan SQL'deki WHERE ile uygula ve Ada için bütün siparişleri dönmesini sağla.

★★★ DELETE /orders/{id} endpoint'i ekle ve aynı OrderOwnerRequirement'ı kullan. Sonra kuralı değiştir: admin her siparişi görebilsin ama yalnız sahibi silebilsin. Bunun için ayrı bir requirement mı, aynı handler'da operasyon adı mı kullanırdın?

Bir sonraki yazıda A01'in ikinci yarısına geçiyoruz: dosya indiren bir endpoint'te path traversal, yanlış açılmış bir CORS policy'si ve 2025'te bu kategoriye katılan SSRF. URL'den kapak resmi çeken bir endpoint'in neden iç ağa açılan bir kapıya dönüşebildiğini konuşacak, sonra allowlist, yönlendirme ve IP kontrolüyle kapatacağız.