Please report security issues privately to the repository owner. Do not file a public issue for an exploitable vulnerability.
bscode is an AI coding assistant that the user runs against their own browser. The threat model has three actors:
- The user — runs the app, supplies the task, owns the browser tab. They authorize everything bscode does.
- The agent — an LLM the user chose, executing in a worker the user authorized via API key. The agent's output is user-directed code: by typing "make me a Vue todo app", the user is asking the agent to write code that will run in their browser.
- External targets — any URL the agent fetches, file the agent reads, or external service it calls.
Trust boundary: the agent is treated as an extension of the user. Code the agent emits and the user previews runs in the user's browser — that's the product, not a vulnerability. Code that leaks out of one user's session and into another's is.
The Live Preview pane (apps/web/src/components/Terminal.tsx)
renders agent-generated HTML in an iframe with:
sandbox="allow-scripts allow-same-origin allow-forms allow-modals"
Chrome surfaces a console warning that this combination can escape sandboxing. The combination is intentional:
- allow-scripts — agent demos commonly include JS (calculators, canvas games, todo apps). Without it the preview is a screenshot.
- allow-same-origin — Monaco-style demos, anything using
localStorage/IndexedDB/ relativefetch()URLs needs a real origin. Without it, those APIs throw. - allow-forms — agent demos commonly include
<form>submissions. - allow-modals — agents sometimes use
confirm()/alert()for UX flows.
Why this is acceptable in our threat model: the HTML inside the iframe is code the user asked the agent to produce. Running it in the user's browser is the product. The sandbox is still useful as defense-in-depth: it scopes the iframe to its srcDoc and prevents it from auto-navigating the top frame.
Do not relax further. Removing the sandbox attribute entirely would let the iframe navigate the top frame on form submit.
The D2 diagram card (@wasmagent/ui-cards-react/src/D2Card.tsx)
uses a strictly tighter sandbox:
sandbox="allow-scripts"
No allow-same-origin, no allow-forms. The iframe runs only a tiny
postMessage script (rendered-height reporter) atop static D2-compiled
SVG. With a null origin even a maliciously-crafted D2 source can't
read host cookies, localStorage, or top-frame DOM.
Browser-driving tools (tools-browser/src/cdp.ts,
tools-browser/src/playwright.ts) execute arbitrary JS in a browser
the agent controls. They're meant for the agent's own scratch pages
— never point them at the user's logged-in browser session. The
agent could exfiltrate cookies trivially. Always pass an isolated
wsEndpoint / fresh Playwright context.
- API keys — stored in
localStoragekeyed by provider. The user controls them. The browser sends them to the worker over the open wire by default; in production setBSCODE_CLIENT_TOKENto require an additional bearer. - Session files — stored per-session in worker KV under
file:<path>and indexed byX-Session-Idheader. Cross-session reads are blocked by the per-session FileTreeManager (verified inapps/worker/src/app.test.ts). The header is not authenticated; it's an isolation token, not an auth token. For multi-user prod deployments, deriveX-Session-Idfrom a signed JWT. - Version history —
apps/worker/src/agents/FileTreeManager.tskeeps the last 10 versions of each file in memory. They're cleared onDELETE /filesand on per-file delete. Versions are not encrypted — assume anything written to a file is queryable until the user explicitly clears the workspace.
For traceability, the following classes of issue were identified and closed in the cross-project audit:
- Cross-tenant version data leak —
globalFileTreewas a singleton; switched to per-sessionMapkeyed onX-Session-Id. - DELETE leaving phantom versions — version map is now cleared alongside the KV delete.
- Rate limiter bypass via corrupted KV — limiter now fails closed for one window when the value is malformed JSON or wrong shape.
- JWT verifier accepting tokens missing
sub— sub presence andnbf(with 60s clock skew) are now validated. - CDP exception details swallowed —
Runtime.evaluateresults now checkexceptionDetailsand throw with the page error text. - Playwright extract returning
""on per-selector errors — now re-throws with a structured per-selector message.
Each finding has a regression test pinning it. Re-running bun test
across worker + web confirms all five remain closed.
- SEC-013 (commit 22791ae) —
assertWorkspacePathnow rejects absolute paths,..traversal segments, NUL/C0 control bytes, DEL, and bidi-override marks (U+202A–U+202E + U+2066–U+2069). The earlier code letwrite_file("../../etc/passwd")reach a downstream shell tool that DID interpret the path. - SEC-014 (commit 2ff2a06) —
run_commandsimulation branch used to evaluate model-supplied JS viaFunction(), making the Worker realm itself the sandbox. Removed; replaced with a no-op hint that points operators to wire a sandboxed kernel. - SEC-015 (commit 16171b1) —
run_commandblock list missed the bare-root formrm -rf /because the regex used\b(a word boundary), which doesn't match between/and EOS. Now anchors on(?:\s|$|\w)so the bare root, trailing-space, and trailing- segment forms all block. - SEC-016 (commit fc2aab0) —
useImport.importFromDirectoryfeature-detected the File System Access API with"showDirectoryPicker" in window, which returns true even when the property isundefined(failed polyfill). Replaced withtypeof ... !== "function"so any non-function value routes to the actionable hint instead of crashing with TypeError mid-call. - SEC-017 (commit 7a4c03d) —
useAgent.onEventrebuilt the entire TokenStats object from scratch, droppinglastModelIdwhenever amodel_doneevent arrived without amodelId. UI tooltip flickered to undefined between calls. Fixed by spreading...prevfirst.
If deploying bscode beyond a single trusted user:
- Set
BSCODE_CLIENT_TOKENand verify it on every/run. - Front the worker with a JWT-issuing auth layer; derive
X-Session-Idfrom the signedsubclaim (don't trust client headers alone). - Set
BSCODE_ALLOWED_ORIGINto the exact preview origin; don't leave it*. - Enable Cloudflare Rate Limiting (binding-based) in addition to the in-app sliding-window limiter — the in-app limiter is best for shaping traffic, but a binding-based limit handles surges the worker shouldn't even spend CPU on.
- Audit every model provider key — bscode supports many providers and every configured key is a bill-line for an attacker who gets through the auth layer.