Repository navigation
Conversation
…ts only Port bc3's Client ID Metadata Document support, recognized tier only: a URL client_id from a host OAUTH_CIMD_ALLOWED_HOSTS names is fetched, validated and provisioned as a public, untrusted client on the authorization POST; the consent GET and denials never persist. Off unless OAUTH_CIMD_ENABLED is true, and never ahead of issuance; the metadata advertises client_id_metadata_document_supported only while on. The fetch pins Surfguard's public address, ignores proxies, follows no redirects, verifies TLS, and holds one 3s deadline, a 5KB body and a 16KB wire budget.
OAUTH_CIMD_ALLOWED_HOSTS alone fetches nothing: metadata documents stay off until OAUTH_CIMD_ENABLED is true, which no destination sets.
|
@codex review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
Security findingsAdvisory findings (1)
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
🛡️ Codex Security Review · Automatically triggeredSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
|
@codex review |
There was a problem hiding this comment.
🛡️ Codex Security Review · Automatically triggered
Here are some automated security review suggestions for this pull request.
Reviewed commit: 67538b5a5d
ℹ️ About Codex security reviews in GitHub
This is an experimental Codex feature. Security reviews are triggered when:
- You comment "@codex security review"
- A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review
Once complete, Codex will leave suggestions, or a comment if no findings are found.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 67538b5a5d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…policy A shared fetch budget was one anyone signed in could spend from the consent screen, refusing a client's first authorization to everyone; budgets are now per person, with the bulkhead bounding fetches overall. Fetches resolve under Surfguard's :iana_special_use policy and refuse an answer set mixing public and blocked addresses. A client_id array no longer reaches find_by as an IN list, and a metadata document client is reachable only through its URL.
|
@codex review |
|
Codex Review: Didn't find any major issues. Keep them coming! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
|
Status at 1b74b96. Draft, held for Jeremy.
|
* origin/oauth-token-digests: Refuse an OAuth parameter that arrives as a list where one value is meant
#3236's require_single_valued_parameters now refuses client_id[]= at authorize before any lookup, so set_client drops its own string check. A metadata document client is still reached only through its own URL. The array test now asserts the shared refusal with the switch off and on, and that no client query runs.
|
@codex review |
|
Codex Review: Didn't find any major issues. Keep it up! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
🛡️ Codex Security Review · Automatically triggeredSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
* origin/oauth-token-digests: (48 commits) Bring main's tests up to this branch: Ruby 4, webhook attribution, token digests Pin the fizzy_mcp accessory to fizzy-mcp-server v0.1.0 by digest Scope webhook dispatch by account as well as board Test the reviewer audit against the structured log line production writes Revoke on the email alone, and record each use in the request's structured log Remove the reviewer's access tokens and passkeys on sign-out, so a code holder can't keep a way back in Wire the reviewer sign-in into production from Deploy/Fizzy Close the reviewer's live sockets on sign-out, and keep the identity id out of Sentry End the reviewer's sessions on rotation, so the old code's sessions don't outlive it Refuse a code that normalizes to nothing, not just a blank one Let the directory reviewer sign in with a fixed code, SaaS only and off unless configured Refuse docker-dev's shared default port in a git worktree Ruby 4.0.9 (#3234) Retry the offline clear only in an active worker Retry clearing old offline pages whenever the worker starts Clear offline pages rendered under an older rendering version Re-key every fragment and page ETag past crass 1.0.7 Bump crass to 1.0.8 in Gemfile.saas.lock, and test rich text style scrubbing Bump crass to 1.0.8 for GHSA-pq4g-8rxf-4g29 story accepted ...
* origin/oauth-token-digests: Filter the code parameter by its whole name, so code_challenge stays readable
* origin/oauth-token-digests: Redact retired refresh tokens from logged SQL
…ping partway * origin/oauth-token-digests: Make this branch's migrations safe to run again after stopping partway Make this branch's migrations safe to run again after stopping partway Make the OAuth migrations safe to run again after stopping partway
* origin/oauth-token-digests: Answer 404 from a dark endpoint however many times it's asked
Draft, held for Jeremy. Stacked on #3192 (
oauth-token-digests). Part of the hosted Fizzy MCP plan (hosted-plan.md, "Then CIMD"), alongside the audience and token-exchange lanes.Claude and ChatGPT connectors identify themselves by a Client ID Metadata Document: the
client_idis the https URL of a JSON document they host (draft-ietf-oauth-client-id-metadata-document). This lets them connect without dynamic registration. It's ported from bc3'sOauth::ClientMetadata, and covers the recognized tier only.Behaviour
Off unless configured.
OAUTH_CIMD_ENABLED=trueturns it on, and only while issuance is on (Oauth::Availability.metadata_documents_enabled?). The metadata addsclient_id_metadata_document_supported: trueonly while it's on, as bc3's does (metadata_controller.rb#L107-L108). Without that member, clients fall back to DCR.Allowlisted hosts only.
OAUTH_CIMD_ALLOWED_HOSTSis a space-separated list of exact hosts, for productionmcp.claude.ai claude.ai claude.com chatgpt.com, the same four as bc3. These are environment values, not credentials (bc3#13306).saas/config/deploy.ymlenv.clearsetsOAUTH_CIMD_ALLOWED_HOSTS: "mcp.claude.ai claude.ai claude.com chatgpt.com"for every SaaS destination (Kamal merges destinationenv.clearover the base). That alone fetches nothing: it stays dark untilOAUTH_CIMD_ENABLED=true, which no destination sets. Turning it on is Jeremy's call.Identifiers are exact strings.
Oauth::ClientMetadata::Urlaccepts:client_idwidth. Widening it would be a type change.oauth_clients.client_id's MySQL collation folds case. SoOauth::Client.find_presentedcompares the stored id exactly after the lookup, and a case variant of a provisioned URL names no client.Two modes, since GETs read from a replica:
client_id_metadata_documentclient.Document validation (
Oauth::ClientMetadata::Document):client_idmember must exactly equal the URL;client_secret*members;none), including when it prefers another method but listsnoneintoken_endpoint_auth_methods_supported(ChatGPT does; the bc3#13578 lesson);grant_types, if present, must includeauthorization_code; others are ignored;redirect_urisis required, at most 10;read/write, and silence offers both;client_name, or the host.Redirects: a metadata document client is held to the self-registered rules, https or loopback, and gets an error redirect before consent only to loopback (
vetted_redirect?).Consent: shows "Name set by the app at host".
Cleanup: metadata document rows left without a grant for 30 days are swept with DCR clients. The next authorization provisions them again.
Caching: the document's
Cache-Control/Expires, clamped to between 5 minutes and 24 hours (default 1 hour). A stale row revalidates withIf-None-Match, and a 304 extends it. Concurrent writers use a compare-and-swap onmetadata_fetched_at, and a creation race adopts the winner through the unique index.Fetch budgets, per person: 6 per URL per minute and 60 per host per hour, counted in
Rails.cacheunder the signed-in identity, failing open. Deviation from bc3, whose budgets are global: a consent GET fetches without provisioning, so anyone signed in could spend a shared budget and refuse a client's first authorization to everyone. The fetcher's bulkhead and deadline bound Fizzy's fetches overall, and answer a retryable 503 rather than refusing the client. A client already provisioned that is over budget keeps its stored document.Only a string names a client: a
client_id[]array never reachesfind_byas anINlist. A metadata document client is reachable only through its URL, so the switch, the exact comparison and the refresh always apply to it.The new outbound fetch: SSRF defences
This is new egress from Fizzy to URLs a client names, limited to the allowlisted hosts.
Oauth::ClientMetadata::Fetcher, ported from bc3::iana_special_usepolicy. That refuses private, loopback and link-local addresses, plus every IANA special-purpose range, including NAT64, AMT and AS112, which the default policy admits. An answer set that mixes public and blocked addresses is refused whole (Surfguard.resolve_public_ip). The connection is pinned to the address it admitted (ipaddr), so DNS rebinding can't move it.nil, soHTTP(S)_PROXYcan't reroute the fetch.Timeout;Content-Lengthand streaming;BudgetedIO#rbuf_fill;max_retries = 0;invalid_client_metadata, or 503 withRetry-Afterunder pressure), and none is stored.OAUTH_CIMD_ENABLEDto anything else, or block egress. Clients already authorized keep refreshing.Migration (row 14 for the runbook)
20261019150000adds tooauth_clients, which is empty in production, in one bulk ALTER:client_id_metadata_documentmetadata_fetched_atmetadata_expires_atmetadata_etagNo foreign keys, no index, and no change to any existing column. The ALTER ran with an explicit
ALGORITHM=INSTANTon MySQL 8.4 against the pre-migration table definition with a row in it.Not ported from bc3, on purpose
DomainQuota: not needed with an allowlist.FetchLease, the cross-process site fetch slots: the bulkhead, the budgets and the deadline bound it. Revisit if the allowlist widens.disable!): Fizzy has none. The allowlist and the switch are the containment.BudgetedIOis tested against a scripted io; theon_connectseam is asserted to exist.private_key_jwtand PAR: a document that offers onlyprivate_key_jwtis refused.Expected conflicts with the sibling lanes
app/controllers/oauth/authorizations_controller.rb:set_clientand its new helpers, beside B'sresourcehandling.app/views/oauth/authorizations/new.html.erb: oneelsifin the Application block, beside B's account picker.app/models/oauth/client.rb: two scopes and three small methods.app/controllers/oauth/metadata_controller.rb, one member in theshowhash; C changesgrant_types_supported.db/schema.rbanddb/schema_sqlite.rb: the version line and theoauth_clientsblock.config/environments/production.rb: the OAuth block.app/models/oauth/availability.rb: one method.All of these are additive, so restacking should resolve by keeping both sides.
The coming DCR allowlist lane (
DCR_ALLOWED_HTTPS_HOSTS, loopback, cursor/claude schemes) will share:app/models/oauth/client.rb#validate_redirect_uri: this PR makes metadata document clients follow the self-registered redirect rule (self_registered?). If DCR's rule narrows to an allowlist, decide whether metadata document clients follow it too. Their redirects are already bound to an allowlisted document host, so they probably shouldn't.config/environments/production.rbandsaas/config/deploy.ymlenv.clear: neighbouring env lines.app/models/oauth/availability.rb, if DCR gets its own switch.Tests
On thelio at
67538b5a5(before the review fixes; CI covers the head):Rubocop is clean. The new tests run with outbound network disabled (
WebMock.disable_net_connect!):test/models/oauth/client_metadata/: url, document, fetcher, budgeted_io, resolvertest/integration/oauth_client_metadata_documents_test.rbtest/models/oauth/client_test.rbThe case-variant test branches on adapter: MySQL's case-folding collation refuses the variant's row, while SQLite gives it a separate row. Either way the variant never resolves to the original client.