Repository navigation
fix(dnssec): don't return DNSSEC records to clients with the DO bit clear - #2203
Merged
Merged
Conversation
The response the server writes back is normalized against the query as the client sent it, not against request.Req, which the resolver chain mutates in place. That was two captured locals and three statements spread across resolve(); collect them in a clientQuery value captured before resolution and applied afterwards, so further per-client normalization has one home. No behavior change: the existing server specs pass unchanged.
…lear Fixes #2202. A validating resolver must query upstream with the DO bit set, so the DNSSEC resolver sets it on every request regardless of what the client asked for. The records that come back were then forwarded verbatim, so a client that left DO clear received the RRSIG (and NSEC/NSEC3/DNSKEY/DS) records blocky had fetched for its own validation. Normalize the response against the client's DO bit in clientQuery: - strip the DNSSEC records, keeping any type the query explicitly asked for, so a DNSKEY or DS query is still answered, and keeping everything for an ANY query (RFC 4035 section 3.2.1, RFC 3225 section 3) - copy the query's DO bit into the response's OPT record (RFC 3225 section 3) - clear AD unless the client set DO or AD (RFC 6840 section 5.8) Stripping runs before Truncate, so a signed answer that only exceeded the client's buffer because of the signatures is no longer truncated. This sits in the server rather than in the DNSSEC resolver because the cache is keyed on qtype and name only: an entry populated by a DO client is served to every other client of that name, so the records have to be stripped per client even when dnssec.validate is off and the DNSSEC resolver is a no-op. Not addressed: the reverse of that cache behaviour, where a DO=1 client is served an unsigned entry populated by a DO=0 client (#1741). That needs a cache-key change; with dnssec.validate enabled it cannot happen, because the chain always fetches with DO set.
kevinpark1217
force-pushed
the
fix/dnssec-do-bit-response
branch
from
August 3, 2026 01:19
cf9883d to
593e29a
Compare
0xERR0R
enabled auto-merge (squash)
August 4, 2026 20:16
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #2203 +/- ##
==========================================
+ Coverage 87.95% 88.00% +0.04%
==========================================
Files 124 126 +2
Lines 9831 9870 +39
==========================================
+ Hits 8647 8686 +39
Misses 922 922
Partials 262 262 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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.
Fixes #2202.
Problem
A validating resolver must query upstream with the DO bit set, so
DNSSECResolversets it on every request regardless of what the client asked for (resolver/dnssec_resolver.go:71-89). Nothing puts it back:resolver/dnssec/validator.goonly classifies the response, andserver.resolvenormalized the OPT record and the message size but not the DNSSEC records. A client that left DO clear therefore received the RRSIG (and NSEC/NSEC3/DNSKEY/DS) records blocky fetched for its own validation, which RFC 4035 §3.2.1 forbids: the name server side "MUST strip any authenticating DNSSEC RRs from the response but MUST NOT strip any DNSSEC RR types that the initiating query explicitly requested".Strict stub resolvers reject the unsolicited records; per the issue, macOS
mDNSResponderstalls ~30s per signed domain, sodnssec.validate: truebreaks resolution of signed domains for macOS clients.Because the client's DO bit is only known before the chain mutates
request.Req, this is fixed where the other client-facing normalization already happens. The first commit is a behavior-preserving refactor collecting that normalization into aclientQueryvalue captured up front; the existing server specs pass unchanged.Fix
clientQuery.normalizeResponsenow also normalizes against the client's DO bit:QTYPE=ANY("Security records that match an explicit SIG, KEY, NXT, or ANY query [...] are included whether or not the DO bit was set"), so an ANY query keeps everything.Stripping runs before
Truncate, so a signed answer that only exceeded the client's buffer because of the signatures is no longer truncated.Why the server and not the DNSSEC resolver: the cache key is qtype and name only (
util/common.go:223) andpackForCachekeeps the signatures, so an entry populated by a DO client is served to every other client of that name — including withdnssec.validate: false, whereDNSSECResolver.Resolveis a no-op. Stripping has to be per client at the point the response leaves, which is also where the OPT-record and truncation normalization already live.The cache is unaffected by the stripping: it stores packed bytes and unpacks a fresh message per hit (
resolver/caching_resolver.go:266-282,:343-351), so mutating one client's response cannot touch the shared entry.Test
util/dnssec_test.go:StripDNSSECRecordsover signed answers, NSEC and NSEC3 denial proofs, unrequested key material, the explicitly-requested exception per type,QTYPE=ANY, multi-type requests, all three sections, and that the OPT record survives.server/server_test.go: throughs.resolvewith a chain that sets DO and returns signatures — stripping for an EDNS0 client with DO clear and for a client without EDNS0, the DO bit mirrored, AD cleared, an explicit DNSKEY query keeping its answer, ANY keeping everything, a chain that returns signatures without validating (thevalidate: falsecache case), a 5×(A+RRSIG) answer no longer truncated to 512 bytes, DO=1 clients still getting records + AD, and AD-only clients keeping AD while still being stripped.e2e/dnssec_test.go: against a real container withdnssec.validate: trueand a valid chain of trust, a DO-clear query gets the A record without RRSIG and without AD; and after the upstream is terminated, a DO=1 query is still served the signatures from the cache.The e2e spec fails against an image built from
main(RRSIG must not be returned to a client that didn't set the DO bit) and passes with this change. Each normalization step and the ANY carve-out were individually reverted to confirm the specs catch them.Validation (isolated
golang:1.26container)go tool ginkgo --label-filter="!e2e" -r— green, 21 suitesgo tool ginkgo --label-filter="dnssec" e2e— green, 12 specsgofumpt -l -extra,goimports -l,go vet ./...,golangci-lint v2.12.2 run— cleanNotes for review
dnssec.validate— an upstream can return DNSSEC records either way, and the cache case above needs them applied with validation off. The docs bullets are worded accordingly. The visible consequence withvalidate: falseis that an upstream-suppliedAD=1is no longer passed to clients that asked for neither DO nor AD, and the/api/queryendpoint (which builds a DO-clear message) no longer shows RRSIGs.packForCache), so an EDNS0 client gets no OPT there at all — pre-existing onmainand left alone here, but it means RFC 6891 §6.1.1 is not currently satisfied on that path.QueryLoggingResolversits above the server. Stripped records and the cleared AD bit will still appear there. This divergence already existed for OPT removal and truncation; this change widens it.dnssec.validateis enabled, because the chain then always fetches with DO set.