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
1 change: 1 addition & 0 deletions modules/express/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ BITGO_EXTERNAL_SIGNER_WALLET_IDS=

# Examples
# BITGO_EXTERNAL_SIGNER_WALLET_IDS="{"tbtc":[{"walletId":"xxx","walletPassword":"xxx","secret":"xxx"}],"hteth":["walletId":"xxx","walletPassword":"xxx"]}"
BITGO_DISABLE_PIN_CERTS=
14 changes: 14 additions & 0 deletions modules/express/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,19 @@ BitGo Express currently supports the following proxy protocols:
- SOCKSv5
- PAC

### Domain-scoped CA trust for BitGo connections

By default, BitGo Express restricts outbound HTTPS connections to official BitGo domains (`*.bitgo.com`, `*.bitgo-test.com`, `*.bitgo-staging.com`, `*.bitgo-dev.com`, and their apex domains) to a bundled set of root certificate authorities (`modules/express/bitgo-ca.pem`: Google Trust Services, Amazon Trust Services, DigiCert, Let's Encrypt/ISRG, and Sectigo). Every other outbound destination (external signer, block explorers, webhooks, KMS) continues to use the Node.js / system trust store.

This is **enabled by default** to shrink the blast radius of a compromise of any unrelated CA in the global trust store without requiring manual certificate-pin maintenance across routine BitGo certificate renewals or CA migrations.

- To opt out, pass `--disablepincerts` or set `BITGO_DISABLE_PIN_CERTS=true`.
- When `BITGO_USE_PROXY` is set, Express logs a startup warning and skips the bundled CA policy because the proxy agent owns the TLS connection.
- Redirects are not followed on covered BitGo hostnames so a redirect cannot move a request outside the domain policy; plain `http://` to a covered BitGo hostname is refused.
- At startup, Express warns if any root CA in `bitgo-ca.pem` expires within 30 days.

> The underlying policy helper (`createBitGoTlsPolicy` in `@bitgo/sdk-api`) is Node-only: it relies on the Node `crypto` and `tls` modules and refuses to build in a browser bundle.

### External Signing Mode

BitGo Express can be run in an external signing mode, where the signing of transactions is performed in a separate instance of BitGo Express that has access to the private keys.
Expand Down Expand Up @@ -239,6 +252,7 @@ BitGo Express is able to take configuration options from either command line arg
| N/A | --lightningSignerFileSystemPath | `BITGO_LIGHTNING_SIGNER_FILE_SYSTEM_PATH` | N/A | Local path specifying how to contact the lightning signer node. |
| N/A | --keepalivetimeout | `BITGO_KEEP_ALIVE_TIMEOUT` | N/A | BitGo Express server KeepAliveTimeout node. |
| N/A | --headerstimeout | `BITGO_HEADERS_TIMEOUT` | N/A | BitGo Express server HeadersTimeout node. |
| N/A | --disablepincerts | `BITGO_DISABLE_PIN_CERTS` | false | Disable the default domain-scoped CA bundle restriction for BitGo hostnames. See [Domain-scoped CA trust for BitGo connections](#domain-scoped-ca-trust-for-bitgo-connections). |

\[0]: BitGo will also check the additional environment variables for some options for backwards compatibility, but these environment variables should be considered deprecated:

Expand Down
381 changes: 381 additions & 0 deletions modules/express/bitgo-ca.pem

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions modules/express/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
},
"scripts": {
"test": "yarn unit-test",
"unit-test": "yarn nyc -- mocha",
"unit-test": "yarn test:data && yarn nyc -- mocha",
"integration-test": "yarn nyc -- mocha \"test/integration/**/*.ts\"",
"clean": "rm -rf dist/*",
"prepare": "yarn build",
Expand All @@ -34,7 +34,8 @@
"build-docker": "cd ../../ && yarn update-dockerfile && ./scripts/build-docker-express.sh",
"check-fmt": "yarn prettier --check '{src,test}/**/*.{ts,js,json}'",
"unprettied": "grep -R -L --include '*.ts' --include '*.js' --include '*.json' '@prettier' src test",
"fmt": "yarn prettier --write '{src,test}/**/*.{ts,js,json}'"
"fmt": "yarn prettier --write '{src,test}/**/*.{ts,js,json}'",
"test:data": "bash ../../scripts/verify-bitgo-policy-data.sh --live"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will run prior to the unit test and fail the CI if it needs updating ~30d prior to the actual expiration date

@bitgo/express: $ bash ../../scripts/verify-bitgo-policy-data.sh --live
@bitgo/express: ok: 18 self-signed CA certs valid (>30d), 13 BitGo hosts covered, http downgrades throw
@bitgo/express: live ok: https://app.bitgo.com/ (HTTP 302)

},
"dependencies": {
"@api-ts/io-ts-http": "^3.2.1",
Expand Down
6 changes: 6 additions & 0 deletions modules/express/src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ parser.addArgument(['--disableenvcheck'], {
help: 'disable checking for proper NODE_ENV when running in prod environment',
});

parser.addArgument(['--disablepincerts'], {
action: 'storeConst',
constant: true,
help: 'disable the default domain-scoped CA bundle restriction for BitGo hostnames',
});

parser.addArgument(['-t', '--timeout'], {
help: 'Proxy server timeout in milliseconds',
});
Expand Down
4 changes: 3 additions & 1 deletion modules/express/src/clientRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
import { handlePayLightningInvoice } from './lightning/lightningInvoiceRoutes';
import { handleUpdateLightningWalletCoinSpecific } from './lightning/lightningWalletRoutes';
import { ProxyAgent } from 'proxy-agent';
import { getBitGoTlsPolicy } from './tlsPolicy';
import { isLightningCoinName } from '@bitgo/abstract-lightning';
import { handleLightningWithdraw } from './lightning/lightningWithdrawRoutes';
import createExpressRouter from './typedRoutes';
Expand Down Expand Up @@ -1807,6 +1808,7 @@ function parseBody(req: express.Request, res: express.Response, next: express.Ne
*/
function prepareBitGo(config: Config) {
const { env, customRootUri, customBitcoinNetwork } = config;
const tlsPolicy = getBitGoTlsPolicy(config);

return function prepBitGo(req: express.Request, res: express.Response, next: express.NextFunction) {
// Get access token
Expand Down Expand Up @@ -1834,7 +1836,7 @@ function prepareBitGo(config: Config) {
getProxyForUrl: () => useProxyUrl,
}),
}
: {}),
: { tlsPolicy }),
};

