Skip to content

Security: Replace NoScripts blacklist with allowlist HTML sanitization - #780

Closed
KrzysztofPajak wants to merge 4 commits into
developfrom
feature/html-sanitization-allowlist
Closed

Security: Replace NoScripts blacklist with allowlist HTML sanitization#780
KrzysztofPajak wants to merge 4 commits into
developfrom
feature/html-sanitization-allowlist

Conversation

@KrzysztofPajak

Copy link
Copy Markdown
Member

Summary

Replaces the bypassable NoScriptsAttribute regex blacklist with an allowlist-based sanitization architecture using HtmlSanitizer (Ganss.Xss).

Security Issue

The prior blacklist had 24 verified bypasses including:

  • <img src=x onerror\n=alert(1)> (no pattern tolerates whitespace before =)
  • (onfocus not in list)
  • <script src=//evil> (needs closing tag to match)
  • (entity-encoded)

Coverage was narrow (5 catalog models only), API DTOs were unprotected, and enforcement relied on each controller checking ModelState.IsValid.

Exploit path: Vendor → Product.FullDescription → stored XSS → Editor.cshtml:16 Html.Raw → admin session. The same page mints an antiforgery token for ElFinder file manager.

Solution

Input-side sanitization

  • Add [SanitizeHtml] / [NoHtml] markers to model properties
  • New HtmlSanitizationFilter (global, registered alongside ValidationFilter)
    • Rewrites marked properties before action body runs
    • Recurses into Locales collections
    • Covers API DTOs on same code path
  • Applied to all rich-text models (previously 5 catalog, now 15+ models)

Output-side sanitization

  • Html.RawSanitized helper for Razor views (~50 storefront + Theme.Modern sinks)
  • Both Editor.cshtml copies (shared, vendor panel)
  • 6 JS-string interpolations now use JavaScriptEncoder

Legacy data

  • Migration MigrationSanitizeStoredRichText at 2.4 (stored data bypass)
    • Runs only on upgrade from 2.3; current 2.4 users must run manually
    • Needed because Vue JSON islands bind with v-html

Testing

  • 52 new unit tests verify every bypass + filter behavior
  • End-to-end verified: seed payload to Mongo → migration sanitizes → page renders clean
  • All 515 existing tests still pass
  • Full solution builds clean

Files Changed

  • New: Sanitizer service, filter, marker attributes, migration, tests
  • Deleted: NoScriptsAttribute
  • Modified: 100 files (models, views, DTOs, configs)

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings August 13, 2026 15:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@KrzysztofPajak
KrzysztofPajak force-pushed the feature/html-sanitization-allowlist branch from 8129579 to ce7431b Compare August 13, 2026 15:22
- Add HtmlSanitizer (Ganss.Xss) package for robust sanitization
- Implement IHtmlSanitizationService with allowlist-based sanitization
- Remove regex blacklist NoScriptsAttribute from validation layer
- Add [SanitizeHtml] / [NoHtml] marker attributes and global HtmlSanitizationFilter
  to sanitize on write, covering both MVC and API models on same code path
- Apply sanitization markers to all rich-text models (Blog, News, Page, Course,
  Document, Knowledgebase, Vendor, etc.) previously unprotected
- Sanitize on render via Html.RawSanitized helper for Razor views and both
  Editor.cshtml copies (shared, vendor panel); fixes the critical sink where
  vendor FullDescription executes in admin session with live antiforgery token
- Add SanitizerAllowedIframeHosts config (youtube/vimeo/google by default);
  relative URLs permitted for self-hosted video from file manager
- Fix 6 unencoded JS-string interpolations with JavaScriptEncoder, 1 attribute-
  context Html.Raw with encoding
- Add 52 unit tests covering all 24 verified NoScripts bypasses, filter behavior,
  and config overrides

