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 containsuseStore('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))
continueThe 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
- 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. - 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.
- 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. - 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
- 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. - Make bridging opt-in. An explicit
defineBridge({ … })/exportmarker 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. - Warn on serialisation size and on obvious secrets. A build-time notice for a bridged value over N KB (the
logscase doubled the page) and for keys named*token*,*key*,*secret*,*password*,*webhook*would have surfaced this immediately. - 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-facingVerified: 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.