Skip to content

Commit 41c9486

Browse files
committed
fix(tls): apply custom CA certificates to all fetch call sites (CLI-1KW)
The CLI-1K6 fix added custom CA support but only wired it into 2 of 13 fetch call sites. Users behind corporate TLS proxies still hit certificate errors on upgrade, shared issue resolution, init wizard, and telemetry. Add customFetch() wrapper in custom-ca.ts and apply it to all remaining bare fetch() calls. For the telemetry transport (uses http.request()), pass caCerts through Sentry.init() transportOptions. Also adds log.debug() to previously-silent catch blocks in delta-upgrade, release-notes, and readiness modules.
1 parent 41977f3 commit 41c9486

10 files changed

Lines changed: 224 additions & 16 deletions

File tree

src/lib/api/issues.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ import { listAnOrganization_sIssues } from "@sentry/api";
99

1010
import type { SentryIssue } from "../../types/index.js";
1111
import type { IssueSubstatus } from "../../types/sentry.js";
12-
12+
import {
13+
buildTlsErrorDetail,
14+
customFetch,
15+
isTlsCertError,
16+
warnIfSaasWithEnvCa,
17+
} from "../custom-ca.js";
1318
import { applyCustomHeaders } from "../custom-headers.js";
1419
import { ApiError, ValidationError } from "../errors.js";
1520
import { resolveOrgRegion } from "../region.js";
@@ -694,7 +699,22 @@ export async function getSharedIssue(
694699
// URL-scoped: headers only attach when `url`'s origin matches the trusted
695700
// host, so IAP tokens etc. can't leak to an attacker-controlled share URL.
696701
applyCustomHeaders(headers, url);
697-
const response = await fetch(url, { headers });
702+
warnIfSaasWithEnvCa(url);
703+
704+
let response: Response;
705+
try {
706+
response = await customFetch(url, { headers });
707+
} catch (error) {
708+
if (error instanceof Error && isTlsCertError(error)) {
709+
throw new ApiError(
710+
"TLS certificate error",
711+
0,
712+
buildTlsErrorDetail(error),
713+
`shared/issues/${shareId}`
714+
);
715+
}
716+
throw error;
717+
}
698718

699719
if (!response.ok) {
700720
if (response.status === 404) {

src/lib/binary.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ import {
1515
import { chmod, mkdir, unlink } from "node:fs/promises";
1616
import { delimiter, join, resolve } from "node:path";
1717
import { getUserAgent } from "./constants.js";
18+
import {
19+
buildTlsErrorDetail,
20+
customFetch,
21+
isTlsCertError,
22+
} from "./custom-ca.js";
1823
import { stringifyUnknown, UpgradeError } from "./errors.js";
1924

2025
/** Known directories where the curl installer may place the binary */
@@ -232,12 +237,15 @@ export async function fetchWithUpgradeError(
232237
serviceName: string
233238
): Promise<Response> {
234239
try {
235-
return await fetch(url, init);
240+
return await customFetch(url, init);
236241
} catch (error) {
237242
// Re-throw AbortError as-is so callers can handle it specifically
238243
if (error instanceof Error && error.name === "AbortError") {
239244
throw error;
240245
}
246+
if (error instanceof Error && isTlsCertError(error)) {
247+
throw new UpgradeError("network_error", buildTlsErrorDetail(error));
248+
}
241249
const msg = stringifyUnknown(error);
242250
throw new UpgradeError(
243251
"network_error",

src/lib/custom-ca.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,38 @@ export function buildTlsErrorDetail(error: Error): string {
280280
);
281281
}
282282

283+
/**
284+
* Get the combined CA certificate PEM string for Node.js `http.request()`.
285+
* Returns undefined when no custom CAs are configured.
286+
*
287+
* Unlike {@link getCustomTlsOptions} (which returns Bun's `{ tls: { ca } }` shape),
288+
* this returns the raw PEM string suitable for Node's `https.RequestOptions.ca`
289+
* and the Sentry SDK's `NodeTransportOptions.caCerts`.
290+
*/
291+
export function getCustomCaCerts(): string | undefined {
292+
resolve();
293+
return resolved?.tls.ca;
294+
}
295+
296+
/**
297+
* Drop-in replacement for `fetch()` that injects custom CA certificates
298+
* when configured. All non-authenticated fetch call sites should use this
299+
* instead of bare `fetch()`.
300+
*
301+
* Authenticated API calls go through `fetchWithTimeout()` in sentry-client.ts
302+
* which already applies TLS options directly alongside the SaaS warning.
303+
*/
304+
export function customFetch(
305+
input: string | URL | Request,
306+
init?: RequestInit
307+
): Promise<Response> {
308+
const tlsOpts = getCustomTlsOptions();
309+
if (!tlsOpts) {
310+
return fetch(input, init);
311+
}
312+
return fetch(input, { ...init, ...tlsOpts });
313+
}
314+
283315
/**
284316
* Reset all cached state. Exported for test isolation only.
285317
* @internal

src/lib/delta-upgrade.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
} from "./binary.js";
3131
import { applyPatch } from "./bspatch.js";
3232
import { CLI_VERSION } from "./constants.js";
33+
import { customFetch } from "./custom-ca.js";
3334
import { formatBytes } from "./formatters/numbers.js";
3435
import {
3536
downloadLayerBlob,
@@ -165,14 +166,15 @@ export async function fetchRecentReleases(
165166
const perPage = MAX_STABLE_CHAIN_DEPTH + 2;
166167
let response: Response;
167168
try {
168-
response = await fetch(`${GITHUB_RELEASES_URL}?per_page=${perPage}`, {
169+
response = await customFetch(`${GITHUB_RELEASES_URL}?per_page=${perPage}`, {
169170
headers: {
170171
Accept: "application/vnd.github.v3+json",
171172
"User-Agent": "sentry-cli",
172173
},
173174
signal,
174175
});
175-
} catch {
176+
} catch (error) {
177+
log.debug("Failed to fetch recent releases from GitHub", error);
176178
return [];
177179
}
178180
if (!response.ok) {
@@ -210,11 +212,12 @@ export async function downloadStablePatch(
210212
): Promise<Uint8Array | null> {
211213
let response: Response;
212214
try {
213-
response = await fetch(url, {
215+
response = await customFetch(url, {
214216
headers: { "User-Agent": "sentry-cli" },
215217
signal,
216218
});
217-
} catch {
219+
} catch (error) {
220+
log.debug("Failed to download stable patch", error);
218221
return null;
219222
}
220223
if (!response.ok) {

src/lib/ghcr.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
*/
1919

2020
import { getUserAgent } from "./constants.js";
21+
import { customFetch } from "./custom-ca.js";
2122
import { UpgradeError } from "./errors.js";
2223

2324
/** Default timeout for GHCR HTTP requests (10 seconds) */
@@ -105,7 +106,7 @@ async function fetchWithRetry(
105106

106107
for (let attempt = 0; attempt <= GHCR_MAX_RETRIES; attempt++) {
107108
try {
108-
const response = await fetch(url, {
109+
const response = await customFetch(url, {
109110
...init,
110111
signal: buildSignal(timeout, externalSignal),
111112
});
@@ -339,7 +340,7 @@ export async function downloadNightlyBlob(
339340
// ghcr.io returns 307 → Azure Blob Storage signed URL.
340341
let blobResponse: Response;
341342
try {
342-
blobResponse = await fetch(blobUrl, {
343+
blobResponse = await customFetch(blobUrl, {
343344
headers: {
344345
Authorization: `Bearer ${token}`,
345346
"User-Agent": getUserAgent(),
@@ -384,7 +385,7 @@ export async function downloadNightlyBlob(
384385
// Azure Blob Storage has reliable latency characteristics.
385386
let redirectResponse: Response;
386387
try {
387-
redirectResponse = await fetch(redirectUrl, {
388+
redirectResponse = await customFetch(redirectUrl, {
388389
headers: { "User-Agent": getUserAgent() },
389390
signal,
390391
});

src/lib/init/readiness.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
* Fails fast with actionable errors instead of failing mid-run.
66
*/
77

8+
import { customFetch } from "../custom-ca.js";
89
import { getAuthToken } from "../db/auth.js";
910
import { WizardError } from "../errors.js";
11+
import { logger } from "../logger.js";
1012
import { MASTRA_API_URL } from "./constants.js";
1113
import type { WizardUI } from "./ui/types.js";
1214

@@ -67,12 +69,13 @@ async function checkMastraApi(): Promise<boolean> {
6769
const controller = new AbortController();
6870
const timer = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS);
6971
try {
70-
const resp = await fetch(`${MASTRA_API_URL}/health`, {
72+
const resp = await customFetch(`${MASTRA_API_URL}/health`, {
7173
signal: controller.signal,
7274
method: "GET",
7375
});
7476
return resp.ok;
75-
} catch {
77+
} catch (error) {
78+
logger.withTag("readiness").debug("Mastra API health check failed", error);
7679
return false;
7780
} finally {
7881
clearTimeout(timer);

src/lib/init/wizard-runner.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from "@sentry/node-core/light";
2323
import { formatBanner } from "../banner.js";
2424
import { CLI_VERSION } from "../constants.js";
25+
import { customFetch } from "../custom-ca.js";
2526
import { detectAgent } from "../detect-agent.js";
2627
import { EXIT, WizardError } from "../errors.js";
2728
import {
@@ -636,7 +637,7 @@ export async function runWizard(initialOptions: WizardOptions): Promise<void> {
636637
// Preserve `init.signal` via the spread — MastraClient may pass its
637638
// own per-request signal, and the client-level `abortSignal` is
638639
// forwarded through the same channel.
639-
return fetch(url, {
640+
return customFetch(url, {
640641
...init,
641642
headers: {
642643
...(init?.headers as Record<string, string> | undefined),

src/lib/release-notes.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
GITHUB_RELEASES_URL,
1919
getGitHubHeaders,
2020
} from "./binary.js";
21+
import { customFetch } from "./custom-ca.js";
2122
import type { GitHubRelease } from "./delta-upgrade.js";
2223
import { logger } from "./logger.js";
2324

@@ -559,11 +560,12 @@ const CHANGELOG_MAX_RELEASES = 30;
559560
async function fetchReleasesForChangelog(): Promise<GitHubRelease[]> {
560561
let response: Response;
561562
try {
562-
response = await fetch(
563+
response = await customFetch(
563564
`${GITHUB_RELEASES_URL}?per_page=${CHANGELOG_MAX_RELEASES}`,
564565
{ headers: getGitHubHeaders() }
565566
);
566-
} catch {
567+
} catch (error) {
568+
log.debug("Failed to fetch releases for changelog", error);
567569
return [];
568570
}
569571
if (!response.ok) {
@@ -650,7 +652,7 @@ async function fetchNightlyChangelog(
650652

651653
let response: Response;
652654
try {
653-
response = await fetch(url, { headers: getGitHubHeaders() });
655+
response = await customFetch(url, { headers: getGitHubHeaders() });
654656
} catch {
655657
log.debug("Failed to fetch nightly commits");
656658
return null;

src/lib/telemetry.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
getConfiguredSentryUrl,
2020
SENTRY_CLI_DSN,
2121
} from "./constants.js";
22+
import { getCustomCaCerts } from "./custom-ca.js";
2223
import { isReadonlyError, tryRepairAndRetry } from "./db/schema.js";
2324
import {
2425
type AgentInfo,
@@ -540,6 +541,10 @@ export function initSentry(
540541
// Automatic gzip fallback when running on Node < 22.15 without the
541542
// `Bun.zstdCompress` polyfill (see script/node-polyfills.ts).
542543
transport: makeCompressedTransport,
544+
// Pass custom CA certificates to the transport for corporate TLS proxies.
545+
// The zstd-transport reads `caCerts` and passes it as `ca:` to
546+
// `http.request()`, and the SDK's fallback `makeNodeTransport` does the same.
547+
transportOptions: { caCerts: getCustomCaCerts() },
543548
// Keep default integrations but filter out ones that add overhead without benefit.
544549
// Important: Don't use defaultIntegrations: false as it may break debug ID support.
545550
// NodeSystemError is excluded on runtimes missing util.getSystemErrorMap (Bun) — CLI-K1.

0 commit comments

Comments
 (0)