DNS Resolver
Validating stub resolver that composes b.network.dns transport (DoT / DoH / system) with b.safeDns parsing and a TTL-aware cache. Used by every framework consumer that walks the DNS: DKIM TXT lookup, MTA-STS verify, DANE TLSA fetch, BIMI / VMC discovery, SVCB / HTTPS discovery, RBL queries, AutoConfig / AutoDiscover endpoint resolution, future MX lookup at submission.
Stub-mode resolver — every query goes to the operator-configured upstream recursive resolver (default cloudflare-dns.com over DoH per b.network.dns.useDnsOverHttps()). DNSSEC validation is delegated to the upstream resolver and surfaced via the AD bit (RFC 4035 §3.2.3); per-call validate: true opt-in re-checks the AD bit per-query, refusing responses with AD=0. Full local RRSIG signature verification is deferred — it requires IANA root trust anchor distribution + management which has its own lifecycle (root KSK rollover, RFC 5011) that doesn't belong in a stub. An operator that needs local RRSIG verify points the resolver at their own validating recursive (Unbound / BIND9) and the AD bit surfaces here; alternatively, b.safeDns.parseResponse exposes the parsed DNSKEY + RRSIG + DS records for an operator-supplied verifier.
QNAME minimization (RFC 9156) is a recursive-resolver concern — the framework runs in stub mode so the operator's upstream recursive (Cloudflare / Google / Unbound) implements QNAME-min; our queries always carry the full QNAME and there's nothing to minimize. Documented so operators don't pass qnameMin: true and expect a behavior change.
## TTL cache + serve-stale (RFC 8767)
Every successful response caches by { name, type } keyed on the minimum TTL across the answer RRs (RFC 2181 §5.2 — RRset TTL is the minimum of the included RR TTLs). On expiry the entry is removed from the live cache; with serveStale: configured, expired entries are retained for that additional window and returned on upstream failure or malformed response (RFC 8767 — stale-bread-is-better-than-no-bread for resolver resiliency under DoS / authoritative outage). Returned entries carry { stale: true } so consumer code can decide whether to use the data or hard-fail. RFC 8767 §6 recommends a 7-day max stale window; we default to 6h.
## CNAME chain following (RFC 1912 §2.4)
followCnames(name, type) walks CNAME redirections until the target record arrives or the chain depth cap from b.safeDns trips. Each hop is its own resolver query; each hop's response parses through b.safeDns.parseResponse independently. Default cap = 8 (matches BIND9's canonical-name-translation cap). RFC 1912 §2.4 warns against long CNAME chains; the cap defends redirect-loop DoS regardless of upstream resolver behavior.
## CVE / threat-model coverage
The resolver layer's defenses (parser-level + cache-level — transport-level defenses live in b.network.dns):
- Cache-poisoning resilience: every parse routes through b.safeDns which caps response bytes, RR counts, name lengths, and pointer-chain depth — bounds the attacker's inflation surface for poisoning attempts (CVE-2008-1447 Kaminsky class; the random query ID + TLS-encrypted DoH transport defend transport-side, this layer defends parse- side). - CVE-2022-3204 (NRDelegationAttack): per-section RR caps in b.safeDns bound the authority + additional sections that back a malicious non-responsive delegation. - CVE-2023-50387 (KeyTrap) + CVE-2023-50868 (NSEC3-encloser): DNSKEY + RRSIG + NSEC3 record counts bounded at parse time; validators downstream don't see the inflated set. - CVE-2024-1737 (BIND9 large-RRset exhaustion): RR-count caps refuse responses with abnormally large RRsets per hostname. - CNAME redirect loops: safeDns.checkCnameChainDepth at every hop in followCnames; matches BIND9's operational cap of 8. - TTL pinning of poisoned entries: operator-configurable maxTtlMs ceiling (default 24h) caps any TTL the upstream returns; a 2^31-second TTL (RFC 2181 absolute max) can't persist past the ceiling.
## Why it exists
node:dns returns parsed values but doesn't bound any of the dimensions an attacker can inflate — RR count, CNAME depth, compression-pointer chain, TXT rdata length. The validating resolver routes every parse through b.safeDns and exposes one shape every framework consumer can compose, replacing the scattered node:dns reach-throughs across lib/mail-*.js, lib/mtla-sts*.js, lib/dane*.js, and future MX / BIMI / SVCB primitives. Audit + posture rides through the resolver instance.
b.network.dns.resolver.create(opts?) #
{
profile: "strict" | "balanced" | "permissive",
posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
maxTtlMs: number, // cap any TTL from upstream; default 24h
minTtlMs: number, // floor short-TTL records; default 60s
serveStale: number | false, // ms to retain expired entries; default 6h
timeoutMs: number, // per-query upstream deadline; default 10s
transport: { lookup(name, qtype) → Promise }, // operator override
audit: b.audit namespace,
}
Build a resolver instance with the given options. Returns an instance with .query(name, type, opts) → Promise<{ rrs, ttl, fromCache, stale, validated, response }> plus per-type shortcuts.
var resolver = b.network.dns.resolver.create({ profile: "strict" });
var r = await resolver.queryTxt("_dmarc.example.com");
console.log(r.rrs.map(function (rr) { return rr.decoded.join(""); }));
b.network.dns.resolver.followCnames(name, type, opts?) #
{
validate: boolean, // per-call: refuse if upstream AD=0
}
Walk CNAME redirections until the target record arrives or the chain depth cap from b.safeDns trips. Returns the same shape as query() plus chain: [name, name, ...] listing each hop.
var r = await resolver.followCnames("alias.example.com", "A");
console.log(r.chain, r.rrs.map(function (rr) { return rr.decoded; }));
b.network.dns.resolver.resolveTxt(qname, dnsLookup?, resolver?) #
Resolve TXT records for qname via the operator's dnsLookup(qname, "TXT") override when supplied, otherwise the framework's shared validating (DoH / DoT / system, DNSSEC-checked) resolver — normalized to the string[][] shape dnsPromises.resolveTxt returns. Throws an ENODATA Error when no TXT records exist.
This is the secure DNS path for every email-auth policy lookup (DMARC / DKIM / ARC / BIMI / MTA-STS / TLS-RPT). Plaintext system DNS (dnsPromises.resolveTxt) is spoofable — DNS cache-poisoning / Kaminsky-class — and must NOT be used for records a downgrade or redirect would exploit.
Pass resolver (a b.network.dns.resolver.create() instance) to reshape TXT records off a caller-owned resolver instead of the shared one — the mail-auth and mail-dkim modules use this so their TXT lookups share the same resolver (and cache) they use for A / MX / PTR, instead of each re-rolling the reshape.
var rrs = await b.network.dns.resolver.resolveTxt("_dmarc.example.com");
// → [ ["v=DMARC1; p=reject"] ]
b.network.dns.resolver.safeResolveTxt(qname, opts?) #
{
dnsLookup: function, // operator override (qname, "TXT") => string[][]
errorFactory: function, // (code, message) => Error, thrown on non-absence failures
code: string, // error code passed to errorFactory
}
resolveTxt wrapped with the email-auth policy-fetch convention: a domain with no record (ENOTFOUND / ENODATA) returns null (absence is not an error); any other failure throws the caller's typed error via errorFactory(code, message) so each protocol keeps its own error class. The same secure resolver path as resolveTxt — never plaintext system DNS.
var rrs = await b.network.dns.resolver.safeResolveTxt("_mta-sts.example.com", {
errorFactory: function (code, msg) { return new SmtpPolicyError(code, msg); },
code: "smtp/mta-sts-txt-lookup-failed",
});
if (rrs === null) return null; // no MTA-STS record published
Last updated 2026-08-08T16:39:49.652Z by seeder.