Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,98 @@ All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.0] - 2026-09-03

A security release. Four defects in DNSSEC validation allowed forged data to be
accepted as authenticated; the most direct required only that the attacker own
a signed domain of their own. Upgrade before relying on a `SECURE` verdict from
0.1.0. `TESTING.md` describes how they were found.

### Security

- An RRSIG's signer was never required to contain the RRset it covers
(RFC 4035 §5.3.1), so any signed zone could sign for any name.
- A wildcard at any ancestor was accepted where only the one at the closest
encloser can have answered (RFC 4035 §5.3.4/§5.4). Three variants: forged
NXDOMAIN, forged NODATA against a nested wildcard, and a replayed wildcard
RRset authenticated under a name a closer wildcard governs.
- Denials served from the parent side of a zone cut were accepted as proof
about the child, and a child's own SOA-bearing record could deny its DS
(RFC 6840 §4.1, RFC 4035 §5.2). Likewise a parent-side NSEC3 as the closest
encloser (RFC 5155 §8.3).
- An opt-out NSEC3 proved NODATA for any type; it only ever proves a missing
DS (RFC 5155 §8.5, §8.6), and even then unauthenticated (§9.2).
- The DNSKEY RRset was authenticated against every published key rather than
the one the parent's DS matched (RFC 4035 §5.2), so a compromised
zone-signing key was enough to introduce a new one.
- A DNSKEY with the REVOKE bit was still used to verify signatures
(RFC 5011 §2.1).
- Three checks read a field from the first RRSIG rdata rather than the
signature that verified, which an attacker orders. Each was exploitable with
a decoy.
- An authenticated RRset could be cached past its signature's expiry
(RFC 4035 §5.3.3), for up to 24 hours; a proven denial for up to an hour.
- An NXDOMAIN or NODATA resting on an opt-out cover was reported
authenticated, and `require_dnssec=True` did not apply to negative answers
at all, cached or fresh.
- NSEC3 records with reserved flag bits were read as Opt-Out (RFC 5155 §8.2).

### Fixed

- One unreachable or out-of-sync nameserver no longer condemns a zone. DNSKEY
and DS fetches, bare referrals, failed denials and answers carrying no
signature at all now sweep the NS set (RFC 4035 §5.5); a stale cached
delegation re-resolves its NS names. The last of those is a nameserver
serving a stale *unsigned* copy of a signed zone: every type it answers
reads as forged and its DNSKEY NODATA proves nothing, so landing on it made
the whole zone intermittently BOGUS. One of `nic.bj`'s four nameservers is
such a copy.
- A single spoofed UDP packet no longer retires a healthy nameserver
(RFC 5452 §9).
- DNAME is implemented: CNAME synthesis when the server omits it, and the
redirection inherits the DNAME's verdict rather than reading the unsigned
synthesized CNAME as a stripped signature (RFC 6672 §3.3, §3.4, §8).
- `ANY` is refused rather than answered as NODATA and cached as a denial.
- An NS set that points sideways or upwards is an error, not a denial.
- The SOA marking a negative answer must be class IN and belong to the zone
queried.
- Glue is cached under its own TTL, not the NS set's.
- `AAAA` is queried for glueless NS targets when IPv6 is enabled.
- A CNAME chain reports the minimum TTL over its hops.
- Empty non-terminals and wildcard-synthesised NODATA are no longer BOGUS
(RFC 4035 §3.1.3.4.1, §5.4; RFC 5155 §8.7), nor is a NODATA whose only proof
is an opt-out gap. The bit says the range holds no *signed* delegation, not
that it holds no names, so the answer is returned unauthenticated rather than
refused (RFC 5155 §8.6, §9.2). Ten zones in a 4000-name corpus needed this.
- RRSIG validity uses RFC 1982 serial arithmetic, allows configurable slack on
inception (`clock_skew`, 60s) for signers whose clock runs ahead, and is
checked before any cryptography.
- The IPv6 transition prefixes (6to4, Teredo, NAT64, IPv4-compatible) are
refused as nameserver addresses by name. Each carries an IPv4 address
inside it, and CVE-2024-4032 moved where the standard library draws that
line, so the control no longer depends on the interpreter's patch level.
- `edns_payload` outside 16 bits, and a cache `min_ttl` above its `max_ttl`,
are rejected at construction rather than misbehaving later.
- `--json` with `--no-dnssec` reports `"dnssec": "disabled"`. It said
`"insecure"`, which claims a zone was checked and found unsigned.
- A resolution that runs out of budget while resolving a referral's
nameservers reports a timeout, not a server failure.

### Changed

- A high NSEC3 iteration count downgrades to insecure rather than failing,
matching the major implementations (RFC 9276 §3.2 permits either).
- `min_ttl` no longer floors authenticated answers or delegations, whose TTL
is already capped by their signature.

### Added

- `DNSSECMaterialUnavailableError`, raised when a DNSKEY or DS could not be
retrieved. Deliberately not a `DNSSECError`, so "refuse to use this data"
handling stays correct while retrieval failures fall through to retries.
- `TESTING.md` and the harnesses it describes, including a mutation catalogue
of 52 reintroduced defects that confirms the suite still catches each one.

## [0.1.0] - 2026-08-09

First public release.
17 changes: 13 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ author, the committer and any `Co-authored-by:` trailer.

This package is MIT and every runtime dependency is permissive. Before copying
anything in, check [THIRD-PARTY.md](THIRD-PARTY.md) and note that several
reference resolvers are **copyleft**: PowerDNS Recursor and Knot Resolver are
GPL, BIND 9 is MPL-2.0. Their *behaviour* is fair to cite: parameter names and
default values are facts, and recording them lets a reader check our choices
widely-used resolver implementations are **copyleft** (GPL or MPL). Their
*behaviour* is fair to cite: parameter names and default values are facts, and
recording them lets a reader check our choices
against the state of the art. Their code, documentation text and curated data
(prefix lists, tables) are not, and must not be pasted in.

Expand All @@ -68,7 +68,12 @@ the file header and add a row to `THIRD-PARTY.md`.
acceptable in the query path: it is what previously turned malformed input
into phantom network timeouts.
- Unit tests must not touch the network. Use the builders in `tests/conftest.py`
and patch `_send_query`.
and patch `_send_query`, or `_query_once` when the sweep across a zone's
nameservers is itself what is being tested.
- **A DNSSEC verdict needs retrieved material.** `DNSSECValidationError` means
signed data was obtained and failed to verify. Anything that could not be
fetched, whether a lame server, a SERVFAIL or no answer at all, is a
`DNSSECMaterialUnavailableError`, which is deliberately not a `DNSSECError`.
- Integration tests go behind the `integration` marker.

## Differential testing
Expand All @@ -77,6 +82,10 @@ Beyond the unit and integration suites, `scripts/diff_harness.py` compares this
resolver against reference resolvers across a large corpus, broken down by
record type, corpus category and resolver configuration.

It compares record values, not DNSSEC verdicts, and runs each name once. The
layers that cover those, driven by `make test-protocol`, are described in
[TESTING.md](TESTING.md).

