Let Razor's @ encode the output: @Model.Name and @Model.Bio HTML-encode & < > " ' and more, in text and in a quoted attribute. Never @Html.Raw on user data. A link needs more than encoding: allow only http and https URLs, because encoding leaves javascript:alert(1) working.
- Find every @Html.Raw(...), new HtmlString(...) and IHtmlContent you build from request data or stored records; all of them skip Razor's automatic encoding.
- Render the value with a plain @expression instead; Razor HTML-encodes any string (and any non-IHtmlContent) with HtmlEncoder.Default, covering & < > " ' and more, which is safe in text and in a quoted attribute.
- Keep attribute values quoted so an encoded quote cannot start a new attribute.
- For a URL in href or src, check the value and link only an http/https (or mailto) scheme or a relative URL; encoding does not make javascript: safe.
- For rich text that must keep some HTML, sanitize it with a maintained allowlist library such as Ganss.Xss (HtmlSanitizer) and wrap the result in HtmlString only then; never a regex.
- Run in the Production environment in production: the Development exception page echoes request detail and stack traces.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
<img src="/avatar.svg" alt="@Html.Raw(Model.Name)" width="64" height="64">
<h1>@Html.Raw(Model.Name)</h1>
<p class="bio">@Html.Raw(Model.Bio)</p>With IMPL=vulnerable, 7 of 9 tests fail (5 exploit tests).
The fix: output encoding
<img src="/avatar.svg" alt="@Model.Name" width="64" height="64">
<h1>@Model.Name</h1>
<p class="bio">@Model.Bio</p>With IMPL=fixed, 9 of 9 tests pass.
The fix: URL scheme allowlist
@* Encoding cannot make a URL safe: javascript:alert(1) has nothing to encode.
Link only to http and https URLs. *@
@if (Model.Website.StartsWith("https://", StringComparison.Ordinal)
|| Model.Website.StartsWith("http://", StringComparison.Ordinal))
{
<a href="@Model.Website">Website</a>
}With IMPL=fixed, 9 of 9 tests pass.
A fix that looks safe but is not
@{
// Print raw so entities stored in old profiles (é) still render,
// but escape < and > so no tag can be injected.
static string EscapeTags(string text) => text.Replace("<", "<").Replace(">", ">");
}
<img src="/avatar.svg" alt="@Html.Raw(EscapeTags(Model.Name))" width="64" height="64">
<h1>@Html.Raw(EscapeTags(Model.Name))</h1>
<p class="bio">@Html.Raw(EscapeTags(Model.Bio))</p>With IMPL=wrong, 4 of 9 tests fail (2 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
/// <summary>Exploit: what a browser would run from the page must stay inert.</summary>
public sealed class ExploitTests(WebApplicationFactory<Program> factory) : ProfilePageTests(factory)
{
/// <summary>No script elements, no event-handler attributes, and only http(s) links.</summary>
private static void AssertNothingExecutable(IDocument page)
{
Assert.Empty(page.QuerySelectorAll("script").Select(s => s.TextContent));
var handlers = page.All
.SelectMany(e => e.Attributes.Where(a => a.Name.StartsWith("on", StringComparison.Ordinal)).Select(a => $"{e.LocalName} {a.Name}"))
.ToArray();
Assert.Empty(handlers);
foreach (var href in Links(page))
{
Assert.True(UrlScheme(href) is "" or "http" or "https", $"link to a {UrlScheme(href)}: URL: {href}");
}
}
private static readonly char[] C0ControlOrSpace = Enumerable.Range(0, 0x21).Select(i => (char)i).ToArray();
/// <summary>
/// The scheme a browser uses for href, or "" for a relative URL. Like the URL Standard's
/// parser, strip leading and trailing C0 controls and spaces and delete every tab and
/// newline before looking for the scheme.
/// </summary>
private static string UrlScheme(string href)
{
var url = Regex.Replace(href.Trim(C0ControlOrSpace), "[\t\n\r]", "");
var scheme = Regex.Match(url, "^([A-Za-z][A-Za-z0-9+.-]*):");
return scheme.Success ? scheme.Groups[1].Value.ToLowerInvariant() : "";
}
[Fact]
public async Task ScriptElementInTheBioDoesNotRun() =>
AssertNothingExecutable(await Profile(("name", "Mallory"), ("bio", "<script>alert(document.domain)</script>")));
[Fact]
public async Task EventHandlerAttributeInTheBioDoesNotRun() =>
AssertNothingExecutable(await Profile(("name", "Mallory"), ("bio", "<img src=x onerror=alert(document.domain)>")));
[Fact]
public async Task QuoteInTheNameCannotBreakOutOfTheAltAttribute()
{
const string name = "\" onerror=\"alert(document.domain)";
var page = await Profile(("name", name));
AssertNothingExecutable(page);
Assert.Equal(name, AvatarAlt(page));
}
[Fact]
public async Task JavascriptWebsiteIsNotLinked() =>
AssertNothingExecutable(await Profile(("name", "Mallory"), ("website", "javascript:alert(document.domain)")));
[Fact]
public async Task TabInsideTheSchemeDoesNotSmuggleAJavascriptUrlThrough() =>
// Browsers delete tabs and newlines from a URL before parsing it.
AssertNothingExecutable(await Profile(("name", "Mallory"), ("website", "java\tscript:alert(document.domain)")));
}- @Html.Raw and new HtmlString opt out of Razor's encoding; they are for HTML you produced, never for user input. A bare @expression is the safe default.
- An interpolated or raw string passed to Html.Raw ($"<b>{name}</b>") is still unencoded output; the @ in a plain expression is what encodes.
- HTML-encoding a URL does not disarm javascript:alert(1); it has no HTML metacharacters. URLs need a scheme allowlist, not encoding.
- A blocklist such as StartsWith("javascript:") misses mixed case, a tab or newline inside the scheme, and other schemes (data:, vbscript:). Allowlist http/https instead, and compare with StringComparison.OrdinalIgnoreCase.
- Tag helpers that bind attributes from a URL (asp-* or a plain href) do not validate the scheme either; the allowlist still applies.
- No built-in .NET analyzer flags @Html.Raw on untrusted input, and Semgrep CE has no rule for it; CodeQL does. Treat every @Html.Raw as a manual review point.
- The JavaScript context is separate again: a value placed inside an inline <script> needs JavaScript-string encoding (System.Text.Encodings.Web.JavaScriptEncoder), not HTML encoding.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs csharp-aspnetcore does this for you).
- Semgrep CE has no rule for @Html.Raw, so grep the .cshtml views for Html.Raw and review each by hand; CodeQL's cs/web/xss will flag them if you run it.
- Against your own endpoint, send name="><script>alert(1)</script> and website=javascript:alert(1) and confirm the script is shown as text and the link is dropped.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | csharp.dotnet.security.audit.razor-use-of-htmlstring.razor-use-of-htmlstring | No findings on this card. The rule matches new HtmlString(...), but the vulnerable and wrong views use the @Html.Raw(...) helper, which Semgrep CE has no registry rule for. The related csharp.razor.security.html-raw-json rule matches only @Html.Raw(Json...). Semgrep adds nothing here, so the regression tests are the control.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | cs/web/xss | Expected to flag this card: semmle/code/csharp/security/dataflow/XSSSinks.qll defines MicrosoftAspNetCoreMvcHtmlHelperRawSink as the 'Microsoft.AspNetCore.Mvc.ViewFeatures.HtmlHelper.Raw() method', so @Html.Raw(request data) is a sink, and Razor.qll models the compiled .cshtml views. This is the tool that covers the gap Semgrep CE leaves.Checked: github/codeql main at b5b8165; not executed |
| .NET code analysis (Roslyn analyzers) | n/aNo built-in analyzer for Razor XSS | The .NET SDK ships no analyzer that flags @Html.Raw on untrusted input (CA2100 is SQL-specific and does not apply). Razor's automatic @ encoding is the control; @Html.Raw opts out of it, and only CodeQL or a third-party scanner catches the misuse.Checked: Reviewed the built-in rule set |
| Component | Version |
|---|---|
| Language | C# |
| Runtime | .NET 10.0.12 (SDK 10.0.112) |
| Framework | ASP.NET Core 10 MVC with Razor views |
| Microsoft.AspNetCore.App (framework) | 10.0.12 |
| Microsoft.AspNetCore.Mvc.Testing (test only) | 10.0.12 |
| xunit.v3 (test only) | 4.0.1 (Microsoft Testing Platform v2) |
| AngleSharp (test only) | 1.8.2 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the xss/csharp-aspnetcorefolder.
dotnet restore tests/Profiles.Tests --locked-mode# Expected: every test passes
IMPL=fixed dotnet test --project tests/Profiles.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: an exploit test fails
IMPL=vulnerable dotnet test --project tests/Profiles.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: a test fails
IMPL=wrong dotnet test --project tests/Profiles.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xmlEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs xss/csharp-aspnetcore runs all three modes and checks the expected results.
Verified
- Toolchain
- dotnetSdk 10.0.112
- dotnetRuntime 10.0.12
- aspNetCoreMvcTesting 10.0.12
- xunitV3 4.0.1
- angleSharp 1.8.2
- semgrep 1.180.0
- Matrix
- fixed: 9 of 9 tests pass; vulnerable: 7 of 9 tests fail (5 exploit tests); wrong: 4 of 9 tests fail (2 exploit tests)
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Cross Site Scripting Prevention Cheat Sheet
- ASP.NET Core: Prevent XSS (Razor @ encoding, Html.Raw)
- Razor syntax reference: expression encoding and HtmlHelper.Raw
- System.Text.Encodings.Web: HtmlEncoder, JavaScriptEncoder, UrlEncoder
- HtmlSanitizer (Ganss.Xss) for rich text
- WHATWG URL Standard: URL parsing (scheme, tab and newline removal)
Attack patterns: