Skip to content

CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') - C# / ASP.NET

Overview

XSS occurs when untrusted data is included in web output without proper encoding, allowing attackers to inject malicious scripts. In C#/ASP.NET applications, use the built-in encoding features and avoid raw HTML output.

Primary Defence: Use Razor's automatic @variable encoding (ASP.NET Core/MVC) and the System.Text.Encodings.Web encoders (HtmlEncoder, JavaScriptEncoder, UrlEncoder) for context-specific encoding. Avoid @Html.Raw() unless the content has already been sanitized with the HtmlSanitizer library. The legacy AntiXSS library is only needed on .NET Framework projects that predate these built-in encoders.

Common Vulnerable Patterns

Raw String Concatenation in Razor

// VULNERABLE - No encoding
@{
    var userName = ViewBag.UserName;
}
<div>Welcome, @Html.Raw(userName)</div>

// VULNERABLE - Direct concatenation
public IActionResult Index()
{
    var html = "<h1>Welcome " + Request.Query["name"] + "</h1>";
    return Content(html, "text/html");
}

Why this is vulnerable: Using @Html.Raw() or direct string concatenation bypasses ASP.NET's automatic HTML encoding, allowing user input containing <script> tags or other malicious HTML to execute in the browser.

Using HttpResponse.Write Without Encoding

// VULNERABLE
protected void Page_Load(object sender, EventArgs e)
{
    string userInput = Request.QueryString["comment"];
    Response.Write("<div>" + userInput + "</div>");
}

Why this is vulnerable: Response.Write() outputs raw HTML without encoding, allowing attackers to inject JavaScript or HTML tags that execute in the victim's browser when rendering the page.

Literal Controls with User Data

// VULNERABLE - ASP.NET WebForms
Literal1.Text = "<p>" + Request["userInput"] + "</p>";

Why this is vulnerable: ASP.NET Literal controls render content as-is without encoding, allowing HTML and JavaScript injection when user-controlled data is included.

JavaScript Context Without Encoding

// VULNERABLE - JavaScript injection
<script>
    var message = '@Html.Raw(ViewBag.Message)';
    alert(message);
</script>

Why this is vulnerable: Html.Raw() bypasses Razor's automatic HTML encoding. A message such as '; alert(1); // closes the string and executes attacker-supplied JavaScript. With a plain @ViewBag.Message string expression, Razor encodes the quote as an HTML entity, which is not decoded inside a <script> block, so that quote breakout does not work. Use JavaScript-specific encoding to preserve the value safely in a JavaScript string.

Secure Patterns

Razor Automatic Encoding

// SECURE - Razor automatically HTML-encodes @ expressions
@{
    var userName = ViewBag.UserName; // Could be "<script>alert('xss')</script>"
}
<div>Welcome, @userName</div>
<!-- Output: Welcome, &lt;script&gt;alert('xss')&lt;/script&gt; -->

// SECURE - Razor with model binding
@model UserViewModel
<h1>Hello, @Model.UserName</h1>
<p>Email: @Model.Email</p>

Why this works: Razor auto-escapes HTML by default when rendering @ expressions, converting <, >, &, and quotes to entities so user input is inserted as text, not markup. The escaping happens at render time, so @Model.UserName and @ViewBag.Message are covered without anyone calling an encoder, and both the reflected and the stored path are closed in MVC views. Bypassing it takes an explicit @Html.Raw(), which makes the risky lines easy to find in review. Where limited user-provided markup has to render, run it through HtmlSanitizer first rather than passing untrusted data to @Html.Raw().

HttpUtility.HtmlEncode for Classic ASP.NET

// SECURE - Web Forms with explicit encoding
using System.Web;

protected void Page_Load(object sender, EventArgs e)
{
    string userInput = Request.QueryString["comment"];
    string encoded = HttpUtility.HtmlEncode(userInput);
    Response.Write("<div>" + encoded + "</div>");
}

// SECURE - Literal control with encoding
Literal1.Text = "<p>" + HttpUtility.HtmlEncode(Request["userInput"]) + "</p>";

