Skip to content

fix(bridge): refuse browser-driven and DNS-rebound requests - #648

Merged
claudiusthebot merged 2 commits into
mainfrom
fix/bridge-cors-dns-rebinding
Jul 25, 2026
Merged

fix(bridge): refuse browser-driven and DNS-rebound requests#648
claudiusthebot merged 2 commits into
mainfrom
fix/bridge-cors-dns-rebinding

Conversation

@claudiusthebot

Copy link
Copy Markdown
Collaborator

Summary

The native bridge sent Access-Control-Allow-Origin: * on every response, answered OPTIONS 204 unconditionally, and never read Host or Origin. Combined with its default posture β€” loopback bind, no token, and authState() returning "ok" for every route when no token is set β€” any website the user visits could drive the agent.

Concretely, a visited page could:

  • read /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: /health is unauthenticated and self-describes as "app":"talon-bridge".

Preconditions are just the documented single-machine setup: frontend: "native", native.host defaulting to 127.0.0.1, no native.token (a token is minted only for a non-loopback bind, index.ts:1206-1210).

The fix β€” two independent guards

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 β†’ 403. Native clients are completely unaffected. Genuine browser clients go in the new native.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.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 (0.0.0.0) skips this β€” there the bearer token is the control.

Also fixed: native.token: "" silently disabled auth

nativeCfg.token ?? mint() treats an empty string as a configured token, so the auto-mint never fired and a 0.0.0.0 bind 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.setHeader once in handle() rather than inside corsHeaders(), so it survives all ~8 writeHead sites 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 BridgeServer over raw HTTP (fetch forbids setting Host, which is half of what needed testing):

[default: loopback, no token]
   OPTIONS /send evil-origin        403
   GET /chats evil-origin           403
   POST /send evil-origin           403
   GET /chats native (no Origin)    200
   GET /chats DNS-rebind Host       403
   GET /health native               200

agent invocations from a web origin: 0

10 new tests. Full non-integration suite passes (4070). tsc --noEmit and oxlint clean.

Not addressed here

The same audit surfaced other mesh issues worth separate PRs β€” every device sharing one bridge token, completeCommand accepting a result with no deviceId, 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

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.
@claudiusthebot
claudiusthebot merged commit 552c13b into main Jul 25, 2026
47 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant