diff --git a/packages/cli/src/lib/auth-header.ts b/packages/cli/src/lib/auth-header.ts index 7edc82517..794392d57 100644 --- a/packages/cli/src/lib/auth-header.ts +++ b/packages/cli/src/lib/auth-header.ts @@ -29,3 +29,21 @@ export function formatAuthHeader(token: string): string { } return header; } + +/** + * Encode a credential for `process.env` storage. + * + * Environment blocks are NUL-terminated on every platform, so + * `process.env.X = "pre\0post"` silently stores `"pre"` — truncating an + * invalid credential into a *different*, possibly valid one before the shared + * validation above ever sees it. NUL is the only byte env storage cannot + * hold, so it is replaced with DEL (`\x7f`), which sits in the same classes + * under the shared token rules: padding at the edges (so `trimAuthToken` + * strips it identically) and never valid inside a credential (so + * `normalizeAuthToken` rejects it identically). The credential therefore + * reaches validation in full and yields the same accept/reject decision the + * raw value would have. + */ +export function encodeAuthTokenForEnv(token: string): string { + return token.replaceAll("\0", "\x7f"); +} diff --git a/packages/cli/src/lib/sentryclirc.ts b/packages/cli/src/lib/sentryclirc.ts index 2a8567bac..c1657f8b5 100644 --- a/packages/cli/src/lib/sentryclirc.ts +++ b/packages/cli/src/lib/sentryclirc.ts @@ -20,6 +20,7 @@ import { readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; +import { encodeAuthTokenForEnv } from "./auth-header.js"; import { normalizeUrl } from "./constants.js"; import { getConfigDir } from "./db/index.js"; import { getEnv } from "./env.js"; @@ -349,6 +350,10 @@ export function getRcInjectedTokenSource(): string | undefined { * - `[auth] token` → `SENTRY_AUTH_TOKEN` (if neither `SENTRY_AUTH_TOKEN` nor `SENTRY_TOKEN` is set) * - `[defaults] url` → `SENTRY_URL` (if both `SENTRY_HOST` and `SENTRY_URL` are unset) * + * The token assignment goes through {@link encodeAuthTokenForEnv}: env values + * cannot hold NUL bytes, and a raw token with an embedded NUL would silently + * truncate into a different — possibly still valid — credential. + * * The URL is applied unconditionally at boot — the trust check is deferred * to {@link assertRcUrlTrusted}, which `buildCommand` calls after Stricli * identifies the command (so the command can opt out via `skipRcUrlCheck`). @@ -367,7 +372,14 @@ export async function applySentryCliRcEnvShim(cwd: string): Promise { log.debug( `Setting SENTRY_AUTH_TOKEN from ${CONFIG_FILENAME} (${config.sources.token})`, ); - env.SENTRY_AUTH_TOKEN = config.token; + const envToken = encodeAuthTokenForEnv(config.token); + if (envToken !== config.token) { + log.debug( + `Token in ${config.sources.token} contains NUL byte(s); ` + + "storing the env-safe encoding so validation still rejects it", + ); + } + env.SENTRY_AUTH_TOKEN = envToken; rcInjectedTokenSource = config.sources.token; } diff --git a/packages/cli/test/lib/auth-header.property.test.ts b/packages/cli/test/lib/auth-header.property.test.ts index 26f79afb2..915d8c5b3 100644 --- a/packages/cli/test/lib/auth-header.property.test.ts +++ b/packages/cli/test/lib/auth-header.property.test.ts @@ -9,6 +9,7 @@ import { } from "fast-check"; import { describe, expect, test } from "vitest"; import { + encodeAuthTokenForEnv, formatAuthHeader, normalizeAuthToken, trimAuthToken, @@ -85,3 +86,61 @@ describe("auth token normalization", () => { }, ); }); + +/** Token-shaped input mixing printable, padding, NUL, and other bytes. */ +const envTokenInput = array( + constantFrom( + "a", + "Z", + "0", + "-", + "_", + " ", + "\t", + "\n", + "\x00", + "\x7f", + "\x01", + "é", + ), + { maxLength: 60 }, +).map((chars) => chars.join("")); + +/** Shared validator result: the normalized credential or null when rejected. */ +function normalizeOrNull(input: string): string | null { + try { + return normalizeAuthToken(input); + } catch (error) { + if (error instanceof MalformedAuthTokenError) { + return null; + } + throw error; + } +} + +describe("env-storage encoding", () => { + test("never emits a byte env storage would truncate on", () => { + fcAssert( + property(envTokenInput, (input) => { + expect(encodeAuthTokenForEnv(input)).not.toContain("\x00"); + }), + { numRuns: DEFAULT_NUM_RUNS }, + ); + }); + + test("preserves the shared normalization outcome for any input", () => { + fcAssert( + property(envTokenInput, (input) => { + const encoded = encodeAuthTokenForEnv(input); + // Edge padding trims identically: NUL and DEL occupy the same + // padding class, so the trimmed lengths always match — including + // both-empty (padding-only) cases. + expect(trimAuthToken(encoded).length).toBe(trimAuthToken(input).length); + // Validity is preserved exactly: the validator accepts the encoded + // form iff it accepts the raw form, with the same normalized result. + expect(normalizeOrNull(encoded)).toBe(normalizeOrNull(input)); + }), + { numRuns: DEFAULT_NUM_RUNS }, + ); + }); +}); diff --git a/packages/cli/test/lib/sentryclirc.test.ts b/packages/cli/test/lib/sentryclirc.test.ts index e541b77ea..b3aa36175 100644 --- a/packages/cli/test/lib/sentryclirc.test.ts +++ b/packages/cli/test/lib/sentryclirc.test.ts @@ -8,7 +8,18 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + formatAuthHeader, + normalizeAuthToken, +} from "../../src/lib/auth-header.js"; +import { + getAuthConfig, + getRawEnvToken, + setAuthToken, +} from "../../src/lib/db/auth.js"; import { closeDatabase } from "../../src/lib/db/index.js"; +import { getEnv, withEnv } from "../../src/lib/env.js"; +import { MalformedAuthTokenError } from "../../src/lib/errors.js"; import { captureEnvTokenHost, resetEnvTokenHostForTesting, @@ -369,6 +380,110 @@ describe("applySentryCliRcEnvShim", () => { expect(readEnv("SENTRY_ORG")).toBe(orgBefore); expect(readEnv("SENTRY_PROJECT")).toBe(projBefore); }); + + test("does not let an embedded NUL truncate an rc token into a valid prefix", async () => { + delete process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_TOKEN; + writeRcFile(testDir, "[auth]\ntoken = synthetic-prefix\0synthetic-tail\n"); + + // Boot must not throw — help/login/logout stay reachable. + await expect(applySentryCliRcEnvShim(testDir)).resolves.toBeUndefined(); + + const stored = readEnv("SENTRY_AUTH_TOKEN"); + // The printable prefix must not become a different, still-valid credential. + expect(stored).not.toBe("synthetic-prefix"); + // The rc token stays selected — not silently dropped onto another identity. + expect(stored?.trim()).toBeTruthy(); + expect(getRawEnvToken()?.trim()).toBeTruthy(); + // And the surviving value still fails the shared token validator when used. + expect(() => normalizeAuthToken(stored ?? "")).toThrow( + MalformedAuthTokenError, + ); + expect(() => formatAuthHeader(stored ?? "")).toThrow( + MalformedAuthTokenError, + ); + // Provenance is still recorded for the env-token-ignored hint. + expect(getRcInjectedTokenSource()).toBe(join(testDir, CONFIG_FILENAME)); + }); + + test("trims NUL edge padding like the shared token policy", async () => { + delete process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_TOKEN; + writeRcFile(testDir, "[auth]\ntoken = \0edge-token\0\n"); + + await applySentryCliRcEnvShim(testDir); + + // NULs at the edges are padding under the shared policy — the credential + // normalizes to the real token instead of truncating to an empty env var. + expect(normalizeAuthToken(readEnv("SENTRY_AUTH_TOKEN") ?? "")).toBe( + "edge-token", + ); + }); + + test("keeps a NUL-only rc token selected instead of silently becoming anonymous", async () => { + delete process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_TOKEN; + writeRcFile(testDir, "[auth]\ntoken = \0\n"); + + await applySentryCliRcEnvShim(testDir); + + // Truncating to "" would look like an unset env var and fall through to + // another identity; the stored value must stay selected and still reject. + const stored = readEnv("SENTRY_AUTH_TOKEN"); + expect(stored?.trim()).toBeTruthy(); + expect(getRawEnvToken()?.trim()).toBeTruthy(); + expect(() => normalizeAuthToken(stored ?? "")).toThrow( + MalformedAuthTokenError, + ); + }); + + test("lets stored OAuth shadow an invalid rc token without rejecting it", async () => { + delete process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_TOKEN; + setAuthToken("stored-oauth-token"); + writeRcFile(testDir, "[auth]\ntoken = bad\0token\n"); + + await expect(applySentryCliRcEnvShim(testDir)).resolves.toBeUndefined(); + + // Stored OAuth still wins; the invalid env token is never validated. + expect(getAuthConfig()?.token).toBe("stored-oauth-token"); + expect(getAuthConfig()?.source).toBe("oauth"); + // But if it ever were selected, it would still reject rather than + // transmit a truncated prefix. + expect(() => normalizeAuthToken(getRawEnvToken() ?? "")).toThrow( + MalformedAuthTokenError, + ); + }); + + test("explicit env token still wins over an invalid rc token", async () => { + process.env.SENTRY_AUTH_TOKEN = "explicit-env-token"; + writeRcFile(testDir, "[auth]\ntoken = bad\0token\n"); + + await applySentryCliRcEnvShim(testDir); + expect(readEnv("SENTRY_AUTH_TOKEN")).toBe("explicit-env-token"); + expect(getRcInjectedTokenSource()).toBeUndefined(); + }); + + test("matches in-memory SDK env: same validation outcome for a NUL token", async () => { + const raw = "synthetic-prefix\0synthetic-tail"; + delete process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_TOKEN; + writeRcFile(testDir, `[auth]\ntoken = ${raw}\n`); + + await applySentryCliRcEnvShim(testDir); + // CLI path: the value that survived process.env storage still rejects. + expect(() => normalizeAuthToken(getRawEnvToken() ?? "")).toThrow( + MalformedAuthTokenError, + ); + // SDK path: the same raw value in an in-memory env object (which stores + // NUL verbatim) rejects identically. + withEnv({ SENTRY_AUTH_TOKEN: raw }, () => { + expect(getEnv().SENTRY_AUTH_TOKEN).toBe(raw); + expect(() => normalizeAuthToken(getRawEnvToken() ?? "")).toThrow( + MalformedAuthTokenError, + ); + }); + }); }); describe("monorepo scenario", () => {