Skip to content

dns: reject '/' '*' and other non-LDH chars in dns.lookup hostname#33990

Open
robobun wants to merge 6 commits into
mainfrom
farm/55c2d865/dns-lookup-reject-invalid-hostname-chars
Open

dns: reject '/' '*' and other non-LDH chars in dns.lookup hostname#33990
robobun wants to merge 6 commits into
mainfrom
farm/55c2d865/dns-lookup-reject-invalid-hostname-chars

Conversation

@robobun

@robobun robobun commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

What

dns.lookup (and everything on top of it: net.connect, the node:http/https client) now rejects hostnames containing /, *, or any other ASCII byte outside [A-Za-z0-9._-] locally with ENOTFOUND, before any packet is built. This matches Node.js on glibc.

Why

c-ares validates lookup names against its record-name charset [A-Za-z0-9-*._/] (it keeps / for RFC 2317 reverse-delegation CNAMEs and * for wildcard CNAMEs). That charset is correct for record values but too lax for the query side: a host/path or a*b string passed to ares_getaddrinfo becomes a real QNAME on the wire. glibc getaddrinfo refuses these with EAI_NONAME before building any packet (the LDH rule plus _), which is what Node.js relies on.

Before this change, on Linux (where c-ares is the dns.lookup backend):

import dns from "node:dns"; import dgram from "node:dgram"; import http from "node:http";
const qnames = [];
const stub = dgram.createSocket("udp4");
stub.on("message", (m, r) => { let i = 12, ls = []; while (m[i]) { ls.push(m.slice(i+1, i+1+m[i]).toString("binary")); i += m[i]+1; }
  qnames.push(ls.join("."));
  stub.send(Buffer.concat([m.slice(0,2), Buffer.from([0x81,0x80]), m.slice(4,6),
    Buffer.from([0,1,0,0,0,0]), m.slice(12, i+5),
    Buffer.from([0xc0,12, 0,1, 0,1, 0,0,0,60, 0,4, 127,0,0,1])]), r.port, r.address); });
await new Promise(r => stub.bind(0, "127.0.0.1", r));
dns.setServers([`127.0.0.1:${stub.address().port}`]);
const origin = http.createServer((rq, rs) => { console.log("origin saw Host =", rq.headers.host); rs.end("x"); });
await new Promise(r => origin.listen(0, "127.0.0.1", r));
await new Promise(r => dns.lookup("leak-a.invalid/x", () => r()));
await new Promise(r => dns.lookup("leak-b*c.invalid", () => r()));
http.get({ host: "h.invalid/smug", port: origin.address().port, path: "/pwned" }, rs => rs.resume());
setTimeout(() => { console.log("DNS queries:", qnames); process.exit(0); }, 500);

Bun 1.4.0:

origin saw Host = h.invalid/smug:35887
DNS queries: [ "leak-a.invalid/x", "leak-a.invalid/x", "leak-b*c.invalid", ..., "h.invalid/smug", ... ]

Node.js v26:

Error: getaddrinfo ENOTFOUND h.invalid/smug

(no packets sent)

A mis-split evil.tld/path URL string reaching dns.lookup used to flow attacker-controlled bytes into resolver logs, and under a wildcard zone would resolve, after which Bun connects and sends the application's headers with Host: evil.tld/path:PORT (not a valid RFC 9112 uri-host, so downstream routers/caches can parse it divergently).

How

Resolver::do_lookup already rejects overlong and NUL-containing names; extend that guard with bun_dns::is_valid_lookup_name, which accepts [A-Za-z0-9._-] plus any non-ASCII byte (IDN passes through unchanged, as in glibc). IPv6 literals contain : so are exempted via strings::is_ip_address. The check runs before backend dispatch, so it applies to the c-ares, libc, libinfo, and libuv paths alike.

dns.resolve* is left alone: Node.js routes those through c-ares and accepts / and * there as well.

Verification