Why this works: HttpUtility.HtmlEncode() performs HTML entity encoding server-side, converting <, >, &, and quotes into entities (&lt;, &gt;, &amp;, &quot;/&#39;). Because that happens before the concatenation, the attacker's characters reach the response as text and cannot break out of the HTML context, which closes both the reflected and the stored path. It's part of System.Web, so classic ASP.NET Web Forms needs no extra dependency. For newer projects, prefer HtmlEncoder.Default.Encode() from System.Text.Encodings.Web, and for JavaScript strings or URLs use the context-specific encoders (JavaScriptEncoder, UrlEncoder).

Context-Specific Encoding

HTML Context

// SECURE - HTML body content
using System.Net;
using System.Text.Encodings.Web;

public string GetSafeHtml(string userInput)
{
    return HtmlEncoder.Default.Encode(userInput);
}

// Usage in controller
public IActionResult Display(string message)
{
    ViewBag.SafeMessage = HtmlEncoder.Default.Encode(message);
    return View();
}

JavaScript Context

// SECURE - JavaScript string context
using System.Text.Encodings.Web;

public IActionResult GetScript(string userName)
{
    var jsEncodedName = JavaScriptEncoder.Default.Encode(userName);
    var script = $"<script>var user = '{jsEncodedName}';</script>";
    return Content(script, "text/html");
}

// SECURE - In Razor view
@using System.Text.Encodings.Web
<script>
    var message = '@JavaScriptEncoder.Default.Encode(ViewBag.Message)';
    console.log(message);
</script>

URL Context

// SECURE - URL parameter encoding
using System.Text.Encodings.Web;

public string BuildUrl(string userQuery)
{
    var encoded = UrlEncoder.Default.Encode(userQuery);
    return $"/search?q={encoded}";
}

// SECURE - In Razor
<a href="/search?q=@UrlEncoder.Default.Encode(Model.SearchTerm)">Search</a>

Why this works: .NET's context-specific encoders (HtmlEncoder, JavaScriptEncoder, UrlEncoder) apply the escaping each sink needs. HtmlEncoder converts <, >, &, and quotes to entities for element bodies and attributes. JavaScriptEncoder escapes quotes, backslashes, and control characters for JS string literals, stopping script-breaking payloads like '</script>. UrlEncoder makes query parameters safe, avoiding delimiter injection. Because each encoder is explicit and named, reviewers can spot misuse such as HTML encoding inside a <script> block. Use these in ASP.NET Core controllers and views for fine-grained control; combine with Razor's auto-escaping for defense in depth.

Encoders for Applications That Cannot Use System.Text.Encodings.Web

Where the modern encoders are unavailable - classic ASP.NET, or a .NET Framework application without System.Web - the equivalents are:

// Classic ASP.NET (System.Web)
string safe    = HttpUtility.HtmlEncode(userInput);
string safeUrl = HttpUtility.UrlEncode(userInput);
string safeJs  = HttpUtility.JavaScriptStringEncode(userInput);

// .NET Framework without System.Web (System.Net)
string safe    = WebUtility.HtmlEncode(userInput);
string safeUrl = WebUtility.UrlEncode(userInput);

// ASP.NET MVC helpers
string safe     = HtmlHelper.Encode(userInput);
string safeAttr = HtmlHelper.AttributeEncode(userInput);

Why this works: each converts the tag characters to entities, so the value cannot re-enter the parser as markup. Two differences from HtmlEncoder are worth knowing rather than assuming:

  • These are not equivalent to HtmlEncoder. HtmlEncoder.Default escapes everything outside Basic Latin, so it is safe by default in more places, and it is configurable through UnicodeRanges. HttpUtility/WebUtility HtmlEncode escape a narrower set. Both are sufficient for element content; they are not interchangeable if you are relying on aggressive encoding for a context that needs it.
  • HtmlAttributeEncode is documented as doing the minimum. Microsoft's reference describes it as "minimally converts a string to an HTML-encoded string", which is enough only for a value inside a quoted attribute. An unquoted attribute can still be terminated by a space, so quote the attribute and do not treat this method as making that decision for you.

WebUtility has no JavaScript-string encoder - its public surface is HtmlEncode, HtmlDecode, UrlEncode and UrlDecode only, whereas HttpUtility adds JavaScriptStringEncode. So on a framework without System.Web, keep the value out of the script block and pass it through a data- attribute rather than reaching for a hand-written escaper.

AntiXSS Library (Legacy)

// SECURE - For older .NET Framework projects
using Microsoft.Security.Application;

string safe = Encoder.HtmlEncode(userInput);
string safeCss = Encoder.CssEncode(cssValue);
string safeUrl = Encoder.UrlEncode(urlParam);
string safeJs = Encoder.JavaScriptEncode(jsValue);

// NuGet: Install-Package AntiXSS
// Last published in 2014 and unmaintained. Only reach for this on .NET
// Framework projects that predate System.Text.Encodings.Web.

Why this works: The Microsoft AntiXSS library provides context-specific encoding for .NET Framework projects that predate the built-in System.Text.Encodings.Web encoders. Encoder.HtmlEncode() escapes HTML metacharacters, JavaScriptEncode() escapes JS strings, UrlEncode() encodes URL components, and CssEncode() handles CSS contexts, each converting the attacker-controlled characters before they reach the output. Wherever the built-in encoders are available, prefer HtmlEncoder.Default.Encode() and its ASP.NET Core siblings; reach for AntiXSS only in a codebase that cannot move off the old framework.

Framework-Specific Guidance

ASP.NET Core MVC (Razor)

// SECURE - Automatic encoding by default
@model CommentViewModel

<div class="comment">
    <h3>@Model.Author</h3>
    <p>@Model.Text</p>
    <small>Posted: @Model.Timestamp</small>
</div>

// When you MUST output raw HTML (e.g., rich text editor):
// 1. Use a sanitization library
@using Ganss.Xss

@{
    var sanitizer = new HtmlSanitizer();
    var safeHtml = sanitizer.Sanitize(Model.RichContent);
}
@Html.Raw(safeHtml)

NuGet Packages:

<PackageReference Include="HtmlSanitizer" Version="9.2.1039" />

ASP.NET Web API

// SECURE - JSON responses are automatically encoded
public IActionResult GetUser(int id)
{
    var user = _userService.GetUser(id);
    return Json(new 
    { 
        name = user.Name,  // Automatically JSON-encoded
        bio = user.Bio 
    });
}

// SECURE - Model validation and encoding
[HttpPost]
public IActionResult CreateComment([FromBody] CommentDto dto)
{
    if (!ModelState.IsValid)
        return BadRequest(ModelState);

    var comment = new Comment
    {
        Text = dto.Text,  // Will be HTML-encoded when rendered
        Author = dto.Author
    };

    _db.Comments.Add(comment);
    _db.SaveChanges();

    return Ok(comment);
}

Blazor Server / WebAssembly

// SECURE - Razor components auto-encode
@page "/profile"
@inject UserService UserService

<h1>Profile: @currentUser.Name</h1>
<p>@currentUser.Bio</p>

@code {
    private User currentUser;

    protected override async Task OnInitializedAsync()
    {
        currentUser = await UserService.GetCurrentUser();
        // All @-expressions are automatically HTML-encoded
    }
}

// For dynamic markup, use MarkupString with sanitization
@using Microsoft.AspNetCore.Components
@using Ganss.Xss

@code {
    private MarkupString GetSafeMarkup(string html)
    {
        var sanitizer = new HtmlSanitizer();
        return (MarkupString)sanitizer.Sanitize(html);
    }
}

<div>@GetSafeMarkup(Model.RichText)</div>

Input Validation (Defense in Depth)

using System.ComponentModel.DataAnnotations;

public class CommentDto
{
    [Required]
    [StringLength(1000, MinimumLength = 1)]
    [RegularExpression(@"^[^<>]*$", ErrorMessage = "HTML tags not allowed")]
    public string Text { get; set; }

    [Required]
    [StringLength(100)]
    [RegularExpression(@"^[a-zA-Z0-9\s]*$", ErrorMessage = "Only alphanumeric")]
    public string Author { get; set; }
}

// Controller with validation
[HttpPost]
public IActionResult PostComment([FromBody] CommentDto dto)
{
    if (!ModelState.IsValid)
        return BadRequest(ModelState);

    // Even with validation, still encode output
    ViewBag.Comment = dto.Text;
    return View();
}

Content Security Policy (CSP)

// Add CSP headers in middleware
public class SecurityHeadersMiddleware
{
    private readonly RequestDelegate _next;

    public SecurityHeadersMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        context.Response.Headers.Add("Content-Security-Policy",
            "default-src 'self'; " +
            "script-src 'self' https://trusted-cdn.com; " +
            "style-src 'self' 'unsafe-inline'; " +
            "img-src 'self' data: https:; " +
            "frame-ancestors 'none';"
        );

        context.Response.Headers.Add("X-Content-Type-Options", "nosniff");
        context.Response.Headers.Add("X-Frame-Options", "DENY");

        await _next(context);
    }
}

