fix(security): name the rejected OAuth endpoint in the banner (#7578) - #7739
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Sound shape: names identity only, reuses the gate's own decode budget and pattern primitives, fails closed, and deliberately defers auto-discovery. Suggestions
[DESIGN-REVIEWED] 21a1e51 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All evidence is gathered. The change decomposes cleanly, every item is declared, and the one depth risk is a counted sibling rejection surface. Final review: First-Principles-Verdict: CONCERNS The banner fix is cause-level and earns its place, but the connections mint path rejects the same URLs with a bare reason code — an unfixed, undeclared sibling. What this change shipsIntent: let a user see which OAuth endpoint the scanner rejected so they can write the
Watch
[FIRST-PRINCIPLES-REVIEWED] 21a1e51 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsEverything verifies: No findings. [OPUS-REVIEWED] 21a1e51 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/security.py:12807 -- False positive or not applicable? A repository writer can comment: |
1f10ec9 to
91c578f
Compare
|
|
|
91c578f to
e4d0fb0
Compare
|
e4d0fb0 to
c604803
Compare
|
c604803 to
54d0665
Compare
|
54d0665 to
7e96bc3
Compare
|
|
The MCP OAuth rejection message "URL contained credential or exfiltration
pattern" named no URL and no host, so a user hitting an unlisted identity
provider could not tell which endpoint tripped the scanner or what to write
into oauth_endpoints.json.
- security.py: new `sanitized_oauth_endpoint(url)` helper returns the
lowercase host + path of a rejected authorization URL. Query, fragment,
port, and userinfo are never included. Both components are scanned at
every percent-decode layer up to the gate's own _MAX_URL_DECODE_PASSES
budget, with unquote_plus (plus-delimited form encoding folds to spaces
before matching) and against the same pattern families the rejection can
fire on (fixed credentials, bare-secret runs, the _EXFIL_PATTERNS set).
A credential-bearing or budget-exhausting path self-redacts to the
shared tag; a credential-bearing hostname makes the helper return None;
a non-ASCII host is surfaced in IDNA A-label (punycode) form. Both
components are length-capped; unparseable URLs return None. The boolean
`oauth_url_contains_credential` API is unchanged.
- chat_runner.py: the rejection banner names the sanitized host+path in
its content — which also spells the expected
{"additional_authorization_endpoints": [{"host": ..., "path": ...}]}
entry shape — and inside the `error` meta field, the field the
dashboard's failed banner actually renders. No new meta keys: no
shipped surface reads any.
- security.py: add Miro's MCP authorization endpoint (mcp.miro.com,
/authorize) to _OAUTH_AUTHORIZATION_ENDPOINTS — maintainer-verified via
RFC 8414 metadata, matching the reporter's independent read.
- docs/system-specs/modules/security.md updated in the same commit.
- Tests: sanitization invariants (query/PKCE values never echoed; single-,
double-, past-budget-encoded, and plus-delimited credential paths all
redact; credential in a DNS label returns None; IDNA A-label surfacing;
truncation; None fallbacks), banner content/error/meta assertions, and a
Miro corpus entry pinning the endpoint as approved.
Scope: issue items 1-2 (+ the one-line item 4 endpoint addition).
Item 3 (end-to-end docs) is tracked in #7579; RFC 8414 auto-discovery
remains an open design question.
Refs #7578
8b57d0d to
21a1e51
Compare
bolichen97
left a comment
There was a problem hiding this comment.
sanitized_oauth_endpoint names only the host and path and reuses the gate's own decode budget (_MAX_URL_DECODE_PASSES) with unquote_plus plus the same three pattern families the rejection fires on, so it cannot echo the bytes the gate refused: an unsafe path collapses to the shared redaction tag, an unsafe or percent-encoded-userinfo authority returns None, a still-decodable component at the budget fails closed, and the post-IDNA re-scan closes the nameprep-normalizes-into-a-credential case. Putting the endpoint in meta["error"] is the correct field given McpOAuthBanner renders that one, and dropping the zero-consumer meta keys keeps the payload honest.
…ion card (#7765) The MCP OAuth banner (PR #7739) names the sanitized endpoint of a URL the credential gate rejects, but the two sibling connection-flow rejection surfaces still reported opaquely: the cold mint set only reason=mint_url_rejected, and warm's blocked slugs carried no endpoint identity. Without the host+path the user cannot know what to write into oauth_endpoints.json, so the failure reads as unfixable. The mint variant was attempted in PR #7739 round 1 and removed after Semgrep (python-logger-credential-disclosure) and CodeQL (py/clear-text-logging- sensitive-data, taint from oauthUrl into the logger) flagged the logger path. This surfaces the endpoint on a NON-LOG channel instead: mint.py computes security.sanitized_oauth_endpoint(oauth_url) at the terminal rejection and stores it as MintState[rejected_endpoint], which rides the card-facing view into ConnectionMintState.rejected_endpoint; the Connections card renders a new mint_failure_url_rejected_endpoint string when present, falling back to the existing unnamed message when the helper returns None (credential-bearing host, userinfo, or unparseable URL). The logger/audit lines stay slug-only, so no inline suppression or CodeQL barrier is needed -- the card view is not a sink either scanner models. warm.py keeps its codified release semantics for a credential-bearing slug (test_a_url_carrying_a_credential_is_refused_rather_than_stored): the claim is released, and the ensuing cold mint re-hits the same URL and names the endpoint through the mint surface above, so the warm rejection gains the endpoint identity without converting a screened premint into a terminal failure. docs/system-specs/modules/security.md records the per-site scanner decision. Refs #7739, #7578
…ion card (#7765) The MCP OAuth banner (PR #7739) names the sanitized endpoint of a URL the credential gate rejects, but the two sibling connection-flow rejection surfaces still reported opaquely: the cold mint set only reason=mint_url_rejected, and warm's blocked slugs carried no endpoint identity. Without the host+path the user cannot know what to write into oauth_endpoints.json, so the failure reads as unfixable. The mint variant was attempted in PR #7739 round 1 and removed after Semgrep (python-logger-credential-disclosure) and CodeQL (py/clear-text-logging- sensitive-data, taint from oauthUrl into the logger) flagged the logger path. This surfaces the endpoint on a NON-LOG channel instead: mint.py computes security.sanitized_oauth_endpoint(oauth_url) at the terminal rejection and stores it as MintState[rejected_endpoint], which rides the card-facing view into ConnectionMintState.rejected_endpoint; the Connections card renders a new mint_failure_url_rejected_endpoint string when present, falling back to the existing unnamed message when the helper returns None (credential-bearing host, userinfo, or unparseable URL). The logger/audit lines stay slug-only, so no inline suppression or CodeQL barrier is needed -- the card view is not a sink either scanner models. warm.py keeps its codified release semantics for a credential-bearing slug (test_a_url_carrying_a_credential_is_refused_rather_than_stored): the claim is released, and the ensuing cold mint re-hits the same URL and names the endpoint through the mint surface above, so the warm rejection gains the endpoint identity without converting a screened premint into a terminal failure. docs/system-specs/modules/security.md records the per-site scanner decision. Refs #7739, #7578
…ion card (#7765) The MCP OAuth banner (PR #7739) names the sanitized endpoint of a URL the credential gate rejects, but the two sibling connection-flow rejection surfaces still reported opaquely: the cold mint set only reason=mint_url_rejected, and warm's blocked slugs carried no endpoint identity. Without the host+path the user cannot know what to write into oauth_endpoints.json, so the failure reads as unfixable. The mint variant was attempted in PR #7739 round 1 and removed after Semgrep (python-logger-credential-disclosure) and CodeQL (py/clear-text-logging- sensitive-data, taint from oauthUrl into the logger) flagged the logger path. This surfaces the endpoint on a NON-LOG channel instead: mint.py computes security.sanitized_oauth_endpoint(oauth_url) at the terminal rejection and stores it as MintState[rejected_endpoint], which rides the card-facing view into ConnectionMintState.rejected_endpoint; the Connections card renders a new mint_failure_url_rejected_endpoint string when present, falling back to the existing unnamed message when the helper returns None (credential-bearing host, userinfo, or unparseable URL). The logger/audit lines stay slug-only, so no inline suppression or CodeQL barrier is needed -- the card view is not a sink either scanner models. warm.py keeps its codified release semantics for a credential-bearing slug (test_a_url_carrying_a_credential_is_refused_rather_than_stored): the claim is released, and the ensuing cold mint re-hits the same URL and names the endpoint through the mint surface above, so the warm rejection gains the endpoint identity without converting a screened premint into a terminal failure. docs/system-specs/modules/security.md records the per-site scanner decision. Refs #7739, #7578
…ion card (#7765) The MCP OAuth banner (PR #7739) names the sanitized endpoint of a URL the credential gate rejects, but the two sibling connection-flow rejection surfaces still reported opaquely: the cold mint set only reason=mint_url_rejected, and warm's blocked slugs carried no endpoint identity. Without the host+path the user cannot know what to write into oauth_endpoints.json, so the failure reads as unfixable. The mint variant was attempted in PR #7739 round 1 and removed after Semgrep (python-logger-credential-disclosure) and CodeQL (py/clear-text-logging- sensitive-data, taint from oauthUrl into the logger) flagged the logger path. This surfaces the endpoint on a NON-LOG channel instead: mint.py computes security.sanitized_oauth_endpoint(oauth_url) at the terminal rejection and stores it as MintState[rejected_endpoint], which rides the card-facing view into ConnectionMintState.rejected_endpoint; the Connections card renders a new mint_failure_url_rejected_endpoint string when present, falling back to the existing unnamed message when the helper returns None (credential-bearing host, userinfo, or unparseable URL). The logger/audit lines stay slug-only, so no inline suppression or CodeQL barrier is needed -- the card view is not a sink either scanner models. warm.py keeps its codified release semantics for a credential-bearing slug (test_a_url_carrying_a_credential_is_refused_rather_than_stored): the claim is released, and the ensuing cold mint re-hits the same URL and names the endpoint through the mint surface above, so the warm rejection gains the endpoint identity without converting a screened premint into a terminal failure. docs/system-specs/modules/security.md records the per-site scanner decision. Refs #7739, #7578
…ion card (#7765) The MCP OAuth banner (PR #7739) names the sanitized endpoint of a URL the credential gate rejects, but the two sibling connection-flow rejection surfaces still reported opaquely: the cold mint set only reason=mint_url_rejected, and warm's blocked slugs carried no endpoint identity. Without the host+path the user cannot know what to write into oauth_endpoints.json, so the failure reads as unfixable. The mint variant was attempted in PR #7739 round 1 and removed after Semgrep (python-logger-credential-disclosure) and CodeQL (py/clear-text-logging- sensitive-data, taint from oauthUrl into the logger) flagged the logger path. This surfaces the endpoint on a NON-LOG channel instead: mint.py computes security.sanitized_oauth_endpoint(oauth_url) at the terminal rejection and stores it as MintState[rejected_endpoint], which rides the card-facing view into ConnectionMintState.rejected_endpoint; the Connections card renders a new mint_failure_url_rejected_endpoint string when present, falling back to the existing unnamed message when the helper returns None (credential-bearing host, userinfo, or unparseable URL). The logger/audit lines stay slug-only, so no inline suppression or CodeQL barrier is needed -- the card view is not a sink either scanner models. warm.py keeps its codified release semantics for a credential-bearing slug (test_a_url_carrying_a_credential_is_refused_rather_than_stored): the claim is released, and the ensuing cold mint re-hits the same URL and names the endpoint through the mint surface above, so the warm rejection gains the endpoint identity without converting a screened premint into a terminal failure. docs/system-specs/modules/security.md records the per-site scanner decision. Refs #7739, #7578
Reconnecting the Figma MCP server fails at the OAuth consent banner with "authentication failed: URL contained credential or exfiltration pattern": its authorization endpoint is absent from the code-owned `_OAUTH_AUTHORIZATION_ENDPOINTS` allowlist, so the generic credential/exfiltration heuristic fires on the high-entropy PKCE state/code_challenge params of an otherwise-normal consent URL. Figma is a shipped/common MCP provider, so until this lands users must hand-edit the operator keystone `oauth_endpoints.json` to connect it. Add the (host, path) pair, taken from the provider's RFC 8414 metadata reached via RFC 9728 protected-resource discovery on its MCP url: - Figma: www.figma.com/oauth/mcp (issuer https://api.figma.com) Cover it with a LEGIT_OAUTH_URLS corpus entry (real consent-URL shape), mirroring the pattern established for Superhuman in kirodotdev#5967. This PR originally also covered Miro; that entry landed separately on main via kirodotdev#7739 and was dropped here during rebase. Original change by Paul SANTUS (psantus). Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
Reconnecting the Figma MCP server fails at the OAuth consent banner with "authentication failed: URL contained credential or exfiltration pattern": its authorization endpoint is absent from the code-owned `_OAUTH_AUTHORIZATION_ENDPOINTS` allowlist, so the generic credential/exfiltration heuristic fires on the high-entropy PKCE state/code_challenge params of an otherwise-normal consent URL. Figma is a shipped/common MCP provider, so until this lands users must hand-edit the operator keystone `oauth_endpoints.json` to connect it. Add the (host, path) pair, taken from the provider's RFC 8414 metadata reached via RFC 9728 protected-resource discovery on its MCP url: - Figma: www.figma.com/oauth/mcp (issuer https://api.figma.com) Cover it with a LEGIT_OAUTH_URLS corpus entry (real consent-URL shape), mirroring the pattern established for Superhuman in kirodotdev#5967. This PR originally also covered Miro; that entry landed separately on main via kirodotdev#7739 and was dropped here during rebase. Also trim the date from the neighboring Miro comment: main outgrew the comment-history baseline for this file after kirodotdev#7739 landed (issues kirodotdev#9384, kirodotdev#9385), so any PR touching it failed the gate. Dropping the one dated narration span returns the file to its baselined count; the verification claim itself stays. Original change by Paul SANTUS (psantus). Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
Reconnecting the Figma MCP server fails at the OAuth consent banner with "authentication failed: URL contained credential or exfiltration pattern": its authorization endpoint is absent from the code-owned `_OAUTH_AUTHORIZATION_ENDPOINTS` allowlist, so the generic credential/exfiltration heuristic fires on the high-entropy PKCE state/code_challenge params of an otherwise-normal consent URL. Figma is a shipped/common MCP provider, so until this lands users must hand-edit the operator keystone `oauth_endpoints.json` to connect it. Add the (host, path) pair, independently corroborated by the drive operator via live RFC 9728 protected-resource discovery and the provider's RFC 8414 metadata reached via RFC 9728 protected-resource discovery on its MCP url: - Figma: www.figma.com/oauth/mcp (issuer https://api.figma.com) Cover it with a LEGIT_OAUTH_URLS corpus entry (real consent-URL shape), mirroring the pattern established for Superhuman in kirodotdev#5967. This PR originally also covered Miro; that entry landed separately on main via kirodotdev#7739 and was dropped here during rebase. Also trim the date from the neighboring Miro comment: main outgrew the comment-history baseline for this file after kirodotdev#7739 landed (issues kirodotdev#9384, kirodotdev#9385), so any PR touching it failed the gate. Dropping the one dated narration span returns the file to its baselined count; the verification claim itself stays. Original change by Paul SANTUS (psantus). Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
Summary
The MCP OAuth rejection banner said only "URL contained credential or exfiltration pattern" — no URL, no host — so a user connecting a remote MCP server whose authorization endpoint is outside the hardcoded allowlist (
_OAUTH_AUTHORIZATION_ENDPOINTS) could not tell which URL tripped the scanner or what to write into the documented remedy file (oauth_endpoints.json).Refs #7578
What changed
security.sanitized_oauth_endpoint(url)(new): returns the lowercase host + path of a rejected authorization URL, safe to surface. Query, fragment, port, and userinfo are never included. Both components are scanned at every percent-decode layer up to the gate's own_MAX_URL_DECODE_PASSESbudget, withunquote_plus(plus-delimited form encoding folds to spaces before matching) and against the same pattern families the rejection can fire on (fixed credentials, bare-secret runs, and the_EXFIL_PATTERNSset — private-key headers, SSH keys, token shapes). A credential-bearing or budget-exhausting path self-redacts to the shared[REDACTED: credential]tag; a credential-bearing hostname makes the helper returnNone(a host is an identity — a redacted host would name nothing); a non-ASCII host surfaces in IDNA A-label form (defuses homoglyph spoofing). Length caps on both. The booleanoauth_url_contains_credentialAPI is untouched.chat_runner.py): names the sanitized endpoint in the banner content — which also spells the{"additional_authorization_endpoints": [{"host": ..., "path": ...}]}entry shape — and inside theerrormeta field, which is the field the dashboard's failed banner actually renders (verified againstMcpOAuthBanner.tsx). No new meta keys: no shipped surface reads any (per First Principles review, an earlier revision'srejected_host/rejected_path/remedy_shapekeys were removed as zero-consumer riders).("mcp.miro.com", "/authorize")added to the builtin set — maintainer-verified via RFC 8414 metadata at mcp.miro.com/.well-known/oauth-authorization-server, matching the reporter's independent read (issue item 4's one-line addition; no auto-discovery built).docs/system-specs/modules/security.mdOAuth section updated in the same commit.Scope
Issue items 1–2 (plus the one-line item-4 endpoint addition). Remaining open:
Tests
TestSanitizedOAuthEndpoint(test_security.py): query/PKCE values never echoed; single-, double-, past-budget-encoded, and plus-delimited credential paths all redact; credential inside a DNS label returnsNone; IDNA A-label surfacing; benign-long path truncation and host cap;Nonefallbacks for empty/invalid/host-less URLs.meta["error"]; entry shape named in content; no extra meta keys; no query value anywhere in the serialized message; credential-bearing path shows the redaction tag, never the credential; unparseable URL falls back to the unnamed banner.Manual verification
N/A — unit coverage sufficient: the banner emit path and the sanitizer are pure functions over the URL, and the tests assert the exact rendered fields (
content,meta["error"]).Pattern harvest
Rule candidate: when a security gate rejects input with an opaque message, the rejection surface must name the sanitized identity of what was rejected (here host+path, never values) — and the naming helper must scan with the SAME pattern families and decode depth as the gate itself, or it can echo the very bytes the gate refused. Also: put user-facing detail in the field the frontend actually renders (
meta["error"]), not just the message content.Why no screenshot: backend-only change; the banner is rendered from the same
errormeta field as before — text content differs only in the appended endpoint sentence, no component/layout/style change.