mirror of
https://github.com/anthropic-experimental/sandbox-runtime.git
synced 2026-05-06 21:52:30 +08:00
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:
69
src/sandbox/mitm-ca.ts
Normal file
69
src/sandbox/mitm-ca.ts
Normal 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
|
||||
}
|
||||
@@ -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 ' +
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
15
test/fixtures/tls-terminate/README.md
vendored
Normal 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
19
test/fixtures/tls-terminate/ca.crt
vendored
Normal 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
28
test/fixtures/tls-terminate/ca.key
vendored
Normal 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-----
|
||||
89
test/sandbox/mitm-ca.test.ts
Normal file
89
test/sandbox/mitm-ca.test.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user