Skip to content

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

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

[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

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

Three confirm dialogs rendered their body with v-html, where the bound value is always a localized sentence with a name substituted into a {0} placeholder. Two of the three could be reached with a value that had been 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 these dialog objects and every locale file that supplies the strings, in all languages present in the repo: none contain tags, so nothing renders differently.

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 fixed here, source was not reachable but the sink was needless
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

The compliance-hub change applies to release.24.05 and to countly-platform main, where the same line exists unchanged. The dialog templates want checking there too. Backports to follow separately rather than widening this PR.

🤖 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>
Three confirm dialogs rendered their body with v-html, but the value bound
there is always a localized sentence with a name substituted into a {0}
placeholder, so it is never markup. Checked every assignment to these dialog
objects and every locale file that supplies the strings: none of them contain
tags, in any language. Text interpolation is what these were always meant to
be, and it removes the sink rather than filtering what reaches it.

Two of the three were reachable with a value that had been html-decoded on the
way in, so the escaping the api applied no longer held by the time it was
rendered.

- populator: template and environment delete confirmations
- plugins: the enable/disable dependency confirmation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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