$ USE_SYSTEM_BUN=1 bun test test/js/node/dns/dns-lookup-invalid-hostname.test.ts
(fail)  "leak-a.invalid/x": null, "leak-b*c.invalid": null   # resolved, packets leaked

$ bun bd test test/js/node/dns/dns-lookup-invalid-hostname.test.ts
(pass)  all 7 invalid names → ENOTFOUND, stub sees only "ok_name.invalid", originHits = 0

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/node/dns/dns-lookup-invalid-hostname.test.ts

c-ares validates lookup names against its record-name charset
[A-Za-z0-9-*._/] (it keeps '/' for RFC 2317 CNAME targets and '*' for
wildcard CNAMEs), so ares_getaddrinfo happily turns 'evil.tld/path' or
'a*b.tld' into a real wire QNAME. Node.js on glibc refuses these locally
with EAI_NONAME because glibc getaddrinfo only accepts [A-Za-z0-9._-].
The gap meant Bun's dns.lookup (and net.connect / http.get on top of it)
would resolve a DNS-illegal hostname, connect to whatever it returned,
and emit the raw string as the Host header.

Add a query-side charset guard in Resolver::do_lookup that rejects any
ASCII byte outside [A-Za-z0-9._-] with ENOTFOUND before the name reaches
a backend. Non-ASCII bytes pass through (IDN), and IPv6 literals are
exempted via is_ip_address since they contain ':'.

dns.resolve* is unchanged: Node.js routes those through c-ares too and
accepts '/' and '*' there.
@robobun

robobun commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix verified locally, all review threads resolved.

Fail-before (USE_SYSTEM_BUN=1 bun test test/js/node/dns/dns-lookup-invalid-hostname.test.ts): / and * hostnames resolve and reach the stub resolver + origin server.
Fail-after (bun bd test ...): all invalid names return ENOTFOUND, zero packets leave the process; _, IP literals, and scoped IPv6 still resolve.

CI: the diff is green on everything it touches. Build 71981's remaining red lane is test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js (JSC exception-scope assertion on macOS x64-asan), which is also failing on unrelated branches (builds 71980, 71977, 71972) and is being handled separately. Ready for review.

@robobun

robobun commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:09 PM PT - Jul 11th, 2026

@robobun, your commit ea29847 has 3 failures in Build #71981 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33990

That installs a local version of the PR into your bun-33990 executable, so you can run:

bun-33990 --bun

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

DNS lookup now validates ASCII hostname characters before resolution while allowing underscores and IP literals. Linux-only tests verify local rejection, absence of DNS and HTTP leakage, valid control resolutions, and subprocess results.

Changes

DNS lookup validation

Layer / File(s) Summary
Hostname validation and resolver integration
src/dns/lib.rs, src/runtime/dns_jsc/dns.rs
Adds byte-level lookup-name validation and applies it in Resolver::do_lookup, while preserving IP-address handling and the existing ENOTFOUND rejection path.
DNS and HTTP fixture behavior
test/js/node/dns/dns-lookup-invalid-hostname-fixture.ts
Adds a UDP DNS stub, HTTP origin tracking, invalid and control lookups, scoped IPv6 coverage, and JSON result reporting.
End-to-end validation assertions
test/js/node/dns/dns-lookup-invalid-hostname.test.ts
Adds Linux-only subprocess assertions for invalid hostname errors, valid resolutions, queried names, origin hits, and exit status.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: rejecting non-LDH hostname characters in dns.lookup.
Description check ✅ Passed The description covers what the PR does and how it was verified, though the headings differ from the template.
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.

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

