Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a5df350
security: scope tokens to hosts to prevent credential exfiltration
Apr 24, 2026
0cc8a5a
fix: surface CliError from .sentryclirc shim; clarify SaaS port test
Apr 24, 2026
eda92e8
fix: extend host-scoping trust class to include discovered regional s…
Apr 24, 2026
b27fcd6
fix: bypass rc URL guard for auth login/logout; fix bare-hostname sco…
Apr 24, 2026
0ecd19e
fix: robust argv parsing for auth login/logout detection
Apr 24, 2026
c53d54b
security: address subagent review findings (fail-closed, HostScopeErr…
Apr 24, 2026
a8d080a
fix: persist login URL only after login succeeds (not before re-auth …
Apr 24, 2026
2b54883
fix: allow IAP custom headers during 'auth login --url' onboarding
Apr 24, 2026
f7a5b46
security: tighten SaaS bypass + trust anchor registration (bot review)
Apr 24, 2026
0ac9480
security: refuse auth login when effective host is rc-sourced (CVE #4)
Apr 24, 2026
10126e3
security: migrate NULL host from boot snapshot + tighten SaaS checks
Apr 24, 2026
fdae715
fix: atomic stored-token-host lookup (no TOCTOU fallback)
Apr 24, 2026
1db4af1
security: parse sntrys_ token claim for fetch-layer DiD + env-token UX
Apr 25, 2026
ac1cac4
fix: normalize bare-hostname rc URL + evict trust state on clearAuth
Apr 25, 2026
06b125b
fix: claim check honors region URL extension (self-hosted multi-region)
Apr 25, 2026
e58ac95
fix(test): format compaction (CI biome stricter than local)
Apr 25, 2026
88acb82
fix: clearAuth preserves login trust anchor (re-auth + IAP)
Apr 25, 2026
d8673ed
refactor: consolidate trust-extension state + remove AI slop
Apr 25, 2026
55c4831
fix: refuseLoginToUntrustedHost checks anchor matches host (not just …
Apr 25, 2026
b79fd88
refactor: extract shared host-scoping test helpers + normalizeUserInp…
Apr 26, 2026
148897c
fix: only refuse --token login to untrusted host (drop OAuth-path fri…
Apr 26, 2026
7c59a51
remove: drop hasLoginTrustAnchor (footgun, no production callers)
Apr 26, 2026
1554a10
revert: re-enable refusal for OAuth login path (phishing defense)
Apr 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 51 additions & 56 deletions AGENTS.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions plugins/sentry-cli/skills/sentry-cli/references/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Authenticate with Sentry
- `--token <value> - Authenticate using an API token instead of OAuth`
- `--timeout <value> - Timeout for OAuth flow in seconds (default: 900) - (default: "900")`
- `--force - Re-authenticate without prompting`
- `--url <value> - Sentry instance URL to authenticate against (e.g. https://sentry.example.com). Required for self-hosted; defaults to SaaS (https://sentry.io).`

**Examples:**

Expand Down
83 changes: 75 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,71 @@
*/

import { getEnv } from "./lib/env.js";
import { captureEnvTokenHost } from "./lib/env-token-host.js";
import { CliError } from "./lib/errors.js";
import { applySentryCliRcEnvShim } from "./lib/sentryclirc.js";

/**
* Extract positional argv tokens, skipping `--flag[=value]` and short `-f`
* tokens. Conservative — bare `--flag` (no `=`) doesn't consume the next
* token, which keeps it as a positional. That's safe for our matching since
* `auth login`/`auth logout` don't take a flag-value that looks like `auth`.
*/
/** @internal exported for testing */
export function extractPositionals(args: readonly string[]): string[] {
const positionals: string[] = [];
let sawDoubleDash = false;
for (const token of args) {
if (sawDoubleDash) {
positionals.push(token);
continue;
}
if (token === "--") {
sawDoubleDash = true;
continue;
}
if (token.startsWith("-") && token.length > 1) {
continue;
}
positionals.push(token);
}
return positionals;
}

/**
* Detect `auth login` / `auth logout` so the `.sentryclirc` URL-trust check
* can be bypassed. These are the only commands that establish or tear down
* host trust, and they must run even when a repo-local `.sentryclirc`
* mismatches the current token (chicken-and-egg otherwise).
*
* Robust against global flags via {@link extractPositionals} so e.g.
* `sentry --foo auth login` still matches.
*/
/** @internal exported for testing */
export function isTrustChangingCommand(args: readonly string[]): boolean {
const positionals = extractPositionals(args);
const [cmd, sub] = positionals;
if (cmd !== "auth") {
return false;
}
return sub === "login" || sub === "logout";
}
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* Preload project context: walk up from `cwd` once, finding both the
* project root (for DSN detection) and `.sentryclirc` config (for
* org/project defaults and env shim). Caches both results so later calls
* to `findProjectRoot` and `loadSentryCliRc` are cache hits.
*/
async function preloadProjectContext(cwd: string): Promise<void> {
async function preloadProjectContext(
cwd: string,
args: readonly string[]
): Promise<void> {
// Snapshot env-token host BEFORE anything mutates env.SENTRY_HOST/URL
// (the .sentryclirc shim or the default-URL fallback below). Pins the
// env-token's trust scope to the user's shell, not a repo-local file.
captureEnvTokenHost();

// Dynamic import keeps the heavy DSN/DB modules out of the completion fast-path
const [{ findProjectRoot }, { setCachedProjectRoot }] = await Promise.all([
import("./lib/dsn/project-root.js"),
Expand All @@ -32,9 +88,13 @@ async function preloadProjectContext(cwd: string): Promise<void> {
reason: result.reason,
});

// Apply .sentryclirc env shim (token, URL) — sentryclirc cache was
// populated as a side effect of findProjectRoot's walk
await applySentryCliRcEnvShim(cwd);
// Apply .sentryclirc env shim (token, URL) — cache was populated as a
// side effect of findProjectRoot's walk. Bypass the URL trust check for
// auth login/logout so onboarding from a repo with a different rc URL
// isn't chicken-and-egg.
await applySentryCliRcEnvShim(cwd, {
skipUrlTrustCheck: isTrustChangingCommand(args),
});

// Apply persistent URL default (lower priority than env vars and .sentryclirc).
// Same mechanism as .sentryclirc — writes to env.SENTRY_URL so all downstream
Expand Down Expand Up @@ -467,11 +527,18 @@ export async function startCli(): Promise<void> {

// Walk up from CWD once to find project root AND .sentryclirc config.
// Caches both so later findProjectRoot / loadSentryCliRc calls are hits.
// Non-fatal — the CLI can still work via env vars and DSN detection.
// Most failures here are non-fatal (unreadable rc file, missing project
// markers), but `CliError` from the rc shim's host-scoping check is an
// actionable rejection that must surface to the user.
try {
await preloadProjectContext(process.cwd());
} catch {
// Gracefully degrade: project context is optional for CLI operation.
await preloadProjectContext(process.cwd(), args);
} catch (err) {
if (err instanceof CliError) {
process.stderr.write(`${err.format()}\n`);
process.exitCode = err.exitCode;
return;
}
// Gracefully degrade: project context is optional.
}

return runCli(args).catch((err) => {
Expand Down
171 changes: 166 additions & 5 deletions src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
listOrganizationsUncached,
} from "../../lib/api-client.js";
import { buildCommand, numberParser } from "../../lib/command.js";
import { DEFAULT_SENTRY_URL, normalizeUrl } from "../../lib/constants.js";
import {
clearAuth,
getActiveEnvVarName,
Expand All @@ -14,9 +15,16 @@ import {
isEnvTokenActive,
setAuthToken,
} from "../../lib/db/auth.js";
import { setDefaultUrl } from "../../lib/db/defaults.js";
import { getDbPath } from "../../lib/db/index.js";
import { getUserInfo, setUserInfo } from "../../lib/db/user.js";
import { AuthError } from "../../lib/errors.js";
import { getEnv } from "../../lib/env.js";
import { getEnvTokenHost } from "../../lib/env-token-host.js";
import {
AuthError,
HostScopeError,
ValidationError,
} from "../../lib/errors.js";
import { success } from "../../lib/formatters/colors.js";
import {
formatDuration,
Expand All @@ -30,6 +38,15 @@ import {
} from "../../lib/interactive-login.js";
import { logger } from "../../lib/logger.js";
import { clearResponseCache } from "../../lib/response-cache.js";
import {
isSaaSTrustOrigin,
normalizeUserInputToOrigin,
} from "../../lib/sentry-urls.js";
import {
isLoginTrustAnchorFor,
normalizeOrigin,
registerLoginTrustAnchor,
} from "../../lib/token-host.js";

const log = logger.withTag("auth.login");

Expand All @@ -56,8 +73,127 @@ type LoginFlags = {
readonly token?: string;
readonly timeout: number;
readonly force: boolean;
readonly url?: string;
};

/**
* Normalize and validate the `--url` flag value. Accepts bare hostnames
* and full URLs; returns the normalized origin.
*/
/** @internal exported for testing */
export function parseLoginUrl(raw: string): string {
const prefixed = normalizeUrl(raw);
if (!prefixed) {
throw new ValidationError("--url cannot be empty", "url");
}
const origin = normalizeOrigin(prefixed);
if (!origin) {
throw new ValidationError(`--url is not a valid URL: ${raw}`, "url");
}
return origin;
}

/**
* Refuse `auth login` against a host that came from an untrusted channel
* (rc-shim bypass wrote env.SENTRY_URL with no matching trust anchor).
*
* Two distinct attack shapes are blocked here:
*
* 1. **Token leak (`auth login --token X`)**: without the refusal, login
* validation POSTs the user's existing API token to the attacker's
* host — direct credential exfiltration.
*
* 2. **Phishing (`auth login` OAuth device flow)**: the CLI directs the
* user's browser to `<attacker-host>/oauth/authorize/...`. A
* homograph / look-alike domain plus a Sentry-cloned login page can
* capture the user's SSO credentials (Google, GitHub, etc.) — much
* worse than a single token leak because it compromises every
* service the SSO covers. `.sentryclirc` is a stealthy phishing
* vector because it slips through code review more easily than a
* `curl evil.com` would.
*
* `applyLoginUrl` only registers a trust anchor when the host comes from
* a trusted source (`--url` flag or boot-time env snapshot), so "no
* matching anchor" is the load-bearing signal that the host arrived via
* an untrusted channel.
*/
function refuseLoginToUntrustedHost(
flags: LoginFlags,
effectiveHost: string
): void {
if (
flags.url ||
isSaaSTrustOrigin(effectiveHost) ||
isLoginTrustAnchorFor(effectiveHost)
) {
return;
}
const tokenHint = flags.token ? " --token <token>" : "";
throw new HostScopeError(
`Refusing to log in against ${effectiveHost}: this URL was configured by a .sentryclirc file in the current or parent directory, not by your shell environment.\n` +
"If you trust this host, pass it explicitly:\n" +
` sentry auth login --url ${effectiveHost}${tokenHint}\n` +
"Otherwise, remove the [defaults] url line from the .sentryclirc file."
);
}
Comment thread
BYK marked this conversation as resolved.
Comment thread
BYK marked this conversation as resolved.

/**
* Persist a non-SaaS `--url` host as the stored default so subsequent CLI
* invocations route correctly without requiring `SENTRY_HOST`. Only writes
* when `--url` was explicitly passed; env/rc-sourced values persist
* through those channels. Non-fatal on DB failure.
*/
function persistLoginUrlAsDefault(
Comment thread
cursor[bot] marked this conversation as resolved.
flagUrl: string | undefined,
effectiveHost: string
): void {
if (!flagUrl || isSaaSTrustOrigin(effectiveHost)) {
return;
}
try {
setDefaultUrl(effectiveHost);
} catch {
log.debug(
`Could not persist default URL to DB; host is recorded on the stored token. Set SENTRY_HOST or run 'sentry cli defaults url ${effectiveHost}' if subsequent commands route incorrectly.`
);
}
}

/**
* When `--url` is passed, set `env.SENTRY_HOST`/`env.SENTRY_URL` so the
* device flow and token refresh hit the requested host. Returns the
* effective host so callers can record it with {@link setAuthToken}.
*
* Also registers a login trust anchor (consumed by {@link applyCustomHeaders}
* for IAP onboarding) — but only when the host comes from a trusted source:
* explicit `--url` argv, or env vars matching the boot-time snapshot. An
* rc-shim-poisoned env value (post-boot mutation) is NOT registered.
*/
export function applyLoginUrl(url: string | undefined): string {
const env = getEnv();
let effectiveHost: string;
let registerAnchor: boolean;

if (url) {
env.SENTRY_HOST = url;
env.SENTRY_URL = url;
effectiveHost = url;
registerAnchor = true;
} else {
effectiveHost =
normalizeUserInputToOrigin(env.SENTRY_HOST || env.SENTRY_URL) ??
DEFAULT_SENTRY_URL;
// Trust the env value only if it matches the boot snapshot — i.e. the
// user's shell, not a post-boot rc-shim write.
registerAnchor = effectiveHost === getEnvTokenHost();
}

if (registerAnchor) {
registerLoginTrustAnchor(effectiveHost);
}
return effectiveHost;
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
BYK marked this conversation as resolved.

/**
* Handle the case where the user is already authenticated.
*
Expand Down Expand Up @@ -118,7 +254,11 @@ export const loginCommand = buildCommand({
fullDescription:
"Log in to Sentry using OAuth or an API token.\n\n" +
"The OAuth flow uses a device code - you'll be given a code to enter at a URL.\n" +
"Alternatively, use --token to authenticate with an existing API token.",
"Alternatively, use --token to authenticate with an existing API token.\n\n" +
"For self-hosted Sentry, pass --url <url> to authenticate against that\n" +
"instance. This is the ONLY way to trust a new Sentry host — URL\n" +
"arguments and config files are refused when they don't match the\n" +
"currently-authenticated host.",
},
parameters: {
flags: {
Expand All @@ -140,10 +280,24 @@ export const loginCommand = buildCommand({
brief: "Re-authenticate without prompting",
default: false,
},
url: {
kind: "parsed",
parse: parseLoginUrl,
brief:
"Sentry instance URL to authenticate against (e.g. https://sentry.example.com). " +
"Required for self-hosted; defaults to SaaS (https://sentry.io).",
optional: true,
},
},
},
output: { human: formatLoginResult },
async *func(this: SentryContext, flags: LoginFlags) {
// Apply --url first so the device flow / token refresh target the
// requested instance. Default URL persistence is deferred until login
// succeeds — see persistLoginUrlAsDefault calls below.
const effectiveHost = applyLoginUrl(flags.url);
refuseLoginToUntrustedHost(flags, effectiveHost);

Comment thread
cursor[bot] marked this conversation as resolved.
// Check if already authenticated and handle re-authentication
if (isAuthenticated()) {
const shouldProceed = await handleExistingAuth(flags.force);
Expand All @@ -161,8 +315,10 @@ export const loginCommand = buildCommand({

// Token-based authentication
if (flags.token) {
// Save token first, then validate by fetching user regions
await setAuthToken(flags.token);
// Save token first (with host scope), then validate by fetching user regions
await setAuthToken(flags.token, undefined, undefined, {
host: effectiveHost,
});

// Validate token by fetching user regions
try {
Expand All @@ -176,6 +332,9 @@ export const loginCommand = buildCommand({
);
}

// Login succeeded — persist default URL for subsequent invocations.
persistLoginUrlAsDefault(flags.url, effectiveHost);

// Fetch and cache user info via /auth/ (works with all token types).
// A transient failure here must not block login — the token is already valid.
const result: LoginResult = {
Expand All @@ -201,12 +360,14 @@ export const loginCommand = buildCommand({
return yield new CommandOutput(result);
}

// OAuth device flow
// OAuth device flow (host scope recorded via completeOAuthFlow → setAuthToken)
const result = await runInteractiveLogin({
timeout: flags.timeout * 1000,
});

if (result) {
// Login succeeded — persist default URL for subsequent invocations.
persistLoginUrlAsDefault(flags.url, effectiveHost);
// Warm the org + region cache so the first real command is fast.
// Fire-and-forget — login already succeeded, caching is best-effort.
warmOrgCache();
Expand Down
4 changes: 3 additions & 1 deletion src/lib/api/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -669,7 +669,9 @@ export async function getSharedIssue(
): Promise<{ groupID: string }> {
const url = `${baseUrl}/api/0/shared/issues/${encodeURIComponent(shareId)}/`;
const headers = new Headers({ "Content-Type": "application/json" });
applyCustomHeaders(headers);
// URL-scoped: headers only attach when `url`'s origin matches the trusted
// host, so IAP tokens etc. can't leak to an attacker-controlled share URL.
applyCustomHeaders(headers, url);
const response = await fetch(url, { headers });

if (!response.ok) {
Expand Down
Loading
Loading