ReviewOS

stacks/stx

Server→client bridge publishes any server binding whose NAME appears in the client script text — including inside comments (leaked session tokens and API keys in a real app)

#1831
Closed glennmichael123 opened this 11 days ago ·
11 days ago

The automatic server→client data bridge decides what to publish by regex-matching identifier names against the raw text of the client script — comments and string literals included. A server binding does not have to be used by client code to be serialised into the HTML; the word merely has to appear somewhere.

In a real app this put a raw session token and every project's API key into the page source. Both were found by reading the served HTML, not by reasoning about the code.

What the app saw

curl of a logged-in /settings:

var token    = "14886291907b059a80baf594aa3425d9ff9ef245e4f016204b58f321b90ad17f…";
var projects = [{"id":"worker-queue-…","name":"Worker Queue","ingest_key":"loghq_e6d55d41145d4d34…"}];

Neither is referenced by client code. They bridged because:

  • token — the client block contains useStore('session').token(), so /\btoken\b/ matches.
  • projects — the client block contains the URL `/api/projects/${id}`, so /\bprojects\b/ matches.
  • logs (50 rows including arbitrary customer JSON payloads) bridged because the word "logs" appears in a comment: // a fragment swap would repaint the old project's logs.

The same mechanism would have published Slack/Discord webhook URLs: the page deliberately builds a masked channelView for display, and the unmasked source array bridged beside it.

Mechanism

packages/stx/src/client-script.ts:775 — every top-level, non-function, non-__/$ binding becomes a bridge candidate:

export function extractBridgeData(context) {
  for (const key in context) {
    if (key.startsWith('__') || key.startsWith('$')) continue
    if (typeof context[key] === 'function') continue
    out[key] = context[key]
  }
}

client-script.ts:846 — publication is decided by a word-boundary regex over the client script source:

// Only bridge identifiers the client actually references…
if (!new RegExp(`\\b${name}\\b`).test(code))
  continue

The comment says "actually references". code is unparsed text, so a comment, a URL, a CSS class, a prop name and an English sentence all count as references.

Why this is a security issue and not just waste

  1. It is invisible at the call site. Nothing in the server block says "this will be published". The author writes const token = cookie('…') — the ordinary way to read a cookie server-side — and ships a credential in the response body.
  2. The trigger is untyped text. Adding an explanatory comment can start publishing a secret. Removing a comment can stop it. Neither shows up in a diff review as a security change.
  3. The body is not the cookie. A token in a Set-Cookie/cookie jar does not travel the way a token in the HTML body does: HAR exports attached to support tickets, "save page as", disk cache, DOM-snapshot and session-replay tools, and screenshots of view-source all capture the body.
  4. It defeats deliberate masking. The app had maskKey(), maskWebhook() and a click-to-reveal gate. All three were bypassed by the bridge publishing the source rows next to the masked projection.

Suggested fixes, roughly in order of value

  1. Parse instead of grep. Match identifiers against the client script's actual AST references (strip comments and string literals at minimum). This alone would have prevented every case above except projects.
  2. Make bridging opt-in. An explicit defineBridge({ … }) / export marker is a one-line change per page and turns publication into a decision. The current default is "publish unless the name happens not to appear", which is the wrong default for a mechanism that can carry credentials.
  3. Warn on serialisation size and on obvious secrets. A build-time notice for a bridged value over N KB (the logs case doubled the page) and for keys named *token*, *key*, *secret*, *password*, *webhook* would have surfaced this immediately.
  4. Document the __ prefix as the security boundary it is. It is currently described as excluding "internal" keys; in practice it is the only opt-out, and it is not mentioned where authors write server blocks.

Workaround for other apps

Prefix anything holding a credential with __ and build an explicit projection for the template:

const __token = requestContext.cookie('…')                       // never bridged
const __rows  = await db.unsafe('SELECT id, name, api_key …')
const rows    = __rows.map(({ api_key: _drop, ...r }) => r)      // template-facing

Verified: raw token 2 occurrences → 0, var token = → 0, keys in the bridge → 0, with rendered visible text byte-identical.

Environment

Fork at f539929dfe with rebuilt dist; reproduced against a live dev server with a real authenticated session.

11 days ago

Fixed in 3b234d5. Reproduced all three cases before changing anything, and one of them needed more than the suggested fix.

Stripping comments and strings is not enough

Suggestion 1 would have caught logs and projects, but not token — the headline case. useStore('session').token() is code, not a comment or a string, so it survives any literal-stripping and \btoken\b still matches through the property.

So reference detection now does both: reads the code with comments and string literals blanked, and only counts a name used as a free identifier. Anything reached through a . (or ?., or #) belongs to somebody else.

Measured on your exact fixtures:

token:    PUBLISHED -> withheld     (matched via session.token())
projects: PUBLISHED -> withheld     (matched via `/api/projects/${id}`)
logs:     PUBLISHED -> withheld     (matched via a comment)
greeting: withheld  -> withheld     (never mentioned)

The part that would have broken apps

stripCommentsAndLiterals already existed in that file — but it blanks template-literal interpolations along with the surrounding text. Reusing it as-is would have withheld every value used only to build a URL:

fetch(`/api/users/${userId}`)   // userId looked unreferenced -> not bridged
                                // -> client throws on an undefined name

That is the likeliest legitimate shape in the codebase, so the stripper now keeps ${...}, with nesting handled (`a${ `b${c}` }d`, and a brace hiding inside a string: `${ ok ? "}" : v }`). It fixes the same blind spot for the auto-import scanner, its other caller, where a helper called only as ${fmt(d)} looked unused.

Suggestions 3 and 4

Tightening the matcher cannot help when the client genuinely references a credential — that is a real reference and the value really does ship. So bridging a name that looks like one (token, secret, apiKey, webhook, …) or a value over 32KB now warns once per name, naming the __ escape hatch.

__ is re-checked at the emitter as well as in extractBridgeData: an author told to rely on it should get that guarantee from whichever path assembles the bridge. And it is now documented in the two places authors actually are — script-types.md, next to where server blocks get written, and security.md — since it was the only opt-out while being described as excluding "internal" keys.

On suggestion 2 (opt-in defineBridge)

Not done, deliberately — it is the right default but it silently breaks every existing page, and that is a maintainer's call rather than mine to make inside a bug fix. Worth noting it can land non-breaking: if a page calls defineBridge({...}) use exactly that, otherwise fall back to the (now much tighter) automatic behaviour. Happy to do it if you want it.

Testing note worth flagging

My first end-to-end test was vacuous and I caught it by running it against the parent commit, where it passed. This path transpiles the client script before the bridge reads it, so a comment fixture is already stripped and the assertion holds with or without the fix. The e2e test now uses the property-access shape, which survives every transformation — verified to render the token into the page at the parent commit. 11 of the 23 new tests fail there.

Full suite 10652 pass in a clean worktree; the one failure is the pre-existing crosswind-arbitrary-values case.

Two residual gaps, both erring toward publishing

  • A regex literal (/token/) is not stripped, so a name inside one still counts. Distinguishing regex from division needs context, and getting it wrong would blank real code and withhold a needed value.
  • An object-literal key ({ token: 1 }) counts, being indistinguishable here from the shorthand { token }.

Both keep the pre-existing behaviour rather than introducing a new leak, and neither is reachable by the mechanism you hit.

Sign in to comment on this issue.