Why: The prior blacklist regex was bypassable (newlines before =, onfocus,
entity-encoded javascript:, unclosed <script src>), covered only 5 catalog
models, was skipped by API DTOs, and enforcement relied on each controller
remembering ModelState.IsValid. Vendor could store XSS in FullDescription and
it would execute in admin session on a page minting an antiforgery token.
@KrzysztofPajak
KrzysztofPajak force-pushed the feature/html-sanitization-allowlist branch from ce7431b to 9885656 Compare August 13, 2026 15:24
Decided to rely solely on write-side [SanitizeHtml]/[NoHtml] enforcement via
HtmlSanitizationFilter. Content stored before this change ships remains
unsanitized until re-saved; this is an accepted residual risk for legacy data,
not addressed by a migration or render-side defense-in-depth.
…filter

- Remove HtmlSanitizationFilter and its DI/pipeline registration; ASP.NET Core
  already invokes DataAnnotations attributes on model bind, so no custom
  reflection-based filter is needed
- SanitizeHtmlAttribute / NoHtmlAttribute now inherit ValidationAttribute and
  reject (rather than silently rewrite) values containing disallowed markup,
  resolving IHtmlSanitizationService via ValidationContext.GetService - the
  supported way for a DataAnnotations attribute to reach a DI service, since
  ASP.NET Core constructs ValidationContext with HttpContext.RequestServices
- IHtmlSanitizationService reworked from rewrite (SanitizeRichText/StripHtml)
  to detection (ContainsDisallowedRichText/ContainsMarkup): runs the same
  allowlist sanitizer and reports whether anything would be removed, using a
  [ThreadStatic] flag toggled by the library's Removing*/FilterUrl events
  instead of comparing sanitized output strings (which would false-positive
  on pure reformatting, e.g. an implied <tbody> or re-spaced CSS)
- Fixed a real detection gap found while switching: a literal <body>/<html>/
  <head> tag in the input merges into AngleSharp's document root and its own
  attributes never reach RemovingAttribute, so <body onload=alert(1)> passed
  through undetected. Closed by explicitly checking those three root elements
  for any attribute of their own after SanitizeDom - verified against the
  library directly before and after the fix
- This gap mattered more under the validation-attribute model than it would
  have under the filter: validation-only means a value that passes is stored
  byte-for-byte unchanged, so an undetected payload is not just unflagged but
  written verbatim
- Rewrote HtmlSanitizationServiceTests for the new boolean detection API; new
  SanitizeHtmlAttributeTests exercises both attributes through
  Validator.TryValidateObject with a real DI-backed ValidationContext,
  matching how ASP.NET Core model validation invokes them
- All 146 Grand.Infrastructure.Tests pass; full solution builds clean
{
if (string.IsNullOrWhiteSpace(html)) return false;

_disallowedContentSeen = false;
//attributes on that wrapper, so any attribute there means the raw input smuggled one in.
if (HasOwnAttributes(document.DocumentElement) || HasOwnAttributes(document.Head) ||
HasOwnAttributes(document.Body))
_disallowedContentSeen = true;
{
if (string.IsNullOrWhiteSpace(text)) return false;

_disallowedContentSeen = false;
//into the document root and its own attributes never reach RemovingAttribute
if (HasOwnAttributes(document.DocumentElement) || HasOwnAttributes(document.Head) ||
HasOwnAttributes(document.Body))
_disallowedContentSeen = true;
if (!isInlineImage)
{
e.SanitizedUrl = null;
_disallowedContentSeen = true;
if (string.Equals(tagName, "IFRAME", StringComparison.OrdinalIgnoreCase) && !IsAllowedIframeUrl(url))
{
e.SanitizedUrl = null;
_disallowedContentSeen = true;
Html.Raw(spec.ValueRaw) was correct there - ValueRaw is already
WebUtility.HtmlEncode-d for the Option-type spec branch that renders this
color-square title (GetProductSpecificationHandler.cs:51). Auto-encoding it
again would have double-encoded the value; not a real vulnerability.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants