Skip to content

Commit 9494c55

Browse files
fix(tls): support custom CA certificates for corporate proxies (CLI-1K6)
## Summary - Add custom CA certificate support so the CLI works behind corporate TLS-intercepting proxies (CLI-1K6) - Bun's `fetch()` doesn't honor `NODE_EXTRA_CA_CERTS` natively — we now read it and pass CA certs via Bun's `fetch({ tls: { ca } })` option - On Node 24+, call `tls.setDefaultCACertificates()` to inject CAs into the process-wide trust store - Add `sentry cli defaults ca-cert` for persistent CA registration that also silences the SaaS security warning ## Details ### Root cause Users behind corporate TLS proxies (Zscaler, Netskope, Palo Alto) get `Error: unable to get local issuer certificate` because Bun uses BoringSSL with Mozilla's compiled-in CA bundle and doesn't read `NODE_EXTRA_CA_CERTS` or the OS certificate store. ### Fix New `src/lib/custom-ca.ts` module reads CA certs from (in priority order): 1. `sentry cli defaults ca-cert` (stored in SQLite) 2. `NODE_EXTRA_CA_CERTS` env var Custom CAs are concatenated with `tls.rootCertificates` (Mozilla's built-in bundle) to preserve additive semantics — only adding CAs, never replacing the default bundle. **Runtime behavior:** | Runtime | Mechanism | |---------|-----------| | Bun (compiled binary) | `fetch({ tls: { ca: combined } })` — Bun-specific option | | Node 24+ (npm) | `tls.setDefaultCACertificates([...rootCerts, custom])` — process-wide | | Node 22 (npm) | `NODE_EXTRA_CA_CERTS` handled natively by Node; `defaults ca-cert` requires Node 24+ | PEM validation logic is shared via `readCaCertFile()` — used by both the eager validation in `sentry cli defaults ca-cert` and the lazy loading at fetch time. ### Security model When custom CAs come from env vars (not stored defaults) AND the target is `*.sentry.io`, a one-time `log.warn()` fires. This creates a forensic trail in CI logs — an attacker who can set env vars in a CI step could inject a rogue CA + proxy to intercept tokens. `sentry cli defaults ca-cert` silences the warning (user has acknowledged). See plan file for the full threat model discussion. ### Error handling improvements - TLS cert errors are detected by pattern (walking `error.cause` chain with cycle detection for Node.js's `TypeError: fetch failed` wrapper) and wrapped in `ApiError` - `buildTlsErrorDetail()` is shared between both fetch paths and branches on whether custom CAs are already loaded - TLS errors are not retried (deterministic) - Only CA trust errors are matched (not `CERT_HAS_EXPIRED` or hostname mismatches which need different guidance) ### Changes | File | Change | |------|--------| | `src/lib/custom-ca.ts` | **New** — CA loading, `readCaCertFile`, `buildTlsErrorDetail`, `isTlsCertError`, Node 24+ injection, SaaS warning | | `src/lib/sentry-client.ts` | Wire custom TLS into `fetchWithTimeout`, TLS error handling | | `src/lib/oauth.ts` | Wire custom TLS into OAuth fetch, TLS error handling | | `src/lib/db/defaults.ts` | Add `defaults.ca-cert` key | | `src/commands/cli/defaults.ts` | Add `ca-cert` handler with aliases, eager PEM validation | | `src/lib/formatters/human.ts` | Add `"ca-cert": "CA Certificate"` to defaults display | | `src/lib/env-registry.ts` | Register `NODE_EXTRA_CA_CERTS` | | `script/generate-docs-sections.ts` | Add env var to self-hosted docs table | | `test/lib/custom-ca.test.ts` | **New** — 24 tests | Fixes CLI-1K6 --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 6ef87e7 commit 9494c55

12 files changed

Lines changed: 769 additions & 63 deletions

File tree

AGENTS.md

Lines changed: 56 additions & 51 deletions
Large diffs are not rendered by default.

docs/src/content/docs/self-hosted.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ If you pass a self-hosted Sentry URL as a command argument (e.g., an issue or ev
7777
| `SENTRY_FORCE_ENV_TOKEN` | Force env token over stored OAuth token |
7878
| `SENTRY_ORG` | Default organization slug |
7979
| `SENTRY_PROJECT` | Default project slug (supports `org/project` format) |
80+
| `NODE_EXTRA_CA_CERTS` | Path to PEM file with additional CA certificates (for corporate proxies) |
8081
<!-- GENERATED:END self-hosted-env-vars -->
8182

8283
See [Configuration](./configuration/) for the full environment variable reference.

script/generate-docs-sections.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,10 @@ const SELF_HOSTED_TABLE_ENTRIES: readonly [string, string][] = [
362362
["SENTRY_FORCE_ENV_TOKEN", "Force env token over stored OAuth token"],
363363
["SENTRY_ORG", "Default organization slug"],
364364
["SENTRY_PROJECT", "Default project slug (supports `org/project` format)"],
365+
[
366+
"NODE_EXTRA_CA_CERTS",
367+
"Path to PEM file with additional CA certificates (for corporate proxies)",
368+
],
365369
];
366370

367371
/**

src/commands/cli/defaults.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,31 @@
33
*
44
* View and manage persistent CLI default settings.
55
*
6-
* Supports four defaults:
6+
* Supports these defaults:
77
* - `org` / `organization` — default organization slug
88
* - `project` — default project slug
99
* - `telemetry` — telemetry preference (on/off)
1010
* - `url` — Sentry instance URL (for self-hosted)
11+
* - `ca-cert` — path to PEM file with custom CA certificates
1112
*/
1213

14+
import { resolve } from "node:path";
1315
import type { SentryContext } from "../../context.js";
1416
import { buildCommand } from "../../lib/command.js";
1517
import { normalizeUrl } from "../../lib/constants.js";
18+
import { readCaCertFile } from "../../lib/custom-ca.js";
1619
import { parseCustomHeaders } from "../../lib/custom-headers.js";
1720
import {
1821
clearAllDefaults,
1922
type DefaultsState,
2023
getAllDefaults,
24+
getDefaultCaCert,
2125
getDefaultHeaders,
2226
getDefaultOrganization,
2327
getDefaultProject,
2428
getDefaultUrl,
2529
getTelemetryPreference,
30+
setDefaultCaCert,
2631
setDefaultHeaders,
2732
setDefaultOrganization,
2833
setDefaultProject,
@@ -47,7 +52,13 @@ import { computeTelemetryEffective } from "../../lib/telemetry.js";
4752
// ---------------------------------------------------------------------------
4853

4954
/** Canonical key names matching DefaultsState fields */
50-
type DefaultKey = "organization" | "project" | "telemetry" | "url" | "headers";
55+
type DefaultKey =
56+
| "organization"
57+
| "project"
58+
| "telemetry"
59+
| "url"
60+
| "headers"
61+
| "ca-cert";
5162

5263
/** Handler for reading, writing, and clearing a single default */
5364
type DefaultHandler = {
@@ -131,6 +142,25 @@ const DEFAULTS_REGISTRY: Record<DefaultKey, DefaultHandler> = {
131142
},
132143
clear: () => setDefaultHeaders(null),
133144
},
145+
"ca-cert": {
146+
get: getDefaultCaCert,
147+
set: (value) => {
148+
const trimmed = value.trim();
149+
if (!trimmed) {
150+
throw new ValidationError(
151+
"CA certificate path cannot be empty.",
152+
"ca-cert"
153+
);
154+
}
155+
const resolved = resolve(trimmed);
156+
const result = readCaCertFile(resolved);
157+
if (!result.ok) {
158+
throw new ValidationError(result.reason, "ca-cert");
159+
}
160+
setDefaultCaCert(resolved);
161+
},
162+
clear: () => setDefaultCaCert(null),
163+
},
134164
};
135165

136166
// ---------------------------------------------------------------------------
@@ -140,6 +170,9 @@ const DEFAULTS_REGISTRY: Record<DefaultKey, DefaultHandler> = {
140170
/** Shorthand aliases for canonical keys (e.g., "org" → "organization") */
141171
const KEY_ALIASES: Partial<Record<string, DefaultKey>> = {
142172
org: "organization",
173+
ca: "ca-cert",
174+
cert: "ca-cert",
175+
"ca-certs": "ca-cert",
143176
};
144177

145178
/** Resolve a user-provided key string to a canonical key, or null if unknown */
@@ -196,6 +229,7 @@ export const defaultsCommand = buildCommand({
196229
"sentry cli defaults telemetry off # Disable telemetry\n" +
197230
"sentry cli defaults url https://... # Set Sentry URL (self-hosted)\n" +
198231
"sentry cli defaults headers 'X-IAP: t' # Set custom headers (self-hosted)\n" +
232+
"sentry cli defaults ca-cert /path/to/ca.pem # Trust a custom CA certificate\n" +
199233
"sentry cli defaults org --clear # Clear a specific default\n" +
200234
"sentry cli defaults --clear --yes # Clear all defaults\n" +
201235
"```\n\n" +
@@ -206,7 +240,8 @@ export const defaultsCommand = buildCommand({
206240
"| `project` | Default project slug |\n" +
207241
"| `telemetry` | Telemetry preference (on/off, yes/no, true/false, 1/0) |\n" +
208242
"| `url` | Sentry instance URL (for self-hosted installations) |\n" +
209-
"| `headers` | Custom HTTP headers for self-hosted proxies (semicolon-separated `Name: Value`) |",
243+
"| `headers` | Custom HTTP headers for self-hosted proxies (semicolon-separated `Name: Value`) |\n" +
244+
"| `ca-cert` | Path to PEM file with custom CA certificates (for corporate proxies) |",
210245
},
211246
output: {
212247
human: formatDefaultsResult,
@@ -260,7 +295,7 @@ export const defaultsCommand = buildCommand({
260295
guardNonInteractive(flags);
261296
if (!isConfirmationBypassed(flags)) {
262297
const confirmed = await log.prompt(
263-
"This will clear all defaults (organization, project, telemetry, URL, headers). Continue?",
298+
"This will clear all defaults (organization, project, telemetry, URL, headers, ca-cert). Continue?",
264299
{ type: "confirm" }
265300
);
266301
if (confirmed !== true) {

0 commit comments

Comments
 (0)