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.

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:

  • yes — the record resolved, or the registry returned it;
  • no — ENOTFOUND/ENODATA, or an RDAP 404 (the registry answered: no such object);
  • did not ask — ESERVFAIL, ETIMEOUT, EREFUSED, a 429, a 5xx. Neither a yes nor a no.

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

Originally published at minia2a.uk.