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
23 changes: 19 additions & 4 deletions src/http/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -980,10 +980,15 @@ pub(crate) fn abort_tracker() -> &'static mut ArrayHashMap<u32, uws::AnySocket>
/// Priority: tls_props.server_name > client.hostname > client.url.hostname
/// The Host header value (client.hostname) may contain a port suffix which
/// must be stripped because it is not part of the DNS name in certificates.
/// IPv6 literals have their surrounding brackets stripped (e.g. "[::1]" -> "::1")
/// so downstream consumers (boringssl::check_x509_server_identity, SNI's
/// is_ip_address check, and any user-supplied checkServerIdentity callback) see
/// the bare address. This mirrors Node.js, which strips brackets in
/// urlToHttpOptions before the hostname reaches tls.checkServerIdentity.
fn get_tls_hostname<'c>(client: &'c HTTPClient<'_>, allow_proxy_url: bool) -> &'c [u8] {
if allow_proxy_url {
if let Some(proxy) = &client.http_proxy {
return proxy.hostname;
return strip_ipv6_brackets(proxy.hostname);
}
}
// Prefer the explicit TLS server_name (e.g. from Node.js servername option)
Expand All @@ -996,15 +1001,25 @@ fn get_tls_hostname<'c>(client: &'c HTTPClient<'_>, allow_proxy_url: bool) -> &'
// `client.tls_props`) without a `(ptr,len)` round-trip.
let sn_slice = unsafe { bun_core::ffi::cstr(sn) }.to_bytes();
if !sn_slice.is_empty() {
return sn_slice;
return strip_ipv6_brackets(sn_slice);
}
}
}
// client.hostname comes from the Host header and may include ":port"
if let Some(host) = &client.hostname {
return strip_port_from_host(host);
return strip_ipv6_brackets(strip_port_from_host(host));
}
strip_ipv6_brackets(client.url.hostname)
}

/// Strips surrounding `[` `]` from an IPv6 literal, e.g. "[::1]" -> "::1".
/// Leaves non-bracketed hostnames unchanged.
fn strip_ipv6_brackets(host: &[u8]) -> &[u8] {
if host.len() >= 2 && host[0] == b'[' && host[host.len() - 1] == b']' {
&host[1..host.len() - 1]
} else {
host
}
client.url.hostname
}
Comment on lines +1015 to 1023

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 Pre-existing, not introduced here — but ProxyTunnel::on_open (src/http/ProxyTunnel.rs:248) computes the inner-TLS SNI hostname via this.hostname.unwrap_or(this.url.hostname) instead of calling get_tls_hostname, so a proxied fetch('https://[::1]:PORT/') still passes "[::1]" to bun_core::is_ip_address (which returns false for bracketed input) and sends it as the SNI server_name. Cert verification on that path is fixed by this PR (it routes through check_server_identityget_tls_hostname), so #30668's user-visible symptom is resolved either way — but if you want the "single chokepoint" claim to actually hold for SNI, swapping line 248 for get_tls_hostname(this, false) would also give the proxy-tunnel path port-stripping and tls_props.server_name precedence for free.

Extended reasoning...

What this is

The PR description says getTlsHostname is the "single chokepoint" so that "every fetch TLS consumer (native cert check, SNI's isIPAddress check, and the user-supplied checkServerIdentity callback) now sees the bare address ::1". For the direct (non-proxied) connection path that's true — get_tls_hostname is called at src/http/lib.rs:1592 and the result feeds the SNI is_ip_address gate. But the proxy-tunnel inner-TLS handshake has its own, older copy of the hostname-selection logic that bypasses the chokepoint.

src/http/ProxyTunnel.rs:248:

let _hostname = this.hostname.unwrap_or(this.url.hostname);
if bun_core::is_ip_address(_hostname) {
    // null SNI
} else {
    // NUL-terminate _hostname and pass to configure_http_client_with_alpn as SNI
}

This does not call get_tls_hostname, so it picks up neither the existing strip_port_from_host nor the new strip_ipv6_brackets (nor the tls_props.server_name override).

Step-by-step

  1. fetch('https://[::1]:9443/', { proxy: 'http://proxyhost:8080', tls: { ca, rejectUnauthorized: true } }).
  2. Outer plain-HTTP connection to the proxy succeeds; CONNECT [::1]:9443 establishes the tunnel.
  3. ProxyTunnel::on_open fires to start the inner TLS handshake to the origin.
  4. this.url.hostname == "[::1]" (the URL parser keeps brackets, per src/url/url.zig:429-432 and the PR description itself).
  5. bun_core::is_ip_address("[::1]")false. It calls ares_inet_pton(AF_INET, ...) then ares_inet_pton(AF_INET6, ...) directly on the input (src/bun_core/lib.rs:2240-2251); inet_pton does not accept bracketed IPv6, so both fail.
  6. Falls into the else branch, NUL-terminates "[::1]", and passes it as the server_name to configure_http_client_with_alpn — an RFC 6066 §3 violation (literal IP addresses are not permitted in SNI HostName).

