Repository navigation
fix(mcp): Keep the env token's startup host when serving - #1801
Conversation
serveMcp checked a .sentryclirc URL with the env token's startup host, but served with a fresh copy of the environment whose host snapshot was re-read from the post-shim SENTRY_URL. A stored login on the rc host got past the rc-URL gate, and the forced SENTRY_AUTH_TOKEN was then scoped to, and sent to, that host. preferEnvToken now gives a copy of the current environment the startup snapshot, for the gate and for serving (including the refresh-on-401 closure). An explicit --host/--url target still scopes the token, as before. Also fail closed when a login replaces the startup account's row in place between the startup pin and the credential read: the credential check now requires the pinned user, as applyCredentialPin already did. Reported by Warden on #1731.
The fatal-error path wrote its report to stderr before exiting 1. With the client's pipes closed, that write raised EPIPE, and the stream handler exited 0 first. The fatal path now records exitCode = 1 before writing, and the EPIPE handler exits with process.exitCode (0 unless a failure was recorded). Reported by Warden on #1731.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
withTargetEnv also copies the environment for --insecure-http alone, and the copy then re-captured the env token's host from the post-shim SENTRY_URL, so the .sentryclirc re-scoping came back with that flag. The env token now keeps its startup host unless the target is an explicit --host/--url, or --insecure-http names the same host as the startup SENTRY_HOST/SENTRY_URL (only the protocol changes). Reported by Warden on #1801. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com>
BYK
left a comment
There was a problem hiding this comment.
I deliberately left one case unchanged. With the deprecated --access-token (or SENTRY_ACCESS_TOKEN), a claimless token is still sent to an rc host the user has a stored login on. deprecatedTokenEnv pins it to the configured target, and the existing test serves a static token for an rc URL the user has logged in to asserts that as intended. It has the same shape as finding 1, though. Should the deprecated token also keep the startup host and refuse there?
Yes. Also, all this auth/token management code should come from the existing CLI logic where we already secured these gaps? Why is all this logic replicated/created inside the mcp command?
…ed CLI guards The deprecated --access-token / SENTRY_ACCESS_TOKEN pinned its env copy to the MCP target, so a claimless token was scoped to a .sentryclirc URL and sent there whenever a stored login on that host passed the rc-URL gate. It is now a plain alias for SENTRY_AUTH_TOKEN: the same env copy, the same rc gate (checked against the token itself), and the same startup-host scoping, so it keeps its startup SENTRY_HOST/SENTRY_URL (or SaaS) unless --host/--url targets another host. The bespoke deprecatedTokenEnv and its org-token gate exemption are gone. To cut the MCP-only auth logic further: - scopeEnvTokenToBootHost (replacing inheritEnvTokenHost) snapshots a derived env's token against the boot host with the same code as the boot-time capture, so the alias needs no snapshot of its own. - The fetch layer's per-request host guard (credential host plus sntrys_ claim) moves to assertCredentialScope in token-host.ts, and the MCP credential check calls it instead of re-implementing a host-only check. Requested by BYK on #1801.
|
Both addressed in e384364. Deprecated token: yes. Reusing the CLI logic: I moved the MCP copies onto the CLI's own helpers:
What I left in |
Conflict in packages/cli/src/lib/mcp.ts imports only: main (#1802) added the CredentialRouter type import, and this branch had already widened the db/accounts.js import. #1802's createRouter gives each routed read's env copy the credential env's env-token snapshot through inheritEnvTokenHost, which this branch had replaced with scopeEnvTokenToBootHost. inheritEnvTokenHost is restored for that caller with its main semantics; the client-token path keeps scopeEnvTokenToBootHost.
Fixes three findings from Warden's run on #1731's final head
c42da35d(check run, review comment). Warden finished after #1731 had auto-merged, so all three are onmain. Review follow-ups add a fourth fix (the deprecated static token) and move the MCP host checks onto the CLI's shared helpers.1. HIGH: the env token could be re-scoped to a
.sentryclirchostVerdict: real.
serveMcpran the rc-URL gate under apreferEnvToken(env)copy that kept the env token's startup host snapshot (inheritEnvTokenHost). It then served under a second copy,preferEnvToken(targetEnv), that didn't.env-token-host.tskeys snapshots per env object, so that copy re-captured its host from theSENTRY_URLthe shim had already set. A claimlessSENTRY_AUTH_TOKEN(forced over the stored login) was then scoped to the rc URL and passed the host check against the rc target.Exploitability.
assertRcUrlTrustedrefuses a self-hosted rc URL unless the active token's host matches orhasStoredAccountForHost(rcUrl)holds. That second condition is the way in:SENTRY_AUTH_TOKEN(say, a SaaS user token). The user has also logged in to a self-hosted host X, and a repo's.sentryclircsetsurl = X.hasSentryTargetskip: with an explicit--host/--url,withTargetEnvreplacesSENTRY_URL, so the rc URL isn't used.Fix:
preferEnvTokengives its copy the startup snapshot, both for the gate and for serving. The refresh-on-401 closure reuses the samecredentialEnvobject, so it keeps the scope. Only two targets may scope the env token instead (mayRescopeEnvToken):--host/--url, as before.--insecure-httpon the same host as the startupSENTRY_HOST/SENTRY_URL. Only the protocol changes there, soSENTRY_HOST=host:9000 --insecure-httpstill works.--insecure-httpon any other configured host (an rc URL or the persisted default) keeps the startup host. Warden flagged that path on this PR, and547ac17ffixes it.Tests:
keeps an env token with no host claim off an rc URL the user has logged in tocovers both no flags and--insecure-http. It fails on the old code and passes here.--hoststill scopes the env token when an rc URL points elsewhere.--insecure-httpon the startupSENTRY_HOSTstill serves over http.2. HIGH: fatal MCP errors exited 0 when stderr was already closed
Verdict: real. I reproduced it with a child process whose stderr the parent destroys.
console.errorraises EPIPE asynchronously, andstream-errors.tscallsprocess.exit(0)before the fatal path'sexit(1)runs.Fix:
exitAfterErrorsetsprocess.exitCode = 1before writing. The EPIPE handler now callsprocess.exit(), so it exits withprocess.exitCode: 0 unless a failure was already recorded.Test:
a fatal error exits non-zero when its report hits a closed stderrwires the real stream handler to the fatal path. It fails on the old code and passes here.3. MEDIUM: a replacement login was accepted during startup credential resolution
Verdict: real.
accountIdentityhashes only the credential. If a login replaced the account row in place afterstartupPin()but beforerefreshTokenread it, the row and the result matched, and the server started as the new user. Only the next refresh would have failed closed, throughapplyCredentialPin's user check.Fix:
isPinnedCredentialnow also requires the pinned user, through anisPinnedUserhelper shared withapplyCredentialPin.Test:
refuses another user's login over the startup account during startupfails on the old code and passes here.Behavior changes
SENTRY_URLthat only a.sentryclirc, or the persistedsentry cli defaults url, set after startup. Without an explicit target,sentry mcpwithSENTRY_AUTH_TOKENand a self-hosted persisted default now refuses withHostScopeErrorinstead of sending the token there. From reading the code, this matches the boot-snapshot trust model the other commands use:preloadProjectContextcaptures the snapshot before the shim and the persisted default are applied.bin.ts) now also exits with an already-recorded non-zeroprocess.exitCodeinstead of always exiting 0.4. The deprecated
--access-token/SENTRY_ACCESS_TOKENgot the same leak (review follow-up)The first version of this PR left this case alone and asked about it. The answer was yes, so
e384364efixes it.deprecatedTokenEnvpinned the token's env copy to the MCP target. A claimless static token was then scoped to a.sentryclircURL and sent there whenever a stored login on that host got past the rc-URL gate.Fix: the deprecated token is now a plain alias for
SENTRY_AUTH_TOKENand takes exactly the same path:clientTokenEnv)mayRescopeEnvTokenruleSo it keeps its startup
SENTRY_HOST/SENTRY_URL(or SaaS), or its org-token claim, unless--host/--urltargets another host.deprecatedTokenEnvand the org-token gate exemption are gone.Tests:
keeps a static token with no host claim off an rc URL the user has logged in toreplaces the oldserves a static token for an rc URL the user has logged in to. It covers--access-token,--access-token --insecure-http, andSENTRY_ACCESS_TOKEN, and checks that an explicit--hoststill scopes the token. It fails on the old code.serves a static token on the rc URL its startup SENTRY_URL names. The old gate wrongly refused this case because it checked the invocation's own credentials.Reusing the CLI's auth logic instead of MCP copies
Most of the MCP-specific code existed because
sentry mcphands its token to mcp-core's own HTTP client. That client skips the CLI's fetch layer, which is where the CLI enforces host scoping. This change moves the remaining copies onto the CLI's own code:sntrys_claim) moves intoassertCredentialScopeintoken-host.ts.prepareHeadersand the MCP credential check both call it. Before, MCP had its own host-only check.scopeEnvTokenToBootHostreplacesinheritEnvTokenHost. It snapshots a derived env's token against the boot host with the same codecaptureEnvTokenHostruns at boot. The deprecated alias therefore needs no special case.assertRcUrlTrusted. MCP setsskipRcUrlCheckand calls it itself only so that an explicit--host/--urlcan override the rc URL, and so the check runs against the token the server will actually use.What stays MCP-specific is the account pin (
startupPin/applyCredentialPin/isPinnedCredential). A one-shot CLI command reads its credential once. The MCP server is long-lived and refreshes after a 401, so another process'ssentry auth switchor a login must not change who it acts as. No CLI path has that lifecycle. I looked atwithStoredAccount/withEnvAccount. They pin a stored account to its own server, but they cannot detect that the row vanished or was replaced by another user mid-refresh, so the pin checks would still be needed on top of them.Behavior change: like
SENTRY_AUTH_TOKEN, a claimless deprecated token with no explicit target no longer follows a self-hosted persistedsentry cli defaults url. It now refuses withHostScopeError.Checks
pnpm run lintandtsc --noEmitforpackages/clipass. The pre-commit generated-files check reports nothing to update.packages/cliunit suite one384364e: 12,987 pass, and 12 fail the same way on the parent commit in this sandbox (completions, permission and readonly-fs,AuthErrordefault messages,auto-login-host-guardwording), so they are unrelated to this change. The focused suites (mcp-start,test/lib/mcp/*,mcp-*,env-token-host,sentryclirc*,token-host*,sentry-client*,test/lib/security/*) pass, apart from the two pre-existingauto-login-host-guardfailures.@sentry/mcp-serverbundle builds, andtest:bundlepasses 15/15.The branch was cut from
e189f67b.e384364emerges cleanly with currentmain(b7c23cf4).--
View Session in Sentry