Skip to content

fix(dns): answer the address a name actually has - #269

Merged
ralyodio merged 1 commit into
mainfrom
fix/resolver-address-answer
Aug 3, 2026
Merged

fix(dns): answer the address a name actually has#269
ralyodio merged 1 commit into
mainfrom
fix/resolver-address-answer

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Our own resolver could not resolve most of the namespace.

Every name pointed at a host — seo.rank, chovy.hacker, alt.2600, all of them — answered like this:

;; flags: qr aa rd; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 0

Authoritative, NOERROR, no answers. A client is entitled to treat that as final, and does.

Why it stayed hidden

google.com resolved the whole time, because forwarded queries never touch this path. So systemctl status was clean, resolvectl was clean, DNS on the machine looked fine, and only the namespace the bridge exists to serve was dead. dig even confirmed the name existed. There was no error to find anywhere.

Two causes, both on the address path

A published A/AAAA record was never read. RECORD_TYPES covered CNAME, MX and TXT; addresses came only from target. alt.2600 publishes AAAA 2604:a880:400:d1:0:4:c3fe:1 in the registry and answered nothing.

A hostname target produced nothing. targetAddress() returns null for a host and nothing picked up after it. The comment there said turning a host into an address would mean the bridge doing clearnet DNS — but it already forwards clearnet queries upstream. That reasoning had gone stale, and it was load-bearing for the wrong conclusion.

Most of the registry points at a host, so this was not an edge case.

The change

addressAnswer() replaces the old answerPolicy-then-look-for-a-CNAME pair with a single plan: target address → published A/AAAA → published CNAME → a CNAME synthesised from a hostname target → NODATA.

The cheap question is still asked first. A name pointed at a bare IP costs one registry call and no record fetch — that is the fast path every page load takes, and it stays intact. Only a name that had nothing to say pays for the second round trip, which is the same bargain the old CNAME lookup already struck.

A bare CNAME would not have been enough. This bridge sets RA=0, so a stub handed a dangling CNAME has been told in the same breath that nobody will chase it — systemd-resolved reports that as a name with no address. buildChainResponse() emits the leaf alongside it. Best-effort by design: the CNAME is already correct on its own, so a slow or silent upstream costs the extra record and never the answer.

targetHostname() is the other half of targetAddress(). It refuses anything a CNAME cannot carry — ports, paths, bare IPs, single labels. A target naming :8080 stays NODATA rather than quietly sending the client to port 80 of the right host, which would be a wrong answer that looks right.

DoH gets the same treatment. Its own comment said a name that resolves over the bridge and not over DoH is the failure that endpoint exists to remove; it carried the identical gap.

Verified against the live registry, before any test was written

seo.rank.             30 IN CNAME dev.profullstack.com.
dev.profullstack.com. 30 IN A     67.205.189.229
chovy.hacker.         30 IN CNAME dev.profullstack.com.
alt.2600.            300 IN AAAA  2604:a880:400:d1:0:4:c3fe:1
google.com                        142.250.188.14   (still forwards)

That alt.2600 AAAA is the record the bridge had been ignoring.

Tests

15 new in test/dns-address-answer.test.mjs, reading answers back off the wire — a reply of the right shape with the wrong bytes is exactly the failure being fixed. They cover both owners in a chain, a leaf of the wrong family being dropped rather than encoded as garbage, the fast path not fetching records, and NXDOMAIN still surviving all of it.

One existing test changed rather than added to: dns-nodata.test.mjs had recorded "a live name pointed at a hostname is NODATA" as correct behaviour. It was the bug, written down.

Not fixed here

The published @moshcoder/moshpit-dns@0.3.0 has the same bug — confirmed directly: RECORD_TYPES is CNAME,MX,TXT, targetAddress('dev.profullstack.com') is null, no hostname path. That is the standalone resolver users install, in a separate repo. dns-drift.test.mjs pins the shared surface between the two, so this fix is deliberately kept out of that list until the package catches up.

The bridge needs ~15s after start before it answers. Until the TLD list pages in, isOurs() says no and every Moshpit name forwards upstream to an NXDOMAIN. Worth a separate look — it is a second, independent reason a freshly-enabled resolver looks broken.

Most of the registry could not be resolved by our own resolver. Every
name pointed at a host — seo.rank, chovy.hacker, alt.2600, all of them —
came back as an authoritative NOERROR with no answers, which a client is
entitled to treat as final.

That is the worst shape a failure can take. `dig` said the name existed,
nothing could reach it, and no log anywhere reported an error. google.com
resolved the whole time, so the machine looked healthy.

Two independent causes, both on the address path:

  - RECORD_TYPES covered CNAME, MX and TXT only, so a published A or AAAA
    record was never consulted for an address question. alt.2600 publishes
    an AAAA in the registry and still answered nothing.

  - targetAddress() returns null for a hostname, and nothing picked up
    after it. The comment said turning a host into an address would mean
    the bridge doing clearnet DNS — but it already forwards clearnet
    queries upstream, so that reasoning had gone stale.

addressAnswer() replaces the old answerPolicy-then-look-for-a-CNAME pair
with one plan: target address, published A/AAAA, published CNAME, then a
CNAME synthesised from a hostname target. The cheap question is still
asked first, so a name pointed at a bare IP costs exactly one registry
call and no record fetch — the fast path every page load takes.

A bare CNAME would not have been enough. This bridge sets RA=0, so a stub
handed a dangling CNAME has been told in the same breath that nobody will
chase it; systemd-resolved reports that as a name with no address.
buildChainResponse() emits the leaf alongside it, best-effort, so a slow
upstream costs the extra record and never the answer.

DoH gets the same treatment. Its own comment said a name that resolves
over the bridge and not over DoH is the failure that endpoint exists to
remove, and it carried the identical gap.

Verified against the live registry before any test was written:

    seo.rank.             30 IN CNAME dev.profullstack.com.
    dev.profullstack.com. 30 IN A     67.205.189.229
    alt.2600.            300 IN AAAA  2604:a880:400:d1:0:4:c3fe:1

One existing test changed rather than added to: it had recorded "a live
name pointed at a hostname is NODATA" as correct. It was the bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

87 finding(s)

HIGH/CRITICAL: 50 | MEDIUM: 37

Severity Rule Location
HIGH manifest-typosquat apps/pwa/package.json:19
HIGH js-ssrf-outbound-request apps/pwa/public/sw.js:45
HIGH secret-generic-credential apps/pwa/test/apikey-bearer-scheme.test.mjs:30
HIGH secret-generic-credential apps/pwa/test/apikey-mask.test.mjs:38
HIGH secret-generic-credential apps/pwa/test/apikey-reveal.test.mjs:35
HIGH secret-generic-credential apps/pwa/test/approvals-context.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/approvals-credits.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/approvals-notify.test.mjs:26
HIGH secret-generic-credential apps/pwa/test/approvals-resolve-race.test.mjs:20
HIGH secret-generic-credential apps/pwa/test/auth-form-email.test.mjs:29
HIGH secret-generic-credential apps/pwa/test/auth-form-email.test.mjs:33
HIGH secret-generic-credential apps/pwa/test/auth-page-error.test.mjs:36
HIGH secret-generic-credential apps/pwa/test/cli-device-token.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/cli-pages-balance.test.mjs:32
HIGH secret-generic-credential apps/pwa/test/cli-token.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/credits-pack.test.mjs:51
HIGH secret-generic-credential apps/pwa/test/credits-webhook-event-match.test.mjs:35
HIGH secret-generic-credential apps/pwa/test/credits-webhook.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/csrf-input-escaping.test.mjs:31
HIGH secret-generic-credential apps/pwa/test/csrf-input-escaping.test.mjs:101
HIGH secret-generic-credential apps/pwa/test/logout-csrf.test.mjs:29
HIGH secret-generic-credential apps/pwa/test/moshpit-api-key.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/moshpit-bulk-claim.test.mjs:23
HIGH secret-generic-credential apps/pwa/test/moshpit-claim-full-name.test.mjs:29
HIGH secret-generic-credential apps/pwa/test/moshpit-crawlable.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/moshpit-ending-page.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/moshpit-pins.test.mjs:22
HIGH secret-generic-credential apps/pwa/test/moshpit-pit-page.test.mjs:33
HIGH secret-generic-credential apps/pwa/test/moshpit-records-page.test.mjs:23
HIGH secret-generic-credential apps/pwa/test/moshpit-records.test.mjs:23
HIGH secret-generic-credential apps/pwa/test/moshpit-registry.test.mjs:20
HIGH secret-generic-credential apps/pwa/test/moshpit-related-endings.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/moshpit-sales.test.mjs:16
HIGH secret-generic-credential apps/pwa/test/moshpit-search.test.mjs:74
HIGH secret-generic-credential apps/pwa/test/moshpit-terms.test.mjs:19
HIGH secret-generic-credential apps/pwa/test/moshpit-tlds-pagination.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/passkey-register-duplicate.test.mjs:38
HIGH secret-generic-credential apps/pwa/test/require-auth-next.test.mjs:31
HIGH secret-generic-credential apps/pwa/test/require-auth-next.test.mjs:35
HIGH secret-generic-credential apps/pwa/test/sessions-output-seq.test.mjs:30
HIGH secret-generic-credential apps/pwa/test/sessions-paste.test.mjs:24
HIGH secret-generic-credential apps/pwa/test/sessions-stream-replay.test.mjs:34
HIGH secret-generic-credential apps/pwa/test/sessions.test.mjs:24
HIGH secret-generic-credential apps/pwa/test/signature.test.mjs:6
HIGH secret-generic-credential test/auth.test.mjs:13
HIGH secret-generic-credential test/auth.test.mjs:63
HIGH secret-generic-credential test/console-cookie-malformed.test.mjs:15
HIGH secret-generic-credential test/console.test.mjs:12
HIGH secret-generic-credential test/mirror.test.mjs:37
HIGH secret-generic-credential test/mirror.test.mjs:77

…and 37 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio marked this pull request as ready for review August 3, 2026 15:04
@ralyodio
ralyodio merged commit da55fd1 into main Aug 3, 2026
7 checks passed
@ralyodio
ralyodio deleted the fix/resolver-address-answer branch August 3, 2026 15:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant