Skip to content

fix(mcp): verify internal headers at the sidecar and fail closed on missing scope - #688

Merged
ducnmm merged 2 commits into
devfrom
fix/gh-685-sidecar-internal-header-verification
Aug 19, 2026
Merged

fix(mcp): verify internal headers at the sidecar and fail closed on missing scope#688
ducnmm merged 2 commits into
devfrom
fix/gh-685-sidecar-internal-header-verification

Conversation

@nikola0x0

Copy link
Copy Markdown
Collaborator

Resolves #685 (WALM-372) — the second remediation item from #659, which #665 did not cover.

Problem

The relayer decides what an MCP caller may do and tells the sidecar with x-memwal-internal-oauth-scope. Two defects made the relayer a single point of failure:

  1. The sidecar never verified the header's origin. /mcp/* is mounted before sharedSecretAuthMiddleware (because Authorization already carries the end user's delegate key), so it had no shared-secret gate at all. fix(mcp): block inbound internal header injection in reverse proxy #665 stops a client smuggling the header through the relayer; it does nothing about a caller reaching the sidecar directly.
  2. A missing scope header failed open. tools/index.ts read const unrestricted = session.oauthScope === undefined, so an absent header granted full write. The intent was legitimate — legacy delegate-key callers have no OAuth scope and should get everything — but the sidecar could not tell "legacy login" from "the header went missing."

Changes

Relayerapply_oauth_headersapply_internal_headers, now called on all three proxy handlers including the legacy Passthrough arm that previously set nothing. It states both internal headers explicitly:

Header Value
x-memwal-internal-sidecar-token SIDECAR_AUTH_TOKEN
x-memwal-internal-oauth-scope OAuth: the resolved grant. Legacy: memwal:read memwal:write

Both use insert (overwrite), so a client-supplied value never survives. Header-build failures return 500 instead of being silently skipped — previously that left an authenticated request with no scope, which under the old fail-open meant full write.

Sidecar — new verifyInternalOrigin() (timingSafeEqual, length-guarded) runs first in resolveAuth, so an unverified caller gets 401 before any internal header is read. registerTools drops the unrestricted branch: absent or empty scope now registers no tools.

No new environment variables

SIDECAR_AUTH_TOKEN is already mandatory (the sidecar process.exit(1)s without it), already documented as required in three places, already in both .env.example files and CI, and already inherited by the spawned sidecar from the relayer's environment. Nothing for operators to do.

Verification

Result
Rust mcp_proxy 14/14 (10 on dev + 4 new)
Rust full unit suite 373 pass / 17 fail — identical to the dev baseline, no regressions
TypeScript 216/216 (205 baseline + 11 new)
rustfmt clean on mcp_proxy.rs
clippy no new warnings

Notes for review

…issing scope

Second remediation item from #659. #665 stops a client smuggling
x-memwal-internal-* through the relayer, but the sidecar still trusted the
header from any caller and treated its absence as unrestricted access.

Relayer: apply_oauth_headers becomes apply_internal_headers, called on all
three proxy handlers including the legacy Passthrough arm that previously set
nothing. It states both internal headers explicitly — the sidecar shared
secret, and the granted scope (the resolved grant for OAuth callers, full
read+write for legacy delegate-key callers). Both are written with insert, so a
client-supplied value is always overwritten. Header-build failures now return
500 instead of being silently skipped, which previously left an authenticated
request with no scope.

Sidecar: new verifyInternalOrigin() compares the token with timingSafeEqual and
runs first in resolveAuth, so an unverified caller gets a 401 before any
internal header is read. registerTools drops the `unrestricted` branch — an
absent or empty scope now registers no tools rather than every tool.

No new environment variables: SIDECAR_AUTH_TOKEN is already mandatory and
already shared by both processes.

Resolves #685

