
This advisory did not start as vulnerability research. It came out of a penetration test a customer commissioned on their own e-commerce storefront, which runs on nopCommerce, the widely deployed open source ASP.NET Core commerce platform. The finding is a platform weakness, not a misconfiguration unique to that one deployment, so we reported it upstream to the nopCommerce maintainers through responsible disclosure. The client and the target remain confidential; everything below concerns the platform itself.
The engagement began as a gray-box test with a limited, authorized account. It became a white-box test the moment BackBox AI fingerprinted the exact platform version, pulled the matching source tree, and started reading the code that handles user-supplied HTML. What it found there was a stored cross-site scripting vulnerability with two properties that make it worth an advisory: the payload slips past the platform's own HTML sanitizer, and the specific vector we used, an SVG animate element with an onbegin handler, also slipped past the web application firewall fronting the site. The underlying sanitizer flaw is still present, byte for byte, in the latest nopCommerce release.
Advisory Summary
| Field | Detail |
|---|---|
| Platform | nopCommerce (open source ASP.NET Core e-commerce) |
| Affected | Up to and including 4.90.6 (latest release at time of testing); root cause present at least since 4.60.6 |
| Component | HtmlFormatter.EnsureOnlyAllowedHtml() in Nop.Services/Html/HtmlFormatter.cs; user HTML rendered with @Html.Raw() |
| Class | Stored cross-site scripting via incomplete blocklist sanitizer (WAF-evasive payload class) |
| CWE | CWE-79 (Improper Neutralization of Input During Web Page Generation) |
| CVSS 3.1 | 7.6 (AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N) |
| Severity | High |
| Discovery | Client-commissioned penetration test (gray-box to white-box) |
| Advisory ID | BBL-2026-08141 (internal tracking) |
| Disclosure | Reported privately, now tracked publicly at nopSolutions/nopCommerce#8284 |
| Fix | Planned for nopCommerce 5.0; unfixed in every current release (through 4.90.6) |
| Status | Publicly tracked upstream; no patched release yet, no CVE assigned |
From Gray-Box to White-Box, Without Waiting for Permission
Gray-box testing gives you credentials but not source. That is usually where an assessment stalls: you can see behavior, but you are guessing at the code behind it. On this engagement, the guesswork ended early.
nopCommerce identifies itself. The authenticated administration panel prints its exact version in the footer, and the storefront carries a generator tag. Once the version was confirmed, BackBox AI retrieved the corresponding source tag from the public nopCommerce repository and, crucially, the source for several later releases as well (4.70.0, 4.80.0 and 4.90.6). From that point the test was white-box: every observed behavior could be traced to a specific line of code, and every candidate weakness could be checked against later releases to answer the question a client actually cares about, which is whether an upgrade would fix it.
That diff-against-the-future step is the part worth emphasizing. Reading the deployed version tells you what is wrong today. Diffing it against the latest release tells you whether the problem is yours to patch or the vendor's to fix. For the vulnerability in this advisory, the answer was uncomfortable: it is unfixed upstream.
The Sanitizer That Blocks Three Words
nopCommerce lets store operators enter HTML in several fields, product descriptions among them, and renders that HTML back to shoppers. To keep that from becoming a scripting hole, the platform runs the content through a sanitizer, HtmlFormatter.EnsureOnlyAllowedHtml().
The problem is how that method decides what is dangerous. It does not parse the HTML and compare it against an allowlist of safe tags and attributes. It scans the string for a short list of forbidden substrings and, finding none, passes the content through. The forbidden list is essentially three items: javascript, vbscript and onclick.
Everything else survives. onerror, onload, onmouseover, onfocus, onpointerenter, and the SVG animation handlers onbegin and onend are all absent from the list, so a payload built on any of them is not sanitized at all. The filtered output is then written into the page with @Html.Raw(), which performs no encoding of its own. A blocklist that enumerates a handful of bad strings will always lose to an attacker who knows a fourth one, and here there are dozens of fourth ones.
Why an SVG animate onbegin Handler
Any of those unlisted handlers would defeat the sanitizer. We built the proof of concept on SVG animation for a specific reason: the deployment we were testing sat behind a web application firewall, and the firewall was doing pattern matching of its own.
During testing, requests whose body contained conventional injection patterns such as <img or onerror= were dropped outright: the fronting proxy terminated the connection and returned nothing. A classic <img src=x onerror=...> payload never even reached the application. The SVG vector looks nothing like those patterns:
<svg><animate onbegin=alert(document.domain) attributeName=x dur=1s></animate></svg>
There is no <img, no onerror, no javascript:. The <animate> element is legitimate SVG, and onbegin is a legitimate SMIL animation event that fires when the animation starts, which happens automatically as the browser renders the element. The request carrying this payload passed the firewall, the string passed the HtmlFormatter blocklist, and it also passed the rich-text editor's client-side filter, which strips only <script> elements. Three layers of filtering, one payload, zero friction.
This is the reason the finding is tracked as a distinct payload class rather than a footnote to the sanitizer bug. The sanitizer flaw is the root cause; the SVG animation vector is what turns it into something that also defeats the perimeter control an operator might reasonably expect to catch stored XSS.
Proof of Concept
The vector requires an authenticated account that can edit content rendered on the storefront. That is a low bar: a limited catalog or content role is enough, and it does not need full administrative rights. The steps, described generically:
Authenticate with an account permitted to edit product content.
In a rich-text HTML field that the storefront later renders (for example a product short or full description), enter the payload:
<svg><animate onbegin=alert(document.domain) attributeName=x dur=1s></animate></svg>Save through the normal product form. The value survives the client-side editor filter and the server-side
HtmlFormatter, and is stored as entered.Open the corresponding public storefront page. The description is emitted with
@Html.Raw(), the animation begins on load,onbeginfires, and the script executes in the visitor's browser.
We validated the full chain end to end in a real browser: the payload was submitted through the genuine administration product form, and the public product page then executed it, with an alert(document.domain) dialog confirming script execution in the page's own origin. The test payload was removed after each capture, and the store was returned to its prior state.
Impact
A stored XSS in a product description is not confined to the attacker's own session. It executes in the browser of every visitor who opens that page, and in a commerce platform that includes the operations staff who browse the storefront while doing their jobs. That is what elevates this from a content-integrity nuisance to a privilege-escalation path: content that a low-privileged editor controls runs code in a high-privileged administrator's browser.
One nuance keeps this at high rather than critical. nopCommerce marks its authentication cookie HttpOnly, so the injected script cannot simply read the cookie and exfiltrate it. The realistic exploitation path is in-session rather than cookie theft: the script reads the anti-forgery token from the page DOM and drives same-origin authenticated requests with the victim administrator's authority, performing privileged actions from inside their session. The platform's default Content-Security-Policy does nothing to stop this; the deployment we tested shipped a permissive policy (script-src allowing inline and unsafe-eval), which is common and removes CSP as a mitigating layer.
Unfixed Upstream: an Upgrade Does Not Help
The most important result from the white-box phase is not the payload, it is the diff. HtmlFormatter.cs is byte-for-byte identical from release 4.60.6 through 4.90.6, the latest release at the time of testing. The blocklist still contains the same three substrings; onbegin and its siblings still pass; user content is still rendered with @Html.Raw().
There are recently filed CVEs in this area (the CVE-2025-65589 through CVE-2025-65592 cluster), but they describe individual symptom fields, XSS through specific inputs such as attributes, currencies, blog posts and product names, rather than the shared sanitizer weakness underneath them. None of them covers the root cause, and none of them is accompanied by a fix that changes how EnsureOnlyAllowedHtml() works. In practice that makes the underlying flaw a zero-day-class issue: an operator who upgrades to the newest release inherits exactly the same blocklist.
The maintainers have indicated that a corrected sanitizer is planned for the next major version, nopCommerce 5.0. At the time of writing that release has not shipped, and no released version contains the fix. Until 5.0 is available and adopted, remediation has to be applied deliberately, not absorbed by keeping current.
How BackBox AI Found It
No part of this required a novel exploitation primitive. What it required was the discipline to turn a limited-access test into a source-level one and then follow the code. BackBox AI confirmed the platform version from the administration footer, fetched the matching source tag and several later ones, and read the path that user HTML travels: which fields accept it, which method sanitizes it, and how it is finally rendered. Seeing that the sanitizer was a substring blocklist rather than a parser, it reasoned about what the blocklist did not contain, rather than what it did, and reached for handlers the list never mentions.
The choice of the SVG animate onbegin vector specifically was driven by live feedback. The agent observed that the fronting firewall was dropping requests with <img and onerror=, treated that as one more filter to route around rather than a wall, and selected a payload class that satisfied all three constraints at once: unknown to the blocklist, ignored by the editor's client-side filter, and unlike anything the firewall was matching on. Then it validated the whole thing in a headed browser rather than asserting exploitability from code alone. Reading a filter for its gaps, adapting the payload to the perimeter observed in the moment, and proving execution end to end is exactly the kind of contextual, multi-step work the platform is built to carry through an engagement.
Remediation
The fix is the same one that closes every field-specific symptom in this platform at once:
- Replace the substring blocklist with a real allowlist sanitizer that parses the HTML and permits only known-safe tags and attributes. A well-maintained library such as Ganss.Xss (
HtmlSanitizer) is the standard choice for .NET; it strips everyon*attribute and script-bearing element, SVG animation handlers included, rather than chasing a list of forbidden words. - Stop rendering user-influenced content with
@Html.Raw(). Rely on Razor's automatic HTML encoding, and reserve raw rendering for content that has been sanitized server-side. - Enforce a strict Content-Security-Policy as defense in depth:
script-src 'self'withoutunsafe-inlineorunsafe-eval, using nonces or hashes for legitimate inline scripts. This constrains the blast radius of any injection that slips through. - Do not treat a web application firewall as the control that stops stored XSS. On this deployment the firewall caught the obvious patterns and missed the SVG one; pattern matching at the perimeter is a speed bump, not a substitute for sanitizing on the server.
Because the sanitizer is unchanged through the latest release, operators running any current nopCommerce version should apply the sanitizer replacement as a deliberate hardening step and not wait for an upgrade to deliver it.
Disclosure and Status
We reported the weakness to the nopCommerce maintainers privately, coordinated with the client whose engagement surfaced it, following our standard responsible-disclosure practice of giving the vendor time before any public writeup. The maintainers then chose to open a public tracking issue for it themselves, nopSolutions/nopCommerce#8284.
That decision is why we are publishing now, ahead of a fix. Ordinarily we would hold a writeup until a patched release exists; here the vendor has already made the finding public on its own initiative, so a detailed analysis adds understanding without disclosing anything that is not already in the open. The maintainers have indicated the fix is slated for nopCommerce 5.0, which has not yet shipped, so operators on any current release should treat the mitigations above as the near-term remedy rather than waiting for an upgrade.
No CVE is associated with the finding yet. That is not for lack of trying: coordinating an identifier for a root-cause weakness that sits underneath an already-fragmented cluster of symptom CVEs, across MITRE and the relevant CNAs, has been slow going. We track the advisory internally as BBL-2026-08141 in the meantime; that identifier is a placeholder and will be replaced if and when a CVE is issued.
Conclusion
Two lessons carry beyond nopCommerce.
The first is old but stubborn: a sanitizer built as a blocklist of forbidden strings is a losing design. The web platform offers too many ways to run script for any hand-curated list to be complete, and the moment one entry is missed, the raw-rendering sink behind it does the rest. Allowlist parsing is the only approach that fails safe, because it rejects everything it does not explicitly understand.
The second is about layers of defense that look like they overlap but do not. A client-side editor filter, a server-side sanitizer, and a perimeter firewall sound like three independent chances to stop an injection. In this case a single payload satisfied all three, because each was matching on a different, incomplete notion of what "dangerous" looks like. Controls only compose into real defense in depth when at least one of them is complete, and here none was.
Both lessons surfaced because the assessment did not stop at the edge of gray-box access. Confirming the version, pulling the source, and diffing it against the vendor's latest release turned a behavioral test into a root-cause one, and turned a single client's finding into an upstream advisory. If you run nopCommerce, or any platform you would like examined to that depth, request an assessment.