Skip to content

Commit 91ea777

Browse files
committed
feat: US-027 - Add TLS database connection e2e-docker fixtures
Implement tls.connect() bridge for TLS socket upgrade, enabling SSL-encrypted database connections through the sandbox. The pg-ssl fixture validates that Postgres SSL connections work end-to-end via the sandbox's net + tls bridge. Bridge changes: - Add NetSocketUpgradeTlsRaw bridge contract for TLS socket upgrade - Add tls module to sandbox (connect, TLSSocket, createSecureContext) - Host-side wraps existing net.Socket with Node.js tls.TLSSocket - Forward end/close events to wrapped raw socket (pg relies on this) - Wire tls module into require-setup for require('tls') resolution Infrastructure: - Custom postgres-ssl Dockerfile with self-signed certificate - Postgres container now runs with SSL enabled (all pg fixtures unaffected) - New pg-ssl fixture: connects with ssl:{rejectUnauthorized:false}, queries pg_stat_ssl to verify encryption, runs CRUD operations through TLS
1 parent a9cf5d3 commit 91ea777

13 files changed

Lines changed: 255 additions & 4 deletions

File tree

packages/secure-exec-core/isolate-runtime/src/inject/require-setup.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1492,6 +1492,14 @@
14921492
return _netModule;
14931493
}
14941494

1495+
// Special handling for tls module
1496+
if (name === 'tls') {
1497+
if (__internalModuleCache['tls']) return __internalModuleCache['tls'];
1498+
__internalModuleCache['tls'] = _tlsModule;
1499+
_debugRequire('loaded', name, 'tls-special');
1500+
return _tlsModule;
1501+
}
1502+
14951503
// Special handling for os module
14961504
if (name === 'os') {
14971505
if (__internalModuleCache['os']) return __internalModuleCache['os'];

packages/secure-exec-core/src/bridge/network.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
NetSocketWriteRawBridgeRef,
2323
NetSocketEndRawBridgeRef,
2424
NetSocketDestroyRawBridgeRef,
25+
NetSocketUpgradeTlsRawBridgeRef,
2526
} from "../shared/bridge-contract.js";
2627

2728
// Declare host bridge References
@@ -67,6 +68,10 @@ declare const _netSocketDestroyRaw:
6768
| NetSocketDestroyRawBridgeRef
6869
| undefined;
6970

71+
declare const _netSocketUpgradeTlsRaw:
72+
| NetSocketUpgradeTlsRawBridgeRef
73+
| undefined;
74+
7075
declare const _registerHandle:
7176
| RegisterHandleBridgeFn
7277
| undefined;
@@ -1957,7 +1962,8 @@ class NetSocket {
19571962
bytesRead = 0;
19581963
bytesWritten = 0;
19591964
private _listeners: Record<string, EventListener[]> = {};
1960-
private _socketId = -1;
1965+
/** @internal socket ID shared with TLS upgrade bridge */
1966+
_socketId = -1;
19611967
private _connectHost = "";
19621968
private _connectPort = 0;
19631969

@@ -2213,6 +2219,7 @@ function onNetSocketDispatch(socketId: number, type: string, data: string): void
22132219
case "end": socket._onEnd(); break;
22142220
case "error": socket._onError(data); break;
22152221
case "close": socket._onClose(data === "1"); break;
2222+
case "secureConnect": socket.emit("secureConnect"); break;
22162223
}
22172224
}
22182225

@@ -2248,12 +2255,101 @@ export const net = {
22482255
isIPv6(input: string): boolean { return netIsIP(input) === 6; },
22492256
};
22502257