@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/node/dns/dns-lookup-invalid-hostname.test.ts`:
- Around line 45-52: Extend the DNS invalid-hostname fixture with a non-ASCII
IDN hostname that should remain accepted, then update the assertions around the
test’s query results to verify that hostname alongside the existing valid
underscore and numeric-literal cases. Preserve the current rejection coverage
and ensure the expected unique query names include the non-ASCII case.
🪄 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: 8f4441c1-594a-42c1-99b1-05324317721d

📥 Commits

Reviewing files that changed from the base of the PR and between 588da0e and ecc2540.

📒 Files selected for processing (4)
  • src/dns/lib.rs
  • src/runtime/dns_jsc/dns.rs
  • test/js/node/dns/dns-lookup-invalid-hostname-fixture.ts
  • test/js/node/dns/dns-lookup-invalid-hostname.test.ts

Comment thread test/js/node/dns/dns-lookup-invalid-hostname.test.ts Outdated
Comment thread test/js/node/dns/dns-lookup-invalid-hostname.test.ts Outdated
Asserting stderr is exactly empty before JSON.parse(stdout) hides the
actual DNS result when a debug build emits a benign diagnostic. Check
stderr together with exitCode after the semantic assertions so a failure
diff shows both.

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/js/node/dns/dns-lookup-invalid-hostname.test.ts (1)

1-9: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep the rationale comment within the three-line limit.

This nine-line block violates the repository rule that code comments must be concise and no longer than three lines. Keep only the invariant being tested here; move the platform/backend explanation to the PR description or documentation.

🤖 Prompt for 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.

In `@test/js/node/dns/dns-lookup-invalid-hostname.test.ts` around lines 1 - 9,
Shorten the introductory comment above the dns.lookup invalid-hostname test to
at most three lines, retaining only the invariant that ASCII characters outside
[A-Za-z0-9._-] must be rejected locally with ENOTFOUND. Remove the subprocess,
platform, backend, and c-ares implementation rationale from this code comment.

Source: Coding guidelines

🤖 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/node/dns/dns-lookup-invalid-hostname.test.ts`:
- Around line 24-26: Update the subprocess handling around Promise.all so the
test asserts a successful exit using exitCode and includes stderr diagnostics
before calling JSON.parse on stdout. Keep the JSON parsing and subsequent
semantic assertions after this subprocess-result check.

---

Outside diff comments:
In `@test/js/node/dns/dns-lookup-invalid-hostname.test.ts`:
- Around line 1-9: Shorten the introductory comment above the dns.lookup
invalid-hostname test to at most three lines, retaining only the invariant that
ASCII characters outside [A-Za-z0-9._-] must be rejected locally with ENOTFOUND.
Remove the subprocess, platform, backend, and c-ares implementation rationale
from this code comment.
🪄 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: 0a03e00b-b3e9-46a3-9030-946502fa08fe

📥 Commits

Reviewing files that changed from the base of the PR and between ecc2540 and 792188d.

📒 Files selected for processing (2)
  • src/dns/lib.rs
  • test/js/node/dns/dns-lookup-invalid-hostname.test.ts

Comment thread test/js/node/dns/dns-lookup-invalid-hostname.test.ts
Comment thread src/runtime/dns_jsc/dns.rs
ares_inet_pton does not parse the RFC 4007 zone suffix, so
is_ip_address("fe80::1%lo") is false and the new guard rejected it
before the system/libc backend (which does parse the zone) could see it.
Strip a leading %zone before the IP-literal check. node:dns paths were
already unaffected (isIP() short-circuits on %zone in JS); this restores
direct Bun.dns.lookup on the non-c-ares backends.
Comment thread test/js/node/dns/dns-lookup-invalid-hostname.test.ts
Comment thread test/js/node/dns/dns-lookup-invalid-hostname.test.ts Outdated
The v4/v6 cases go through node:dns which short-circuits numeric literals
via isIP() before reaching Resolver::do_lookup, so they did not exercise
the is_ip_address exemption in the guard. Add a Bun.dns.lookup('::1')
case that does.

Use a trailing dot on the underscore control name so c-ares skips
resolv.conf search expansion regardless of the host's ndots setting; the
wire QNAME and the assertion are unchanged.

@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 `@src/runtime/dns_jsc/dns.rs`:
- Around line 5206-5214: In the validation condition surrounding the `%` scan,
replace the iterator-based position lookup with strings::index_of(name, b"%").
Preserve the existing split-at-index behavior and fallback to the full name when
no percent sign is found.
🪄 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: 9cfeae96-6499-41a3-b837-6621fa304956

📥 Commits

Reviewing files that changed from the base of the PR and between a98ef51 and ea29847.

📒 Files selected for processing (3)
  • src/runtime/dns_jsc/dns.rs
  • test/js/node/dns/dns-lookup-invalid-hostname-fixture.ts
  • test/js/node/dns/dns-lookup-invalid-hostname.test.ts

Comment thread src/runtime/dns_jsc/dns.rs

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

No issues found in this revision — the scoped-IPv6 exemption, native IP-literal coverage, and ndots hermeticity from earlier rounds are all addressed. Deferring to a human because this tightens hostname validation on the dns.lookup path that node:net/node:http route through; it's a small change but a user-visible security-boundary shift worth a maintainer's sign-off.

Checked: is_valid_lookup_name against glibc's LDH+_ rule vs. c-ares's record-name charset; the %zone-strip → is_ip_address exemption for plain and scoped IPv6 (and that non-IP prefixes like evil.com%lo are still rejected); empty-name and %-only inputs fall through to the existing handling; dns.resolve* and the native fetch/Bun__addrinfo_* DNS path are untouched.

Extended reasoning...

Overview

Adds bun_dns::is_valid_lookup_name (accepts [A-Za-z0-9._-] + non-ASCII bytes) in src/dns/lib.rs and wires it into the existing length/NUL rejection guard in Resolver::do_lookup (src/runtime/dns_jsc/dns.rs), with an IP-literal exemption that strips a trailing RFC 4007 %zone before calling strings::is_ip_address. Two new test files under test/js/node/dns/ spin up a UDP DNS stub and an HTTP origin in a subprocess to prove invalid names are rejected before any packet leaves and that underscore names, IP literals, and scoped IPv6 still resolve.

Security risks

The change is security-positive in intent: it stops attacker-influenced /, *, space, etc. from becoming wire QNAMEs or Host: header values via mis-split URL strings, aligning Bun's c-ares path with glibc getaddrinfo's EAI_NONAME behavior that Node.js relies on. The risk surface is the tightening itself — any legitimate hostname pattern outside the accepted set would newly fail. I verified the predicate matches glibc's documented LDH+underscore rule, non-ASCII bytes pass through (so IDN handling is unchanged), IP literals are exempted including scoped IPv6, and dns.resolve* (where / and * are legitimate for RFC 2317 / wildcard records) is left alone. No injection, auth, or data-exposure risk introduced.

Level of scrutiny

High. Resolver::do_lookup sits under Bun.dns.lookup, node:dns lookup, net.connect, and the node:http/https client — every JS-initiated hostname resolution on the Node-compat surface flows through this guard. It does not touch the native fetch() / bun install DNS path (those go through Bun__addrinfo_*, not do_lookup), which bounds the blast radius, but it's still a behavior change on a critical, security-adjacent boundary. The IP-literal exemption logic already had one regression (scoped IPv6) caught and fixed during review, which reinforces that the edge cases here are subtle enough to merit a maintainer's eyes.

Other factors

Three prior review rounds (mine and CodeRabbit's) surfaced test-hygiene and correctness edge cases; the author addressed every one with clear rationale and follow-up commits. The test is hermetic (local UDP stub via dns.setServers, rooted control name to defeat ndots search expansion), Linux-gated where c-ares is the backend, exercises both the node:dns wrapper and the native Bun.dns.lookup path so each clause of the guard is load-bearing, and was verified fail-before/pass-after. The Rust change is ~20 lines and follows the local pattern (std slice methods, matching the pre-existing name.contains(&0) on the adjacent line). This is in good shape — I'm deferring on policy grounds (security-sensitive input validation, user-visible behavior change), not because of any open concern with the code.

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.

1 participant