MVC ve Razor Pages'te Formlar: Model Binding, Post-Redirect-Get, Antiforgery ve Overposting
MVC controller'larında form tag helper'ı antiforgery token'ı üretir ama doğrulamaz: token'sız curl isteği kitabı kaydetti, AutoValidateAntiforgeryTokenAttribute ile 400 oldu. Post-Redirect-Get ve TempData'yı, fetch ile header'da token göndermeyi, koleksiyon/nested object/checkbox/enum binding'ini ve indeks boşluğunu, entity bind edince stoğu 999 yapan overposting'i ve [Bind]'ın güncellemede veri silen sınırını gerçek isteklerle görüyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.402) ve Microsoft.EntityFrameworkCore.Sqlite 10.0.14 ile derlenip denenmiştir.
Önce Tahmin Et
Geçen yazıda form tag helper'ının her POST formuna kendiliğinden bir __RequestVerificationToken eklediğini gördük. Şimdi admin area'sındaki Create action'ına [HttpPost] ekledik ve başka hiçbir attribute yazmadık. Terminalden, sayfayı hiç açmadan, token'sız bir istek atıyorsun:
curl -i -X POST http://localhost:17711/Admin/Books/Create -d "Title=Night Trains&Isbn=9780000000073&Price=199.90&Stock=5&PublishedOn=2026-10-01&Genre=0&AuthorId=1"
Hangi status code'u alırsın: 400 mü, 403 mü, yoksa 302 mi? Kitap veritabanına yazılır mı?
Formun POST Action'ı ve Post-Redirect-Get
Geçen yazıdaki BookFormViewModel'i alan POST action'ı:
[HttpPost]
public async Task<IActionResult> Create(BookFormViewModel model, CancellationToken cancellationToken)
{
if (!ModelState.IsValid)
{
model.Authors = await GetAuthorItemsAsync(cancellationToken);
return View(model);
}
int id = await books.CreateAsync(model, cancellationToken);
TempData["StatusMessage"] = $"Book #{id} \"{model.Title}\" was created as a draft.";
return RedirectToAction(nameof(Index));
}
Action'ın iki çıkışı var ve ikisi de bilinçli. Doğrulama başarısızsa aynı view'ı aynı modelle döneriz: kullanıcı yazdıklarını kaybetmez ve hata mesajlarını alanların yanında görür. Dropdown'ın listesi form ile gelmediği için (Authors POST'ta boş) onu yeniden doldurmak gerekiyor; bu satırı unutmak, hatalı formun dropdown'ı boş dönmesi demek. Başarılıysa kaydı yapar ve 302 ile listeye yönlendiririz. Bu Post-Redirect-Get (PRG):

