Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
6 changes: 5 additions & 1 deletion src/runtime/socket/Listener.rs
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,8 @@ impl Listener {
return Ok(JSValue::UNDEFINED);
};

super::socket_body::install_sni_alpn_selector(sni_ctx.as_ptr());

// The C SNI tree SSL_CTX_up_ref()s; ours drops here.
// S008: `ListenSocket` is an `opaque_ffi!` ZST — safe deref.
let ls_ref = bun_opaque::opaque_deref_mut(ls);
Expand Down Expand Up @@ -2036,7 +2038,9 @@ fn decode_sni_result(result: JSValue, abort_handshake: *mut core::ffi::c_int) ->
}
if let Some(sc) = result.as_class_ref::<SecureContext>() {
// The C dispatcher frees this +1 after `SSL_set_SSL_CTX` takes its own.
return sc.ctx.clone().into_raw().cast();
let ctx = sc.ctx.clone().into_raw();
super::socket_body::install_sni_alpn_selector(ctx);
return ctx.cast();
}
// Anything else is not a SecureContext: Node treats this as an invalid SNI
// context and drops the connection.
Expand Down
14 changes: 14 additions & 0 deletions src/runtime/socket/socket_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,19 @@ extern "C" fn select_alpn_callback(
}
}

/// BoringSSL reads the server ALPN callback off the *current* `ssl->ctx`, and
/// SNI replaces that with `SSL_set_SSL_CTX` before ALPN negotiation.
Comment thread
robobun marked this conversation as resolved.
pub(in crate::socket) fn install_sni_alpn_selector(ctx: *mut boringssl_sys::SSL_CTX) {
if ctx.is_null() {
return;
}
tls_socket_functions::ffi::SSL_CTX_set_alpn_select_cb(
SSL_CTX::opaque_ref(ctx),
Some(select_alpn_callback),
ptr::null_mut(),
);
}

// ──────────────────────────────────────────────────────────────────────────
// NewSocket<SSL>
// ──────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -891,6 +904,7 @@ impl<const SSL: bool> NewSocket<SSL> {
} else {
core::ptr::null_mut()
};
install_sni_alpn_selector(ctx_ptr);
socket.sni_resolve(ctx_ptr, is_error);
Ok(JSValue::UNDEFINED)
}
Expand Down
125 changes: 125 additions & 0 deletions test/js/node/tls/node-tls-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { describe, expect, it } from "bun:test";

import { bunEnv, bunExe, tempDir } from "harness";
import { X509Certificate } from "node:crypto";
import { once } from "node:events";
import { readFileSync } from "node:fs";
import { AddressInfo } from "node:net";
import { join } from "node:path";
Expand Down Expand Up @@ -359,6 +360,130 @@ describe("tls.Server", () => {
});
});

describe("ALPN survives an SNI-selected SecureContext", () => {
// Node negotiates ALPN at the connection level, independent of which
// SecureContext SNI selected; an SNI/multi-domain server is exactly the
// deployment shape that needs ALPN (h2, gRPC), so selecting a per-domain
// context must not drop the server's ALPN configuration.
const identity = { key: agent1Key, cert: agent1Cert };
type SNICb = (err: Error | null, ctx?: unknown) => void;
const cases: [string, object, undefined | ((server: tls.Server) => void)][] = [
[
"synchronous SNICallback",
{ SNICallback: (_name: string, cb: SNICb) => cb(null, tls.createSecureContext(identity)) },
undefined,
],
[
"asynchronous SNICallback",
{
SNICallback(_name: string, cb: SNICb) {
setImmediate(() => cb(null, tls.createSecureContext(identity)));
},
},
undefined,
],
["addContext", {}, (server: tls.Server) => server.addContext("alpn.sni.test", identity)],
];

for (const [label, extraOptions, setup] of cases) {
it(`negotiates the server's ALPNProtocols: ${label}`, async () => {
const serverSide = Promise.withResolvers<string | false | null>();
await using server = tls.createServer(
{ ...identity, ALPNProtocols: ["h2", "http/1.1"], ...extraOptions },
socket => {
serverSide.resolve(socket.alpnProtocol);
socket.end();
},
);
server.on("tlsClientError", serverSide.reject);
server.listen(0);
await once(server, "listening");
setup?.(server);
const { port } = server.address() as AddressInfo;
await using client = tls.connect({
port,
host: "127.0.0.1",
servername: "alpn.sni.test",
ALPNProtocols: ["h2"],
rejectUnauthorized: false,
});
client.on("error", serverSide.reject);
await once(client, "secureConnect");
expect({ client: client.alpnProtocol, server: await serverSide.promise }).toEqual({
client: "h2",
server: "h2",
});
});
}

// https://github.com/oven-sh/bun/issues/17932
it("still consults the server's ALPNCallback", async () => {
const seen: { servername: string; protocols: string[] }[] = [];
const failed = Promise.withResolvers<never>();
await using server = tls.createServer(
{
...identity,
ALPNCallback(arg: { servername: string; protocols: string[] }) {
seen.push(arg);
return "h2";
},
SNICallback: (_name: string, cb: SNICb) => cb(null, tls.createSecureContext(identity)),
},
socket => socket.end(),
);
server.on("tlsClientError", failed.reject);
server.listen(0);
await once(server, "listening");
const { port } = server.address() as AddressInfo;
await using client = tls.connect({
port,
host: "127.0.0.1",
servername: "alpn.sni.test",
ALPNProtocols: ["http/1.1", "h2"],
rejectUnauthorized: false,
});
client.on("error", failed.reject);
await Promise.race([once(client, "secureConnect"), failed.promise]);
expect(client.alpnProtocol).toBe("h2");
expect(seen).toEqual([{ servername: "alpn.sni.test", protocols: ["http/1.1", "h2"] }]);
});

it("sends the fatal no_application_protocol alert on a genuine mismatch", async () => {
// RFC 7301 3.2: no overlap between the offered and configured protocol
// lists is a fatal no_application_protocol alert, not a silent handshake
// that negotiated nothing.
await using server = tls.createServer({
...identity,
ALPNProtocols: ["h2"],
SNICallback: (_name: string, cb: SNICb) => cb(null, tls.createSecureContext(identity)),
});
server.on("tlsClientError", () => {});
server.listen(0);
await once(server, "listening");
const { port } = server.address() as AddressInfo;
// Not `await using`: a Readable's Symbol.asyncDispose rejects with the
// stream's terminal error, and this client is expected to end in one.
const client = tls.connect({
port,
host: "127.0.0.1",
servername: "alpn.sni.test",
ALPNProtocols: ["spdy/3"],
rejectUnauthorized: false,
});
try {
const outcome = Promise.withResolvers<Error & { code?: string }>();
client.on("error", outcome.resolve);
client.on("secureConnect", () => {
outcome.reject(new Error(`handshake succeeded with alpnProtocol=${JSON.stringify(client.alpnProtocol)}`));
});
const err = await outcome.promise;
expect(err.code).toBe("ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL");
} finally {
client.destroy();
}
});
});

describe("Bun.serve SNI", () => {
function doClientRequest(options: any) {
return new Promise((resolve, reject) => {
Expand Down
Loading