recursive-resolver
A pure-Python library that performs true iterative DNS resolution from the root servers, with DNSSEC validation. Unlike a stub resolver that forwards queries to whatever is in /etc/resolv.conf, this library starts at the DNS root and follows the delegation chain itself, so you see what the authoritative servers actually say, not what an intermediate cache decided to remember.
Built for high-assurance lookups where the answer matters: DKIM and DMARC key retrieval, SPF evaluation, certificate validation, and DNS auditing.
Uses dnspython for wire-format parsing, transport and DNSSEC primitives; the iteration algorithm, the security policy and the caching are implemented here.
When not to use it. A cold walk from the root costs several round trips, so
this is the wrong tool for ordinary application traffic: for that, keep using
your system resolver or dnspython's stub, both of which sit behind a warm
shared cache. Reach for this library when you need to know what the
authoritative servers actually say and to prove it cryptographically. Note also
that queries go over IPv4 by default, so set ipv4_only=False on an IPv6-only
host.
pip install recursive-resolver
Quick start
from recursive_resolver import RecursiveResolver
resolver = RecursiveResolver()
resolver.resolve("example.com", "A")
# ['104.20.23.154', '172.66.147.243']
answer = resolver.resolve_answer("cloudflare.com", "A")
answer.records # ['104.16.132.229', '104.16.133.229']
answer.dnssec # <ValidationState.SECURE: 'secure'>
answer.secure # True
The addresses shown here and in the CLI examples below are a snapshot taken in August 2026. They are live CDN records and will differ when you run this; only the shapes and the DNSSEC verdicts are part of the API.
Multi-chunk TXT records
This is the one API detail worth reading. A TXT record is a sequence of
<character-string> chunks of at most 255 octets, so any longer value arrives
split. RFC 6376 (DKIM) and RFC 7208 (SPF) both require the chunks to be joined
with no separator; DNS presentation format renders them as "chunk1" "chunk2".
Code that strips the quotes naively corrupts the value. An RSA-2048 DKIM key
does not fit in one chunk, and publishers may split it at any boundary they
like, into two chunks or more — so the invariant to hold on to is
concatenation with no separator. This is the difference between a key that
verifies and one that never does.
answer = resolver.resolve_answer("zendesk1._domainkey.zendesk.com", "TXT")
answer.text_values() # ✅ ['v=DKIM1;t=s;n=core;k=rsa;p=MIIBIjANBgkq…'] : correct
answer.records # ⚠️ ['"v=DKIM1;…MII" "BIjANBgkq…"'] : has a seam
answer.rrset[0].strings # (b'v=DKIM1;…MII', b'BIjANBgkq…') : raw chunks
Use text_values() for any TXT-like record, or resolve_rrset() when you need
the raw dnspython rdata objects.
DNSSEC
Validation is on by default and chains from the IANA root trust anchors (KSK-2017 and KSK-2024). Every answer carries its state:
| State | Meaning | Behaviour |
|---|---|---|
SECURE |
Signed and validated back to the root | 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
rejecting INSECURE would reject the majority of domains. A zone signed with
an algorithm this build cannot verify is INSECURE rather than BOGUS, per
RFC 4035 §5.2: not being able to check a zone is not evidence against it, and
treating it as an attack would take a legitimate domain off the air. If you
need authenticated data and nothing less:
resolver = RecursiveResolver(require_dnssec=True)
resolver.resolve("google.com", "A") # DNSSECInsecureError: unsigned zone
resolver.resolve("cloudflare.com", "A") # fine: signed
This matters most when the record itself is a credential. A DKIM key, a CAA
policy and an SSHFP fingerprint are all trust decisions delegated to DNS, so
whoever can spoof the response picks the answer. DNSSEC closes that hole for
signed zones whose answers validate. It cannot close it for an unsigned zone,
and the default RecursiveResolver() returns those answers as INSECURE
rather than refusing them — so if a credential's zone must be signed for you to
trust the value, say so with require_dnssec=True and handle the error.
DNSSEC needs the cryptography package, which ships as part of the default
install. To go without it: RecursiveResolver(dnssec=False).
Caching
There is a built-in cache, on by default. It holds answers, negatives and delegations; answers and delegations are controlled separately. That matters because the reasons to cache each are not the same.
RecursiveResolver(
cache_enabled=True, # master switch
cache_answers=True, # cache final answer RRsets
max_delegation_cache_depth="all", # "tld", "all", "none", or a label depth
min_ttl=0, # 0 honours the wire TTL exactly
)
Zone cuts (root -> com -> example.com) are cached separately from answers.
Do not turn that off in production. With max_delegation_cache_depth="none"
every upstream cache miss begins with a query to a root server, which at any
real volume is abusive to the root operators and will get you rate-limited.
(Answer and negative hits still short-circuit, so this is not literally every
call — but it is every call that has to go out on the wire.)
When freshness matters, such as key rotation or GSLB failover, keep delegations cached but not answers:
resolver = RecursiveResolver(cache_answers=False) # fresh answers, cheap path to them
To cache only the root -> TLD cuts and re-walk everything below on each query:
resolver = RecursiveResolver(max_delegation_cache_depth=1)
Concurrent lookups of the same name are collapsed into a single walk, so a thread pool hammering one domain does not produce N independent query storms.
Security
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 at127.0.0.1or at a cloud metadata endpoint. Opt out withallow_private_addresses=Trueonly 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.1for 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.
-
AA required, answers matched on class as well as type,
NXDOMAIN-carrying-answers rejected, truncated responses never treated as complete. -
EDNS payload 1232 (DNS Flag Day 2020), with a downgrade ladder to 512 and then to plain DNS, so a broken-PMTU path does not silently blackhole large responses.
-
KeyTrap hardening (CVE-2023-50387 / CVE-2023-50868). A zone publishing many DNSKEYs and RRSIGs that collide on key tag can force quadratic signature verification. At most 2 keys and 2 signatures per (tag, algorithm) are tried, at most 8 signatures per RRset, and the whole resolution is bounded to 96 signature verifications and 600 NSEC3 hashes. NSEC3 iteration counts above 100 are refused.
-
IDNA 2008. IDNA 2003 maps
ßtoss, which would resolvestraße.deas the entirely different, separately registrablestrasse.de.
See SECURITY.md for the threat model and reporting process.
CLI
recursive-resolver example.com # A records
recursive-resolver example.com MX # a specific type
recursive-resolver --text s1._domainkey.stripe.com TXT # joined TXT chunks
recursive-resolver --trace example.com # full delegation trace
recursive-resolver --json example.com MX # JSON output
recursive-resolver --require-dnssec example.com # fail unless authenticated
recursive-resolver 8.8.8.8 PTR # reverse lookup
python -m recursive_resolver example.com # as a module
recursive-resolver --help lists every option.
The DNSSEC verdict goes to stderr, using the same wording as delv, so
that plain output is never silently unvalidated while stdout stays pipeable:
$ recursive-resolver cloudflare.com A
; fully validated
104.16.132.229
104.16.133.229
$ recursive-resolver google.com A
; unsigned answer
142.251.39.174
$ recursive-resolver cloudflare.com A 2>/dev/null # just the values
104.16.132.229
104.16.133.229
API
resolver.resolve(name, rdtype) # -> list[str] presentation format
resolver.resolve_rrset(name, rdtype) # -> dns.rrset.RRset raw rdata
resolver.resolve_answer(name, rdtype) # -> Answer records + DNSSEC state
resolver.trace_answer(name, rdtype) # -> (Answer | None, list[TraceStep])
RecursiveResolver is thread-safe; share one instance across a thread pool to
get the benefit of the shared cache and query deduplication.
Exceptions
Every failure is a ResolverError. Nothing from dnspython escapes.
| Exception | Raised when |
|---|---|
NXDOMAINError |
The name does not exist |
NoAnswerError |
The name exists but has no records of that type |
CNAMELoopError |
A CNAME loop or over-long chain |
ServfailError |
Every nameserver returned an error |
ResolutionTimeoutError |
Nameservers timed out, or the deadline elapsed |
MaxDepthError |
Delegation depth exceeded |
InvalidNameError |
Malformed name (bad label, too long, IDNA failure) |
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 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
RecursiveResolver(
timeout=2.0, # per-query timeout
max_resolution_time=15.0, # hard wall-clock cap per resolve()
max_depth=20, # delegation depth
max_cname_chain=10, # CNAME follows
max_retries=2, # retries per nameserver (each downgrades EDNS)
limits=Limits(), # hardening limits; see below
edns_payload=1232,
dnssec=True,
require_dnssec=False,
require_authoritative=True, # demand the AA bit
ipv4_only=True,
use_tcp_fallback=True,
allow_private_addresses=False,
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
)
Limits
The bounds that stop a hostile zone from making you do unbounded work live in
one object, because they move as a set: raising max_queries without raising
max_referrals only changes which counter fires first.
from recursive_resolver import Limits, RecursiveResolver
RecursiveResolver(limits=Limits(
max_queries=64, # total upstream queries per resolve()
max_ns_per_referral=13, # NS hostnames chased per referral
max_nx_targets=5, # NS hostnames allowed to fail (NXNSAttack)
max_referrals=130, # referrals followed per resolve()
max_signature_validations=96, # RRSIG verifications (KeyTrap)
max_nsec3_hashes=600, # NSEC3 hashes (KeyTrap)
))
The defaults match what the major implementations ship. Raise them only against a measured legitimate name that needs the headroom.
How it works
- Start at the root (or at the deepest cached delegation) using hardcoded root hints.
- Query with RD=0 so servers answer only from their own authority.
- Follow referrals downward, verifying at each step that the delegation descends and stays in bailiwick.
- Resolve glueless NS hostnames when a referral carries no usable glue, under the shared budget.
- 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.
- 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.
- 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 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,
upward-referral crash, truncation acceptance, and the rest.
make check # lint, format, types and the offline tests
make check-all # everything, including live DNS and the coverage gate
make test # offline tests only
make test-integration # live-DNS tests only
make coverage-all # full coverage report (HTML in htmlcov/)
Differential testing against reference resolvers
A separate harness compares this resolver against dig (or any reference) over
a deliberately awkward corpus: Tranco sampled across popularity bands, every
IANA TLD including the IDN ones, multi-label public suffixes, underscore
subdomains, and curated pathological cases.
python scripts/collect_domains_diverse.py -o domains.csv
python scripts/diff_harness.py --csv domains.csv
The most recent run: 99.76% agreement over 39,713 comparisons (3,983 names across
10 record types). Of the 95 remaining differences, 37 are CDN answers that vary
per resolver, 16 are TLD zones publishing a rotating timestamp, 8 are zones
whose own nameservers disagree with each other, and 34 are authoritative servers
unreachable from the test host. No non-ResolverError exception escaped the
public API. See CONTRIBUTING.md for how to read the output.
License
MIT, © 2025-2026 Sylvain Zimmer.
Every runtime dependency is permissive (ISC, Apache-2.0/BSD, MIT-0, BSD-3-Clause); nothing copyleft is pulled in. THIRD-PARTY.md records the full dependency licensing, the two files adapted from another MIT project, and the terms of the data the test-corpus scripts download.
See CHANGELOG.md for release history and CONTRIBUTING.md for the development process, the release process and the policy on AI-assisted contributions.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file recursive_resolver-0.2.0.tar.gz.
File metadata
- Download URL: recursive_resolver-0.2.0.tar.gz
- Upload date:
- Size: 215.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.15
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
58e8f8f086915dfdc142b254163c739d5adac51ccf746d73f3fe318e084cfef8
|
|
| MD5 |
94fff92b8a47cb78bab571c653215c19
|
|
| BLAKE2b-256 |
4e8100be33403bd855deef6cd05cb67459809879de1ddb6dd72d9d92d24c4056
|
File details
Details for the file recursive_resolver-0.2.0-py3-none-any.whl.
File metadata
- Download URL: recursive_resolver-0.2.0-py3-none-any.whl
- Upload date:
- Size: 73.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.15
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8305c86a75eab5fd6ccae943993f800f9361ab54c604afcf8eee6843c9ca27b2
|
|
| MD5 |
80b92635d5ef2fae3256633df44cb7fd
|
|
| BLAKE2b-256 |
f375114ea562329a08f6ebfb82ba4576a8c2853038453be2d967800c19b4d6bc
|