Skip to content

Derive the NSEC next name for QNAMEs at the 255-octet ceiling - #360

Merged
NelsonVides merged 3 commits into
mainfrom
fix/nsec-successor-at-max-name-length
Aug 4, 2026
Merged

Derive the NSEC next name for QNAMEs at the 255-octet ceiling#360
NelsonVides merged 3 commits into
mainfrom
fix/nsec-successor-at-max-name-length

Conversation

@NelsonVides

Copy link
Copy Markdown
Contributor

Compact denial of existence builds the NSEC Next Domain Name by prepending a label of one zero octet to the QNAME (RFC 9824 §3.1). That costs two octets on the wire, which a QNAME already sitting at the 255-octet maximum cannot afford, so dns_domain:to_wire/1 raised name_too_long while the synthesized NSEC was being canonicalised for its RRSIG.

Observed in production: https://app.datadoghq.com/logs?query=service%3Aerldnsimple%20%40what%3Apipe_failed_with_exception&agg_m=count&agg_m_source=base&agg_t=count&clustering_pattern_field_path=message&cols=host%2Cservice%2C%40what%2C%40reason&event=AwAAAZ-tDrLB14qdvAAAABhBWi10RHNRcEFBRHNRTUtEdkVucFVnQUEAAAAkMDE5ZmFkMTMtNzEyZS00OTI3LWFmMzEtNTg0OTE4MmEwZTc4AAF6KQ&fromUser=true&messageDisplay=inline&refresh_mode=sliding&storage=hot&stream_sort=desc&viz=stream&from_ts=1785292156911&to_ts=1785306556911&live=true

erldns_pipeline caught the exception and carried on with the message untouched, so the server did not fall over — but the response went out as a bare NXDOMAIN plus SOA, with no NSEC, no RRSIG and no AD bit. A validating resolver that asked with DO=1 against a signed zone and received an unsigned negative answer treats it as bogus, so every such query became a SERVFAIL for the end user.

🔍 QA

N/A

📋 Deployment Pre/Post tasks

N/A

:shipit: Deployment Verification

  • No what=pipe_failed_with_exception carrying error=name_too_long from erldns_dnssec in the logs after rollout.
  • The [erldns, pipeline, error] telemetry counter returns to its pre-incident baseline.
  • A max-length probe against a signed zone with DO=1 answers NOERROR carrying an NSEC and its RRSIG, and validates (delv, or dig +sigchase).
  • SERVFAIL rates for signed zones are unchanged or improved.

RFC 9824 3.1 builds the compact denial-of-existence NSEC Next Domain
Name by prepending a label of one zero octet to the QNAME. That costs
two octets on the wire, which a QNAME already at the 255-octet maximum
cannot afford, so dns_domain:to_wire/1 raised name_too_long while the
synthesized NSEC was being canonicalised for its RRSIG.

erldns_pipeline caught the exception and carried on with the message
untouched, so the response went out as a bare NXDOMAIN plus SOA with no
NSEC, no RRSIG and no AD bit. A validating resolver that asked with DO=1
against a signed zone and got an unsigned negative answer treats it as
bogus, so every such query became a SERVFAIL downstream, plus an error
log carrying a full message dump and a telemetry event. Any name of 254
octets or more triggered it, on any signed zone.

Names at the ceiling now fall back to the successor ladder of RFC 4471
3.1.2: append a zero octet inside the left-most label where the label
and the name both have room, otherwise increment the right-most octet
below 0xff and drop everything to its right, otherwise drop the label
and start again one level up. Uppercase US-ASCII is skipped when
incrementing, because canonical order compares names lowercased
(RFC 4034 6.1) and 0x41-0x5a therefore never occur in a canonical name.

