From e15986b0f3395afe3821b46ed8374eb89301fda1 Mon Sep 17 00:00:00 2001 From: shawnm-anthropic Date: Tue, 5 May 2026 16:29:11 -0700 Subject: [PATCH] feat(terminating-tls): Add opt-in configuration for providing CA cert and key (#247) * Add opt-in configuration for providing CA cert and key * Wire tlsTerminate CA loader into SandboxManager.initialize() When network.tlsTerminate is set, initialize() loads and validates the CA (throws on unreadable/non-PEM). reset() clears the cache. No behavior change when tlsTerminate is unset. Co-Authored-By: Claude Opus 4.7 * Add tests for tlsTerminate config and loadMitmCA - test/fixtures/tls-terminate/: committed test-only RSA-2048 self-signed CA (CN=srt-test-ca DO NOT TRUST, valid to 2126). README documents the generating openssl command. - test/sandbox/mitm-ca.test.ts: load/cache/reset plus all throw paths (missing file, non-PEM, swapped cert/key) against the fixture CA. - test/config-validation.test.ts: schema cases for network.tlsTerminate (optional, both paths required, non-empty). Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- src/sandbox/mitm-ca.ts | 69 +++++++++++++++++++++ src/sandbox/sandbox-config.ts | 21 +++++++ src/sandbox/sandbox-manager.ts | 8 +++ test/config-validation.test.ts | 44 +++++++++++++ test/fixtures/tls-terminate/README.md | 15 +++++ test/fixtures/tls-terminate/ca.crt | 19 ++++++ test/fixtures/tls-terminate/ca.key | 28 +++++++++ test/sandbox/mitm-ca.test.ts | 89 +++++++++++++++++++++++++++ 8 files changed, 293 insertions(+) create mode 100644 src/sandbox/mitm-ca.ts create mode 100644 test/fixtures/tls-terminate/README.md create mode 100644 test/fixtures/tls-terminate/ca.crt create mode 100644 test/fixtures/tls-terminate/ca.key create mode 100644 test/sandbox/mitm-ca.test.ts diff --git a/src/sandbox/mitm-ca.ts b/src/sandbox/mitm-ca.ts new file mode 100644 index 0000000..b572f2c --- /dev/null +++ b/src/sandbox/mitm-ca.ts @@ -0,0 +1,69 @@ +/** + * MITM CA loader for the in-process TLS-terminating proxy. + * + * Loads a user-provided CA cert + key from disk. The CA is supplied via + * `network.tlsTerminate.{caCertPath,caKeyPath}` (see sandbox-config.ts). + * SRT does not generate the CA itself — TLS termination is opt-in and + * requires the caller to provide both paths. + */ + +import { readFileSync } from 'node:fs' +import { logForDebugging } from '../utils/debug.js' + +export type MitmCA = { + certPath: string + keyPath: string + certPem: string + keyPem: string +} + +let ca: MitmCA | undefined + +/** + * Load the MITM CA from the given paths. Throws if either file is missing, + * unreadable, or not PEM — TLS termination is explicit opt-in, so a bad + * config is a hard error (same posture as checkDependencies()). + * + * Idempotent: subsequent calls return the cached CA. + */ +export function loadMitmCA(opts: { + caCertPath: string + caKeyPath: string +}): MitmCA { + if (ca) return ca + + const { caCertPath: certPath, caKeyPath: keyPath } = opts + + const certPem = readPem(certPath, 'CERTIFICATE', 'tlsTerminate.caCertPath') + const keyPem = readPem(keyPath, 'PRIVATE KEY', 'tlsTerminate.caKeyPath') + + ca = { certPath, keyPath, certPem, keyPem } + logForDebugging(`[mitm-ca] loaded CA from ${certPath}`) + return ca +} + +/** Return the cached CA, or undefined if tlsTerminate was not configured. */ +export function getMitmCA(): MitmCA | undefined { + return ca +} + +/** Clear the cached CA — for tests / config reload. */ +export function resetMitmCA(): void { + ca = undefined +} + +function readPem(path: string, label: string, field: string): string { + let pem: string + try { + pem = readFileSync(path, 'utf8') + } catch (err) { + const code = (err as NodeJS.ErrnoException).code ?? String(err) + throw new Error(`${field}: cannot read ${path} (${code})`) + } + // Accept either the exact label or a prefixed variant (e.g. "RSA PRIVATE KEY", + // "EC PRIVATE KEY") for the key case. + if (!new RegExp(`-----BEGIN [A-Z ]*${label}-----`).test(pem)) { + throw new Error(`${field}: ${path} is not a PEM ${label}`) + } + return pem +} diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index 7013d68..2fd7e80 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -173,6 +173,27 @@ export const NetworkConfigSchema = z.object({ mitmProxy: MitmProxyConfigSchema.optional().describe( 'Optional MITM proxy configuration. Routes matching domains through an upstream proxy via Unix socket while SRT still handles allow/deny filtering.', ), + tlsTerminate: z + .object({ + caCertPath: z + .string() + .min(1) + .describe( + 'Path to a PEM-encoded CA certificate. The sandboxed child is ' + + 'configured to trust this CA, and the TLS-terminating proxy uses ' + + 'it to sign per-host certificates.', + ), + caKeyPath: z + .string() + .min(1) + .describe('Path to the PEM-encoded private key for caCertPath.'), + }) + .optional() + .describe( + '[EXPERIMENTAL] Enable in-process TLS termination so HTTPS ' + + 'request/response bodies are visible to SRT. Requires a user-' + + 'supplied CA cert+key; SRT does not generate one.', + ), parentProxy: ParentProxyConfigSchema.optional().describe( "Upstream HTTP proxy for outbound connections. When set, SRT's proxy " + 'tunnels non-mitmProxy traffic through this parent instead of ' + diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 97fd34b..deab1e0 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -1,6 +1,7 @@ import { createHttpProxyServer } from './http-proxy.js' import { createSocksProxyServer } from './socks-proxy.js' import type { SocksProxyWrapper } from './socks-proxy.js' +import { loadMitmCA, resetMitmCA } from './mitm-ca.js' import { logForDebugging } from '../utils/debug.js' import { whichSync } from '../utils/which.js' import { getPlatform, getWslVersion } from '../utils/platform.js' @@ -277,6 +278,12 @@ async function initialize( ) } + // Load TLS-termination CA if configured. Throws on unreadable/non-PEM — + // tlsTerminate is explicit opt-in, so a bad config is a hard error. + if (runtimeConfig.network.tlsTerminate) { + loadMitmCA(runtimeConfig.network.tlsTerminate) + } + // Check dependencies const deps = checkDependencies() if (deps.errors.length > 0) { @@ -930,6 +937,7 @@ async function reset(): Promise { managerContext = undefined initializationPromise = undefined parentProxy = undefined + resetMitmCA() } function getSandboxViolationStore() { diff --git a/test/config-validation.test.ts b/test/config-validation.test.ts index 148f881..352e2c5 100644 --- a/test/config-validation.test.ts +++ b/test/config-validation.test.ts @@ -321,4 +321,48 @@ describe('Config Validation', () => { expect(result.success).toBe(false) }) }) + + describe('network.tlsTerminate', () => { + const base = { + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, + } + + test('is optional — config without it validates', () => { + expect(SandboxRuntimeConfigSchema.safeParse(base).success).toBe(true) + }) + + test('accepts caCertPath + caKeyPath', () => { + const result = SandboxRuntimeConfigSchema.safeParse({ + ...base, + network: { + ...base.network, + tlsTerminate: { caCertPath: '/etc/ca.crt', caKeyPath: '/etc/ca.key' }, + }, + }) + expect(result.success).toBe(true) + }) + + test('rejects when caKeyPath is missing', () => { + const result = SandboxRuntimeConfigSchema.safeParse({ + ...base, + network: { + ...base.network, + tlsTerminate: { caCertPath: '/etc/ca.crt' }, + }, + }) + expect(result.success).toBe(false) + }) + + test('rejects empty caCertPath', () => { + const result = SandboxRuntimeConfigSchema.safeParse({ + ...base, + network: { + ...base.network, + tlsTerminate: { caCertPath: '', caKeyPath: '/etc/ca.key' }, + }, + }) + expect(result.success).toBe(false) + }) + }) }) diff --git a/test/fixtures/tls-terminate/README.md b/test/fixtures/tls-terminate/README.md new file mode 100644 index 0000000..27a9c10 --- /dev/null +++ b/test/fixtures/tls-terminate/README.md @@ -0,0 +1,15 @@ +# tlsTerminate test fixture CA + +`ca.crt` / `ca.key` are a **test-only** self-signed CA used by +`test/sandbox/mitm-ca.test.ts`. The private key is intentionally committed — +it is never used outside the test suite and must never be trusted by anything. + +Generated with: + +```sh +openssl req -x509 -newkey rsa:2048 -nodes -sha256 \ + -keyout ca.key -out ca.crt -days 36500 \ + -subj '/CN=srt-test-ca DO NOT TRUST/O=sandbox-runtime test fixture' +``` + +Regenerate with the same command if the files are ever lost or need rotating. diff --git a/test/fixtures/tls-terminate/ca.crt b/test/fixtures/tls-terminate/ca.crt new file mode 100644 index 0000000..4a1e537 --- /dev/null +++ b/test/fixtures/tls-terminate/ca.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDEjCCAfoCCQD3ID1fWieYFjANBgkqhkiG9w0BAQsFADBKMSEwHwYDVQQDDBhz +cnQtdGVzdC1jYSBETyBOT1QgVFJVU1QxJTAjBgNVBAoMHHNhbmRib3gtcnVudGlt +ZSB0ZXN0IGZpeHR1cmUwIBcNMjYwNTA1MjMxODA0WhgPMjEyNjA0MTEyMzE4MDRa +MEoxITAfBgNVBAMMGHNydC10ZXN0LWNhIERPIE5PVCBUUlVTVDElMCMGA1UECgwc +c2FuZGJveC1ydW50aW1lIHRlc3QgZml4dHVyZTCCASIwDQYJKoZIhvcNAQEBBQAD +ggEPADCCAQoCggEBALxtTI2B4sBhVmLkrvQKq/TKmtsdFyA4iqBKAucbfFfzWnih +6vxGdWuXAe3JcfwG2htfbtpG2BSKjil60GjFoEnt76UmHnsR/CCY+Jlz5VldYeAq +2URFaVpQ9L70Rr/qZjqViHphocy8w+her9eaTuAHJ0SHs+JK0RyPYBUKAg/AEemE +kpgbkjzFCdfm4oVxBskJgu8D0d6Y42DB2BWtiWCkBdKpUeeH6DZeq6IaY7XVbqTl +UxHijNQ5B6TQDTiUKW88ZKrRDg0VDVUMaBvAeoIwrgrunN+15fwn4pTpfhTYsEqc +qhKEjWDRFaOmtRqMJrddOO3fgV4ECkB8ogLb/NUCAwEAATANBgkqhkiG9w0BAQsF +AAOCAQEARjaAKUMoqLFliLCvhcp+Uq7guE/8zI3Bzf7/Eo62RCaBPmxj3W9xir2O +gju3mvc3o+OF+h1oWJ3uqgpB/JVlJwcBsrPWG50qFPPDIxjr8GozsJz8SR6gzxkk +O6tEoeoxXCSJiBUEmWrlSR9jeZ5SqjB2Sou3gtSMWGCsPA+Ys7slWPdbI0KDOwJ7 +Y/FFmunzTrDNvB1TnTUYcGhBRJN4Qho4GmC+TlNNGf1VtDQqKj+Q7cCs2bNjQGeC +LMtGSdO9mL1KMTIT4Hv+A6onjiVn2mDS95YkSLRuAM2gX5u+9Wr4tuygR1kbgdX6 +GDCTjmLlqjJ6ksxExfNSNI6G1ZrAwg== +-----END CERTIFICATE----- diff --git a/test/fixtures/tls-terminate/ca.key b/test/fixtures/tls-terminate/ca.key new file mode 100644 index 0000000..b744fc4 --- /dev/null +++ b/test/fixtures/tls-terminate/ca.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEwAIBADANBgkqhkiG9w0BAQEFAASCBKowggSmAgEAAoIBAQC8bUyNgeLAYVZi +5K70Cqv0yprbHRcgOIqgSgLnG3xX81p4oer8RnVrlwHtyXH8BtobX27aRtgUio4p +etBoxaBJ7e+lJh57EfwgmPiZc+VZXWHgKtlERWlaUPS+9Ea/6mY6lYh6YaHMvMPo +Xq/Xmk7gBydEh7PiStEcj2AVCgIPwBHphJKYG5I8xQnX5uKFcQbJCYLvA9HemONg +wdgVrYlgpAXSqVHnh+g2XquiGmO11W6k5VMR4ozUOQek0A04lClvPGSq0Q4NFQ1V +DGgbwHqCMK4K7pzfteX8J+KU6X4U2LBKnKoShI1g0RWjprUajCa3XTjt34FeBApA +fKIC2/zVAgMBAAECggEBAK3bF1g1sLea2C81G2wW8KRT0GA9zhmvsdDFmToN9UPd +SOunUn7kr/DOizwQs7g7xkCdZFXIKhWB5jvFksgpGIU+IfC6ZENfy8dz/WTxN3um +GunP/1nrxZJMwhXyo9jt+NczI5LvxG94+DXOL+b5/K7eStADeelg6OK2M4wWCOLn +2bMwy9PD3cabd5NxtsNGiuW2EFihQm6oVYy50Xwjl6GB4ry5oUfZF/8mq1npkFG1 +02K/a0r5CePm42uGn8t8IyJK9GQiI+Cw7qvHZJrKUDNcv0t4BOp/eZEEIZNUpunC +RjupTCoSv5brsA/tbVkD5gm0QSHUqlSn/TvW8GfTkEECgYEA7nkLEwQ8uJP3gya6 +mFWpoguCUPOJoyNOFt6CYT4Gdkb9RPL3lBYSEfZjGrt5EwCMTe2fvmgMQHxmIkwr +q+3QdygvwAs+gsD9QJNRqoxx8Hw6/20hK5QLrS6n8kIVuHmsgvHApOn/U9uHRKcL +zVmmd+4Kkt9nfpDpDvvsEQNbzsUCgYEAykafpaKUSUaLdvTNmc2jnFFQWnCN4HqD +55hNoyjdbjGPd7x5QLw4CBhR2kQqxjJ3tIZE3XVEdNZjL/t/wh4fJtSLJMKzTJtw +fd6bPKAhoSK/i2rljRdNa/JLh2JMy8om2Nuh32xA9JuhyS6BEQ3r+2pxZuu93Cj1 +vCieP47KVtECgYEA2nLjJB5jiSlyOB/IGjeOVrR4QbN1x41VwTk+8dkhjkNlSj3P +cUXuc6niCuDk/fUokVI1XPRvFLtfy9c+whXtOtoDM8aZEqm60+afjr1sukDywnyz +P/oz4Aa3LgI/Z2d+Ec1nDSqVC7ozZT4oX4naJk5WPUiMw7H27BT1oHgVJ4kCgYEA +kl10G+h+oF8Zf6Q4ObihUPVNzYNwRiSg2a5NT2i8gYX/KEcK/hqz+LeQUv3MbcoK +8GfP4Od/94NCFnBHy/D73Z8iaCEymJZJWesALWg5rV11eK6LGALqlNeoa3hn1Xab +kYOrp/2vKtCKywaJgguu3CfzkuO2aF6DIfnKOHdcVmECgYEAlxacqJvK99nvCtn0 +xRoCfBx/VA8Urjn1pFf8X/l2K672nBDXT56zcPEJafo9bQim0NwtMbJn43S37KC2 +jAzBbOJWG35ILeofE9FpaGRutNR9U2hbV4WFNn4XKJtt+XiOHPlasXj9rVI6HFi8 +jFvT2ainozHkqWPC4ycGVmmZNlQ= +-----END PRIVATE KEY----- diff --git a/test/sandbox/mitm-ca.test.ts b/test/sandbox/mitm-ca.test.ts new file mode 100644 index 0000000..90e13dd --- /dev/null +++ b/test/sandbox/mitm-ca.test.ts @@ -0,0 +1,89 @@ +import { describe, test, expect, beforeEach, afterAll } from 'bun:test' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + loadMitmCA, + getMitmCA, + resetMitmCA, +} from '../../src/sandbox/mitm-ca.js' + +// Committed test-only CA — see test/fixtures/tls-terminate/README.md. +const FIXTURE_DIR = join(import.meta.dir, '..', 'fixtures', 'tls-terminate') +const certPath = join(FIXTURE_DIR, 'ca.crt') +const keyPath = join(FIXTURE_DIR, 'ca.key') +const certPem = readFileSync(certPath, 'utf8') +const keyPem = readFileSync(keyPath, 'utf8') + +describe('mitm-ca: loadMitmCA', () => { + const scratch = mkdtempSync(join(tmpdir(), 'srt-mitm-ca-')) + const junkPath = join(scratch, 'junk.txt') + writeFileSync(junkPath, 'not pem\n') + + beforeEach(() => { + resetMitmCA() + }) + + afterAll(() => { + rmSync(scratch, { recursive: true, force: true }) + }) + + test('loads a real cert+key pair and exposes it via getMitmCA', () => { + const ca = loadMitmCA({ caCertPath: certPath, caKeyPath: keyPath }) + expect(ca.certPath).toBe(certPath) + expect(ca.keyPath).toBe(keyPath) + expect(ca.certPem).toBe(certPem) + expect(ca.keyPem).toBe(keyPem) + expect(ca.certPem).toContain('-----BEGIN CERTIFICATE-----') + // openssl req -nodes emits PKCS8 ("PRIVATE KEY"); the loader's regex also + // accepts PKCS1 "RSA PRIVATE KEY" / "EC PRIVATE KEY". + expect(ca.keyPem).toMatch(/-----BEGIN (RSA |EC )?PRIVATE KEY-----/) + expect(getMitmCA()).toBe(ca) + }) + + test('caches: second call returns the same instance', () => { + const a = loadMitmCA({ caCertPath: certPath, caKeyPath: keyPath }) + const b = loadMitmCA({ caCertPath: certPath, caKeyPath: keyPath }) + expect(b).toBe(a) + }) + + test('resetMitmCA clears the cache', () => { + loadMitmCA({ caCertPath: certPath, caKeyPath: keyPath }) + expect(getMitmCA()).toBeDefined() + resetMitmCA() + expect(getMitmCA()).toBeUndefined() + }) + + test('throws with field+path+code when cert path is missing', () => { + const missing = join(scratch, 'nope.crt') + expect(() => + loadMitmCA({ caCertPath: missing, caKeyPath: keyPath }), + ).toThrow(/tlsTerminate\.caCertPath: cannot read .*nope\.crt \(ENOENT\)/) + expect(getMitmCA()).toBeUndefined() + }) + + test('throws with field+path+code when key path is missing', () => { + const missing = join(scratch, 'nope.key') + expect(() => + loadMitmCA({ caCertPath: certPath, caKeyPath: missing }), + ).toThrow(/tlsTerminate\.caKeyPath: cannot read .*nope\.key \(ENOENT\)/) + }) + + test('throws when cert file is not PEM', () => { + expect(() => + loadMitmCA({ caCertPath: junkPath, caKeyPath: keyPath }), + ).toThrow(/tlsTerminate\.caCertPath: .* is not a PEM CERTIFICATE/) + }) + + test('throws when key file is not PEM', () => { + expect(() => + loadMitmCA({ caCertPath: certPath, caKeyPath: junkPath }), + ).toThrow(/tlsTerminate\.caKeyPath: .* is not a PEM PRIVATE KEY/) + }) + + test('throws when cert and key are swapped', () => { + expect(() => + loadMitmCA({ caCertPath: keyPath, caKeyPath: certPath }), + ).toThrow(/is not a PEM CERTIFICATE/) + }) +})