Only an authorized member can connect a Facebook or Instagram page, and only back to their own console

FixMediaService
Shipped
September 17, 2026 at 3:13 AM UTC
Author
Kamo
Commit
08b81d7

The Meta connect flow trusted its URL. GET **************** took connectionId and returnUrl from the query string with no session, and state was unsigned base64(connectionId|returnUrl). Anyone who knew a connection's UUID could run the flow with their OWN Facebook account and overwrite that organization's page token, so the org's Messenger / Instagram inbox would receive the attacker's page. And returnUrl was an open redirect on the API host: **************** answered 302 to example.invalid (seen live). Now nothing about which connection is written, or where the browser goes next, comes from a URL: - POST /connect-start (new) needs the member's session and MANAGE_CRM_SETTINGS, the right every other change to a social connection needs. The connection must belong to the member's organization (another org's answers like a missing one) and be MESSENGER or INSTAGRAM. It stores a single-use intent in Redis (member, org, connection, and a return URL OAuthReturnUrlPolicy approved) and answers { startUrl } on the redirect URI's host. - GET /start?intent= redeems the intent with GETDEL, mints a random single-use state held server-side, and sets a browser-binding cookie (HttpOnly, Secure, SameSite=Lax, path-scoped; only its SHA-256 is stored). A missing, spent or legacy ?connectionId= request is a 400 page, never a redirect. - GET /callback redeems the state with GETDEL, clears the cookie, refuses a browser that is not the one that started it, re-checks that the member is still eligible and still in the connection's organization, and redirects only to the stored return URL. The page-token exchange itself is unchanged. OAuthReturnUrlPolicy accepts an https URL with a path on a console origin this organization's members use (its own live domain, each organization up its security-provider chain, the platform's), all chosen by OrgLinkHosts, and rebuilds it from its parsed parts; anything else becomes the org's own console plus the screen's path. The settings screen change that calls connect-start ships in kamo-internal. Until it deploys the old button keeps failing the way it already did: it navigated to /api/media/... on the console host, which has no such route. Tests: MetaOAuthControllerTest walks the flow through MockMvc with a Graph API that WOULD connect the page, so each refusal is the check's doing. Disabling the cookie check, the eligibility re-check, the org check or single use each fails a test. OAuthReturnUrlPolicyTest pins accepted and refused URLs. Full suite: 953 tests, 0 failures.

All changes

Like what you see shipping?

All of it arrives in your workspace on its own. Start on the free plan and read this page again in a month.

Start Free ForeverView Pricing