@ducnmm ducnmm left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking this over — the shared-secret header and explicit legacy scope are the right direction. Two blockers before merge:

  1. Bind the granted scope to the MCP session. resolveAuth() still builds sessionKey from only {accountId, delegatePubKey}. Existing SSE/streamable servers keep the tools registered at session creation, so a later request with the same account/delegate but a missing or narrower scope still passes the session-key check and can drive the original write-capable session. This means missing scope does not actually fail closed after initialization. Include the normalized scope in sessionKey, and add a regression test that opens a full-scope session then reuses its session ID with no scope / memwal:read and expects 403.

  2. The stdio bridge integration test no longer exercises a successful upstream connection. Its /api test mount bypasses the Rust proxy, so the bridge cannot send x-memwal-internal-sidecar-token or the explicit legacy scope. The test can still pass via the local/auth fallback because it only asserts memwal_login. Add proxy-simulation middleware on the /api router to inject the internal token + full scope, then assert a remote tool such as memwal_recall is present.

Everything else looks solid, and CI is green.

Addresses review on #688.

The tool set is bound at session-open time, so a session opened with write
scope keeps its write tools for its whole life. sessionKey was built from
{accountId, delegatePubKey} only, so a later request with a narrower or absent
scope passed the session-binding check and drove that write-capable transport —
the fail-closed guarantee held only until initialization.

Delegate keys are reused across grants for the same account
(find_reusable_oauth_delegate keys on account_id alone), so {account, delegate}
does not distinguish two grants of differing scope. The key now includes the
normalized scope: deduplicated and sorted, so reordering a grant does not fork
the session.

Also fixes the stdio bridge test, which had stopped exercising a successful
upstream connection. Its /api mount has no Rust proxy, so nothing supplied the
internal headers and the bridge fell back to its local tool list. The fallback
advertises the same tool names as the relayer, so no assertion on tool names
could detect this. The mount now simulates the proxy's apply_internal_headers,
and the test waits for the upstream SSE and asserts it returned 200 before
requesting tools — the earlier list was answered by the bridge's coldstart path
while the connection was still in flight.
@nikola0x0

Copy link
Copy Markdown
Collaborator Author

Both fixed in 422d4c6. Verified each one first — the second turned out to be worse than described.

1. Scope bound to the session key

Confirmed and exploitable, not theoretical. I'd previously assumed each grant mints its own delegate keypair, which would have made the session keys differ anyway. That's wrong: routes/oauth.rs:640 calls find_reusable_oauth_delegate(&account_id), keyed on account_id alone, and the comment at :381 says reuse is deliberate. So two grants of differing scope for the same account share a delegate pubkey and produced an identical session key.

sessionKey now includes the normalized scope — deduplicated and sorted, so reordering the same grant doesn't fork a session. Two regression tests: opening with memwal:read memwal:write then reusing the session id with memwal:read, and with the scope header dropped entirely. Both returned 200 before the fix and 403 after. Plus unit tests pinning the normalization, mutation-checked (removing the sort, and removing the scope from the key, each fail a test).

2. Stdio bridge test

Correct, and the assertion couldn't be repaired by swapping in a remote tool name. auth-required.ts now advertises all seven tool names locally (memwal_remember_bulk and memwal_health included), so the bridge's fallback list is name-identical to the relayer's. I confirmed it empirically: forcing the origin check to fail vs. leaving it working produced byte-identical tools/list output.

There was a second cause underneath. Even with the proxy simulation added, the test still passed with the scope injection removed — because tools/list was being answered by the bridge's coldstart path while the upstream SSE was still in flight. The list never came from upstream at all, regardless of auth.

So the fix is three parts:

  • The /api mount simulates the relayer's apply_internal_headers, injecting the sidecar token and the granted scope.
  • The mount records response status at header-write time (not finish — an SSE response never finishes), and the test waits for /mcp/sse and asserts 200. Before the fix this recorded [{"path":"/mcp/sse","status":401}].
  • tools/list is sent only after that 200, so it is answered upstream, and asserts the relayer-registered set.

Mutation-checked: removing either injected header now fails the test. Previously removing the scope changed nothing.

Verification

TS sidecar 221/221, packages/mcp 18/18, Rust mcp_proxy 14/14, Rust full unit suite unchanged from the dev baseline (373 pass / 17 pre-existing failures), rustfmt clean.

@nikola0x0
nikola0x0 requested a review from ducnmm August 18, 2026 13:18

@ducnmm ducnmm left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! The session key now properly includes the normalized scope to prevent post-init privilege escalation, and the integration tests properly simulate the Rust proxy headers. All CI checks pass.

@ducnmm
ducnmm merged commit 98af57d into dev Aug 19, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants