node:dns: match Node.js argument validation in lookup()

Closed
RO
2mo ago
+261-55

dns.lookup()'s argument validation diverges from Node.js in three ways, all of which let a caller bug through quietly.

Repro

import dns from "node:dns";
const show = async fn => {
  try {
    return "resolved: " + JSON.stringify(await fn());
  } catch (e) {
    return `${e.name}[${e.code}] ${e.message.slice(0, 80)}`;
  }
};

console.log("1.", await show(() => dns.promises.lookup("localhost", { family: "IPv4" })));
console.log("2.", await show(() => dns.promises.lookup("localhost", { all: 1 })));
console.log("3.", await show(() => dns.promises.lookup("l".repeat(300))));
Node.js v26.3.0Bun 1.4.0 / main
1TypeError[ERR_INVALID_ARG_VALUE] The property 'options.family' must be one of: 0, 4, 6. Received 'IPv4'resolved: {"address":"127.0.0.1","family":4}
2TypeError[ERR_INVALID_ARG_TYPE] The "options.all" property must be of type boolean. Received type number (1)TypeError[ERR_INVALID_ARG_TYPE] The "undefined" argument must be of type boolean. Received type number (1)
3Error[EINVAL] getaddrinfo EINVAL llll...DNSException[ENOTFOUND] getaddrinfo ENOTFOUND
  1. A string family that Node.js hard-rejects resolves successfully, so a config parser that relies on the throw to reject a bad dns.lookup option object silently proceeds.
  2. The "undefined" argument must be... names no argument at all, and does not match what Node.js prints.
  3. An impossible (over-long) hostname gets the same code as a legitimately missing one, so retry/fallback logic treats a caller bug as a transient resolution miss.

Cause

  1. validateFamilyOption() mapped "IPv4"/"IPv6" to 4/6 before validating. That mapping belongs to Bun.dns.lookup, and to Node.js's callback dns.lookup (which does accept those two spellings), but dns.promises.lookup rejects them. Bun shared one validator between both entry points.
  2. validateBoolean(all) / validateBoolean(verbatim) / validateNumber(flags) were called without their name argument, so the message interpolated undefined.
  3. Nothing checked the hostname length. getaddrinfo encodes the name into a 256 byte buffer (IDNA ASCII + NUL), so Node.js fails a longer one with EINVAL before any query; Bun handed it to the resolver and surfaced its ENOTFOUND.

Fix

In src/js/node/dns.ts:

  • validateFamilyOption() takes an allowFamilyStrings flag: the callback lookup() passes true, promises.lookup() passes false. Everything else still goes through validateFamily(), which now throws Node's must be one of: 0, 4, 6.
  • validateBoolean/validateNumber/validateOneOf get "options.all", "options.verbatim", "options.hints", "options.order" (and "dnsOrder" for setDefaultResultOrder).
  • An ASCII hostname over 255 characters short-circuits to getaddrinfo EINVAL <hostname> (code: "EINVAL", errno: -22), delivered on the next tick for the callback form and as a rejection for the promise form, exactly like Node's dnsException(UV_EINVAL, ...). A 255 character hostname still reaches the resolver. The guard is ASCII-only on purpose: an ASCII hostname is its own IDNA encoding, whereas "𝕏".repeat(128) has a .length of 256 but encodes to 135 bytes, which Node resolves normally. Non-ASCII hostnames keep today's behavior rather than paying for a punycode conversion on every lookup.
  • A null option now means unset, as in Node.js. { all: null }, { verbatim: null } and { hints: null } used to throw.
  • dns.lookup no longer carries the promisify.custom symbol and gets customPromisifyArgs = ["address", "family"], like Node. Otherwise util.promisify(dns.lookup) (which Bun aliased straight to dnsPromises.lookup) would have inherited the stricter family validation, where Node promisifies the callback implementation and still accepts "IPv4".

Two supporting changes:

  • src/jsc/bindings/ErrorCode.cpp: the validateOneOf overload of ERR_INVALID_ARG_VALUE hardcoded The argument '...', while Node.js says property whenever the name contains a dot. That also fixes the message for options.serialization, options.microtaskMode, options.mode, options.execution, options.type and options.backpressure, so test-child-process-advanced-serialization.js now matches upstream's expectation instead of Bun's old wording.
  • src/js/internal/promisify.ts: build the customPromisifyArgs object only when the callback passed more than one value, as Node does, so util.promisify(dns.lookup)(host, { all: true }) stays an array. The other users (fs.read/readv/write/writev, crypto.generateKeyPair) always pass two values.

Bun.dns.lookup() is unchanged and still accepts family: "IPv4".

Verification

test/js/node/dns/dns-lookup-validation.test.ts (new, hermetic: hosts-file lookups and pure validation, no network) covers every case above plus the boundaries. 19 tests, 16 of which fail on the released binary. It lives beside node-dns.test.js rather than inside it because that file is network-bound.

Node's own suite still passes, with two assertions restored to upstream's text:

test-dns-lookup.js                          (options.family, options.hints, options.all, options.verbatim, options.order)
test-dns-set-default-order.js
test-dns-lookup-promises-options-deprecated.js
test-dns-default-order-{ipv4,ipv6,verbatim}.js
test-child-process-advanced-serialization.js
test-console-tty-colors.js
test-http-agent-scheduling.js
test-util-promisify.js, test-util-promisify-custom-names.mjs
test-fs-read-promises-optional-params.js, test-fs-readv-promisify.js
test-crypto-keygen-promisify.js

node-dns.test.js and test/js/bun/dns/resolve-dns.test.ts report the same pass/fail counts before and after (the failures are the network-bound tests, which this sandbox cannot run).

Out of scope, still different from Node.js: dns.promises.lookup's resolver errors are named DNSException and omit the hostname from the message (getaddrinfo ENOTFOUND vs Node's getaddrinfo ENOTFOUND example.com). That is error decoration in the promise path, not argument validation.