Neden doğrudan View("Index", list) dönmüyoruz? Çünkü o durumda tarayıcının son isteği POST olarak kalır. Kullanıcı sayfayı yenilediğinde (F5) tarayıcı "formu yeniden göndermek istiyor musunuz?" diye sorar ve "evet" derse aynı kitap ikinci kez oluşur. Redirect'ten sonra son istek bir GET'tir; yenilemek zararsızdır. Boş başlıkla, eksik bir ISBN (International Standard Book Number) ve sıfır fiyatla gönderdiğim hatalı formun yanıtı HTTP/1.1 200 OK ve alanların yanında şu satırlar:
<span class="text-danger field-validation-error" data-valmsg-for="Title" data-valmsg-replace="true">The Title field is required.</span>
<span class="text-danger field-validation-error" data-valmsg-for="Isbn" data-valmsg-replace="true">ISBN must be 13 digits.</span>
<span class="text-danger field-validation-error" data-valmsg-for="Price" data-valmsg-replace="true">The field Price must be between 1 and 10000.</span>
Validation'ın kendisi, client-side karşılığı ve iş kuralı hataları bir sonraki yazının konusu. Burada akışa odaklanıyoruz.
TempData: Redirect'ten Sonra "Kaydedildi" Demek
Redirect'in bir bedeli var: POST'taki bilgi (kaydın id'si, başlığı) GET'e kendiliğinden geçmez. "Kitap oluşturuldu" mesajını taşımak için TempData var: bir sonraki istekte okunana kadar yaşayan küçük bir sözlük. Varsayılan sağlayıcı onu şifrelenmiş bir cookie'de tutar. Başarılı POST'un gerçek header'ları:
HTTP/1.1 302 Found
Location: /Admin/Books
Set-Cookie: .AspNetCore.Mvc.CookieTempDataProvider=CfDJ8EINq_wm4TFCv7guzy6SHCcuiQaPofijpUf8kYhdsLcyXucrQb801rrQkbizgDO2C...
Liste view'ı mesajı okuyor:
@if (TempData["StatusMessage"] is string message)
{
<div class="alert alert-success">@message</div>
}
Tarayıcının redirect'i izleyip attığı GET'in yanıtında mesaj var ve aynı yanıt cookie'yi siliyor:
Set-Cookie: .AspNetCore.Mvc.CookieTempDataProvider=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=lax; httponly
<div class="alert alert-success">Book #7 "Night Trains" was created as a draft.</div>
Aynı sayfayı bir kez daha istediğimde alert-success 0 kez geçiyordu. Okunan TempData değeri istek sonunda silinir; bir istek daha yaşamasını istersen TempData.Keep("StatusMessage"), okumadan bakmak istersen TempData.Peek("StatusMessage") var. Cookie'de taşındığı için TempData'ya büyük object koyma; cookie boyutu tarayıcı sınırlarına takılır ve her istekte gidip gelir.
Antiforgery: Token Kendiliğinden Doğrulanmıyor
Tahminin cevabı: 302. Token'sız istek kabul edildi ve 7 numaralı "Night Trains" kitabı veritabanına yazıldı. Form tag helper'ının token üretmesi, birinin onu doğruladığı anlamına gelmiyor. MVC (Model-View-Controller) controller'larında doğrulama, action'a ya da controller'a [ValidateAntiForgeryToken] koyduğunda ya da global bir filter eklediğinde çalışır. Razor Pages'te ise POST handler'ları varsayılan olarak doğrulanır; iki model bu noktada farklı davranıyor.
Bu neden önemli? CSRF (Cross-Site Request Forgery) saldırısında, admin paneline giriş yapmış kullanıcı başka bir sitede gezinirken o site arka planda /Admin/Books/Create'e form gönderir; tarayıcı authentication cookie'sini kendiliğinden ekler. Token, isteğin gerçekten bizim formumuzdan geldiğinin kanıtı. Saldırının genel anlatımı Web Güvenliği (OWASP Top 10) serisinin cookie flag'lerini ve CSRF'i işleyen "OWASP A07: Authentication Failures (2)" yazısında; OWASP, Open Worldwide Application Security Project'in kısaltması.
Her POST action'ına attribute yazmayı unutmak çok kolay, bu yüzden doğrulamayı global açıyorum:
builder.Services.AddControllersWithViews(options =>
options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()));
AutoValidateAntiforgeryTokenAttribute, GET, HEAD, OPTIONS ve TRACE dışındaki her istekte token arar. Aynı token'sız istek artık HTTP/1.1 400 Bad Request döndü. Microsoft.AspNetCore.Mvc.ViewFeatures.Filters log kategorisini Information'a açınca sebebi görünüyor:
info: Microsoft.AspNetCore.Mvc.ViewFeatures.Filters.AutoValidateAntiforgeryTokenAuthorizationFilter[1]
Antiforgery token validation failed. The required antiforgery cookie ".AspNetCore.Antiforgery.OQdF6r0KLeY" is not present.
Doğrulama iki parçalı: form sayfası GET edilirken sunucu bir cookie yazar, formun içine de onunla eşleşen bir token koyar. Formun GET yanıtının ilgili header'ları:
Cache-Control: no-cache, no-store
Pragma: no-cache
Set-Cookie: .AspNetCore.Antiforgery.OQdF6r0KLeY=CfDJ8EINq_wm4TFCv7guzy6SHCce175_lDMtAM3ZzHD8db_wqXxfxhxyLAXKW1GfsAS2n-O2mIIFir0Jn0hiCJooZ-Hi...
X-Frame-Options: SAMEORIGIN
Antiforgery token üretilen sayfanın cache'lenmemesi ve başka bir sitede iframe içinde açılamaması için Cache-Control ve X-Frame-Options da kendiliğinden eklenmiş. Cookie'yi gönderip form alanını göndermeyen istek de 400 aldı, log'da başka bir mesajla:
Microsoft.AspNetCore.Antiforgery.AntiforgeryValidationException: The required antiforgery request token was not provided in either form field "__RequestVerificationToken" or header value "RequestVerificationToken".
Bu mesaj, bir sonraki bölümün ipucunu da veriyor: token form alanında ya da RequestVerificationToken header'ında gelebilir. Bilinçli olarak doğrulamadan muaf tutmak istediğin bir action için [IgnoreAntiforgeryToken] var; örneğin dışarıdan imzalı istek alan bir webhook endpoint'i.
fetch İsteklerinde Token Göndermek
4. yazının denemesindeki soruya dönelim: sepete ekleyen bir POST'u JavaScript'ten çağırıyoruz. Global filter açık olduğu için bu istek de token istiyor. Controller:
[HttpPost]
public IActionResult Add(int bookId)
{
string? current = Request.Cookies["cart"];
string updated = string.IsNullOrEmpty(current) ? $"{bookId}" : $"{current}-{bookId}";
Response.Cookies.Append("cart", updated, new CookieOptions { HttpOnly = true, SameSite = SameSiteMode.Lax });
// Özet yeni cookie ile hesaplanmalı; bu yüzden redirect (fetch için de PRG)
return RedirectToAction(nameof(Summary), new { compact = false });
}
Sayfada form olmadığı için token'ı IAntiforgery service'inden alıp butonun data- attribute'una koyuyoruz:
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery
...
<button id="add-to-cart" class="btn btn-primary" data-book-id="@Model.Id"
data-token="@Antiforgery.GetAndStoreTokens(Context).RequestToken">Add to cart</button>
...
<script>
document.getElementById("add-to-cart").addEventListener("click", async (event) => {
const button = event.currentTarget;
const response = await fetch("/Cart/Add", {
method: "POST",
headers: { "RequestVerificationToken": button.dataset.token },
body: new URLSearchParams({ bookId: button.dataset.bookId })
});
document.getElementById("cart-summary").innerHTML = await response.text();
});
</script>
GetAndStoreTokens hem token'ı üretir hem de eşleşen cookie'yi yanıta yazar. curl ile aynı akışı denedim. Detay sayfasını GET edip cookie'yi ve data-token'ı aldıktan sonra header'sız POST 400, header'lı POST:
HTTP/1.1 302 Found
Location: /Cart/Summary?compact=False
Set-Cookie: cart=2; path=/; samesite=lax; httponly
fetch redirect'i kendiliğinden izler, yeni cookie ile /Cart/Summary'yi GET eder ve view component'in HTML'ini (HyperText Markup Language) döner. Tarayıcıda "Stars Over Ankara" sayfasında butona iki kez tıkladıktan sonra #cart-summary'nin içeriği şuydu:
<span class="navbar-text">Cart: 2 items · 590.00 TL</span>
Koleksiyon, Nested Object, Checkbox ve Enum Binding
Model binding'in kaynaklarını (route, query, form) Web API (Application Programming Interface) serisinin Model Binding yazısında gördük. Form tarafında asıl soru, düz bir key-value listesinin nasıl iç içe bir object'e dönüştüğü. Stoğu azalan kitaplar için tedarikçiye sipariş açan bir admin formu:
public enum RestockPriority { Low, Normal, High }
public class RestockViewModel
{
public List<RestockLine> Lines { get; set; } = [];
public SupplierContact Supplier { get; set; } = new();
public RestockPriority Priority { get; set; } = RestockPriority.Normal;
}
public class RestockLine
{
public int BookId { get; set; }
public string Title { get; set; } = "";
[Range(0, 500)]
public int Quantity { get; set; }
public bool Urgent { get; set; }
}
public class SupplierContact
{
[Required]
public string Name { get; set; } = "";
[EmailAddress]
public string? Email { get; set; }
}
@for (int i = 0; i < Model.Lines.Count; i++)
{
<tr>
<td>
<input asp-for="Lines[i].BookId" type="hidden" />
@Model.Lines[i].Title
</td>
<td><input asp-for="Lines[i].Quantity" class="form-control" /></td>
<td><input asp-for="Lines[i].Urgent" class="form-check-input" /> Urgent</td>
</tr>
}
...
<input asp-for="Supplier.Name" class="form-control mb-2" placeholder="Supplier" />
<input asp-for="Supplier.Email" class="form-control mb-2" placeholder="Email" />
@foreach (RestockPriority priority in Enum.GetValues<RestockPriority>())
{
<label><input type="radio" asp-for="Priority" value="@priority" /> @priority</label>
}
foreach değil for kullanmamızın sebebi, asp-for'un indeksi ifadeden okuyabilmesi. Üretilen HTML'den seçtiğim satırlar:
<input type="hidden" ... id="Lines_0__BookId" name="Lines[0].BookId" value="2" />
<input class="form-control" type="number" ... id="Lines_0__Quantity" name="Lines[0].Quantity" value="0" /><input name="__Invariant" type="hidden" value="Lines[0].Quantity" />
<input class="form-check-input" type="checkbox" ... id="Lines_0__Urgent" name="Lines[0].Urgent" value="true" />
<input class="form-control mb-2" placeholder="Supplier" type="text" ... id="Supplier_Name" name="Supplier.Name" value="" />
<label><input type="radio" value="Normal" checked="checked" id="Priority" name="Priority" /> Normal</label>
...
<input name="Lines[0].Urgent" type="hidden" value="false" /><input name="Lines[1].Urgent" type="hidden" value="false" /><input name="Lines[2].Urgent" type="hidden" value="false" /></form>
Kurallar name'lerde: Lines[0].Quantity koleksiyonun ilk elemanının property'si, Supplier.Name nested object'in property'si. id'lerde nokta ve köşeli parantez alt çizgiye dönüşüyor, çünkü CSS (Cascading Style Sheets) selector'larında sorun çıkarırlar. Checkbox'ın bir sorunu var: işaretlenmemiş checkbox hiçbir şey göndermez. Tag helper bu yüzden formun sonuna aynı adla value="false" bir hidden input ekliyor. İşaretliyse form true ve false'u birlikte gönderir, binder ilkini alır; işaretli değilse yalnız false gelir. Bir de radio button'ların hepsinin id="Priority" aldığına dikkat et; aynı sayfada tekrar eden id, <label for> ile erişilebilirliği bozar, radio'lara elle id vermek gerekir.
Tarayıcının bu formu gönderirken üreteceği gövdeyi (ilk satırın checkbox'ı işaretli) curl ile gönderdim; action'ın log'ladığı bağlanmış model:
Line: book 2, quantity 10, urgent True
Line: book 3, quantity 5, urgent False
Line: book 6, quantity 0, urgent False
Supplier Ink & Paper Ltd <orders@inkpaper.example>, priority High
Enum, Priority=High gibi adla da Priority=2 gibi sayıyla da bağlanır. Priority=Urgent gönderdiğimde ise sayfa 200 ile geri döndü ve validation summary'de The value 'Urgent' is not valid for Priority. yazıyordu.
Koleksiyonlarda en sık yapılan hata indeks boşluğu. JavaScript ile ortadaki satırı silen bir form Lines[0] ve Lines[2]'yi gönderir. Bunu denediğimde log'da tek satır vardı:
Line: book 2, quantity 10, urgent False
Supplier Ink & Paper Ltd <(null)>, priority Low
Lines[2] sessizce kayboldu: binder sıralı indeksleri 0'dan başlayarak okur ve ilk boşlukta durur. Satır silinebilen ya da sırası değişebilen formlarda her satır için <input type="hidden" name="Lines.Index" value="..."> ekleyip indeksleri Lines[a7].Quantity gibi rastgele anahtarlarla vermek bu sorunu çözer.
Overposting: Entity'yi Bind Etmenin Bedeli
Kitabın yalnız adını ve fiyatını değiştiren bir "hızlı düzenleme" ekranı. İlk sürümü kestirmeden yazdım: view Book entity'sine bağlı, POST action'ı entity'yi bulup üzerine bind ediyor:
[HttpPost]
public async Task<IActionResult> QuickEdit(int id)
{
Book? book = await db.Books.FindAsync(id);
if (book is null)
{
return NotFound();
}
await TryUpdateModelAsync(book);
await db.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
Formda yalnız Title ve Price input'u var. Ama binder formda ne varsa okur, view'da ne olduğuyla ilgilenmez. Formu GET edip cookie'yi ve token'ı aldım, sonra isteğe iki alan ekledim:
curl -b cookies.txt -X POST http://localhost:17711/Admin/Books/QuickEdit/2 \
--data-urlencode "__RequestVerificationToken=$TOKEN" \
-d "Title=Rivers of Ink&Price=249.90&IsFeatured=true&Stock=999"
İsteğin öncesinde ve sonrasında sqlite3 ile okunan satır (Id|Title|Price|Stock|IsFeatured|Status):
2|Rivers of Ink|265.5|4|0|1
2|Rivers of Ink|249.9|999|1|1
Fiyatı düzenleme yetkisi olan bir editör, kitabı ana sayfada öne çıkardı ve stoğu 999 yaptı. Buna overposting (ya da mass assignment) denir. Çözüm, formun kabul ettiği alanları tip olarak tanımlamak:
public class BookQuickEditInput
{
[Required]
[StringLength(120)]
public string Title { get; set; } = "";
[Range(1, 10000)]
public decimal Price { get; set; }
}
[HttpPost]
public async Task<IActionResult> QuickEdit(int id, BookQuickEditInput input, CancellationToken cancellationToken)
{
if (!ModelState.IsValid)
{
return View(input);
}
Book? book = await db.Books.FindAsync([id], cancellationToken);
if (book is null)
{
return NotFound();
}
book.Title = input.Title;
book.Price = input.Price;
await db.SaveChangesAsync(cancellationToken);
TempData["StatusMessage"] = $"Book #{id} was updated.";
return RedirectToAction(nameof(Index));
}
Aynı saldırı, bu kez Status=Draft da eklenerek: yanıt 302, satır 2|Rivers of Ink|249.9|4|0|1. Yalnız fiyat değişti. BookQuickEditInput'ta Stock diye bir property olmadığı için o alan bağlanacak bir yer bulamadı. Admin'in tam düzenleme formu da aynı mantıkla çalışıyor: BookFormViewModel bağlanıyor, BookAdminService yalnız formdaki alanları entity'ye kopyalıyor ve Status ile IsFeatured'a hiç dokunmuyor.
Peki [Bind]? [Bind("Id,Title,Price")] Book book listede olmayan alanları bağlamaz, overposting'i keser. Ama entity ile güncelleme yaparken ters tepiyor. db.Update(book) ile denediğimde istek 500 döndü:
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Microsoft.Data.Sqlite.SqliteException (0x80004005): SQLite Error 19: 'NOT NULL constraint failed: Books.Isbn'.
Bağlanmayan her property varsayılan değerinde kaldı (Isbn null, Stock 0, AuthorId 0) ve Update hepsini veritabanına yazmaya çalıştı. Burada bir NOT NULL kısıtı bizi kurtardı; nullable kolonlarda veri sessizce silinirdi. [Bind] yeni kayıt oluşturmada işe yarar ama listesi string olduğu için property adı değiştiğinde derleyici uyarmaz. Input model hem daha güvenli hem daha okunur. Dosya yükleyen formlar da aynı kurala uyar: enctype="multipart/form-data" ve input model'de bir IFormFile; boyut sınırları ve güvenli dosya adı için Web API serisinin Dosya Yükleme ve İndirme yazısı aynen geçerli.
Üretimde Dikkat
Birden fazla sunucu çalıştırıyorsan antiforgery token'ları ve TempData cookie'leri Data Protection ile şifrelenir; key'ler sunucular arasında paylaşılmazsa bir sunucunun ürettiği token'ı diğeri çözemez ve kullanıcı rastgele 400'ler alır. Key'leri ortak bir yerde (veritabanı, Redis, blob storage) tut.
Antiforgery hataları kullanıcı tarafında da olur: formu açıp bir gün sonra gönderen, ya da oturumu başka bir sekmede kapatıp açan kullanıcının token'ı geçersiz kalır. Boş bir 400 sayfası yerine "sayfayı yenileyip tekrar deneyin" diyen bir hata sayfası göster ve bu 400'leri ayrıca say; ani bir artış ya bir deploy sorununa ya da gerçek bir saldırı denemesine işaret eder.
PRG'nin bir yan etkisi, her başarılı kaydın iki istek olması. Bu neredeyse hiçbir zaman sorun değil; ama başarılı POST'tan sonra sayfayı doğrudan render etmeye karar verirsen yenileme ile çift kayıt riskini de kabul etmiş olursun.
Sık Düşülen Tuzaklar
Token üretildiği için doğrulandığını sanmak. MVC controller'larında global AutoValidateAntiforgeryTokenAttribute ya da action başına [ValidateAntiForgeryToken] olmadan token sadece süs.
Hatalı POST'ta dropdown listelerini yeniden doldurmamak. SelectListItem listeleri form ile geri gelmez; return View(model) öncesinde tekrar yükle.
Entity'yi form modeli olarak kullanmak. Overposting, [Bind] ile güncellemede veri kaybı ve validation attribute'larının entity'ye sızması aynı kökten geliyor.
Koleksiyonlarda foreach ile asp-for yazmak. foreach (RestockLine line in Model.Lines) içinde asp-for="@line.Quantity" yazdığımda her satır için aynı name="line.Quantity" ve aynı id="line_Quantity" üretildi; RestockViewModel'de line diye bir property olmadığı için binder hiçbirini Lines'a bağlayamaz.
TempData'da büyük ya da hassas veri taşımak. Cookie şifreli olsa da her istekte taşınır ve tarayıcının cookie boyutu sınırına takılır.
Küçük Bir Deneme
★☆☆ Global antiforgery filter'ını kaldırıp yalnız Create POST'una [ValidateAntiForgeryToken] koy. QuickEdit'e token'sız istek at; hangi action korunuyor, hangisi korunmuyor?
★★☆ Restock formuna "satırı kaldır" butonu ekle (JavaScript ile <tr>'yi silsin). Önce indeks boşluğu sorununu tarayıcıda yeniden üret, sonra Lines.Index hidden alanlarıyla düzelt ve log'da bütün satırların geldiğini gör.
★★★ Admin'e bir Delete POST action'ı ekle ve listedeki her satıra küçük bir silme formu koy. Silme işlemini fetch ile yapan bir sürüm de yaz: token'ı formun içindeki hidden alandan okuyup header'da gönder ve başarılı olunca satırı DOM'dan (Document Object Model) kaldır. Token'ı bilerek bozduğunda tarayıcının network sekmesinde ne görüyorsun?
Form gönderiliyor, doğru yere bağlanıyor ve yalnız izin verilen alanlar kaydediliyor. Ama "ISBN 13 hane olmalı" kuralı sunucuya gitmeden de çalışabilir, "bu ISBN zaten kayıtlı" kuralı ise yalnız sunucuda. Bir sonraki yazıda DataAnnotations, ModelState, unobtrusive client-side validation, [Remote] ve kendi validation attribute'umuzun client karşılığını yazıyoruz; Türkçe kültürde "1,5" ile "1.5" farkının validation'ı nasıl bozduğunu da göreceğiz.