Skip to content

fix(oauth): forbid framing of the consent and setup pages and show where approval sends access - #747

Open
aliasunder wants to merge 18 commits into
mainfrom
worktree-oauth-consent-framing
Open

aliasunder wants to merge 18 commits into
mainfrom
worktree-oauth-consent-framing

Conversation

@aliasunder

Copy link
Copy Markdown
Owner

Summary

The OAuth consent page and the setup pages can no longer be shown inside another site's frame, and the consent page now shows where Approve sends access and what it grants.

  • Response headers on every response from the server and from the :remote image's setup mode: X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none', X-Content-Type-Options: nosniff and Referrer-Policy: same-origin. X-Powered-By is no longer sent. One middleware in src/utils/security-headers.ts, mounted first in both apps.
    • Express's own 404 page sets its own content policy, so a 404 carries X-Frame-Options but not frame-ancestors; it still cannot be framed.
  • No caching of consent decisions (/oauth/decide, set ahead of the form parser so parser errors carry it too) or of any setup-mode response, plus the configured server's GET /setup page.
  • Consent page:
    • A new "Sends access to" row shows the redirect address's scheme and host (https://claude.ai, http://127.0.0.1:19876), or the whole address when it has no host (claude.ai:callback). Only a web address can start with https://.
    • An "Access" row reads "Everything this server's tools can do in your vault" in place of the requested-scope list.
  • Granted scope: every new sign-in is granted vault, the server's only advertised scope, whatever scope the client asks for. The token response's scope says so. Refresh grants already stored keep their scope; nothing reads a scope, so access is unchanged.

Docs: ARCHITECTURE.md (Auth) and a new SECURITY.md "Response headers" section.

Test plan

  • npm test: all pass except load timeouts in two shell-script test files this change does not touch, which pass when run alone
  • npm run build:server, npm run lint, npm run knip
  • Mutation checks: removing either mount, either x-powered-by switch, each no-store line, or restoring the requested-scope grant fails the matching tests
  • Live test on the test instance (headers through API Gateway, the consent page, a connector sign-in)

🤖 Generated with Claude Code

aliasunder and others added 7 commits November 7, 2026 15:02
Both Express apps now mount one middleware ahead of everything else. It
sets X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors
'none', X-Content-Type-Options: nosniff and Referrer-Policy: same-origin,
and both apps stop sending X-Powered-By.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every /oauth/decide response, including the form parser's errors, and
every setup-mode response now sends Cache-Control: no-store, as does the
full server's /setup page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the vault scope

The consent page now shows the destination of Approve (the redirect
address's scheme and host, or the address as written when it has no
host) and a fixed description of the grant, in place of the scope list.
New sign-ins always get the vault scope; refresh grants keep their
stored scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and the response headers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/utils/security-headers.ts Resolved
@umm-actually

umm-actually Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

umm-actually re-reviewed at 4bd0391

No new findings (4 tracked finding(s) across all runs).

Context notes
  • Priority docs already in context: ARCHITECTURE.md, SECURITY.md

umm-actually · z-ai/glm-5.3-flash

aliasunder and others added 8 commits November 7, 2026 15:31
…curately

The server's error handler answers a form parser error with a 500, so the
comment no longer names a 413.

Ship-Check: pr-review · claude-opus-5-5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ments

- Scope the "mounted first" comments in both apps to what mounting first
  does, and name the consent and setup pages in the content-policy comment.
- Say why the consent page always shows the scheme, why it does not use
  URL.origin, and that the SDK passes only addresses URL.parse can read.
- State that `vault` is the server's only scope, and that any consent
  action but Approve denies.
- Point setup-server at the scripts that set SETUP_REASON and derive
  PUBLIC_URL, and say why it keeps its own error middleware.
- SECURITY.md: name the redirect address plainly, say which 404 page sets
  its own content policy, and list every page that sends no-store.
- ARCHITECTURE.md: say where a refresh grant with another scope comes from.

