Skip to content

fix(dnssec): don't return DNSSEC records to clients with the DO bit clear - #2203

Merged
0xERR0R merged 3 commits into
0xERR0R:mainfrom
kevinpark1217:fix/dnssec-do-bit-response
Aug 4, 2026
Merged

0xERR0R merged 3 commits into
0xERR0R:mainfrom
kevinpark1217:fix/dnssec-do-bit-response

Conversation

@kevinpark1217

@kevinpark1217 kevinpark1217 commented Aug 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2202.

Problem

A validating resolver must query upstream with the DO bit set, so DNSSECResolver sets it on every request regardless of what the client asked for (resolver/dnssec_resolver.go:71-89). Nothing puts it back: resolver/dnssec/validator.go only classifies the response, and server.resolve normalized 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 mDNSResponder stalls ~30s per signed domain, so dnssec.validate: true breaks 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 a clientQuery value captured up front; the existing server specs pass unchanged.

Fix

clientQuery.normalizeResponse now also normalizes against the client's DO bit:

  • Strip the DNSSEC records when DO is clear, keeping any type the query asked for explicitly, so a DNSKEY or DS query is still answered — just without the signatures over it (RFC 4035 §3.2.1). RFC 3225 §3 extends the exception to 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.
  • Copy the query's DO bit into the response's OPT record (RFC 3225 §3).
  • Clear AD unless the client set DO or AD (RFC 6840 §5.8: "validating resolvers SHOULD only set the AD bit when a response both meets the conditions listed in Section 3.2.3 of [RFC4035], and the request contained either a set DO bit or a set AD bit").

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) and packForCache keeps the signatures, so an entry populated by a DO client is served to every other client of that name — including with dnssec.validate: false, where DNSSECResolver.Resolve is 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: StripDNSSECRecords over 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: through s.resolve with 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 (the validate: false cache 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 with dnssec.validate: true and 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.26 container)

  • go tool ginkgo --label-filter="!e2e" -r — green, 21 suites
  • go tool ginkgo --label-filter="dnssec" e2e — green, 12 specs
  • gofumpt -l -extra, goimports -l, go vet ./..., golangci-lint v2.12.2 run — clean

Notes for review

  • The three normalizations are unconditional, not gated on 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 with validate: false is that an upstream-supplied AD=1 is no longer passed to clients that asked for neither DO nor AD, and the /api/query endpoint (which builds a DO-clear message) no longer shows RRSIGs.
  • DO mirroring only applies to responses that carry an OPT record. Cache hits are served from bytes packed without one (packForCache), so an EDNS0 client gets no OPT there at all — pre-existing on main and left alone here, but it means RFC 6891 §6.1.1 is not currently satisfied on that path.
  • The query log and dnstap record the pre-normalization response, since QueryLoggingResolver sits 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.
  • Not addressed: the reverse cache-key problem, where a DO=1 client is served an unsigned entry populated by a DO=0 client (DNS Cache ignores DNSSEC flag #1741, closed as stale). That needs a cache-key change and cannot happen while dnssec.validate is enabled, because the chain then always fetches with DO set.

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
kevinpark1217 force-pushed the fix/dnssec-do-bit-response branch from cf9883d to 593e29a Compare August 3, 2026 01:19
@0xERR0R 0xERR0R added the 🐞 bug Something isn't working label Aug 4, 2026
@0xERR0R 0xERR0R added this to the v0.35.0 milestone Aug 4, 2026

@0xERR0R 0xERR0R left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for this fix

@0xERR0R
0xERR0R enabled auto-merge (squash) August 4, 2026 20:16
@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.00%. Comparing base (91a8f44) to head (c7edd07).
⚠️ Report is 7 commits behind head on main.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@0xERR0R
0xERR0R merged commit a42d656 into 0xERR0R:main Aug 4, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐞 bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DNSSEC validation returns RRSIG records to clients that didn't set the DO bit

2 participants