Skip to content

Commit b216040

Browse files
NathanFlurryclaude
andcommitted
feat: US-028 - Add HTTPS error handling e2e fixture for TLS edge cases
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fe633d8 commit b216040

1 file changed

Lines changed: 310 additions & 0 deletions

File tree

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
import { execSync } from "node:child_process";
2+
import * as fs from "node:fs";
3+
import * as http from "node:http";
4+
import * as https from "node:https";
5+
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
6+
import {
7+
allowAllNetwork,
8+
NodeRuntime,
9+
createNodeDriver,
10+
createNodeRuntimeDriverFactory,
11+
} from "../../../src/index.js";
12+
import type { NetworkAdapter } from "../../../src/types.js";
13+
import type { StdioEvent } from "../../../src/shared/api-types.js";
14+
15+
const TMP = `/tmp/se-tls-err-${process.pid}`;
16+
17+
// Generate certs for three TLS error scenarios:
18+
// 1. Expired cert (CA-signed, days=0)
19+
// 2. Hostname mismatch (CA-signed, SAN=wrong.example.com)
20+
// 3. Self-signed cert (no CA trust chain)
21+
function generateTestCerts() {
22+
fs.mkdirSync(TMP, { recursive: true });
23+
24+
function genKey(name: string): string {
25+
const key = execSync(
26+
"openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 2>/dev/null",
27+
{ encoding: "utf-8" },
28+
);
29+
fs.writeFileSync(`${TMP}/${name}.key`, key);
30+
return key;
31+
}
32+
33+
function genCsr(name: string, subj: string): void {
34+
execSync(
35+
`openssl req -new -key ${TMP}/${name}.key -out ${TMP}/${name}.csr -subj "${subj}" 2>/dev/null`,
36+
);
37+
}
38+
39+
function signWithCa(name: string, days: number, san: string): string {
40+
execSync(
41+
`openssl x509 -req -in ${TMP}/${name}.csr -CA ${TMP}/ca.crt -CAkey ${TMP}/ca.key -CAcreateserial -out ${TMP}/${name}.crt -days ${days} -extfile <(echo "subjectAltName=${san}") 2>/dev/null`,
42+
{ shell: "/bin/bash" },
43+
);
44+
return fs.readFileSync(`${TMP}/${name}.crt`, "utf-8");
45+
}
46+
47+
function selfSign(name: string, days: number, san: string): string {
48+
execSync(
49+
`openssl x509 -req -in ${TMP}/${name}.csr -signkey ${TMP}/${name}.key -out ${TMP}/${name}.crt -days ${days} -extfile <(echo "subjectAltName=${san}") 2>/dev/null`,
50+
{ shell: "/bin/bash" },
51+
);
52+
return fs.readFileSync(`${TMP}/${name}.crt`, "utf-8");
53+
}
54+
55+
// CA (valid, long-lived)
56+
genKey("ca");
57+
execSync(
58+
`openssl req -new -x509 -key ${TMP}/ca.key -out ${TMP}/ca.crt -days 365 -subj "/CN=Test CA" 2>/dev/null`,
59+
);
60+
const caCert = fs.readFileSync(`${TMP}/ca.crt`, "utf-8");
61+
62+
// Expired cert (CA-signed, days=0 so notAfter=now, valid SANs)
63+
const expiredKey = genKey("expired");
64+
genCsr("expired", "/CN=localhost");
65+
const expiredCert = signWithCa("expired", 0, "DNS:localhost,IP:127.0.0.1");
66+
67+
// Hostname mismatch cert (CA-signed, SAN for wrong host only)
68+
const mismatchKey = genKey("mismatch");
69+
genCsr("mismatch", "/CN=wrong.example.com");
70+
const mismatchCert = signWithCa(
71+
"mismatch",
72+
365,
73+
"DNS:wrong.example.com",
74+
);
75+
76+
// Self-signed cert (valid SANs, no CA trust chain)
77+
const selfSignedKey = genKey("selfsigned");
78+
genCsr("selfsigned", "/CN=localhost");
79+
const selfSignedCert = selfSign(
80+
"selfsigned",
81+
365,
82+
"DNS:localhost,IP:127.0.0.1",
83+
);
84+
85+
return {
86+
caCert,
87+
expiredCert,
88+
expiredKey,
89+
mismatchCert,
90+
mismatchKey,
91+
selfSignedCert,
92+
selfSignedKey,
93+
};
94+
}
95+
96+
// Network adapter that bypasses SSRF and passes custom TLS options to the host request
97+
function createTestNetworkAdapter(
98+
tlsOptions?: https.RequestOptions,
99+
): NetworkAdapter {
100+
return {
101+
async fetch() {
102+
throw new Error("fetch not implemented in test adapter");
103+
},
104+
async dnsLookup() {
105+
return { address: "127.0.0.1", family: 4 };
106+
},
107+
async httpRequest(url, options) {
108+
return new Promise((resolve, reject) => {
109+
const urlObj = new URL(url);
110+
const isHttps = urlObj.protocol === "https:";
111+
const transport: typeof https = isHttps ? https : http;
112+
const reqOptions: https.RequestOptions = {
113+
hostname: urlObj.hostname,
114+
port: urlObj.port || (isHttps ? 443 : 80),
115+
path: urlObj.pathname + urlObj.search,
116+
method: options?.method || "GET",
117+
headers: options?.headers || {},
118+
...(isHttps &&
119+
options?.rejectUnauthorized !== undefined && {
120+
rejectUnauthorized: options.rejectUnauthorized,
121+
}),
122+
...tlsOptions,
123+
};
124+
125+
const req = transport.request(reqOptions, (res) => {
126+
const chunks: Buffer[] = [];
127+
res.on("data", (chunk: Buffer) => chunks.push(chunk));
128+
res.on("end", () => {
129+
const headers: Record<string, string> = {};
130+
for (const [k, v] of Object.entries(res.headers)) {
131+
if (typeof v === "string") headers[k] = v;
132+
else if (Array.isArray(v)) headers[k] = v.join(", ");
133+
}
134+
resolve({
135+
status: res.statusCode || 200,
136+
statusText: res.statusMessage || "OK",
137+
headers,
138+
body: Buffer.concat(chunks).toString("utf-8"),
139+
url,
140+
});
141+
});
142+
res.on("error", reject);
143+
});
144+
145+
req.on("error", reject);
146+
if (options?.body) req.write(options.body);
147+
req.end();
148+
});
149+
},
150+
};
151+
}
152+
153+
describe("HTTPS TLS error parity between host and sandbox", () => {
154+
let certs: ReturnType<typeof generateTestCerts>;
155+
let expiredServer: https.Server;
156+
let expiredPort: number;
157+
let mismatchServer: https.Server;
158+
let mismatchPort: number;
159+
let selfSignedServer: https.Server;
160+
let selfSignedPort: number;
161+
const runtimes = new Set<NodeRuntime>();
162+
163+
beforeAll(async () => {
164+
certs = generateTestCerts();
165+
166+
// Ensure expired cert is past its notAfter (days=0 means notAfter=now)
167+
await new Promise((r) => setTimeout(r, 2000));
168+
169+
async function startServer(
170+
key: string,
171+
cert: string,
172+
): Promise<[https.Server, number]> {
173+
const srv = https.createServer({ key, cert }, (_req, res) => {
174+
res.writeHead(200).end("ok");
175+
});
176+
await new Promise<void>((r) =>
177+
srv.listen(0, "127.0.0.1", () => r()),
178+
);
179+
return [srv, (srv.address() as { port: number }).port];
180+
}
181+
182+
[expiredServer, expiredPort] = await startServer(
183+
certs.expiredKey,
184+
certs.expiredCert,
185+
);
186+
[mismatchServer, mismatchPort] = await startServer(
187+
certs.mismatchKey,
188+
certs.mismatchCert,
189+
);
190+
[selfSignedServer, selfSignedPort] = await startServer(
191+
certs.selfSignedKey,
192+
certs.selfSignedCert,
193+
);
194+
});
195+
196+
afterAll(async () => {
197+
const close = (s?: https.Server) =>
198+
s
199+
? new Promise<void>((r) => s.close(() => r()))
200+
: Promise.resolve();
201+
await Promise.all([
202+
close(expiredServer),
203+
close(mismatchServer),
204+
close(selfSignedServer),
205+
]);
206+
fs.rmSync(TMP, { recursive: true, force: true });
207+
});
208+
209+
afterEach(async () => {
210+
for (const rt of runtimes) {
211+
try {
212+
await rt.terminate();
213+
} catch {
214+
rt.dispose();
215+
}
216+
}
217+
runtimes.clear();
218+
});
219+
220+
// Direct host HTTPS request — capture error message
221+
async function hostTlsError(
222+
port: number,
223+
ca?: string,
224+
): Promise<string> {
225+
return new Promise((resolve) => {
226+
const req = https.request(
227+
{
228+
hostname: "127.0.0.1",
229+
port,
230+
path: "/",
231+
method: "GET",
232+
...(ca ? { ca } : {}),
233+
},
234+
() => resolve("NO_ERROR"),
235+
);
236+
req.on("error", (err) => resolve(err.message));
237+
req.end();
238+
});
239+
}
240+
241+
// Sandbox HTTPS request via bridge — capture error message from stdout
242+
async function sandboxTlsError(
243+
port: number,
244+
ca?: string,
245+
): Promise<string> {
246+
const events: StdioEvent[] = [];
247+
const adapter = createTestNetworkAdapter(ca ? { ca } : {});
248+
const runtime = new NodeRuntime({
249+
onStdio: (e) => events.push(e),
250+
systemDriver: createNodeDriver({
251+
networkAdapter: adapter,
252+
permissions: allowAllNetwork,
253+
}),
254+
runtimeDriverFactory: createNodeRuntimeDriverFactory(),
255+
});
256+
runtimes.add(runtime);
257+
258+
await runtime.exec(`
259+
(async () => {
260+
const https = require('https');
261+
try {
262+
await new Promise((resolve, reject) => {
263+
const req = https.request({
264+
hostname: '127.0.0.1',
265+
port: ${port},
266+
path: '/',
267+
method: 'GET',
268+
}, resolve);
269+
req.on('error', reject);
270+
req.end();
271+
});
272+
console.log('TLS_ERR:none');
273+
} catch (err) {
274+
console.log('TLS_ERR:' + err.message);
275+
}
276+
})();
277+
`);
278+
279+
const stdout = events
280+
.filter((e) => e.channel === "stdout")
281+
.map((e) => e.message)
282+
.join("");
283+
const m = stdout.match(/TLS_ERR:(.*)/);
284+
return m ? m[1] : "CAPTURE_FAILED";
285+
}
286+
287+
it("expired cert: sandbox error matches host error", async () => {
288+
const host = await hostTlsError(expiredPort, certs.caCert);
289+
expect(host).not.toBe("NO_ERROR");
290+
291+
const sandbox = await sandboxTlsError(expiredPort, certs.caCert);
292+
expect(sandbox).toBe(host);
293+
}, 30_000);
294+
295+
it("hostname mismatch: sandbox error matches host error", async () => {
296+
const host = await hostTlsError(mismatchPort, certs.caCert);
297+
expect(host).not.toBe("NO_ERROR");
298+
299+
const sandbox = await sandboxTlsError(mismatchPort, certs.caCert);
300+
expect(sandbox).toBe(host);
301+
}, 30_000);
302+
303+
it("self-signed cert with rejectUnauthorized:true: sandbox error matches host error", async () => {
304+
const host = await hostTlsError(selfSignedPort);
305+
expect(host).not.toBe("NO_ERROR");
306+
307+
const sandbox = await sandboxTlsError(selfSignedPort);
308+
expect(sandbox).toBe(host);
309+
}, 30_000);
310+
});

0 commit comments

Comments
 (0)