Step 4 deviates from the RFC on purpose. It loops back to step 2, whose
guard cannot hold once a 64-octet label has been dropped, so the RFC
falls through to step 3 and yields a name past the true successor. An
NSEC asserts nothing exists between its owner and its next name, and
RFC 8198 resolvers cache that span and answer NXDOMAIN from it for names
they never queried, so a span wider than the truth denies names that
exist. Both PowerDNS and BIND ship that behaviour, BIND with
synth-from-dnssec enabled by default, and neither recognises these
fallback names as synthesized: PowerDNS matches only next names of the
form \000.<owner>, BIND only type bitmaps of exactly RRSIG+NSEC.
Applying step 2's action in the recursion keeps the span empty instead.

Tested with a case per rung of the ladder, each asserting the exact
successor plus the two invariants every rung owes, that the name encodes
at all and that it sorts after its input, and an end-to-end case that
reproduces the original crash through handle/7. Full ct suite at 331
passing, plus fmt, lint, xref and dialyzer.
Both exception logs in erldns_pipeline named the caught reason `error`.
Every other exception log in the library, and this module's own docs for
the corresponding telemetry event, call it `reason`, alongside `class`
and `stacktrace`.

That matters beyond tidiness: error reporters key off the shape of a
structured log to decide whether it describes an exception, and they
match on #{class, reason, stacktrace}. A report carrying `error` where
`reason` belongs matches nothing and is dropped without a trace, so
these two logs — the ones covering every exception a pipe can raise on
the query path — never reached the error reporter. The NSEC crash fixed
earlier in this branch is a worked example: it ran in production, logged
on every occurrence, and was never reported.

The telemetry metadata built two lines above each log already uses
kind/reason/stacktrace and was unaffected.

pipe_raises now asserts the shape of the report, not just that the
telemetry event fires, so a rename cannot silently cost this visibility
again. The handler is keyed by test case name, as the pipelines are,
because pipe_calls is a parallel group and a shared key lets a sibling's
end_per_testcase tear it down mid-case. Checked against the old key
first: the case fails with "No log report carrying an exception shape".

The async site takes the same rename. Its test drives a peer node, whose
logs a handler on the test node cannot see, so it is covered by the
rename alone.
@NelsonVides NelsonVides self-assigned this Jul 30, 2026
@NelsonVides
NelsonVides requested a review from a team July 30, 2026 12:34
Comment thread src/pipes/erldns_dnssec.erl Outdated
The ladder walked the QNAME's labels with no notion of where the zone
began. Its ascent drops a label whose every octet is 0xff, and if the
label directly below the apex is such a label, the next step grew the
apex's own left-most label instead — naming a sibling of the zone.

The Next Domain Name is a name in this zone (RFC 4034 4.1.1), so that
record was malformed. Reproduced with an apex of 190 octets and a QNAME
of <0xff x63>.<apex>: the successor came back as a sibling of the apex.
190 is the threshold, since the ascent needs one saturated label between
the apex and the 254-octet floor where the ladder starts; for ordinary
zone names no arrangement of saturated labels reaches the apex, which is
why this went unnoticed.

The denial itself was never wrong. The ascent only passes a maximal
label, so nothing sorts between owner and next either way. What was
wrong was naming something outside the zone.

RFC 4034 4.1.1 also supplies the answer: the last NSEC of a zone puts
the apex in that field. The ascent now stops there and returns the apex,
which is what dnssec:gen_nsec's add_next_dname/3 does for the last
record of a real chain. It stays truthful, too: the owner is the maximal
representable name in the zone, so wrapping to the apex asserts nothing
exists after it.

The depth bound also covers a QNAME that is the apex itself, which
needed an apex of 254 octets or more to reach and would otherwise have
grown the apex the same way.

Reported in review by DXTimer.
@NelsonVides
NelsonVides requested a review from DXTimer August 3, 2026 14:34
@NelsonVides
NelsonVides merged commit 3efc5ff into main Aug 4, 2026
11 checks passed
@NelsonVides
NelsonVides deleted the fix/nsec-successor-at-max-name-length branch August 4, 2026 06:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants