Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
136 changes: 136 additions & 0 deletions packages/browserstack-service/src/caCert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,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}`)
}
}
57 changes: 52 additions & 5 deletions packages/browserstack-service/src/cli/cliUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ const CLI_LOCK_POLL_MS = 1000
const CLI_DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000
const CLI_DOWNLOAD_TMP_PREFIX = 'downloaded_file_'
const CLI_DOWNLOAD_TMP_SUFFIX = '.zip'
// Connection-establishment timeout for CLI network calls (update_cli + binary
// download). Node's global fetch hard-codes this at 10s and it is NOT overridable
// via AbortSignal, so a slow TLS/TCP handshake in a constrained/containerised CI
// network (e.g. Kubernetes pods) aborts with UND_ERR_CONNECT_TIMEOUT even though
// the endpoint is healthy (SDK-6152). Routed through a custom undici dispatcher.
// Overridable via env for tuning.
const CLI_CONNECT_TIMEOUT_MS = Number(process.env.BROWSERSTACK_CLI_CONNECT_TIMEOUT_MS) || 60 * 1000
const CLI_FETCH_MAX_ATTEMPTS = 3
const CLI_FETCH_RETRY_BASE_DELAY_MS = 500

export class CLIUtils {
static automationFrameworkDetail = {}
Expand Down Expand Up @@ -199,7 +208,9 @@ export class CLIUtils {
logger.debug(`Resolved binary path: ${finalBinaryPath}`)
return finalBinaryPath
} catch (err) {
logger.debug(
// Surface at warn (not debug) so a failed CLI setup is visible instead of
// the run exiting silently with no results (SDK-6152).
logger.warn(
`Error in setting up cli path directory, Exception: ${util.format(err)}`,
)
}
Expand Down Expand Up @@ -412,6 +423,39 @@ export class CLIUtils {
}
}

/**
* fetch wrapper for CLI network calls that (a) raises undici's connect timeout
* above the non-overridable 10s default of global fetch and (b) retries a few
* times with linear backoff on transient connection failures. Both are needed
* for reliability in constrained CI/containerised networks (SDK-6152).
*/
static fetchWithRetry = async (
input: string,
init: RequestInit,
label: string,
): Promise<Response> => {
let lastError: unknown
for (let attempt = 1; attempt <= CLI_FETCH_MAX_ATTEMPTS; attempt++) {
try {
return await fetch(input, init, { connectTimeoutMs: CLI_CONNECT_TIMEOUT_MS })
} catch (err) {
lastError = err
const reason =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(err as any)?.cause?.code || (err as any)?.code || (err as Error)?.message
logger.warn(
`${label} attempt ${attempt}/${CLI_FETCH_MAX_ATTEMPTS} failed: ${reason}`,
)
if (attempt < CLI_FETCH_MAX_ATTEMPTS) {
await new Promise((resolve) =>
setTimeout(resolve, CLI_FETCH_RETRY_BASE_DELAY_MS * attempt),
)
}
}
}
throw lastError
}

static requestToUpdateCLI = async (
queryParams: Record<string, string>,
config: Options.Testrunner,
Expand All @@ -423,9 +467,10 @@ export class CLIUtils {
Authorization: `Basic ${Buffer.from(`${getBrowserStackUser(config)}:${getBrowserStackKey(config)}`).toString('base64')}`,
},
}
const response = await fetch(
const response = await this.fetchWithRetry(
`${APIUtils.BROWSERSTACK_AUTOMATE_API_URL}/${UPDATED_CLI_ENDPOINT}?${params.toString()}`,
requestInit,
'update_cli request',
)
const jsonResponse = await response.json()
logger.debug(`response ${JSON.stringify(jsonResponse)}`)
Expand Down Expand Up @@ -663,9 +708,11 @@ export class CLIUtils {
)
let response: Response
try {
response = await fetch(binDownloadUrl, {
signal: abortController.signal,
})
response = await fetch(
binDownloadUrl,
{ signal: abortController.signal },
{ connectTimeoutMs: CLI_CONNECT_TIMEOUT_MS },
)
} finally {
clearTimeout(timeout)
}
Expand Down
12 changes: 9 additions & 3 deletions packages/browserstack-service/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,10 @@ class BrowserStackConfig {
options?: BrowserstackConfig & Options.Testrunner,
config?: Options.Testrunner,
capabilities?: Capabilities.TestrunnerCapabilities,
isBrowserStackInfra?: boolean,
): BrowserStackConfig {
if (!this._instance && options && config) {
this._instance = new BrowserStackConfig(options, config, capabilities)
this._instance = new BrowserStackConfig(options, config, capabilities, isBrowserStackInfra)
}
return this._instance
}
Expand All @@ -94,6 +95,7 @@ class BrowserStackConfig {
options: BrowserstackConfig & Options.Testrunner,
config: Options.Testrunner,
capabilities?: Capabilities.TestrunnerCapabilities,
isBrowserStackInfra: boolean = true,
) {
this.framework = config.framework
this.userName = config.user
Expand All @@ -102,8 +104,12 @@ class BrowserStackConfig {
this.percy = options.percy || false
this.accessibility = options.accessibility
this.app = options.app
this.appAutomate = !isUndefined(options.app) || detectAppAutomate(capabilities)
this.automate = !this.appAutomate
// `automate`/`app_automate` describe a BrowserStack-hosted run. When the session
// runs on an external (non-BrowserStack) grid — e.g. Test Observability only, with
// credentials inside testObservabilityOptions — neither should be set, otherwise the
// build is reported with origin `Automate` even though it never touched BrowserStack.
this.appAutomate = isBrowserStackInfra && (!isUndefined(options.app) || detectAppAutomate(capabilities))
this.automate = isBrowserStackInfra && !this.appAutomate
this.buildIdentifier = options.buildIdentifier
this.sdkRunID = uuidv4()
BStackLogger.info(`BrowserStack service started with id: ${this.sdkRunID}`)
Expand Down
14 changes: 14 additions & 0 deletions packages/browserstack-service/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,20 @@ export const EDS_URL = 'https://eds.browserstack.com'

export const SUPPORTED_BROWSERS_FOR_AI = ['chrome', 'microsoftedge', 'firefox']

export const SUPPORTED_BROWSERS_FOR_ACCESSIBILITY = ['chrome', 'chromefortesting', 'safari']

export const MIN_BROWSER_VERSIONS_A11Y = {
chrome: 95,
chromefortesting: 141,
safari: 18.4
} as const

export const MIN_BROWSER_VERSIONS_A11Y_NON_BSTACK = {
chrome: 100,
chromefortesting: 141,
safari: 18.4
} as const

export const TCG_URL = 'https://tcg.browserstack.com'

export const TCG_INFO = {
Expand Down
75 changes: 70 additions & 5 deletions packages/browserstack-service/src/fetchWrapper.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { fetch as undiciFetch, type RequestInit as UndiciRequestInit, ProxyAgent } from 'undici'
import { fetch as undiciFetch, type RequestInit as UndiciRequestInit, ProxyAgent, Agent, type Dispatcher } from 'undici'

import { getMergedCa } from './caCert.js'

export class ResponseError extends Error {
public response: Response
Expand All @@ -8,15 +10,64 @@ export class ResponseError extends Error {
}
}

export default async function fetchWrap(input: RequestInfo | URL, init?: RequestInit) {
const res = await _fetch(input, init)
/**
* Extra options for {@link _fetch} that cannot be expressed through the standard
* `RequestInit`.
*
* `connectTimeoutMs` overrides undici's connection-establishment timeout. Node's
* global `fetch` hard-codes this to 10s and it is NOT overridable via
* `AbortSignal` (the signal only bounds the overall request, not the socket
* connect). In constrained/containerised CI networks (e.g. Kubernetes pods) a
* TLS/TCP handshake can legitimately take longer than 10s, so global fetch aborts
* with `UND_ERR_CONNECT_TIMEOUT` even though the endpoint is healthy. Supplying
* this option routes the request through a custom undici dispatcher with a larger
* connect timeout.
*/
export interface FetchOptions {
connectTimeoutMs?: number
}

// Dispatchers are relatively expensive to build and hold a connection pool, so we cache one per
// (proxy, connectTimeout, hasCa) combination instead of creating a new one on every request.
// SDK-5953: when a customer `proxyCaCertificate` is configured, an explicit dispatcher bypasses the
// global one installed in caCert.ts, so the merged CA (system roots + customer cert) must be applied
// here too — on the proxy tunnel's upstream TLS (`requestTls.ca`) and on a direct `Agent`
// (`connect.ca`). `hasCa` is a sufficient cache discriminator since the merged CA is process-stable.
const dispatcherCache = new Map<string, Dispatcher>()

function getDispatcher(proxyUrl: string | undefined, connectTimeoutMs?: number): Dispatcher {
const ca = getMergedCa()
const key = `${proxyUrl ?? 'direct'}:${connectTimeoutMs ?? 'default'}:${ca ? 'ca' : 'noca'}`
let dispatcher = dispatcherCache.get(key)
if (!dispatcher) {
const connect: { timeout?: number, ca?: string[] } = {}
if (connectTimeoutMs) {
connect.timeout = connectTimeoutMs
}
if (proxyUrl) {
// requestTls.ca = upstream (through-tunnel) TLS, which the inspecting proxy re-signs.
dispatcher = new ProxyAgent({ uri: proxyUrl, connect, ...(ca ? { requestTls: { ca } } : {}) })
} else {
if (ca) {
connect.ca = ca
}
dispatcher = new Agent({ connect })
}
dispatcherCache.set(key, dispatcher)
}
return dispatcher
}

export default async function fetchWrap(input: RequestInfo | URL, init?: RequestInit, options?: FetchOptions) {
const res = await _fetch(input, init, options)
if (!res.ok) {
throw new ResponseError(`Error response from server ${res.status}: ${await res.text()}`, res)
}
return res
}

export function _fetch(input: RequestInfo | URL, init?: RequestInit) {
export function _fetch(input: RequestInfo | URL, init?: RequestInit, options?: FetchOptions) {
const connectTimeoutMs = options?.connectTimeoutMs
const proxyUrl = process.env.HTTP_PROXY || process.env.HTTPS_PROXY
if (proxyUrl) {
const noProxy = process.env.NO_PROXY && process.env.NO_PROXY.trim()
Expand All @@ -25,11 +76,25 @@ export function _fetch(input: RequestInfo | URL, init?: RequestInit) {
const request = new Request(input)
const url = new URL(request.url)
if (!noProxy.some((str) => url.hostname.endsWith(str))) {
// Always route the proxy tunnel through getDispatcher: an explicit dispatcher bypasses
// the global one, so its upstream TLS must trust the customer CA (SDK-5953). getDispatcher
// also applies connectTimeoutMs when provided and caches the agent per key.
const dispatcher = getDispatcher(proxyUrl, connectTimeoutMs)
return undiciFetch(
request.url,
{ ...(init as UndiciRequestInit), dispatcher: new ProxyAgent(proxyUrl) },
{ ...(init as UndiciRequestInit), dispatcher },
) as unknown as Promise<Response>
}
}
// Without a proxy, global fetch's connect timeout is fixed at 10s and cannot be
// overridden, so when a caller needs a larger one we go through undici directly
// with a custom Agent (which also carries the custom CA when configured). The default
// path is left untouched — non-proxy fetch already trusts the CA via the global dispatcher.
if (connectTimeoutMs) {
return undiciFetch(
new Request(input).url,
{ ...(init as UndiciRequestInit), dispatcher: getDispatcher(undefined, connectTimeoutMs) },
) as unknown as Promise<Response>
}
return fetch(input, init)
}
Loading
Loading