Razor Syntax ve Layout'lar: @ İfadeleri, HTML Encoding, _Layout, Section'lar, _ViewStart ve _ViewImports
Razor, @ ile yazılan her değeri varsayılan olarak HTML encode eder; bu XSS'i kapatır ama Türkçe karakterleri de ı gibi entity'lere çevirir, WebEncoderOptions ile düzeltilir. Implicit ve explicit expression farkını, Html.Raw'ın ve JavaScript'e '@Model' gömmenin tehlikesini, Json.Serialize'ı, section'ları, _ViewStart/_ViewImports hiyerarşisini ve nested layout'ta kaybolan section hatasını gerçek çıktılarla 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
Bookstore'un kitap detay view'ında şu üç satır var:
<p>Member price: @Model.Price * 0.9m TL</p>
<p>Member price: @(Model.Price * 0.9m) TL</p>
<p>Questions? Write to support@bookstore.example or follow @@bookstore</p>
Kitabın fiyatı 310. Tarayıcıya giden HTML'de (HyperText Markup Language) bu üç satır nasıl görünür? Bir de şunu düşün: yönetim panelinden bir editör kitabın adını Haritaların Kısa Tarihi <script>alert('hi')</script> olarak kaydetti. Sayfa açıldığında alert çıkar mı? Çıkmazsa, "ı" harfi kaynakta nasıl görünür?
Razor'ın Tek Kuralı: @ İşareti
Razor, HTML'in içine C# yerleştirmenin syntax'ı. Kural basit: @ işaretiyle C#'a geçersin, Razor ifadenin nerede bittiğine kendisi karar verir ve HTML'e geri döner. Bu kararın nasıl verildiğini bilmek, Razor'daki şaşırtıcı çıktıların çoğunu açıklıyor. Yukarıdaki satırların gerçek çıktısı (fiyat 310 olan "A Short History of Maps" sayfası):
<p>Member price: 310 * 0.9m TL</p>
<p>Member price: 279.0 TL</p>
<p>Questions? Write to support@bookstore.example or follow @bookstore</p>
İlk satır bir implicit expression: @Model.Price bir identifier ve property erişim zinciri olarak okunur, boşlukta biter. Geri kalan * 0.9m TL düz metindir. Derleme hatası yok, uyarı yok; sayfada "310 * 0.9m" yazıyor. İkinci satır explicit expression: @( ... ) parantezin içindeki her şeyi tek bir C# ifadesi olarak değerlendirir. Kural şu: bir hesaplama, bir operatör ya da boşluk içeren her şey parantez ister.
Üçüncü satırda iki incelik var. support@bookstore.example bir e-posta adresi gibi göründüğü için Razor, @'den önce harf olduğunu görüp onu C#'a geçiş saymıyor. @@ ise kaçış dizisi ve tek bir @ basıyor; sosyal medya kullanıcı adları ya da CSS'teki (Cascading Style Sheets) @media kuralları için gerekiyor.
Implicit expression'ın bir sınırı daha var: generic çağrılar. @ToList<string>() yazarsan Razor <string>'i bir HTML etiketi sanar. Generic tip parametresi içeren her çağrıyı @( ... ) içine al. @await ise implicit olarak çalışır: @await Component.InvokeAsync("CartSummary") gibi bir satır parantez istemez.
Kod Blokları, Kontrol Akışı ve Direktifler
Detay view'ının tamamı:
@model BookDetailsViewModel
@{
ViewData["Title"] = Model.Title;
string stockText = Model.InStock ? $"{Model.Stock} in stock" : "Out of stock";
}
<h1>@Model.Title</h1>
<p>by @Model.AuthorName · @Model.Genre · @Model.PublishedOn.Year</p>
<p>Price: @Model.Price TL</p>
<p>Member price: @Model.Price * 0.9m TL</p>
<p>Member price: @(Model.Price * 0.9m) TL</p>
<p>@stockText</p>
<p>Questions? Write to support@bookstore.example or follow @@bookstore</p>
@if (Model.Status == BookStatus.OutOfPrint)
{
@:This edition is out of print.
}
@* <p>Supplier: @Model.SupplierName</p> *@
<!-- visible in page source -->
@section Sidebar {
<p>More by @Model.AuthorName</p>
}
@section Scripts {
<script>
const titleUnsafe = '@Model.Title';
const book = @Json.Serialize(new { Model.Id, Model.Title });
</script>
}
@{ ... } bir code block: içindeki her satır C# ve hiçbir şey çıktıya yazılmaz. Burada tanımlanan stockText view'ın geri kalanında kullanılabilir. @if, @foreach, @switch, @using ve @try kontrol akışı için; süslü parantezlerin içinde Razor yeniden HTML moduna döner, yani <p> ile başlayan bir satır HTML olarak yazılır. HTML etiketiyle başlamayan düz metin basmak istediğinde @: (tek satır) ya da <text>...</text> (çok satır) kullanırsın. <text> etiketinin kendisi çıktıya yazılmaz.
Aynı isteğin <main> içindeki gerçek çıktısı:
<h1>A Short History of Maps</h1>
<p>by Grace Hill · History · 2015</p>
<p>Price: 310 TL</p>
<p>Member price: 310 * 0.9m TL</p>
<p>Member price: 279.0 TL</p>
<p>Out of stock</p>
<p>Questions? Write to support@bookstore.example or follow @bookstore</p>
This edition is out of print.
<!-- visible in page source -->
İki yorumun farkına dikkat et. @* ... *@ bir Razor yorumu: derleme sırasında atılır, içindeki @Model.SupplierName hiç çalışmaz ve çıktıda iz bırakmaz. <!-- ... --> ise düz HTML: olduğu gibi tarayıcıya gider ve "kaynağı görüntüle" diyen herkes okur. Üstelik HTML yorumunun içindeki @ ifadeleri çalışır. Yorumu kapatılmış bir maliyet ya da tedarikçi alanı, HTML yorumunun içinde hâlâ render edilip sayfa kaynağına sızar.
Direktifler view'ın tepesinde durur. @model view'ın model tipini bağlar. @using namespace ekler. @inject IBookCatalog Catalog DI (Dependency Injection) container'ından bir service alıp view'da property olarak açar. @section layout'a gönderilecek bir içerik bloğu tanımlar. @addTagHelper tag helper'ları devreye alır, ayrıntısı 5. yazıda. Bunların çoğu tek tek view'lara değil, birazdan göreceğimiz _ViewImports.cshtml'e yazılır.
Varsayılan HTML Encoding: XSS'e Karşı İlk Savunma
Şimdi tahminin ikinci kısmı. Editörün kaydettiği başlığı veritabanına yazdım ve sayfayı açtım. Gerçek çıktı:
<title>Haritaların Kısa Tarihi <script>alert('hi')</script> - Bookstore</title>
<h1>Haritaların Kısa Tarihi <script>alert('hi')</script></h1>
Alert çıkmadı. Razor, @ ile yazılan her değeri HtmlEncoder'dan geçirir: < < olur, tek tırnak ' olur ve tarayıcı bunu etiket değil metin olarak gösterir. XSS'in (Cross-Site Scripting) en yaygın biçimi, kullanıcı girdisinin HTML olarak yorumlanması, varsayılan olarak kapalı. Bunun için hiçbir şey yapmadık; tehlike, bu korumayı bilerek ya da bilmeyerek devre dışı bıraktığın yerlerde başlıyor.
Ama çıktıda bir sürpriz daha var: "ı" harfi ı olarak yazılmış. Tarayıcı bunu doğru gösterir, yani kullanıcı fark etmez. Fark eden, sayfa kaynağını okuyan sen, HTML boyutu ve curl | grep "Haritaların" ile arama yapan bir testtir. Sebep, ASP.NET Core'un HTML encoder'ının varsayılan olarak yalnızca Basic Latin aralığını güvenli sayması; geri kalan her karakteri numeric entity'ye çeviriyor. Türkçe içerikli bir sitede bu ayarı genişletmek gerekiyor:
using System.Text.Encodings.Web;
using System.Text.Unicode;
using Microsoft.Extensions.WebEncoders;
builder.Services.AddControllersWithViews();
builder.Services.Configure<WebEncoderOptions>(options =>
options.TextEncoderSettings = new TextEncoderSettings(UnicodeRanges.All));
Aynı sayfanın yeni çıktısı:
<title>Haritaların Kısa Tarihi <script>alert('hi')</script> - Bookstore</title>
<h1>Haritaların Kısa Tarihi <script>alert('hi')</script></h1>
Türkçe karakterler artık olduğu gibi, ama <, > ve tırnaklar hâlâ encode ediliyor. Bu ayar güvenlikten taviz değil; yalnızca hangi harflerin "zararsız" sayılacağını değiştiriyor. HTML'de anlamı olan karakterler her durumda encode edilir.
@Html.Raw ve JavaScript'e Veri Gömmek
Encoding'i kapatmanın en kısa yolu @Html.Raw. Başlığı <h1>@Html.Raw(Model.Title)</h1> ile basınca gerçek çıktı:
<h1>Haritaların Kısa Tarihi <script>alert('hi')</script></h1>
Bu artık sayfayı açan herkesin tarayıcısında çalışan bir script. Yönetim panelindeki bir editörün yazdığı metin, halka açık katalogda her ziyaretçinin oturum bağlamında çalışıyor. Html.Raw'ı yalnızca içeriği tamamen senin kodunun ürettiği yerlerde (sabit bir SVG (Scalable Vector Graphics) görseli, sunucuda üretilmiş ve kullanıcı girdisi içermeyen bir parça) kullan. Kullanıcıdan gelen zengin metni (bir kitap açıklaması için WYSIWYG (What You See Is What You Get) editörü gibi) göstermen gerekiyorsa, onu kaydederken ya da render etmeden önce izin listesi tabanlı bir HTML sanitizer'dan geçirmen gerekir. Konunun genel güvenlik anlatımı Web Güvenliği (OWASP Top 10) serisinin XSS'i ve Razor encoding'ini işleyen "OWASP A05: Injection (2)" yazısında; OWASP, Open Worldwide Application Security Project'in kısaltması.
İkinci tehlikeli yer <script> bloğunun içi. View'daki iki satırın gerçek çıktısı:
const titleUnsafe = 'Haritaların Kısa Tarihi <script>alert('hi')</script>';
const book = {"id":3,"title":"Haritalar\u0131n K\u0131sa Tarihi \u003Cscript\u003Ealert(\u0027hi\u0027)\u003C/script\u003E"};
titleUnsafe satırında Razor yine HTML encoding yaptı. Ama <script> içinde tarayıcı HTML entity'lerini çözmez; JavaScript değişkeninin değeri gerçekten <script> karakterleri olur ve ekranda öyle görünür. Daha önemlisi, HTML encoder JavaScript string'inin kurallarını bilmez: bu bağlam için tasarlanmamıştır, satır sonu ya da ters bölü gibi karakterlerde string'i bozar. Doğru araç @Json.Serialize: değeri JSON'a (JavaScript Object Notation) çevirir ve <, >, tırnak gibi karakterleri \u003C biçiminde escape eder. Çıktı geçerli bir JavaScript literal'ı, değer ise orijinal başlığın kendisi. WebEncoderOptions ayarı bu serializer'ı etkilemiyor, bu yüzden "ı" hâlâ \u0131; JavaScript'te bu aynı karakter, sorun değil.
Layout, RenderBody ve Section'lar
Her view yalnızca kendi parçasını üretir; sayfanın iskeleti layout'ta. Template'in Views/Shared/_Layout.cshtml dosyasına, sayfa isterse sağda görünecek bir Sidebar alanı ekledik:
<div class="container">
<main role="main" class="pb-3">
@RenderBody()
</main>
@if (IsSectionDefined("Sidebar"))
{
<aside class="border-top pt-2">
@await RenderSectionAsync("Sidebar")
</aside>
}
</div>
...
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
RenderBody() view'ın section dışındaki bütün çıktısını basar ve layout'ta tam bir kez çağrılmalı. RenderSectionAsync("Scripts", required: false) view Scripts tanımladıysa onu, tanımlamadıysa hiçbir şey basmaz. IsSectionDefined ise section'ın etrafındaki sarmalayıcı HTML'i de koşula bağlamak için: sidebar'ı olmayan sayfada boş bir <aside> kalmıyor. Scripts section'ının layout'un en sonunda, jQuery'den sonra render edilmesi de bilinçli: sayfaya özel script'ler kütüphanelere bağımlı.
required: true ise bir sözleşme: layout "bu section'ı her sayfa vermek zorunda" diyor. Layout'a @await RenderSectionAsync("Breadcrumbs", required: true) ekleyip uygulamayı açtığımda ana sayfa 500 döndü:
System.InvalidOperationException: The layout page '/Views/Shared/_Layout.cshtml' cannot find the section 'Breadcrumbs' in the content page '/Views/Home/Index.cshtml'.
Hata derleme anında değil, ilgili sayfa ilk kez render edildiğinde geliyor. Birçok sayfası olan bir uygulamada required: true eklemek, test edilmemiş her sayfayı potansiyel bir 500'e çevirir. Bu yüzden ben neredeyse her zaman required: false ile IsSectionDefined kombinasyonunu tercih ediyorum.
_ViewStart, _ViewImports ve Nested Layout
Her view'ın başına Layout = "_Layout"; yazmamamızın sebebi _ViewStart.cshtml: aynı klasördeki ve alt klasörlerdeki her view'dan önce çalışan kod. _ViewImports.cshtml ise her view'a eklenecek direktiflerin yeri. Bookstore'da ikisinin de iki kopyası var:
Views/_ViewStart.cshtml Layout = "_Layout"
Views/_ViewImports.cshtml @using + @addTagHelper
Areas/Admin/Views/_ViewStart.cshtml Layout = "_AdminLayout"
Areas/Admin/Views/_ViewImports.cshtml @using + @addTagHelper
@using Bookstore.Web
@using Bookstore.Web.Models
@using Bookstore.Web.ViewModels
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
İkisi de hiyerarşik çalışır. _ViewStart dosyaları kökten view'ın klasörüne doğru sırayla çalışır, yani en yakın olanın ayarı kazanır. _ViewImports direktifleri birikir: bir alt klasördeki _ViewImports üstteki @using'leri ezmez, onlara ekler. @model gibi tekil direktiflerde ise en yakın olan geçerli. Geçen yazıda gördüğümüz gibi Areas klasörü Views'un altında olmadığı için area'nın kendi dosyaları gerekiyor.
Admin area'sının layout'u, ana layout'un içine yerleşen bir nested layout. Kendi layout'unu tanımlayan bir layout:
@{
Layout = "_Layout";
}
<nav class="alert alert-secondary py-2">
Admin panel ·
<a asp-area="Admin" asp-controller="Books" asp-action="Index">Books</a>
</nav>
@RenderBody()
_AdminLayout, Areas/Admin/Views/Shared altında; içindeki "_Layout" adı ise view arama kuralıyla Views/Shared/_Layout.cshtml'e çözülüyor. İlk denemede admin kitap listesi 500 döndü. Admin listesi bir Scripts section'ı tanımlıyordu ve gerçek hata şuydu:
System.InvalidOperationException: The following sections have been defined but have not been rendered by the page at '/Areas/Admin/Views/Shared/_AdminLayout.cshtml': 'Scripts'. To ignore an unrendered section call IgnoreSection("sectionName").
Section'lar yalnızca bir üst seviyeye gider. View'ın Scripts'i _AdminLayout'a ulaşır ama _AdminLayout onu render etmediği için en üstteki _Layout'a hiç varmaz. Çözüm, ara layout'ta section'ı yeniden tanımlayıp içinde aşağıdan geleni render etmek:
@RenderBody()
@section Scripts {
@await RenderSectionAsync("Scripts", required: false)
}
Düzeltmeden sonra sayfanın sonu beklediğimiz gibi:
<script src="/js/site.js?v=hRQyftXiu1lLX2P9Ly9xa4gHJgLeR1uGN5qegUobtGo"></script>
<script>console.log("admin books loaded");</script>
</body>
Üretimde Dikkat
Encoding ayarını uygulamanın başında, ilk view yazılmadan yap. Sonradan değiştirdiğinde HTML'i string olarak karşılaştıran testler, cache'lenmiş sayfalar ve ı arayan log'lar bir anda bozulur. Ayarın etkisini ölçmek de kolay: Türkçe ağırlıklı bir sayfada her "ı", "ş", "ğ" için 6-8 byte'lık bir entity gidiyordu, şimdi Unicode Transformation Format (UTF-8) ile kodlanmış 2 byte'lık karakter gidiyor.
View'lar build sırasında derlenir; bir view'daki C# hatası dotnet build'i kırar. Ama section ve layout sözleşmeleri, ViewBag erişimleri ve partial adları runtime'da çözülür. Bu yüzden her sayfayı en az bir kez render eden basit bir smoke test (her route'a istek atıp 200 bekleyen bir integration test) MVC (Model-View-Controller) uygulamalarında maliyetine göre en çok hata yakalayan testtir.
Son olarak Html.Raw kullanımlarını kod incelemesinde ayrıca işaretle. grep -rn "Html.Raw" Views/ çıktısının her satırı için "bu değerin içinde kullanıcı girdisi var mı?" sorusunun cevabı net olmalı.
Sık Düşülen Tuzaklar
Hesaplamayı parantezsiz yazmak. @Model.Price * 0.9m derlenir ve sayfada "310 * 0.9m" yazar. Operatör, boşluk ya da generic içeren her ifade @( ... ) ister.
View'da iş mantığı ve veritabanı çağrısı. @inject BookstoreDbContext Db yazıp view'da @Db.Books.Count(...) çağırmak teknik olarak çalışır. Ama sorgu, render sırasında, senkron olarak ve controller'ın hiç bilmediği bir yerde atılır; bir foreach'in içindeyse her satır için ayrı bir sorgu olur. View'ın ihtiyacı olan her şey ViewModel'de hazır gelmeli. Bunun partial'lar içindeki hâlini bir sonraki yazıda ölçeceğiz.
Kullanıcı girdisini Html.Raw ile basmak ya da JavaScript'e '@Model.X' diye gömmek. İlki doğrudan XSS, ikincisi bozuk veri ve yanlış bağlamda encoding. JavaScript'e veri @Json.Serialize ile, HTML attribute'una veri tag helper ya da normal @ ile gider.
Gizli bilgiyi HTML yorumuyla "kapatmak". <!-- @Model.InternalNote --> çalışır ve sayfa kaynağına yazılır. Razor tarafında kapatmak için @* *@.
Nested layout'ta section'ları ileri taşımamak. Ara layout'un tanımlamadığı bir section'ı view tanımlarsa sayfa 500 döner; hata yalnızca o section'ı kullanan sayfalarda ortaya çıkar.
Küçük Bir Deneme
★☆☆ Detay view'ına <p>Published @Model.PublishedOn.ToString("dd.MM.yyyy")</p> ve <p>Published @Model.PublishedOn.ToString("dd MMMM yyyy")</p> satırlarını ekle. İkincisi neden parantez istemeden çalışıyor, ama @Model.PublishedOn.AddYears(1).Year + 1 neden beklediğin sonucu vermiyor?
★★☆ WebEncoderOptions ayarını kaldırıp Türkçe başlıklı bir sayfanın boyutunu curl -s -o /dev/null -w "%{size_download}" ile ölç; ayarı geri koyup tekrar ölç. Fark ne kadar?
★★★ Admin area'sına _AdminLayout için ikinci bir section, PageActions, ekle ve bunu admin layout'unun nav çubuğunda render et. Sonra bu section'ı yalnızca admin listesi view'ında tanımla. Ana _Layout'un bu section'dan haberi olmalı mı? Neden?
View'larımız artık doğru encode ediyor ve ortak bir iskelete oturuyor. Ama katalog listesindeki kitap satırını, ana sayfadaki "öne çıkanlar" kutusunda da kullanmak istiyoruz; sağ üstte de her sayfada görünen bir sepet özeti lazım. Bir sonraki yazıda partial view'ları, kendi verisini çeken view component'leri ve MVC view'ı içinde bir Razor component render etmeyi karşılaştırıyoruz.