Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ec52ce8
fix(test): use local HTTPS server for cleanup resource test
ardatan Aug 12, 2026
00aac2d
fix(test): add node:https TestServer and harden curl CA loading
ardatan Aug 12, 2026
0787f74
fix(test): gate node:https on CA APIs and preserve URL protocol for ipv6
ardatan Aug 12, 2026
c21643f
revert: keep fetchCurl CA loading unchanged
ardatan Aug 12, 2026
9217ac6
refactor: trust CAs via tls.getCACertificates in fetchCurl
ardatan Aug 12, 2026
f962c78
fix: preserve NODE_EXTRA_CA_CERTS when getCACertificates is unavailable
ardatan Aug 12, 2026
58c1317
docs: note when getCACertificates fallback can be removed
ardatan Aug 12, 2026
64898ed
changeset: document libcurl default CA store alignment
ardatan Aug 12, 2026
7db01c2
docs(test): note Bun may expose setDefaultCACertificates
ardatan Aug 12, 2026
7577dfc
fix(test): support node:https on Deno via openssl CA and createHttpCl…
ardatan Aug 12, 2026
31aa3a0
fix(test): skip node:https on Deno and fix raw http clients for TLS
ardatan Aug 12, 2026
d9b2cc4
fix(test): type requestForUrl with http.RequestOptions
ardatan Aug 12, 2026
986456b
fix(test): include ::1 in ephemeral TLS cert SAN for ipv6 tests
ardatan Aug 12, 2026
40265e2
fix(test): soft-skip external HTTPS connectivity errors and clarify C…
ardatan Aug 12, 2026
9ba05b7
fix(test): skip ipv6 checks on node:https due to Node TLS SAN regression
ardatan Aug 12, 2026
350d848
fix(node-fetch): verify IPv6 IP SANs in node-http HTTPS ponyfill
ardatan Aug 12, 2026
ed3d981
perf(node-fetch): apply IPv6 SAN workaround only when Node probe fails
ardatan Aug 12, 2026
4ae4e57
perf(node-fetch): probe IPv6 SAN workaround lazily on first HTTPS use
ardatan Aug 12, 2026
8116a51
fix(server): trim Accept-Encoding / Content-Encoding tokens
ardatan Aug 12, 2026
0e0f9a9
fix(test): soft-skip flaky github.com http→https redirect
ardatan Aug 12, 2026
acd7830
fix(test): replace github.com redirect with local http→https servers
ardatan Aug 12, 2026
1c03a6e
revert(test): restore intentional github.com http→https redirect check
ardatan Aug 12, 2026
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
11 changes: 11 additions & 0 deletions .changeset/libcurl-default-ca-store.md
Original file line number Diff line number Diff line change
@@ -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])` on supported Node versions so both Node TLS and libcurl pick up the same store.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
10 changes: 8 additions & 2 deletions packages/node-fetch/src/fetchCurl.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -21,7 +21,13 @@ export function fetchCurl<TResponseJSON = any, TRequestJSON = any>(
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'));
Expand Down
20 changes: 17 additions & 3 deletions packages/node-fetch/tests/cleanup-resources.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,23 @@ 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) {
// AbortSignal.timeout() rejects with TimeoutError (name); some runtimes use AbortError.
if (
error instanceof Error &&
(error.name === 'AbortError' || error.name === 'TimeoutError')
) {
console.warn('Request timed out, skipping test');
} else {
throw error;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
});
});
Expand Down
44 changes: 15 additions & 29 deletions packages/node-fetch/tests/http2.spec.ts
Original file line number Diff line number Diff line change
@@ -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<ServerHttp2Session>();
beforeAll(async () => {
const { createCertificate } = await import('pem');
const keys = await new Promise<CertificateCreationResult>((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, {
Expand All @@ -57,8 +44,7 @@ describeIf(globalThis.libcurl && !process.env.LEAK_TEST && !globalThis.Deno)('ht
await new Promise<void>(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();
}
Expand Down
18 changes: 12 additions & 6 deletions packages/server/test/formdata.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 15 additions & 3 deletions packages/server/test/node.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Buffer } from 'node:buffer';
import { 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';
Expand All @@ -13,6 +14,15 @@

const NODE_MAJOR_VERSION = Number.parseInt(process.versions.node.split('.')[0], 10);

function requestForUrl(
url: URL,
options: Parameters<typeof httpRequest>[0],
cb?: Parameters<typeof httpRequest>[1],
) {
const request = url.protocol === 'https:' ? httpsRequest : httpRequest;
return request(options, cb);

Check failure on line 23 in packages/server/test/node.spec.ts

View workflow job for this annotation

GitHub Actions / type check

No overload matches this call.
}

describe('Node Specific Cases', () => {
runTestsForEachFetchImpl(
(
Expand Down Expand Up @@ -419,8 +429,9 @@
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 serverUrl = new URL(testServer.url);
const ipv6Url = new URL(serverUrl.href);
ipv6Url.hostname = '[::1]';
const response = await fetch(ipv6Url);
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe('Hello world!');
Expand Down Expand Up @@ -596,14 +607,15 @@

const url = new URL(testServer.url);
const rawHeaders = await new Promise<string[]>((resolve, reject) => {
const req = httpRequest(
const req = requestForUrl(
url,
{
hostname: url.hostname,
port: Number(url.port),

Check failure on line 614 in packages/server/test/node.spec.ts

View workflow job for this annotation

GitHub Actions / type check

Type 'number' is not assignable to type 'string'.
path: url.pathname || '/',
method: 'GET',
},
res => {

Check failure on line 618 in packages/server/test/node.spec.ts

View workflow job for this annotation

GitHub Actions / type check

Parameter 'res' implicitly has an 'any' type.
res.resume();
resolve(res.rawHeaders);
},
Expand Down
70 changes: 70 additions & 0 deletions packages/server/test/test-server.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
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';
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;
Expand Down Expand Up @@ -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<Socket>();
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<void>((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));
Expand Down
Loading
Loading