Add DNS resolver benchmark, after GRC's DNS Benchmark - #5
Merged
Conversation
Steve Gibson's GRC DNS Benchmark separates cached, uncached and "dotcom" lookups because a resolver can be instant from cache and badly connected to everything else. That split translates to the browser; the rest of his tool does not, and saying so is the point. What is measured, over RFC 8484 DNS-over-HTTPS: - cached / uncached / dotcom medians for seven CORS-capable public resolvers, using a random label under a popular domain to force an authoritative round trip and a random second-level .com name to force a TLD consultation - whether each resolver returns NXDOMAIN for names that do not exist, or an address (GRC's "redirection") - whether each validates DNSSEC, from a broken-chain name and a signed control What is stated on screen rather than approximated: the system/ISP resolver cannot be reached from a page at all, every figure includes the HTTPS round trip, ten well-known providers send no CORS header and are listed with no figures, and a failed request is not attributable to the resolver — so the column is "Answered", never "Reliability". No significance claim is made either; a fastest resolver is named only when its observed range clears the runner-up's. New: - utils/dnsWire.ts — pure RFC 1035 codec, null on anything malformed. A response declaring answers it does not carry decodes to null rather than to "zero answers", so a truncated reply cannot read as a resolver's silence. - utils/dnsBenchmark.ts — catalogue, round-robin sequential scheduler, classifiers, and the CORS-vs-unreachable disambiguation. - components/DnsBenchmark.tsx — permanent limits panel carrying the credit, sortable table with nulls pinned last, and a chart that omits unmeasured resolvers entirely rather than drawing a zero-length bar. Also: percentile / median / sampleStdDev / summariseSamples in network.ts (p95 withheld below ten samples, so max is not relabelled as a percentile), and timeoutSignal hoisted there out of its second and third copy. A live browser run caught one bug worth naming: a resolver returning HTTP 505 was reported as not sending the CORS header. Reading a status code proves the browser was allowed to see the response, so that note asserted what the reply itself disproved. Only a fetch rejection is ambiguous enough to warrant the no-cors retry now, and a regression test pins it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtwUzw55TKVneGK4WvGQh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Steve Gibson's GRC DNS Benchmark separates cached, uncached and "dotcom" lookups because a resolver can be instant from cache and badly connected to everything else — a benchmark that mixes the two tells you neither. That split translates to the browser. The rest of his tool does not, and saying so plainly is the point of this change.
What it measures
Over RFC 8484 DNS-over-HTTPS, against seven CORS-capable public resolvers:
<random12>.<popular-domain>— a label that cannot be in any cache, forcing a real authoritative round trip<random16>.com— a random second-level name, forcing a.comTLD consultationdnssec-failed.org(deliberately broken chain) must SERVFAIL, and a signed control must come back with AD setAll five were verified against all seven endpoints with
curlbefore any code was written.Three decisions that depart from a naive port
1. Nothing is called "Reliability." GRC counts queries a nameserver never answered, over UDP, to a known IP. In a browser a failed
fetchmay be the resolver, TLS, a blocking extension, or CORS — indistinguishable. Labelling that "reliability" attributes a browser-side failure to the operator. The column is Answered and reads4 / 5.2. No significance claim. GRC applies a 95% confidence threshold. No significance test's assumptions hold over five correlated samples sharing one uplink, so instead of inventing a p-value this reports whether the leader's observed range clears the runner-up's. When they overlap, the conclusion is "this run does not separate them" — a statement about what was seen.
3. Randomness is
crypto.getRandomValues, notMath.random. The cache-buster is a randomised 16-bit DNS message ID: RFC 8484 §4.1 recommendsid = 0for HTTP-cache friendliness and this tool wants the opposite, and appending an extra&_nr=parameter makes Quad9 return HTTP 403. UsingMath.randomwould have put an unexplained hit in the CLAUDE.md pre-commit grep, which teaches reviewers to ignore the grep. Both sanctioned uses are commented, and CLAUDE.md now names them.What a browser cannot do — stated on screen, not approximated
A permanent, non-collapsible panel sits above the results:
Timing-Allow-Origin, so the DNS/TCP/TLS split is unreadable — re-observed each run via the existingreadPhasesfromedgePath.tsrather than asserted in a comment that could go stale.New files
src/utils/dnsWire.ts— pure RFC 1035 codec; no network, no clock, no randomness. Returnsnullfor anything malformed. The case that matters: a response declaring three answers but carrying none decodes tonull, not to "zero answers" — otherwise a truncated reply reads as a resolver's silence.src/utils/dnsBenchmark.ts— resolver catalogue, round-robin sequential scheduler, classifiers, and the CORS-vs-unreachable disambiguation. One request in flight at a time, cycling resolvers with a rotating start index: concurrent DoH requests share one uplink and would measure the browser congesting itself.src/components/DnsBenchmark.tsx— limits panel, sortable table with nulls pinned last in every direction, and a chart that omits unmeasured resolvers rather than drawing a zero-length bar (which reads as "instant"). X-axis pinned to[0, 'dataMax']; Recharts' default can start a numeric axis above zero and visually multiply a small difference.Also
percentile/median/sampleStdDev/summariseSamplesinnetwork.ts(p95 withheld below ten samples, so "the slowest sample" is not relabelled as a percentile), andtimeoutSignalhoisted there out of its second and third copy.The bug a live browser run caught
A resolver returned HTTP 505 and the app reported "does not send the header that lets a web page read its answers." But reading a status code proves the browser was allowed to see the response — the note asserted what the reply itself disproved. Only a
fetchrejection is ambiguous enough to warrant theno-corsretry now, and a regression test pins it. Static review did not catch this; driving the real app did.Verification
npm run check— 266 tests green (22 new for the codec, 29 for the benchmark, plus stats and CSV coverage).—, zero digits anywhere, no bars, no conclusions, correctnetwork-offlinenotice. Offline runs also list the seven reachable resolvers as not attempted rather than dropping them from the table.PrivacySafetyModal.tsxand mirrored in the README table in the same commit, including the point thatdnssec-failed.org/internetsociety.orgare query subjects the browser never contacts.Two gaps worth naming: Chromium in the dev container has no external egress (
example.comfails too), so the browser run relayed DoH requests through Node — the app's own encode, fetch, decode, timing and classifiers all ran for real, but those timings are relay timings, not internet timings. And Playwright'sroute.fulfillbypasses the browser's CORS check, so the dynamic CORS-detection branch is unit-tested but not browser-verified; the ten statically-listed providers do render through the real path.Deferred: rebinding protection. Beyond the
nip.iodependency and zero current differentiation, there is a correctness trap — ifnip.iois down, every resolver returns SERVFAIL and the tool concludes they all filter. A false positive on a security claim, caused by someone else's outage.🤖 Generated with Claude Code
https://claude.ai/code/session_01DQtwUzw55TKVneGK4WvGQh
Generated by Claude Code