Skip to content

fix(csrf): bypass on training-agent hostname so per-tenant routes reach verifier (closes #2368)#4503

Merged
bokelley merged 3 commits into
mainfrom
bokelley/issue-2368
May 13, 2026
Merged

fix(csrf): bypass on training-agent hostname so per-tenant routes reach verifier (closes #2368)#4503
bokelley merged 3 commits into
mainfrom
bokelley/issue-2368

Conversation

@bokelley

Copy link
Copy Markdown
Contributor

Summary

  • CSRF middleware now bypasses validation for requests on test-agent.adcontextprotocol.org. Per-tenant strict routes (/<tenant>/mcp-strict, mounted at root via host-based dispatch) were 403'd by global CSRF before reaching the RFC 9421 verifier — the path-based exemption lists only matched the legacy single-URL shape (/mcp-strict) and the AAO mount prefix (/api/training-agent/), not the per-tenant shape introduced in feat(training-agent): close #2347 (SDK dispatcher) + #2368 (/mcp-strict grader route) #2592. The training-agent host serves exclusively server-to-server bearer/signed traffic, so CSRF has no work to do there.
  • Also fixes scripts/verify-version-sync.cjs Rule 1, which was inconsistent with the dual-branch release model its own docstring describes. The hook now allows adcp_version >= published_version (catching the real bug — stale legacy alias when canonical bumps) instead of requiring strict equality (which false-positived the forward-merge window). This was blocking unrelated pre-push pushes on main.

Why this issue persisted

The verifier wiring this issue originally asked for has actually been in the tree since PR #2592request-signing.ts ships JWKS loading, test-revoked-2026 pre-revocation, PostgresReplayStore, and four route variants (/mcp, /mcp-strict, /mcp-strict-required, /mcp-strict-forbidden). The earlier triage on this issue concluded /mcp-strict returned 404 because the route wasn't mounted; the deeper diagnosis is that the route shape changed to per-tenant (/<tenant>/mcp-strict) and the curl probe was hitting the obsolete root path. Probing /sales/mcp-strict directly surfaces the actual blocker: CSRF 403 before the verifier ever runs.

How to verify against the live agent (post-merge / post-deploy)

adcp grade request-signing https://test-agent.adcontextprotocol.org/sales/mcp-strict --transport mcp

Expected: positive vectors return 200, vector 001 returns request_signature_required, vector 017 returns request_signature_revoked (via the pre-loaded test-revoked-2026 keyid).

Test plan

  • npx vitest run server/tests/unit/csrf-middleware.test.ts — 47/47 pass (44 existing + 3 new covering training-agent hostname bypass, per-tenant URL matrix, and the negative case that non-exempt hostnames still validate).
  • npm run verify-version-sync — green with the new at-or-ahead semantic.
  • Pre-commit hook (test:unit + typecheck) green at each commit.
  • Post-deploy: re-probe https://test-agent.adcontextprotocol.org/sales/mcp-strict with grade_agent_signing and confirm vectors flow through to the verifier.

Closes #2368.

🤖 Generated with Claude Code

bokelley and others added 3 commits May 13, 2026 17:07
…ch verifier

Per-tenant strict routes (`/<tenant>/mcp-strict`, mounted at root via host-based dispatch on `test-agent.adcontextprotocol.org`) were 403'd by global CSRF before reaching the request-signing verifier. The path-based exemption lists only matched the legacy single-URL shape (`/mcp-strict`) and the AAO mount prefix (`/api/training-agent/`).

The training-agent host serves exclusively server-to-server bearer/signed traffic, so CSRF has nothing to protect there. Hostname-based bypass mirrors the existing `EXEMPT_EXACT` entries that were added for the same host's legacy URL shape.

Closes adcp#2368.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… docstring describes

The script's docstring describes a dual-branch release model where, on main during a forward-merge window, `adcp_version` (legacy alias tracking the latest released 3.0.x) may legitimately be ahead of `published_version` (canonical, tracks this branch's package.json). Rule 2 enforces that direction correctly. But Rule 1 required strict equality between the two registry fields — false-positiving the exact scenario the docstring permits.

Replace strict equality with the same at-or-ahead-of semantic Rule 2 uses: `adcp_version` must be `>= published_version`. Catches the real bug (canonical bumps without keeping the alias in sync, leaving alias readers stale) without blocking the forward-merge window.

Concrete trigger that exposed this: package.json `3.0.3`, `published_version: 3.0.3`, `adcp_version: 3.0.12` (from a prior forward-merge). Rule 2 was fine; Rule 1 fired and blocked unrelated pre-push pushes on main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Security review caught that the prior hostname-based bypass used `req.hostname`, which Express derives from `X-Forwarded-Host` under `trust proxy = 1`. Fly's edge forwards that header as-received, so an attacker could spoof `X-Forwarded-Host: test-agent.adcontextprotocol.org` on a request to `app.adcontextprotocol.org/api/me/<cookie-authed-route>` and bypass CSRF — the cookie-authed handler would still match by path and run.

Switch to a path-shape regex that matches the per-tenant MCP route shape (`/<tenant>/mcp[-strict[-required|-forbidden]]`) without trusting any client-supplied header. Path shape isn't client-spoofable; adds nine bypass tests and seven near-miss tests to guard the regex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley merged commit 5a73382 into main May 13, 2026
13 checks passed
@bokelley bokelley deleted the bokelley/issue-2368 branch May 13, 2026 23:42
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.

Stand up signed-requests on test-agent.adcontextprotocol.org

1 participant