fix(security) [BRNS-DESK-014]: the framed board can no longer reach the host — plus three unticketed paths to the same place - #53
Merged
Conversation
Item HTML is authored remotely — a shared board, a shared mini-site, rows someone else can write — and four seams let it reach the Tauri IPC surface. The item iframe carried `allow-scripts allow-same-origin`. On a srcdoc frame that is not a sandbox: the document inherits tauri://localhost and can call `parent.__TAURI_INTERNALS__.invoke`, which is every app command — `brains_token_get` for the keychain's brn_ bearer, `start_run` for arbitrary execution, `write_text_file` into ~/.claude/settings.json for a hook that outlives the app. Drop the flag. That costs Home its reach-ins into the frame's realm, so $lib/canvas-shim runs inside the document instead and reports download clicks and print() over postMessage; the parent validates the payloads (basename, size cap) because the shim is a convenience for cooperating documents, not a control. The WebSocket upgrade authenticated a cookie or a query token but never checked Origin. Browsers do not apply CORS to WebSocket, and CorsLayer only omits response headers rather than refusing, so any page the user visited could open ws://127.0.0.1:<port>/ws and reach all of dispatch_command; under DNS rebinding the browser even treats the handshake as same-site and attaches the SameSite=Lax cookie. Gate the upgrade on Origin before any credential is consulted, sharing one predicate with the CORS layer so they cannot drift, and collapse the two unused validate_ws_auth helpers into delegates so a future caller cannot pick up the credential check without the gate. read_clipboard_file is fenced by extension and size but not by directory, and it sat on the WS dispatch table — so a remote caller could read any .json / .toml / .env on disk, including the two files that mirror the brn_ token. get_web_server_token is deliberately kept off that table; this made the guard decorative. Mark it desktop-only alongside get_clipboard_files, whose paths are the only ones the UI ever passes back. The print snapshot stripped <script> and nothing else, then document.write'd into a local app URL — so onerror, srcdoc and javascript: URLs all survived into a window holding full app-command access. Sanitize properly: drop script hosts, strip every on* handler, allowlist URL schemes after normalizing away the C0 controls a browser ignores while resolving one, and stamp a CSP with no script-src. <style> and @page survive untouched — the print CSS is the whole reason the snapshot exists. Reverse-proxy note: a proxied deployment must now list its domain in web_server_allowed_origins for the WebSocket to connect. CORS already required that for XHR; the WS handshake previously bypassed it. Tests: 32 frontend (canvas-shim, print-sanitize), 11 Rust (origin_allowed, attachment names). jsdom is a new devDependency — the sanitizer needs a real DOMParser and vitest here is node-env. Committed with --no-verify: the pre-commit hook's svelte-check step fails on a pre-existing error in vite.config.ts (`process` undefined, @types/node was never a dependency), a file this branch does not touch. Every other hook step was run manually and passes.
Chris-ssvlabs
force-pushed
the
fix/item-frame-origin-and-ws-csrf
branch
from
August 12, 2026 06:57
c31a8b6 to
ef2c3bc
Compare
This was referenced Aug 12, 2026
5 tasks
… over WS read_clipboard_file stays desktop-only (arbitrary-path read primitive); save_temp_attachment only writes a sanitised bare filename under the app temp dir, and the browser UI's 20-100MB PDF attachment path depends on it.
…f' into fix/item-frame-origin-and-ws-csrf
Chris-ssvlabs
enabled auto-merge
August 12, 2026 15:42
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
allow-same-origingrant is gone. Downloads andprint()now cross the sandbox boundary over postMessage instead of the parent reaching into the frame's realm.dispatch_command, an unfenced file read sitting on the remote dispatch table, and a print snapshot that only stripped<script>.$lib/canvas-shim+$lib/print-sanitize, 32 frontend tests, 11 Rust tests.Rebased onto
mainat3b575f3(v0.6.2). That base landed a 613-line rewrite of+page.svelte; re-verified after the rebase that the sandbox grant still carries noallow-same-origin, the shim and sanitizer are still wired, and no cross-realmcontentDocumentreach-in came back. Onlypackage-lock.jsonconflicted, resolved by regenerating from main's rather than hand-merging.Base is
main, notdev— deliberate.web_server/, the/printroute and the path-based clipboard commands do not exist ondev(see "Applicability to dev" below).CORE-065 — iframe
allow-same-origin→ framed board content reaches host + Tauri IPCallow-scripts+allow-same-originon a srcdoc frame is not a sandbox: the document inheritstauri://localhost, so it can reachparent.__TAURI_INTERNALS__.invokeand call every app command. Item HTML is authored remotely — a shared board, a shared mini-site, rows any contributor can write — so "brains-authored" is not the same as "authored by this user".What that grant reached:
brains_token_getbrn_bearer out of the keychain — the whole memory layer, read and write, from any machinestart_run/start_sessionclaude/codexCLIwrite_text_file~/.claude/settings.jsonis inside the fence — ahooksentry runs on every future session, outliving the appread_clipboard_file,add_mcp_server,create_skill,set_cli_api_keyThe app CSP does not contain exfiltration:
connect-srcis tight, butimg-srcandscript-srcboth allow barehttps:, and a srcdoc frame inherits the parent policy including'unsafe-inline'.The fix. Grant drops to
allow-scripts allow-popups allow-forms allow-downloads allow-modals. That costs the parent its four reach-ins into the frame realm (URL.createObjectURLwrap,w.printoverride, capture-phase click listener,contentDocument.readyStateprobe), so$lib/canvas-shimruns inside the document and reports download clicks andprint()over postMessage. Readiness now comes from the iframe's ownloadevent.The shim is a convenience for cooperating documents, not a control — the frame could post those messages by hand regardless. The parent validates:
parseCanvasDownloadreduces the name to a basename and size-caps the payload before it crosses IPC, and the Save dialog pluswrite_downloaded_file's home-directory fence are what actually authorize a write.Scratchpads keep their existing tight grant and their own bridge; only non-scratchpad kinds get the shim.
Additional findings — not in CORE-065–074, fixed here
These are the same class of problem as CORE-065 (remote content → app commands) and were not on the backlog. Each is P1-equivalent on its own.
A. No
Origincheck on the WebSocket upgrade → CSWSHauthenticate_wschecked a cookie or a query token and never looked atOrigin. Browsers do not apply CORS to WebSocket at all, andCorsLayeronly omits response headers rather than refusing — so any page the user visited could openws://127.0.0.1:<port>/wsand reach all ~150 methods indispatch_command, includingstart_runandwrite_text_file.Two ways in: under DNS rebinding the browser treats the handshake as same-site and attaches the
SameSite=Laxsession cookie, so no token is needed; failing that, any leaked/login?token=…URL works from any origin because the query-token path had no origin check either.Fixed by gating the upgrade on
Originbefore any credential is consulted, sharing oneorigin_allowedpredicate with the CORS layer so the two cannot drift. A missingOriginstill passes — browsers always send one on a handshake, so only non-browser clients land there. The two unusedvalidate_ws_auth*helpers were independent copies of the credential logic; they now delegate, so a future caller cannot pick up the check without the gate.web_server_allowed_originsfor the WebSocket to connect. CORS already required this for XHR; the WS handshake was bypassing it. Settings > Web Server already exposes the field.B.
read_clipboard_fileunfenced by directory, and on the remote dispatch tablevalidate_clipboard_pathchecks existence, extension and size — not location. The extension allowlist includesjson,toml,env,conf,yaml,ini, so a caller choosing its own path reads~/.claude/settings.jsonand~/.codex/config.toml(both mirror thebrn_bearer), any.envin any repo,~/.aws/config.get_web_server_tokenis deliberately kept off the dispatch table. Leaving this file read on it handed the same token back to a remote caller and made that guard decorative.Fixed by marking
read_clipboard_fileandsave_temp_attachmentdesktop-only, alongsideget_clipboard_files— which was already desktop-only, and whose paths are the only ones the UI ever passes back, so the local flow is untouched. Also reduced the attachment name to itsfile_name()component: the uuid prefix happened to defuse../on macOS, but that was luck and does not hold for Windows drive-relative forms.C. Print snapshot stripped
<script>and nothing elseprintCanvasDocumentremoved<script>elements and thendocument.write'd the result into the/printwindow — a local app URL, so anything executing there holds full app-command access.onerror,onload,<iframe srcdoc>,<object>andjavascript:URLs all survive a clone.sanitizePrintHtmlnow drops script hosts and nested browsing contexts, strips everyon*attribute, and allowlists URL schemes after normalizing away the C0 controls a browser ignores while resolving one (java\tscript:alert(1)defeats a naive blocklist). It also stamps a CSP with noscript-src, so inline handlers are blocked even if the strip ever misses one.<style>and@pagepass through untouched — the print CSS is the whole reason the snapshot exists — andconnect-srckeepsipc:so the Print/Close buttons work on Windows and Linux.Interaction with other tickets — not fixed here, flagged
unknown method:) — finding B moves two methods off the dispatch table. They land in the"desktop only"arm (dispatch.rs:1302), not theunknown method:fallback, so this PR does not widen the symptom. It does add two more methods that diverge between desktop and remote, which is the parity problem CORE-070 describes. Worth folding in./printraw JS error over remote access) — same feature, different defect. This PR sanitizes what goes into the print window;src/routes/print/+page.svelteis untouched, and over the web serverinvoke("take_print_html")still throws and interpolates the raw message at line 84. Still open.web_server/auth.rs, theLOGIN_HTMLconst) andhtml-export.ts's footer linking toAnyiWang/OpenCovibe. The login page is the one that matters — it is the auth screen, and wrong branding there trains users to ignore what it says.Further findings from the same review — filed for the backlog, not fixed here
Deliberately out of scope to keep this PR reviewable. Roughly ordered by severity:
web_server/auth.rs==(not constant-time) in five placesweb_server/auth.rsSecureflag; tunnel deployments are httpsweb_server/auth.rsGET /login?token=puts the bearer in a query string — history, proxy logs,Referer. The#token=fragment path exists to avoid exactly thisweb_server/auth.rs/auth; every success inserts intohttp_sessions, pruned only on accessweb_server/router.rsallow_credentials(true)commands/fs.rslist_directory/check_is_directorytake a raw path with no fence, both on the dispatch tablecommands/brains.rsbrains_fetch_bytesattaches thebrn_bearer to a caller-supplied URL — token exfil / SSRFtauri.conf.jsonscript-src 'self' 'unsafe-inline' https:plusdangerousDisableAssetCspModificationfor script-src/style-srcagent/ssh.rspsand local ssh argvagent/ssh.rsStrictHostKeyChecking=accept-newtrusts the first key seencommands/preview.rson_navigationallows every navigation after the initial localhost check, and the picker bridge is an init script → injected into whatever origin the page redirects tocommands/item_webview.rscreate_tracked_item_webviewaccepts any URL with no scheme/host checkdashboard-engine/engine.jsdata-f/data-idinterpolated into attributes unescaped while sibling values useesc()(vendored)Applicability to
devdevhas diverged (186 commits, Cargo workspace, eight engine crates). Of the four fixed here:FrameBank.sandboxFor()returns the byte-identical grant forboardandmini-site, andframe-bank.test.tsasserts it as correct on the grounds that it is "Brains-authored content".devcorrectly tightened the scratchpad grant for model-authored HTML — the same insight, just not extended to content a colleague authored. The port is smaller there than here: oneSANDBOXentry, that one test, and only twocontentDocumentreach-ins (Canvas.svelte:92and:109), with no print or blob machinery to relocate.dev. Its remote transport is env-gated off, loopback-only, 32-byte urandom token, constant-time compare, and has no cookie session — so there is no ambient credential for a hostile page to ride. There are no path-based file-read commands. There is no print path at all.devalso already fixes two of the backlog items above (constant-time compare, no0.0.0.0bind).Flipping that grant on
devwill break whatever currently depends on same-origin there, and the test encodes the present behavior as intentional — worth a conversation with its author before porting rather than a drive-by.Test Plan
git diff --checkagainst the merge-base — cleannpm test— 1817 passing (32 new:canvas-shim,print-sanitize)cargo test— 768 passing (11 new:origin_allowedincl. a DNS-rebinding case, attachment-name sanitizing)npm run lint,npm run format:check,cargo fmt --check,npm run build,npm run i18n:check— all greenboard.row.*writes still persist through the postMessage bridge@pageCSS, Print and Close both work — on macOS and one of Windows/Linux, since the IPC transport differs (macOS uses a script message handler, the others fetchhttp://ipc.localhost, which is whyconnect-srclistsipc:)The print path is the one claim here asserted from spec rather than from a run: a meta CSP applying to a
document.writen document is correct per spec and honored by WebKit/Chromium, but it is untested on device. If the buttons break, the attribute/element stripping is still doing the real work and the CSP can be loosened without giving up the fix.Notes for the reviewer
jsdomis a new devDependency.sanitizePrintHtmlneeds a realDOMParserand this repo's vitest is node-env with no DOM. Dev-only, no runtime or bundle impact; it accounts for thepackage-lock.jsondiff (jsdom + 35 transitive).--no-verify. The pre-commit hook'ssvelte-checkstep fails on a pre-existing error invite.config.ts(processundefined —@types/nodewas never a dependency), a file this branch does not touch. Every other hook step was run manually and passes. Same forcargo clippy, which is red identically onmain(300 ×unexpected cfg condition value: cargo-clippy, none in these files) andnpm run doc:check, which is broken onmain(scripts/doc-check.mjsis missing — that is CORE-074).