Skip to content

security(core): escape "<" in the res.expose script island (stored XSS via app name) - #7949

Open
ar2rsawseen wants to merge 1 commit into
masterfrom
security/expose-script-island-xss
Open

security(core): escape "<" in the res.expose script island (stored XSS via app name)#7949
ar2rsawseen wants to merge 1 commit into
masterfrom
security/expose-script-island-xss

Conversation

@ar2rsawseen

Copy link
Copy Markdown
Member

Summary

The dashboard serializes the exposed countlyGlobal object into an inline script block in dashboard.html:

<script><%- javascript %></script>

frontend/express/libs/express-expose.js produced that JavaScript and tried to prevent a breakout by replacing the exact sequence </script>. But the HTML tokenizer also ends a <script> element at </script >, </script/> and other whitespace/slash spellings, which the exact-match replace missed. An application name containing such a spelling therefore broke out of the script block.

Because a global admin's dashboard lists every app, an app admin of a single app could set their own app's name to a </script >-based payload; when any global admin loaded the dashboard, the payload executed in the global admin's own session. From there it can read the session token / api_key and drive global-admin-only endpoints, i.e. escalate a single app-admin account to full instance control.

Requires an authenticated app-admin account, so this is Medium under SECURITY.md.

Root cause

  • The primitive branch escaped only the literal </script> (and <!--), not the other end-tag spellings the parser accepts.
  • escape_js_string (used for object keys) escaped quotes, backticks and control chars but never <, so a hostile key could break out even with the exact spelling.

Fix

Escape every < as < in both serialization paths — string values and object keys. < parses back to <, so every value read out of the exposed object is unchanged; this is the value-preserving JS-string escaping direction of the previous fix (aa33b31, which removed the old value-changing escape_html), not a return to HTML-entity escaping.

Separately, the active-app name is now rendered with .text() instead of .html() in countly.template.js (line 2406) — an independent DOM sink for the same value, and the only .html() sink fed by an app name anywhere in the frontend or plugins (the others render trusted templates, clear content, decode numeric metrics, or i18n strings).

Why this does not break the dashboard

The escaping that broke dashboards historically was escape_html (<&lt;), which changed the runtime values. < does not: it is exactly < after the JS parser reads it. Verified by an eval round-trip:

  • Security: no raw < survives for </script>, </script >, </script/>, </script\t>, </script\n>, <img …>, <!--, <script> — in values and keys.
  • Safety: evaluating the serialized output reproduces the input object byte-for-byte, and matches the previous serializer's output exactly — no dashboard-visible change.

Notes

  • Structurally moving the island to a non-executed <script type="application/json"> data block would retire this class entirely, but is intentionally out of scope here (larger change to a UI being phased out).
  • Reported through the security bug bounty program (received 2026-08-17).

🤖 Generated with Claude Code

…S via app name)

The dashboard serialises the exposed countlyGlobal object into an inline
<script> block (dashboard.html: <script><%- javascript %></script>). The
serialiser only neutralised the exact sequence "</script>", but the HTML
tokeniser also ends a script element at "</script >", "</script/>" and other
whitespace/slash spellings, so an application name containing one of those
broke out of the script block. An app admin of a single app could store such
a name; any global admin who then loaded the dashboard (which lists every app)
executed the attacker's markup in their own session, escalating an app-admin
account to global-admin control.

Escape every "<" as < in both serialisation paths: string values (the
primitive branch, replacing the exact-match "</script>"/"<!--" replaces) and
object keys (escape_js_string). < parses back to "<", so every value read
from the exposed object is unchanged. This continues the value-preserving
direction of the previous fix (aa33b31, which removed the old HTML-entity
escape_html that corrupted values); it does not reintroduce entity escaping.

Also render the active-app name with .text() instead of .html() in
countly.template.js (line 2406), an independent DOM sink for the same value.

Verified: no raw "<" survives for any breakout spelling in values or keys; and
an eval round-trip of the serialiser output reproduces the input object
byte-for-byte and matches the previous serialiser's output (no dashboard-visible
change).

Reported through the security bug bounty programme (received 2026-08-17).

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