req.bitgo = new BitGo(bitgoConstructorParams);
Expand Down
13 changes: 13 additions & 0 deletions modules/express/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ function readEnvVar(name, ...deprecatedAliases): string | undefined {
}
}

function readBooleanEnvVar(name: string, ...deprecatedAliases: string[]): boolean | undefined {
const val = readEnvVar(name, ...deprecatedAliases);
if (val === undefined) {
return undefined;
}
return !['false', '0', 'no', 'off'].includes(val.trim().toLowerCase());
}

export interface Config {
port: number;
bind: string;
Expand All @@ -33,6 +41,7 @@ export interface Config {
disableSSL: boolean;
disableProxy: boolean;
disableEnvCheck: boolean;
disablePinCerts: boolean;
timeout: number;
customRootUri?: string;
customBitcoinNetwork?: V1Network;
Expand All @@ -59,6 +68,7 @@ export const ArgConfig = (args): Partial<Config> => ({
disableSSL: args.disablessl,
disableProxy: args.disableproxy,
disableEnvCheck: args.disableenvcheck,
disablePinCerts: args.disablepincerts,
timeout: args.timeout,
customRootUri: args.customrooturi,
customBitcoinNetwork: args.custombitcoinnetwork,
Expand All @@ -85,6 +95,7 @@ export const EnvConfig = (): Partial<Config> => ({
disableSSL: readEnvVar('BITGO_DISABLE_SSL', 'BITGO_DISABLESSL', 'DISABLESSL', 'DISABLE_SSL') ? true : undefined,
disableProxy: readEnvVar('BITGO_DISABLE_PROXY', 'DISABLE_PROXY') ? true : undefined,
disableEnvCheck: readEnvVar('BITGO_DISABLE_ENV_CHECK', 'DISABLE_ENV_CHECK') ? true : undefined,
disablePinCerts: readBooleanEnvVar('BITGO_DISABLE_PIN_CERTS'),
timeout: Number(readEnvVar('BITGO_TIMEOUT')),
customRootUri: readEnvVar('BITGO_CUSTOM_ROOT_URI'),
customBitcoinNetwork: readEnvVar('BITGO_CUSTOM_BITCOIN_NETWORK') as V1Network,
Expand All @@ -108,6 +119,7 @@ export const DefaultConfig: Config = {
// BG-9584: temporarily disable env check while we give users time to react to change in runtime behavior
// This will require a major version bump, since this is a breaking change to default behavior.
disableEnvCheck: true,
disablePinCerts: false,
timeout: 305 * 1000,
authVersion: 2,
};
Expand Down Expand Up @@ -171,6 +183,7 @@ function mergeConfigs(...configs: Partial<Config>[]): Config {
disableSSL,
disableProxy: get('disableProxy'),
disableEnvCheck: get('disableEnvCheck'),
disablePinCerts: get('disablePinCerts'),
timeout: get('timeout'),
customRootUri: customRootUri || undefined,
customBitcoinNetwork: get('customBitcoinNetwork'),
Expand Down
4 changes: 4 additions & 0 deletions modules/express/src/expressApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {

import { Environments } from 'bitgo';
import * as clientRoutes from './clientRoutes';
import { getBitGoTlsPolicy } from './tlsPolicy';

/**
* Set up the logging middleware provided by morgan
Expand Down Expand Up @@ -267,6 +268,9 @@ function checkPreconditions(config: Config) {
if (lightningSignerFileSystemPath !== undefined) {
checkJsonFilePath(lightningSignerFileSystemPath);
}

// Fail fast on TLS policy misconfiguration before the server listens.
getBitGoTlsPolicy(config);
}

export function setupRoutes(app: express.Application, config: Config): void {
Expand Down
7 changes: 6 additions & 1 deletion modules/express/src/fetchEncryptedPrivKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { writeFile } from 'fs';
import { logger } from '@bitgo/logger';
import { BitGo, EnvironmentName } from 'bitgo';
import 'dotenv/config';
import { config } from './config';
import { getBitGoTlsPolicy } from './tlsPolicy';

type Output = {
[key: string]: string;
Expand All @@ -37,7 +39,10 @@ const esWalletIDs = process.env.BITGO_EXTERNAL_SIGNER_WALLET_IDS; // example: "{
const esWalletWithPrv = process.env.BITGO_EXTERNAL_SIGNER_WALLET_IDS_WITH_PRV; // example: "[{"walletId":"xxx","encryptedPrv":"xxx"}]"

// TODO: set env to 'test' or 'prod'
const bg = new BitGo({ env: (process.env.BITGO_EXTERNAL_SIGNER_ENV as EnvironmentName) ?? 'test' });
const bg = new BitGo({
env: (process.env.BITGO_EXTERNAL_SIGNER_ENV as EnvironmentName) ?? 'test',
tlsPolicy: getBitGoTlsPolicy(config()),
});

// TODO: set your access token here
const accessToken = esAccessToken || '';
Expand Down
109 changes: 109 additions & 0 deletions modules/express/src/tlsPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import * as fs from 'fs';
import * as path from 'path';
import debugLib from 'debug';
import { BitGoTlsPolicy, createBitGoTlsPolicy, getCaExpiries } from 'bitgo';
import { Config } from './config';
import { TlsConfigurationError } from './errors';

const debug = debugLib('bitgo:express');

/**
* Official BitGo domain patterns covered by the default CA bundle restriction.
* Covers apex and all subdomains (`app.*`, `admin.*`, `cdn.*`, etc.) across prod, test, staging, and dev.
*/
export const BITGO_DEFAULT_TLS_HOSTS: string[] = [
'bitgo.com',
'.bitgo.com',
'bitgo-test.com',
'.bitgo-test.com',
'bitgo-staging.com',
'.bitgo-staging.com',
'bitgo-dev.com',
'.bitgo-dev.com',
];

function resolveBundledCaPath(): string {
const candidates = [path.resolve(__dirname, '../bitgo-ca.pem'), path.resolve(__dirname, '../../bitgo-ca.pem')];
const found = candidates.find((p) => fs.existsSync(p));
if (!found) {
throw new TlsConfigurationError(`Bundled BitGo CA file not found (searched: ${candidates.join(', ')})`);
}
return found;
}

let cachedBundledCaPem: string | undefined;

function getBundledCaPem(): string {
if (cachedBundledCaPem === undefined) {
cachedBundledCaPem = fs.readFileSync(resolveBundledCaPath(), 'utf8');
}
return cachedBundledCaPem;
}

const policies = new WeakMap<Config, BitGoTlsPolicy | undefined>();

/**
* Logs the policy summary to debug, plus a console.warn per bundle CA expiring within 30 days, so an operator
* can update Express before a bundled root CA expiry breaks connections.
*/
function logPolicyStatus(hosts: string[], ca: string | Buffer | Array<string | Buffer>): void {
const expiries = getCaExpiries(ca);
debug('BitGo TLS policy enabled for %d host pattern(s), %d CA(s)', hosts.length, expiries.length);
for (const expiry of expiries) {
if (expiry.daysRemaining < 30) {
const when =
expiry.daysRemaining < 0
? `expired ${-expiry.daysRemaining} day(s) ago`
: `expires in ${expiry.daysRemaining} day(s)`;
console.warn(
`BitGo TLS policy CA '${
expiry.subject
}' ${when} (${expiry.validTo.toISOString()}); update @bitgo/express to refresh the bundled CA roots`
);
}
}
}

/**
* Builds (once per config) the default outbound TLS policy for BitGo hostnames using the bundled multi-provider
* CA store (`bitgo-ca.pem`), or returns undefined when disabled via `disablePinCerts` (`--disablepincerts` /
* `BITGO_DISABLE_PIN_CERTS`) or when `BITGO_USE_PROXY` is set.
*/
export function getBitGoTlsPolicy(config: Config, caBundlePath?: string): BitGoTlsPolicy | undefined {
if (config.disablePinCerts) {
console.warn(
'warning: BitGo TLS CA pinning is disabled via disablePinCerts (--disablepincerts / BITGO_DISABLE_PIN_CERTS).'
);
policies.set(config, undefined);
return undefined;
}

if (process.env.BITGO_USE_PROXY) {
console.warn(
'warning: BitGo TLS CA pinning is skipped because BITGO_USE_PROXY is set; the proxy agent owns the TLS connection.'
);
policies.set(config, undefined);
return undefined;
}

if (!caBundlePath && policies.has(config)) {
const cached = policies.get(config);
if (cached !== undefined) {
return cached;
}
}

let policy: BitGoTlsPolicy;
try {
const ca = caBundlePath ? fs.readFileSync(caBundlePath) : getBundledCaPem();
policy = createBitGoTlsPolicy({ ca, hosts: BITGO_DEFAULT_TLS_HOSTS });
logPolicyStatus(BITGO_DEFAULT_TLS_HOSTS, ca);
} catch (err) {
throw err instanceof TlsConfigurationError ? err : new TlsConfigurationError((err as Error).message);
}

if (!caBundlePath) {
policies.set(config, policy);
}
return policy;
}
29 changes: 29 additions & 0 deletions modules/express/test/unit/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ describe('Config:', () => {
BITGO_DISABLE_SSL: 'envdisableSSL',
BITGO_DISABLE_PROXY: 'envdisableProxy',
BITGO_DISABLE_ENV_CHECK: 'envdisableEnvCheck',
BITGO_DISABLE_PIN_CERTS: 'true',
BITGO_TIMEOUT: 'envtimeout',
BITGO_CUSTOM_ROOT_URI: 'envcustomRootUri',
BITGO_CUSTOM_BITCOIN_NETWORK: 'envcustomBitcoinNetwork',
Expand All @@ -145,6 +146,7 @@ describe('Config:', () => {
disablessl: 'argdisableSSL',
disableproxy: 'argdisableProxy',
disableenvcheck: 'argdisableEnvCheck',
disablepincerts: 'argdisablePinCerts',
timeout: 'argtimeout',
customrooturi: 'argcustomRootUri',
custombitcoinnetwork: 'argcustomBitcoinNetwork',
Expand Down Expand Up @@ -174,6 +176,7 @@ describe('Config:', () => {
disableSSL: 'argdisableSSL',
disableProxy: 'argdisableProxy',
disableEnvCheck: 'argdisableEnvCheck',
disablePinCerts: 'argdisablePinCerts',
timeout: 'argtimeout',
customRootUri: 'https://argcustomRootUri',
customBitcoinNetwork: 'argcustomBitcoinNetwork',
Expand Down Expand Up @@ -278,6 +281,32 @@ describe('Config:', () => {
should.not.exist(parsed.disablessl);
should.not.exist(parsed.disableenvcheck);
should.not.exist(parsed.disableproxy);
should.not.exist(parsed.disablepincerts);
argvStub.restore();
});

it('should handle BITGO_DISABLE_PIN_CERTS boolean values and --disablepincerts CLI flag', () => {
for (const [raw, expected] of [
['true', true],
['1', true],
['false', false],
['0', false],
['no', false],
] as const) {
const envStub = sinon.stub(process, 'env').value({ BITGO_DISABLE_PIN_CERTS: raw });
const { config: proxyConfig } = proxyquire('../../src/config', {
'./args': { args: () => ({}) },
});
proxyConfig().disablePinCerts.should.equal(expected);
envStub.restore();
}

const argvStub = sinon.stub(process, 'argv').value([process.argv[0], process.argv[1], '--disablepincerts']);
try {
const parsed = args.args();
parsed.disablepincerts.should.equal(true);
} finally {
argvStub.restore();
}
});
});
Loading
Loading