OWASP A05: Injection (2) — XSS, Razor Encoding, innerHTML, CSP and Trusted Types
We examine reflected, stored and DOM-based XSS in the Bookstore lab: HtmlEncoder and Razor's automatic encoding, the trap of writing a value from a JSON API with innerHTML and the textContent fix, Angular's sanitization and the bypassSecurityTrust* risk, then how CSP and Trusted Types stop unsafe code in headless Chrome as a second line of defense.
Version note: This post follows the OWASP Top 10:2025; the code was tested with ASP.NET Core 10 (.NET 10).
Guess First
The Bookstore API produces no HTML; it only returns JSON. A user writes <img src=x onerror=alert(1)> as a book review and the API stores it as is. Is it right to say "our API is JSON, XSS doesn't concern us"? What happens when the page that lists reviews displays this value?
The answer is not in the API but in the line that writes the value into the page. In this post we will see XSS on the server and in the browser, then two lines of defense (CSP and Trusted Types) in headless Chrome. Every attempt targets the lab on your own machine; only try these techniques on systems you own or have written permission to test.
The Three Forms of XSS
Cross-Site Scripting (CWE-79) has been part of the Injection category since 2021 (A05 in 2025), and in OWASP's 2025 data it maps to more than 30,000 CVEs; for SQL injection the number is above 14,000. The root cause is the same as in the previous post: data mixes with code, this time HTML and JavaScript, and the interpreter is the browser. Because the attacker's script runs in your site's origin, it can send requests with the user's session, change the page and read everything the page can read.
There are three types, and the difference is where the value comes from. In reflected XSS the value comes from the same request (often the query string) and is reflected back in the response. In stored XSS the value is first saved to the database and then shown to everyone who opens that page. In DOM-based XSS the server isn't involved at all; JavaScript in the browser takes a value such as location.hash and writes it into the DOM unsafely. The defense is built on the same idea for all three: encode the value for the context it ends up in, or write it with an API that does not interpret it as HTML.
Server Side: Encoding
The lab's Program.cs; reviews are kept in memory and Razor Pages is enabled:
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();
// VULNERABLE: input is written into HTML without encoding
app.MapGet("/search-unsafe", (string q) =>
Results.Content($"<h1>Results for {q}</h1>", "text/html"));
// FIXED: encode for the HTML context
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 is classic reflected XSS: the q value is pasted into the HTML as is, and the browser builds the <img> as an element and runs onerror. Let's send the same value to the fixed endpoint:
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>
The < and > characters became < and >; the browser now sees text, not an element. When I opened it with headless Chrome, the value sat in the page's DOM as text as well. Encoding is context-specific: HtmlEncoder for the HTML body, JavaScriptEncoder for a JavaScript string, UrlEncoder for a URL part; all three live in System.Text.Encodings.Web.
Razor does this on its own. 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 is HTML-encoded by default. What turns this protection off is @Html.Raw(q) (MarkupString in Blazor); make those the first lines you look for in code review.
The JSON API and the innerHTML Trap
Now to the opening question. Let's save the review and read it back:
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)>"}]
The API isn't doing anything wrong: the Content-Type is application/json, the browser won't interpret this response as HTML, and the value sits in the JSON as text. The problem is in the JavaScript that writes this value into the page:
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");
// VULNERABLE: text from the server is interpreted as HTML
item.innerHTML = `<b>${review.author}</b>: ${review.text}`;
list.append(item);
}
A string assigned to innerHTML is parsed as HTML. When I opened this page in its unprotected form in the lab, Mallory's review was added to the DOM as an <img> element and onerror ran. This is stored XSS: a single record affects everyone who opens the page. The fix is to use APIs that write the value as text:
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");
// SAFE: textContent writes text as text
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>
In headless Chrome's --dump-dom output the review is now text. textContent and a string passed to append produce a text node; the HTML parser never gets involved. The fix for DOM-based XSS is the same: don't pass a value read from location.hash to innerHTML, outerHTML, insertAdjacentHTML or document.write.
Angular: Sanitization and bypassSecurityTrust*
Angular treats every value as untrusted by default. I wrote the code in this section following the security guide on angular.dev; I did not run it in the lab:
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)>' };
// DANGEROUS: turns off Angular's sanitization
trustedText: SafeHtml = this.sanitizer.bypassSecurityTrustHtml(this.review.text);
}
The interpolation on the first line is always escaped, and the value appears as text. On the second line the [innerHTML] binding goes through Angular's HTML sanitization; harmless tags stay, dangerous parts such as onerror are removed. Angular knows four contexts: HTML, style, URL and resource URL. The third line deliberately turns the protection off. For the bypassSecurityTrustHtml, bypassSecurityTrustScript, bypassSecurityTrustStyle, bypassSecurityTrustUrl and bypassSecurityTrustResourceUrl methods, angular.dev's warning is clear: trusting a value that may be malicious introduces a security vulnerability. Never pass a user-supplied value through these methods. Don't generate templates on the server by combining them with user data, and use AOT compilation, which is the default.
Second Line of Defense: CSP
Getting encoding right everywhere is the goal, but one day a line slips through. Content Security Policy (CSP) is a response header that tells the browser which scripts it may run. ASP.NET Core has no built-in CSP middleware; a small middleware is enough, as we did in the A02 post. Right above the app.UseStaticFiles(); line:
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' allows only script files loaded from your own origin; inline scripts and inline event handlers such as onerror= are blocked. I deliberately left the vulnerable innerHTML page in place and opened it again with CSP. The element was still added to the DOM, but the console showed this and the handler did not run:
[...: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)
This doesn't mean the bug is fixed; it only limits its consequences. For CSP to work you must not add 'unsafe-inline'. If you need inline scripts, use a nonce generated fresh for every request, or a hash; since Angular 16 there is the ngCspNonce attribute and the CSP_NONCE token, and autoCsp, which came with Angular CLI 19, generates hashes of inline scripts at build time. Trying a new CSP first with Content-Security-Policy-Report-Only is the way to see what would be blocked without breaking existing pages.
Trusted Types
CSP stopped the inline handler, but the vulnerable line still writes a string to innerHTML. Trusted Types catches this at the source: you tell the browser "don't let plain strings reach the APIs that write HTML into the DOM". I added a single directive to the end of the CSP string:
"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)
On the vulnerable page the assignment line (line 8) threw an exception directly and the list stayed empty. The fixed page using textContent worked fine with the same header and showed the review as text. So Trusted Types makes code that uses unsafe sinks visible during testing.
Watch browser support: according to MDN, require-trusted-types-for is supported since Chrome 83, Firefox 148 and Safari 26, and the feature has been "Baseline 2026, newly available" since February 2026. Older browsers don't know the directive and ignore it; that is why Trusted Types is an extra layer and doesn't replace encoding. Angular has supported Trusted Types since 11.0.0; the policy name is angular, and if you use the bypass methods you must also allow the angular#unsafe-bypass policy: trusted-types angular; require-trusted-types-for 'script';.
Common Pitfalls
The first is trying to "clean" input when you store it. A filter that removes the word <script> misses another tag; the same value is dangerous in different ways in HTML, in an attribute and in JavaScript. Encode output for the context you write it into; if you really need rich text, use a maintained HTML sanitizer library.
The second is assuming a JSON API is safe by itself. As you saw in the lab, the value sat harmlessly in the JSON; the danger was in the client writing it with innerHTML. The response's Content-Type matters too: if you return JSON as text/html, the browser may interpret it as a page.
The third is forgetting the attribute context. A user URL written into href can start with javascript:; HTML encoding doesn't stop that. Validate URLs with a scheme allowlist (https:, mailto:).
The fourth is adding 'unsafe-inline' to the CSP and saying "we have CSP". That reopens the door to inline scripts and handlers and removes most of the protection.
Checklist
On the server, rely on Razor's default encoding, review every use of Html.Raw and MarkupString, and use HtmlEncoder where you generate HTML by hand. On the client, prefer safe APIs such as textContent, append and setAttribute; don't feed innerHTML and similar sinks with user data.
In Angular, rely on interpolation and [innerHTML] sanitization, and flag bypassSecurityTrust* calls in code review. As a second line, set up a CSP without 'unsafe-inline', try it first in report-only mode, and turn on Trusted Types to catch unsafe sinks.
A Small Experiment
★☆☆ In the lab, remove the HtmlEncoder.Default.Encode call from the /search endpoint, compare the response with curl -i, then put it back.
★★☆ Open the reviews page with CSP and capture the console output with headless Chrome's --enable-logging=stderr flag. Then send the CSP as Content-Security-Policy-Report-Only and note how the output changes.
★★★ With require-trusted-types-for 'script' on, try to make the vulnerable page work without switching to textContent: create a policy that you allow with the trusted-types directive and make the assignment valid by encoding the value inside createHTML.
In the next post we look at mistakes in design rather than code: A06:2025 Insecure Design. We will talk about business logic flaws such as orders with negative quantities, reused coupons and race conditions, about threat modeling, and about ASP.NET Core's rate limiting middleware.