// Startup.cs / Program.cs
public void Configure(IApplicationBuilder app)
{
    app.UseMiddleware<SecurityHeadersMiddleware>();
    // ... other middleware
}

Rich HTML Sanitization

When you need to allow safe HTML (e.g., from a WYSIWYG editor):

using Ganss.Xss;

public class HtmlSanitizerService
{
    private readonly HtmlSanitizer _sanitizer;

    public HtmlSanitizerService()
    {
        _sanitizer = new HtmlSanitizer();

        // Allow only safe tags
        _sanitizer.AllowedTags.Clear();
        _sanitizer.AllowedTags.Add("p");
        _sanitizer.AllowedTags.Add("br");
        _sanitizer.AllowedTags.Add("strong");
        _sanitizer.AllowedTags.Add("em");
        _sanitizer.AllowedTags.Add("ul");
        _sanitizer.AllowedTags.Add("ol");
        _sanitizer.AllowedTags.Add("li");

        // Allow only safe attributes
        _sanitizer.AllowedAttributes.Clear();
        _sanitizer.AllowedAttributes.Add("class");

        // Remove all event handlers
        _sanitizer.AllowedAttributes.Remove("onclick");
        _sanitizer.AllowedAttributes.Remove("onerror");
    }

    public string Sanitize(string html)
    {
        return _sanitizer.Sanitize(html);
    }
}

