Review HTML-encoding bypasses

Rule ND3133 Security

Matches methods that bypass the HTML encoder that Razor and Blazor apply by default: Html.Raw(), new HtmlString(), new MarkupString(), MvcHtmlString.Create(), and the AppendHtml(string) / SetHtmlContent(string) members of tag helpers and IHtmlContentBuilder.

Each of these members tells the view engine trust this string, it is already HTML. When the string contains data that a user controls (a comment, a profile field, a query string value), a payload such as <script> is rendered as markup and executed in the browser of every visitor: cross-site scripting. This is CWE-79 (OWASP Top 10 A03 Injection).

Compiled Razor views are generated methods and are deliberately included: the issue then points to the view. Methods that also assemble the string (String.Concat(), String.Format(), interpolation, StringBuilder) are reported with the High severity, since the raw fragment is built rather than picked from a trusted source.

Flagged Code
public class CommentViewComponent : ViewComponent {
   public IViewComponentResult Invoke(Comment comment) {
      var html = new HtmlString("<p>" + comment.Body + "</p>");   // flagged: raw HTML
      return View(html);
   }
}
Fixed Code
public class CommentViewComponent : ViewComponent {
   public IViewComponentResult Invoke(Comment comment) {
      return View(comment);   // the view writes @Model.Body, encoded by Razor
   }
}

How to fix

Let the view engine encode the value: write @Model.Body instead of @Html.Raw(Model.Body). When markup must be produced from data, build it with an IHtmlContentBuilder and Append() (encoded) the dynamic parts, or sanitize the fragment with an allow-list HTML sanitizer before wrapping it in an HtmlString.

Suppress the issue when the raw string is a constant or comes from a trusted source, such as a resource file or markup authored by administrators only.

CQLinq source

This rule is written in CQLinq, C# LINQ over the NDepend code model. It ships with every new NDepend project: you can read it, adapt it, or use it as a starting point for your own custom rules.

// <Name>Review HTML-encoding bypasses</Name>
// <Id>ND3133:ReviewHtmlEncodingBypasses</Id>
warnif count > 0

// Third-party members are part of the code model only when the application uses them.
// Razor and Blazor HTML-encode every value by default; these members are the explicit opt-outs.
let rawHtmlMembers = ThirdParty.Methods.Where(m =>

   // @Html.Raw(value) in MVC and Razor Pages, both ASP.NET Core and ASP.NET MVC 5
   (m.SimpleName == "Raw" &&
    m.ParentType.SimpleNameEqualsAny("IHtmlHelper", "IHtmlHelper<TModel>", "HtmlHelper", "HtmlHelper<TModel>") &&
    m.ParentType.FullName.StartsWithAny("Microsoft.AspNetCore.Mvc.", "System.Web.Mvc.")) ||

   // new HtmlString(value), new MarkupString(value), MvcHtmlString.Create(value)
   (m.NbParameters == 1 && (m.IsConstructor || m.SimpleName == "Create") &&
    m.ParentType.FullNameEqualsAny(
      "Microsoft.AspNetCore.Html.HtmlString",
      "Microsoft.AspNetCore.Components.MarkupString",
      "System.Web.HtmlString",
      "System.Web.Mvc.MvcHtmlString")) ||

   // Tag helpers and IHtmlContentBuilder: AppendHtml(string) / SetHtmlContent(string) skip the encoder
   (m.SimpleNameEqualsAny("AppendHtml", "SetHtmlContent") &&
    m.Name.Contains("String") &&
    m.ParentType.FullName.StartsWithAny("Microsoft.AspNetCore.Html.", "Microsoft.AspNetCore.Razor.TagHelpers.")))
   .ToHashSetEx()

where rawHtmlMembers.Any()

// Compiled Razor views are generated methods: they are the primary place where Html.Raw() is used,
// hence generated methods are NOT excluded here.
from m in Application.Methods.UsingAny(rawHtmlMembers)

// The static constructor of a compiled Razor view only builds the tag helper attribute values
// written literally in the markup: new HtmlString("stylesheet") wraps a constant, never user input.
where !(m.IsClassConstructor &&
        m.ParentType.HasAttribute("Microsoft.AspNetCore.Razor.Hosting.RazorCompiledItemMetadataAttribute".AllowNoMatch()) &&
        m.MethodsCalled.Any(mc => mc.IsConstructor &&
           mc.ParentType.FullName == "Microsoft.AspNetCore.Razor.TagHelpers.TagHelperAttribute"))
let rawHtmlMembersUsed = m.MethodsCalled.Intersect(rawHtmlMembers).ToArray()

// A raw fragment assembled with a string operation is the riskiest shape.
let composesString = m.MethodsCalled.Any(mc =>
   (mc.ParentType.FullName == "System.String" && mc.SimpleNameEqualsAny("Concat", "Format", "Join", "Replace", "Insert")) ||
    mc.ParentType.FullNameEqualsAny("System.Text.StringBuilder", "System.Runtime.CompilerServices.DefaultInterpolatedStringHandler"))

select new {
  m,
  rawHtmlMembersUsed,
  composesString,
  Debt = 15.ToMinutes().ToDebt(),
  Severity = composesString ? Severity.High : Severity.Medium
}

//<Expl>
// The method *{0}* calls *{1}*, which emits a string as HTML without
// encoding it. If any part of that string derives from user input, a
// script injected in it runs in the browser of every visitor (XSS).
//</Expl>

// <Desc>
// Matches methods that bypass the HTML encoder that Razor and Blazor
// apply by default: *Html.Raw()*, *new HtmlString()*,
// *new MarkupString()*, *MvcHtmlString.Create()*, and the
// *AppendHtml(string)* / *SetHtmlContent(string)* members of tag helpers
// and *IHtmlContentBuilder*.
//
// Each of these members tells the view engine *trust this string, it is
// already HTML*. When the string contains data that a user controls (a
// comment, a profile field, a query string value), a payload such as
// *<script>* is rendered as markup and executed in the browser
// of every visitor: **cross-site scripting**. This is CWE-79 (OWASP Top
// 10 A03 Injection).
//
// Compiled Razor views are generated methods and are deliberately
// included: the issue then points to the view. Methods that also
// assemble the string (*String.Concat()*, *String.Format()*,
// interpolation, *StringBuilder*) are reported with the *High* severity,
// since the raw fragment is built rather than picked from a trusted
// source.
//
// **Flagged Code**
// ```csharp
// public class CommentViewComponent : ViewComponent {
//    public IViewComponentResult Invoke(Comment comment) {
//       var html = new HtmlString("<p>" + comment.Body + "</p>");   // flagged: raw HTML
//       return View(html);
//    }
// }
// ```
//
// **Fixed Code**
// ```csharp
// public class CommentViewComponent : ViewComponent {
//    public IViewComponentResult Invoke(Comment comment) {
//       return View(comment);   // the view writes @Model.Body, encoded by Razor
//    }
// }
// ```
// </Desc>

// <HowToFix>
// Let the view engine encode the value: write *@Model.Body* instead of
// *@Html.Raw(Model.Body)*. When markup must be produced from data, build
// it with an *IHtmlContentBuilder* and *Append()* (encoded) the dynamic
// parts, or sanitize the fragment with an allow-list HTML sanitizer
// before wrapping it in an *HtmlString*.
//
// Suppress the issue when the raw string is a constant or comes from a
// trusted source, such as a resource file or markup authored by
// administrators only.
// </HowToFix>

Security: all 39 items