diff --git a/.changeset/load-runtime-intercept-ca-cert.md b/.changeset/load-runtime-intercept-ca-cert.md new file mode 100644 index 000000000..75c9a2858 --- /dev/null +++ b/.changeset/load-runtime-intercept-ca-cert.md @@ -0,0 +1,5 @@ +--- +'@cloudflare/sandbox': patch +--- + +Trust injected CA certificate at container startup for HTTPS egress interception diff --git a/packages/sandbox-container/src/cert.ts b/packages/sandbox-container/src/cert.ts new file mode 100644 index 000000000..fd2883a1b --- /dev/null +++ b/packages/sandbox-container/src/cert.ts @@ -0,0 +1,41 @@ +import { appendFileSync, existsSync, readFileSync } from 'node:fs'; +import { createLogger } from '@repo/shared'; + +const logger = createLogger({ component: 'container' }); + +const SYSTEM_CA_BUNDLE = '/etc/ssl/certs/ca-certificates.crt'; +const CERT_WAIT_TIMEOUT_MS = 5000; +const CERT_WAIT_POLL_MS = 100; + +async function waitForCertFile(certPath: string): Promise { + const deadline = Date.now() + CERT_WAIT_TIMEOUT_MS; + while (Date.now() < deadline) { + if (existsSync(certPath)) return true; + await Bun.sleep(CERT_WAIT_POLL_MS); + } + return false; +} + +export async function trustRuntimeCert(): Promise { + // Default to the Cloudflare containers injected CA certificate + const certPath = + process.env.SANDBOX_CA_CERT || + '/etc/cloudflare/certs/cloudflare-containers-ca.crt'; + if (!(await waitForCertFile(certPath))) { + logger.warn( + 'Certificate not found, could not enable HTTPS intercept support' + ); + return; + } + + const certContent = readFileSync(certPath, 'utf8'); + appendFileSync(SYSTEM_CA_BUNDLE, `\n${certContent}`); + + // NODE_EXTRA_CA_CERTS is additive in Node/Bun; the rest replace the default + // store entirely, so they must point to the full bundle. + process.env.NODE_EXTRA_CA_CERTS = certPath; + process.env.SSL_CERT_FILE = SYSTEM_CA_BUNDLE; + process.env.CURL_CA_BUNDLE = SYSTEM_CA_BUNDLE; + process.env.REQUESTS_CA_BUNDLE = SYSTEM_CA_BUNDLE; + process.env.GIT_SSL_CAINFO = SYSTEM_CA_BUNDLE; +} diff --git a/packages/sandbox-container/src/server.ts b/packages/sandbox-container/src/server.ts index 4de6b6e29..da42044d0 100644 --- a/packages/sandbox-container/src/server.ts +++ b/packages/sandbox-container/src/server.ts @@ -1,6 +1,7 @@ import { createLogger } from '@repo/shared'; import type { ServerWebSocket } from 'bun'; import { serve } from 'bun'; +import { trustRuntimeCert } from './cert'; import { Container } from './core/container'; import { Router } from './core/router'; import type { PtyWSData } from './handlers/pty-ws-handler'; @@ -9,11 +10,10 @@ import { generateConnectionId, WebSocketAdapter } from './handlers/ws-adapter'; +import { setupRoutes } from './routes/setup'; export type WSData = (ControlWSData & { type: 'control' }) | PtyWSData; -import { setupRoutes } from './routes/setup'; - const logger = createLogger({ component: 'container' }); const SERVER_PORT = 3000; @@ -203,6 +203,10 @@ export async function startServer(): Promise { hostname: '0.0.0.0' }); + // Server is now listening, the platform injects the cert after the container + // is ready, so wait for it to be available before proceeding. + await trustRuntimeCert(); + return { port: SERVER_PORT, // Cleanup handles application-level resources (processes, ports). diff --git a/packages/sandbox-container/tests/cert.test.ts b/packages/sandbox-container/tests/cert.test.ts new file mode 100644 index 000000000..4c340c121 --- /dev/null +++ b/packages/sandbox-container/tests/cert.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, mock, vi } from 'bun:test'; + +const mockExistsSync = vi.fn(); +const mockReadFileSync = vi.fn(); +const mockAppendFileSync = vi.fn(); + +mock.module('node:fs', () => ({ + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + appendFileSync: mockAppendFileSync +})); + +import { trustRuntimeCert } from '../src/cert'; + +const SYSTEM_CA_BUNDLE = '/etc/ssl/certs/ca-certificates.crt'; +const DEFAULT_CERT_PATH = '/etc/cloudflare/certs/cloudflare-containers-ca.crt'; + +describe('trustRuntimeCert', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.SANDBOX_CA_CERT; + delete process.env.NODE_EXTRA_CA_CERTS; + delete process.env.SSL_CERT_FILE; + delete process.env.CURL_CA_BUNDLE; + delete process.env.REQUESTS_CA_BUNDLE; + delete process.env.GIT_SSL_CAINFO; + }); + + it('does nothing when the cert file does not exist', async () => { + mockExistsSync.mockReturnValue(false); + const sleepSpy = vi.spyOn(Bun, 'sleep').mockResolvedValue(); + const dateSpy = vi + .spyOn(Date, 'now') + .mockReturnValueOnce(0) + .mockReturnValue(10_000); + + try { + await trustRuntimeCert(); + } finally { + sleepSpy.mockRestore(); + dateSpy.mockRestore(); + } + + expect(mockReadFileSync).not.toHaveBeenCalled(); + expect(mockAppendFileSync).not.toHaveBeenCalled(); + }); + + it('does not set env vars when the cert file does not exist', async () => { + mockExistsSync.mockReturnValue(false); + const sleepSpy = vi.spyOn(Bun, 'sleep').mockResolvedValue(); + const dateSpy = vi + .spyOn(Date, 'now') + .mockReturnValueOnce(0) + .mockReturnValue(10_000); + + try { + await trustRuntimeCert(); + } finally { + sleepSpy.mockRestore(); + dateSpy.mockRestore(); + } + + expect(process.env.NODE_EXTRA_CA_CERTS).toBeUndefined(); + expect(process.env.SSL_CERT_FILE).toBeUndefined(); + expect(process.env.CURL_CA_BUNDLE).toBeUndefined(); + expect(process.env.REQUESTS_CA_BUNDLE).toBeUndefined(); + expect(process.env.GIT_SSL_CAINFO).toBeUndefined(); + }); + + it('appends cert content to the system bundle when the cert file exists', async () => { + const certContent = + '-----BEGIN CERTIFICATE-----\nABCDEF\n-----END CERTIFICATE-----\n'; + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(certContent); + + await trustRuntimeCert(); + + expect(mockReadFileSync).toHaveBeenCalledWith(DEFAULT_CERT_PATH, 'utf8'); + expect(mockAppendFileSync).toHaveBeenCalledWith( + SYSTEM_CA_BUNDLE, + `\n${certContent}` + ); + }); + + it('sets all env vars to the correct paths when cert file exists', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('cert-content'); + + await trustRuntimeCert(); + + expect(process.env.NODE_EXTRA_CA_CERTS).toBe(DEFAULT_CERT_PATH); + expect(process.env.SSL_CERT_FILE).toBe(SYSTEM_CA_BUNDLE); + expect(process.env.CURL_CA_BUNDLE).toBe(SYSTEM_CA_BUNDLE); + expect(process.env.REQUESTS_CA_BUNDLE).toBe(SYSTEM_CA_BUNDLE); + expect(process.env.GIT_SSL_CAINFO).toBe(SYSTEM_CA_BUNDLE); + }); + + it('uses SANDBOX_CA_CERT env var instead of the default path', async () => { + const customPath = '/tmp/my-corp-ca.crt'; + process.env.SANDBOX_CA_CERT = customPath; + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('cert-content'); + + await trustRuntimeCert(); + + expect(mockExistsSync).toHaveBeenCalledWith(customPath); + expect(mockReadFileSync).toHaveBeenCalledWith(customPath, 'utf8'); + expect(process.env.NODE_EXTRA_CA_CERTS).toBe(customPath); + }); +}); diff --git a/packages/sandbox-container/tests/main.test.ts b/packages/sandbox-container/tests/main.test.ts index 96845b9f6..5967c141e 100644 --- a/packages/sandbox-container/tests/main.test.ts +++ b/packages/sandbox-container/tests/main.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'bun:test'; -import type { Logger } from '@repo/shared'; import { createNoOpLogger } from '@repo/shared'; import { createSupervisorController } from '../src/main'; diff --git a/tests/e2e/standalone-binary-workflow.test.ts b/tests/e2e/standalone-binary-workflow.test.ts index 4d05bf76a..30b958e4f 100644 --- a/tests/e2e/standalone-binary-workflow.test.ts +++ b/tests/e2e/standalone-binary-workflow.test.ts @@ -25,7 +25,10 @@ describe('Standalone Binary Workflow', () => { let headers: Record; beforeAll(async () => { - sandbox = await createTestSandbox({ type: 'standalone' }); + sandbox = await createTestSandbox({ + type: 'standalone', + initCommand: 'until [ -f /tmp/startup-marker.txt ]; do sleep 0.1; done' + }); workerUrl = sandbox.workerUrl; headers = sandbox.headers(createUniqueSession()); }, 120000);