Skip to content

C#: Fix cs/web/xss false positive on Razor tag-helper attribute values - #22628

Open
felickz wants to merge 5 commits into
github:mainfrom
forks-felickz:felickz-csharp-razor-tag-helper-xss-fp
Open

felickz wants to merge 5 commits into
github:mainfrom
forks-felickz:felickz-csharp-razor-tag-helper-xss-fp

Conversation

@felickz

@felickz felickz commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

cs/web/xss currently flags RazorPageBase.WriteLiteral(...) calls that the Razor source generator emits for the value of an HTML attribute on an element that also carries a tag helper (for example, asp-for="Model.Something"). This is a false positive: the Razor codegen brackets these calls between BeginWriteTagHelperAttribute() / EndWriteTagHelperAttribute(), which capture the value into an internal buffer rather than writing it directly to the response. It is not a real XSS sink.

Example

Given a controller action that binds user-provided input to a view model:

[HttpPost]
public IActionResult UpdateProfile(ProfileViewModel model)
{
    if (!ModelState.IsValid)
        return View(model);
    ...
}

and a Razor Pages/MVC view that renders one of its properties through a tag helper attribute:

<input asp-for="DisplayName" type="text" value="@Model.DisplayName" class="textEntry">

cs/web/xss previously reported:

User-provided value flows to here and is written to HTML or JavaScript: Microsoft.AspNetCore.Mvc.Razor.RazorPageBase.WriteLiteral() method.

This is a false positive. The value assigned to the asp-for attribute is captured into an internal string buffer via matching BeginWriteTagHelperAttribute() / EndWriteTagHelperAttribute() calls generated around the WriteLiteral call, so it becomes a tag helper attribute value rather than being written directly to the response as page markup.

Fix

  • AspNetCore.qll: add getBeginWriteTagHelperAttributeMethod() / getEndWriteTagHelperAttributeMethod() to MicrosoftAspNetCoreMvcRazorPageBase.
  • Html.qll: add isBracketedForTagHelperAttribute() and use it to exclude bracketed WriteLiteral calls from MicrosoftAspNetRazorPageWriteLiteralSink. The predicate requires beginCall, writeLiteral, endCall to appear (in that order) in the same basic block, with no other Begin/EndWriteTagHelperAttribute call in between on either side, and all three calls to share an implicit this receiver, so an unrelated bracket (on this or another page instance) can't "adopt" an unbracketed call.

Test coverage

Added RazorTagHelperAttribute.cshtml / .cshtml.g.cs to the existing Security Features/CWE-079/XSS test, covering:

  • A bracketed WriteLiteral(model) (must not alert, suppressed FP).
  • An unbracketed WriteLiteral(model) (must alert, positive control).
  • Two independent bracket pairs back-to-back in one basic block (must not leak into each other).
  • A bare WriteLiteral(model) sandwiched between two unrelated brackets (must still alert).

Full Security Features/CWE-079 suite (6 tests) passes with no regressions. Also validated end-to-end against a small standalone ASP.NET Core Razor Pages reproduction project mirroring the example above: the false positive is no longer reported, while a genuine Html.Raw-based positive control in the same project remains reported.

Change note

Added csharp/ql/lib/change-notes/2026-09-18-razor-tag-helper-attribute-xss-fp.md (category: majorAnalysis).

felickz and others added 3 commits September 18, 2026 10:33
Razor's source generator brackets WriteLiteral calls that populate an
HTML attribute value on a tag-helper-enabled element (e.g. �sp-for)
between BeginWriteTagHelperAttribute()/EndWriteTagHelperAttribute()
calls. The captured text is buffered internally and HTML-attribute-
encoded before being rendered, so it is not a real XSS sink, but
cs/web/xss previously flagged it as one.

- Add getBeginWriteTagHelperAttributeMethod() /
  getEndWriteTagHelperAttributeMethod() to
  MicrosoftAspNetCoreMvcRazorPageBase.
- Add isBracketedForTagHelperAttribute() to Html.qll and use it to
  exclude bracketed WriteLiteral calls from
  MicrosoftAspNetRazorPageWriteLiteralSink, using same-basic-block,
  immediately-adjacent-bracket matching so unrelated bracket pairs
  cannot "adopt" an unbracketed call.
- Add RazorTagHelperAttribute.cshtml(.g.cs) test coverage: a
  suppressed bracketed write, an unbracketed positive control, two
  independent brackets in one basic block, and a bare write sandwiched
  between brackets.
- Add change note (majorAnalysis).

