198 lines
5.9 KiB
TypeScript
198 lines
5.9 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|||
|
|
import { readFileSync } from "fs";
|
||
|
|
import { bunEnv, bunExe } from "harness";
|
||
|
|
import { join } from "path";
|
||
|
|
|
||
|
|
// Self-signed cert with ONLY DNS:localhost in SANs (no IP SANs).
|
||
|
|
// Valid from 2025-01-01 to 2035-01-01 to avoid CI clock skew issues.
|
||
|
|
// This is critical: if the cert also had IP:127.0.0.1, the custom lookup
|
||
|
|
// tests would pass even without the SNI fix, since BoringSSL would match
|
||
|
|
// on the IP SAN directly. By excluding IP SANs, we ensure the test only
|
||
|
|
// passes when the original hostname ("localhost") is correctly preserved
|
||
|
|
// for TLS SNI and certificate SAN matching.
|
||
|
|
const localhostOnlyTls = {
|
||
|
|
cert: readFileSync(join(import.meta.dir, "27890-localhost-only.crt"), "utf8"),
|
||
|
|
key: readFileSync(join(import.meta.dir, "27890-localhost-only.key"), "utf8"),
|
||
|
|
};
|
||
|
|
|
||
|
|
// Uses a local HTTPS server with self-signed certs to avoid CI environments
|
||
|
|
// lacking system CA certificates (Windows, Alpine).
|
||
|
|
describe("custom lookup with HTTPS", () => {
|
||
|
|
test("https.request with custom lookup should not break TLS", async () => {
|
||
|
|
using server = Bun.serve({
|
||
|
|
tls: localhostOnlyTls,
|
||
|
|
port: 0,
|
||
|
|
fetch() {
|
||
|
|
return new Response("OK");
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
await using proc = Bun.spawn({
|
||
|
|
cmd: [
|
||
|
|
bunExe(),
|
||
|
|
"-e",
|
||
|
|
`
|
||
|
|
const https = require("https");
|
||
|
|
const port = ${server.port};
|
||
|
|
const ca = ${JSON.stringify(localhostOnlyTls.cert)};
|
||
|
|
|
||
|
|
function customLookup(hostname, options, callback) {
|
||
|
|
// Resolve "localhost" to 127.0.0.1 — simulates the real-world scenario
|
||
|
|
// where a custom lookup returns an IP address for a hostname.
|
||
|
|
if (options && options.all) {
|
||
|
|
callback(null, [{ address: "127.0.0.1", family: 4 }]);
|
||
|
|
} else {
|
||
|
|
callback(null, "127.0.0.1", 4);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const req = https.request("https://localhost:" + port, {
|
||
|
|
lookup: customLookup,
|
||
|
|
ca,
|
||
|
|
}, (res) => {
|
||
|
|
let data = "";
|
||
|
|
res.on("data", (chunk) => data += chunk);
|
||
|
|
res.on("end", () => {
|
||
|
|
console.log("status:" + res.statusCode + " body:" + data);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
req.on("error", (e) => {
|
||
|
|
console.error("error:" + e.message + " " + (e.code || ""));
|
||
|
|
process.exitCode = 1;
|
||
|
|
});
|
||
|
|
req.end();
|
||
|
|
`,
|
||
|
|
],
|
||
|
|
env: bunEnv,
|
||
|
|
stderr: "pipe",
|
||
|
|
stdout: "pipe",
|
||
|
|
});
|
||
|
|
|
||
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||
|
|
|
||
|
|
expect(stderr).not.toContain("certificate");
|
||
|
|
expect(stderr).not.toContain("CERT_ALTNAME");
|
||
|
|
expect(stdout).toContain("status:200");
|
||
|
|
expect(stdout).toContain("body:OK");
|
||
|
|
expect(exitCode).toBe(0);
|
||
|
|
}, 30_000);
|
||
|
|
|
||
|
|
test("https.request without custom lookup should still work", async () => {
|
||
|
|
using server = Bun.serve({
|
||
|
|
tls: localhostOnlyTls,
|
||
|
|
port: 0,
|
||
|
|
fetch() {
|
||
|
|
return new Response("OK");
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
await using proc = Bun.spawn({
|
||
|
|
cmd: [
|
||
|
|
bunExe(),
|
||
|
|
"-e",
|
||
|
|
`
|
||
|
|
const https = require("https");
|
||
|
|
const port = ${server.port};
|
||
|
|
const ca = ${JSON.stringify(localhostOnlyTls.cert)};
|
||
|
|
|
||
|
|
const req = https.request("https://localhost:" + port, { ca }, (res) => {
|
||
|
|
let data = "";
|
||
|
|
res.on("data", (chunk) => data += chunk);
|
||
|
|
res.on("end", () => {
|
||
|
|
console.log("status:" + res.statusCode + " body:" + data);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
req.on("error", (e) => {
|
||
|
|
console.error("error:" + e.message + " " + (e.code || ""));
|
||
|
|
process.exitCode = 1;
|
||
|
|
});
|
||
|
|
req.end();
|
||
|
|
`,
|
||
|
|
],
|
||
|
|
env: bunEnv,
|
||
|
|
stderr: "pipe",
|
||
|
|
stdout: "pipe",
|
||
|
|
});
|
||
|
|
|
||
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||
|
|
|
||
|
|
expect(stderr).not.toContain("certificate");
|
||
|
|
expect(stdout).toContain("status:200");
|
||
|
|
expect(stdout).toContain("body:OK");
|
||
|
|
expect(exitCode).toBe(0);
|
||
|
|
}, 30_000);
|
||
|
|
|
||
|
|
test("custom lookup via dns.lookup should preserve hostname for TLS SNI", async () => {
|
||
|
|
// This test verifies the specific scenario from issue #27890:
|
||
|
|
// A custom lookup that resolves hostname to IP should not break TLS.
|
||
|
|
// The lookup uses dns.lookup (which checks /etc/hosts) to resolve
|
||
|
|
// "localhost" to an IP, but the original hostname must be preserved
|
||
|
|
// for SNI and certificate SAN matching.
|
||
|
|
// The cert only has DNS:localhost (no IP SANs), so if SNI is broken
|
||
|
|
// and the IP is used for cert verification, it WILL fail.
|
||
|
|
using server = Bun.serve({
|
||
|
|
tls: localhostOnlyTls,
|
||
|
|
port: 0,
|
||
|
|
fetch() {
|
||
|
|
return new Response("OK");
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
await using proc = Bun.spawn({
|
||
|
|
cmd: [
|
||
|
|
bunExe(),
|
||
|
|
"-e",
|
||
|
|
`
|
||
|
|
const https = require("https");
|
||
|
|
const dns = require("dns");
|
||
|
|
const port = ${server.port};
|
||
|
|
const ca = ${JSON.stringify(localhostOnlyTls.cert)};
|
||
|
|
|
||
|
|
// Custom lookup using dns.lookup — exercises the real resolution path
|
||
|
|
// (including /etc/hosts) where hostname is resolved to IP, reproducing
|
||
|
|
// the exact issue #27890 scenario. Forces IPv4 to avoid inconsistent
|
||
|
|
// results on dual-stack hosts.
|
||
|
|
function customLookup(hostname, options, callback) {
|
||
|
|
dns.lookup(hostname, { all: true, family: 4 }, (err, addresses) => {
|
||
|
|
if (err) { callback(err); return; }
|
||
|
|
if (options && options.all) {
|
||
|
|
callback(null, addresses);
|
||
|
|
} else {
|
||
|
|
const first = addresses[0];
|
||
|
|
callback(null, first.address, first.family);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const req = https.request("https://localhost:" + port, {
|
||
|
|
lookup: customLookup,
|
||
|
|
ca,
|
||
|
|
}, (res) => {
|
||
|
|
let data = "";
|
||
|
|
res.on("data", (chunk) => data += chunk);
|
||
|
|
res.on("end", () => {
|
||
|
|
console.log("status:" + res.statusCode + " body:" + data);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
req.on("error", (e) => {
|
||
|
|
console.error("error:" + e.message + " " + (e.code || ""));
|
||
|
|
process.exitCode = 1;
|
||
|
|
});
|
||
|
|
req.end();
|
||
|
|
`,
|
||
|
|
],
|
||
|
|
env: bunEnv,
|
||
|
|
stderr: "pipe",
|
||
|
|
stdout: "pipe",
|
||
|
|
});
|
||
|
|
|
||
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||
|
|
|
||
|
|
expect(stderr).not.toContain("certificate");
|
||
|
|
expect(stderr).not.toContain("CERT_ALTNAME");
|
||
|
|
expect(stdout).toContain("status:200");
|
||
|
|
expect(stdout).toContain("body:OK");
|
||
|
|
expect(exitCode).toBe(0);
|
||
|
|
}, 30_000);
|
||
|
|
});
|