Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
d62b774873 fix(node-fetch, undici): extract TLS options from agent/dispatcher
Previously, Bun's node-fetch implementation ignored the `agent` option,
making it impossible to use custom TLS configurations (like
`rejectUnauthorized: false` for self-signed certificates) via
`https.Agent`. Similarly, undici's fetch ignored the `dispatcher` option.

This fix extracts TLS options (rejectUnauthorized, ca, cert, key,
passphrase) from the agent/dispatcher and passes them to the native
fetch's `tls` option.

Fixes #10642

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 07:11:31 +00:00
3 changed files with 315 additions and 2 deletions

View File

@@ -8,6 +8,47 @@ const FormData: typeof globalThis.FormData = bindings[4];
const File: typeof globalThis.File = bindings[5];
const nativeFetch = Bun.fetch;
/**
* Extracts TLS options from an agent object (https.Agent or similar).
* @param {Object} agent The agent object to extract options from
* @returns {Object|null} TLS options object or null if none found
*/
function extractTlsFromAgent(agent: any): object | null {
const connectOpts = agent?.connectOpts || agent?.options;
if (connectOpts && typeof connectOpts === "object") {
const tlsOptions: Record<string, any> = {};
let hasTlsOptions = false;
if (connectOpts.rejectUnauthorized !== undefined) {
tlsOptions.rejectUnauthorized = connectOpts.rejectUnauthorized;
hasTlsOptions = true;
}
if (connectOpts.ca) {
tlsOptions.ca = connectOpts.ca;
hasTlsOptions = true;
}
if (connectOpts.cert) {
tlsOptions.cert = connectOpts.cert;
hasTlsOptions = true;
}
if (connectOpts.key) {
tlsOptions.key = connectOpts.key;
hasTlsOptions = true;
}
if (connectOpts.passphrase) {
tlsOptions.passphrase = connectOpts.passphrase;
hasTlsOptions = true;
}
if (hasTlsOptions) {
return tlsOptions;
}
}
return null;
}
// node-fetch extends from URLSearchParams in their implementation...
// https://github.com/node-fetch/node-fetch/blob/8b3320d2a7c07bce4afc6b2bf6c3bbddda85b01f/src/headers.js#L44
class Headers extends WebHeaders {
@@ -150,7 +191,7 @@ async function fetch(
url: any,
// eslint-disable-next-line no-unused-vars
init?: RequestInit & { body?: any },
init?: RequestInit & { body?: any; agent?: any; tls?: any },
) {
// Convert Node.js streams to Web ReadableStream if they don't have Symbol.asyncIterator.
// This is needed for libraries like `form-data` that use CombinedStream which extends
@@ -169,6 +210,15 @@ async function fetch(
init = { ...init, body: Readable.toWeb(readable) };
}
}
// Extract TLS options from agent if provided (node-fetch compatibility)
if (init?.agent && typeof init.agent === "object" && !init.tls) {
const tlsOptions = extractTlsFromAgent(init.agent);
if (tlsOptions) {
init = { ...init, tls: tlsOptions };
}
}
const response = await nativeFetch.$call(undefined, url, init);
Object.setPrototypeOf(response, ResponsePrototype);
return response;

View File

@@ -6,7 +6,64 @@ const { _ReadableFromWeb: ReadableFromWeb } = require("internal/webstreams_adapt
const ObjectCreate = Object.create;
const kEmptyObject = ObjectCreate(null);
var fetch = Bun.fetch;
const nativeFetch = Bun.fetch;
/**
* Extracts TLS options from a dispatcher/agent object.
* @param {Object} dispatcher The dispatcher object to extract options from
* @returns {Object|null} TLS options object or null if none found
*/
function extractTlsFromDispatcher(dispatcher) {
// Check for connect options on the dispatcher (undici Agent/Pool/Client style)
const connectOpts = dispatcher?.connectOpts || dispatcher?.options || dispatcher?.connect;
if (connectOpts && typeof connectOpts === "object") {
const tlsOptions = {};
let hasTlsOptions = false;
if (connectOpts.rejectUnauthorized !== undefined) {
tlsOptions.rejectUnauthorized = connectOpts.rejectUnauthorized;
hasTlsOptions = true;
}
if (connectOpts.ca) {
tlsOptions.ca = connectOpts.ca;
hasTlsOptions = true;
}
if (connectOpts.cert) {
tlsOptions.cert = connectOpts.cert;
hasTlsOptions = true;
}
if (connectOpts.key) {
tlsOptions.key = connectOpts.key;
hasTlsOptions = true;
}
if (connectOpts.passphrase) {
tlsOptions.passphrase = connectOpts.passphrase;
hasTlsOptions = true;
}
if (hasTlsOptions) {
return tlsOptions;
}
}
return null;
}
/**
* Wrapper around Bun.fetch that handles undici's dispatcher option.
*/
async function fetch(url, init) {
// Extract TLS options from dispatcher if provided (undici compatibility)
if (init?.dispatcher && typeof init.dispatcher === "object" && !init.tls) {
const tlsOptions = extractTlsFromDispatcher(init.dispatcher);
if (tlsOptions) {
init = { ...init, tls: tlsOptions };
}
}
return nativeFetch(url, init);
}
const bindings = $cpp("Undici.cpp", "createUndiciInternalBinding");
const Response = bindings[0];
const Request = bindings[1];

View File

@@ -0,0 +1,206 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir, tls } from "harness";
test("node-fetch should respect https.Agent rejectUnauthorized option", async () => {
// Create a temp directory with a test script
using dir = tempDir("node-fetch-agent-test", {
"test.mjs": `
import nodefetch from 'node-fetch';
import https from 'https';
const agent = new https.Agent({
rejectUnauthorized: false
});
const url = process.argv[2];
try {
const response = await nodefetch(url, { agent });
console.log("STATUS:" + response.status);
const text = await response.text();
console.log("BODY:" + text);
} catch (error) {
console.log("ERROR:" + error.code + ":" + error.message);
}
`,
});
// Start the self-signed HTTPS server
const server = Bun.serve({
port: 0,
tls,
fetch(req) {
return new Response("Hello from self-signed server!");
},
});
try {
const url = `https://localhost:${server.port}/`;
// Test with rejectUnauthorized: false - should succeed
await using proc = Bun.spawn({
cmd: [bunExe(), "test.mjs", url],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Should succeed with status 200
expect(stdout).toContain("STATUS:200");
expect(stdout).toContain("BODY:Hello from self-signed server!");
expect(exitCode).toBe(0);
} finally {
server.stop();
}
});
test("node-fetch should fail with self-signed cert when agent is not provided", async () => {
using dir = tempDir("node-fetch-no-agent-test", {
"test.mjs": `
import nodefetch from 'node-fetch';
const url = process.argv[2];
try {
const response = await nodefetch(url);
console.log("STATUS:" + response.status);
} catch (error) {
console.log("ERROR:" + (error.code || error.cause?.code || "UNKNOWN"));
}
`,
});
const server = Bun.serve({
port: 0,
tls,
fetch(req) {
return new Response("Hello from self-signed server!");
},
});
try {
const url = `https://localhost:${server.port}/`;
await using proc = Bun.spawn({
cmd: [bunExe(), "test.mjs", url],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Should fail with certificate error
expect(stdout).toContain("ERROR:");
expect(stdout).toMatch(/DEPTH_ZERO_SELF_SIGNED_CERT|UNABLE_TO_VERIFY_LEAF_SIGNATURE|SELF_SIGNED_CERT/);
} finally {
server.stop();
}
});
test("node-fetch should respect agent with ca certificate", async () => {
using dir = tempDir("node-fetch-agent-ca-test", {
"test.mjs": `
import nodefetch from 'node-fetch';
import https from 'https';
const ca = process.argv[3];
const agent = new https.Agent({ ca });
const url = process.argv[2];
try {
const response = await nodefetch(url, { agent });
console.log("STATUS:" + response.status);
const text = await response.text();
console.log("BODY:" + text);
} catch (error) {
console.log("ERROR:" + (error.code || error.cause?.code || "UNKNOWN") + ":" + error.message);
}
`,
});
const server = Bun.serve({
port: 0,
tls,
fetch(req) {
return new Response("Hello with CA!");
},
});
try {
const url = `https://localhost:${server.port}/`;
await using proc = Bun.spawn({
cmd: [bunExe(), "test.mjs", url, tls.cert],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Should succeed with the CA certificate
expect(stdout).toContain("STATUS:200");
expect(stdout).toContain("BODY:Hello with CA!");
expect(exitCode).toBe(0);
} finally {
server.stop();
}
});
test("undici fetch should respect dispatcher with rejectUnauthorized option", async () => {
using dir = tempDir("undici-dispatcher-test", {
"test.mjs": `
import { fetch } from 'undici';
// Create a simple dispatcher-like object with connect options
const dispatcher = {
options: {
rejectUnauthorized: false
}
};
const url = process.argv[2];
try {
const response = await fetch(url, { dispatcher });
console.log("STATUS:" + response.status);
const text = await response.text();
console.log("BODY:" + text);
} catch (error) {
console.log("ERROR:" + (error.code || error.cause?.code || "UNKNOWN") + ":" + error.message);
}
`,
});
const server = Bun.serve({
port: 0,
tls,
fetch(req) {
return new Response("Hello from undici!");
},
});
try {
const url = `https://localhost:${server.port}/`;
await using proc = Bun.spawn({
cmd: [bunExe(), "test.mjs", url],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Should succeed with status 200
expect(stdout).toContain("STATUS:200");
expect(stdout).toContain("BODY:Hello from undici!");
expect(exitCode).toBe(0);
} finally {
server.stop();
}
});