Repository navigation
feat(oauth): exact-match DCR redirect allowlist for the MCP portal - #691
Conversation
The Cloudflare MCP portal can only run this server in automatic mode if POST /oauth/register accepts its registration, and its callbacks are not on OAUTH_ALLOWED_IMPLICIT_HOSTS (nor may they be: that host list also governs every unregistered client_id, per the #585 design). Org rule mctlhq/.github#137 asks for DCR restricted to the portal's callbacks. OAUTH_DCR_REDIRECT_URIS is a new, separate, byte-exact allowlist, empty by default. A registration whose redirect_uris are all on it is accepted without the implicit-host check; one mixing a listed URI with anything else gets invalid_redirect_uri; one naming no listed URI takes the unchanged implicit-host path. Entries are validated at startup like pre-registered redirect URIs. Such a registration is pinned: its client_id is derived from the redirect set, so re-registering is idempotent and the rows it can create are bounded by the list; it is exempt from the 24h registration sweep and the registration cap (the portal keeps using one client_id for every user login); and at /oauth/authorize the current list is re-applied, so removing an entry revokes it. A DCR client naming no scope already receives the full entitlement through narrowGrant; a test now pins that for a pinned client (all five DCRNegotiableScopes for a client-tier principal). Refs mctlhq/.github#137, mctlhq/mctl-gitops#1363 Claude-Session: https://claude.ai/code/session_01T5nLoYSquzMsGsdS64wvry
|
Claude finished @mashkovd's task in 5m 3s —— View job Review posted — changes requested (0 P1, 2 P2, 2 P3)
The exact-match boundary is sound. The pinned path never widens P2 — P2 — Test gap: nothing covers the pinned + P3 (non-blocking), inline on the PR: unescaped Four inline comments and a |
Antigravity (agy) review — informationalModel: gemini-3.8-flash-medium · commit No significant issues found. |
There was a problem hiding this comment.
Has P1/P2 findings, changes requested: 0 P1, 2 P2, 2 P3.
The exact-match boundary itself is right, and the security argument in the PR body holds up: the pinned path never widens AllowedImplicitHosts, never touches an unregistered client_id at /oauth/authorize, and checkPinnedRedirect correctly re-applies the current list to a registration that outlives the sweep. Validation is shared and fail-closed at boot. Test coverage of the in-memory path and of the SQL exemptions is thorough.
Two things to fix:
- P2 (
internal/oauth/server.go:2712) — theuseDBbranch callsEvictOldestClientRegIfOverfor pinned registrations, while the in-memory branch below deliberately skips it (&& !pinned). At the cap, each replayed pinned registration destroys a real dynamic registration while adding no row of its own. - P2 (
internal/db/store_oauth.go:266) — the last-write-winsclient_nameis not display-only: it propagates tooauth_refresh_tokens.client_namefor every subsequent portal login, whichinternal/db/broadcast.go:182uses for audience selection. Since a pinnedclient_idis derivable from public callbacks, any unauthenticated caller can set it. Make the name write-once for pinned rows.
Also worth noting: nothing covers the pinned + useDB wiring — dcr_allowlist_test.go is in-memory only and client_reg_pinned_test.go is SQL only, which is the seam the first P2 fell through.
P3s (non-blocking) are inline: unescaped _ in the LIKE pattern, and no boot-time guard against a preregistered client_id using the reserved tgdcr_ prefix.
Review of #691: - The DB path ran EvictOldestClientRegIfOver for pinned registrations too. A pinned replay adds no row, so at the cap each replay of the portal's public registration deleted a real dynamic registration for free. The pinned branch now skips eviction, as the in-memory path already did. - A pinned row's client_name was last-write-wins, and a pinned client_id is derivable from public callbacks. The name flows into oauth_refresh_tokens.client_name and broadcast audience selection, so it is now write-once (InsertPinnedClientReg, ON CONFLICT DO NOTHING; the in-memory map keeps an existing entry) and the response echoes the stored name. - The pinned exemptions use starts_with instead of a LIKE pattern whose '_' is a wildcard. - A preregistered client_id with the reserved tgdcr_ prefix is refused at boot. TestDCRAllowlist_PostgresPinnedWiring drives /oauth/register through the useDB path in an isolated schema and fails without the eviction or the write-once fix. Claude-Session: https://claude.ai/code/session_01T5nLoYSquzMsGsdS64wvry
|
Claude finished @mashkovd's task in 3m 58s —— View job Review of
|
Antigravity (agy) review — informationalModel: gemini-3.8-flash-medium · commit No significant issues found. |
Write-once still let whoever registered the portal's exact callbacks first
choose the name that flows into refresh-token records and broadcast
audiences. A pinned registration now always stores and answers with
db.PinnedClientName ("Cloudflare MCP portal"); the caller's client_name is
ignored. Write-once stays as a guard for rows written by an earlier build.
Claude-Session: https://claude.ai/code/session_01T5nLoYSquzMsGsdS64wvry
|
Claude finished @mashkovd's task in 5m 35s —— View job Review posted — approved (0 P1, 0 P2, 3 P3)
Both P2s from the previous round are fixed, and in the stronger form rather than the minimum:
Both P3s are handled as well: all three query sites use Re-verified on this commit: a registered pinned client with an unlisted P3s (non-blocking), two inline on the PR: pinned rows are exempt from both the sweep and the cap, so the permanent-row count grows with the subsets of Still open from last round: the CI "must not skip" guard at Approving review bound to |
Antigravity (agy) review — informationalModel: gemini-3.8-flash-medium · commit No significant issues found. |
There was a problem hiding this comment.
No P1/P2 findings (3 P3). Good to merge.
Both P2s from the previous round are properly fixed, and the fixes are the right shape rather than the minimum:
- Cap eviction (
internal/oauth/server.go:2718) — the pinned registration now takes its ownuseDBbranch with noEvictOldestClientRegIfOvercall, matching the in-memory!pinnedguard, andEvictOldestClientRegIfOveralso excludes pinned rows from the count, not just from being the victim.TestDCRAllowlist_PostgresPinnedWiringcovers the handler/store seam the finding fell through, on a real schema-isolated Postgres. client_name(internal/db/store_oauth.go:289,server.go:2714) — the name is now server-assigned (db.PinnedClientName) rather than merely write-once, which closes the first-writer squat that write-once alone left open.InsertPinnedClientRegkeepsON CONFLICT DO NOTHINGas a guard for rows written by an earlier build and refuses a non-pinnedclient_id; the in-memory path keeps the existing entry; the response echoes the stored value. Checked that no other call site can write atgdcr_row through the plainInsertClientRegupsert — both inserts have exactly one caller, insidehandleClientRegistration.
The P3s are also handled: all three query sites use NOT starts_with(client_id, $2) instead of a LIKE pattern with an unescaped _ (and the store test pins tgdcrXnotpinned being swept normally), and validatePreregisteredClient refuses the reserved prefix at boot.
Re-verified on this commit: the exact-match boundary still never widens AllowedImplicitHosts; a registered pinned client with an unlisted redirect_uri fails closed in validateClient rather than falling through to the implicit path; removing an allowlist entry revokes it with no DB change; starts_with is Postgres-only and sweepDB/useDB are both gated, so the SQLite path never reaches it; the $1/$2 reuse in the eviction query binds correctly; boot validation is shared through validateExactRedirectURI with the pre-registration messages unchanged.
Non-blocking P3s, two inline:
- pinned rows are exempt from both the sweep and the cap, so the permanent-row count grows with the subsets of
OAUTH_DCR_REDIRECT_URIS(2^n-1), not its length — 3 rows for the two entries this ships with, thousands if the list grows to one dashboard callback per portal-managed server. Docs wording or a dedicated pinned cap. - the server-assigned
client_namemeans the registration response carries metadata the client did not send (RFC 7591 §3.2.1 permits it); worth a line indocs/cloudflare-portal-compat.mdso a portal-side mismatch is not mistaken for an allowlist bug. - still open from last round: the CI "must not skip" guard in
.github/workflows/build.yml:68covers only./internal/db/, so the newTestDCRAllowlist_PostgresPinnedWiringininternal/oauthcan silently degrade to a skip — adding./internal/oauth/to thatgo testline fixes it. I cannot edit workflow files myself.
I could not run go build, go vet or the tests in this environment (those commands need approval), so this is a static review; the signatures the new Postgres test depends on (db.Open, db.Migrate, db.NewStore, newPostgresTestStore) were checked by hand.
What
Adds
OAUTH_DCR_REDIRECT_URIS, a new, separate, byte-exact redirect URI allowlist forPOST /oauth/register. It is empty by default, so nothing changes for any deployment that does not set it. With it set, the Cloudflare MCP portal can register itself, which letstgmove from manual to automatic (DCR) mode as org rule mctlhq/.github#137 requires.Rules at
/oauth/register:redirect_urisOAUTH_DCR_REDIRECT_URIShttps://claude.ai/cb)400 invalid_redirect_uri, new audit reasonredirect_not_on_dcr_listOAUTH_ALLOWED_IMPLICIT_HOSTSpathA pinned registration:
client_idderived from its canonical (deduplicated, sorted) redirect set (tgdcr_+ a truncated SHA-256). Repeating the registration returns the same client and adds no row, so the rows are bounded by the subsets of the list. seerrsense#75 made the same idempotency choice.ClientRegistrationTTLsweep and from theMaxRegisteredClientscap. This covers both the in-memory map and the Postgres queries. It is needed because the portal registers once and then uses thatclient_idfor every user login. Without it, portal logins would break 24h after registration, since the portal hosts are not on the implicit list./oauth/authorize: eachredirect_urimust still be on the current list, so removing an entry revokes it without a DB change.Entries are validated at startup with the pre-registration rules: https, or http on loopback only; a host; no userinfo, backslash or fragment. A bad entry aborts the boot. The check is shared through a new
validateExactRedirectURI, andvalidatePreregisteredClientnow uses it too, with its error messages unchanged.Scopes (rule 3): no code change was needed.
narrowGrantalready returns the full entitlement when/oauth/authorizenames no scope. A new end-to-end test covers this for a pinned DCR client: register with no scope, authorize with no scope, and a client-tier principal getstelegram:dialogs:read telegram:messages:read telegram:messages:send telegram:messages:pin account:manage. A client that names scopes still gets exactly those.scopein the registration body is still only logged, as before.Rate limit: the per-IP
allowRegisterlimit (10/min by default) runs before anything else, pinned path included.Why, and how it fits with #585
The #585 design says the portal callback must not go into
OAUTH_ALLOWED_IMPLICIT_HOSTSor into any new additive implicit-host list. A host list is too broad and also covers every unregisteredclient_id. This PR follows that:/oauth/register, and never an unregisteredclient_idat/oauth/authorize;#585 made pre-registration the preferred portal path when DCR was compatibility-only. #137 since found that manual mode freezes the portal's tool catalogue, so the org rule now wants DCR restricted to the portal. This adds that path with the same exact-match boundary.
OAUTH_PREREGISTERED_CLIENTSkeeps working, so both paths can run side by side during the switch.docs/cloudflare-portal-compat.mdhas a new "Automatic (DCR) mode" section that explains this.client_nameof a pinned client is server-assigned. It is always the constantdb.PinnedClientName("Cloudflare MCP portal"), stored and returned; the caller'sclient_nameis ignored. The reason is that a pinnedclient_idcan be derived from public callbacks, and the name flows intooauth_refresh_tokens.client_nameand broadcast audience selection. Neither a replay nor whoever registers first can choose it. The row is also written once (ON CONFLICT DO NOTHING) as a guard. Registering never evicts for a pinned client (it adds no row), on the DB path as well as in memory. A preregisteredclient_idusing the reservedtgdcr_prefix is refused at boot. Codes always go to the listed callbacks.Tests
New in
internal/oauth/dcr_allowlist_test.go. These fail on the old code; there, the portal registration is refused onredirect_host_not_allowed:TestDCRAllowlist_ExactSetAccepted: both callbacks accepted withAllowImplicitClient=false, and the client validates against each.TestDCRAllowlist_OneForeignRedirectRejected: a foreign URI in the set gives 400invalid_redirect_uri, includingclaude.ai, and leaves no pinned row behind.TestDCRAllowlist_NearMissesRejected: prefix, trailing slash, suffix, query, fragment, port, host/path case, http, another server id, and leading whitespace are all rejected.TestDCRAllowlist_EmptyListKeepsPreviousBehaviour: with the list empty, the portal registration is refused with the same message as before and claude.ai still gets a randomtgmcp_id. With the list set, the implicit path is unchanged.TestDCRAllowlist_IdempotentAndBounded,TestDCRAllowlist_PinnedSurvivesSweepAndCap,TestDCRAllowlist_RemovedURIStopsAuthorizing,TestNew_RejectsBadDCRRedirectURIs.TestDCRAllowlist_NoScopeGetsFullNegotiableScopes: the full negotiable set when no scope is named, and exactly the named scopes otherwise.Also added:
TestDCRAllowlist_PostgresPinnedWiring(internal/oauth/dcr_allowlist_pg_test.go): drives/oauth/registeron theuseDBpath in an isolated Postgres schema. A pinned replay at the cap evicts no dynamic row, an arbitraryclient_nameis stored and echoed as the server-assigned constant, and the client validates from the DB. It fails without either fix (checked by mutation).internal/db/client_reg_pinned_test.go:TestClientRegPinned_PostgresExemptFromSweepAndEviction, a Postgres-backed test of the SQL exemptions. It is picked up by the CI "must not skip" guard. It fails when the exemption is removed (checked by mutation).internal/config:TestLoadOAuthDCRRedirectURIs.The tests use synthetic callbacks (
*.example.test), following the repo rule not to bake production callbacks into it.Local runs (
TEST_DATABASE_URLpointed at a postgres:17.2 container):Docs updated:
.env.example,README.md(env table),SECURITY.md,docs/cloudflare-portal-compat.md,docs/runbook.md(new reason token,dcr_allowlistedfield on accepted lines),docs/troubleshooting.md, and the config comments ininternal/configandinternal/oauth.CHANGELOG.mdis maintained by release-please.Deployment (not done here; mctl-gitops)
Once this is released, add this to
platform-gitops/services/labs/mctl-telegram/values.yaml, next toOAUTH_PREREGISTERED_CLIENTS:Then switch
tgto automatic mode inmcp-servers.tf(mctlhq/mctl-gitops#1363). KeepOAUTH_PREREGISTERED_CLIENTSuntil the automatic-mode login is confirmed, then it can be dropped.Refs mctlhq/.github#137, mctlhq/mctl-gitops#1363
🤖 Generated with Claude Code
https://claude.ai/code/session_01T5nLoYSquzMsGsdS64wvry