From ae8fa31d64a11e7747f4da5172884f7ef25c1d0a Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:03:08 +0000 Subject: [PATCH 1/4] fix(mcp): Keep the env token's startup host when serving 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. --- packages/cli/src/lib/mcp.ts | 55 +++++++++++++++++++------ packages/cli/test/lib/mcp-start.test.ts | 44 ++++++++++++++++++++ 2 files changed, 86 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/lib/mcp.ts b/packages/cli/src/lib/mcp.ts index f7e74135e..034657656 100644 --- a/packages/cli/src/lib/mcp.ts +++ b/packages/cli/src/lib/mcp.ts @@ -1,7 +1,11 @@ import { normalizeAuthToken } from "./auth-header.js"; import { getConfiguredSentryUrl } from "./constants.js"; import { getAccountScope } from "./db/account-scope.js"; -import { accountIdentity, getAccountRow } from "./db/accounts.js"; +import { + type AccountRow, + accountIdentity, + getAccountRow, +} from "./db/accounts.js"; import { getEffectiveAccountRow, type RefreshTokenResult, @@ -78,6 +82,18 @@ const ACCOUNT_CHANGED_MESSAGE = const ENV_PIN: McpCredentialPin = { kind: "env" }; +/** + * Whether `row` still belongs to the pinned user. A login can replace an + * account's credentials in place, so the account ID alone is not enough when + * the account recorded a user. + */ +function isPinnedUser( + row: AccountRow, + pin: Extract, +): boolean { + return !pin.userId || row.user_id === pin.userId; +} + /** * Point this process's credential reads at the pinned credential, or fail * closed when it is gone. The host check alone cannot tell two accounts on @@ -91,9 +107,7 @@ function applyCredentialPin(pin: McpCredentialPin): void { return; } const row = getAccountRow(pin.accountId); - // A login can replace an account's credentials in place, so also require - // the same user when the account recorded one. - if (!row?.token || (pin.userId && row.user_id !== pin.userId)) { + if (!(row?.token && isPinnedUser(row, pin))) { throw new AuthError("expired", ACCOUNT_CHANGED_MESSAGE, { skipAutoAuth: true, }); @@ -116,7 +130,9 @@ function startupPin(): McpCredentialPin | undefined { /** * Whether `result` is the pinned credential. A stored pin must match the * account row's current credential: if the row vanished mid-refresh, the - * CLI falls back to another account, and that token must not be used. + * CLI falls back to another account, and that token must not be used. The + * row must also still belong to the pinned user, in case a login replaced it + * between the pin and the credential read. */ function isPinnedCredential( result: RefreshTokenResult, @@ -130,7 +146,9 @@ function isPinnedCredential( } // SQLite yields null, not undefined, for a missing row. const row = getAccountRow(pin.accountId); - return row ? accountIdentity(row) === result.identity : false; + return row + ? isPinnedUser(row, pin) && accountIdentity(row) === result.identity + : false; } /** @@ -327,12 +345,25 @@ function withTargetEnv( * * A token the `.sentryclirc` shim copied in is not forced: it comes from the * repository, not the client, so it must not replace the user's login. + * + * A copy of the current environment keeps the env token's startup host: by + * now its `SENTRY_URL` can come from a `.sentryclirc` or the persisted + * default, which must not scope the token (see `inheritEnvTokenHost`). A copy + * of an explicit `--host`/`--url` target (`withTargetEnv`) is scoped to that + * target instead. */ function preferEnvToken(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - return (env.SENTRY_AUTH_TOKEN?.trim() || env.SENTRY_TOKEN?.trim()) && - !getRcInjectedTokenSource() - ? { ...env, SENTRY_FORCE_ENV_TOKEN: "1" } - : env; + if ( + !(env.SENTRY_AUTH_TOKEN?.trim() || env.SENTRY_TOKEN?.trim()) || + getRcInjectedTokenSource() + ) { + return env; + } + const forced = { ...env, SENTRY_FORCE_ENV_TOKEN: "1" }; + if (env === getEnv()) { + inheritEnvTokenHost(forced); + } + return forced; } /** @@ -381,9 +412,7 @@ export async function serveMcp(flags: McpFlags, cwd: string): Promise { (deprecatedToken && parseSntrysClaim(deprecatedToken)) ) ) { - const checkEnv = preferEnvToken(env); - inheritEnvTokenHost(checkEnv); - await withEnv(checkEnv, () => assertRcUrlTrusted(cwd)); + await withEnv(preferEnvToken(env), () => assertRcUrlTrusted(cwd)); } const targetEnv = withTargetEnv(env, flags, config); diff --git a/packages/cli/test/lib/mcp-start.test.ts b/packages/cli/test/lib/mcp-start.test.ts index 0f44bb0c1..90042121b 100644 --- a/packages/cli/test/lib/mcp-start.test.ts +++ b/packages/cli/test/lib/mcp-start.test.ts @@ -242,6 +242,24 @@ describe("serveMcp account pinning", () => { expect(refreshedWith).toEqual([]); }); + test("refuses another user's login over the startup account during startup", async () => { + login("a-token", "1"); + // After the startup pin is taken but before the credential read, another + // user replaces the account's credentials in place. + refreshHooks.before = () => { + refreshHooks.before = undefined; + setAuthToken("c-token", 3600, "c-token-refresh", { + host: "https://sentry.io", + }); + setUserInfo({ userId: "3", email: "3@example.com" }); + }; + + await expect(serve([])).rejects.toThrow( + "The account this MCP server started with is no longer available", + ); + expect(served.calls).toHaveLength(0); + }); + test("pins the account whose token startup returned, even if the active account changes right after", async () => { const a = login("a-token", "1"); const b = login("b-token", "2"); @@ -634,6 +652,18 @@ describe("serveMcp .sentryclirc URLs", () => { const { config, options } = await start([]); expect(config.sentryHost).toBe("sentry.example.com"); expect(options.accessToken).toBe("env-token"); + // The refresh after an upstream 401 runs under the same scope. + await expect(options.refreshAccessToken?.()).resolves.toBe("env-token"); + }); + + test("scopes the env token to an explicit --host over the rc URL", async () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + captureEnvTokenHost(); + await inRcDir("https://evil.example.com"); + + const { config, options } = await start(["--host=sentry.example.com"]); + expect(config.sentryHost).toBe("sentry.example.com"); + expect(options.accessToken).toBe("env-token"); }); test("serves the env token over a token in the same .sentryclirc", async () => { @@ -742,6 +772,20 @@ describe("serveMcp .sentryclirc URLs", () => { expect(served.calls).toHaveLength(0); }); + test("keeps an env token with no host claim off an rc URL the user has logged in to", async () => { + // The stored login on the rc host gets past the rc-URL gate; the env + // token still wins for serving, so it must keep its startup (SaaS) host. + setAuthToken("cli-session-token", undefined, undefined, { + host: "https://sentry.example.com", + }); + process.env.SENTRY_AUTH_TOKEN = "env-token"; + captureEnvTokenHost(); + await inRcDir("https://sentry.example.com"); + + await expect(serve([])).rejects.toBeInstanceOf(HostScopeError); + expect(served.calls).toHaveLength(0); + }); + test("serves a static token for an rc URL the user has logged in to", async () => { setAuthToken("cli-session-token", undefined, undefined, { host: "https://sentry.example.com", From 4f8fb0cd07ff1e1dd5180ecdd9fc8eb3422004a9 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:03:10 +0000 Subject: [PATCH 2/4] fix(mcp): Exit non-zero on a fatal error after the client closed stderr 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. --- packages/cli/src/lib/mcp/server.ts | 3 ++ packages/cli/src/lib/stream-errors.ts | 5 ++- .../cli/test/lib/mcp/process-handlers.test.ts | 37 +++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/lib/mcp/server.ts b/packages/cli/src/lib/mcp/server.ts index 666454625..78ea5a632 100644 --- a/packages/cli/src/lib/mcp/server.ts +++ b/packages/cli/src/lib/mcp/server.ts @@ -298,6 +298,9 @@ export function installProcessHandlers(): ( process.exit(1); } exiting = true; + // Record the failure first: writing to a closed stderr below raises EPIPE, + // whose stream handler exits with the recorded code (see stream-errors.ts). + process.exitCode = 1; // If the client closed every pipe, console.error can throw EPIPE inside // this handler and recurse forever (getsentry/sentry-mcp#1274). diff --git a/packages/cli/src/lib/stream-errors.ts b/packages/cli/src/lib/stream-errors.ts index 05d2da8a4..a1edfdb05 100644 --- a/packages/cli/src/lib/stream-errors.ts +++ b/packages/cli/src/lib/stream-errors.ts @@ -8,12 +8,13 @@ // Handle non-recoverable stream I/O errors gracefully instead of crashing. // - EPIPE (errno -32): downstream pipe consumer closed (e.g., `sentry issue list | head`, // or an MCP client closing its pipes). Normal Unix behavior — not an error. -// Exit 0 because the process succeeded. +// Exit with `process.exitCode`: 0 unless a failure was already recorded, +// such as a fatal MCP error whose report hit the closed stderr. // - EIO (errno -5): low-level I/O failure on the stream fd. // Non-recoverable — exit 1 so callers know the output was lost. function handleStreamError(err: NodeJS.ErrnoException): void { if (err.code === "EPIPE") { - process.exit(0); + process.exit(); } if (err.code === "EIO") { process.exit(1); diff --git a/packages/cli/test/lib/mcp/process-handlers.test.ts b/packages/cli/test/lib/mcp/process-handlers.test.ts index 8469cd0de..1ec1a6129 100644 --- a/packages/cli/test/lib/mcp/process-handlers.test.ts +++ b/packages/cli/test/lib/mcp/process-handlers.test.ts @@ -12,6 +12,8 @@ vi.mock("@sentry/node", async (importOriginal) => ({ const { installProcessHandlers } = await import("../../../src/lib/mcp/server.js"); +const { installStreamErrorHandlers } = + await import("../../../src/lib/stream-errors.js"); type Handler = (...args: unknown[]) => void; @@ -42,6 +44,7 @@ beforeEach(() => { }); afterEach(() => { + process.exitCode = undefined; vi.clearAllTimers(); vi.useRealTimers(); vi.restoreAllMocks(); @@ -70,4 +73,38 @@ describe("installProcessHandlers", () => { await settle(); expect(exit).toHaveBeenCalledWith(0); }); + + test("a fatal error exits non-zero when its report hits a closed stderr", async () => { + // The code each exit would end the process with: its argument, or + // `process.exitCode` when it has none. + const codes: unknown[] = []; + exit.mockImplementation(((code?: number) => { + codes.push(code ?? process.exitCode ?? 0); + }) as typeof process.exit); + let stderrError: Handler | undefined; + vi.spyOn(process.stderr, "on").mockImplementation((( + event: string, + handler: Handler, + ) => { + if (event === "error") { + stderrError = handler; + } + return process.stderr; + }) as typeof process.stderr.on); + vi.spyOn(process.stdout, "on").mockImplementation( + (() => process.stdout) as typeof process.stdout.on, + ); + installStreamErrorHandlers(); + const exitAfterError = installProcessHandlers(); + // The client closed its pipes: writing the report raises EPIPE. + vi.mocked(console.error).mockImplementation(() => { + stderrError?.(Object.assign(new Error("write EPIPE"), { code: "EPIPE" })); + }); + + exitAfterError(new Error("boom"), "Server error:"); + await settle(); + + expect(codes[0]).toBe(1); + expect(codes).not.toContain(0); + }); }); From 547ac17fd95f96186de668670eaae8fde24d3e6f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:40:27 +0000 Subject: [PATCH 3/4] fix(mcp): Keep the startup host for --insecure-http on another host 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> --- packages/cli/src/lib/mcp.ts | 44 +++++++++++++++++++------ packages/cli/test/lib/mcp-start.test.ts | 16 +++++++++ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/lib/mcp.ts b/packages/cli/src/lib/mcp.ts index 034657656..a845c76ce 100644 --- a/packages/cli/src/lib/mcp.ts +++ b/packages/cli/src/lib/mcp.ts @@ -11,7 +11,10 @@ import { type RefreshTokenResult, refreshToken, } from "./db/auth.js"; -import { inheritEnvTokenHost } from "./env-token-host.js"; +import { + getBootConfiguredSentryUrl, + inheritEnvTokenHost, +} from "./env-token-host.js"; import { getEnv, withEnv } from "./env.js"; import { AuthError, HostScopeError } from "./errors.js"; import { @@ -336,6 +339,25 @@ function withTargetEnv( return targeted; } +/** + * Whether the env token may be scoped to the server's target instead of its + * startup host: for an explicit `--host`/`--url`, or for `--insecure-http` on + * the host the startup `SENTRY_HOST`/`SENTRY_URL` named, which only changes the + * protocol. `--insecure-http` on any other configured host (a `.sentryclirc` + * URL or the persisted default) must not. + */ +function mayRescopeEnvToken(flags: McpFlags, target: McpTarget): boolean { + if (hasSentryTarget(flags)) { + return true; + } + const bootUrl = getBootConfiguredSentryUrl(); + return ( + !!flags["insecure-http"] && + bootUrl !== undefined && + new URL(bootUrl).host === target.sentryHost + ); +} + /** * The environment a `SENTRY_AUTH_TOKEN` (or `SENTRY_TOKEN`) runs under: forced * over any stored login, as if `SENTRY_FORCE_ENV_TOKEN=1` were set. An MCP @@ -346,13 +368,15 @@ function withTargetEnv( * A token the `.sentryclirc` shim copied in is not forced: it comes from the * repository, not the client, so it must not replace the user's login. * - * A copy of the current environment keeps the env token's startup host: by - * now its `SENTRY_URL` can come from a `.sentryclirc` or the persisted - * default, which must not scope the token (see `inheritEnvTokenHost`). A copy - * of an explicit `--host`/`--url` target (`withTargetEnv`) is scoped to that - * target instead. + * With `keepStartupHost`, the copy keeps the env token's startup host: by now + * `SENTRY_URL` can come from a `.sentryclirc` or the persisted default, which + * must not scope the token (see `inheritEnvTokenHost`). See + * {@link mayRescopeEnvToken} for when the server's target may scope it. */ -function preferEnvToken(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { +function preferEnvToken( + env: NodeJS.ProcessEnv, + keepStartupHost: boolean, +): NodeJS.ProcessEnv { if ( !(env.SENTRY_AUTH_TOKEN?.trim() || env.SENTRY_TOKEN?.trim()) || getRcInjectedTokenSource() @@ -360,7 +384,7 @@ function preferEnvToken(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return env; } const forced = { ...env, SENTRY_FORCE_ENV_TOKEN: "1" }; - if (env === getEnv()) { + if (keepStartupHost) { inheritEnvTokenHost(forced); } return forced; @@ -412,13 +436,13 @@ export async function serveMcp(flags: McpFlags, cwd: string): Promise { (deprecatedToken && parseSntrysClaim(deprecatedToken)) ) ) { - await withEnv(preferEnvToken(env), () => assertRcUrlTrusted(cwd)); + await withEnv(preferEnvToken(env, true), () => assertRcUrlTrusted(cwd)); } const targetEnv = withTargetEnv(env, flags, config); const credentialEnv = deprecatedToken ? deprecatedTokenEnv(targetEnv, deprecatedToken, config) - : preferEnvToken(targetEnv); + : preferEnvToken(targetEnv, !mayRescopeEnvToken(flags, config)); // Loaded only now: mcp-core's server, the agent providers, and the MCP // telemetry are not needed for --help or setup errors. diff --git a/packages/cli/test/lib/mcp-start.test.ts b/packages/cli/test/lib/mcp-start.test.ts index 90042121b..e32c84ec0 100644 --- a/packages/cli/test/lib/mcp-start.test.ts +++ b/packages/cli/test/lib/mcp-start.test.ts @@ -656,6 +656,17 @@ describe("serveMcp .sentryclirc URLs", () => { await expect(options.refreshAccessToken?.()).resolves.toBe("env-token"); }); + test("serves the env token over http with --insecure-http on its startup SENTRY_HOST", async () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + process.env.SENTRY_HOST = "sentry.internal:9000"; + captureEnvTokenHost(); + + const { config, options } = await start(["--insecure-http"]); + expect(config.sentryHost).toBe("sentry.internal:9000"); + expect(config.sentryProtocol).toBe("http"); + expect(options.accessToken).toBe("env-token"); + }); + test("scopes the env token to an explicit --host over the rc URL", async () => { process.env.SENTRY_AUTH_TOKEN = "env-token"; captureEnvTokenHost(); @@ -783,6 +794,11 @@ describe("serveMcp .sentryclirc URLs", () => { await inRcDir("https://sentry.example.com"); await expect(serve([])).rejects.toBeInstanceOf(HostScopeError); + // --insecure-http alone keeps the rc host as the target, so it must not + // scope the env token either. + await expect(serve(["--insecure-http"])).rejects.toBeInstanceOf( + HostScopeError, + ); expect(served.calls).toHaveLength(0); }); From e384364e20d7316fd29fe75ce8c035dd9cb89122 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:28:15 +0000 Subject: [PATCH 4/4] fix(mcp): Scope the deprecated token like SENTRY_AUTH_TOKEN, via shared 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. --- docs/operations/stdio-auth.md | 9 +- packages/cli/src/lib/env-token-host.ts | 37 ++++---- packages/cli/src/lib/mcp.ts | 116 ++++++++++-------------- packages/cli/src/lib/sentry-client.ts | 30 +----- packages/cli/src/lib/token-host.ts | 48 +++++++++- packages/cli/test/lib/mcp-start.test.ts | 34 ++++++- 6 files changed, 155 insertions(+), 119 deletions(-) diff --git a/docs/operations/stdio-auth.md b/docs/operations/stdio-auth.md index 69d75484b..cd9556c33 100644 --- a/docs/operations/stdio-auth.md +++ b/docs/operations/stdio-auth.md @@ -53,9 +53,12 @@ or `SENTRY_ACCESS_TOKEN`. Both still work, as an alias: the server runs as if and takes the same env-token path. It also replaces any `SENTRY_AUTH_TOKEN` / `SENTRY_TOKEN` already in the environment, because the flag is the more explicit choice. `--access-token` wins over -`SENTRY_ACCESS_TOKEN`. Like a plain env token, the alias is sent to the -configured Sentry instance (an org token's own URL claim still has to match -it). +`SENTRY_ACCESS_TOKEN`. Like a plain env token, the alias is scoped to the +Sentry instance configured at startup (`SENTRY_HOST`/`SENTRY_URL`, or +`--host`/`--url`), else SaaS; an org token's own URL claim wins. A +`.sentryclirc` URL or the persisted default URL never scopes it, so the server +refuses to send it there (`HostScopeError`), even when you have a stored login +on that server. Either one prints a single deprecation warning to stderr (stdout carries the MCP protocol), without the token: diff --git a/packages/cli/src/lib/env-token-host.ts b/packages/cli/src/lib/env-token-host.ts index c2d01a980..ebb36d9c5 100644 --- a/packages/cli/src/lib/env-token-host.ts +++ b/packages/cli/src/lib/env-token-host.ts @@ -37,7 +37,7 @@ import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; import { DEFAULT_SENTRY_URL, normalizeUrl } from "./constants.js"; import { getRawEnvToken } from "./db/auth.js"; -import { getEnv } from "./env.js"; +import { getEnv, withEnv } from "./env.js"; import { ConfigError } from "./errors.js"; import { parseSntrysClaim } from "./token-claims.js"; @@ -132,16 +132,21 @@ export function captureEnvTokenHost(): void { env.SENTRY_HOST?.trim() || env.SENTRY_URL?.trim(), env.SENTRY_HOST?.trim() ? "SENTRY_HOST" : "SENTRY_URL", ) ?? null; + snapshotState.byEnv.set(env, snapshotEnvToken(configuredHost)); +} + +/** Scope the current env token to its claim, else `configuredHost`, else SaaS. */ +function snapshotEnvToken(configuredHost: string | null): HostSnapshot { const token = getRawEnvToken(); // Claim first: for sntrys_ tokens, the embedded url is authoritative. const claim = captureClaimHost(token); - snapshotState.byEnv.set(env, { + return { configuredHost, host: claim.host ?? configuredHost ?? DEFAULT_SENTRY_URL, hostFromClaim: claim.host !== undefined, ...(claim.error ? { claimError: claim.error } : {}), ...(token ? { token } : {}), - }); + }; } /** @@ -194,20 +199,20 @@ export function getTrustedEnvTokenHost(): string | undefined { } /** - * Give `derived`, a copy of the current environment, the env-token host this - * environment snapshotted instead of one re-read from the copy. By now the - * copy's `SENTRY_HOST`/`SENTRY_URL` can come from a `.sentryclirc`, which must - * not scope the env token. + * Scope the env token in `derived`, a copy of the current environment, as if + * it had been set at startup: to its `sntrys_` claim, else the + * `SENTRY_HOST`/`SENTRY_URL` this environment had at boot, else SaaS. By now + * the copy's `SENTRY_HOST`/`SENTRY_URL` can come from a `.sentryclirc` or the + * persisted default, which must not scope the token. The copy may carry a + * different token than the boot environment did (`sentry mcp`'s deprecated + * `--access-token`), so the token is read from the copy. */ -export function inheritEnvTokenHost(derived: NodeJS.ProcessEnv): void { - const env = getEnv(); - if (!snapshotState.byEnv.has(env)) { - captureEnvTokenHost(); - } - const snapshot = snapshotState.byEnv.get(env); - if (snapshot) { - snapshotState.byEnv.set(derived, snapshot); - } +export function scopeEnvTokenToBootHost(derived: NodeJS.ProcessEnv): void { + const configuredHost = getBootConfiguredSentryUrl() ?? null; + snapshotState.byEnv.set( + derived, + withEnv(derived, () => snapshotEnvToken(configuredHost)), + ); } /** Only the explicit URL captured at boot may migrate a legacy stored login. */ diff --git a/packages/cli/src/lib/mcp.ts b/packages/cli/src/lib/mcp.ts index a845c76ce..e6e7355f4 100644 --- a/packages/cli/src/lib/mcp.ts +++ b/packages/cli/src/lib/mcp.ts @@ -7,16 +7,17 @@ import { getAccountRow, } from "./db/accounts.js"; import { + getCredentialContext, getEffectiveAccountRow, type RefreshTokenResult, refreshToken, } from "./db/auth.js"; import { getBootConfiguredSentryUrl, - inheritEnvTokenHost, + scopeEnvTokenToBootHost, } from "./env-token-host.js"; import { getEnv, withEnv } from "./env.js"; -import { AuthError, HostScopeError } from "./errors.js"; +import { AuthError } from "./errors.js"; import { deprecatedFlags, type McpFlags, @@ -25,8 +26,7 @@ import { } from "./mcp/args.js"; import { finalizeMcpConfig } from "./mcp/config.js"; import { assertRcUrlTrusted, getRcInjectedTokenSource } from "./sentryclirc.js"; -import { parseSntrysClaim } from "./token-claims.js"; -import { getActiveTokenHost, isHostTrusted } from "./token-host.js"; +import { assertCredentialScope } from "./token-host.js"; type McpTarget = { sentryHost: string; @@ -173,15 +173,13 @@ export async function resolveCliMcpCredential( applyCredentialPin(requested); } const result = await refreshToken({ force: options.force }); - const tokenHost = getActiveTokenHost(); - - if (!(tokenHost && isHostTrusted(targetUrl, tokenHost))) { - throw new HostScopeError( - "Cannot start MCP server with the active CLI credentials", - targetUrl, - tokenHost, - ); - } + // The same host guard the CLI applies to each of its own requests: the + // server sends this token to the target. + assertCredentialScope( + targetUrl, + getCredentialContext(), + "Cannot start MCP server with the active CLI credentials", + ); // At startup, an unusable stored credential falls back to the env token, // the same as other CLI commands. Later, that fallback is a different @@ -290,33 +288,6 @@ function warnDeprecatedAccessToken(source: DeprecatedAccessToken["source"]) { ); } -/** - * The environment the deprecated token runs under: the same as - * `SENTRY_AUTH_TOKEN= SENTRY_FORCE_ENV_TOKEN=1`, so it takes the CLI's - * env-token path and wins over any stored login. It also replaces an env - * token that is already set, since the flag (or `SENTRY_ACCESS_TOKEN`) is the - * more explicit choice for this server. - * - * The URL is pinned to the MCP target: `--host`/`--url` have always told the - * MCP server where to send this token, and the env-token snapshot for this - * copy then scopes the token to that server. An org auth token's own URL - * claim still wins, so it cannot be sent anywhere else. - */ -function deprecatedTokenEnv( - env: NodeJS.ProcessEnv, - token: string, - target: McpTarget, -): NodeJS.ProcessEnv { - const aliased: NodeJS.ProcessEnv = { ...env }; - delete aliased.SENTRY_ACCESS_TOKEN; - delete aliased.SENTRY_TOKEN; - aliased.SENTRY_AUTH_TOKEN = token; - aliased.SENTRY_FORCE_ENV_TOKEN = "1"; - delete aliased.SENTRY_HOST; - aliased.SENTRY_URL = `${target.sentryProtocol}://${target.sentryHost}`; - return aliased; -} - /** * The environment with the deprecated `--host`/`--url`/`--insecure-http` * target applied as `SENTRY_URL`, the setting they stand in for. The @@ -359,33 +330,44 @@ function mayRescopeEnvToken(flags: McpFlags, target: McpTarget): boolean { } /** - * The environment a `SENTRY_AUTH_TOKEN` (or `SENTRY_TOKEN`) runs under: forced - * over any stored login, as if `SENTRY_FORCE_ENV_TOKEN=1` were set. An MCP - * client passes the token in its server config to pick the account, so a - * stored CLI login must not silently replace it. Other commands keep - * preferring the stored login. + * The environment the client's token runs under: `SENTRY_AUTH_TOKEN` (or + * `SENTRY_TOKEN`) forced over any stored login, as if `SENTRY_FORCE_ENV_TOKEN=1` + * were set. An MCP client passes the token in its server config to pick the + * account, so a stored CLI login must not silently replace it. Other commands + * keep preferring the stored login. + * + * The deprecated `--access-token` / `SENTRY_ACCESS_TOKEN` is an alias for it: + * it replaces any env token, as the more explicit choice for this server, and + * then takes the same path, with the same host scoping. * * A token the `.sentryclirc` shim copied in is not forced: it comes from the * repository, not the client, so it must not replace the user's login. * - * With `keepStartupHost`, the copy keeps the env token's startup host: by now - * `SENTRY_URL` can come from a `.sentryclirc` or the persisted default, which - * must not scope the token (see `inheritEnvTokenHost`). See + * With `keepStartupHost`, the token is scoped as if set at startup (see + * `scopeEnvTokenToBootHost`): by now `SENTRY_URL` can come from a + * `.sentryclirc` or the persisted default, which must not scope it. See * {@link mayRescopeEnvToken} for when the server's target may scope it. */ -function preferEnvToken( +function clientTokenEnv( env: NodeJS.ProcessEnv, + deprecatedToken: string | undefined, keepStartupHost: boolean, ): NodeJS.ProcessEnv { if ( - !(env.SENTRY_AUTH_TOKEN?.trim() || env.SENTRY_TOKEN?.trim()) || - getRcInjectedTokenSource() + !deprecatedToken && + (!(env.SENTRY_AUTH_TOKEN?.trim() || env.SENTRY_TOKEN?.trim()) || + getRcInjectedTokenSource()) ) { return env; } - const forced = { ...env, SENTRY_FORCE_ENV_TOKEN: "1" }; + const forced: NodeJS.ProcessEnv = { ...env, SENTRY_FORCE_ENV_TOKEN: "1" }; + if (deprecatedToken) { + delete forced.SENTRY_ACCESS_TOKEN; + delete forced.SENTRY_TOKEN; + forced.SENTRY_AUTH_TOKEN = deprecatedToken; + } if (keepStartupHost) { - inheritEnvTokenHost(forced); + scopeEnvTokenToBootHost(forced); } return forced; } @@ -424,25 +406,19 @@ export async function serveMcp(flags: McpFlags, cwd: string): Promise { // source: without this gate, a token with no host claim would be sent to // whatever server the file names. Apply the same rc-URL check as every // other CLI command, unless an explicit `--host`/`--url` flag overrides the - // target, or the deprecated token is an org token, whose host claim the - // credential check below matches against the target. Checked against the - // invocation's own credentials, before the deprecated token replaces them: - // an env token from the client wins over a stored login, as it does for - // serving, but keeps the host snapshotted at startup, so the rc URL cannot - // scope it. - if ( - !( - hasSentryTarget(flags) || - (deprecatedToken && parseSntrysClaim(deprecatedToken)) - ) - ) { - await withEnv(preferEnvToken(env, true), () => assertRcUrlTrusted(cwd)); + // target. Checked against the credential this server will use, scoped to + // its startup host, so the rc URL cannot scope it. + if (!hasSentryTarget(flags)) { + await withEnv(clientTokenEnv(env, deprecatedToken, true), () => + assertRcUrlTrusted(cwd), + ); } - const targetEnv = withTargetEnv(env, flags, config); - const credentialEnv = deprecatedToken - ? deprecatedTokenEnv(targetEnv, deprecatedToken, config) - : preferEnvToken(targetEnv, !mayRescopeEnvToken(flags, config)); + const credentialEnv = clientTokenEnv( + withTargetEnv(env, flags, config), + deprecatedToken, + !mayRescopeEnvToken(flags, config), + ); // Loaded only now: mcp-core's server, the agent providers, and the MCP // telemetry are not needed for --help or setup errors. diff --git a/packages/cli/src/lib/sentry-client.ts b/packages/cli/src/lib/sentry-client.ts index 2eedf7189..048fb8cad 100644 --- a/packages/cli/src/lib/sentry-client.ts +++ b/packages/cli/src/lib/sentry-client.ts @@ -19,7 +19,7 @@ import { type TransportCache, } from "@sentry/toolkit-core/api-client/transport"; import { computeInvalidationPrefixes } from "@sentry/toolkit-core/api-client/response-cache"; -import { formatAuthHeader, normalizeAuthToken } from "./auth-header.js"; +import { formatAuthHeader } from "./auth-header.js"; import { seedCompletions } from "./api/completion-seed.js"; import { maybeWarnEnvTokenIgnored } from "./auth-hint.js"; import { @@ -57,11 +57,9 @@ import { lookupCachedResponse, storeCachedResponse, } from "./response-cache.js"; -import { normalizeOrigin } from "./sentry-urls.js"; import { isManualHttpTracing, withTracingSpan } from "./telemetry.js"; -import { parseSntrysClaim } from "./token-claims.js"; import { - isHostTrustedForClaim, + assertCredentialScope, isRequestOriginTrustedForContext, } from "./token-host.js"; @@ -157,29 +155,7 @@ function prepareHeaders( // Host-scoping guard (defense in depth). Primary rejection happens at the // URL-arg / rc-shim entry points; this catches any code path that mutated // SENTRY_HOST/SENTRY_URL without going through those guards. - if (!isRequestOriginTrustedForContext(input, credential)) { - throw new HostScopeError( - "Credentials", - normalizeOrigin(input) ?? "", - credential.host, - ); - } - - // sntrys_ claim check — defense-in-depth for users with access to - // multiple Sentry instances. The claim is unsigned (see token-claims.ts); - // fail-open on parse errors. Uses isHostTrustedForClaim so multi-region - // fan-out via the control silo's region URLs still works. - const claimUrl = parseSntrysClaim(normalizeAuthToken(credential.token))?.url; - if ( - claimUrl && - !isHostTrustedForClaim(input, claimUrl, credential.identity) - ) { - throw new HostScopeError( - "Credentials", - normalizeOrigin(input) ?? "", - claimUrl, - ); - } + assertCredentialScope(input, credential); if (isManualHttpTracing()) { const traceData = getTraceData(); diff --git a/packages/cli/src/lib/token-host.ts b/packages/cli/src/lib/token-host.ts index ad8063a13..1d057be64 100644 --- a/packages/cli/src/lib/token-host.ts +++ b/packages/cli/src/lib/token-host.ts @@ -16,13 +16,17 @@ */ import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; +import { normalizeAuthToken } from "./auth-header.js"; import { + type CredentialContext, getActiveAuthHost, getCredentialContext, getIdentityFingerprint, } from "./db/auth.js"; import { isTrustedRegionOrigin } from "./db/regions.js"; -import { normalizeHttpOrigin } from "./sentry-urls.js"; +import { HostScopeError } from "./errors.js"; +import { normalizeHttpOrigin, normalizeOrigin } from "./sentry-urls.js"; +import { parseSntrysClaim } from "./token-claims.js"; function normalizeTrustOrigin( input: string | URL | Request | undefined | null, @@ -151,6 +155,48 @@ export function isRequestOriginTrustedForContext( return isOriginTrustedFor(requestInput, context.host, context.identity); } +/** + * Refuse to send `credential` to `requestInput` unless it is scoped to that + * host: the fetch layer's guard on every request, also applied where a + * credential is handed to another client (`sentry mcp`). `source` names what + * was refused in the error. + * + * Besides the credential's own host, an `sntrys_` token's claim must match + * too — defense in depth for users with access to multiple Sentry + * instances. The claim is unsigned (see token-claims.ts); fail-open on parse + * errors. Multi-region fan-out via the control silo's region URLs still + * works (see {@link isHostTrustedForClaim}). + * + * @throws {HostScopeError} When there is no credential, or it is scoped to + * another host + */ +export function assertCredentialScope( + requestInput: string | URL | Request, + credential: + | Pick + | undefined, + source = "Credentials", +): void { + const refuse = (scope: string | undefined) => + new HostScopeError( + source, + normalizeOrigin(requestInput) ?? "", + scope, + ); + if ( + !(credential && isRequestOriginTrustedForContext(requestInput, credential)) + ) { + throw refuse(credential?.host); + } + const claimUrl = parseSntrysClaim(normalizeAuthToken(credential.token))?.url; + if ( + claimUrl && + !isHostTrustedForClaim(requestInput, claimUrl, credential.identity) + ) { + throw refuse(claimUrl); + } +} + /** * Like {@link isRequestOriginTrusted}, but anchored on the `sntrys_` claim * url instead of `getActiveTokenHost()`. diff --git a/packages/cli/test/lib/mcp-start.test.ts b/packages/cli/test/lib/mcp-start.test.ts index e32c84ec0..28d65443d 100644 --- a/packages/cli/test/lib/mcp-start.test.ts +++ b/packages/cli/test/lib/mcp-start.test.ts @@ -549,6 +549,8 @@ describe("serveMcp .sentryclirc URLs", () => { rcDirs.push(dir); workingDir = dir; clearSentryCliRcCache(); + // Boot order: the env-token host is snapshotted before the shim runs. + captureEnvTokenHost(); await applySentryCliRcEnvShim(dir); } @@ -802,13 +804,41 @@ describe("serveMcp .sentryclirc URLs", () => { expect(served.calls).toHaveLength(0); }); - test("serves a static token for an rc URL the user has logged in to", async () => { + test("keeps a static token with no host claim off an rc URL the user has logged in to", async () => { + // The same as SENTRY_AUTH_TOKEN: the stored login on the rc host gets + // past the rc-URL gate, but the static token keeps its startup (SaaS) + // host. setAuthToken("cli-session-token", undefined, undefined, { host: "https://sentry.example.com", }); await inRcDir("https://sentry.example.com"); - const { config } = await start(["--access-token=static-token"]); + await expect(serve(["--access-token=static-token"])).rejects.toBeInstanceOf( + HostScopeError, + ); + await expect( + serve(["--access-token=static-token", "--insecure-http"]), + ).rejects.toBeInstanceOf(HostScopeError); + process.env.SENTRY_ACCESS_TOKEN = "env-static-token"; + await expect(serve([])).rejects.toBeInstanceOf(HostScopeError); + expect(served.calls).toHaveLength(0); + + // An explicit target still scopes it. + const { config, options } = await start([ + "--access-token=static-token", + "--host=sentry.example.com", + ]); expect(config.sentryHost).toBe("sentry.example.com"); + expect(options.accessToken).toBe("static-token"); + }); + + test("serves a static token on the rc URL its startup SENTRY_URL names", async () => { + process.env.SENTRY_URL = "https://sentry.example.com"; + captureEnvTokenHost(); + await inRcDir("https://sentry.example.com"); + + const { config, options } = await start(["--access-token=static-token"]); + expect(config.sentryHost).toBe("sentry.example.com"); + expect(options.accessToken).toBe("static-token"); }); });