๐Ÿ“… Historical page. This content reflects minia2a as of its publication date and is kept for the record. Current model: x402 pay-per-call in USDC on Base only, 5 free trial calls per signed wallet, no credits and no top-up rail. โ†’ See current

A DNS Timeout Is Not a Verdict

2026-10-19 ยท when "not found" means "not asked"

Two of our paid verification handlers answered a question they had not been able to ask. Both looked like this inside:

let ns = null;
try { ns = await dns.resolveNs(domain); } catch (e) { /* nothing */ }

return { ok: true, legit: Boolean(whois && ns && mx), ns, mx, whois };

The intent is reasonable: if the lookup throws, treat the record as absent. The code has one operator doing two jobs. A DNS response code carries more than "yes" and "no": ENOTFOUND means the name does not exist, ENODATA means the name exists but has no record of that type โ€” those are answers. ESERVFAIL, ETIMEOUT, and EREFUSED mean the resolver did not give us an answer at all. The catch flattened all five into null, and null flowed into legit: false.

So a domain that was briefly unanswerable produced the same fields and the same verdict as a domain that does not exist:

whois: null   ns: null   mx: null   dmarc: null
legit: false

Same for domain-audit, which additionally folded TLS into the same shape. These are $3 calls whose product is a judgement. The judgement was being made by the resolver's weather.

The second half: a field that was never written

A third handler, osint-pack, built its risk flags like this:

if (!results.ssl.authorized) flags.push('untrusted-cert');

But the certificate object never carried an authorized field. !undefined is true, so every domain with a perfectly valid certificate was flagged untrusted-cert and scored at least medium. The 402 response advertised the bug in its own output example: a valid example.com certificate, riskFlags: ["untrusted-cert"], riskScore: "medium". The shop window was showing the defect.

Both shapes are the same mistake at different depths: a value that was never observed was allowed to decide a boolean. null and undefined are not evidence; they are the absence of evidence. Code that treats them as a negative answer will produce confident false negatives, which are the expensive kind โ€” the caller pays for the answer.

Three states, again

The fix is to keep the third state instead of collapsing it:

Concretely, both handlers now collect the failures they could not classify:

{ "ok": true,
  "check": {
    "whois": { ... },
    "ns": ["elliott.ns.cloudflare.com", "hera.ns.cloudflare.com"],
    "mx": null,
    "legit": null            // unknown, not false
  },
  "unresolved": ["mx"] }     // the lookup that did not answer

legit is null when the inputs required for the judgement were not all observed. unresolved names which lookups never answered. A caller can now distinguish "this domain is not a company" from "ask again"; the second one is cheap to retry and the first one should not be invented. Only ENOTFOUND and ENODATA reach the negative branch; an RDAP 404 stays a definitive no.

osint-pack now carries authorized: s.authorized on the certificate object, so the flag can only fire on an observed false. It also gained an upstream-state signal: a transient DNS failure now surfaces as a dns-unavailable flag and riskScore: "unknown" instead of a confident clean โ€” the same rule in the other direction. An unanswered lookup must not produce a positive verdict either.

The same lie in the address check

Nine more handlers had a smaller version of it. Before fetching a caller-supplied host, they resolve it to reject private addresses. On a resolution failure they returned:

{ "ok": false, "error": "internal address blocked" }

That sentence is a security finding about the caller. A resolver timeout is not. The private-address branch is unchanged โ€” 127.0.0.1 still says internal address blocked โ€” but a failure to resolve now says host did not resolve: โ€ฆ with retryable: true. Nothing about the protection changed; only the claim about why it fired.

Why the guard did not see it

We already had a guard for exactly this class of bug: sixteen handlers that take a domain and report DNS verdicts. Its population rule was "handlers that only do DNS". company-check, domain-audit, and osint-pack also do RDAP, TLS, or HTTP, so the rule excluded them โ€” the three handlers with the bug were outside the population that checked for it.

The guard now has a second class: handlers that require a DNS module and also use ssrf, tls, or fetch. Population 16 โ†’ 22 (17 pure, 5 mixed); controls 12 โ†’ 25. Before the fix it named the two handlers by id. After it, the same command over the deployed bytes is clean.

The population rule is part of the predicate. A guard that only inspects the pure cases will report clean on the mixed ones, and the mixed ones are where the composition bug lives.

What generalizes

Anywhere a network result is coerced into a boolean, ask which failures the coercion is allowed to swallow. A timeout, a rate limit, and a 5xx are not answers. If the response cannot say "I did not ask", it will eventually say "no" on someone else's behalf โ€” and when that response costs $3, the false negative is the product.

The same three-state discipline is what we wrote about in The Monitor That Reported Itself, one layer up: there the reporter could not say "I did not measure this". Here the service could not say "I did not ask". The state is the same; the altitude is different.


Related: the monitor that reported itself ยท the error class that was thrown away ยท signature valid is not authorized