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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
shawnm-anthropic
2026-05-05 16:29:11 -07:00
committed by GitHub
parent 04baa776d6
commit e15986b0f3
8 changed files with 293 additions and 0 deletions

69
src/sandbox/mitm-ca.ts Normal file
View File

@@ -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
}

View File

@@ -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 ' +

View File

@@ -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<void> {
managerContext = undefined
initializationPromise = undefined
parentProxy = undefined
resetMitmCA()
}
function getSandboxViolationStore() {

View File

@@ -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)
})
})
})

15
test/fixtures/tls-terminate/README.md vendored Normal file
View File

@@ -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.

19
test/fixtures/tls-terminate/ca.crt vendored Normal file
View File

@@ -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-----

28
test/fixtures/tls-terminate/ca.key vendored Normal file
View File

@@ -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-----

View File

@@ -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/)
})
})