What is and isn't fixed on this path

  • Cert verification (the actual Bug: tls.checkServerIdentity rejecting valid IPv6 hostnames in bracket #30668 symptom): fixed. ProxyTunnel's identity check goes through check_server_identity(..., allow_proxy_url=false)get_tls_hostname at src/http/lib.rs:1487, which now strips brackets. So ERR_TLS_CERT_ALTNAME_INVALID is gone for proxied IPv6 origins too.
  • SNI emission: not fixed. Only on_open is missed, because it duplicates the hostname-selection logic locally.

grep confirms get_tls_hostname has exactly two call sites (lib.rs:1487 cert check, lib.rs:1592 direct-path SNI); ProxyTunnel.rs:248 is a third hostname consumer that doesn't go through it.

Why file it at all (addressing the "no observable effect" objection)

It's true that most TLS servers ignore an unrecognized/garbage server_name rather than aborting the handshake, and that for an IP-literal origin the "correct" behaviour is to send no SNI anyway — so the practical blast radius is small. I'm flagging it as pre-existing, not a blocker, for three reasons:

  1. It's the same root cause this PR is fixing (bracketed IPv6 leaking into a TLS-hostname consumer that gates on is_ip_address), in a directly adjacent code path — not an unrelated drive-by.
  2. The PR description and the new doc comment on get_tls_hostname both name "SNI's is_ip_address check" as a covered consumer; the proxy-tunnel SNI's is_ip_address check at ProxyTunnel.rs:252 is not covered, so the claim is slightly overstated.
  3. The one-line fix has independent value beyond bracket-stripping: routing line 248 through get_tls_hostname(this, false) would also give the proxy-tunnel inner handshake strip_port_from_host and tls_props.server_name precedence, which it currently lacks relative to the direct path at lib.rs:1592.

Suggested follow-up

// src/http/ProxyTunnel.rs:248
let _hostname = crate::get_tls_hostname(this, false);

(make get_tls_hostname pub(crate) if it isn't already). That makes the chokepoint claim hold for all three fetch-TLS hostname consumers.


// ── support types ───────────────────────────────────────────────────────
Expand Down
90 changes: 90 additions & 0 deletions test/js/web/fetch/fetch.tls.ipv6.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Regression test for https://github.com/oven-sh/bun/issues/30668
//
// The URL parser keeps the surrounding `[`/`]` on IPv6 hostnames. That bracketed
// form must NOT leak into TLS certificate verification:
// - strings::is_ip_address("[::1]") is false, so the native fast path would
// take the DNS-name branch and skip the IP-SAN match on the cert.
// - node:tls.checkServerIdentity uses net.isIP("[::1]") === 0 and likewise
// falls through to CN matching and emits ERR_TLS_CERT_ALTNAME_INVALID.
//
// Node.js strips brackets in urlToHttpOptions before either verification path
// runs. get_tls_hostname in src/http/lib.rs now mirrors that contract so every
// TLS consumer (native cert check, SNI's is_ip_address check, and the
// user-supplied checkServerIdentity callback) sees the bare address `::1`.
//
// This test lives in its own file rather than fetch.tls.test.ts because the
// subprocess approach is sensitive to environment setup (HTTP_PROXY,
// NO_PROXY) and we want a clean top-level env rather than sharing one with
// other concurrent tests in that file.

import { expect, it } from "bun:test";
import { bunEnv, bunExe, isIPv6, tls as validTls } from "harness";

// Skipped on Buildkite Linux — those AWS instances don't have IPv6 set up
// (see `isIPv6` in harness.ts). Matches the gating pattern already used by
// the directly analogous valkey-tls-verify.test.ts:177.
it.skipIf(!isIPv6())("fetch with IPv6 literal hostname verifies the certificate", async () => {
using server = Bun.serve({
port: 0,
tls: validTls,
fetch() {
return new Response("Hello World");
},
});
const port = server.port;

// Drop HTTP_PROXY/HTTPS_PROXY so the container-level egress proxy doesn't
// intercept a request to [::1] (NO_PROXY doesn't match the bracketed form
// in some fetch paths).
const { HTTP_PROXY, HTTPS_PROXY, http_proxy, https_proxy, ...cleanEnv } = bunEnv;
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const tls = require("node:tls");
const cert = ${JSON.stringify(validTls.cert)};
const url = "https://[::1]:${port}/";
// Native fast path — no user-supplied checkServerIdentity. Native
// check_x509_server_identity must see "::1" (not "[::1]") so that
// strings::is_ip_address() picks the IP-SAN branch.
{
const res = await fetch(url, {
keepalive: false,
tls: { ca: cert, rejectUnauthorized: true },
});
console.log("native:", await res.text());
}
// User-supplied checkServerIdentity path — the hostname handed to
// the callback must be bracket-stripped so that
// node:tls.checkServerIdentity (net.isIP) accepts it, matching
// Node.js's urlToHttpOptions behavior.
{
let observed;
const res = await fetch(url, {
keepalive: false,
tls: {
ca: cert,
rejectUnauthorized: true,
checkServerIdentity(hostname, cert) {
observed = hostname;
return tls.checkServerIdentity(hostname, cert);
},
},
});
console.log("callback hostname:", observed);
console.log("js:", await res.text());
}
`,
],
env: cleanEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Asserting stdout/stderr before exitCode produces a more useful failure
// message when the subprocess crashes unexpectedly.
expect(stdout).toBe("native: Hello World\ncallback hostname: ::1\njs: Hello World\n");
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
Loading