Güvenlik Laboratuvarı: ASP.NET Core 10 ile Bookstore API ve HTTP'yi curl ile Okumak
Serinin geri kalanında kullanacağımız lab'ı kuruyoruz: SQLite kullanan bir ASP.NET Core 10 minimal API. curl -i ile method, status code, header ve cookie'leri güvenlik gözüyle okuyor, aynı hatanın Development ve Production'da istemciye nasıl farklı döndüğünü, HTTPS geliştirme sertifikasını ve lab kurallarını görüyoruz.
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
Bir endpoint'in içinde yakalanmamış bir NullReferenceException fırlıyor. Kodda tek bir satır değiştirmeden uygulamayı bir kez Development, bir kez Production ortamında çalıştırıyorsun. İki durumda istemciye dönen response aynı mı olur? Farklıysa hangisi saldırgan için daha değerli? Cevabı bu yazının ortasında, kendi lab uygulamamızda göreceğiz.
Bu yazının amacı serinin geri kalanında kullanacağımız lab'ı kurmak: SQLite kullanan küçük bir ASP.NET Core 10 minimal API, adı Bookstore API. Kurarken de bir güvenlikçinin HTTP'ye nasıl baktığını, yani method, status code, header ve cookie'leri nasıl okuduğunu curl -i ile çalışacağız.
Lab Kuralları
Kurala koddan önce başlıyorum, çünkü serinin geri kalanı buna dayanıyor. Bu seride gösterilen her saldırı yalnız kendi bilgisayarında, localhost üzerinde çalışan bu lab uygulamasına karşı yapılır. Bu teknikleri yalnız sahibi olduğun ya da yazılı izin aldığın sistemlerde dene. İşyerindeki test ortamı da bu kapsama girmez; orası için de yazılı izin gerekir.
Lab'ı ayrı bir klasörde tut, gerçek projelerinin yanına koyma. Örneklerdeki parola, anahtar ve connection string'ler sahte ve açıkça öyle görünüyor: dev-only- ile başlıyor. Gerçek bir secret'ı lab'a kopyalama; lab kodunu bir repo'ya push edersen o secret da gider. Veritabanı tek bir bookstore.db dosyası. Bir şeyi bozduğunda dosyayı silip uygulamayı yeniden başlatman yeterli; tablolar ve örnek veri baştan oluşur. Bu dosyayı .gitignore'a eklemeyi de unutma.
Bookstore API'yi Kurmak
Boş bir web projesi açıp SQLite için Microsoft'un ADO.NET provider'ını ekliyoruz. EF Core'u bilerek kullanmıyorum; SQL'i açıkça görmek, injection yazısında işimize yarayacak:
$ dotnet new web -n Bookstore.Api
$ cd Bookstore.Api
$ dotnet add package Microsoft.Data.Sqlite
Komut paketin o günkü en güncel sürümünü ekler; bu yazıda csproj'a şu satır geldi:
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.14" />
Veritabanı işini tek bir class'a topluyoruz. Initialize dört tabloyu oluşturur ve örnek veriyi ekler: üç kullanıcı (admin Ada, müşteriler Alan ve Grace), üç kitap, üç sipariş ve iki yorum. INSERT OR IGNORE sayesinde uygulamayı tekrar başlatmak veriyi çoğaltmaz:
using Microsoft.Data.Sqlite;
public sealed class BookstoreDb(string connectionString)
{
public SqliteConnection Open()
{
SqliteConnection connection = new(connectionString);
connection.Open();
return connection;
}
public void Initialize()
{
using SqliteConnection connection = Open();
using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL, role TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY, title TEXT NOT NULL,
author TEXT NOT NULL, price REAL NOT NULL);
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id),
book_id INTEGER NOT NULL REFERENCES books(id), quantity INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS reviews (
id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL REFERENCES books(id),
user_id INTEGER NOT NULL REFERENCES users(id), body TEXT NOT NULL);
INSERT OR IGNORE INTO users VALUES
(1, 'ada', 'ada@bookstore.test', 'admin'),
(2, 'alan', 'alan@bookstore.test', 'customer'),
(3, 'grace', 'grace@bookstore.test', 'customer');
INSERT OR IGNORE INTO books VALUES
(1, 'Clean Code', 'Robert C. Martin', 34.90),
(2, 'The Pragmatic Programmer', 'Andrew Hunt, David Thomas', 39.50),
(3, 'Designing Data-Intensive Applications', 'Martin Kleppmann', 45.00);
INSERT OR IGNORE INTO orders VALUES
(1, 2, 1, 1),
(2, 3, 2, 2),
(3, 1, 3, 1);
INSERT OR IGNORE INTO reviews VALUES
(1, 1, 2, 'Still relevant.'),
(2, 2, 3, 'Read it twice.');
""";
command.ExecuteNonQuery();
}
public async Task<List<Book>> GetBooksAsync()
{
await using SqliteConnection connection = Open();
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT id, title, author, price FROM books ORDER BY id";
await using SqliteDataReader reader = await command.ExecuteReaderAsync();
List<Book> books = [];
while (await reader.ReadAsync())
{
books.Add(new Book(reader.GetInt32(0), reader.GetString(1), reader.GetString(2), reader.GetDecimal(3)));
}
return books;
}
public async Task<Book?> GetBookAsync(int id)
{
await using SqliteConnection connection = Open();
await using SqliteCommand command = connection.CreateCommand();
// Değer SQL metnine gömülmez, parameter olarak gider
command.CommandText = "SELECT id, title, author, price FROM books WHERE id = $id";
command.Parameters.AddWithValue("$id", id);
await using SqliteDataReader reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync()
? new Book(reader.GetInt32(0), reader.GetString(1), reader.GetString(2), reader.GetDecimal(3))
: null;
}
public async Task<Review?> GetLatestReviewAsync(int bookId)
{
await using SqliteConnection connection = Open();
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT id, book_id, body FROM reviews WHERE book_id = $bookId ORDER BY id DESC LIMIT 1";
command.Parameters.AddWithValue("$bookId", bookId);
await using SqliteDataReader reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync()
? new Review(reader.GetInt32(0), reader.GetInt32(1), reader.GetString(2))
: null;
}
}
public record Book(int Id, string Title, string Author, decimal Price);
public record Review(int Id, int BookId, string Body);
public record Preferences(string Theme);
Sorgularda değeri SQL metnine yapıştırmak yerine $id gibi parameter'lar kullanıyoruz. Lab'ın geri kalanı bu alışkanlıkla yazıldı; string birleştirmenin neye mal olduğunu A05 Injection yazısında bilerek bozarak göreceğiz. Program.cs ise şöyle:
var builder = WebApplication.CreateBuilder(args);
string connectionString = builder.Configuration.GetConnectionString("Bookstore")
?? "Data Source=bookstore.db";
builder.Services.AddSingleton(new BookstoreDb(connectionString));
var app = builder.Build();
app.Services.GetRequiredService<BookstoreDb>().Initialize();
app.UseHttpsRedirection();
app.MapGet("/books", async (BookstoreDb db) => await db.GetBooksAsync());
app.MapGet("/books/{id:int}", async (int id, BookstoreDb db) =>
await db.GetBookAsync(id) is Book book ? Results.Ok(book) : Results.NotFound());
// Bilerek bırakılmış hata: henüz yorumu olmayan kitapta patlar
app.MapGet("/books/{id:int}/summary", async (int id, BookstoreDb db) =>
{
Review? latest = await db.GetLatestReviewAsync(id);
return Results.Ok(new { id, latestReview = latest!.Body });
});
app.MapPost("/preferences", (Preferences preferences, HttpResponse response) =>
{
response.Cookies.Append("theme", preferences.Theme);
return Results.NoContent();
});
app.Run();
Port'ları sabitlemek için Properties/launchSettings.json içindeki applicationUrl değerlerini değiştirdim: http profili http://localhost:5102, https profili https://localhost:5103;http://localhost:5102. Tarayıcının kendiliğinden açılmaması için launchBrowser da false. Uygulamayı HTTPS profiliyle başlatalım:
$ dotnet run --launch-profile https
Using launch settings from /.../Bookstore.Api/Properties/launchSettings.json...
info: Microsoft.Hosting.Lifetime[14]
Now listening on: https://localhost:5103
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5102
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path: /.../Bookstore.Api
Dikkat etmen gereken satır Hosting environment: Development. Launch profile, ASPNETCORE_ENVIRONMENT değişkenini Development yapıyor; birazdan bunun neleri değiştirdiğini göreceğiz.
HTTP'yi Güvenlik Gözüyle Okumak: Method ve Status Code
Tarayıcı response'un yalnız gövdesini gösterir. Güvenlikte ise asıl hikâye çoğu zaman status satırında ve header'larda. curl -i ikisini de basar:
$ curl -i https://localhost:5103/books
HTTP/2 200
content-type: application/json; charset=utf-8
date: Fri, 06 Nov 2026 19:02:38 GMT
server: Kestrel
[{"id":1,"title":"Clean Code","author":"Robert C. Martin","price":34.9},{"id":2,"title":"The Pragmatic Programmer","author":"Andrew Hunt, David Thomas","price":39.5},{"id":3,"title":"Designing Data-Intensive Applications","author":"Martin Kleppmann","price":45.0}]
Bağlantı HTTPS üzerinden HTTP/2 ile kuruldu; HTTP/2'de header adları küçük harfle gelir. Şimdi olmayan bir kitap isteyelim ve izin verilmeyen bir method deneyelim:
$ curl -i https://localhost:5103/books/99
HTTP/2 404
date: Fri, 06 Nov 2026 19:02:38 GMT
server: Kestrel
content-length: 0
$ curl -i -X DELETE https://localhost:5103/books/1
HTTP/2 405
date: Fri, 06 Nov 2026 19:02:38 GMT
server: Kestrel
allow: GET
content-length: 0
Status code'lar bir saldırgan için de bilgi taşır. 404 "böyle bir kayıt yok", 405 ise "bu adres var ama bu method'la değil" der; allow: GET header'ı hangi method'un kabul edildiğini de söylüyor. Bunlar sorun değil, HTTP'nin doğal davranışı. Ama yetkilendirmede bu farklar önem kazanacak: başka bir kullanıcının siparişine 403 mü, 404 mü döneceğine bir sonraki yazıda bilinçli karar vereceğiz. Yanlış Content-Type ile gelen gövdeyi de minimal API kendisi reddeder:
$ curl -i -X POST https://localhost:5103/preferences -d 'theme=dark'
HTTP/2 415
date: Fri, 06 Nov 2026 19:03:42 GMT
server: Kestrel
content-length: 0
curl -d varsayılan olarak form verisi gönderir; endpoint JSON beklediği için cevap 415 Unsupported Media Type.
Header ve Cookie
Her response'ta bir header göze çarpıyor: server: Kestrel. Uygulamanın hangi web sunucusunda çalıştığını herkese söylüyor. Tek başına bir zafiyet değil ama saldırı yüzeyini anlatan bedava bir bilgi; A02 Security Misconfiguration yazısında kapatacağız. Şimdi cookie üreten endpoint'e bakalım:
$ curl -i -X POST https://localhost:5103/preferences -H "Content-Type: application/json" -d '{"theme":"dark"}'
HTTP/2 204
date: Fri, 06 Nov 2026 19:02:38 GMT
server: Kestrel
set-cookie: theme=dark; path=/
Cookies.Append'e seçenek vermezsen cookie yalnız path=/ ile gelir. Bu cookie'yi sayfadaki her JavaScript document.cookie ile okuyabilir, tarayıcı da onu düz HTTP isteklerinde ve başka sitelerden tetiklenen bazı isteklerde gönderebilir. Bir tema tercihi için bunun önemi yok; ama aynı satırı session ya da token taşıyan bir cookie için yazarsan durum değişir. Alışkanlığı baştan doğru kuralım:
app.MapPost("/preferences", (Preferences preferences, HttpResponse response) =>
{
response.Cookies.Append("theme", preferences.Theme, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
MaxAge = TimeSpan.FromDays(30)
});
return Results.NoContent();
});
$ curl -i -X POST https://localhost:5103/preferences -H "Content-Type: application/json" -d '{"theme":"dark"}'
HTTP/2 204
date: Fri, 06 Nov 2026 19:03:42 GMT
server: Kestrel
set-cookie: theme=dark; max-age=2592000; path=/; secure; samesite=strict; httponly
httponly cookie'yi JavaScript'ten gizler, secure yalnız HTTPS üzerinden gönderilmesini sağlar, samesite=strict başka bir siteden başlayan isteklerde cookie'nin eklenmesini engeller. Bu üç bayrağın ayrıntısı ve tarayıcıların varsayılanları A07 Authentication Failures yazısının konusu.
Development ve Production
Açılıştaki soruya geldik. /books/3/summary endpoint'i, henüz yorumu olmayan üçüncü kitapta null bir yorumun Body'sine erişmeye çalışıyor. Development'ta:
$ curl -i https://localhost:5103/books/3/summary
HTTP/2 500
content-type: text/plain; charset=utf-8
date: Fri, 06 Nov 2026 19:02:39 GMT
server: Kestrel
System.NullReferenceException: Object reference not set to an instance of an object.
at Program.<>c.<<<Main>$>b__0_2>d.MoveNext() in /.../Bookstore.Api/Program.cs:line 22
--- End of stack trace from previous location ---
at Microsoft.AspNetCore.Http.RequestDelegateFactory.ExecuteTaskResult[T](Task`1 task, HttpContext httpContext)
at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
HEADERS
=======
Accept: */*
Host: localhost:5103
User-Agent: curl/8.7.1
Exception tipi, dosyanın sunucudaki tam yolu, satır numarası, framework'ün iç yapısı ve isteğin header'ları. Bunu üreten developer exception page; WebApplication onu yalnız Development ortamında kendiliğinden açar. Geliştirirken çok değerli, çünkü hatayı anında görürsün. Aynı bilgi bir saldırgan için de değerli: hangi framework'ü, hangi dosya yapısını kullandığını ve kodun nerede kırıldığını öğrenir. Şimdi aynı kodu Production ortamında çalıştıralım. -e seçeneği, launch profile'daki değişkeni ezer:
$ dotnet run --launch-profile https -e ASPNETCORE_ENVIRONMENT=Production
...
Hosting environment: Production
...
$ curl -i https://localhost:5103/books/3/summary
HTTP/2 500
date: Fri, 06 Nov 2026 19:03:08 GMT
server: Kestrel
content-length: 0
Gövde boş. İstemci yalnız "sunucuda bir hata oldu" bilgisini aldı. Ayrıntı kaybolmadı, olması gereken yere, sunucunun log'una gitti:
fail: Microsoft.AspNetCore.Server.Kestrel[13]
Connection id "0HNPS72C9G1FB", Request id "0HNPS72C9G1FB:00000001": An unhandled exception was thrown by the application.
System.NullReferenceException: Object reference not set to an instance of an object.
at Program.<>c.<<<Main>$>b__0_2>d.MoveNext() in /.../Bookstore.Api/Program.cs:line 22
Açılıştaki sorunun cevabı bu: kod aynı, response farklı. Development, geliştiriciye kolaylık sağlamak için bilerek "konuşkan"; Production ise sessiz olmalı. Ortamın adı da bir yapılandırma ayarı; sunucuda yanlışlıkla Development kalırsa bütün bu ayrıntı internete açılır. Boş bir 500 yerine düzgün bir ProblemDetails cevabı dönmeyi A10 yazısında, ortam bazında yapılandırmayı A02 yazısında konuşacağız. Ortamların genel çalışmasını merak edersen ASP.NET Core Web API serisindeki Program.cs ve environment'lar yazısına bakabilirsin.
HTTPS Geliştirme Sertifikası
Yukarıdaki istekler HTTPS üzerinden sorunsuz çalıştı, çünkü makinede güvenilir bir geliştirme sertifikası var. Durumu şu komutla kontrol edebilirsin:
$ dotnet dev-certs https --check --trust
A trusted certificate was found: 4682C08F...25AE0 - CN=localhost - Valid from 2026-03-07 23:13:48Z to 2027-03-07 23:13:48Z - IsHttpsDevelopmentCertificate: true - IsExportable: true
Sertifika yoksa ya da güvenilir değilse dotnet dev-certs https --trust onu oluşturur ve işletim sisteminin güven deposuna ekler; macOS ve Windows'ta bir onay penceresi çıkabilir. Bu sertifika yalnız localhost için ve yalnız senin makinende geçerli. Production'da gerçek bir sertifika kullanılır.
Programdaki app.UseHttpsRedirection() satırı da düz HTTP ile gelen isteği HTTPS'e yönlendirir:
$ curl -i http://localhost:5102/books
HTTP/1.1 307 Temporary Redirect
Content-Length: 0
Date: Fri, 06 Nov 2026 19:02:38 GMT
Server: Kestrel
Location: https://localhost:5103/books
Varsayılan kod 307. Yönlendirme yararlı ama tek başına yeterli değil: ilk istek yine şifresiz gidiyor. Tarayıcıya "bu siteye bir daha yalnız HTTPS ile gel" demenin yolu HSTS header'ı; onu da A02 ve A04 yazılarında ele alacağız. Lab'da HTTPS'i zorunlu tutmuyorum; sonraki yazılarda örnekleri sadeleştirmek için çoğu zaman düz http://localhost kullanacağım ve HTTPS'in fark yarattığı yerde bunu ayrıca söyleyeceğim.
Sık Düşülen Tuzaklar
İlk tuzak, lab'ı gerçek projenin içinde kurmak. Bilerek açık bırakılmış bir endpoint, bir gün yanlış bir merge ile gerçek koda karışabilir. Lab ayrı klasörde, ayrı repo'da yaşar.
İkincisi, sunucuda ortam adını kontrol etmemek. Container ya da hosting ayarlarında ASPNETCORE_ENVIRONMENT hiç verilmezse varsayılan Production'dır ve bu iyi bir şey. Sorun, birinin "hatayı görmek için" onu geçici olarak Development yapıp unutmasıdır. Uygulama açılırken log'a yazılan Hosting environment satırına deploy sonrası bir kez bakmak bu hatayı yakalar.
Üçüncüsü, sertifika uyarısını curl -k ya da kodda "her sertifikayı kabul et" callback'iyle susturmak. Lab'da bile bu alışkanlığı edinme; aynı satır bir gün Production koduna taşınır ve HTTPS'in bütün anlamını ortadan kaldırır.
Dördüncüsü, cookie seçeneklerini varsayılana bırakmak. Cookies.Append("x", value) kısa ve rahat; ama hassas bir değer taşıyorsa bayrakları her zaman açıkça yaz.
Kontrol Listesi
Lab hazır sayılır, eğer şunlar doğruysa: uygulama dotnet run --launch-profile https ile açılıyor ve log'da Hosting environment: Development yazıyor; curl -i https://localhost:5103/books üç kitabı dönüyor; dotnet dev-certs https --check --trust güvenilir bir sertifika buluyor; bookstore.db dosyasını silip uygulamayı yeniden başlattığında veri baştan geliyor.
HTTP'yi okurken de her response'ta aynı dört şeye bak: status code ne söylüyor, hangi header'lar sunucu hakkında bilgi veriyor, set-cookie satırında hangi bayraklar var ve hata durumunda gövdede ne dönüyor.
Küçük Bir Deneme
★☆☆ Lab'ı kur, /books/2 ve /books/abc adreslerine curl -i ile istek at. İkincisi neden 404 dönüyor? İpucu: route'taki {id:int} constraint'i.
★★☆ /preferences endpoint'ine {"color":"dark"} gövdesiyle istek at. theme alanı gelmediğinde ne oluyor, Development ve Production'da response nasıl farklılaşıyor? Bu, input validation'ın neden gerektiğine dair küçük bir ön izleme.
★★★ Uygulamayı Production'da çalıştırıp /books/3/summary adresine istek at ve log'daki fail satırını bul. Sonra appsettings.json'da log seviyesini değiştirip hangi satırların kaybolduğunu gözle.
Bir sonraki yazıda serinin ilk kategorisine giriyoruz: OWASP A01:2025 Broken Access Control. Lab'a cookie tabanlı giriş ekleyecek, Alan'ın Grace'in siparişini okuduğu IDOR açığını ve admin endpoint'indeki eksik yetki kontrolünü gösterecek, sonra ikisini de policy'ler ve resource-based authorization ile kapatacağız.