```bash
# Build a deliberately awkward corpus (~4k names).
python scripts/collect_domains_diverse.py -o domains.csv
Expand Down
45 changes: 44 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ CSV ?= domains.csv
SOURCES := src/ tests/ scripts/

.PHONY: help install test test-integration test-from-csv coverage coverage-all lint format typecheck \
check check-all build clean docker-shell release release-check
check check-all build clean docker-shell release release-check \
test-corpus test-verdicts test-record test-offline test-protocol

help: ## Show this help message
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
Expand Down Expand Up @@ -47,6 +48,48 @@ check-all: lint typecheck ## Everything, including live DNS and the coverage gat
build: ## Build sdist and wheel
uv build

# ── Real-world testing protocol (TESTING.md) ──────────────────────────

CORPUS ?= corpus.csv
ADVERSARIAL ?= adversarial.csv
CASSETTES ?= cassettes.jsonl

# The later stages read the files the earlier ones write, so those files are the
# dependencies - not the phony targets. A phony prerequisite would rebuild the
# 30,000-name corpus on every verdict run; a file one reuses what is there and
# collects only when it is missing. Delete the file to force a fresh collection.
#
# A collector killed halfway leaves a partial file, which Make would otherwise
# treat as a finished one and every later stage would read as the corpus.
.DELETE_ON_ERROR:

$(CORPUS):
uv run python scripts/collect_domains_diverse.py -o $@ --limit 30000

$(ADVERSARIAL): $(CORPUS)
uv run python scripts/collect_domains_adversarial.py --csv $< -o $@

$(CASSETTES): $(ADVERSARIAL)
uv run python scripts/cassette.py record --csv $< -o $@ --types A,MX

test-corpus: $(ADVERSARIAL) ## Build the adversarial corpus (network, ~10 min)

test-verdicts: $(ADVERSARIAL) ## Verdict differential and flap detection (network, ~1h)
uv run python scripts/verdict_harness.py --csv $(ADVERSARIAL) -o verdicts.csv --escalate 8

test-record: $(CASSETTES) ## Record cassettes for offline replay (network)

# The only target here that must never reach the network, so it asks for the
# cassettes rather than depending on them: a missing file is an instruction to
# the operator, not a licence to spend ten minutes recording one.
test-offline: ## Replay cassettes under every order and fault, then mutate (no network)
@test -f $(CASSETTES) || { echo "$(CASSETTES) not found; run 'make test-record' first (needs network)" >&2; exit 1; }
uv run python scripts/cassette.py replay --cassettes $(CASSETTES)
uv run python scripts/cassette.py perturb --cassettes $(CASSETTES)
uv run python scripts/mutation_check.py --cassettes $(CASSETTES)

test-protocol: test-corpus test-verdicts test-record test-offline ## The whole protocol, before a release
Comment thread
coderabbitai[bot] marked this conversation as resolved.

docker-shell: ## Drop into a Docker shell with uv, make, dig and deps installed
docker build -t recursive-resolver .
docker run --rm -it -v $(CURDIR):/app -w /app recursive-resolver
Expand Down
30 changes: 22 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Validation is **on by default** and chains from the IANA root trust anchors
| State | Meaning | Behaviour |
|---|---|---|
| `SECURE` | Signed and validated back to the root | Returned |
| `INSECURE` | Provably unsigned (an ancestor proved no DS exists), or signed only with an algorithm this build cannot verify | Returned |
| `INSECURE` | Provably unsigned (an ancestor proved no DS exists), signed only with an algorithm this build cannot verify, or proven only by an opt-out NSEC3 or an iteration count this build will not compute | Returned |
| `BOGUS` | Claims to be signed but does not validate | **`DNSSECValidationError`** |

Only `BOGUS` is an error: most of the internet is legitimately unsigned, so
Expand Down Expand Up @@ -147,6 +147,13 @@ This library is designed to be pointed at names an attacker controls, which is
exactly what happens when you verify DKIM on inbound mail.

- **Nameserver address filtering.** Glue records are attacker-controlled data. An address must be globally routable and none of loopback, link-local, multicast, reserved or private: classification that follows the IANA special-purpose registries rather than a hand-maintained CIDR list. A short explicit list then adds the ranges that classification still calls routable, notably Azure's `168.63.129.16`. So a hostile zone cannot steer the resolver at `127.0.0.1` or at a cloud metadata endpoint. Opt out with `allow_private_addresses=True` only for split-horizon DNS you trust.

The filter applies to **nameserver** addresses, not to answers. A zone is
entitled to publish `A 127.0.0.1` for its own name, and this library returns
it, as other implementations do: deciding whether an answer is safe to
connect to is the application's business, not the resolver's. If you are
guarding against DNS rebinding, filter the addresses you get back before you
use them.
- **Query budget.** A shared per-resolution budget bounds total queries (64), failed NS-hostname lookups (5), referrals followed (130) and NS names chased per referral (13, randomly sampled). This is the NXNSAttack / Non-Responsive-Delegation control (CVE-2020-8616, CVE-2020-12662, CVE-2022-3204); without it a hostile zone can provoke tens of thousands of upstream queries from one call.
- **Strict downward progress.** A referral must name a zone *strictly* below the one queried, and the qname must lie at or below it. Sideways and upward referrals are rejected rather than followed in circles.
- **Bailiwick from the query, not the response.** Glue is judged against the zone we asked, never against a zone name the responder supplied.
Expand Down Expand Up @@ -218,7 +225,12 @@ Every failure is a `ResolverError`. Nothing from dnspython escapes.
| `UnsupportedRdtypeError` | Unknown or unqueryable record type |
| `QueryBudgetExceededError` | The work budget was exhausted (likely an attack) |
| `DNSSECValidationError` | Signed data failed validation: **do not use it** |
| `DNSSECInsecureError` | `require_dnssec=True` and the zone is unsigned |
| `DNSSECInsecureError` | `require_dnssec=True` and the answer, or the denial, was not authenticated |
| `DNSSECMaterialUnavailableError` | A DNSKEY or DS could not be fetched, so no verdict was reached |

`DNSSECMaterialUnavailableError` is deliberately **not** a `DNSSECError`: a lame
nameserver says nothing about the zone, so it must not read as tampering. Code
that treats `DNSSECError` as "refuse to proceed" keeps working unchanged.

## Configuration

Expand All @@ -240,6 +252,7 @@ RecursiveResolver(
extra_blocked_networks=None, # further CIDRs to refuse, added to the built-ins
idna_codec=None, # defaults to IDNA 2008 (practical)
trust_anchors=None, # defaults to the IANA root anchors
clock_skew=60, # slack on an RRSIG's inception, in seconds
)
```

