-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathcaCert.ts
More file actions
136 lines (128 loc) · 7.01 KB
/
Copy pathcaCert.ts
File metadata and controls
136 lines (128 loc) · 7.01 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
import { setGlobalDispatcher, Agent } from 'undici'
import tls from 'node:tls'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { BStackLogger } from './bstackLogger.js'
// Convert a DER (binary) certificate Buffer to a PEM string (base64, 64-char lines).
function derToPem(der: Buffer): string {
const b64 = der.toString('base64').replace(/(.{64})/g, '$1\n')
return `-----BEGIN CERTIFICATE-----\n${b64}${b64.endsWith('\n') ? '' : '\n'}-----END CERTIFICATE-----\n`
}
// Read a customer CA Buffer into an array of PEM cert strings, supporting BOTH PEM
// (single or multi-cert bundle) and DER (binary) — any extension (.pem/.crt/.cer/.der).
function loadCaCertsAsPem(buf: Buffer): string[] {
if (buf.includes('-----BEGIN CERTIFICATE-----')) {
return buf.toString('utf8').match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) || []
}
return [derToPem(buf)] // DER (binary) single cert
}
/*
* SDK-5953: trust a customer-provided CA certificate for SSL-inspecting corporate
* proxies (Zscaler, Netskope, Forcepoint).
*
* Resolution order for the cert path:
* 1. env BROWSERSTACK_EXTRA_CA_CERTS (consistency with the other SDKs)
* 2. the `proxyCaCertificate` service option
*
* The service makes outbound HTTPS via undici `fetch`: most call sites use the
* global fetch (covered by a global undici Agent), and the proxy path in
* fetchWrapper uses a ProxyAgent (which needs the CA on `requestTls`). We build a
* MERGED ca list (Node's default roots + the customer cert) so non-intercepted
* endpoints still validate. Also export NODE_EXTRA_CA_CERTS for child processes.
*
* Never throws — a misconfigured cert must not break the customer's run.
*/
let mergedCa: string[] | undefined
let configured = false
function resolveCaCertPath(options?: { proxyCaCertificate?: string }): string | undefined {
let p = process.env.BROWSERSTACK_EXTRA_CA_CERTS
if ((!p || !p.trim()) && options?.proxyCaCertificate) {
p = options.proxyCaCertificate
}
if (!p || !String(p).trim()) {
return undefined
}
p = String(p).trim()
try {
if (fs.existsSync(p) && fs.statSync(p).isFile()) {
return p
}
BStackLogger.warn(`proxyCaCertificate: path does not exist or is not a file, falling back to system trust store: ${p}`)
} catch (e) {
BStackLogger.warn(`proxyCaCertificate: failed to stat cert path ${p}: ${(e as Error).message}`)
}
return undefined
}
/** The merged CA list (system roots + customer cert), or undefined when not configured. */
export function getMergedCa(): string[] | undefined {
return mergedCa
}
/** Idempotent. Sets a global undici dispatcher trusting the merged CA, and NODE_EXTRA_CA_CERTS. */
export function configureCaCertificate(options?: { proxyCaCertificate?: string }): void {
if (configured) {
return
}
try {
const certPath = resolveCaCertPath(options)
if (!certPath) {
return
}
const buf = fs.readFileSync(certPath)
const isPem = buf.includes('-----BEGIN CERTIFICATE-----')
const pemCerts = loadCaCertsAsPem(buf)
if (!pemCerts.length) {
BStackLogger.warn(`proxyCaCertificate: no certificate found in ${certPath}; falling back to system trust store.`)
return
}
// Merge (not replace) the customer cert(s) with Node's default roots. Covers every
// global fetch() call in this process (undici) + the fetchWrapper ProxyAgent tunnel.
mergedCa = [...tls.rootCertificates, ...pemCerts]
// NOTE: setGlobalDispatcher REPLACES (not extends) the process-wide undici dispatcher.
// Safe here because we merge with the system roots, so every fetch() in the process still
// validates public endpoints (and still rejects the MITM cert when no custom CA is set).
// Trade-off: if something had already installed a custom global dispatcher (tuned
// timeouts/pools, interceptors), that config is discarded — the idiomatic undici approach.
setGlobalDispatcher(new Agent({ connect: { ca: mergedCa } }))
// The custom CA is now trusted for this process's undici fetch — the primary effect is
// done, so mark configured BEFORE the secondary NODE_EXTRA_CA_CERTS export below. This
// stops a failure in that best-effort export from (a) being logged as a total "setup
// failed / falling back to system trust store" when the dispatcher is in fact active, and
// (b) leaving `configured` false so a later call re-installs the global dispatcher.
configured = true
BStackLogger.info(`proxyCaCertificate: trusting custom CA from ${certPath} (merged with system roots).`)
// Child Node processes (e.g. the detached cleanup spawn) inherit NODE_EXTRA_CA_CERTS and
// trust it at startup. It must be a PEM file: reuse the customer's path when already PEM,
// else write a PEM-converted copy (Node can't load a raw DER through that var).
// SCOPE: NODE_EXTRA_CA_CERTS is Node-only — it covers this service's outbound HTTPS and
// detached Node children, but NOT the BrowserStack Local (Go) binary, which has its own
// proxy flags (--proxy-host, etc.). proxyCaCertificate intentionally covers the service's
// egress, not the Local tunnel binary (consistent with the Java agent's tool-scope).
// Best-effort + scoped: a failure here only affects detached children, not this process
// (which already trusts the CA via the global dispatcher installed above).
try {
if (!process.env.NODE_EXTRA_CA_CERTS) {
let nodeExtra = certPath
if (!isPem) {
// Write the PEM-converted trust anchor into a fresh, owner-only temp dir
// (random name via mkdtemp) with mode 0600 + O_EXCL/O_NOFOLLOW. A predictable
// path in a world-writable tmpdir would let a local attacker pre-plant or
// symlink-race the file the process is about to TRUST as a CA.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browserstack_sdk_ca_'))
nodeExtra = path.join(tmpDir, 'ca.pem')
const fd = fs.openSync(nodeExtra, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600)
try {
fs.writeFileSync(fd, pemCerts.join(''))
} finally {
fs.closeSync(fd)
}
}
process.env.NODE_EXTRA_CA_CERTS = nodeExtra
}
} catch (e) {
BStackLogger.warn(`proxyCaCertificate: CA is trusted for this process, but exporting NODE_EXTRA_CA_CERTS for detached child processes failed (children may not trust the custom CA): ${(e as Error).message}`)
}
} catch (e) {
BStackLogger.warn(`proxyCaCertificate: setup failed, falling back to system trust store: ${(e as Error).message}`)
}
}