2258+
// ----------------------------------------------------------------
2259+
// tls module — TLS socket upgrade bridge
2260+
// ----------------------------------------------------------------
2261+
2262+
/** TLS socket that wraps an existing NetSocket after host-side TLS upgrade. */
2263+
class TLSSocket extends NetSocket {
2264+
encrypted = true;
2265+
authorized = false;
2266+
authorizationError: string | null = null;
2267+
alpnProtocol: string | false = false;
2268+
private _wrappedSocket: NetSocket | null = null;
2269+
2270+
constructor(originalSocket: NetSocket) {
2271+
super();
2272+
this._wrappedSocket = originalSocket;
2273+
// Copy connection state from original socket
2274+
this.remoteAddress = originalSocket.remoteAddress;
2275+
this.remotePort = originalSocket.remotePort;
2276+
this.remoteFamily = originalSocket.remoteFamily;
2277+
this.localAddress = originalSocket.localAddress;
2278+
this.localPort = originalSocket.localPort;
2279+
this.connecting = false;
2280+
this.pending = false;
2281+
this.readyState = "open";
2282+
// Share the same socketId — bridge events route here after upgrade
2283+
this._socketId = originalSocket._socketId;
2284+
// Copy private connect info so _cleanup unregisters the correct handle
2285+
(this as Record<string, unknown>)._connectHost = (originalSocket as Record<string, unknown>)._connectHost;
2286+
(this as Record<string, unknown>)._connectPort = (originalSocket as Record<string, unknown>)._connectPort;
2287+
netSocketInstances.set(this._socketId, this);
2288+
}
2289+
2290+
_onSecureConnect(): void {
2291+
this.authorized = true;
2292+
this.emit("secureConnect");
2293+
}
2294+
2295+
// Forward end/close to the wrapped raw socket — Node.js tls.TLSSocket
2296+
// closes the underlying socket, which fires its 'close' event. Libraries
2297+
// like pg rely on the original socket's 'close' listener to detect shutdown.
2298+
_onEnd(): void {
2299+
super._onEnd();
2300+
if (this._wrappedSocket) this._wrappedSocket._onEnd();
2301+
}
2302+
2303+
_onClose(hadError: boolean): void {
2304+
super._onClose(hadError);
2305+
if (this._wrappedSocket) {
2306+
this._wrappedSocket._onClose(hadError);
2307+
this._wrappedSocket = null;
2308+
}
2309+
}
2310+
}
2311+
2312+
export const tlsModule = {
2313+
TLSSocket: TLSSocket as unknown as typeof import("tls").TLSSocket,
2314+
connect(options: Record<string, unknown>): NetSocket {
2315+
const existingSocket = options.socket as NetSocket | undefined;
2316+
if (!existingSocket || existingSocket._socketId < 0) {
2317+
throw new Error("tls.connect requires an existing connected socket via options.socket");
2318+
}
2319+
2320+
// Create TLS socket wrapper on sandbox side
2321+
const tlsSocket = new TLSSocket(existingSocket);
2322+
2323+
if (typeof _netSocketUpgradeTlsRaw === "undefined") {
2324+
Promise.resolve().then(() => {
2325+
tlsSocket._onError("tls.connect requires NetworkAdapter TLS support");
2326+
});
2327+
return tlsSocket;
2328+
}
2329+
2330+
// Tell host to wrap the underlying TCP socket with TLS
2331+
_netSocketUpgradeTlsRaw.applySync(undefined, [
2332+
existingSocket._socketId,
2333+
JSON.stringify({
2334+
rejectUnauthorized: options.rejectUnauthorized ?? true,
2335+
servername: options.servername,
2336+
}),
2337+
]);
2338+
2339+
return tlsSocket;
2340+
},
2341+
createSecureContext(_options?: Record<string, unknown>): Record<string, unknown> {
2342+
return {};
2343+
},
2344+
};
2345+
22512346
// Export modules and make them available as globals for require()
22522347
exposeCustomGlobal("_httpModule", http);
22532348
exposeCustomGlobal("_httpsModule", https);
22542349
exposeCustomGlobal("_http2Module", http2);
22552350
exposeCustomGlobal("_dnsModule", dns);
22562351
exposeCustomGlobal("_netModule", net);
2352+
exposeCustomGlobal("_tlsModule", tlsModule);
22572353
exposeCustomGlobal("_httpServerDispatch", dispatchServerRequest);
22582354
exposeCustomGlobal("_httpServerUpgradeDispatch", dispatchUpgradeRequest);
22592355
exposeCustomGlobal("_upgradeSocketData", onUpgradeSocketData);
@@ -2280,6 +2376,7 @@ export default {
22802376
https,
22812377
http2,
22822378
net,
2379+
tls: tlsModule,
22832380
IncomingMessage,
22842381
ClientRequest,
22852382
};

packages/secure-exec-core/src/generated/isolate-runtime.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

