OWASP A05: Injection (2) — XSS, Razor Encoding, innerHTML, CSP ve Trusted Types
Reflected, stored ve DOM-based XSS'i Bookstore lab'ında inceliyoruz: HtmlEncoder ve Razor'ın otomatik encoding'i, JSON API'den gelen değeri innerHTML ile yazmanın tuzağı ve textContent ile düzeltmesi, Angular'ın sanitization'ı ve bypassSecurityTrust* riski, ardından ikinci savunma hattı olarak CSP ile Trusted Types'ın headless Chrome'da güvensiz kodu nasıl durdurduğu.
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'si HTML üretmiyor, yalnız JSON döndürüyor. Bir kullanıcı kitap yorumu olarak <img src=x onerror=alert(1)> yazıyor ve API bunu olduğu gibi kaydediyor. "Bizim API'miz JSON, XSS bizi ilgilendirmez" demek doğru mu? Yorumları listeleyen sayfa bu değeri gösterdiğinde ne olur?
Cevap API'de değil, değeri sayfaya yazan satırda. Bu yazıda XSS'i sunucuda ve tarayıcıda, ardından iki savunma hattıyla (CSP ve Trusted Types) headless Chrome'da göreceğiz. Bütün denemeler kendi makinendeki lab'a karşı; bu teknikleri yalnız sahibi olduğun ya da yazılı izin aldığın sistemlerde dene.
XSS'in Üç Hâli
Cross-Site Scripting (CWE-79) 2021'den beri Injection kategorisinin içinde (2025'te A05) ve OWASP'ın 2025 verisinde 30.000'den fazla CVE'ye eşleniyor; SQL injection için bu sayı 14.000'in üzerinde. Önceki yazıdaki kök neden aynı: veri, bu kez HTML ve JavaScript ile karışıyor ve yorumlayıcı tarayıcı. Saldırganın script'i senin sitenin origin'inde çalıştığı için kullanıcının oturumuyla istek atabilir, sayfayı değiştirebilir, okuyabildiği her şeyi okuyabilir.
Üç türü var ve fark, değerin nereden geldiğinde. Reflected XSS'te değer aynı isteğin içinden (çoğu zaman query string) gelir ve yanıta geri yansır. Stored XSS'te değer önce veritabanına kaydedilir, sonra o sayfayı açan herkese gösterilir. DOM-based XSS'te sunucu hiç karışmaz; tarayıcıdaki JavaScript location.hash gibi bir değeri alıp DOM'a güvensiz biçimde yazar. Savunma üçünde de aynı fikre dayanıyor: değeri bulunduğu bağlama göre encode et ya da HTML olarak yorumlanmayacak bir API ile yaz.
Sunucu Tarafı: Encoding
Lab'ın Program.cs dosyası; yorumlar bellekte tutuluyor, Razor Pages de açık:
using System.Collections.Concurrent;
using System.Text.Encodings.Web;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
var app = builder.Build();
app.UseStaticFiles();
app.MapRazorPages();
// ZAFİYETLİ: girdi HTML'e encode edilmeden yazılıyor
app.MapGet("/search-unsafe", (string q) =>
Results.Content($"<h1>Results for {q}</h1>", "text/html"));
// DÜZELTİLMİŞ: HTML bağlamı için encode
app.MapGet("/search", (string q) =>
Results.Content($"<h1>Results for {HtmlEncoder.Default.Encode(q)}</h1>", "text/html"));
ConcurrentQueue<Review> reviews = new ConcurrentQueue<Review>();
reviews.Enqueue(new Review("Ada", "Clear examples, short chapters."));
app.MapGet("/api/books/1/reviews", () => Results.Ok(reviews));
app.MapPost("/api/books/1/reviews", (Review review) =>
{
reviews.Enqueue(review);
return Results.Created("/api/books/1/reviews", review);
});
app.Run();
record Review(string Author, string Text);
/search-unsafe klasik reflected XSS: q değeri HTML'in içine olduğu gibi yapışıyor, tarayıcı da <img>'i bir element olarak kurup onerror'u çalıştırıyor. Düzeltilmiş endpoint'e aynı değeri gönderelim:
curl -i -G "http://localhost:5130/search" --data-urlencode "q=<img src=x onerror=alert(1)>"
HTTP/1.1 200 OK
Content-Length: 55
Content-Type: text/html
Date: Fri, 06 Nov 2026 19:11:37 GMT
Server: Kestrel
<h1>Results for <img src=x onerror=alert(1)></h1>
< ve > karakterleri < ve > oldu; tarayıcı artık bir element değil, metin görüyor. Headless Chrome ile açtığımda sayfanın DOM'unda da değer metin olarak duruyordu. Encoding bağlama özel: HTML gövdesi için HtmlEncoder, JavaScript string'i için JavaScriptEncoder, URL parçası için UrlEncoder; üçü de System.Text.Encodings.Web içinde.
Razor bunu kendiliğinden yapıyor. Pages/BookSearch.cshtml:
@page
@{
string q = Request.Query["q"].ToString();
}
<h1>Results for @q</h1>
curl -i -G "http://localhost:5130/BookSearch" --data-urlencode "q=<img src=x onerror=alert(1)>"
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
...
<h1>Results for <img src=x onerror=alert(1)></h1>
@q varsayılan olarak HTML-encode edilir. Bu korumayı kapatan şey @Html.Raw(q) (Blazor'da MarkupString); kod incelemesinde ilk aradığın satırlar bunlar olsun.
JSON API ve innerHTML Tuzağı
Şimdi açılıştaki soruya gelelim. Yorumu kaydedip geri okuyalım:
curl -i -X POST http://localhost:5130/api/books/1/reviews -H "Content-Type: application/json" -d '{"author":"Mallory","text":"<img src=x onerror=alert(1)>"}'
curl -i http://localhost:5130/api/books/1/reviews
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
...
[{"author":"Ada","text":"Clear examples, short chapters."},{"author":"Mallory","text":"<img src=x onerror=alert(1)>"}]
API'nin yaptığı yanlış değil: Content-Type application/json, tarayıcı bu yanıtı HTML olarak yorumlamaz ve değer JSON'da metin olarak duruyor. Sorun, bu değeri sayfaya yazan JavaScript'te:
const list = document.getElementById("reviews");
const response = await fetch("/api/books/1/reviews");
const reviews = await response.json();
for (const review of reviews) {
const item = document.createElement("li");
// ZAFİYETLİ: sunucudan gelen metin HTML olarak yorumlanıyor
item.innerHTML = `<b>${review.author}</b>: ${review.text}`;
list.append(item);
}
innerHTML'e atanan string HTML olarak parse edilir. Lab'da bu sayfayı savunmasız hâliyle açtığımda Mallory'nin yorumu DOM'a bir <img> element'i olarak eklendi ve onerror çalıştı. Bu stored XSS: tek bir kayıt, sayfayı açan herkesi etkiliyor. Düzeltme, değeri metin olarak yazan API'leri kullanmak:
const list = document.getElementById("reviews");
const response = await fetch("/api/books/1/reviews");
const reviews = await response.json();
for (const review of reviews) {
const item = document.createElement("li");
const author = document.createElement("b");
// GÜVENLİ: textContent metni metin olarak yazar
author.textContent = review.author;
item.append(author, `: ${review.text}`);
list.append(item);
}
<ul id="reviews"><li><b>Ada</b>: Clear examples, short chapters.</li><li><b>Mallory</b>: <img src=x onerror=alert(1)></li></ul>
Headless Chrome'un --dump-dom çıktısında yorum artık metin. textContent ve append'e verilen string text node üretir; HTML parser hiç devreye girmez. DOM-based XSS'in düzeltmesi de aynı: location.hash'ten okuduğun değeri innerHTML, outerHTML, insertAdjacentHTML ya da document.write'a verme.
Angular: Sanitization ve bypassSecurityTrust*
Angular varsayılan olarak her değeri güvenilmez kabul eder. Bu bölümdeki kodu angular.dev'deki güvenlik rehberine göre yazdım; lab'da çalıştırmadım:
import { Component, inject } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@Component({
selector: 'app-review',
template: `
<p>{{ review.text }}</p>
<p [innerHTML]="review.text"></p>
<p [innerHTML]="trustedText"></p>
`
})
export class ReviewComponent {
private sanitizer = inject(DomSanitizer);
review = { author: 'Mallory', text: '<img src=x onerror=alert(1)>' };
// TEHLİKELİ: Angular'ın sanitization'ını kapatır
trustedText: SafeHtml = this.sanitizer.bypassSecurityTrustHtml(this.review.text);
}
İlk satırdaki interpolation her zaman escape edilir, değer metin olarak görünür. İkinci satırda [innerHTML] binding'i Angular'ın HTML sanitization'ından geçer; zararsız etiketler kalır, onerror gibi tehlikeli kısımlar atılır. Angular dört bağlam tanır: HTML, style, URL ve resource URL. Üçüncü satır ise korumayı bilerek kapatıyor. bypassSecurityTrustHtml, bypassSecurityTrustScript, bypassSecurityTrustStyle, bypassSecurityTrustUrl ve bypassSecurityTrustResourceUrl method'ları için angular.dev'in uyarısı açık: kötü niyetli olabilecek bir değere güvenmek bir güvenlik açığı oluşturur. Kullanıcıdan gelen bir değeri asla bu method'lardan geçirme. Template'leri sunucuda kullanıcı verisiyle birleştirerek üretme ve varsayılan olan AOT derlemeyi kullan.
İkinci Savunma Hattı: CSP
Encoding'i her yerde doğru yapmak hedef, ama bir gün bir satır gözden kaçar. Content Security Policy (CSP), tarayıcıya hangi script'leri çalıştırabileceğini söyleyen bir response header'ı. ASP.NET Core'da yerleşik bir CSP middleware'i yok; A02 yazısında yaptığımız gibi küçük bir middleware yeterli. app.UseStaticFiles(); satırının hemen üstüne:
app.Use(async (context, next) =>
{
context.Response.Headers.ContentSecurityPolicy =
"default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'";
await next();
});
script-src 'self' yalnız kendi origin'inden yüklenen script dosyalarına izin verir; inline script'ler ve onerror= gibi inline event handler'lar engellenir. Zafiyetli innerHTML sayfasını bilerek yerinde bırakıp CSP ile tekrar açtım. Element yine DOM'a eklendi, ama konsolda şu çıktı ve handler çalışmadı:
[...:INFO:CONSOLE:8] "Executing inline event handler violates the following Content Security Policy directive 'script-src 'self''. Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a nonce ('nonce-...') is required to enable inline execution. Note that hashes do not apply to event handlers, style attributes and javascript: navigations unless the 'unsafe-hashes' keyword is present. The action has been blocked.", source: http://localhost:5130/js/reviews-unsafe.js (8)
Bu, hatanın düzeldiği anlamına gelmez; sadece sonucunu sınırlar. CSP'nin işe yaraması için 'unsafe-inline' eklememen gerekir. Inline script şartsa her istekte yeni üretilen bir nonce ya da hash kullan; Angular 16'dan beri ngCspNonce attribute'ü ve CSP_NONCE token'ı var, Angular CLI 19 ile gelen autoCsp ise inline script'lerin hash'ini build sırasında üretiyor. Yeni bir CSP'yi önce Content-Security-Policy-Report-Only ile denemek, mevcut sayfaları kırmadan neyin engelleneceğini görmenin yolu.
Trusted Types
CSP inline handler'ı durdurdu ama zafiyetli satır hâlâ innerHTML'e string yazıyor. Trusted Types bunu kaynağında yakalar: tarayıcıya "DOM'a HTML yazan API'lere düz string verilmesin" dersin. CSP string'inin sonuna tek bir direktif ekledim:
"default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; require-trusted-types-for 'script'";
[...:INFO:CONSOLE:8] "This document requires 'TrustedHTML' assignment. The action has been blocked.", source: http://localhost:5130/js/reviews-unsafe.js (8)
[...:INFO:CONSOLE:8] "Uncaught TypeError: Failed to set the 'innerHTML' property on 'Element': This document requires 'TrustedHTML' assignment.", source: http://localhost:5130/js/reviews-unsafe.js (8)
Zafiyetli sayfada atama satırı (8. satır) doğrudan exception fırlattı ve liste boş kaldı. textContent kullanan düzeltilmiş sayfa ise aynı header ile sorunsuz çalıştı ve yorumu metin olarak gösterdi. Yani Trusted Types, güvensiz sink'leri kullanan kodu test sırasında görünür hâle getiriyor.
Tarayıcı desteğine dikkat: MDN'e göre require-trusted-types-for Chrome 83, Firefox 148 ve Safari 26'dan beri destekleniyor ve özellik Şubat 2026'dan beri "Baseline 2026, newly available". Eski tarayıcılar direktifi tanımaz ve yok sayar; bu yüzden Trusted Types ek bir katman, encoding'in yerine geçmez. Angular, 11.0.0'dan beri Trusted Types destekliyor; policy adı angular, bypass method'larını kullanıyorsan angular#unsafe-bypass policy'sine de izin vermen gerekir: trusted-types angular; require-trusted-types-for 'script';.
Sık Düşülen Tuzaklar
Birincisi, girdiyi kaydederken "temizlemeye" çalışmak. <script> kelimesini silen bir filtre başka bir etiketi kaçırır; aynı değer HTML'de, attribute'ta ve JavaScript'te farklı biçimde tehlikelidir. Çıktıyı yazdığın bağlama göre encode et; zengin metin gerçekten gerekiyorsa bakımı süren bir HTML sanitizer library'si kullan.
İkincisi, JSON API'nin kendiliğinden güvenli olduğunu sanmak. Lab'da gördüğün gibi değer JSON'da zararsız duruyordu; tehlike onu innerHTML ile yazan istemcideydi. Yanıtın Content-Type'ı da önemli: JSON'u text/html ile dönersen tarayıcı onu sayfa olarak yorumlayabilir.
Üçüncüsü, attribute bağlamını unutmak. href içine yazılan kullanıcı URL'i javascript: ile başlayabilir; HTML encoding bunu durdurmaz. URL'leri şema allowlist'iyle (https:, mailto:) doğrula.
Dördüncüsü, CSP'ye 'unsafe-inline' ekleyip "CSP'miz var" demek. Bu, inline script ve handler'lara yeniden kapıyı açar ve korumanın büyük kısmını ortadan kaldırır.
Kontrol Listesi
Sunucuda Razor'ın varsayılan encoding'ine güven, Html.Raw ve MarkupString kullanımlarını tek tek gözden geçir, elle HTML ürettiğin yerde HtmlEncoder kullan. İstemcide textContent, append ve setAttribute gibi güvenli API'leri tercih et; innerHTML ve benzeri sink'leri kullanıcı verisiyle besleme.
Angular'da interpolation ve [innerHTML] sanitization'ına güven, bypassSecurityTrust* çağrılarını code review'da işaretle. İkinci hat olarak 'unsafe-inline' içermeyen bir CSP kur, önce report-only ile dene ve Trusted Types'ı açarak güvensiz sink'leri yakala.
Küçük Bir Deneme
★☆☆ Lab'da /search endpoint'inde HtmlEncoder.Default.Encode çağrısını kaldır, curl -i ile yanıtı karşılaştır ve sonra geri koy.
★★☆ Yorum sayfasını CSP ile aç, konsol çıktısını headless Chrome'un --enable-logging=stderr bayrağıyla yakala. Sonra CSP'yi Content-Security-Policy-Report-Only olarak gönder ve çıktının nasıl değiştiğini not et.
★★★ require-trusted-types-for 'script' açıkken zafiyetli sayfayı textContent'e geçmeden çalıştırmayı dene: trusted-types direktifiyle izin verdiğin bir policy oluştur ve createHTML içinde değeri encode ederek atamayı geçerli hâle getir.
Bir sonraki yazıda kodun değil tasarımın hatasına bakıyoruz: A06:2025 Insecure Design. Negatif miktarlı sipariş, tekrar kullanılan kupon ve race condition gibi iş mantığı açıklarını, threat modeling'i ve ASP.NET Core'un rate limiting middleware'ini konuşacağız.