Skip to content

fetch: strip IPv6 brackets before TLS cert verification#30674

Open
robobun wants to merge 3 commits into
mainfrom
farm/1cba0bed/ipv6-tls-checkserveridentity
Open

fetch: strip IPv6 brackets before TLS cert verification#30674
robobun wants to merge 3 commits into
mainfrom
farm/1cba0bed/ipv6-tls-checkserveridentity

Conversation

@robobun

@robobun robobun commented May 14, 2026

Copy link
Copy Markdown
Collaborator

Fixes #30668

Repro

await fetch('https://[::1]:PORT/', {
  tls: {
    ca: cert,
    checkServerIdentity: (hostname, cert) => {
      console.log(hostname); // before: "[::1]" — Node: "::1"
      return require('tls').checkServerIdentity(hostname, cert);
    },
    rejectUnauthorized: true,
  },
});
// before: ERR_TLS_CERT_ALTNAME_INVALID — Host: [::1]. is not cert's CN

Cause

The URL parser keeps the surrounding [/] on IPv6 hostnames (brackets
are part of url.hostname). That form is fine for the Host: header
and DNS, but breaks TLS hostname verification on two paths:

  • Native fast path. check_x509_server_identity gates on
    strings::is_ip_address(hostname). is_ip_address("[::1]") is false,
    so it takes the DNS-name branch and never compares against the cert's
    IP:::1 SAN. Result: ERR_TLS_CERT_ALTNAME_INVALID.
  • User-supplied checkServerIdentity callback. The hostname was
    handed to the user verbatim. If the user forwarded it to
    node:tls.checkServerIdentity, the implementation's
    net.isIP("[::1]") === 0 check likewise falls through to CN matching
    and rejects.

Node.js strips brackets in urlToHttpOptions (lib/internal/url.js)
before the hostname reaches tls.checkServerIdentity, so its own
checkServerIdentity never needs to handle bracketed input.

Fix

Mirror Node's strip-at-the-edge contract at the single chokepoint
get_tls_hostname in src/http/lib.rs. Every fetch TLS consumer
(native cert check, SNI's is_ip_address check, and the user-supplied
checkServerIdentity callback that the FetchTasklet invokes) now sees
the bare address ::1.

Non-bracketed inputs are passed through unchanged, so DNS hostnames
and non-bracketed IPv4 literals are unaffected.

Verification

  • New subprocess-based test in test/js/web/fetch/fetch.tls.test.ts
    exercises both paths end-to-end against https://[::1]:port/ with a
    cert carrying IP:::1 SAN, and asserts that the user callback
    receives the bracket-stripped "::1".
  • Fails without the fix (ERR_TLS_CERT_ALTNAME_INVALID), passes with it.
  • Surrounding fetch.tls.test.ts, fetch.tls.wildcard.test.ts,
    node-tls-connect-hostname-verification.test.ts all still pass.

@robobun

robobun commented May 14, 2026

Copy link
Copy Markdown
Collaborator Author

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(tls): handle bracketed IPv6 hostnames in checkServerIdentity #30662 - Also fixes IPv6 bracket stripping for TLS certificate verification, targeting the checkServerIdentity path in node:tls rather than getTlsHostname() in src/http/http.zig

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented May 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The PR fixes IPv6 hostname handling in TLS certificate verification by normalizing bracketed IPv6 addresses. The getTlsHostname function now consistently strips [ and ] from all hostname sources (proxy, explicit TLS config, client hostname, or URL fallback) before returning values used for TLS SNI and certificate verification. A new helper function stripIPv6Brackets performs the bracket removal. A new test validates certificate verification succeeds with IPv6 literal hostnames.

Changes

IPv6 TLS Hostname Normalization

Layer / File(s) Summary
IPv6 bracket stripping in TLS hostname resolution
src/http/http.zig
getTlsHostname applies bracket normalization across all hostname sources (proxy URL when enabled, explicit TLS server_name, client.hostname with port removed, and client.url.hostname fallback). A new stripIPv6Brackets helper removes surrounding brackets from IPv6 literals while leaving non-bracketed hostnames unchanged.
IPv6 literal hostname TLS certificate verification test
test/js/web/fetch/fetch.tls.test.ts
New concurrent test spawns a subprocess to fetch https://[::1]:{port}/ and verifies TLS certificate checking succeeds on both native verification and user-supplied checkServerIdentity paths, validating the callback receives the bracket-stripped hostname ::1. Test documentation explains the IPv6 bracket handling issue and subprocess isolation rationale.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: stripping IPv6 brackets before TLS certificate verification, which is the primary focus of the entire changeset.
Linked Issues check ✅ Passed The PR fully addresses issue #30668 by implementing bracket stripping in getTlsHostname, which allows both native TLS verification and user-supplied checkServerIdentity callbacks to receive bare IPv6 addresses without brackets.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the IPv6 bracket issue: modifications to getTlsHostname in http.zig and a targeted test case in fetch.tls.test.ts. No unrelated changes detected.
Description check ✅ Passed PR description comprehensively addresses the issue with detailed root cause analysis, reproduction steps, and verification plan.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/web/fetch/fetch.tls.test.ts`:
- Around line 129-138: The test currently asserts stdout, stderr and exitCode in
one object equality; change it to assert stdout and stderr first and then assert
exitCode last: after awaiting proc.stdout.text(), proc.stderr.text(), and
proc.exited into stdout, stderr, exitCode, replace the single expect({ stdout,
stderr, exitCode }).toEqual(...) with separate assertions such as
expect(stdout).toBe("native: Hello World\ncallback hostname: ::1\njs: Hello
World\n"), expect(stderr).toBe(""), and finally expect(exitCode).toBe(0) so
stdout/stderr failures are reported before the process exit code (refer to proc,
stdout, stderr, exitCode in this test).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2c6c7201-9026-4e99-a9df-2efadb2b94ee

📥 Commits

Reviewing files that changed from the base of the PR and between 23427db and 40ea450.

📒 Files selected for processing (2)
  • src/http/http.zig
  • test/js/web/fetch/fetch.tls.test.ts

Comment thread test/js/web/fetch/fetch.tls.test.ts Outdated
Comment thread test/js/web/fetch/fetch.tls.test.ts Outdated
@robobun robobun force-pushed the farm/1cba0bed/ipv6-tls-checkserveridentity branch from 40ea450 to a524878 Compare May 14, 2026 08:39
Comment thread src/http/lib.rs
Comment on lines +1015 to 1023
/// 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
}

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.

@robobun robobun force-pushed the farm/1cba0bed/ipv6-tls-checkserveridentity branch from bb69ec9 to 744b528 Compare May 14, 2026 09:08
@robobun

robobun commented May 14, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: diff-green, lane-broken.

The only failures across all lanes of https://buildkite.com/bun/bun/builds/54232 are on debian-13 x64-asan test-bun, and both are in a file my diff does not touch:

  • test/js/web/fetch/fetch-tcp-keepalive.test.ts:85fetch sockets have TCP keepalive enabled — expects "02" in the SO_KEEPALIVE column of /proc/net/tcp, got "00".
  • test/js/web/fetch/fetch-tcp-keepalive.test.ts:127node:http globalAgent (keepAlive: true) enables SO_KEEPALIVE — same shape.

These tests were added by the recent keepalive PRs (#30627 / #30640) and appear to be deterministically broken on this ASAN lane (same failure on unrelated PR #54218 / fix-minify-require-bun at the same snapshot). They are not exercised by get_tls_hostname in src/http/lib.rs — the only file this PR changes in src/.

My test/js/web/fetch/fetch.tls.ipv6.test.ts passes locally with the fix and fails without it. I already used my one ci: retrigger (commit 1384b14); same failures repeated, so this needs a maintainer eye rather than another re-roll.

robobun and others added 3 commits May 14, 2026 17:29
The URL parser preserves the surrounding brackets on IPv6 hostnames
("[::1]"). When that form reached TLS certificate verification, two
paths broke:

- Native fast path: strings::is_ip_address("[::1]") returns false, so
  check_x509_server_identity took the DNS-name branch and never compared
  against the cert's IP SAN entries.
- User-supplied tls.checkServerIdentity callback: the hostname was
  handed to the user verbatim; if the user then forwarded it to
  node:tls.checkServerIdentity, net.isIP("[::1]") === 0 meant the same
  DNS-name fallthrough and ERR_TLS_CERT_ALTNAME_INVALID.

Node.js strips brackets in urlToHttpOptions before either path runs.
Mirror that at the single chokepoint get_tls_hostname in src/http/lib.rs,
so every fetch TLS consumer (native cert check, SNI's is_ip_address
check, and the user-supplied checkServerIdentity callback) sees the
bare address.

Test gated on isIPv6() (matches valkey-tls-verify.test.ts) and placed
in its own file so the subprocess env doesn't race with other concurrent
tests in fetch.tls.test.ts. Asserts stdout/stderr before exitCode per
CLAUDE.md.

Fixes #30668
CI on f537883 hit an unrelated flake on the debian-13 x64-asan lane
(test/js/web/fetch/fetch-tcp-keepalive.test.ts: "fetch sockets have
TCP keepalive enabled" + "node:http globalAgent (keepAlive: true)
enables SO_KEEPALIVE", both checking SO_KEEPALIVE via /proc/net/tcp).
Same tests failed on unrelated PR #54218 at the same sha snapshot,
confirming flake rather than regression — this PR doesn't touch TCP
keepalive code.
@robobun robobun force-pushed the farm/1cba0bed/ipv6-tls-checkserveridentity branch from 1384b14 to 8400029 Compare May 14, 2026 17:29
@robobun

robobun commented May 14, 2026

Copy link
Copy Markdown
Collaborator Author

Latest build https://buildkite.com/bun/bun/builds/54348 at sha 8400029 shows the same two failures (fetch sockets have TCP keepalive enabled + node:http globalAgent (keepAlive: true) enables SO_KEEPALIVE, both in test/js/web/fetch/fetch-tcp-keepalive.test.ts:85,127) — still unrelated to this diff (src/http/lib.rs::get_tls_hostname). Same failure signature as before but now across 6 Linux lanes, not just ASAN, which confirms this is a broader issue with the keepalive tests added in #30627/#30640, not something my IPv6 change regressed. I already used my one ci: retrigger (commit 1384b14) so nothing more for me to do here — this needs a maintainer to merge or fix the upstream flake.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: tls.checkServerIdentity rejecting valid IPv6 hostnames in bracket

1 participant