Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
34 changes: 34 additions & 0 deletions src/runtime/webcore/fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,40 @@ fn fetch_impl<const ALLOW_GET_BODY: bool>(
return Ok(JSValue::ZERO);
}
Ok(Some(config)) => {
// The HTTP thread builds this SSL_CTX lazily and, on a cert/key
// parse failure, can only surface a generic FailedToOpenSocket
// (the BoringSSL error queue is thread-local and uSockets does
// not set `*err` for bad cert/key). Build it here so a bad
// client identity rejects with the same ERR_OSSL_* code as
// node:tls createSecureContext. Only probe when a client
// identity is present so ca-only / serverName-only configs
// keep hitting the HTTP-thread cache without an extra build.
Comment thread
robobun marked this conversation as resolved.
Resolved
let has_client_identity = config.cert.is_some()
|| config.key.is_some()
|| !config.cert_file_name.is_null()
|| !config.key_file_name.is_null();
if has_client_identity {
let mut err = bun_uws::create_bun_socket_error_t::none;
match config
.as_usockets_for_client_verification()
.create_ssl_context(&mut err)
{
Some(ctx) => {
// SAFETY: `create_ssl_context` hands back a +1
// ref; release it. The HTTP thread builds its
// own from the interned config.
unsafe { bun_boringssl_sys::SSL_CTX_free(ctx) };
}
None => {
return Err(global_this.throw_value(
crate::socket::uws_jsc::create_bun_socket_error_to_js(
err,
global_this,
),
));
}
}
}
// Intern via `ssl_config::global_registry` for dedup and pointer equality
break 'extract_ssl_config Some(ssl_config_intern_for_http(config));
}
Expand Down
77 changes: 77 additions & 0 deletions test/js/web/fetch/fetch.tls.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "bun:test";
import { bunEnv, bunExe, isASAN, tmpdirSync } from "harness";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import tls from "node:tls";

Expand Down Expand Up @@ -698,4 +699,80 @@ describe.concurrent("fetch-tls", () => {
expect(stderr).toContain("ignoring extra certs");
}
});

describe("client tls material errors carry the BoringSSL reason", () => {
// These used to collapse to { code: "FailedToOpenSocket", message: "Was
// there a typo in the url or port?" } because the SSL_CTX is built lazily
// on the HTTP thread, which only ever returned a generic init failure.
const keysDir = join(import.meta.dir, "..", "..", "node", "test", "fixtures", "keys");
const readKey = (name: string) => readFileSync(join(keysDir, name), "utf8");

const agent1Cert = readKey("agent1-cert.pem");
const agent1Key = readKey("agent1-key.pem");
const agent2Key = readKey("agent2-key.pem");
const rsaCert = readKey("rsa_cert.crt");
// rsa_private.pem re-encrypted with pass:'password' (see fixtures/keys/Makefile).
const rsaEncryptedKey = readKey("rsa_private_encrypted.pem");

async function rejection(clientTls: TLSOptions) {
await using server = Bun.serve({
port: 0,
tls: CERT_LOCALHOST_IP,
fetch: () => new Response("ok"),
});
let caught: any;
await fetch(server.url, { tls: { ...clientTls, rejectUnauthorized: false } as any }).then(
r => r.text(),
e => (caught = e),
);
return caught;
}

it("cert/key mismatch rejects with ERR_OSSL_X509_KEY_VALUES_MISMATCH", async () => {
const err = await rejection({ cert: agent1Cert, key: agent2Key });
expect(err).toBeInstanceOf(Error);
expect({ code: err.code, library: err.library, reason: err.reason }).toEqual({
code: "ERR_OSSL_X509_KEY_VALUES_MISMATCH",
library: "X.509 certificate routines",
reason: "KEY_VALUES_MISMATCH",
});
expect(err.message).not.toContain("typo in the url or port");
});

it("wrong passphrase rejects with the BoringSSL decrypt reason", async () => {
const err = await rejection({ cert: rsaCert, key: rsaEncryptedKey, passphrase: "wrong" });
expect(err).toBeInstanceOf(Error);
// BoringSSL reports this from ERR_LIB_CIPHER, which Node's library-prefix
// map does not cover, so the code has no library segment.
expect({ code: err.code, library: err.library, reason: err.reason }).toEqual({
code: "ERR_OSSL_BAD_DECRYPT",
library: "Cipher functions",
reason: "BAD_DECRYPT",
});
expect(err.message).not.toContain("typo in the url or port");
});

it("non-PEM cert string rejects with ERR_OSSL_PEM_NO_START_LINE", async () => {
const err = await rejection({ cert: "not a pem", key: agent1Key });
expect(err).toBeInstanceOf(Error);
expect({ code: err.code, library: err.library, reason: err.reason }).toEqual({
code: "ERR_OSSL_PEM_NO_START_LINE",
library: "PEM routines",
reason: "NO_START_LINE",
});
expect(err.message).not.toContain("typo in the url or port");
});

it("matching cert/key still succeeds", async () => {
await using server = Bun.serve({
port: 0,
tls: CERT_LOCALHOST_IP,
fetch: () => new Response("ok"),
});
const res = await fetch(server.url, {
tls: { cert: agent1Cert, key: agent1Key, rejectUnauthorized: false } as any,
});
expect(await res.text()).toBe("ok");
});
});
});
Loading