security(core): escape "<" in the res.expose script island (stored XSS via app name) - #7949
Open
ar2rsawseen wants to merge 1 commit into
Open
security(core): escape "<" in the res.expose script island (stored XSS via app name)#7949ar2rsawseen wants to merge 1 commit into
ar2rsawseen wants to merge 1 commit into
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The dashboard serializes the exposed
countlyGlobalobject into an inline script block indashboard.html:frontend/express/libs/express-expose.jsproduced 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
</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-changingescape_html), not a return to HTML-entity escaping.Separately, the active-app name is now rendered with
.text()instead of.html()incountly.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(<→<), which changed the runtime values.<does not: it is exactly<after the JS parser reads it. Verified by an eval round-trip:<survives for</script>,</script >,</script/>,</script\t>,</script\n>,<img …>,<!--,<script>— in values and keys.Notes
<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).🤖 Generated with Claude Code