Skip to content

[security][core] escape values at dashboard html sinks the api escaper does not cover - #7954

Closed
ar2rsawseen wants to merge 2 commits into
masterfrom
security/compliance-hub-actions-app-name-escape
Closed

[security][core] escape values at dashboard html sinks the api escaper does not cover#7954
ar2rsawseen wants to merge 2 commits into
masterfrom
security/compliance-hub-actions-app-name-escape

Conversation

@ar2rsawseen

@ar2rsawseen ar2rsawseen commented Aug 18, 2026

Copy link
Copy Markdown
Member

Found while looking at how the dashboard's HTML sinks are fed, alongside #7949.

Countly's model is that API output is HTML-escaped server side by common.escape_html_entities, so a value that came from an API response is already safe by the time a template renders it. This PR covers the two situations where that guarantee does not hold, and were the only ones the sweep found still open.

1. A value the API escaper does not cover

The compliance-hub export/purge history datatable builds an HTML string in onReady and the template renders it. Most of what goes into that string comes from the row, i.e. through common.returnOutput, and is already escaped: those are deliberately left alone, because escaping them a second time would surface the entities literally in the UI.

One value in that string comes from countlyGlobal instead. That object is serialized into the dashboard by express-expose, and that serializer escapes for the JavaScript string context, not the HTML one. It is value-preserving by design (see #7949), so the API's HTML escaping was never applied to this value at any point. It is now escaped where it is interpolated.

2. Dialog bodies that never needed HTML

The populator template and environment delete confirmations rendered their body with v-html, where the bound value is a localized sentence with a name substituted into a {0} placeholder. Both names are HTML-decoded on the way in, so the escaping the API applied no longer held at render time.

Rather than filter what reaches those, they now use text interpolation, which removes the sink. I checked every assignment to this dialog object and every populator locale file that supplies these strings, in all 26 languages present: none contain tags, so nothing renders differently.

The plugins plugin's dependency confirmation is deliberately left on v-html. Its strings genuinely carry markup — plugins.confirm has a <br/><br/> in all 24 translated locale files, even though the default locale no longer does — so converting it would render those literally for every non-English user. The values interpolated into that one are plugin titles from package metadata rather than anything a dashboard account can write, so there is nothing to neutralize. Worth knowing for anyone auditing the same pattern: identical-looking sinks here have opposite correct answers, and the default .properties file is not a reliable guide to what the translations contain.

Scope

The sweep covered v-html bindings, jQuery .html(<var>), innerHTML, insertAdjacentHTML, and every unescapeHtml call site, in this repo and in the enterprise plugins, cross-referencing each sink against whether its source is API-escaped or not.

Site Verdict
compliance-hub countly.models.js actions column fixed here
populator template + environment delete confirmations fixed here
plugins enable/disable dependency confirmation intentional markup, source not writable from the dashboard, left as is
vue/components/vis.js chart tooltips already covered by #7882 / #7883, left alone
dashboards note widget, EP content localization table, EP surveys, EP users note already hardened previously
<textarea v-html> in hooks effects RCDATA, parsed as text
formatTimeAgo / i18n-only bindings no external input
two-factor-auth QR markup generated SVG geometry, and caller's own secret
remaining v-html bindings API-escaped with no decode step in between, inert
app-name accessors in countly.template.js and appIdsToNames consumers already text sinks

Counts, so the sweep's completeness is checkable: 62 v-html bindings here and 60 in the enterprise plugins, 57 .html() hits of which 12 are application code, 45 innerHTML-family hits of which about 20 are application code, and 137 unescapeHtml call sites classified by destination.

Worth recording for future readers: a detached-element .html() is still a live sink, since image loading is not gated on document insertion. The ones above are inert because of their inputs, not because they are detached.

Verification

New file test/unit-tests/plugins.compliance-hub.actions-escaping.js loads the real module in a sandbox and exercises the actual onReady builder, so it tests the shipped code path rather than a copy. It pins both directions, because the failure mode here is symmetric: a value carrying markup must be neutralized, and an already-escaped API value must not be double-escaped.

  • new tests against unpatched code: 3 failing, 2 passing. Against patched: 5 passing. The three failures are exactly the three security assertions.
  • full unit suite: 185 passing before, 190 after (+5, the new tests), with the same 2 pre-existing failures either way (Countly Request, network dependent). Baseline established by re-running with the new file excluded, not assumed.
  • eslint clean.
  • The dialog changes are template-only and carry no test: the guarantee is structural, since text interpolation cannot render markup, and the "nothing renders differently" claim rests on the locale audit described above.

Propagation

Handled in their own PRs rather than by widening this one:

Platform is a straight port, since those frontend files are unchanged from this repo. release.24.05 is not: there the environment delete string is Are you sure you want to delete <b>{0}</b> environment?, so that dialog keeps v-html and escapes the name at the assignment instead of converting the template. Same class of fix, different mechanism, chosen by what each branch's locale files actually contain.

🤖 Generated with Claude Code

ar2rsawseen and others added 2 commits August 18, 2026 14:54
…istory actions column

The export/purge history datatable builds an html string in onReady and the
template renders it, so every value interpolated into it has to be html-safe
before it gets there.

Values taken from the row arrive through common.returnOutput, which
escape_html_entities has already escaped, so they are deliberately left alone:
escaping them a second time would surface the entities literally in the ui.
One value in that string comes from countlyGlobal instead. That object is
serialized into the dashboard by express-expose, whose escaping is for the
javascript string context and is value-preserving by design, so the api's html
escaping never applied to it. It is now escaped where it is interpolated.

Adds test/unit-tests/plugins.compliance-hub.actions-escaping.js, which loads
the real module in a sandbox and exercises the actual onReady builder. It pins
both directions: a value carrying markup is neutralized, and an already-escaped
api value is not double-escaped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ad of html

The populator template and environment delete confirmations rendered their
body with v-html, but the value bound there is a localized sentence with a name
substituted into a {0} placeholder. Both names are html-decoded on the way in,
so the escaping the api applied no longer held by the time they were rendered.

Text interpolation removes the sink instead of filtering what reaches it.
Checked every assignment to this dialog object and every populator locale file
that supplies these strings, in all 26 languages present: none contain tags, so
nothing renders differently.

Left the plugins plugin's dependency confirmation on v-html deliberately. Its
strings do carry markup: plugins.confirm has a <br/><br/> in all 24 translated
locale files, even though the default locale no longer has it. The values
interpolated into that one are plugin titles from package metadata rather than
anything a dashboard account can write.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ar2rsawseen

Copy link
Copy Markdown
Member Author

Superseded by #7970, which consolidates the frontend HTML-sink hardening into a single per-repo PR. The same change (same authorship) is included there. Closing this one.

@ar2rsawseen
ar2rsawseen deleted the security/compliance-hub-actions-app-name-escape branch August 19, 2026 14:17
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.

1 participant