Expand All @@ -262,7 +275,7 @@ RecursiveResolver(limits=Limits(
))
```

The defaults are the values Unbound and PowerDNS ship. Raise them only
The defaults match what the major implementations ship. Raise them only
against a measured legitimate name that needs the headroom.

## How it works
Expand All @@ -272,15 +285,16 @@ against a measured legitimate name that needs the headroom.
3. **Follow referrals downward**, verifying at each step that the delegation descends and stays in bailiwick.
4. **Resolve glueless NS hostnames** when a referral carries no usable glue, under the shared budget.
5. **Validate DNSSEC** at every zone cut: DS against the parent's keys, DNSKEY against the DS, answers against the DNSKEY, and NSEC/NSEC3 proofs for negative answers.
6. **Chase CNAMEs**, using target records already present in the response when the server supplied them.
6. **Chase CNAMEs**, using target records already present in the response when the server supplied them, and perform the CNAME synthesis a DNAME implies (RFC 6672) when the server did not.
7. **Cache** answers, negatives (NXDOMAIN by name per RFC 2308/8020) and delegations, with TTLs from the wire and negative TTLs from the SOA.

## Testing

The suite is 520 tests at 100% coverage: 483 unit tests with mocked DNS and 37
integration tests against live DNS, including deliberately-bogus DNSSEC test
domains (`dnssec-failed.org`, `rhybar.cz`, `bogus.nlnetlabs.nl`) that must be
rejected, and real DKIM selectors that must round-trip byte-exactly.
The suite is 723 tests at 100% statement and branch coverage: 686 unit tests
with mocked DNS and 37 integration tests against live DNS, including
deliberately-bogus DNSSEC test domains (`dnssec-failed.org`, `rhybar.cz`,
`bogus.nlnetlabs.nl`) that must be rejected, and real DKIM selectors that must
round-trip byte-exactly.

`tests/test_security.py` is a regression test per defect found in the
pre-release audit: SSRF via glue, NXNS amplification, referral ping-pong,
Expand Down
Loading
Loading