Ship-Check: code-quality · claude-opus-5-5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the reveal toggle's variables

The page shows the client ID when a client has no name; both render paths
passed the same fallback before. The Show/Hide toggle's inline script on the
consent and setup pages uses descriptive names. The Referrer-Policy comment
says what a null Origin would hide.

Ship-Check: triage · claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and pin the setup page's reveal toggle

- The consent decision's no-store checks move out of the approve, reject
  and 400 tests into their own cases, so a missing header fails tests named
  for caching.
- The 404 page's framing check moves from the root-redirect test into the
  security headers block.
- "keeps the scope a request names" becomes "grants vault when the request
  names vault", matching the fixed grant.
- The setup page's Show/Hide handler gets the pin the consent page's has.

Ship-Check: test-audit · claude-opus-5-5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ship-Check: test-audit · claude-opus-5-5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…andles

The page replaces the content policy for a known path called with an unanswered method too, such as GET /oauth/decide, not only for an unknown path.

Ship-Check: bug-check · claude-opus-5-5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ship-Check: triage · claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e paragraph

Every new sign-in now gets this scope whatever it asks for, so the
DEFAULT_SCOPE name no longer described it.

Ship-Check: triage · claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread SECURITY.md Resolved
…n oversized form

The consent page's no-store comes from the MCP SDK's authorize handler; the
integration test now asserts it, so the SECURITY.md claim stays true if the
SDK changes. The oversized-form case notes that only the bare router answers
413. The vault-scope test group drops its old default-scope title.

Ship-Check: pr-monitor · claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@umm-actually

umm-actually Bot commented Nov 7, 2026

Copy link
Copy Markdown

CSP header on consent page overwrites the frame-ancestors-only policy
Medium severity · security · medium confidence

src/vault-mcp/oauth/oauth-routes.ts:167 — beyond the diff's line ranges, in code the changes touch or depend on.

The oauth-routes.ts response sets Content-Security-Policy to 'default-src none' inside the same send callback that follows the global middleware's frame-ancestors-only CSP; Express replaces the earlier header value rather than adding a second one, so the consent page is served with only default-src 'none' and loses the frame-ancestors 'none' directive that ARCHITECTURE.md and SECURITY.md state every response carries.

Failure scenario: Load the consent page and inspect response headers: Content-Security-Policy is 'default-src none' with no frame-ancestors directive, so a page can embed the consent screen in an iframe despite the documented protection.

Suggested fix
Include frame-ancestors 'none' in the CSP value set in the /authorize response callback, e.g. "default-src 'none'; frame-ancestors 'none'", or verify res.append is used instead of res.set so both policies are emitted.

umm-actually · z-ai/glm-5.3-flash

@aliasunder

Copy link
Copy Markdown
Owner Author

Re: umm-actually comment 6713588389 ("CSP header on consent page overwrites the frame-ancestors-only policy"):

This is intentional, and the described header does not occur. The only place in src/ that sets Content-Security-Policy is src/utils/security-headers.ts:8 (frame-ancestors 'none'); oauth-routes.ts:167 is a log call in the /oauth/decide handler, and the consent page itself is served by GET /authorize. The consent-page integration test (server-oauth-integration.test.ts, "serves the consent page with the security headers…") asserts Content-Security-Policy: frame-ancestors 'none' on that response, and a Chromium run against this branch confirmed a cross-origin frame of the consent page is refused. The one response that carries default-src 'none' instead is Express's own 404 page, which SECURITY.md names, and it still sends X-Frame-Options: DENY.


🔍 ship-check · pr-monitor · claude-opus-5-5

aliasunder and others added 2 commits November 7, 2026 17:05
…e consent page its own paragraph

Ship-Check: code-quality · claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cacheable

The router-only test answers an oversized form with the parser's 413. On the
full server the error middleware answers the same error with a 500, and the
response must still carry no-store.

Ship-Check: test-audit · claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant