Public: anyone can read this; nobody can change it.

The connect flow page goes on only to the authorize URL stored on the flow, so a crafted link cannot choose the client or the callback #69

opened this view on origin

Check · Review · Land · Live — No review yet. Next: reviewer, no review yet.

Merges on the origin

**Security.** The connect flow page (`/settings/origins/flows/:id`) took its authorize URL from its own query string (`?to=`). It checked only two things: that the URL was on the instance's origin, and that it had a PKCE challenge. So a crafted link to a forge's flow page could choose another OAuth application's client id, with that application's callback, on the same instance. The page then offer…

0 files · +0 −0

waiting checks and the preview wait for a writer to approve this head

Check on trevato-apps/forge: running, as of

Checks and the preview wait for a writer to approve this head

A guest

This pull comes from a guest: its preview runs without the app's variables or a forge token until a person who can write here approves it.

This pull cannot land as it is, whatever its review says, because it changes guarded paths: (the diff) (it could not be read).

opened this view on origin

Security. The connect flow page (/settings/origins/flows/:id) took its authorize URL from its own query string (?to=). It checked only two things: that the URL was on the instance's origin, and that it had a PKCE challenge. So a crafted link to a forge's flow page could choose another OAuth application's client id, with that application's callback, on the same instance. The page then offered it as "Continue to the instance". A person who trusted the forge's page could approve an application that the forge did not start. The flow row already seals the authorize URL (target_enc, for the short link), so the query string was never necessary.

Now flowTarget (src/origins/oauth.ts) opens the URL sealed on a pending flow, and the page links to that URL and to no other. The page checks the URL again first: the instance's own /login/oauth/authorize, the flow's client_id and redirect_uri, and a code_challenge. Starting a flow redirects to the bare flow page, with then=migrate when it started on the migrate page. A to that an older link still has must be exactly the stored URL. Any other to gets "This connect cannot be continued from here". A pending flow past its ten minutes says "This connect link expired" and shows a fresh Connect for the same host. That button keeps then=migrate.

The device flow, the short link (/go/:code), the poll and the callback are unchanged. Nothing new is stored, and there is no migration.

Tests

bun 1.3.13, a scrubbed environment (no agent variables), Chromium 151:

  • test/origin-flow-page.test.ts: 5 pass, 0 fail. One of them is new here: a connect started from migrate keeps then=migrate and no to, and its expired page starts again from migrate.
  • test/oauth-connect-browser.test.ts (FORGE_CHROMIUM set, so it ran and did not skip): 1 pass, 0 fail. It clicks Authorize and Continue and checks that the consent screen is asked for with no CSP refusal.
  • With the origin, OAuth, migrate and cross suites, in one bun test --timeout 60000 call: 432 pass, 0 fail across 33 files.
  • bun scripts/gate.ts and tsc --noEmit: clean.
the 33 files

origin-flow-page, oauth-connect-browser, origins, origins-oauth, origins-operator, origins-reconnect, origins-security-auth, origins-security-provider, origins-security-push, origins-sync, origins-sync-settled, web-origins, web-origin-link, mcp-origins, mcp-origin-apps, push-through, trusted-hosts, trusted-hosts-ui, steps-lists, no-polling, refresh-meta, mirror-outbound, mirror-out, carried, migrate, migrate-after, tui-migrate, apps-feedback-mirror, and the five cross files reserved-names, studio, shards, check-list, hollow (each test/<name>.test.ts).

🤖 Generated with Claude Code

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs