diff --git a/.changeset/ipv6-check-server-identity.md b/.changeset/ipv6-check-server-identity.md new file mode 100644 index 00000000000..6830946f335 --- /dev/null +++ b/.changeset/ipv6-check-server-identity.md @@ -0,0 +1,9 @@ +--- +'@whatwg-node/node-fetch': patch +--- + +Fix HTTPS verification for IPv6 address literals in the node-http ponyfill. + +Work around a Node.js regression (`tls.checkServerIdentity` + `domainToASCII`) that rejects valid `IP Address` SANs for hosts like `::1` on Node.js 22.23+ / 24.17+ (https://github.com/nodejs/node/issues/64032). + +The override is installed lazily on the first HTTPS request, and only when a one-time probe shows the running Node build is affected; healthy Node versions keep the built-in verifier. diff --git a/.changeset/libcurl-default-ca-store.md b/.changeset/libcurl-default-ca-store.md new file mode 100644 index 00000000000..4cb7349ba82 --- /dev/null +++ b/.changeset/libcurl-default-ca-store.md @@ -0,0 +1,11 @@ +--- +'@whatwg-node/node-fetch': patch +--- + +Align libcurl TLS trust with Node's default CA store. + +When `tls.getCACertificates` is available (Node.js 22.15+ / 23.10+), the libcurl fetch implementation now loads CAs from `tls.getCACertificates('default')` instead of only reading `NODE_EXTRA_CA_CERTS` / `tls.rootCertificates`. That means custom CAs installed with `tls.setDefaultCACertificates(...)`, plus CAs from `NODE_EXTRA_CA_CERTS` when it was set **before** process start, are honored the same way as Node's built-in `https` client. + +On older Node versions (engines still allow `>=18`), behavior is unchanged: `NODE_EXTRA_CA_CERTS` still maps to libcurl `CAINFO`, otherwise the bundled Mozilla roots are used. + +If you previously set `NODE_EXTRA_CA_CERTS` at runtime after the process started, prefer `tls.setDefaultCACertificates([...tls.getCACertificates('default'), ...yourCerts])` so both Node TLS and libcurl pick up the same store. That setter requires Node.js 22.19+ / 24.5+ (guard with `typeof tls.setDefaultCACertificates === 'function'` on older versions). diff --git a/.changeset/trim-content-encoding-tokens.md b/.changeset/trim-content-encoding-tokens.md new file mode 100644 index 00000000000..af87a24312a --- /dev/null +++ b/.changeset/trim-content-encoding-tokens.md @@ -0,0 +1,7 @@ +--- +'@whatwg-node/server': patch +--- + +Trim values when parsing `Accept-Encoding` / `Content-Encoding` header lists. + +Native HTTPS clients (e.g. undici) send values like `br, gzip, deflate`; without trimming, encodings after the first comma never matched and response compression was skipped. diff --git a/packages/node-fetch/src/checkServerIdentity.ts b/packages/node-fetch/src/checkServerIdentity.ts new file mode 100644 index 00000000000..4425ccff741 --- /dev/null +++ b/packages/node-fetch/src/checkServerIdentity.ts @@ -0,0 +1,92 @@ +import { isIP } from 'node:net'; +import tls, { type PeerCertificate } from 'node:tls'; + +/** + * Canonicalize an IP for equality checks (`::1` vs `0:0:0:0:0:0:0:1`). + */ +export function normalizeIpAddress(ip: string): string { + const bare = ip.replace(/^\[|\]$/g, ''); + const family = isIP(bare); + if (family === 4) { + return bare; + } + if (family === 6) { + // WHATWG URL hostname normalizes IPv6 to a canonical form. + return new URL(`http://[${bare}]`).hostname.replace(/^\[|\]$/g, '').toLowerCase(); + } + return bare.toLowerCase(); +} + +function collectCertIpAddresses(cert: PeerCertificate): string[] { + const alt = cert.subjectaltname; + if (!alt) { + return []; + } + const ips: string[] = []; + for (const part of alt.split(', ')) { + if (part.startsWith('IP Address:')) { + ips.push(part.slice('IP Address:'.length)); + } + } + return ips; +} + +let probedIpv6SanWorkaround = false; +let needsIpv6SanWorkaroundValue = false; + +/** + * Detect Node.js IPv6 IP-SAN regression in `tls.checkServerIdentity` + * (https://github.com/nodejs/node/issues/64032). Probes once, on first use. + */ +export function needsIpv6SanWorkaround(): boolean { + if (!probedIpv6SanWorkaround) { + probedIpv6SanWorkaround = true; + try { + needsIpv6SanWorkaroundValue = + tls.checkServerIdentity('::1', { + subject: {}, + subjectaltname: 'IP Address:::1', + } as PeerCertificate) != null; + } catch { + // If the probe itself throws, prefer the workaround. + needsIpv6SanWorkaroundValue = true; + } + } + return needsIpv6SanWorkaroundValue; +} + +/** + * Custom verifier that correctly matches IPv6 literals against `IP Address` SANs. + */ +export function checkServerIdentityIpv6San( + hostname: string, + cert: PeerCertificate, +): Error | undefined { + const bareHost = hostname.replace(/^\[|\]$/g, '').replace(/\.$/, ''); + + if (isIP(bareHost)) { + const certIps = collectCertIpAddresses(cert); + const want = normalizeIpAddress(bareHost); + if (certIps.some(ip => normalizeIpAddress(ip) === want)) { + return undefined; + } + const reason = `Hostname/IP does not match certificate's altnames: IP: ${bareHost} is not in the cert's list: ${certIps.join(', ')}`; + const error = new Error(reason) as NodeJS.ErrnoException & { + reason: string; + host: string; + cert: PeerCertificate; + }; + error.reason = reason; + error.host = bareHost; + error.cert = cert; + error.code = 'ERR_TLS_CERT_ALTNAME_INVALID'; + return error; + } + + return tls.checkServerIdentity(hostname, cert); +} + +/** Lazy: only set when the first https request probes an affected Node build. */ +export function getHttpsCheckServerIdentity(): typeof checkServerIdentityIpv6San | undefined { + return needsIpv6SanWorkaround() ? checkServerIdentityIpv6San : undefined; +} diff --git a/packages/node-fetch/src/fetchCurl.ts b/packages/node-fetch/src/fetchCurl.ts index 9a879f4efc4..218513f2e49 100644 --- a/packages/node-fetch/src/fetchCurl.ts +++ b/packages/node-fetch/src/fetchCurl.ts @@ -1,6 +1,6 @@ import { Buffer } from 'node:buffer'; import { PassThrough, Readable } from 'node:stream'; -import { rootCertificates } from 'node:tls'; +import tls, { rootCertificates } from 'node:tls'; import { createDeferredPromise } from '@whatwg-node/promise-helpers'; import { PonyfillRequest } from './Request.js'; import { PonyfillResponse } from './Response.js'; @@ -21,7 +21,13 @@ export function fetchCurl( curlHandle.setOpt('SSL_VERIFYPEER', false); } - if (process.env.NODE_EXTRA_CA_CERTS) { + // Prefer Node's current default CA store (includes setDefaultCACertificates and + // NODE_EXTRA_CA_CERTS loaded at process start). + // tls.getCACertificates() exists since Node.js 22.15 / 23.10. Until engines bump + // past that (currently >=18), keep the NODE_EXTRA_CA_CERTS / rootCertificates fallback. + if (typeof tls.getCACertificates === 'function') { + curlHandle.setOpt('CAINFO_BLOB', tls.getCACertificates('default').join('\n')); + } else if (process.env.NODE_EXTRA_CA_CERTS) { curlHandle.setOpt('CAINFO', process.env.NODE_EXTRA_CA_CERTS); } else { curlHandle.setOpt('CAINFO_BLOB', rootCertificates.join('\n')); diff --git a/packages/node-fetch/src/fetchNodeHttp.ts b/packages/node-fetch/src/fetchNodeHttp.ts index 1d79c0bf6f6..ba2f1af54f7 100644 --- a/packages/node-fetch/src/fetchNodeHttp.ts +++ b/packages/node-fetch/src/fetchNodeHttp.ts @@ -3,6 +3,7 @@ import { request as httpsRequest } from 'node:https'; import { PassThrough, Readable } from 'node:stream'; import zlib from 'node:zlib'; import { handleMaybePromise } from '@whatwg-node/promise-helpers'; +import { getHttpsCheckServerIdentity } from './checkServerIdentity.js'; import { PonyfillRequest } from './Request.js'; import { PonyfillResponse } from './Response.js'; import { PonyfillURL } from './URL.js'; @@ -25,6 +26,16 @@ function getRequestFnForProtocol(url: string) { throw new Error(`Unsupported protocol: ${url.split(':')[0] || url}`); } +function isHttpsRequest(url: string | URL | undefined): boolean { + if (!url) { + return false; + } + if (typeof url === 'string') { + return url.startsWith('https:'); + } + return url.protocol === 'https:'; +} + export function fetchNodeHttp( fetchRequest: PonyfillRequest, ): Promise> { @@ -52,21 +63,27 @@ export function fetchNodeHttp( let nodeRequest: ReturnType; + const requestTarget = fetchRequest.parsedUrl || fetchRequest.url; + const requestOptions: Parameters[1] = { + method: fetchRequest.method, + headers: nodeHeaders, + signal, + agent: fetchRequest.agent, + }; + // Probe once on first https use; override only if this Node build is affected + // (https://github.com/nodejs/node/issues/64032). + const httpsCheckServerIdentity = isHttpsRequest(requestTarget) + ? getHttpsCheckServerIdentity() + : undefined; + if (httpsCheckServerIdentity) { + requestOptions.checkServerIdentity = httpsCheckServerIdentity; + } + // If it is our ponyfilled Request, it should have `parsedUrl` which is a `URL` object if (fetchRequest.parsedUrl) { - nodeRequest = requestFn(fetchRequest.parsedUrl, { - method: fetchRequest.method, - headers: nodeHeaders, - signal, - agent: fetchRequest.agent, - }); + nodeRequest = requestFn(fetchRequest.parsedUrl, requestOptions); } else { - nodeRequest = requestFn(fetchRequest.url, { - method: fetchRequest.method, - headers: nodeHeaders, - signal, - agent: fetchRequest.agent, - }); + nodeRequest = requestFn(fetchRequest.url, requestOptions); } nodeRequest.once('error', reject); diff --git a/packages/node-fetch/tests/checkServerIdentity.spec.ts b/packages/node-fetch/tests/checkServerIdentity.spec.ts new file mode 100644 index 00000000000..2e43105edd0 --- /dev/null +++ b/packages/node-fetch/tests/checkServerIdentity.spec.ts @@ -0,0 +1,52 @@ +import type { PeerCertificate } from 'node:tls'; +import { describe, expect, it } from '@jest/globals'; +import { + checkServerIdentityIpv6San, + getHttpsCheckServerIdentity, + needsIpv6SanWorkaround, + normalizeIpAddress, +} from '../src/checkServerIdentity'; + +describe('checkServerIdentity', () => { + it('normalizes compressed and expanded IPv6 forms', () => { + expect(normalizeIpAddress('::1')).toBe(normalizeIpAddress('0:0:0:0:0:0:0:1')); + expect(normalizeIpAddress('[::1]')).toBe(normalizeIpAddress('::1')); + }); + + it('exposes the https override only when the Node probe detects the bug', () => { + if (needsIpv6SanWorkaround()) { + expect(getHttpsCheckServerIdentity()).toBe(checkServerIdentityIpv6San); + } else { + expect(getHttpsCheckServerIdentity()).toBeUndefined(); + } + }); + + it('accepts IPv6 host when cert lists expanded IP SAN', () => { + const cert = { + subject: {}, + subjectaltname: 'DNS:localhost, IP Address:127.0.0.1, IP Address:0:0:0:0:0:0:0:1', + } as PeerCertificate; + expect(checkServerIdentityIpv6San('::1', cert)).toBeUndefined(); + expect(checkServerIdentityIpv6San('[::1]', cert)).toBeUndefined(); + expect(checkServerIdentityIpv6San('::1.', cert)).toBeUndefined(); + }); + + it('rejects IPv6 host that is not in the cert SAN list', () => { + const cert = { + subject: {}, + subjectaltname: 'IP Address:127.0.0.1', + } as PeerCertificate; + const error = checkServerIdentityIpv6San('::1', cert); + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('::1'); + }); + + it('still validates DNS names via tls.checkServerIdentity', () => { + const cert = { + subject: { CN: 'example.com' }, + subjectaltname: 'DNS:example.com', + } as PeerCertificate; + expect(checkServerIdentityIpv6San('example.com', cert)).toBeUndefined(); + expect(checkServerIdentityIpv6San('evil.example', cert)?.message).toMatch(/evil\.example/); + }); +}); diff --git a/packages/node-fetch/tests/cleanup-resources.spec.ts b/packages/node-fetch/tests/cleanup-resources.spec.ts index f3146668f00..7ad435e0489 100644 --- a/packages/node-fetch/tests/cleanup-resources.spec.ts +++ b/packages/node-fetch/tests/cleanup-resources.spec.ts @@ -4,6 +4,37 @@ import { runTestsForEachServerImpl } from '../../server/test/test-server'; const describeIf = (condition: boolean) => (condition ? describe : describe.skip); +function isExternalConnectivityError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + if ( + error.name === 'AbortError' || + error.name === 'TimeoutError' || + error.name === 'FetchError' || + error.name === 'ConnectTimeoutError' || + error.name === 'HeadersTimeoutError' || + error.name === 'BodyTimeoutError' + ) { + return true; + } + const code = (error as NodeJS.ErrnoException).code; + return ( + code === 'ENOTFOUND' || + code === 'EAI_AGAIN' || + code === 'ECONNREFUSED' || + code === 'ECONNRESET' || + code === 'ECONNABORTED' || + code === 'ETIMEDOUT' || + code === 'EHOSTUNREACH' || + code === 'ENETUNREACH' || + code === 'UND_ERR_CONNECT_TIMEOUT' || + code === 'UND_ERR_HEADERS_TIMEOUT' || + code === 'UND_ERR_BODY_TIMEOUT' || + code === 'UND_ERR_SOCKET' + ); +} + describeIf(!globalThis.Deno)('Cleanup Resources', () => { runTestsForEachFetchImpl((_, { createServerAdapter, fetchAPI: { Response, fetch } }) => { describe('internal calls', () => { @@ -28,9 +59,20 @@ describeIf(!globalThis.Deno)('Cleanup Resources', () => { } }); it('https - should free resources when body is not consumed', async () => { - const response = await fetch('https://httpbin.org/get'); - if (response.status !== 503) { - expect(response.ok).toBe(true); + try { + const response = await fetch('https://httpbin.org/get', { + signal: AbortSignal.timeout(3000), + }); + if (response.status !== 503) { + expect(response.ok).toBe(true); + } + } catch (error) { + // Soft-skip timeouts and common connectivity failures (DNS / refuse / reset / undici). + if (isExternalConnectivityError(error)) { + console.warn('External HTTPS unavailable, skipping test:', error); + } else { + throw error; + } } }); }); diff --git a/packages/node-fetch/tests/http2.spec.ts b/packages/node-fetch/tests/http2.spec.ts index 6b15da3a1d4..1c81b71a07f 100644 --- a/packages/node-fetch/tests/http2.spec.ts +++ b/packages/node-fetch/tests/http2.spec.ts @@ -1,43 +1,30 @@ -import { unlink, writeFile } from 'node:fs/promises'; import { createSecureServer, ServerHttp2Session, type Http2SecureServer } from 'node:http2'; import { AddressInfo } from 'node:net'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import type { CertificateCreationResult } from 'pem'; +import tls from 'node:tls'; import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; +import { createEphemeralTlsCerts } from '../../server/test/test-tls-certs'; import { fetchPonyfill } from '../src/fetch'; const describeIf = (condition: boolean) => (condition ? describe : describe.skip); -describeIf(globalThis.libcurl && !process.env.LEAK_TEST && !globalThis.Deno)('http2', () => { +describeIf( + globalThis.libcurl && + !process.env.LEAK_TEST && + !globalThis.Deno && + typeof tls.setDefaultCACertificates === 'function', +)('http2', () => { let server: Http2SecureServer; - let pemPath: string; - const oldEnvVar = process.env.NODE_EXTRA_CA_CERTS; + let previousDefaultCaCerts: string[]; const sessions = new Set(); beforeAll(async () => { - const { createCertificate } = await import('pem'); - const keys = await new Promise((resolve, reject) => { - createCertificate( - { - selfSigned: true, - days: 1, - }, - (err, result) => { - if (err) { - reject(err); - } - resolve(result); - }, - ); - }); - pemPath = join(tmpdir(), 'test.pem'); - process.env.NODE_EXTRA_CA_CERTS = pemPath; - await writeFile(pemPath, keys.certificate); + const { caCert, serviceKey, certificate } = await createEphemeralTlsCerts(); + previousDefaultCaCerts = tls.getCACertificates('default'); + tls.setDefaultCACertificates([...previousDefaultCaCerts, caCert]); // Create a secure HTTP/2 server server = createSecureServer( { allowHTTP1: false, - key: keys.serviceKey, - cert: keys.certificate, + key: serviceKey, + cert: certificate, }, (request, response) => { response.writeHead(200, { @@ -57,8 +44,7 @@ describeIf(globalThis.libcurl && !process.env.LEAK_TEST && !globalThis.Deno)('ht await new Promise(resolve => server.listen(0, resolve)); }); afterAll(async () => { - await unlink(pemPath); - process.env.NODE_EXTRA_CA_CERTS = oldEnvVar; + tls.setDefaultCACertificates(previousDefaultCaCerts); for (const session of sessions) { session.destroy(); } diff --git a/packages/server/src/plugins/useContentEncoding.ts b/packages/server/src/plugins/useContentEncoding.ts index 873d03a4f15..d9ef247042b 100644 --- a/packages/server/src/plugins/useContentEncoding.ts +++ b/packages/server/src/plugins/useContentEncoding.ts @@ -15,6 +15,7 @@ export function useContentEncoding(): ServerAdapterPlugin encoding.trim()) .filter(encoding => !emptyEncodings.includes(encoding)) as CompressionFormat[]; if (contentEncodings.length) { if ( @@ -57,7 +58,9 @@ export function useContentEncoding(): ServerAdapterPlugin encoding.trim()) as CompressionFormat[]; if (encodings.length && response.body) { const supportedEncoding = encodings.find(encoding => getSupportedEncodings(fetchAPI).includes(encoding), diff --git a/packages/server/src/utils.ts b/packages/server/src/utils.ts index 5f1adf90b36..3cf583275cc 100644 --- a/packages/server/src/utils.ts +++ b/packages/server/src/utils.ts @@ -591,7 +591,7 @@ export function handleResponseDecompression(response: Response, fetchAPI: FetchA let decompressedResponse = decompressedResponseMap.get(response); if (!decompressedResponse || decompressedResponse.bodyUsed) { let decompressedBody = response.body; - const contentEncodings = contentEncodingHeader.split(','); + const contentEncodings = contentEncodingHeader.split(',').map(encoding => encoding.trim()); if ( !contentEncodings.every(encoding => getSupportedEncodings(fetchAPI).includes(encoding as CompressionFormat), diff --git a/packages/server/test/formdata.spec.ts b/packages/server/test/formdata.spec.ts index 04ba1ec363a..b5d22b21e1c 100644 --- a/packages/server/test/formdata.spec.ts +++ b/packages/server/test/formdata.spec.ts @@ -1,5 +1,6 @@ import { Buffer } from 'node:buffer'; import http from 'node:http'; +import https from 'node:https'; import { setTimeout } from 'node:timers/promises'; import NodeFormData from 'form-data'; import { describe, expect, it } from '@jest/globals'; @@ -9,6 +10,15 @@ import { runTestsForEachServerImpl } from './test-server.js'; const skipIf = (condition: boolean) => (condition ? it.skip : it); +function requestForUrl(url: URL, options: http.RequestOptions) { + const request = url.protocol === 'https:' ? https.request : http.request; + return request({ + ...options, + hostname: url.hostname, + port: url.port, + }); +} + describe('FormData', () => { runTestsForEachServerImpl(testServer => { runTestsForEachFetchImpl( @@ -83,10 +93,8 @@ describe('FormData', () => { const url = new URL(testServer.url); - const req = http.request({ + const req = requestForUrl(url, { method: 'post', - hostname: url.hostname, - port: url.port, headers: { ...formData.getHeaders(), 'content-length': 10, @@ -136,10 +144,8 @@ describe('FormData', () => { const url = new URL(testServer.url); - const req = http.request({ + const req = requestForUrl(url, { method: 'post', - hostname: url.hostname, - port: url.port, headers: { ...formData.getHeaders(), 'content-length': 1000, diff --git a/packages/server/test/node.spec.ts b/packages/server/test/node.spec.ts index f9645de28e0..fa2a3c1536a 100644 --- a/packages/server/test/node.spec.ts +++ b/packages/server/test/node.spec.ts @@ -1,5 +1,11 @@ import { Buffer } from 'node:buffer'; -import { request as httpRequest, IncomingMessage, ServerResponse, STATUS_CODES } from 'node:http'; +import http, { + request as httpRequest, + IncomingMessage, + ServerResponse, + STATUS_CODES, +} from 'node:http'; +import { request as httpsRequest } from 'node:https'; import { setTimeout } from 'node:timers/promises'; import React from 'react'; import { renderToReadableStream } from 'react-dom/server.edge'; @@ -13,6 +19,15 @@ import { runTestsForEachServerImpl } from './test-server.js'; const NODE_MAJOR_VERSION = Number.parseInt(process.versions.node.split('.')[0], 10); +function requestForUrl( + url: URL, + options: http.RequestOptions, + cb?: (res: IncomingMessage) => void, +) { + const request = url.protocol === 'https:' ? httpsRequest : httpRequest; + return request(options, cb); +} + describe('Node Specific Cases', () => { runTestsForEachFetchImpl( ( @@ -412,20 +427,25 @@ describe('Node Specific Cases', () => { expect(disposedThen).toHaveBeenCalled(); }); - skipIf(globalThis.Deno && serverImplName !== 'Deno')( - 'handles ipv6 addresses correctly', - async () => { - await using serverAdapter = createServerAdapter(() => { - return new Response('Hello world!', { status: 200 }); - }); - await testServer.addOnceHandler(serverAdapter); - const port = new URL(testServer.url).port; - const ipv6Url = new URL(`http://[::1]:${port}/`); - const response = await fetch(ipv6Url); - expect(response.status).toBe(200); - await expect(response.text()).resolves.toBe('Hello world!'); - }, - ); + // Native fetch/undici still hits Node's broken IPv6 IP-SAN check + // (https://github.com/nodejs/node/issues/64032). node-http ponyfill uses + // our checkServerIdentity workaround. + skipIf( + (globalThis.Deno && serverImplName !== 'Deno') || + (serverImplName === 'node:https' && fetchImplName === 'native'), + )('handles ipv6 addresses correctly', async () => { + await using serverAdapter = createServerAdapter(() => { + return new Response('Hello world!', { status: 200 }); + }); + await testServer.addOnceHandler(serverAdapter); + const serverUrl = new URL(testServer.url); + const ipv6Url = new URL( + `${serverUrl.protocol}//[::1]:${serverUrl.port}${serverUrl.pathname}`, + ); + const response = await fetch(ipv6Url); + expect(response.status).toBe(200); + await expect(response.text()).resolves.toBe('Hello world!'); + }); describe('handles status codes correctly', () => { for (const statusCodeStr in STATUS_CODES) { @@ -596,7 +616,8 @@ describe('Node Specific Cases', () => { const url = new URL(testServer.url); const rawHeaders = await new Promise((resolve, reject) => { - const req = httpRequest( + const req = requestForUrl( + url, { hostname: url.hostname, port: Number(url.port), diff --git a/packages/server/test/test-server.ts b/packages/server/test/test-server.ts index 27a1bac1a67..d6b185ffcfa 100644 --- a/packages/server/test/test-server.ts +++ b/packages/server/test/test-server.ts @@ -1,6 +1,8 @@ import { createServer, globalAgent, Server, ServerResponse } from 'node:http'; +import { createServer as createHttpsServer } from 'node:https'; import { AddressInfo, Socket } from 'node:net'; import { Readable } from 'node:stream'; +import tls from 'node:tls'; import express from 'express'; import fastify, { FastifyReply, FastifyRequest } from 'fastify'; import Koa, { Context } from 'koa'; @@ -8,6 +10,7 @@ import Hapi from '@hapi/hapi'; import { afterAll, beforeAll, describe } from '@jest/globals'; import { DisposableSymbols, patchSymbols } from '@whatwg-node/disposablestack'; import { ServerAdapter, ServerAdapterBaseObject } from '@whatwg-node/server'; +import { createEphemeralTlsCerts } from './test-tls-certs'; export interface TestServer extends AsyncDisposable { name: string; @@ -126,6 +129,73 @@ if ((globalThis as any)['createUWS']) { serverImplMap['node:http'] = createNodeHttpTestServer; +// Keep `node:https` in the shared server matrix on Node (and Bun when the API exists). +// Skip Deno: its `node:https` is incomplete for this suite matrix. +if (!globalThis.Deno && typeof tls.setDefaultCACertificates === 'function') { + serverImplMap['node:https'] = async function createNodeHttpsTestServer() { + let handler: any; + const { caCert, serviceKey, certificate } = await createEphemeralTlsCerts(); + + // Trust the ephemeral CA for Node TLS / libcurl (via getCACertificates('default')). + const previousDefaultCaCerts = tls.getCACertificates('default'); + tls.setDefaultCACertificates([...previousDefaultCaCerts, caCert]); + + const server = createHttpsServer( + { + key: serviceKey, + cert: certificate, + }, + function handlerWrapper(req, res) { + return handler(req, res); + }, + ); + const connections = new Set(); + server.on('connection', socket => { + connections.add(socket); + socket.once('close', () => { + connections.delete(socket); + }); + }); + return new Promise(resolve => { + server.listen(0, () => { + const addressInfo = server.address() as AddressInfo; + const url = `https://localhost:${addressInfo.port}/`; + resolve({ + name: 'Node.js https', + url, + async addOnceHandler(newHandler, ...ctxParts) { + await handler?.[DisposableSymbols.asyncDispose]?.(); + handler = newHandler; + if (ctxParts.length) { + handler = function (...args: any[]) { + return newHandler(...args, ...ctxParts); + }; + } + }, + async [DisposableSymbols.asyncDispose]() { + tls.setDefaultCACertificates(previousDefaultCaCerts); + connections.forEach(socket => { + socket.destroy(); + }); + if (!globalThis.Bun) { + server.closeAllConnections(); + } + await new Promise((resolve, reject) => { + server.close(err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + }, + }); + }); + }); + }; +} + serverImplMap['express'] = async function createExpressTestServer() { let handler: any; const app = express().use((...args) => handler(...args)); diff --git a/packages/server/test/test-tls-certs.ts b/packages/server/test/test-tls-certs.ts new file mode 100644 index 00000000000..f8d7cb35bd9 --- /dev/null +++ b/packages/server/test/test-tls-certs.ts @@ -0,0 +1,101 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +export interface EphemeralTlsCerts { + /** Trust anchor to install in the client CA store. */ + caCert: string; + /** Server private key (PEM). */ + serviceKey: string; + /** Server leaf certificate signed by `caCert` (PEM). */ + certificate: string; +} + +/** + * Create an ephemeral test CA + localhost leaf via OpenSSL. + * Avoids the `pem` package and produces a proper CA:FALSE leaf with SAN. + */ +export async function createEphemeralTlsCerts( + commonName = 'localhost', +): Promise { + const dir = await mkdtemp(join(tmpdir(), 'whatwg-node-tls-')); + const caKeyPath = join(dir, 'ca-key.pem'); + const caCertPath = join(dir, 'ca-cert.pem'); + const leafKeyPath = join(dir, 'leaf-key.pem'); + const leafCsrPath = join(dir, 'leaf.csr'); + const leafCertPath = join(dir, 'leaf-cert.pem'); + const extPath = join(dir, 'leaf-ext.cnf'); + + try { + await writeFile( + extPath, + [ + 'basicConstraints=critical,CA:FALSE', + 'keyUsage=critical,digitalSignature,keyEncipherment', + 'extendedKeyUsage=serverAuth', + `subjectAltName=DNS:${commonName},DNS:localhost,IP:127.0.0.1,IP:::1`, + ].join('\n'), + ); + + await execFileAsync('openssl', [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-keyout', + caKeyPath, + '-out', + caCertPath, + '-days', + '1', + '-subj', + '/CN=whatwg-node-test-ca', + ]); + + await execFileAsync('openssl', [ + 'req', + '-newkey', + 'rsa:2048', + '-nodes', + '-keyout', + leafKeyPath, + '-out', + leafCsrPath, + '-subj', + `/CN=${commonName}`, + ]); + + await execFileAsync('openssl', [ + 'x509', + '-req', + '-in', + leafCsrPath, + '-CA', + caCertPath, + '-CAkey', + caKeyPath, + '-CAcreateserial', + '-out', + leafCertPath, + '-days', + '1', + '-extfile', + extPath, + ]); + + const [caCert, serviceKey, certificate] = await Promise.all([ + readFile(caCertPath, 'utf8'), + readFile(leafKeyPath, 'utf8'), + readFile(leafCertPath, 'utf8'), + ]); + + return { caCert, serviceKey, certificate }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +}