Skip to content

Parse IP literals strictly: 127.1, 0x7f000001 and ::1/64 are host names - #43979

Merged
Jarred-Sumner merged 7 commits into
mainfrom
robobun/aaa5140b/strict-is-ip-address
Sep 25, 2026
Merged

Jarred-Sumner merged 7 commits into
mainfrom
robobun/aaa5140b/strict-is-ip-address

Conversation

@robobun

@robobun robobun commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #43873, found by code reading. Supersedes #39931.

Problem

  • bun_core::ip_address parses with c-ares ares_inet_pton, which is inet_net_pton. It also takes 127.1, 0x7f000001, zero-padded octets and a trailing /bits.
  • Bun.connect and Bun.udpSocket dial :: for ::1/64. On Windows they dial 127.1.0.0 for 127.1, and elsewhere 127.0.0.1 for 127.0.0.1 db.example.
  • fetch, Bun.connect, RedisClient and Bun.SQL verify 0x7f000001 against a certificate for 127.0.0.1. fetch sends no SNI for 127.1.

Fix

  • is_ip_address, is_ipv6_address, the IPv6 arm of to_ip_address and its IPv4 arm on Windows use the core::net parser.
  • Elsewhere the IPv4 arm keeps inet_aton and takes a host only when inet_aton read it all. The dev server Host check accepts that shorthand too.
  • Verified on Linux: 75 new test rows in 8 files, 58 fail on main. On Windows: 68 of the rows.
  • Self-reviewed: 12 concerns raised, 11 addressed. The tarball case has no test.

Background

Downsides

  • Breaking: a host in hex, zero-padded or /bits form no longer matches an IP address in a certificate, only the same text.
  • Breaking: Bun.connect answers ENOTFOUND for "127.0.0.1\n" and any host with text after whitespace. On Windows also for 0x7f000001 and 127.0.0.1/32.
  • Cost: release .text -1,024 bytes. Instructions per call fall, with two exceptions: IPv4 shorthand at the dev server (127.1: 471 to 1,391) and ::1/64 in to_ip_address (520 to 538).
Notes

What changes, by caller

Function Callers A host in shorthand before Now
is_ip_address SNI in src/http/lib.rs, ProxyTunnel.rs, WebSocketUpgradeClient.rs, WebSocketProxyTunnel.rs no SNI SNI as typed, as tls.connect
certificate check, src/boringssl/lib.rs (file not touched) 0x7f000001 matched IP:127.0.0.1 a name: matches a dNSName or CN with that text only
bun install DNS prefetch, src/url/lib.rs a registry host arrives canonical the same. A host that the URL parser rejects (08.1.1.1) is now prefetched
is_ip_address and to_ip_address dev server Host check, src/runtime/bake/DevServer.rs (the one call site that changes) allowed what the resolver reads stays allowed (127.1), the rest is refused ([::1/64])
to_ip_address, IPv6 arm Bun.connect, Bun.listen, Bun.udpSocket, is_valid_hostname, strip_ipv6_brackets (20 call sites) ::1/64 was the address :: not an address: ENOTFOUND, Invalid address, brackets stay
to_ip_address, IPv4 arm on Windows the same callers 127.1 was 127.1.0.0, 127.0.0.1/32 was an address not an address, as for the Windows resolver
to_ip_address, IPv4 arm elsewhere the same callers 127.0.0.1 db.example was 127.0.0.1, because inet_aton stops at whitespace or a NUL not an address. 127.1 stays 127.0.0.1
is_ipv6_address normalize_dns_name, brackets of a displayed URL ::1/64 was IPv6 not reached: is_valid_hostname rejects it first

Shorthand gets to the TLS callers from tls.serverName, a proxy in HTTP_PROXY or HTTPS_PROXY, a tarball URL and a wss+unix: URL host. A fetch or wss: URL host, the proxy option and a registry URL go through the URL parser first, which writes 127.1 as 127.0.0.1.

is_ipv6_address and to_ip_address must change together. do_lookup (src/runtime/dns_jsc/dns.rs) asks is_valid_hostname first, and that asks to_ip_address. With a strict is_ipv6_address alone, ::1/64 would stay on the c-ares backend, and c-ares reads it as a literal.

Breaking changes, measured. Linux rows: release builds of one tree that differ only in the two source files. Windows rows: a debug build of this branch against the canary of main (29d9638), on Windows Server 2019 x64.

Case main this branch
fetch, ca set, tls.serverName 0x7f000001, 127.000.000.001, 127.0.0.1/32, 127.0.0.1/8, ::1/128 verified ERR_TLS_CERT_ALTNAME_INVALID
Bun.connect, socket.upgradeTLS, RedisClient (rediss://0x7f000001), Bun.SQL sslmode=verify-full verified ERR_TLS_CERT_ALTNAME_INVALID
HTTP_PROXY=https://0x7f000001:PORT verified, no SNI ERR_TLS_CERT_ALTNAME_INVALID, SNI 0x7f000001. On Windows ENOTFOUND
bun install of https://0x7f000001:PORT/pkg.tgz with --cafile installs ERR_TLS_CERT_ALTNAME_INVALID downloading tarball
host 127.1 against a certificate with DNS:127.1, or CN=127.1 and no dNSName rejected verified
127.1, 10 against IP:127.0.0.1 rejected rejected
Bun.connect to ::1/64, ::1/0, 2001:db8::1/0, [::1/64] connects to ::1 ENOTFOUND
Bun.udpSocket send() to ::1/64, 2001:db8::1/0 delivered to ::1 throws Invalid address
Windows: Bun.connect to 127.1 connects, the server sees 127.1.0.0 ENOTFOUND
Windows: Bun.connect to 0x7f000001, 127.000.000.001, 127.0.0.1/32 connects to 127.0.0.1 ENOTFOUND
Windows: Bun.connect to 1.2.3.4/8 dials 1.2.3.4 ENOTFOUND
Windows: dns.lookup of each of these, in Bun and in Node.js v26.3.0 ENOTFOUND ENOTFOUND
Linux: Bun.connect to 127.1, 0x7f000001, 2130706433 connects to 127.0.0.1 the same
Linux: Bun.connect to 127.0.0.1 db.allowed.example, "127.0.0.1\n", and the 7 other host names of #39931 connects to 127.0.0.1 ("12\t7.0.0.1": dials 0.0.0.12) ENOTFOUND, with no lookup
Linux: Bun.udpSocket send() to 127.0.0.1 rebound.example, or to 127.0.0.1 and a NUL and more text delivered to 127.0.0.1 throws Invalid address

The dev server Host check

Host header main this branch
127.0.0.1, [::1], localhost 200 200
127.1:PORT, 0x7f000001, 127.000.000.001 (raw, wget 1.25.0, Python 3.13.5 urllib) 200 200. On Windows 403, where no resolver reads them
[::1/64], [::ffff:127.1], 1.2.3.4/8, 08.1.1.1 200 403
2130706433, 0.0.0.0x1 (inet_aton reads them, c-ares did not) 403 200
127.0.0.1 rebound-host.example (inet_aton stops at the space) 403 403
rebound-host.example 403 403

An earlier commit of this branch made the check strict, which answered 403 to wget and Python at http://127.1:PORT. That stopped no DNS rebinding request: a URL parser writes a dotted quad or rejects the host, so a browser cannot send these forms. The check now takes an IP literal, or a host that starts with a digit and that to_ip_address reads as IPv4. Over 25,792 Host values, 1,924 go from allowed to refused and 539 from refused to allowed. Each of the 539 has a number as its last label, so none is a DNS name. #40946 reports that the check cannot be configured, and #40733 adds development.allowedHosts.

Measurements. Release builds on Linux, one tree, the two source files from main against this branch.

  • Size (size -A, nm -S): .text 58,301,116 to 58,300,092 bytes. File size 80,995,912 bytes in both. HTTPClient::on_open::<true> 1,684 to 1,474 bytes, check_x509_server_identity 1,481 to 1,336, parse_strict 447 to 303, to_ip_address 477 to 724, is_allowed_host_header 579 to 468.
  • Instructions per call (gdb stepi from entry to return, callees included, 3 equal runs per input):
Input is_allowed_host_header to_ip_address
registry.npmjs.org 596 to 548 367 to 345
localhost / example.com 592 to 533 (example.com) 386 to 352 / 441 to 409
127.0.0.1 617 to 445 1,436 to 1,113
::1 ([::1] as a Host) 1,055 to 959 424 to 366
a 51-byte name 792 to 465 435 to 177
a 253-byte name 724 to 524 347 to 216
127.1 471 to 1,391 1,048 to 813
0x7f000001 722 to 1,269
::1/64 (verdict changes) 520 to 538
  • Variants that I measured and dropped: parse_strict inlined by LLVM (.text +1,024 bytes against main), parse_strict for the IPv6 arm too (to_ip_address("localhost") 386 to 404), the Host check with to_ip_address for every host (registry.npmjs.org 596 to 895), and a check of the bytes in the Host check in place of the change in to_ip_address.
  • Stack (objdump): is_allowed_host_header sub $0x228,%rsp to sub $0x18,%rsp, on_open::<true> 0x278 to 0x68. Calls into c-ares per check 2 to 0. No allocator call before or after.
  • Classification by is_ip_address, through SNI on the real binaries: 141 server names, 19 go from address to name, 0 go the other way. The result equals net.isIP without a zone on every name.
  • SNI over fetch, a CONNECT tunnel and wss+unix:: 28 of 28 shorthand cells change and equal tls.connect. 0 of 8 literal cells change.
  • DNS prefetch calls per bun install (gdb breakpoint count): localhost 1 to 1, 127.1 0 to 0, 127.0.0.1 0 to 0, 08.1.1.1 0 to 1.
  • bun run rust:check-all: 12 of 12 targets, 0 errors.
  • Not measured: instructions per TLS handshake. perf, valgrind and strace are not on this machine.

Tests. On Linux with src/ from main each of the 8 files fails, and only new rows fail in them (58). With this branch they pass: fetch.tls.test.ts 56, fetch.tls.wildcard.test.ts 158, proxy.test.ts 96, websocket-unix.test.ts 8, test/bake/dev/esm.test.ts 18, socket-dns-error.test.ts 29, udp_socket.test.ts 230, tls-reject-before-client-cert.test.ts 121. On Windows with this branch the same files pass (websocket-unix.test.ts is skipped there), and on the canary of main 10 of the 12 UDP rows fail. The 7 rows for #39931 came after the Windows run. No new row needs a resolver: a row that expects ENOTFOUND has a byte that is_valid_hostname refuses, and the rows for /bits and for the Windows shorthand are UDP rows, where send() throws at once. Same failure set on both Linux builds for resolve-dns.test.ts, node-dns.test.js, socket.test.ts, serve.test.ts and node-dgram.test.js (they need the network or a user that is not root). The tarball case has no test: the download uses the HTTP client path that the fetch rows pin.

Other open PRs

Not in this PR


no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/websocket/websocket-unix.test.ts, test/js/web/fetch/fetch.tls.test.ts, test/js/bun/udp/udp_socket.test.ts, test/js/bun/net/tls-reject-before-client-cert.test.ts, test/js/bun/net/socket-dns-error.test.ts, test/js/bun/http/proxy.test.ts, test/bake/dev/esm.test.ts

…_ip_address

These three asked c-ares ares_inet_pton, which is inet_net_pton
underneath. It also reads "127.1" (as 127.1.0.0), "10", "0x7f000001",
zero-padded octets and a trailing "/bits" as an address, and it stops at
a NUL. For an IPv6 address with "/bits" it writes only the prefix bytes,
so "::1/64" and "2001:db8::1/0" both came out as "::".

is_ip_address and is_ipv6_address now ask parse_strict, the core::net
parser: a dotted quad or an IPv6 address and nothing else. to_ip_address
asks it for IPv6 and keeps inet_aton for the IPv4 shorthand that the
resolver reads. parse_strict returns early for an input above 45 bytes,
the longest text of an address, and stays out of line. c-ares is left
only as the inet_aton of Windows.
@robobun

robobun commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. CI is running.

How to reproduce on main (no network needed)

// An IPv6 text with a prefix length is dialed as "::", and text after a space is ignored.
const v6 = Bun.listen({ hostname: "::1", port: 0, socket: { open: s => void s.end(), data() {} } });
const v4 = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { open: s => void s.end(), data() {} } });
for (const [hostname, port] of [["::1/64", v6.port], ["2001:db8::1/0", v6.port], ["127.0.0.1 db.example", v4.port]]) {
  const result = await Bun.connect({ hostname, port, socket: { open: s => void s.end(), data() {} } }).then(
    () => "connected",
    e => e.code,
  );
  console.log(JSON.stringify(hostname), result);
}
v6.stop(true);
v4.stop(true);
::1/64 2001:db8::1/0 127.0.0.1 db.example
main (Linux) connected connected connected
this branch ENOTFOUND ENOTFOUND ENOTFOUND
Node.js v26.3.0 (net.connect) ENOTFOUND ENOTFOUND ENOTFOUND

On Windows main also dials 127.1.0.0 for 127.1. The Windows resolver answers ENOTFOUND for it, and so does this branch.

The TLS side: fetch(url, { tls: { ca, serverName: "0x7f000001" } }) against a certificate for IP:127.0.0.1 verifies on main and sends no SNI. On this branch it fails with ERR_TLS_CERT_ALTNAME_INVALID, as tls.checkServerIdentity("0x7f000001", cert) does on both.

Proof in the tree. With src/ from main, each of the 8 changed test files fails on Linux, and only new rows fail (58). With this branch they pass on Linux. They also passed on Windows, before the 7 rows for #39931 were added:

bun bd test test/js/bun/net/socket-dns-error.test.ts
bun bd test test/js/bun/udp/udp_socket.test.ts
bun bd test test/js/web/fetch/fetch.tls.test.ts
bun bd test test/js/web/fetch/fetch.tls.wildcard.test.ts
bun bd test test/js/bun/net/tls-reject-before-client-cert.test.ts
bun bd test test/js/bun/http/proxy.test.ts
bun bd test test/js/web/websocket/websocket-unix.test.ts
bun bd test test/bake/dev/esm.test.ts

Comment thread src/bun_core/ip_address.rs Outdated
@coderabbitai

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: e7209f15-c35e-4ba4-96c5-9bc2a9cae4b1

📥 Commits

Reviewing files that changed from the base of the PR and between fd007ac and e02246b.

📒 Files selected for processing (1)
  • test/js/bun/net/socket-dns-error.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 5 remain after this review.


Walkthrough

IP address classification and conversion now use strict parsing in several paths, while non-Windows IPv4 conversion retains inet_aton shorthand support. Tests cover HTTP Host validation, TLS SNI and certificate matching, DNS resolution, and UDP address validation.

Changes

Strict IP Address Recognition

Layer / File(s) Summary
Strict IP parsing and HTTP host handling
src/bun_core/ip_address.rs, src/runtime/bake/DevServer.rs, test/bake/dev/esm.test.ts
IP classification uses strict Rust parsing. to_ip_address parses the portion before an IPv6 zone suffix. Development-server Host handling also accepts selected IPv4 shorthand forms. Tests cover standard, alternate, and rejected forms.
TLS SNI and hostname verification
test/js/bun/net/tls-reject-before-client-cert.test.ts, test/js/web/fetch/fetch.tls.wildcard.test.ts, test/js/web/fetch/fetch.tls.test.ts, test/js/bun/http/proxy.test.ts, test/js/web/websocket/websocket-unix.test.ts
Tests check SNI selection and certificate hostname matching for strict IP literals and alternate numeric forms across TLS client, fetch, proxy, and WebSocket connections.
DNS and UDP address validation
test/js/bun/net/socket-dns-error.test.ts, test/js/bun/udp/udp_socket.test.ts
Tests check that malformed address forms fail DNS resolution or UDP validation without reaching local listeners.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to e0224

Strict IP handling follows the stated scope: resolver shorthand remains platform-specific, while TLS treats non-strict forms as hostnames. No concrete blocker was established; normal checks should confirm the changed tests.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: strict parsing treats shorthand, hexadecimal, and prefixed IP forms as host names.
Description check ✅ Passed The description thoroughly explains the problem, implementation, behavioral changes, trade-offs, and verification results. It does not use the template headings exactly, but it provides the required c…

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

The commit that added the attribute gives the reason: inlined into its
ten callers the parser adds code at each of them.

@claude claude 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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the reordered to_ip_address (the strict v6 attempt now runs before the 512-byte guard, but parse_strict_v6 caps at 45 bytes, so no unbounded scan and empty input still returns None), the ::ffff:010.0.0.1 zero-padded mapped form (rejected by core::net, matching net.isIP), and the Windows-only ares_inet_pton declaration (its single caller sys::aton passes the NUL-terminated buffer, and the AF_INET = 2 value matches ws2def.h). A human look at the TLS/SNI and certificate-matching callers is still worthwhile given the intentional behavior change.

Extended reasoning...

The only source change is src/bun_core/ip_address.rs, which switches is_ip_address/is_ipv6_address and the IPv6 arm of to_ip_address from c-ares inet_net_pton to the core::net parser; the other eight files add tests. It touches security-sensitive surfaces: SNI selection, certificate identity matching in src/boringssl/lib.rs, and the Bake dev server Host-header check. Three confirmed findings are posted inline, so the change already needs a human; this note only records what else was examined and ruled out.

Comment thread src/bun_core/ip_address.rs
Comment thread src/bun_core/ip_address.rs Outdated
Comment thread src/bun_core/ip_address.rs Outdated

@claude claude 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.

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

Still open from earlier reviews (3):

  • Unresolved: 3 minor or pre-existing.

…nd in the dev server Host check

On Windows to_ip_address asked c-ares for IPv4, which reads "127.1" as
127.1.0.0 and takes a trailing "/bits". The Windows resolver reads a
dotted quad only, so Bun.connect dialed hosts that dns.lookup refuses.
The Windows arm is now the strict parse, and c-ares is gone from the
module.

The dev server Host check accepts what the resolver reads as a number:
an IP literal, or the inet_aton shorthand made of digits, dots, hex
letters and x. So wget and Python at http://127.1:PORT keep working, and
a host with a name after whitespace stays refused.
Comment thread src/runtime/bake/DevServer.rs Outdated
Comment thread test/js/bun/net/socket-dns-error.test.ts Outdated
inet_aton stops at whitespace or a NUL and reads what comes before, so
to_ip_address took "127.0.0.1 db.example" for 127.0.0.1. It now takes a
host only when inet_aton read all of it. The dev server Host check asks
to_ip_address and needs no check of its own.

The new rows that expect ENOTFOUND are decided without a lookup. The rows
that need the resolver of the platform moved to the UDP tests, where
send() asks to_ip_address and throws at once.

@claude claude 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.

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

Each host name has a byte that isspace() takes. On main inet_aton reads
the IPv4 address before that byte, and Bun.connect dials it. The rows
also check the syscall and the hostname of the error.

@claude claude 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.

I reviewed the latest push (the last three commits) and found no bugs; the points raised in earlier passes are addressed in the code. Because this changes TLS identity matching, SNI selection and the dev server's DNS-rebinding Host check, and has platform-gated behavior, a human look is still worthwhile before merging.

What was reviewed:

  • to_ip_address IPv4 arm: the POSIX whitespace/NUL rejection after inet_aton and the Windows parse_strict fallback now give the same verdict as is_ip_address for the whitespace inputs the earlier thread named; the %zone on the Windows fallback is harmless since no IPv4 text contains %.
  • socket-dns-error.test.ts ENOTFOUND rows: every row is now rejected locally by is_valid_hostname (whitespace, :, [ are not hostname bytes), so none reaches the system resolver; the /bits IPv4 rows moved to the UDP test where send() fails synchronously.
  • is_allowed_host_header: host_without_port runs first, so 127.1:3000 reaches the to_ip_address branch as 127.1; the leading-digit guard keeps names from ever hitting inet_aton.
  • Cfg parity: Ipv4Addr import and sys module are gated together with the POSIX arm, and both arms return Option<IpAddr>, so the Windows branch should type-check.
Extended reasoning...

The change replaces c-ares ares_inet_pton with core::net's strict parser in src/bun_core/ip_address.rs for is_ip_address, is_ipv6_address and the v6 arm of to_ip_address, keeps BSD inet_aton for the IPv4 arm on POSIX with a whitespace/NUL rejection, uses strict parsing on Windows, and widens is_allowed_host_header in src/runtime/bake/DevServer.rs to accept resolver shorthand; eight test files gain rows. It touches security-sensitive surface: TLS certificate identity matching and SNI selection (via is_ip_address callers in http/proxy/websocket) and the dev server DNS-rebinding Host check. The commits since the last review addressed the earlier inline threads (DNS-dependent test rows, Windows IPv4 parity, whitespace consistency), and the bug hunt ran dry with no findings. Not approved outright because the change is intentionally breaking for TLS hostname verification of hex/zero-padded//bits hosts and has platform-divergent behavior that maintainers should weigh.

@Jarred-Sumner
Jarred-Sumner merged commit 0d73249 into main Sep 25, 2026
10 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/aaa5140b/strict-is-ip-address branch September 25, 2026 21:45
Jarred-Sumner pushed a commit that referenced this pull request Sep 25, 2026
… IP host (#42766)

### Problem

- `new Bun.SQL("postgres://u@[::1]:5432/db?sslmode=verify-full")`
rejects a certificate that has `IP:::1` with
`ERR_TLS_CERT_ALTNAME_INVALID`. `mysql://` does the same.
- `parseOptions` copies `URL.hostname`, brackets kept, into
`tls.serverName` (`src/js/internal/sql/shared.ts:2159`). Both adapters
check the certificate against that text, and `"[::1]"` is not an IP
address.
- Both adapters send an IP literal as SNI (`127.0.0.1`). RFC 6066
section 3 forbids that.

### Fix

- `SSLConfig::server_name_bytes()` (`src/sql_jsc/jsc.rs`) returns the
name through `bun_core::ip_address::strip_ipv6_brackets`. The identity
checks of both adapters read the name there.
- New `SSLConfig::sni()` returns no name for an IP literal, bracketed or
not. Both `adopt_tls` sites use it. libpq and fetch send none either.
- Verified: `test/js/sql/sql-tls-ip-literal-host.test.ts` (new, 8
cases). 6 fail on main 0d73249, all pass here.

### Background

- `sslmode=verify-full` checks that the certificate names the host. An
IP host matches only an IP SAN entry, and only as a bare address
(`check_x509_server_identity`, `src/boringssl/lib.rs:452`).
- SNI is the host name a TLS client sends in its first message.
`adopt_tls` sets it.
- Considered a strip in `parseOptions` (the first version). That was a
second copy of `strip_ipv6_brackets`, which fetch, RedisClient and
Bun.connect use, and it changed `sql.options`.

### Downsides

- Bun.SQL sends no SNI for an IP-literal name. A TLS proxy that routes
on such a value loses it.
- A zone-scoped address (`hostname: "::1%lo"`) still fails
`verify-full`, and without brackets it still goes out as SNI:
`is_ip_address` accepts no `%zone`.
- Cost per TLS connection: one `is_ip_address` call (at most 45 bytes,
no allocation) and a bracket check at each name read.

<details><summary>Notes</summary>

- History of this PR. The first version removed the brackets in
`parseOptions` (JS). Main then gained
`bun_core::ip_address::strip_ipv6_brackets` and moved every other client
to it. A review noted that the JS helper disagreed with it (`[db]` lost
its brackets too). aa4d699 moves the fix to the two native accessors
and restores `parseOptions` to its state on main. So this PR no longer
touches `src/js/internal/sql/shared.ts`, and it does not conflict with
#42054 or #41761, which edit that block.
- `sql.options.tls.serverName` and `sql.options.hostname` keep the
brackets, as on main. Only the native reads see the bare address.
- When this PR opened (canary 09bb546), Bun.SQL also accepted a
certificate whose only SAN is `DNS:[::1]`, and a hostname mismatch gave
an `Error` with an empty `code` and `message`. Main changed both since:
#43873 makes a name that is not a hostname match no certificate name,
and #43694 rejects the mismatch inside the handshake with
`ERR_TLS_CERT_ALTNAME_INVALID`. This PR changes neither.
- Probed on the debug build with a mock TLS server on 127.0.0.1 and an
explicit `tls.serverName`. `"[::1]"` and `"::1"` under `verify-full`:
connects, no SNI. `"[db]"` under `verify-full`:
`ERR_TLS_CERT_ALTNAME_INVALID`, as on main. `"[fe80::1%lo]"` under
`require`: no SNI. `"fe80::1%lo"` and `"127.1"` under `require`: sent as
SNI, because `is_ip_address` takes neither as an IP literal (#43979).
`"localhost"`: sent as SNI.
- Observed on main 0d73249: peer SNI `127.0.0.1` for host
`127.0.0.1`. With this change: none.
- The cases that dial `[::1]` gate on `isIPv6()` (Buildkite Linux has no
IPv6 loopback). The other cases run on every lane. One of them dials
127.0.0.1 with `tls.serverName: "[::1]"`, so every lane checks a
bracketed name against the `IP:::1` entry.
- Two gaps in the same `parseOptions` block are on main and stay out of
this PR: `tls.servername` (the Node spelling) is ignored, which #42054
owns, and `tls: true` without an sslmode derives no `serverName`, which
#26369 tracks. A `BunFile` given as `tls` with an explicit sslmode loses
the file there, which #41761 owns.
- Same bug class as #30668, which #30674 fixed for fetch and WebSocket.
The dial path already removes the brackets
(`src/uws_sys/socket.rs:791`).
- Other suites run on the merged debug build (main 601af5a):
`postgres-pgsslmode-env`, `sql-mysql-tls-plaintext-injection`, all of
`adapter-env-var-precedence`, and
`test/js/bun/net/tls-reject-before-client-cert.test.ts` (121 pass, 9
skip). I ran the new file 40 times on the earlier head: 320 of 320 cases
pass. The container TLS suites (`tls-sql`, `local-sql`) need Docker and
run in CI.

</details>

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/sql/adapter-env-var-precedence.test.ts

<!-- robobun:evidence:end -->
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.

2 participants