Diagnosed and validated against the real customer reproduction that
motivated this fix (field-security-codeql#257 / github#261): the false
positive (ChangeAccountInfo.cshtml) is no longer reported, while the
genuine Html.Raw-based positive control (ProfileSummary.cshtml)
remains reported. Full CWE-079 test suite (6 tests) passes with no
regressions.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot Code Review correctly flagged that isBracketedForTagHelperAttribute
never related the receivers of beginCall/writeLiteral/endCall, so a bracket
on one page instance could theoretically be mistaken for a bracket around a
WriteLiteral call on a different page (e.g. otherPage.BeginWriteTagHelperAttribute();
this.WriteLiteral(model); otherPage.EndWriteTagHelperAttribute();).

Require all three calls to have an implicit 	his qualifier, which is how
the Razor source generator always emits them, guaranteeing they act on the
same page instance. Re-verified: XSS.ql compiles, all 6 CWE-079 tests pass,
and the real customer reproduction database still shows the FP suppressed
and the genuine Html.Raw positive control still reported.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Shortened to a single terse sentence, matching the depth/style of other
recent change-notes (one bullet, no implementation detail), and called
out the ASP.NET Core Razor Pages/MVC scope.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@felickz
felickz marked this pull request as ready for review September 18, 2026 21:33
@felickz
felickz requested a review from a team as a code owner September 18, 2026 21:33
Copilot AI balanced review requested due to automatic review settings September 18, 2026 21:33

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 review overview

🟡 Changes recommended

The documentation incorrectly promises downstream HTML encoding instead of stating that the call only writes to a temporary buffer.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 Medium severity · 1 Low severity

Open (2)
What changed in this PR

Prevents false-positive C# XSS alerts for buffered Razor tag-helper attribute writes.

Changes:

  • Models Razor tag-helper attribute buffering methods.
  • Excludes correctly bracketed WriteLiteral calls from direct XSS sinks.
  • Adds regression fixtures and a change note.
File Description
Html.qll Detects buffered WriteLiteral calls.
AspNetCore.qll Models tag-helper buffering methods.
RazorTagHelperAttribute.cshtml Provides source-map fixture.
RazorTagHelperAttribute.cshtml.g.cs Adds generated-code test scenarios.
XSS.expected Updates expected query results.
Change note Documents the analysis change.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread csharp/ql/lib/semmle/code/csharp/security/dataflow/flowsinks/Html.qll Outdated
Comment thread csharp/ql/lib/change-notes/2026-09-18-razor-tag-helper-attribute-xss-fp.md Outdated
Copilot Code Review correctly pointed out that bracketing between
BeginWriteTagHelperAttribute()/EndWriteTagHelperAttribute() only proves
the value is captured into a buffer rather than written directly to the
response; it does not, by itself, guarantee that every tag helper later
HTML-attribute-encodes that buffer. Reworded the doc comments and the
change note to justify the exclusion on "not a direct write to the
response" rather than on assumed downstream encoding.

No logic change; XSS.ql compiles and the CWE-079/XSS test still passes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

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 review overview

🟢 Approval recommended

The sink exclusion is narrowly constrained by receiver, control-flow order, and bracket boundaries, with appropriate regression coverage.

Review effort: Balanced
Findings: None

Resolved since last review (2)

@michaelnebel michaelnebel 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.

Thank you very much for doing this @felickz !
I have added a couple of minor comments.
In the description, you mention "ASP.NET Core Razor Pages reproduction project mirroring the example". Which project is that? Perhaps, we should add it to our DCA suite.

Comment thread csharp/ql/lib/semmle/code/csharp/security/dataflow/flowsinks/Html.qll Outdated
Comment thread csharp/ql/lib/semmle/code/csharp/security/dataflow/flowsinks/Html.qll Outdated

@Kamil-0425 Kamil-0425 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Setup

Consolidate the two separate not-exists checks (]i,j[ and ]j,k[) into a
single check over the whole open interval ]i,k[. Since no other
Begin/EndWriteTagHelperAttribute call can share writeLiteral's own control
flow node index j, checking the combined interval is equivalent to
checking both sides separately, but simpler. Also bind BasicBlock bb once
via writeLiteral.getBasicBlock() and reuse it, instead of calling it
independently for each of i, j, and k.

No behavior change: all CWE-079/XSS tests still pass.
@felickz

felickz commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Setup

Thank you very much for doing this @felickz ! I have added a couple of minor comments. In the description, you mention "ASP.NET Core Razor Pages reproduction project mirroring the example". Which project is that? Perhaps, we should add it to our DCA suite.

I detected these FPs while reviewing the intentionally vulnerable project: tobyash86/WebGoat.NET. I built a CodeQL database and ran cs/web/xss with --threat-model=local enabled (so database/file-sourced taint is included, not just request-based) before and after this fix.

Verified against a public reproduction repo (tobyash86/WebGoat.NET) — 14 currently-open cs/web/xss alerts break down as:

🟢 Closed by this fix (12) — Razor tag-helper attribute false positives:

❌ Still flagged (2) — genuine sinks, unaffected by this fix:

Note: I re-ran the (pre-fix) query against a real CodeQL database for this repo under both default settings and --threat-model=local. All 12 results above reproduce identically either way — none of them require --threat-model=local to detect.

Happy to share anything further that is needed for DCA suite.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants