Bun.serve: negotiate ALPN over TLS

Closed
RO
1mo ago
+302-28

Bun.serve({ tls }) never negotiated ALPN. Its TLS contexts had no select callback installed, so the server picked no protocol even for a client offering http/1.1, never sent the mandatory no_application_protocol alert on zero overlap (RFC 7301 §3.2), and silently ignored its own tls.ALPNProtocols option, while the identical option on Bun.listen worked.

Every Bun.serve HTTPS endpoint (and therefore node:https.createServer, which is Bun.serve underneath) was invisible to ALPN-based protocol selection: strict-ALPN clients like node:http2 would complete the handshake and then send an h2 preface at an HTTP/1.1 server.

Repro

import tls from "node:tls";

const server = Bun.serve({ port: 0, tls: { cert, key }, fetch: () => new Response("hi") });

const probe = (protocols) => new Promise(res => {
  const s = tls.connect({ host: "127.0.0.1", port: server.port, servername: "localhost", ca: cert, ALPNProtocols: protocols },
    () => { res(s.alpnProtocol); s.destroy(); });
  s.on("error", e => res(e.code));
});

console.log(await probe(["h2", "http/1.1"]));  // false       (node: "http/1.1")
console.log(await probe(["bogus/9"]));         // false       (node: ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL)

Cause

us_bun_socket_context_options_t had nowhere to carry an ALPN list, so us_ssl_ctx_build_raw never called SSL_CTX_set_alpn_select_cb and the parsed SSLConfig.protos was dropped. Bun.listen works because it installs a per-connection selector on the SSL itself.

Fix

  • us_bun_socket_context_options_t carries the wire-format list. us_ssl_ctx_build_raw copies it into the SSL_CTX and installs a select callback that runs SSL_select_next_proto and returns SSL_TLSEXT_ERR_ALERT_FATAL on no overlap.
  • The copy hangs off SSL_CTX ex_data rather than the callback arg: SNI does SSL_set_SSL_CTX, and BoringSSL reads alpn_select_cb (and through it the list) off the post-SNI context, so each SNI context selects from its own list.
  • Bun.serve sets the list when it builds its context options, defaulting to http/1.1 when tls.ALPNProtocols is absent. That is the default node's https.Server applies. An explicit empty list opts out, as on Bun.listen.
  • as_usockets() deliberately leaves protos unset: an ALPN list is only a context property for a server that selects from it, and that conversion is shared with Bun.connect, upgradeTLS and SecureContext, which apply theirs per-SSL. BunSocketContextOptions::digest() still covers protos so a future caller cannot get a false SSLContextCache hit.
  • tls.ALPNProtocols is validated where it is parsed. It is raw wire bytes, so ALPNProtocols: "h2" (the shape a reader of node's ["h2"] reaches for) parses as a 104-byte name and matches nothing. That was inert on Bun.serve before, and once the list is actually consulted it would refuse every client that offers ALPN, with only the alert to go on. Bun.listen and Bun.connect have always had the same trap, so the check sits at the shared parse boundary. An empty list still opts out.

Bun.listen is unchanged: Listener::listen moves protos out of the config before building the context, and its per-connection selector (which also backs ALPNCallback) keeps running.

A server refusing an ALPN offer also made the fetch client report UNKNOWN_CERTIFICATE_VERIFICATION_ERROR, because get_cert_error_from_no was applied to uSockets' EPROTO sentinel rather than an X509 verify code. That is reproducible on main against a real node https server. It now reports HTTP2Unsupported when the offer was h2-only, and ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL otherwise, which is the code node:tls already gives for the same alert.

Verification

Bun.serve, default and explicit, now matches node's https.Server:

node httpsbeforeafter
default, offer [h2, http/1.1]"http/1.1"false"http/1.1"
+ALPNProtocols, offer [h2, http/1.1]"http/1.1"false"http/1.1"
default, offer [bogus/9]fatal alerthandshake completesfatal alert
Bun.listen +ALPN, offer [h2, http/1.1]n/a"http/1.1""http/1.1"
Bun.listen +ALPN, offer [bogus/9]n/afatal alertfatal alert

curl --http2 against Bun.serve now negotiates down to 1.1 through ALPN instead of guessing.

New tests in test/js/bun/http/bun-serve-ssl.test.ts cover the default, an explicit wire-format list, an explicit string, the empty list, a client with no ALPN extension, no-overlap rejection, SNI contexts, the fetch error, and four malformed ALPNProtocols values. Most fail with src/ and packages/ reverted to the base; the two that pass both ways are the negative contracts (a client offering no ALPN must still handshake, and an empty list must stay opted out).

test/js/node/http2/node-http2.test.js's "should fail to connect over HTTP/1.1" asserted ERR_HTTP2_ERROR against a Bun.serve TLS server, which was the mis-handshake this fixes. It now asserts the ALPN rejection.

suites run

bun-serve-ssl, serve, bun-server, serve-http3, serve-protocols, node-tls-server, node-tls-connect, node-http2, node-http2-upgrade, fetch, fetch-http2-client, websocket-server, proxy-stress-errors, plus test-http2-alpn.js / test-http2-https-fallback.js / test-http2-server-unknown-protocol.js / test-http2-create-client-secure-session.js. No new failures; the pre-existing ones reproduce identically with src/ and packages/ reverted to the base.

200 TLS servers with SNI entries created and stopped under ASAN/LSAN report zero allocations from the new ex_data copy.

Known gap, not addressed here

node:https.createServer whitelists which TLS keys it forwards to Bun.serve (src/js/node/_http_server.ts, the normalizeServerTls({...}) call) and ALPNProtocols is not among them, so it is still dropped there. Honoring it needs the native TLSOptions to also accept node's string[] form, and Bun.serve cannot speak the h2 a user would most likely ask for, so that belongs in its own change.

SSLConfig.protos is stored as a NUL-terminated C string, so a NUL byte inside a list truncates it. Unreachable for IANA-registered protocol names, which are printable ASCII, and it predates this change.


[review] gate passed · iteration 5 · 13 files touched

fails on main (without fix)
ASAN without fix: 19 failed, 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/bun-serve-ssl.test.ts "test/js/node/http2/node-http2.test.js"
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (346e474e7)

test/js/bun/http/bun-serve-ssl.test.ts:
(pass) Bun.serve SSL validations > invalid key development [7.73ms]
(pass) Bun.serve SSL validations > invalid key #2 development [2.23ms]
(pass) Bun.serve SSL validations > invalid cert development [1.52ms]
(pass) Bun.serve SSL validations > invalid cert #2 development [6.07ms]
(pass) Bun.serve SSL validations > invalid serverName: missing serverName development [2.29ms]
(pass) Bun.serve SSL validations > invalid serverName: empty serverName development [1.89ms]
(pass) Bun.serve SSL validations > invalid key production [2.29ms]
(pass) Bun.serve SSL validations > invalid key #2 production [1.81ms]
(pass) Bun.serve SSL validations > inval
... (truncated)

release without fix: 25 failed, 6 skipped
bun test v1.4.0-canary.1 (1498d7b77)

test/js/bun/http/bun-serve-ssl.test.ts:
(pass) Bun.serve SSL validations > invalid key development [0.32ms]
(pass) Bun.serve SSL validations > invalid key #2 development [0.06ms]
(pass) Bun.serve SSL validations > invalid cert development [0.02ms]
(pass) Bun.serve SSL validations > invalid cert #2 development [0.35ms]
(pass) Bun.serve SSL validations > invalid serverName: missing serverName development [0.03ms]
(pass) Bun.serve SSL validations > invalid serverName: empty serverName development [0.01ms]
(pass) Bun.serve SSL validations > invalid key production [0.05ms]
(pass) Bun.serve SSL validations > invalid key #2 production [0.04ms]
(pass) Bun.serve SSL validations > invalid cert production [0.03ms]
(pass) Bun.serve SSL validations > invalid cert #2 production [0.28ms]
(pass) Bun.serve SSL validations > invalid serverName: missing serverName production [0.01ms]
(pass) Bun.serve SSL validations > invalid serverName: empty serverName production
(pass) Bun.serve SSL validations > valid development [8.51ms]
(pass) Bun.serve SSL validations > valid 2 development [4.16ms]
(pass) Bun.serve SSL validations > valid production [2.76ms
... (truncated)
passes on PR (with fix)
ASAN with fix: 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/bun-serve-ssl.test.ts "test/js/node/http2/node-http2.test.js"
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (346e474e7)

test/js/bun/http/bun-serve-ssl.test.ts:
(pass) Bun.serve SSL validations > invalid key development [8.00ms]
(pass) Bun.serve SSL validations > invalid key #2 development [2.23ms]
(pass) Bun.serve SSL validations > invalid cert development [1.53ms]
(pass) Bun.serve SSL validations > invalid cert #2 development [6.18ms]
(pass) Bun.serve SSL validations > invalid serverName: missing serverName development [2.34ms]
(pass) Bun.serve SSL validations > invalid serverName: empty serverName development [1.59ms]
(pass) Bun.serve SSL validations > invalid key production [2.27ms]
(pass) Bun.serve SSL validations > invalid key #2 production [1.79ms]
(pass) Bun.serve SSL validations > inval
... (truncated)

release with fix: 6 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     346e474e71
  features     (none)

22 deps, 105 codegen, 1168 objects in 758ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1230] gen ErrorCode+*.h
[2/1230] install /workspace/bun
bun install v1.3.14 (0d9b296a)

Checked 124 installs across 170 packages (no changes) [39.00ms]
[3/1230] install /workspace/bun/packages/bun-error
bun install v1.3.14 (0d9b296a)

Checked 1 install across 2 packages (no changes) [6.00ms]
[4/1230] gen bindgenv2
[5/1230] fetch picohttpparser
[picohttpparser] up to date
[6/1230] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[7/1230] fetch tinycc
[tinycc] up to date
[8/1229] gen .bind.ts → GeneratedBindings.cpp
[9/1229] fetch zlib
[zlib] up to date
[10/1229] gen ProcessBindingConstants.lut.h
Ge
... (truncated)
diff hotspot
packages/bun-types/bun.d.ts                | 10 ++++
 packages/bun-usockets/src/crypto/openssl.c | 70 +++++++++++++++++++++++
 packages/bun-usockets/src/libusockets.h    |  5 ++
 packages/bun-uws/src/App.h                 |  2 +
 src/http/HTTPCertError.rs                  | 50 +++++++++--------
 src/http/HTTPContext.rs                    | 15 +++++
 src/http/ssl_config.rs                     |  3 +
 src/runtime/server/mod.rs                  | 27 ++++++++-
 src/runtime/socket/SSLConfig.rs            | 29 +++++++++-
 src/runtime/webcore/fetch/FetchTasklet.rs  |  3 +
 src/uws_sys/SocketContext.rs               | 14 +++++
 test/js/bun/http/bun-serve-ssl.test.ts     | 90 ++++++++++++++++++++++++++++++
 test/js/node/http2/node-http2.test.js      | 12 +++-
 13 files changed, 302 insertions(+), 28 deletions(-)

gate history · 1 passed · 0 rejected · iteration 5

evidence per changed file
file                                        reads  edits  tests
packages/bun-types/bun.d.ts                     2      3      0
packages/bun-usockets/src/crypto/openssl.c      8     10      0
packages/bun-usockets/src/libusockets.h         2      3      0
packages/bun-uws/src/App.h                      2      1      0
src/http/HTTPCertError.rs                       4      5      0
src/http/HTTPContext.rs                         2      4      0
src/http/ssl_config.rs                          3      5      0
src/runtime/server/mod.rs                       6      8      0
src/runtime/socket/SSLConfig.rs                 7      7      0
src/runtime/webcore/fetch/FetchTasklet.rs       1      1      0
src/uws_sys/SocketContext.rs                    2      3      0
test/js/bun/http/bun-serve-ssl.test.ts          3      7      0
test/js/node/http2/node-http2.test.js           2      3      0