// Usage
public IActionResult SaveArticle([FromBody] ArticleDto dto)
{
    var sanitized = _htmlSanitizer.Sanitize(dto.Content);

    var article = new Article
    {
        Title = dto.Title,  // Will be encoded in view
        Content = sanitized  // Pre-sanitized HTML
    };

    _db.Articles.Add(article);
    _db.SaveChanges();

    return Ok();
}

// View with @Html.Raw (safe because sanitized)
@model Article
<h1>@Model.Title</h1>
<div class="content">
    @Html.Raw(Model.Content)
</div>

Testing

To verify XSS protection is working:

  • Test with XSS payloads: Submit common XSS patterns (<script>alert('xss')</script>, <img src=x onerror=alert('xss')>, etc.) and verify they appear encoded in HTML source
  • Check rendered output: View page source to confirm user input is HTML-encoded (< appears as &lt;, > as &gt;)
  • Test JavaScript context: Verify data in JavaScript strings is properly JavaScript-encoded (quotes escaped)
  • Test URL context: Confirm user data in URLs is URL-encoded
  • Review Razor views: Search for @Html.Raw(), prebuilt IHtmlContent, MarkupString, and values inserted into JavaScript blocks without JavaScriptEncoder
  • Check Content Security Policy: Verify CSP headers are present and properly configured
  • Test DOM-based XSS: Check client-side JavaScript for unsafe DOM manipulation (innerHTML, document.write with user data)
  • Use browser tools: Inspect rendered HTML and check for unencoded user input
  • Run the Roslyn analyzers: dotnet add package Microsoft.CodeAnalysis.NetAnalyzers, built with /p:EnableNETAnalyzers=true. SecurityCodeScan.VS2019 adds XSS-specific rules, but its last release was in 2022, so treat it as a supplement to the maintained analyzers rather than the thing you rely on

Common Pitfalls

  • Assuming Razor protects @Html.Raw() output or pre-built IHtmlContent.
  • Using HTML encoding for JavaScript strings, URLs, CSS, or other non-HTML contexts.
  • Sanitizing rich HTML and then appending unsanitized fragments before rendering.
  • Treating input validation, CSP, or security headers as substitutes for output encoding.
  • Encoding data before storage and then double-encoding or decoding inconsistently later.
  • Forgetting stored XSS paths where database content is later rendered in admin or reporting views.
  • Fixing only the view named in the finding. Sweep shared layouts, partial views and Tag Helpers too - a helper that emits markup is rendered into every page that uses it, so one unencoded value there reappears everywhere the layout does.

Dependencies and Installation

  • System.Text.Encodings.Web provides HtmlEncoder, JavaScriptEncoder, and UrlEncoder for ASP.NET Core and modern .NET.
  • System.Web.HttpUtility.HtmlEncode() is mainly for classic ASP.NET/.NET Framework applications.
  • Ganss.Xss HtmlSanitizer is useful when limited user-provided HTML must be allowed. Use 9.2.1039 or later - it is the first release without the SanitizeDom(string) wrapper-element attribute bypass; the fix carries no advisory of its own, so version checks against GHSA-j92c-7v7g-gj3f (CVE-2026-25543, fixed in 9.0.892) alone will pass an affected version.
  • Keep ASP.NET Core, Razor, sanitizer packages, and security analyzers current.

Additional Resources