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
5 changes: 5 additions & 0 deletions .changeset/load-runtime-intercept-ca-cert.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cloudflare/sandbox': patch
---

Trust injected CA certificate at container startup for HTTPS egress interception
41 changes: 41 additions & 0 deletions packages/sandbox-container/src/cert.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<void> {
// 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;
}
8 changes: 6 additions & 2 deletions packages/sandbox-container/src/server.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;

Expand Down Expand Up @@ -203,6 +203,10 @@ export async function startServer(): Promise<ServerInstance> {
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).
Expand Down
110 changes: 110 additions & 0 deletions packages/sandbox-container/tests/cert.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
1 change: 0 additions & 1 deletion packages/sandbox-container/tests/main.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
5 changes: 4 additions & 1 deletion tests/e2e/standalone-binary-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ describe('Standalone Binary Workflow', () => {
let headers: Record<string, string>;

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);
Expand Down
Loading