packages/secure-exec-core/src/shared/bridge-contract.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export const HOST_BRIDGE_GLOBAL_KEYS = {
7575
netSocketWriteRaw: "_netSocketWriteRaw",
7676
netSocketEndRaw: "_netSocketEndRaw",
7777
netSocketDestroyRaw: "_netSocketDestroyRaw",
78+
netSocketUpgradeTlsRaw: "_netSocketUpgradeTlsRaw",
7879
ptySetRawMode: "_ptySetRawMode",
7980
processConfig: "_processConfig",
8081
osConfig: "_osConfig",
@@ -101,6 +102,7 @@ export const RUNTIME_BRIDGE_GLOBAL_KEYS = {
101102
upgradeSocketData: "_upgradeSocketData",
102103
upgradeSocketEnd: "_upgradeSocketEnd",
103104
netModule: "_netModule",
105+
tlsModule: "_tlsModule",
104106
netSocketDispatch: "_netSocketDispatch",
105107
fsFacade: "_fs",
106108
requireFrom: "_requireFrom",
@@ -273,6 +275,9 @@ export type NetSocketWriteRawBridgeRef = BridgeApplySyncRef<[number, string], vo
273275
export type NetSocketEndRawBridgeRef = BridgeApplySyncRef<[number], void>;
274276
export type NetSocketDestroyRawBridgeRef = BridgeApplySyncRef<[number], void>;
275277

278+
// TLS socket upgrade boundary contract.
279+
export type NetSocketUpgradeTlsRawBridgeRef = BridgeApplySyncRef<[number, string], void>;
280+
276281
// PTY boundary contracts.
277282
export type PtySetRawModeBridgeRef = BridgeApplySyncRef<[boolean], void>;
278283

packages/secure-exec-core/src/shared/permissions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ export function wrapNetworkAdapter(
295295
netSocketWrite: adapter.netSocketWrite?.bind(adapter),
296296
netSocketEnd: adapter.netSocketEnd?.bind(adapter),
297297
netSocketDestroy: adapter.netSocketDestroy?.bind(adapter),
298+
netSocketUpgradeTls: adapter.netSocketUpgradeTls?.bind(adapter),
298299
};
299300
}
300301

packages/secure-exec-core/src/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,18 @@ export interface NetworkAdapter {
258258
netSocketEnd?(socketId: number): void;
259259
/** Destroy a TCP socket. */
260260
netSocketDestroy?(socketId: number): void;
261+
/** Upgrade an existing TCP socket to TLS. */
262+
netSocketUpgradeTls?(
263+
socketId: number,
264+
optionsJson: string,
265+
callbacks: {
266+
onData: (dataBase64: string) => void;
267+
onEnd: () => void;
268+
onError: (message: string) => void;
269+
onClose: (hadError: boolean) => void;
270+
onSecureConnect: () => void;
271+
},
272+
): void;
261273
}
262274

263275
export interface PermissionDecision {

packages/secure-exec-node/src/bridge-setup.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1656,10 +1656,24 @@ export async function setupRequire(
16561656
},
16571657
);
16581658

1659+
const netSocketUpgradeTlsRef = new ivm.Reference(
1660+
(socketId: number, optionsJson: string): void => {
1661+
checkBridgeBudget(deps);
1662+
adapter.netSocketUpgradeTls?.(socketId, optionsJson, {
1663+
onData: (dataBase64) => dispatchNetEvent(socketId, "data", dataBase64),
1664+
onEnd: () => dispatchNetEvent(socketId, "end", ""),
1665+
onError: (message) => dispatchNetEvent(socketId, "error", message),
1666+
onClose: (hadError) => dispatchNetEvent(socketId, "close", hadError ? "1" : "0"),
1667+
onSecureConnect: () => dispatchNetEvent(socketId, "secureConnect", ""),
1668+
});
1669+
},
1670+
);
1671+
16591672
await jail.set(HOST_BRIDGE_GLOBAL_KEYS.netSocketConnectRaw, netSocketConnectRef);
16601673
await jail.set(HOST_BRIDGE_GLOBAL_KEYS.netSocketWriteRaw, netSocketWriteRef);
16611674
await jail.set(HOST_BRIDGE_GLOBAL_KEYS.netSocketEndRaw, netSocketEndRef);
16621675
await jail.set(HOST_BRIDGE_GLOBAL_KEYS.netSocketDestroyRaw, netSocketDestroyRef);
1676+
await jail.set(HOST_BRIDGE_GLOBAL_KEYS.netSocketUpgradeTlsRaw, netSocketUpgradeTlsRef);
16631677
}
16641678

16651679
// Set up PTY setRawMode bridge ref when stdin is a TTY

packages/secure-exec-node/src/driver.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as net from "node:net";
44
import type { AddressInfo } from "node:net";
55
import * as http from "node:http";
66
import * as https from "node:https";
7+
import * as tls from "node:tls";
78
import type { Server as HttpServer } from "node:http";
89
import * as zlib from "node:zlib";
910
import {
@@ -503,6 +504,35 @@ export function createDefaultNetworkAdapter(options?: {
503504
}
504505
},
505506

507+
// TLS upgrade: wrap existing TCP socket with TLS
508+
netSocketUpgradeTls(socketId, optionsJson, callbacks) {
509+
const socket = upgradeSockets.get(socketId);
510+
if (!socket) return;
511+
512+
const opts = JSON.parse(optionsJson);
513+
// Remove bridge event listeners from the raw TCP socket so encrypted
514+
// data doesn't leak through the old callbacks. Only remove specific
515+
// event types to preserve internal Node.js stream listeners.
516+
for (const ev of ["data", "end", "error", "close", "connect"]) {
517+
socket.removeAllListeners(ev);
518+
}
519+
520+
const tlsSocket = tls.connect({
521+
socket: socket as net.Socket,
522+
rejectUnauthorized: opts.rejectUnauthorized ?? true,
523+
servername: opts.servername,
524+
});
525+
526+
// Wire bridge callbacks to the TLS socket (decrypted data)
527+
tlsSocket.on("data", (chunk) => callbacks.onData(chunk.toString("base64")));
528+
tlsSocket.on("end", () => callbacks.onEnd());
529+
tlsSocket.on("error", (err) => callbacks.onError(err.message));
530+
tlsSocket.on("close", (hadError) => callbacks.onClose(hadError));
531+
tlsSocket.on("secureConnect", () => callbacks.onSecureConnect());
532+
533+
upgradeSockets.set(socketId, tlsSocket);
534+
},
535+
506536
async fetch(url, options) {
507537
// SSRF: validate initial URL and manually follow redirects
508538
// Allow loopback fetch to sandbox-owned server ports

packages/secure-exec/tests/e2e-docker.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,16 +157,23 @@ describe.skipIf(skipReason)("e2e-docker", () => {
157157
return;
158158
}
159159

160-
// Build SSH image
160+
// Build custom images
161161
const sshdDockerfile = path.join(
162162
FIXTURES_ROOT,
163163
"dockerfiles",
164164
"sshd.Dockerfile",
165165
);
166166
buildImage(sshdDockerfile, "secure-exec-test-sshd");
167167

168+
const pgSslDockerfile = path.join(
169+
FIXTURES_ROOT,
170+
"dockerfiles",
171+
"postgres-ssl.Dockerfile",
172+
);
173+
buildImage(pgSslDockerfile, "secure-exec-test-postgres-ssl");
174+
168175
// Start containers (startContainer is synchronous — sequential start)
169-
const pg = startContainer("postgres:16-alpine", {
176+
const pg = startContainer("secure-exec-test-postgres-ssl", {
170177
ports: { 5432: 0 },
171178
env: {
172179
POSTGRES_USER: "testuser",
@@ -176,6 +183,12 @@ describe.skipIf(skipReason)("e2e-docker", () => {
176183
healthCheck: ["pg_isready", "-U", "testuser", "-d", "testdb"],
177184
healthCheckTimeout: 30_000,
178185
args: ["--tmpfs", "/var/lib/postgresql/data"],
186+
// Enable SSL with self-signed certificate from custom image
187+
command: [
188+
"-c", "ssl=on",
189+
"-c", "ssl_cert_file=/var/lib/postgresql/server.crt",
190+
"-c", "ssl_key_file=/var/lib/postgresql/server.key",
191+
],
179192
});
180193

181194
const mysql = startContainer("mysql:8.0", {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
FROM postgres:16-alpine
2+
# Generate self-signed certificate for SSL testing
3+
RUN apk add --no-cache openssl \
4+
&& openssl req -new -x509 -days 365 -nodes \
5+
-out /var/lib/postgresql/server.crt \
6+
-keyout /var/lib/postgresql/server.key \
7+
-subj "/CN=localhost" \
8+
&& chown postgres:postgres /var/lib/postgresql/server.crt /var/lib/postgresql/server.key \
9+
&& chmod 600 /var/lib/postgresql/server.key
10+
EXPOSE 5432

0 commit comments

Comments
 (0)