fix(bridge): refuse browser-driven and DNS-rebound requests - #648
Merged
Conversation
The native bridge answered every response with `Access-Control-Allow-Origin: *`, answered OPTIONS 204 unconditionally, and never read `Host` or `Origin`. Its default posture is loopback with NO token (`native.host` defaults to 127.0.0.1, and a token is minted only for a non-loopback bind), and `authState()` returns "ok" for every route when no token is set. Those combine badly. Any web page the user visits could call `http://127.0.0.1:19880` cross-origin, be allowed by the wildcard, and: - read /chats, /history, /logs, /config and /devices β transcripts, daemon logs, and the GPS coordinates of every registered phone; - POST /send, which runs an agent turn with full tool access. That is arbitrary code execution on the host, from a visited web page. Port discovery is trivial: /health is unauthenticated and self-describes as "talon-bridge". Two independent guards, because they stop different attacks: - ORIGIN. Browsers attach `Origin` to cross-origin requests and page scripts cannot forge it; native clients (Electron main, Flutter, curl, talon-node) omit it entirely. So a non-allowlisted Origin means "a web page is driving us" and is refused 403. Native clients are unaffected. Browser clients that genuinely need access go in the new `native.allowedOrigins`, which is echoed back explicitly β the wildcard is gone for good, including on the preflight, since a 204 that green-lights any origin IS the permission the browser wanted. - HOST. A hostname that resolves to 127.0.0.1 makes the request same-origin, so no Origin is sent and the check above never fires. Host is now pinned to loopback spellings or the configured bind. A wildcard bind skips this β there the bearer token is the control. The grant is applied with `res.setHeader` once in `handle()` rather than inside `corsHeaders()`, so it survives all ~8 `writeHead` sites without threading the origin through each. Getting this wrong is easy and quiet: my first attempt set it only on the preflight, so an allow-listed origin passed CORS and then could not read a single response. There is a test pinning that specifically. Also fixed: `native.token: ""` disabled auth entirely while looking configured. `nativeCfg.token ?? mint()` treats an empty string as a real token, so the auto-mint never fired and a `0.0.0.0` bind served the LAN unauthenticated with TLS up. Easy to hit from `"token": "${BRIDGE_TOKEN}"` with the variable unset. Empty now counts as absent. Verified against a live BridgeServer over raw HTTP (fetch forbids setting Host): with defaults, every browser-origin request and every rebound Host is refused and zero agent invocations get through; native no-Origin clients and /health still work; with an allowlisted origin the full flow works and carries the echo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI's typecheck caught two mistakes the local test run did not: a .ts import specifier (allowImportingTsExtensions is off) and a missing required BridgeServer option. I added the file after my last typecheck and only re-ran vitest, which resolves .ts happily.
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 native bridge sent
Access-Control-Allow-Origin: *on every response, answeredOPTIONS204 unconditionally, and never readHostorOrigin. Combined with its default posture β loopback bind, no token, andauthState()returning"ok"for every route when no token is set β any website the user visits could drive the agent.Concretely, a visited page could:
/chats,/history,/logs,/config,/devicesβ transcripts, daemon logs, and the GPS coordinates of every registered phone;POST /send, which runs an agent turn with full tool access β arbitrary code execution on the host.Port discovery is trivial:
/healthis unauthenticated and self-describes as"app":"talon-bridge".Preconditions are just the documented single-machine setup:
frontend: "native",native.hostdefaulting to127.0.0.1, nonative.token(a token is minted only for a non-loopback bind,index.ts:1206-1210).The fix β two independent guards
Origin. Browsers attach
Originto cross-origin requests and page scripts cannot forge it; native clients (Electron main, Flutter, curl, talon-node) omit it entirely. So a non-allowlistedOriginmeans a web page is driving us β 403. Native clients are completely unaffected. Genuine browser clients go in the newnative.allowedOrigins, echoed back explicitly. The wildcard is gone, including on the preflight β a 204 that green-lights any origin is the permission the browser was asking for.Host. A hostname that resolves to
127.0.0.1makes the request same-origin, so noOriginis sent and the check above never fires.Hostis now pinned to loopback spellings or the configured bind. A wildcard bind (0.0.0.0) skips this β there the bearer token is the control.Also fixed:
native.token: ""silently disabled authnativeCfg.token ?? mint()treats an empty string as a configured token, so the auto-mint never fired and a0.0.0.0bind served the LAN unauthenticated with TLS up β looking hardened. Easy to hit from"token": "${BRIDGE_TOKEN}"with the variable unset. Empty now counts as absent.Implementation note
The grant is applied via
res.setHeaderonce inhandle()rather than insidecorsHeaders(), so it survives all ~8writeHeadsites without threading the origin through each one.This is easy to get subtly wrong: my first attempt set it only on the preflight, so an allow-listed origin passed CORS and then couldn't read a single response. There's a test pinning exactly that.
Verification
Against a live
BridgeServerover raw HTTP (fetchforbids settingHost, which is half of what needed testing):10 new tests. Full non-integration suite passes (4070).
tsc --noEmitandoxlintclean.Not addressed here
The same audit surfaced other mesh issues worth separate PRs β every device sharing one bridge token,
completeCommandaccepting a result with nodeviceId, device commands broadcast to all SSE clients including transfer tokens, and unbounded registry growth. Happy to take those next; this PR is scoped to the remotely-reachable one.π€ Generated with Claude Code