diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..ad47c10 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,44 @@ +name: Bug report +description: A wrong answer, a crash, or a resolution that should have succeeded +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + For anything security-sensitive, please use a + [private advisory](https://github.com/sylvinus/recursive-resolver/security/advisories/new) + instead of a public issue. + - type: input + id: domain + attributes: + label: Domain and record type + description: The exact query, so the behaviour can be reproduced. + placeholder: "s1._domainkey.example.com TXT" + validations: + required: true + - type: textarea + id: trace + attributes: + label: Delegation trace + description: Output of `recursive-resolver --trace `. This is usually enough to diagnose the problem on its own. + render: text + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected versus actual + description: >- + What you expected, what happened, and what a reference resolver says. + Please query an explicit resolver rather than the system stub — + `dig +dnssec @1.1.1.1 ` — because a stub can synthesise + a CNAME of its own and the comparison then is not like for like. + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: Output of `recursive-resolver --version` and your Python version. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..f5d6bc8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/sylvinus/recursive-resolver/security/advisories/new + about: Please report security issues privately, never as a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..bf1dc79 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,20 @@ +name: Feature request +description: Suggest a capability or a change in behaviour +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: What problem does this solve? + description: The use case, not the implementation. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed behaviour + - type: textarea + id: rfc + attributes: + label: Relevant RFCs or prior art + description: How do BIND, Unbound, Knot or PowerDNS handle this? diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..f09a058 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## What and why + + + +## Checklist + +- [ ] `make check` passes (lint, format, types, offline tests) +- [ ] New behaviour has tests; security-relevant changes have a regression test in `tests/test_security.py` +- [ ] `CHANGELOG.md` updated +- [ ] If a defensive check was relaxed, the reasoning is stated in the PR body +- [ ] I am the author of this change and can stand behind it in review. If an AI + tool helped write it, I have read and understood the result, and no AI is + credited as an author or co-author (see CONTRIBUTING.md) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9074291 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,113 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + schedule: + # Weekly, so root-hint / trust-anchor drift and upstream breakage surface + # before a release rather than after one. + - cron: "17 5 * * 1" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + # --python is explicit on purpose: without it uv picks its default + # interpreter, and every matrix job could silently test the same one. + - name: Install + run: uv sync --all-extras --python ${{ matrix.python-version }} + - name: Lint + run: uv run ruff check src/ tests/ scripts/ + - name: Format check + run: uv run ruff format --check src/ tests/ scripts/ + - name: Type check + run: uv run mypy src/ + - name: Unit tests + run: uv run pytest -m "not integration" -q --no-cov + + authors: + name: No AI co-authors + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + # The whole history, so the base commit of the PR is present. + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Check commit authorship + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: python scripts/check_authors.py "$BASE_SHA..$HEAD_SHA" + + integration: + runs-on: ubuntu-latest + # Live DNS: without a bound, a hang runs to the 6-hour default. + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@v5 + - run: uv python install 3.13 + - run: uv sync --all-extras --python 3.13 + - name: Full suite (live DNS + DNSSEC), 100% statement and branch coverage enforced + run: uv run pytest -q --cov + + build: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@v5 + - run: uv python install 3.13 + - name: Build sdist and wheel + run: uv build + - name: Check metadata + run: uvx twine check dist/* + - name: Verify the wheel ships py.typed + # `uv run --no-project python` rather than a bare `python`: this job + # never runs setup-python, and `uv python install` does not promise a + # `python` on PATH. zipfile also takes a single archive, so assert + # that dist/ holds exactly one wheel before looking inside it. + run: | + shopt -s nullglob + wheels=(dist/*.whl) + if [ ${#wheels[@]} -ne 1 ]; then + echo "expected exactly one wheel in dist/, found ${#wheels[@]}"; exit 1 + fi + uv run --no-project python -m zipfile -l "${wheels[0]}" \ + | grep -q 'recursive_resolver/py.typed' \ + || { echo "py.typed missing from the wheel"; exit 1; } + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..afbf4b4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +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.1.0] - unreleased + +First public release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..efec134 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,178 @@ +# Contributing + +## Development setup + +```bash +make install # uv sync +make test # unit tests (no network) +make test-integration # integration tests (needs network) +make coverage # coverage report +make lint # ruff +make format # ruff format +make typecheck # mypy +make check # lint, format, types and the offline tests +``` + +A Docker shell with `uv`, `make`, `dig` and the dependencies preinstalled: + +```bash +make docker-shell +``` + +## AI-assisted contributions + +Using an AI tool to help write a patch is fine. Three conditions apply. + +**Merging is best-effort, and nothing more is promised.** A patch that arrives +with a lot of generated code still has to be read, understood and maintained by +someone. Large or unfocused submissions may be closed without a detailed review. +Small, well-scoped changes with tests are far more likely to land. + +**The MIT licence still has to hold.** You are submitting the patch under +[LICENSE](LICENSE), so you need to be satisfied that nothing in it was copied +from an incompatible source. Generated code can reproduce training data, and +several of the reference resolvers cited in this project are copyleft. See +[THIRD-PARTY.md](THIRD-PARTY.md) for where the line sits. + +**The human author is responsible.** You are the author of anything you submit, +whatever produced the first draft: you are expected to understand it, to stand +behind it in review, and to have run the tests. Do not credit an AI tool as an +author or co-author. `Co-authored-by:` trailers naming Claude, Codex, Copilot or +any other tool will be removed before merge. This is enforced: CI runs +`scripts/check_authors.py` over every commit in a pull request, checking the +author, the committer and any `Co-authored-by:` trailer. + +## Licensing + +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 +against the state of the art. Their code, documentation text and curated data +(prefix lists, tables) are not, and must not be pasted in. + +If you adapt from a permissively-licensed project, put the copyright notice in +the file header and add a row to `THIRD-PARTY.md`. + +## Guidelines + +- **Security changes need a regression test.** `tests/test_security.py` is + organised by the attack each control prevents; add yours there with a comment + explaining what breaks without it. +- **Do not loosen a defensive check without saying why.** The referral, + bailiwick, AA, class and address rules each exist because a specific attack or + real-world failure was observed. `CHANGELOG.md` records which. +- **Keep dnspython exceptions inside the resolver.** Everything raised out of + the public API must be a `ResolverError`. Bare `except Exception` is not + 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`. +- Integration tests go behind the `integration` marker. + +## Differential testing + +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. + +```bash +# Build a deliberately awkward corpus (~4k names). +python scripts/collect_domains_diverse.py -o domains.csv + +# Compare against dig, all supported types, one configuration. +python scripts/diff_harness.py --csv domains.csv + +# Compare configurations against each other on a sample. +python scripts/diff_harness.py --csv domains.csv --sample 250 \ + --types A,MX,TXT,NS --configs default,no-dnssec,no-cache,no-tcp,small-edns,ipv6 +``` + +The corpus is assembled from Tranco sampled across popularity bands (the tail is +where broken configurations live), every IANA TLD including the IDN ones, +multi-label public suffixes, mail and underscore subdomains layered onto popular +domains, and a curated set of pathological cases: signed and deliberately-bogus +zones, IDN homographs, shared zone cuts, empty non-terminals, CNAME chains. + +Two things matter when reading the output: + +* The reference is queried **by explicit IP**, never through the system stub. A + local `systemd-resolved` synthesises a self-referential `CNAME` with TTL 0 for + names that have none, which makes every CNAME query look like a mismatch. +* Several references are queried and matching **any** of them counts as + agreement. Zones whose parent delegation disagrees with their own NS RRset + make public recursives disagree with each other; those are reported separately + rather than counted against either resolver. + +`scripts/prepare_test_domains.py` and the `collect_domains_*.py` scripts build +alternative corpora, and `tests/test_csv.py` runs a smaller version under +pytest: + +```bash +make test-from-csv CSV=domains.csv +``` + +By default this covers every supported type that a list of apex domains can +exercise: `A, AAAA, MX, TXT, NS, SOA, CAA, CNAME, DS, DNSKEY`. A type the domain +does not publish yields NODATA on both sides, which is still a meaningful +agreement. + +`PTR`, `SRV` and `NAPTR` are excluded from this harness because apex domains +cannot exercise them: PTR needs an IP address and SRV/NAPTR need +`_service._proto` labels. They are covered by the unit and integration suites. + +Mismatches are written to a timestamped JSONL file. The test asserts an overall +match rate of at least 96%; the residual differences are CDN round-robin and +domains with genuinely dead nameservers. DNSSEC is disabled for this comparison +because `dig +short` does not validate either. + +## Releasing + +1. **Bump the version** in both `pyproject.toml` and + `src/recursive_resolver/__init__.py`, and add a `CHANGELOG.md` entry. +2. **Run `scripts/release.sh`.** + +```bash +./scripts/release.sh +``` + +The script is interactive and every step is a y/N gate, so you can read what it +found before continuing. It: + +1. checks the tree is clean, the two version strings agree, the changelog has an + entry and the tag does not already exist; +2. runs `make check-all`; +3. builds the sdist and wheel **inside Docker**, so the host never needs `pip`, + `build` or `twine`, and runs `twine check`; +4. inspects the artifacts against what this package promises: `py.typed` + present, no tests or scripts in the wheel, LICENSE bundled, and the + `dnspython` floor at or above the 2.8.0 security floor with the `dnssec` and + `idna` extras. It reads these out of the built wheel's `METADATA`, not out + of `pyproject.toml`, so a build that lost them is caught; +5. uploads to TestPyPI, then installs from there in a throwaway container and + **actually resolves a name**, checking that a signed zone validates and that + a multi-chunk DKIM key joins without a separator; +6. uploads to PyPI; +7. tags, pushes, and creates the GitHub release with the artifacts attached and + the changelog section as the notes. + +`SKIP_GATES=1` skips step 2 on a retry; `SKIP_TESTPYPI=1` goes straight to PyPI. + +### Prerequisites + +Docker, git, and, for the last step only, an authenticated `gh` CLI. You will +be prompted for the TestPyPI and PyPI API tokens; they are read into the +container's environment and never written to disk. + +### Root hints and trust anchors + +`src/recursive_resolver/roots.py` contains hardcoded root server addresses and +DNSSEC trust anchors. Re-check them against the upstream sources when preparing +a release: + +- Root hints: +- Trust anchors: + +Only anchors that are currently valid belong in the file: retired ones (such as +KSK-2010, tag 19036) must be removed, not kept "just in case". diff --git a/Dockerfile b/Dockerfile index 340164d..f84b207 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,16 +5,18 @@ RUN apt-get update -qq && \ curl dnsutils make && \ rm -rf /var/lib/apt/lists/* -COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/ +# Pinned, not :latest: a floating tag makes the image non-reproducible and +# pulls unreviewed uv releases into the build. Bump this deliberately. +COPY --from=ghcr.io/astral-sh/uv:0.12.3 /uv /uvx /usr/local/bin/ WORKDIR /app # Install deps first for caching COPY pyproject.toml ./ -RUN uv sync --no-install-project +RUN uv sync --no-install-project --all-extras # Copy source COPY . . -RUN uv sync +RUN uv sync --all-extras CMD ["bash"] diff --git a/LICENSE b/LICENSE index 7f5910f..dd24f63 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Sylvain Zimmer +Copyright (c) 2025-2026 Sylvain Zimmer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index 14405fe..9b8a983 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,16 @@ .DEFAULT_GOAL := help CSV ?= domains.csv +SOURCES := src/ tests/ scripts/ -.PHONY: help install test test-integration test-from-csv coverage lint format typecheck build clean docker-shell \ - release-check release-build release-shell release-test-pypi release-pypi release-github release +.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 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}' install: ## Install all dependencies - uv sync + uv sync --all-extras test: ## Run unit tests (no network) uv run pytest -m "not integration" -v @@ -17,21 +18,32 @@ test: ## Run unit tests (no network) test-integration: ## Run integration tests (requires network) uv run pytest -m integration -v -test-from-csv: ## Bulk test from CSV file (CSV=path/to/file.csv) +test-from-csv: ## Bulk differential test against dig (CSV=path/to/file.csv) uv run pytest tests/test_csv.py --csv=$(CSV) -v -coverage: ## Run tests with coverage report - uv run pytest --cov=recursive_resolver --cov-report=term-missing --cov-report=html -m "not integration" +coverage: ## Offline coverage report (HTML in htmlcov/); does not meet the gate alone + uv run pytest -m "not integration" --cov --cov-report=html --cov-fail-under=0 -lint: ## Lint source and tests - uv run ruff check src/ tests/ +coverage-all: ## Full coverage including live DNS (this is the enforced gate) + uv run pytest --cov --cov-report=html -format: ## Auto-format source and tests - uv run ruff format src/ tests/ +lint: ## Lint source, tests and scripts + uv run ruff check $(SOURCES) + +format: ## Auto-format source, tests and scripts + uv run ruff format $(SOURCES) typecheck: ## Run type checking uv run mypy src/ +check: lint typecheck ## Lint, typecheck and run the offline tests + uv run ruff format --check $(SOURCES) + uv run pytest -m "not integration" -q --no-cov + +check-all: lint typecheck ## Everything, including live DNS and the coverage gate + uv run ruff format --check $(SOURCES) + uv run pytest -q --cov + build: ## Build sdist and wheel uv build @@ -39,59 +51,30 @@ 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 -release-shell: release-build ## Test the built wheel in a clean Docker environment - docker run --rm -it -v $(CURDIR)/dist:/dist python:3.13-slim bash -c '\ - pip install --quiet /dist/recursive_resolver-$(VERSION)-py3-none-any.whl && \ - echo "Installed recursive-resolver $(VERSION)" && \ - echo "Try: recursive-resolver example.com" && \ - echo " recursive-resolver --trace example.com" && \ - echo " python -c \"from recursive_resolver import RecursiveResolver; print(RecursiveResolver().resolve(\\\"example.com\\\"))\"" && \ - exec bash' - clean: ## Remove build artifacts - rm -rf dist/ build/ htmlcov/ .coverage .pytest_cache .mypy_cache + rm -rf dist/ build/ htmlcov/ .coverage .pytest_cache .mypy_cache .ruff_cache find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true find . -type f -name '*.pyc' -delete 2>/dev/null || true -# ── Release targets ─────────────────────────────────────────────────── +# ── Release ─────────────────────────────────────────────────────────── +# +# Releases are driven by scripts/release.sh, which is interactive and builds, +# checks, publishes and tags from inside Docker. See CONTRIBUTING.md. -VERSION := $(shell python3 -c "import re; print(re.search(r'version = \"(.+?)\"', open('pyproject.toml').read()).group(1))") +# Anchored with re.M, matching the `awk -F'"' '/^version = /'` lookup in +# scripts/release.sh: unanchored, this returns the first `version = "..."` +# anywhere in the file, and the two release paths could disagree. +VERSION := $(shell python3 -c "import re; print(re.search(r'^version = \"(.+?)\"', open('pyproject.toml').read(), re.M).group(1))") -release-check: ## Run all checks before a release (lint, typecheck, tests) - @echo "Running pre-release checks..." - uv run ruff check src/ tests/ - uv run ruff format --check src/ tests/ - uv run mypy src/ - uv run pytest -m "not integration" -x -q +release: ## Interactive release to PyPI and GitHub (see CONTRIBUTING.md) + ./scripts/release.sh + +release-check: check-all ## Everything the release script checks, without publishing @echo "Verifying version consistency..." - @PY_VER=$$(python3 -c "import re; print(re.search(r'__version__ = \"(.+?)\"', open('src/recursive_resolver/__init__.py').read()).group(1))"); \ - TOML_VER=$$(python3 -c "import re; print(re.search(r'version = \"(.+?)\"', open('pyproject.toml').read()).group(1))"); \ - if [ "$$PY_VER" != "$$TOML_VER" ]; then \ - echo "ERROR: Version mismatch — __init__.py=$$PY_VER vs pyproject.toml=$$TOML_VER"; exit 1; \ + @PY_VER=$$(python3 -c "import re; print(re.search(r'^__version__ = \"(.+?)\"', open('src/recursive_resolver/__init__.py').read(), re.M).group(1))"); \ + if [ "$$PY_VER" != "$(VERSION)" ]; then \ + echo "ERROR: Version mismatch: __init__.py=$$PY_VER vs pyproject.toml=$(VERSION)"; exit 1; \ fi + @awk -v v="## [$(VERSION)]" 'index($$0, v) == 1 { found = 1; exit } END { exit !found }' CHANGELOG.md \ + || { echo "ERROR: no '## [$(VERSION)]' section heading in CHANGELOG.md"; exit 1; } @echo "All checks passed. Version: $(VERSION)" - -release-build: clean ## Build sdist and wheel for release - uv build - @echo "Built dist/ artifacts for version $(VERSION):" - @ls -lh dist/ - -release-test-pypi: release-build ## Upload to Test PyPI - uv publish --publish-url https://test.pypi.org/legacy/ - @echo "Published $(VERSION) to Test PyPI" - @echo "Install with: pip install --index-url https://test.pypi.org/simple/ recursive-resolver==$(VERSION)" - -release-pypi: release-build ## Upload to PyPI (production) - uv publish - @echo "Published $(VERSION) to PyPI" - -release-github: ## Create a GitHub release with tag - @if git rev-parse "v$(VERSION)" >/dev/null 2>&1; then \ - echo "ERROR: Tag v$(VERSION) already exists"; exit 1; \ - fi - git tag -a "v$(VERSION)" -m "Release v$(VERSION)" - git push origin "v$(VERSION)" - gh release create "v$(VERSION)" dist/* --title "v$(VERSION)" --generate-notes - @echo "GitHub release v$(VERSION) created" - -release: release-check release-pypi release-github ## Full release: checks + PyPI + GitHub diff --git a/README.md b/README.md index e21d0cc..7580c3b 100644 --- a/README.md +++ b/README.md @@ -1,272 +1,327 @@ # recursive-resolver -A pure-Python library that implements **true recursive (iterative) DNS resolution** from root servers. Unlike stub resolvers that forward queries to a local DNS server, this library starts from the DNS root and follows the delegation chain itself. This enables fully cache-less DNS queries, bypassing any intermediate resolver caches. +[![CI](https://github.com/sylvinus/recursive-resolver/actions/workflows/ci.yml/badge.svg)](https://github.com/sylvinus/recursive-resolver/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/recursive-resolver.svg)](https://pypi.org/project/recursive-resolver/) +[![Python](https://img.shields.io/pypi/pyversions/recursive-resolver.svg)](https://pypi.org/project/recursive-resolver/) +[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](#testing) +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/sylvinus/recursive-resolver/blob/main/LICENSE) -Uses [dnspython](https://www.dnspython.org/) only for wire-format parsing and UDP/TCP transport — not its built-in `dns.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. -## Installation +Built for high-assurance lookups where the answer matters: **DKIM and DMARC key retrieval**, SPF evaluation, certificate validation, and DNS auditing. -```bash -pip install recursive-resolver -``` +Uses [dnspython](https://www.dnspython.org/) for wire-format parsing, transport and DNSSEC primitives; the iteration algorithm, the security policy and the caching are implemented here. -Or with [uv](https://docs.astral.sh/uv/): +**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. ```bash -uv add recursive-resolver +pip install recursive-resolver ``` -## CLI Usage - -After installation, the `recursive-resolver` command is available: - -```bash -# Resolve A records -recursive-resolver example.com - -# Resolve a specific record type -recursive-resolver example.com MX - -# Reverse PTR lookup -recursive-resolver 8.8.8.8 PTR +## Quick start -# Show the full DNS delegation trace from root servers -recursive-resolver --trace example.com - -# JSON output -recursive-resolver --json example.com MX +```python +from recursive_resolver import RecursiveResolver -# Trace with JSON output -recursive-resolver --trace --json example.com +resolver = RecursiveResolver() -# Enable debug logging -recursive-resolver -v example.com +resolver.resolve("example.com", "A") +# ['104.20.23.154', '172.66.147.243'] -# Custom timeout and depth -recursive-resolver --timeout 10 --max-depth 30 example.com +answer = resolver.resolve_answer("cloudflare.com", "A") +answer.records # ['104.16.132.229', '104.16.133.229'] +answer.dnssec # +answer.secure # True ``` -You can also run it as a Python module: +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. -```bash -python -m recursive_resolver example.com MX -``` - -Run `recursive-resolver --help` for all options. +### Multi-chunk TXT records -## Quick Start +This is the one API detail worth reading. A TXT record is a sequence of +`` 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. ```python -from recursive_resolver import RecursiveResolver +answer = resolver.resolve_answer("zendesk1._domainkey.zendesk.com", "TXT") -resolver = RecursiveResolver() +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 +``` -# A records -ips = resolver.resolve("example.com", "A") -print(ips) # ['93.184.216.34'] +Use `text_values()` for any TXT-like record, or `resolve_rrset()` when you need +the raw dnspython rdata objects. -# MX records -mx = resolver.resolve("example.com", "MX") -print(mx) # ['0 .'] +## DNSSEC -# PTR (auto-converts IP to reverse pointer) -ptr = resolver.resolve("1.1.1.1", "PTR") -print(ptr) # ['one.one.one.one.'] -``` +Validation is **on by default** and chains from the IANA root trust anchors +(KSK-2017 and KSK-2024). Every answer carries its state: -## Supported DNS Record Types +| 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 | +| `BOGUS` | Claims to be signed but does not validate | **`DNSSECValidationError`** | -A, AAAA, CNAME, MX, TXT, SOA, PTR, NS, SRV, CAA, DNSKEY, DS, NAPTR - -## Configuration +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: ```python -resolver = RecursiveResolver( - timeout=5.0, # per-query timeout in seconds - max_resolution_time=30.0, # hard cap on total wall-clock time per resolve() call - max_depth=20, # max delegation depth - max_cname_chain=10, # max CNAME follows before error - cache_enabled=True, # enable DNS response caching - use_tcp_fallback=True, # TCP fallback on truncation - max_retries=2, # retries per nameserver - ipv4_only=True, # only use IPv4 for queries -) +resolver = RecursiveResolver(require_dnssec=True) +resolver.resolve("google.com", "A") # DNSSECInsecureError: unsigned zone +resolver.resolve("cloudflare.com", "A") # fine: signed ``` -## DNS Resolution Trace +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. -```python -trace = resolver.resolve_with_trace("example.com", "A") -for step in trace: - print(f"{step.server:20s} {step.qname:30s} {step.response_type:10s} {step.detail}") -``` +DNSSEC needs the `cryptography` package, which ships as part of the default +install. To go without it: `RecursiveResolver(dnssec=False)`. -Output: -``` -198.41.0.4 example.com. referral NS: a.gtld-servers.net., ... -192.5.6.30 example.com. referral NS: a.iana-servers.net., ... -199.43.135.53 example.com. answer -``` +## Caching -## Exceptions +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. ```python -from recursive_resolver import ( - ResolverError, # Base exception - NXDOMAINError, # Domain does not exist (DNS NXDOMAIN) - NoAnswerError, # No DNS records of requested type - MaxDepthError, # Exceeded max delegation depth - ResolutionTimeoutError, # All nameservers timed out - CNAMELoopError, # CNAME loop detected - ServfailError, # All nameservers returned errors +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 ) ``` -## How It Works +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.) -1. **Start at the root**: The resolver begins with hardcoded root DNS server IP addresses (a.root-servers.net through m.root-servers.net). +**When freshness matters**, such as key rotation or GSLB failover, keep +delegations cached but not answers: -2. **Send non-recursive queries**: All queries are sent with `RD=0` (Recursion Desired = off), meaning we're asking the server for what it knows directly, not asking it to recurse on our behalf. - -3. **Follow delegations**: When a server doesn't have the final answer, it returns a referral — NS records pointing to DNS nameservers closer to the target. The resolver follows these delegations. - -4. **Handle glue records**: Referrals often include "glue" A/AAAA records in the additional section, providing IP addresses for the referred nameservers. When glue is missing, the resolver sub-resolves the NS hostnames. - -5. **Chase CNAMEs**: When a CNAME is encountered, DNS resolution restarts from the root for the canonical name (since it may be in a completely different zone). - -6. **Cache results**: DNS responses are cached with TTL-based expiry using monotonic time, including negative responses (NXDOMAIN/NODATA). - -## Reliability +```python +resolver = RecursiveResolver(cache_answers=False) # fresh answers, cheap path to them +``` -### Built on dnspython +To cache only the root -> TLD cuts and re-walk everything below on each query: -This library does not implement DNS wire format parsing or UDP/TCP transport from scratch. All low-level DNS operations are handled by [dnspython](https://www.dnspython.org/), one of the most mature and widely-used DNS libraries in the Python ecosystem (first released in 2001). We use it for: +```python +resolver = RecursiveResolver(max_delegation_cache_depth=1) +``` -- Building and parsing DNS messages (`dns.message`) -- UDP and TCP transport with timeout handling (`dns.query`) -- Record type and rcode constants (`dns.rdatatype`, `dns.rcode`) +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. -What we implement on top of dnspython is the **iterative resolution algorithm** — the logic that starts at root servers, follows delegations, chases CNAMEs, handles glueless referrals, and enforces bailiwick checking. This is the part that `dns.resolver` (dnspython's built-in stub resolver) delegates to your system's recursive resolver. +## Security -### Test suite +This library is designed to be pointed at names an attacker controls, which is +exactly what happens when you verify DKIM on inbound mail. -The test suite has **97% code coverage** across 92 tests at three levels: +- **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. +- **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 `ß` to `ss`, which would resolve `straße.de` as the entirely different, separately registrable `strasse.de`. -- **81 unit tests** (no network) — resolver logic is tested with mocked DNS responses covering: delegation chains, CNAME following, NXDOMAIN/NODATA, glueless referrals, CNAME loops, max depth, timeout/retry behavior, TCP fallback, EDNS0 fallback, PTR auto-reverse, cache hits, negative caching, bailiwick validation, deadline enforcement, and CLI argument handling. -- **11 integration tests** (real DNS) — live queries against well-known domains verifying A, AAAA, MX, TXT, NS, SOA, PTR, CNAME chains, NXDOMAIN errors, trace output, and cache speedup. -- **Bulk CSV tests** — automated comparison against `dig` on thousands of real domains (see below). +See [SECURITY.md](https://github.com/sylvinus/recursive-resolver/blob/main/SECURITY.md) for the threat model and reporting process. -Run the full suite: +## CLI ```bash -make test # Unit tests (fast, no network) -make test-integration # Integration tests (requires network) -make coverage # Unit tests with coverage report +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 ``` -### Large-scale validation against dig +`recursive-resolver --help` lists every option. -We validated the resolver against `dig` (the reference DNS tool) on over **200,000 queries** across **115,000+ real-world domains** and **7 record types** (A, AAAA, MX, TXT, NS, SOA, CAA). The only differences are CDN round-robin (different valid IPs returned to different resolvers) and a handful of domains with dead nameservers. +The DNSSEC verdict goes to **stderr**, using the same wording as `delv`, so +that plain output is never silently unvalidated while stdout stays pipeable: -#### Reproduce the bulk tests +```console +$ recursive-resolver cloudflare.com A +; fully validated +104.16.132.229 +104.16.133.229 -```bash -# 1. Generate a domain list from data.gouv.fr public dataset -python scripts/prepare_test_domains.py -o domains.csv +$ recursive-resolver google.com A +; unsigned answer +142.251.39.174 -# 2. Run against dig with default types (A, MX) -make test-from-csv CSV=domains.csv +$ recursive-resolver cloudflare.com A 2>/dev/null # just the values +104.16.132.229 +104.16.133.229 +``` -# 3. Run with all record types on a sample of 5,000 domains -uv run pytest tests/test_csv.py --csv=domains.csv \ - --types=A,AAAA,MX,TXT,NS,SOA,CAA --sample=5000 -s +## API -# 4. Or bring your own domain list — any CSV with a "domain" column works -echo "domain" > my_domains.csv -echo "example.com" >> my_domains.csv -echo "wikipedia.org" >> my_domains.csv -make test-from-csv CSV=my_domains.csv +```python +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]) ``` -Failures are written to a timestamped JSONL file for inspection. The test asserts an overall match rate of at least 96%. - -## Development +`RecursiveResolver` is thread-safe; share one instance across a thread pool to +get the benefit of the shared cache and query deduplication. -```bash -# Install dependencies -make install +### Exceptions -# Run unit tests (no network needed) -make test +Every failure is a `ResolverError`. Nothing from dnspython escapes. -# Run integration tests (requires network) -make test-integration +| 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 zone is unsigned | -# Coverage report -make coverage +## Configuration -# Lint and format -make lint -make format +```python +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 +) +``` -# Type check -make typecheck +### Limits -# Build package -make build +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. -# Bulk test DNS resolution against dig -python scripts/prepare_test_domains.py -o domains.csv -make test-from-csv CSV=domains.csv +```python +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) +)) ``` -Run `make help` to see all available targets. - -## Releasing +The defaults are the values Unbound and PowerDNS ship. Raise them only +against a measured legitimate name that needs the headroom. -Releases are published to both [PyPI](https://pypi.org/project/recursive-resolver/) and [GitHub Releases](https://github.com/sylvinus/recursive-resolver/releases). +## How it works -### 1. Bump the version +1. **Start at the root** (or at the deepest cached delegation) using hardcoded root hints. +2. **Query with RD=0** so servers answer only from their own authority. +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. +7. **Cache** answers, negatives (NXDOMAIN by name per RFC 2308/8020) and delegations, with TTLs from the wire and negative TTLs from the SOA. -Update the version string in **both** files: +## Testing -- `pyproject.toml` → `version = "X.Y.Z"` -- `src/recursive_resolver/__init__.py` → `__version__ = "X.Y.Z"` +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. -### 2. Run pre-release checks +`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. ```bash -make release-check +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/) ``` -This runs lint, format check, type check, unit tests, and verifies the version is consistent between the two files. +### Differential testing against reference resolvers -### 3. Publish +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. ```bash -# Full release: checks + PyPI + GitHub tag & release -make release - -# Or step by step: -make release-build # Build sdist + wheel into dist/ -make release-test-pypi # Upload to Test PyPI first (optional) -make release-pypi # Upload to PyPI (production) -make release-github # Git tag + GitHub release with dist artifacts +python scripts/collect_domains_diverse.py -o domains.csv +python scripts/diff_harness.py --csv domains.csv ``` -`make release-test-pypi` lets you verify the package on [test.pypi.org](https://test.pypi.org/project/recursive-resolver/) before the real publish: +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](https://github.com/sylvinus/recursive-resolver/blob/main/CONTRIBUTING.md) for how to read the output. -```bash -pip install --index-url https://test.pypi.org/simple/ recursive-resolver -``` - -### Prerequisites - -- **PyPI credentials**: Configure a [PyPI API token](https://pypi.org/manage/account/token/) via `UV_PUBLISH_TOKEN` env var, or `~/.pypirc`, or `uv publish --token `. -- **GitHub CLI**: `gh` must be installed and authenticated (`gh auth login`). +## License -Run `make help` to see all available targets. +MIT, © 2025-2026 Sylvain Zimmer. -## License +Every runtime dependency is permissive (ISC, Apache-2.0/BSD, MIT-0, BSD-3-Clause); +nothing copyleft is pulled in. [THIRD-PARTY.md](https://github.com/sylvinus/recursive-resolver/blob/main/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. -MIT — Sylvain Zimmer +See [CHANGELOG.md](https://github.com/sylvinus/recursive-resolver/blob/main/CHANGELOG.md) for release history and +[CONTRIBUTING.md](https://github.com/sylvinus/recursive-resolver/blob/main/CONTRIBUTING.md) for the development process, the release +process and the policy on AI-assisted contributions. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..db4aa39 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,51 @@ +# Security Policy + +## Reporting a vulnerability + +Please report security issues privately via [GitHub Security Advisories](https://github.com/sylvinus/recursive-resolver/security/advisories/new). Please do not open a public issue for an unpatched vulnerability. + +We will triage them with best effort. + +## Threat model + +This library is designed to be pointed at domain names chosen by an attacker. That is the normal case for its intended use: verifying DKIM signatures on inbound mail means resolving names controlled by whoever sent the message. + +**Assumed hostile:** the queried domain's zone contents, its NS records, its glue records, its referrals, its response flags, and the timing and size of its responses. + +**Assumed trustworthy:** the root hints and the IANA root trust anchors compiled into this package, and the local network path (this library does not defend against a full on-path attacker; use `require_dnssec=True` if you need cryptographic assurance). + +### What is defended against + +| Attack | Control | +|---|---| +| **DNS-driven SSRF**: glue pointing at `127.0.0.1`, RFC1918, or `169.254.169.254` to probe internal networks or a cloud metadata endpoint | Every candidate nameserver address must be globally routable and none of loopback, link-local, multicast, reserved or private, per the IANA special-purpose registries as tracked by Python's `ipaddress`. A short explicit list adds ranges that classification still calls routable: Azure's `168.63.129.16`, 6to4 relay anycast, ORCHIDv2 and deprecated site-local. IPv4-mapped IPv6 is unwrapped first so it cannot bypass the v4 rules | +| **NXNSAttack / Non-Responsive Delegation amplification** (CVE-2020-8616, CVE-2020-12662, CVE-2022-3204): a zone that answers every query with a glueless referral to many fresh NS names, using the resolver as a DDoS amplifier | A budget shared across the whole resolution and all sub-resolutions: 64 queries, 5 failed NS targets, 130 referrals, 13 NS names per referral (randomly sampled, not a prefix) | +| **Referral loops and lame delegation** | A referral must name a zone strictly below the zone queried; sideways and upward referrals are rejected | +| **Cache poisoning via out-of-bailiwick glue** | Bailiwick is evaluated against the zone we queried, never against a zone name supplied in the response | +| **Answer injection** | Answers are matched on owner name, type *and* class, must carry the AA bit, and `NXDOMAIN` responses carrying answer records are rejected | +| **Off-path spoofing** | dnspython validates the source address, query ID and question section; DNSSEC validation provides cryptographic assurance where the zone is signed | +| **EDNS downgrade by a spoofed packet** | Unmatched or unparseable responses do not trigger an EDNS downgrade or retire a nameserver | +| **IP fragmentation attacks** | EDNS payload defaults to 1232 (DNS Flag Day 2020) rather than 4096 | +| **Truncation-based data loss** | A truncated response is never treated as complete, on any code path | +| **Random-subdomain (water torture) floods** | NXDOMAIN is cached by name and suppresses everything below it (RFC 8020) | +| **IDN homograph confusion** | IDNA 2008 is used, so `straße.de` is not silently resolved as `strasse.de` | +| **KeyTrap** (CVE-2023-50387, CVE-2023-50868): colliding key tags forcing quadratic signature verification | At most 2 keys and 2 signatures per (tag, algorithm), 8 signatures per RRset, 8 DS records per zone; per-resolution budget of 96 signature verifications and 600 NSEC3 hashes | +| **NSEC3 CPU exhaustion** | NSEC3 iteration counts above 100 are rejected (RFC 9276 requires zones to publish 0) | +| **Unbounded resolution time** | A hard wall-clock deadline applies to the whole call, including all sub-resolutions | + +### What is not defended against + +- **A full on-path attacker**, unless the target zone is DNSSEC-signed and you use `require_dnssec=True`. An unsigned zone offers no cryptographic assurance at all: that is a property of DNS, not of this library. +- **Traffic analysis.** QNAME minimisation (RFC 9156) is not implemented, so the full query name is visible to root and TLD servers. +- **Resource exhaustion from your own call volume.** The budget bounds a single `resolve()`; rate-limiting your callers is your responsibility. +- **Malicious `allow_private_addresses=True`.** That flag disables the SSRF control by design. Only enable it for split-horizon DNS you fully control. + +## Supported versions + +Security fixes are applied to the latest released version. Given the pre-1.0 status, please upgrade to the newest release before reporting. + +## Dependency policy + +The only runtime dependency is `dnspython` (ISC licence), with its `dnssec` extra pulling in `cryptography` and its `idna` extra pulling in `idna`. + +The floor is `dnspython>=2.8.0`. Versions below 2.6.1 are affected by [CVE-2023-29483](https://nvd.nist.gov/vuln/detail/CVE-2023-29483) ("TuDoor"), and 2.6.0 additionally regressed truncation handling in a way that breaks UDP→TCP failover. diff --git a/THIRD-PARTY.md b/THIRD-PARTY.md new file mode 100644 index 0000000..dfd7b2b --- /dev/null +++ b/THIRD-PARTY.md @@ -0,0 +1,82 @@ +# Third-party notices + +`recursive-resolver` is MIT licensed (see [LICENSE](LICENSE)). This file records +everything third-party the project depends on, derives from, or fetches, and the +terms each comes under. + +## Runtime dependencies + +These are installed alongside the package and ship to every user. All are +permissive and compatible with MIT; none are copyleft. + +| Package | Licence | Why it is here | +|---|---|---| +| [dnspython](https://www.dnspython.org/) | ISC | Wire format, transport, DNSSEC primitives | +| [cryptography](https://github.com/pyca/cryptography) | Apache-2.0 OR BSD-3-Clause | Signature verification (via the `dnssec` extra) | +| [cffi](https://cffi.readthedocs.io/) | MIT-0 | Transitive, via `cryptography` | +| [pycparser](https://github.com/eliben/pycparser) | BSD-3-Clause | Transitive, via `cffi` | +| [idna](https://github.com/kjd/idna) | BSD-3-Clause | IDNA 2008 (via the `idna` extra) | + +Development dependencies (pytest, ruff, mypy, coverage, build, twine) are not +distributed with the package. + +## Derived work + +Two files are adapted from [suitenumerique/messages](https://github.com/suitenumerique/messages), +Copyright (c) 2025 Direction Interministérielle du Numérique, Gouvernement +Français, **MIT licensed**. Both carry the notice in their header: + +- `scripts/release.sh`: the interactive-gate structure, hermetic + build-and-upload inside Docker, and the artifact-inspection step are adapted + from `bin/release-jmap-email.sh`. +- `src/recursive_resolver/addresses.py`: the ordering of the address checks, + the `is_global` catch-all, and naming the cloud-metadata endpoints explicitly + are adapted from `core/services/ssrf.py`. + +## Reference values, not copied code + +`budget.py`, `dnssec.py` and `resolver.py` cite the defaults that BIND 9, +Unbound, PowerDNS Recursor and Knot Resolver use for the same protections +(`max-recursion-queries`, `MAX_TARGET_COUNT`, `max-ns-per-resolve`, and so on). +These are parameter names and numeric values: facts about how those resolvers +behave, recorded so a reader can check our choices against the state of the art. +No code or documentation text is reproduced from any of them. + +This distinction matters because several of those projects are copyleft +(PowerDNS Recursor and Knot Resolver are GPL, BIND 9 is MPL-2.0). Nothing +derived from them may be copied into this MIT-licensed package. An earlier draft +of `addresses.py` did embed PowerDNS's curated `dont-query` prefix list; it has +been removed and replaced with classification from Python's `ipaddress` module, +which follows the IANA special-purpose address registries directly. + +## Protocol data compiled into the package + +- **Root server hints** (`roots.py`): from + . Public protocol data published + by IANA/ICANN for exactly this purpose; every recursive resolver embeds them. +- **DNSSEC root trust anchors** (`roots.py`): from + . Likewise published for + validators to embed. + +## Data fetched by the test corpus scripts + +`scripts/collect_domains_diverse.py` and the other `collect_domains_*.py` +scripts download these **at run time**. None of it is vendored into the +repository, and generated corpora are `.gitignore`d, so the project does not +redistribute any of it. If you commit or publish a generated corpus, these terms +become yours to honour: + +| Source | Terms | +|---|---| +| [Tranco](https://tranco-list.eu/) | CC BY 4.0; the underlying lists carry their own terms (Majestic CC BY 3.0, CrUX CC BY-SA 4.0). Attribution required, and the authors ask that research cite their paper | +| [Cisco Umbrella top 1M](https://umbrella-static.s3-us-west-1.amazonaws.com/index.html) (`collect_domains_3_zone.py`) | Free for research and analysis; attribution required | +| [Public Suffix List](https://publicsuffix.org/) | MPL-2.0 | +| [IANA TLD list](https://data.iana.org/TLD/tlds-alpha-by-domain.txt) | Public IANA registry data | +| [data.gouv.fr dataset](https://data.gouv.fr/) (`prepare_test_domains.py`) | Open Licence / Licence Ouverte | +| [crt.sh](https://crt.sh/) (`collect_domains_2_ct.py`) | Public Certificate Transparency log data | + +## Reporting a problem + +If you believe anything here is attributed incorrectly or licensed +incompatibly, please open an issue, or, if it is sensitive, follow +[SECURITY.md](SECURITY.md). diff --git a/pyproject.toml b/pyproject.toml index e29fdac..38a266b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,40 +1,63 @@ [build-system] -requires = ["hatchling"] +# >=1.27 is the floor for final PEP 639 support: earlier Hatchling either +# rejects `license-files` or emits metadata below 2.4, and scripts/release.sh +# asserts on `License-Expression` and the bundled licence files in the built +# wheel's METADATA. +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [project] name = "recursive-resolver" version = "0.1.0" -description = "Pure-Python DNS recursive resolver — iterates from root servers, bypassing all caches" +description = "Pure-Python iterative DNS resolver: resolves from the root servers with DNSSEC validation" readme = "README.md" license = "MIT" +license-files = ["LICENSE", "THIRD-PARTY.md"] requires-python = ">=3.10" authors = [ { name = "Sylvain Zimmer", email = "sylvain@sylvainzimmer.com" }, ] -keywords = ["dns", "resolver", "recursive", "nameserver", "iterative", "root-servers"] +keywords = ["dns", "resolver", "recursive", "nameserver", "iterative", "root-servers", "dnssec", "dkim"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Intended Audience :: System Administrators", - "License :: OSI Approved :: MIT License", + # No "License ::" classifier: PEP 639 deprecates them in favour of the + # `license = "MIT"` expression above, which PyPI reads instead. "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Internet :: Name Service (DNS)", "Topic :: System :: Networking", + "Topic :: Security", "Typing :: Typed", ] dependencies = [ - "dnspython>=2.6", + # >=2.6.1 is a security floor: 2.6.0 and earlier are affected by + # CVE-2023-29483 (TuDoor), and 2.6.0 additionally regressed truncation + # handling, which breaks UDP->TCP failover. 2.7.0 is the first release + # with the EDNS and DNSSEC behaviour this package relies on; 2.8.0 (Sep 2025) + # is the current release and there are no advisories after 2.6.1. + # The dnssec extra pulls in `cryptography` (DNSSEC validation) and the + # idna extra pulls in `idna` (IDNA 2008; without it, internationalised + # names fall back to IDNA 2003 and resolve to different domains). + "dnspython[dnssec,idna]>=2.8.0", ] +[project.optional-dependencies] +# Kept as an explicit extra so `pip install recursive-resolver[dnssec]` works +# for anyone pinning the base dnspython without extras. +dnssec = ["cryptography>=42"] + [project.urls] Homepage = "https://github.com/sylvinus/recursive-resolver" Repository = "https://github.com/sylvinus/recursive-resolver" Issues = "https://github.com/sylvinus/recursive-resolver/issues" +Changelog = "https://github.com/sylvinus/recursive-resolver/blob/main/CHANGELOG.md" [project.scripts] recursive-resolver = "recursive_resolver.cli:main" @@ -50,11 +73,47 @@ dev = [ [tool.hatch.build.targets.wheel] packages = ["src/recursive_resolver"] +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", + "/scripts", + "/README.md", + "/LICENSE", + "/CHANGELOG.md", + "/SECURITY.md", + "/CONTRIBUTING.md", + "/THIRD-PARTY.md", + "/Makefile", +] + [tool.pytest.ini_options] testpaths = ["tests"] +# The shared helpers live in tests/conftest.py and tests/signed_zone.py and are +# imported by name from the test modules. There is deliberately no +# tests/__init__.py: with one, pytest would import the suite as `tests.test_x`, +# put only the repo root on sys.path, and load conftest twice under two names. +# This entry states the requirement rather than relying on rootdir insertion. +pythonpath = ["tests"] +addopts = "--strict-markers --strict-config -ra" markers = [ "integration: marks tests that require network access (deselect with '-m \"not integration\"')", ] +filterwarnings = ["error"] + +[tool.coverage.run] +source = ["recursive_resolver"] +branch = true + +[tool.coverage.report] +# The suite covers every statement and every branch, including the error paths +# a hostile zone drives. Regressions in that are treated as build failures. +fail_under = 100 +show_missing = true +exclude_also = [ + "if TYPE_CHECKING:", + "raise NotImplementedError", +] [tool.ruff] target-version = "py310" diff --git a/scripts/check_authors.py b/scripts/check_authors.py new file mode 100755 index 0000000..35feee5 --- /dev/null +++ b/scripts/check_authors.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Reject commits that credit an AI tool as an author or co-author. + +CONTRIBUTING.md says the human submitting a change is its author, whatever wrote +the first draft. This enforces that, because a policy nobody checks is a policy +nobody follows. + +Three fields are checked, since naming a tool as the author is just as much a +violation as a trailer: the commit author, the committer, and any +``Co-authored-by:`` trailer. + +The matching is deliberately conservative. Claude, Jean-Claude, Devin, Gemini +and Mistral are all real people's names, so a bare given name never trips this; +a vendor address or a product name is required. + +Usage: + python scripts/check_authors.py # origin/main..HEAD + python scripts/check_authors.py # any git range + python scripts/check_authors.py --commit-msg # a single message +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +# Identifying a tool has to be done without blocking people. "Claude", +# "Jean-Claude", "Devin", "Gemini" and "Mistral" are all real human names, so a +# bare given name is never enough on its own. Two signals are used instead: the +# vendor address the tool commits from, and product names no person is called. + +# Domains that only ever belong to a vendor's automation. Matched on the domain, +# never the local part, so devin@example.com stays fine. +AI_EMAIL_DOMAINS = ( + r"@anthropic\.com", + r"@openai\.com", + r"@cursor\.(?:sh|com)", + r"@cognition(?:-ai)?\.(?:ai|com)", + r"@mistral\.ai", +) + +# Product names. Each needs a qualifier where the first word is also a human +# name, and stands alone only where it is not. +AI_NAME_PATTERNS = ( + r"\bclaude[\s-]*(?:code|opus|sonnet|haiku|ai|assistant|bot|\d)", + r"\bcopilot\b", + r"\bchatgpt\b", + r"\bopenai\b", + r"\banthropic\b", + r"\bgpt-?[0-9](?:\.\d+)?o?\b", + r"\bcodex\b", + r"\baider\b", + r"\bgemini[\s-]*(?:cli|pro|code|ai|assistant|bot|\d)", + r"\bcursor[\s-]*(?:agent|ai|bot)", + r"\bdevin[\s-]*(?:ai|bot)", + r"\bmistral[\s-]*(?:ai|bot)", + r"\bllama[\s-]*(?:\d|ai|bot)", + # The GitHub App convention, e.g. "copilot[bot]" or "openai-codex[bot]". + # Scoped to the AI apps on purpose: this check runs on every pull request, + # and a bare `\[bot\]` would fail dependabot[bot], renovate[bot] and + # github-actions[bot], which the policy is not about. + r"\b(?:copilot|codex|claude|devin|cursor|gemini|chatgpt|openai|anthropic|aider)[\w.-]*\[bot\]", +) + +MATCHER = re.compile("|".join(AI_EMAIL_DOMAINS + AI_NAME_PATTERNS), re.IGNORECASE) + +ADVICE = """ +CONTRIBUTING.md: the human submitting a change is its author. An AI tool may +have written the first draft, but it is not an author or co-author. + +Fix the commit(s): + git commit --amend # drop the trailer, or correct the author + git rebase -i # for more than the last commit + +and check your identity: + git config user.name + git config user.email +""" + + +def _run(args: list[str]) -> str: + result = subprocess.run(["git", *args], capture_output=True, text=True, check=False) + if result.returncode != 0: + print(f"git {' '.join(args)} failed: {result.stderr.strip()}", file=sys.stderr) + sys.exit(2) + return result.stdout + + +def check_message(text: str, label: str) -> list[str]: + """Return the offending lines in one commit message.""" + problems = [] + for line in text.splitlines(): + if line.lower().startswith("co-authored-by:") and MATCHER.search(line): + problems.append(f"{label}: {line.strip()}") + return problems + + +def check_range(rev_range: str) -> list[str]: + """Return every violation across the commits in ``rev_range``.""" + # A record separator keeps multi-line bodies unambiguous. + raw = _run(["log", "--format=%H%x00%an <%ae>%x00%cn <%ce>%x00%B%x1e", rev_range]) + problems: list[str] = [] + for record in raw.split("\x1e"): + if not record.strip(): + continue + sha, author, committer, body = (record.strip().split("\x00") + ["", "", "", ""])[:4] + short = sha[:12] + if MATCHER.search(author): + problems.append(f"{short} author: {author}") + if MATCHER.search(committer): + problems.append(f"{short} committer: {committer}") + problems += check_message(body, f"{short} trailer") + return problems + + +def main(argv: list[str]) -> int: + if argv and argv[0] == "--commit-msg": + if len(argv) < 2: + print("--commit-msg needs a path", file=sys.stderr) + return 2 + # Git writes commit messages as UTF-8; read_text() would otherwise use + # the platform default and blow up on an accented name in a trailer. + problems = check_message(Path(argv[1]).read_text(encoding="utf-8"), "commit message") + else: + rev_range = argv[0] if argv else "origin/main..HEAD" + problems = check_range(rev_range) + + if not problems: + return 0 + + print("An AI tool is credited as an author or co-author:\n", file=sys.stderr) + for problem in problems: + print(f" {problem}", file=sys.stderr) + print(ADVICE, file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/collect_domains_1_tranco.py b/scripts/collect_domains_1_tranco.py old mode 100644 new mode 100755 index a12f209..fa90b49 --- a/scripts/collect_domains_1_tranco.py +++ b/scripts/collect_domains_1_tranco.py @@ -3,7 +3,7 @@ Tranco is a research-oriented top sites ranking that combines multiple lists (Alexa, Chrome UX, Majestic, Umbrella). Downloads the latest list and samples -domains across the full popularity spectrum — top, middle, and tail. +domains across the full popularity spectrum: top, middle, and tail. """ from __future__ import annotations diff --git a/scripts/collect_domains_2_ct.py b/scripts/collect_domains_2_ct.py old mode 100644 new mode 100755 index 54d95c2..2fe9a2d --- a/scripts/collect_domains_2_ct.py +++ b/scripts/collect_domains_2_ct.py @@ -2,7 +2,7 @@ """Strategy 2: Certificate Transparency logs via crt.sh. Queries crt.sh for recently issued certificates across diverse TLDs. -This produces domains that actually exist and have active HTTPS — good for +This produces domains that actually exist and have active HTTPS: good for testing CNAME chains, CDN delegations, and unusual DNS configurations. """ @@ -12,23 +12,48 @@ import json import random import time -from urllib.request import urlopen, Request from urllib.error import URLError +from urllib.request import Request, urlopen OUTPUT = "domains_extra2.csv" # Search terms that produce diverse results across TLDs and configurations SEARCH_TERMS = [ # Country TLDs - "%.de", "%.jp", "%.br", "%.in", "%.au", "%.kr", "%.ru", - "%.nl", "%.se", "%.ch", "%.za", "%.mx", "%.ar", "%.pl", + "%.de", + "%.jp", + "%.br", + "%.in", + "%.au", + "%.kr", + "%.ru", + "%.nl", + "%.se", + "%.ch", + "%.za", + "%.mx", + "%.ar", + "%.pl", # New gTLDs - "%.io", "%.dev", "%.app", "%.cloud", "%.xyz", "%.online", - "%.tech", "%.store", "%.site", "%.blog", + "%.io", + "%.dev", + "%.app", + "%.cloud", + "%.xyz", + "%.online", + "%.tech", + "%.store", + "%.site", + "%.blog", # Infrastructure / interesting DNS - "%.gov", "%.edu", "%.mil", + "%.gov", + "%.edu", + "%.mil", # CDN / cloud hosted (complex DNS chains) - "%cdn%", "%api%", "%mail%", "%static%", + "%cdn%", + "%api%", + "%mail%", + "%static%", ] diff --git a/scripts/collect_domains_3_zone.py b/scripts/collect_domains_3_zone.py old mode 100644 new mode 100755 index 6db553e..aa2a9f9 --- a/scripts/collect_domains_3_zone.py +++ b/scripts/collect_domains_3_zone.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 -"""Strategy 3: Public DNS zone data and RDAP/WHOIS enumeration. +"""Strategy 3: public zone and popularity data. Combines multiple public sources: -- Public suffix list (all known TLDs/eTLDs) -- Wikipedia's list of most-visited websites +- Public Suffix List (all known TLDs/eTLDs) +- Cisco Umbrella top 1M (a popularity list built from resolver traffic) - Hardcoded "interesting" domains with unusual DNS setups - DNS infrastructure domains (root servers, TLD nameservers, CDNs) """ @@ -13,48 +13,90 @@ import csv import io import random -from urllib.request import urlopen, Request from urllib.error import URLError +from urllib.request import Request, urlopen OUTPUT = "domains_extra3.csv" # Domains known for interesting/complex DNS configurations INTERESTING_DOMAINS = [ # Multi-level CNAME chains - "www.github.com", "www.heroku.com", "www.shopify.com", - "www.squarespace.com", "www.wix.com", "www.wordpress.com", + "www.github.com", + "www.heroku.com", + "www.shopify.com", + "www.squarespace.com", + "www.wix.com", + "www.wordpress.com", # Anycast / GeoDNS - "www.google.com", "www.facebook.com", "www.amazon.com", - "www.netflix.com", "www.cloudflare.com", "www.akamai.com", + "www.google.com", + "www.facebook.com", + "www.amazon.com", + "www.netflix.com", + "www.cloudflare.com", + "www.akamai.com", # DNSSEC-signed domains - "dnssec-failed.org", "good.dnssec-or-not.com", - "internetsociety.org", "ietf.org", "ripe.net", + "dnssec-failed.org", + "good.dnssec-or-not.com", + "internetsociety.org", + "ietf.org", + "ripe.net", # IDN / punycode - "münchen.de", "中国.cn", "日本.jp", + "münchen.de", + "中国.cn", + "日本.jp", # Long delegation chains - "bbc.co.uk", "gov.uk", "nhs.uk", "ac.uk", - "csiro.au", "defence.gov.au", + "bbc.co.uk", + "gov.uk", + "nhs.uk", + "ac.uk", + "csiro.au", + "defence.gov.au", # Infrastructure - "ns1.google.com", "dns.google", "one.one.one.one", - "resolver1.opendns.com", "dns.quad9.net", + "ns1.google.com", + "dns.google", + "one.one.one.one", + "resolver1.opendns.com", + "dns.quad9.net", # Email infrastructure (MX-heavy) - "gmail.com", "outlook.com", "yahoo.com", "protonmail.com", - "zoho.com", "fastmail.com", "tutanota.com", + "gmail.com", + "outlook.com", + "yahoo.com", + "protonmail.com", + "zoho.com", + "fastmail.com", + "tutanota.com", # CDN endpoints - "d1234.cloudfront.net", "cdn.jsdelivr.net", - "unpkg.com", "cdnjs.cloudflare.com", + "d1234.cloudfront.net", + "cdn.jsdelivr.net", + "unpkg.com", + "cdnjs.cloudflare.com", # Government / institutional - "whitehouse.gov", "nasa.gov", "cern.ch", "mit.edu", - "stanford.edu", "ox.ac.uk", "cam.ac.uk", - "elysee.fr", "bundeskanzler.de", "gov.cn", + "whitehouse.gov", + "nasa.gov", + "cern.ch", + "mit.edu", + "stanford.edu", + "ox.ac.uk", + "cam.ac.uk", + "elysee.fr", + "bundeskanzler.de", + "gov.cn", # TLD nameservers themselves - "a.gtld-servers.net", "a.nic.fr", "dns1.nic.uk", + "a.gtld-servers.net", + "a.nic.fr", + "dns1.nic.uk", # Large hosting (many NS configurations) - "aws.amazon.com", "cloud.google.com", "azure.microsoft.com", - "pages.github.com", "netlify.app", "vercel.app", + "aws.amazon.com", + "cloud.google.com", + "azure.microsoft.com", + "pages.github.com", + "netlify.app", + "vercel.app", # Unusual record types - "_dmarc.google.com", "_spf.google.com", - "meet.google.com", "chat.google.com", + "_dmarc.google.com", + "_spf.google.com", + "meet.google.com", + "chat.google.com", ] # Public suffix list URL @@ -86,7 +128,7 @@ def fetch_public_suffixes() -> list[str]: if "." in line: suffixes.append(line) - # Sample — there are thousands + # Sample: there are thousands random.shuffle(suffixes) return suffixes[:500] @@ -94,6 +136,7 @@ def fetch_public_suffixes() -> list[str]: def fetch_umbrella_sample() -> list[str]: """Fetch a sample from Cisco Umbrella top 1M.""" import zipfile + print("Fetching Cisco Umbrella top-1M...") try: with urlopen(UMBRELLA_URL, timeout=30) as resp: diff --git a/scripts/collect_domains_diverse.py b/scripts/collect_domains_diverse.py new file mode 100755 index 0000000..2ba56c6 --- /dev/null +++ b/scripts/collect_domains_diverse.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""Build a deliberately awkward corpus for differential testing. + +A list of popular apex domains exercises the happy path and little else. This +script assembles names chosen to stress the parts of a resolver that actually +break: unusual delegation shapes, deep names, internationalised labels, signed +and deliberately-bogus zones, wildcards, empty non-terminals and the underscore +labels that mail lookups depend on. + +Sources +------- +Tranco + A research-grade popularity ranking, sampled across bands rather than just + the head, so the tail (where broken and exotic configurations live) is + represented. +IANA TLD list + Every delegated TLD, including the IDN ``xn--`` ones. Querying a TLD apex + exercises single-label names and referral handling at the top of the tree. +Public Suffix List + Multi-label suffixes (``co.uk``, ``github.io``, ``s3.amazonaws.com``) whose + delegation structure is unusual: several zone cuts, sometimes served by the + same nameservers, sometimes not. +Derived subdomains + Mail and service labels layered onto popular domains: ``_dmarc``, + ``_domainkey`` selectors, ``www``, ``autodiscover``, which is where the + deep, underscore-prefixed names live. +Curated + Hand-picked pathological cases with a known expected shape. + +Usage: + python scripts/collect_domains_diverse.py -o domains.csv + python scripts/collect_domains_diverse.py -o domains.csv --limit 20000 +""" + +from __future__ import annotations + +import argparse +import csv +import io +import random +import sys +import zipfile +from urllib.error import URLError +from urllib.request import Request, urlopen + +TRANCO_URL = "https://tranco-list.eu/top-1m.csv.zip" +IANA_TLDS_URL = "https://data.iana.org/TLD/tlds-alpha-by-domain.txt" +PSL_URL = "https://publicsuffix.org/list/public_suffix_list.dat" + +USER_AGENT = "recursive-resolver-corpus/1.0" + +# Popularity bands sampled from Tranco. The tail matters most: that is where +# lame delegations, dead glue and odd nameserver software concentrate. +TRANCO_BANDS = ( + (1, 100, 100), + (100, 1_000, 150), + (1_000, 10_000, 250), + (10_000, 100_000, 400), + (100_000, 1_000_000, 600), +) + +# Labels layered onto popular domains. Underscore-prefixed names are the ones +# mail authentication depends on, and they are frequently empty non-terminals. +SUBDOMAIN_LABELS = ( + "www", + "mail", + "_dmarc", + "selector1._domainkey", + "google._domainkey", + "autodiscover", + "api", + "cdn", + "_sip._tcp", + "ftp.internal.dev", +) + +# Names with a known-interesting shape, each paired with why it is here. +CURATED: tuple[tuple[str, str], ...] = ( + # Internationalised names: IDNA 2003 and 2008 disagree on these. + ("bücher.de", "idn-unicode"), + ("xn--bcher-kva.de", "idn-punycode"), + ("straße.de", "idn-sharp-s"), + ("faß.de", "idn-sharp-s"), + ("münchen.de", "idn-unicode"), + ("日本.jp", "idn-cjk"), + ("россия.рф", "idn-cyrillic"), + ("中国.cn", "idn-cjk"), + # DNSSEC: signed, and deliberately broken. + ("cloudflare.com", "dnssec-signed"), + ("nic.cz", "dnssec-signed-shared-cut"), + ("ietf.org", "dnssec-signed"), + ("nlnetlabs.nl", "dnssec-signed"), + ("internetsociety.org", "dnssec-signed"), + ("dnssec-tools.org", "dnssec-signed"), + ("verisign.com", "dnssec-signed"), + ("ripe.net", "dnssec-signed"), + ("sigok.verteiltesysteme.net", "dnssec-signed"), + ("dnssec-failed.org", "dnssec-bogus"), + ("rhybar.cz", "dnssec-bogus"), + ("bogus.nlnetlabs.nl", "dnssec-bogus"), + ("sigfail.verteiltesysteme.net", "dnssec-bogus"), + # Zone cuts served by the same nameservers as their parent. + ("bbc.co.uk", "shared-zone-cut"), + ("amazon.co.uk", "shared-zone-cut"), + ("gov.uk", "shared-zone-cut"), + ("ac.uk", "shared-zone-cut"), + ("com.au", "shared-zone-cut"), + # CNAME shapes. + ("www.github.com", "cname"), + ("www.microsoft.com", "cname-chain"), + ("selector1._domainkey.microsoft.com", "cname-to-elsewhere"), + ("zendesk1._domainkey.zendesk.com", "txt-multi-chunk"), + ("s1._domainkey.stripe.com", "dkim"), + ("k1._domainkey.mailchimp.com", "dkim"), + ("20230601._domainkey.gmail.com", "dkim"), + # Deep names and empty non-terminals. + ("a.b.c.d.e.f.example.com", "deep-nonexistent"), + ("_domainkey.zendesk.com", "empty-non-terminal"), + ("_tcp.example.com", "empty-non-terminal"), + # Reverse lookups. + ("8.8.8.8.in-addr.arpa", "reverse-v4"), + ("1.1.1.1.in-addr.arpa", "reverse-v4"), + # Apexes with unusual answers. + ("example.com", "reserved-apex"), + ("example.org", "reserved-apex"), + ("localhost", "special-use"), + ("invalid", "special-use"), + ("test", "special-use"), + # Large responses that stress EDNS and truncation. + ("google.com", "large-txt"), + ("microsoft.com", "large-txt"), + ("salesforce.com", "large-txt"), + # Names that should not exist. + ("this-domain-should-not-exist-zz99.com", "nxdomain"), + ("nonexistent.cloudflare.com", "nxdomain-signed-zone"), + ("nonexistent.nic.cz", "nxdomain-nsec3"), + ("nonexistent.ietf.org", "wildcard-or-nodata"), +) + + +def _fetch(url: str, timeout: int = 60) -> bytes: + request = Request(url, headers={"User-Agent": USER_AGENT}) + with urlopen(request, timeout=timeout) as response: # noqa: S310 - fixed https URLs + data: bytes = response.read() + return data + + +def collect_tranco(rng: random.Random) -> list[tuple[str, str]]: + """Sample Tranco across popularity bands, not just the head.""" + print(f"Downloading Tranco from {TRANCO_URL} ...", file=sys.stderr) + archive = zipfile.ZipFile(io.BytesIO(_fetch(TRANCO_URL))) + text = archive.read(archive.namelist()[0]).decode("utf-8", "replace") + + ranked: list[str] = [] + for row in csv.reader(io.StringIO(text)): + if len(row) >= 2: + ranked.append(row[1].strip().lower()) + + out: list[tuple[str, str]] = [] + for low, high, count in TRANCO_BANDS: + band = ranked[low - 1 : min(high, len(ranked))] + if not band: + continue + picked = rng.sample(band, min(count, len(band))) + label = f"tranco-{low}-{high}" + out.extend((domain, label) for domain in picked) + print(f" Tranco: {len(out)} domains across {len(TRANCO_BANDS)} bands", file=sys.stderr) + return out + + +def collect_tlds() -> list[tuple[str, str]]: + """Every delegated TLD, including the internationalised ones.""" + print(f"Downloading the IANA TLD list from {IANA_TLDS_URL} ...", file=sys.stderr) + lines = _fetch(IANA_TLDS_URL).decode("ascii", "replace").splitlines() + out: list[tuple[str, str]] = [] + for line in lines: + tld = line.strip().lower() + if not tld or tld.startswith("#"): + continue + out.append((tld, "tld-idn" if tld.startswith("xn--") else "tld")) + print(f" TLDs: {len(out)}", file=sys.stderr) + return out + + +def collect_public_suffixes(rng: random.Random, count: int = 400) -> list[tuple[str, str]]: + """Multi-label public suffixes: several zone cuts, unusual delegation shapes.""" + print(f"Downloading the Public Suffix List from {PSL_URL} ...", file=sys.stderr) + lines = _fetch(PSL_URL).decode("utf-8", "replace").splitlines() + suffixes: list[str] = [] + for line in lines: + entry = line.strip() + if not entry or entry.startswith("//"): + continue + entry = entry.lstrip("*.").lstrip("!") + # Only multi-label entries are interesting; single labels are TLDs, + # already covered above. + if entry.count(".") >= 1 and " " not in entry: + suffixes.append(entry.lower()) + picked = rng.sample(suffixes, min(count, len(suffixes))) + print(f" Public suffixes: {len(picked)} of {len(suffixes)}", file=sys.stderr) + return [(suffix, "public-suffix") for suffix in picked] + + +def derive_subdomains(apexes: list[str], rng: random.Random, count: int = 600) -> list[tuple[str, str]]: + """Layer mail and service labels onto popular domains.""" + out: list[tuple[str, str]] = [] + sample = rng.sample(apexes, min(count, len(apexes))) + for apex in sample: + label = rng.choice(SUBDOMAIN_LABELS) + kind = "subdomain-underscore" if "_" in label else "subdomain" + out.append((f"{label}.{apex}", kind)) + print(f" Derived subdomains: {len(out)}", file=sys.stderr) + return out + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("-o", "--output", default="domains.csv", help="Output CSV path") + parser.add_argument("--limit", type=int, default=0, help="Cap the total number of names (0 = no cap)") + parser.add_argument("--seed", type=int, default=20260808, help="Sampling seed, for reproducible corpora") + parser.add_argument("--no-tranco", action="store_true", help="Skip the Tranco download") + parser.add_argument("--no-tlds", action="store_true", help="Skip the IANA TLD list") + parser.add_argument("--no-psl", action="store_true", help="Skip the Public Suffix List") + args = parser.parse_args() + if args.limit < 0: + # Otherwise a negative limit is truthy below and Python's negative + # slicing quietly drops rows from the *end* of the corpus instead. + parser.error("--limit must be zero or greater") + + rng = random.Random(args.seed) + collected: list[tuple[str, str]] = list(CURATED) + tranco_apexes: list[str] = [] + + for enabled, fn in ( + (not args.no_tranco, lambda: collect_tranco(rng)), + (not args.no_tlds, collect_tlds), + (not args.no_psl, lambda: collect_public_suffixes(rng)), + ): + if not enabled: + continue + try: + rows = fn() + except (URLError, OSError, zipfile.BadZipFile) as exc: + print(f" WARNING: source unavailable ({exc}); continuing without it", file=sys.stderr) + continue + collected.extend(rows) + if rows and rows[0][1].startswith("tranco"): + tranco_apexes = [domain for domain, _ in rows] + + if tranco_apexes: + collected.extend(derive_subdomains(tranco_apexes, rng)) + + # De-duplicate, keeping the first (most specific) category for each name. + seen: dict[str, str] = {} + for domain, category in collected: + seen.setdefault(domain, category) + + rows = sorted(seen.items()) + rng.shuffle(rows) + if args.limit > 0: + rows = rows[: args.limit] + + with open(args.output, "w", newline="") as handle: + writer = csv.writer(handle) + writer.writerow(["domain", "category"]) + writer.writerows(rows) + + by_category: dict[str, int] = {} + for _domain, category in rows: + by_category[category] = by_category.get(category, 0) + 1 + + print(f"\nWrote {len(rows)} names to {args.output}", file=sys.stderr) + for category, count in sorted(by_category.items(), key=lambda item: -item[1]): + print(f" {category:26s} {count:6d}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/diff_harness.py b/scripts/diff_harness.py new file mode 100755 index 0000000..0d6d659 --- /dev/null +++ b/scripts/diff_harness.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python3 +"""Differential testing against a reference resolver. + +Runs a corpus of names through this resolver and through a reference (``dig``, +or a stub resolver pointed at a public recursive), then reports where they +disagree: broken down by record type, by corpus category and by resolver +configuration. + +Comparing DNS answers needs care, because two correct resolvers legitimately +disagree: + +* CDNs return different addresses to different clients, so an *overlapping* + answer set counts as agreement rather than a difference. +* ``dig +short`` renders a multi-chunk TXT record on one line while an rdata + object keeps the chunks separate, so TXT is compared as a set of chunks. +* SOA serials differ between a zone's own nameservers, so SOA is compared on + its stable fields. +* Public recursives genuinely disagree with each other on zones whose parent + delegation and child NS RRset differ, so several references are queried and + matching *any* of them counts as agreement. Cases where the references + disagree among themselves are reported separately: that is the internet being + inconsistent, not a resolver being wrong. + +The reference is always queried by explicit IP, never through the system stub. +A local ``systemd-resolved`` synthesises a self-referential ``CNAME`` with TTL 0 +for names that have none, which makes every CNAME query look like a mismatch. + +Usage: + python scripts/diff_harness.py --csv domains.csv + python scripts/diff_harness.py --csv domains.csv --types A,MX,TXT --configs default,no-dnssec + python scripts/diff_harness.py --csv domains.csv --reference stub --sample 500 +""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import csv +import json +import re +import shutil +import subprocess +import sys +import threading +import time +from collections import defaultdict +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) + +from recursive_resolver import ( # noqa: E402 + NoAnswerError, + NXDOMAINError, + RecursiveResolver, + ResolverError, +) + +DEFAULT_TYPES = "A,AAAA,MX,TXT,NS,SOA,CAA,CNAME,DS,DNSKEY" + +# Independent public recursives. Never the system stub: see the module docstring. +DEFAULT_REFERENCE_SERVERS = "1.1.1.1,8.8.8.8" + +# Resolver configurations exercised by the matrix. Each is a real deployment +# shape, not an arbitrary permutation. +CONFIGS: dict[str, dict[str, Any]] = { + # What you get out of the box. + "default": {}, + # DNSSEC off: isolates iteration behaviour from validation behaviour, and + # is the only fair comparison against a non-validating reference. + "no-dnssec": {"dnssec": False}, + # The recommended DKIM shape: delegations cached, answers never. + "fresh-answers": {"dnssec": False, "cache_answers": False}, + # No cache at all: every lookup walks from a root server. + "no-cache": {"dnssec": False, "cache_enabled": False}, + # Only root->TLD cuts retained. + "tld-cache-only": {"dnssec": False, "max_delegation_cache_depth": 1}, + # UDP only, so truncation must be handled without falling back to TCP. + "no-tcp": {"dnssec": False, "use_tcp_fallback": False}, + # Minimum EDNS payload, as if a middlebox forced the downgrade. + "small-edns": {"dnssec": False, "edns_payload": 512}, + # Dual stack. + "ipv6": {"dnssec": False, "ipv4_only": False}, + # Accept answers without the AA bit. + "lax-aa": {"dnssec": False, "require_authoritative": False}, +} + +# Outcomes that count as the two resolvers agreeing. +AGREEING = {"exact", "overlap", "both-empty", "both-nxdomain", "both-nodata", "reference-empty"} + +# Not agreement, but not our defect either: the references contradict each other. +INCONCLUSIVE = {"references-disagree"} + + +@dataclass +class Result: + domain: str + category: str + rdtype: str + config: str + status: str + ours: list[str] = field(default_factory=list) + reference: list[str] = field(default_factory=list) + our_error: str | None = None + reference_status: str | None = None + + +# ── reference resolvers ───────────────────────────────────────────────── + + +def _dig(domain: str, rdtype: str, server: str, timeout: int = 15) -> tuple[str, set[str]]: + """Query via dig against an explicit server. Returns (rcode, rdata set).""" + command = [ + "dig", "+nocmd", "+noall", "+comments", "+answer", + "+time=5", "+tries=2", f"@{server}", "-t", rdtype, domain, + ] # fmt: skip + try: + proc = subprocess.run(command, capture_output=True, text=True, timeout=timeout) + except (subprocess.TimeoutExpired, FileNotFoundError): + return "TIMEOUT", set() + + rcode = "UNKNOWN" + match = re.search(r"status:\s*([A-Z]+)", proc.stdout) + if match: + rcode = match.group(1) + + records: set[str] = set() + for line in proc.stdout.splitlines(): + if line.startswith(";") or not line.strip(): + continue + parts = line.split(None, 4) + if len(parts) == 5 and parts[3].upper() == rdtype.upper(): + records.add(parts[4].strip()) + return rcode, records + + +def _stub(domain: str, rdtype: str, nameserver: str) -> tuple[str, set[str]]: + """Query via dnspython against a public recursive resolver.""" + import dns.resolver + + resolver = dns.resolver.Resolver() + resolver.nameservers = [nameserver] + resolver.timeout = 5 + resolver.lifetime = 10 + try: + answer = resolver.resolve(domain, rdtype) + except dns.resolver.NXDOMAIN: + return "NXDOMAIN", set() + except dns.resolver.NoAnswer: + return "NOERROR", set() + except Exception as exc: # noqa: BLE001 - any failure is a reference failure + return type(exc).__name__.upper(), set() + return "NOERROR", {str(record) for record in answer} + + +# ── normalisation ─────────────────────────────────────────────────────── + + +def _normalise(values: set[str], rdtype: str) -> set[str]: + """Reduce both sides to a comparable form for this record type.""" + out: set[str] = set() + for value in values: + text = value.strip() + if rdtype == "TXT": + # dig renders every chunk of one RR on a single line; rdata objects + # keep them separate. Compare the individual chunks either way. + quoted = re.findall(r'"[^"]*"', text) + out.update(chunk.lower() for chunk in quoted) if quoted else out.add(text.lower()) + continue + if rdtype == "SOA": + # Serials drift between a zone's nameservers; MNAME and RNAME do not. + fields = text.split() + text = " ".join(fields[:2]) if len(fields) >= 2 else text + if rdtype in {"DNSKEY", "DS", "CAA", "NAPTR"}: + # Whitespace inside base64 and hex payloads is not significant. + text = re.sub(r"\s+", "", text) + out.add(text.rstrip(".").lower()) + return out + + +def _classify(ours: set[str], theirs: set[str], our_error: str | None, ref_status: str) -> str: + if our_error == "NXDOMAINError" and ref_status == "NXDOMAIN": + return "both-nxdomain" + if our_error == "NoAnswerError" and ref_status == "NOERROR" and not theirs: + return "both-nodata" + if ours == theirs and ours: + return "exact" + if not ours and not theirs: + return "both-empty" + if ours & theirs: + return "overlap" + if not theirs: + # The reference could not answer either; not our disagreement to own. + return "reference-empty" + if not ours: + return "we-are-empty" + return "different" + + +# ── execution ─────────────────────────────────────────────────────────── + + +def run_one( + resolver: RecursiveResolver, + domain: str, + category: str, + rdtype: str, + config: str, + reference: str, + servers: list[str], +) -> Result: + our_error: str | None = None + try: + ours = set(resolver.resolve(domain, rdtype)) + except (NXDOMAINError, NoAnswerError, ResolverError) as exc: + ours = set() + our_error = type(exc).__name__ + except Exception as exc: # noqa: BLE001 - a leaked non-ResolverError is itself a finding + ours = set() + our_error = f"LEAKED:{type(exc).__name__}" + + ours_n = _normalise(ours, rdtype) + + # Ask every reference, and take the most favourable verdict: a zone whose + # parent delegation disagrees with its own NS RRset makes public recursives + # disagree with each other, and that is not our error to own. + best: tuple[int, str, str, set[str]] | None = None + seen: list[tuple[str, frozenset[str]]] = [] + for server in servers: + status_code, theirs = _dig(domain, rdtype, server) if reference == "dig" else _stub(domain, rdtype, server) + theirs_n = _normalise(theirs, rdtype) + seen.append((status_code, frozenset(theirs_n))) + verdict = _classify(ours_n, theirs_n, our_error, status_code) + rank = 0 if verdict in AGREEING else 1 + if best is None or rank < best[0]: + best = (rank, verdict, status_code, theirs_n) + if rank == 0: + break + + assert best is not None + _rank, status, ref_status, theirs_n = best + if status not in AGREEING and len(set(seen)) > 1: + status = "references-disagree" + + return Result( + domain=domain, + category=category, + rdtype=rdtype, + config=config, + status=status, + ours=sorted(ours_n), + reference=sorted(theirs_n), + our_error=our_error, + reference_status=ref_status, + ) + + +def load_corpus(path: Path, sample: int, seed: int) -> list[tuple[str, str]]: + rows: list[tuple[str, str]] = [] + with open(path, newline="") as handle: + for row in csv.DictReader(handle): + domain = (row.get("domain") or "").strip() + if domain: + rows.append((domain, (row.get("category") or "uncategorised").strip())) + if sample and sample < len(rows): + import random + + rows = random.Random(seed).sample(rows, sample) + return rows + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--csv", required=True, type=Path, help="Corpus CSV with a 'domain' column") + parser.add_argument("--types", default=DEFAULT_TYPES, help=f"Record types (default: {DEFAULT_TYPES})") + parser.add_argument("--configs", default="no-dnssec", help=f"Configs from: {','.join(CONFIGS)}") + parser.add_argument("--reference", choices=("dig", "stub"), default="dig") + parser.add_argument( + "--reference-servers", + default=DEFAULT_REFERENCE_SERVERS, + help=f"Comma-separated reference resolvers (default: {DEFAULT_REFERENCE_SERVERS})", + ) + parser.add_argument("--sample", type=int, default=0, help="Sample N names (0 = all)") + parser.add_argument("--seed", type=int, default=20260808) + parser.add_argument("--workers", type=int, default=16) + parser.add_argument("--timeout", type=float, default=10.0, help="Per-resolution wall clock") + parser.add_argument("--out", type=Path, default=None, help="JSONL path for disagreements") + args = parser.parse_args() + + if args.reference == "dig" and not shutil.which("dig"): + print("dig not found; use --reference stub", file=sys.stderr) + return 2 + + if args.workers < 1: + print("--workers must be at least 1", file=sys.stderr) + return 2 + + rdtypes = [t.strip().upper() for t in args.types.split(",") if t.strip()] + configs = [c.strip() for c in args.configs.split(",") if c.strip()] + for config in configs: + if config not in CONFIGS: + print(f"unknown config {config!r}; choose from {', '.join(CONFIGS)}", file=sys.stderr) + return 2 + + servers = [s.strip() for s in args.reference_servers.split(",") if s.strip()] + if not servers: + # With no reference there is nothing to compare against, and run_one + # trips its own "best is not None" assertion instead of saying so. + print("--reference-servers must contain at least one server", file=sys.stderr) + return 2 + + corpus = load_corpus(args.csv, args.sample, args.seed) + total = len(corpus) * len(rdtypes) * len(configs) + print( + f"{len(corpus)} names x {len(rdtypes)} types x {len(configs)} configs = {total} comparisons" + f" (reference: {args.reference} via {', '.join(servers)})", + file=sys.stderr, + ) + + out_path = args.out or Path(f"diff_{time.strftime('%Y%m%d_%H%M%S')}.jsonl") + results: list[Result] = [] + lock = threading.Lock() + done = 0 + started = time.monotonic() + + for config in configs: + kwargs = dict(CONFIGS[config]) + kwargs.setdefault("timeout", 3.0) + kwargs.setdefault("max_resolution_time", args.timeout) + resolver = RecursiveResolver(**kwargs) + + def task(item: tuple[str, str], rdtype: str, config: str = config, resolver: Any = resolver) -> None: + nonlocal done + result = run_one(resolver, item[0], item[1], rdtype, config, args.reference, servers) + with lock: + results.append(result) + done += 1 + if result.status not in AGREEING and result.status not in INCONCLUSIVE: + with open(out_path, "a") as handle: + handle.write(json.dumps(result.__dict__) + "\n") + if done % 2000 == 0: + rate = done / max(1e-9, time.monotonic() - started) + print(f" {done}/{total} {rate:.0f}/s", file=sys.stderr, flush=True) + + with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool: + futures = [pool.submit(task, item, rdtype) for item in corpus for rdtype in rdtypes] + # result(), not wait(): wait() returns happily when a task died, and + # the harness would then report an agreement rate over a silently + # incomplete result set. + for future in concurrent.futures.as_completed(futures): + future.result() + + report(results, rdtypes, configs, out_path, time.monotonic() - started) + # Same exclusion rule as _rate() and report(): a run that meets the + # threshold on judged results must not exit 1 because of comparisons both + # references disagreed about. + _agree, judged, rate = _rate(results) + return 0 if judged and rate >= 0.96 else 1 + + +def _rate(rows: list[Result]) -> tuple[int, int, float]: + """Agreement rate, excluding cases where the references contradict each other.""" + judged = [r for r in rows if r.status not in INCONCLUSIVE] + agree = sum(1 for r in judged if r.status in AGREEING) + return agree, len(judged), (agree / len(judged) if judged else 1.0) + + +def report(results: list[Result], rdtypes: list[str], configs: list[str], out_path: Path, elapsed: float) -> None: + agree, total, rate = _rate(results) + print(f"\n{'=' * 78}\nAGREEMENT: {agree}/{total} ({rate:.2%}) in {elapsed:.0f}s\n{'=' * 78}") + + counts: dict[str, int] = defaultdict(int) + for r in results: + counts[r.status] += 1 + print("\nOutcomes:") + for status, count in sorted(counts.items(), key=lambda kv: -kv[1]): + marker = " " if status in AGREEING else ("~" if status in INCONCLUSIVE else "!") + print(f" {marker} {status:18s} {count:7d} {count / total:6.2%}") + + for title, key in (("record type", "rdtype"), ("config", "config"), ("corpus category", "category")): + buckets: dict[str, list[Result]] = defaultdict(list) + for r in results: + buckets[getattr(r, key)].append(r) + print(f"\nBy {title}:") + for name, rows in sorted(buckets.items(), key=lambda kv: _rate(kv[1])[2]): + a, t, pct = _rate(rows) + flag = " <-- worst" if pct < 0.9 else "" + print(f" {name:28s} {a:6d}/{t:<6d} {pct:7.2%}{flag}") + + inconclusive = [r for r in results if r.status in INCONCLUSIVE] + if inconclusive: + print(f"\n{len(inconclusive)} excluded: the reference resolvers disagreed with each other.") + + disagreements = [r for r in results if r.status not in AGREEING and r.status not in INCONCLUSIVE] + if disagreements: + print(f"\n{len(disagreements)} disagreements written to {out_path}") + print("\nFirst 15:") + for r in disagreements[:15]: + print( + f" {r.domain[:44]:44s} {r.rdtype:6s} {r.config:14s} {r.status:12s}" + f" ours={str(r.ours)[:34]} ref={str(r.reference)[:34]} err={r.our_error}" + ) + + leaked = [r for r in results if r.our_error and r.our_error.startswith("LEAKED:")] + if leaked: + print(f"\n!! {len(leaked)} non-ResolverError exceptions leaked out of the public API:") + for r in leaked[:10]: + print(f" {r.domain} {r.rdtype} {r.our_error}") + else: + print("\nNo non-ResolverError exceptions leaked out of the public API.") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prepare_test_domains.py b/scripts/prepare_test_domains.py old mode 100644 new mode 100755 index d10917d..2eaa740 --- a/scripts/prepare_test_domains.py +++ b/scripts/prepare_test_domains.py @@ -12,6 +12,7 @@ from __future__ import annotations import argparse +import contextlib import csv import gzip import io @@ -110,11 +111,11 @@ def _enrich_one(domain: str, resolver: object) -> list[str]: if "v=spf1" in txt_str: for part in txt_str.split(): if part.startswith("include:"): - spf_domain = part[len("include:"):].rstrip(".") + spf_domain = part[len("include:") :].rstrip(".") if "." in spf_domain: found.append(spf_domain) elif part.startswith("redirect="): - spf_domain = part[len("redirect="):].rstrip(".") + spf_domain = part[len("redirect=") :].rstrip(".") if "." in spf_domain: found.append(spf_domain) except Exception: @@ -145,10 +146,8 @@ def enrich_with_mx_spf(domains: set[str], workers: int = 32) -> set[str]: done += 1 if done % 500 == 0: print(f" Enriching... {done}/{total} domains checked, {len(enriched)} total") - try: + with contextlib.suppress(Exception): enriched.update(future.result()) - except Exception: - pass return enriched diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..0b0f034 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,469 @@ +#!/usr/bin/env bash +# +# Interactive release for recursive-resolver: PyPI, then a GitHub release. +# +# The structure of this script: the interactive gates, the hermetic +# build-and-upload inside Docker, and the artifact-inspection step: is adapted +# from bin/release-jmap-email.sh in https://github.com/suitenumerique/messages +# +# Copyright (c) 2025 Direction Interministérielle du Numérique +# Gouvernement Français +# Licensed under the MIT License. +# +# Hermetic: building, checking and uploading all happen inside the +# python:3.13-slim image, so the host never needs pip, build or twine. A clean +# machine needs only Docker, git and (for the last step) the gh CLI. +# +# Flow: +# 1. Pre-flight: clean tree, version consistency, changelog entry, no tag yet +# 2. make check-all: lint, format, types, full test suite, coverage gate +# 3. Build sdist + wheel in Docker, run twine check +# 4. Inspect artifact contents and METADATA against what this package promises +# 5. Upload to TestPyPI, then smoke-install and actually resolve a name +# 6. Upload to PyPI +# 7. Tag and create the GitHub release with the artifacts attached +# +# Every gate is interactive (y/N). Ctrl-C bails out at any point. +# +# SKIP_GATES=1 skip step 2 on a retry +# SKIP_TESTPYPI=1 go straight to PyPI (not recommended for a first release) + +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PYTHON_IMAGE="python:3.13-slim" +PKG_NAME="recursive-resolver" +PKG_MODULE="recursive_resolver" + +BOLD=$'\033[1m' +GREEN=$'\033[1;32m' +BLUE=$'\033[1;34m' +RED=$'\033[1;31m' +YELLOW=$'\033[1;33m' +RESET=$'\033[0m' + +say() { printf '%s%s%s\n' "${BLUE}" "$*" "${RESET}"; } +ok() { printf '%s✓ %s%s\n' "${GREEN}" "$*" "${RESET}"; } +warn() { printf '%s%s%s\n' "${YELLOW}" "$*" "${RESET}"; } +die() { printf '%s✗ %s%s\n' "${RED}" "$*" "${RESET}" >&2; exit 1; } + +confirm() { + local ans + read -r -p "${BOLD}$1 [y/N]${RESET} " ans + [[ "${ans}" =~ ^[Yy]$ ]] || die "aborted" +} + +read_token() { + local label="$1" varname="$2" token + read -r -s -p "${BOLD}${label} API token (pypi-…):${RESET} " token + echo + [[ -n "${token}" ]] || die "empty token" + [[ "${token}" == pypi-* ]] || warn "token does not start with 'pypi-': continuing anyway" + printf -v "${varname}" '%s' "${token}" +} + +in_container() { + # $1 = extra docker args (may be empty), rest = bash -c script + docker run --rm -t \ + --user "$(id -u):$(id -g)" \ + -v "${REPO_DIR}:/pkg" \ + -w /pkg \ + -e HOME=/tmp \ + "$@" +} + +# ── pre-flight ──────────────────────────────────────────────────────────── +command -v docker >/dev/null || die "docker not found in PATH" +command -v git >/dev/null || die "git not found in PATH" +[[ -f "${REPO_DIR}/pyproject.toml" ]] || die "no pyproject.toml at ${REPO_DIR}" + +VERSION="$(awk -F'"' '/^version = /{print $2; exit}' "${REPO_DIR}/pyproject.toml")" +[[ -n "${VERSION}" ]] || die "could not read version from pyproject.toml" + +MODULE_VERSION="$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' "${REPO_DIR}/src/${PKG_MODULE}/__init__.py")" +[[ "${MODULE_VERSION}" == "${VERSION}" ]] \ + || die "version mismatch: pyproject.toml=${VERSION} but __init__.py=${MODULE_VERSION}" + +# The release notes below are extracted from a "## [VERSION]" *heading*, so a +# version that only appears in a link definition at the bottom of the file must +# not pass pre-flight and then yield empty notes after the irreversible PyPI +# upload. index()==1 anchors at column 1, and awk's index() is a fixed-string +# search, so the version's dots are not wildcards. This matches the check the +# `release-check` target in the Makefile runs. +awk -v v="## [${VERSION}]" 'index($0, v) == 1 { found = 1; exit } END { exit !found }' \ + "${REPO_DIR}/CHANGELOG.md" \ + || die "CHANGELOG.md has no '## [${VERSION}]' section heading" + +TAG="v${VERSION}" +if git -C "${REPO_DIR}" rev-parse "${TAG}" >/dev/null 2>&1; then + die "tag ${TAG} already exists locally: bump the version first" +fi +# Also check the remote. A tag pushed from another machine, or left behind by a +# run that died between push and release, would otherwise pass here and only +# fail after the irreversible PyPI upload. +if git -C "${REPO_DIR}" ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then + die "tag ${TAG} already exists on origin: bump the version first" +fi + +if [[ -n "$(git -C "${REPO_DIR}" status --porcelain)" ]]; then + warn "working tree is dirty:" + git -C "${REPO_DIR}" status --short + confirm "Release anyway?" +fi + +printf '\n%s════════════════════════════════════════════════════════════%s\n' "${BLUE}" "${RESET}" +printf '%s Release %s %s%s\n' "${BLUE}" "${PKG_NAME}" "${VERSION}" "${RESET}" +printf '%s════════════════════════════════════════════════════════════%s\n\n' "${BLUE}" "${RESET}" +say "Image: ${PYTHON_IMAGE}" +say "Repo: ${REPO_DIR}" +say "Tag: ${TAG}" +say "Flow: checks → build → inspect → TestPyPI → smoke → PyPI → GitHub release" +[[ "${SKIP_GATES:-0}" == "1" ]] && warn "SKIP_GATES=1: checks will be skipped" +[[ "${SKIP_TESTPYPI:-0}" == "1" ]] && warn "SKIP_TESTPYPI=1: going straight to PyPI" +echo +confirm "Proceed?" + +# ── 1. checks ───────────────────────────────────────────────────────────── +if [[ "${SKIP_GATES:-0}" == "1" ]]; then + warn "skipping lint/typecheck/tests (SKIP_GATES=1)" +else + say "→ make check-all (lint, format, types, full suite, 100% coverage gate)" + make -C "${REPO_DIR}" check-all + ok "Checks passed" +fi + +# ── 2. build ────────────────────────────────────────────────────────────── +BUILD_SHA="$(git -C "${REPO_DIR}" rev-parse HEAD)" +say "→ Building from ${BUILD_SHA}" + +say "→ Cleaning previous artifacts" +rm -rf "${REPO_DIR}/dist" "${REPO_DIR}/build" + +say "→ Building sdist + wheel inside ${PYTHON_IMAGE}" +in_container "${PYTHON_IMAGE}" bash -c ' + set -euo pipefail + pip install --quiet --no-cache-dir --root-user-action=ignore --target /tmp/pip build twine + export PYTHONPATH=/tmp/pip + python -m build --outdir dist + python -m twine check dist/* +' +echo +ls -lh "${REPO_DIR}/dist/" +echo +ok "Build + twine check passed" + +# ── 3. inspect artifacts ────────────────────────────────────────────────── +say "→ Inspecting wheel and sdist contents against what this package promises" +docker run --rm -i -v "${REPO_DIR}/dist:/dist:ro" "${PYTHON_IMAGE}" \ + python - "${VERSION}" <<'PYEOF' +import re +import sys +import tarfile +import zipfile +from pathlib import Path + +VERSION = sys.argv[1] +DIST = Path("/dist") +WHEEL = DIST / f"recursive_resolver-{VERSION}-py3-none-any.whl" +SDIST = DIST / f"recursive_resolver-{VERSION}.tar.gz" + +errors, warnings = [], [] + +if not WHEEL.exists(): + print(f"FATAL: wheel missing: {WHEEL}") + sys.exit(1) +with zipfile.ZipFile(WHEEL) as zf: + wheel_names = set(zf.namelist()) + metadata = zf.read(f"recursive_resolver-{VERSION}.dist-info/METADATA").decode() + wheel_file_count = sum(1 for n in wheel_names if not n.endswith("/")) + +expected_wheel = { + "recursive_resolver/__init__.py", + "recursive_resolver/__main__.py", + "recursive_resolver/addresses.py", + "recursive_resolver/budget.py", + "recursive_resolver/cache.py", + "recursive_resolver/cli.py", + "recursive_resolver/dnssec.py", + "recursive_resolver/exceptions.py", + "recursive_resolver/resolver.py", + "recursive_resolver/roots.py", + "recursive_resolver/singleflight.py", + # Without this marker the Typing :: Typed classifier is a lie and every + # downstream type checker silently sees Any. + "recursive_resolver/py.typed", + f"recursive_resolver-{VERSION}.dist-info/METADATA", + f"recursive_resolver-{VERSION}.dist-info/WHEEL", + f"recursive_resolver-{VERSION}.dist-info/RECORD", + f"recursive_resolver-{VERSION}.dist-info/entry_points.txt", +} +missing = expected_wheel - wheel_names +if missing: + errors.append(f"wheel missing required files: {sorted(missing)}") + +forbidden = [ + ("tests/", lambda n: n.startswith("tests/")), + ("scripts/", lambda n: n.startswith("scripts/")), + (".pyc files", lambda n: n.endswith(".pyc")), + ("__pycache__", lambda n: "__pycache__" in n), + (".pytest_cache", lambda n: ".pytest_cache" in n), +] +for label, pred in forbidden: + bad = [n for n in wheel_names if pred(n)] + if bad: + errors.append(f"wheel contains forbidden {label}: {bad[:3]}") + +if not any("LICENSE" in n for n in wheel_names): + errors.append("wheel does not bundle LICENSE") + + +def meta(key): + m = re.search(rf"^{re.escape(key)}: (.+)$", metadata, re.MULTILINE) + return m.group(1).strip() if m else None + + +if meta("Name") != "recursive-resolver": + errors.append(f"METADATA Name: expected 'recursive-resolver', got {meta('Name')!r}") +if meta("Version") != VERSION: + errors.append(f"METADATA Version: expected {VERSION!r}, got {meta('Version')!r}") + +rp = meta("Requires-Python") +if not rp or "3.10" not in rp: + errors.append(f"METADATA Requires-Python missing or not >=3.10: {rp!r}") + +if not (meta("License-Expression") or meta("License")): + errors.append("METADATA has no License or License-Expression") + +dct = meta("Description-Content-Type") +if not dct or "markdown" not in dct.lower(): + errors.append(f"METADATA Description-Content-Type isn't markdown: {dct!r}") + +# The dependency floor is a security floor, and there is exactly one canonical +# value for it: pyproject.toml, SECURITY.md, CONTRIBUTING.md and this check all +# say 2.8.0. Below 2.6.1 dnspython is affected by CVE-2023-29483, 2.6.0 broke +# UDP->TCP failover, and 2.7.0 is the first release with the EDNS and DNSSEC +# behaviour this package relies on. +deps = re.findall(r"^Requires-Dist: (.+)$", metadata, re.MULTILINE) +dnspython = [d for d in deps if d.startswith("dnspython")] +if not dnspython: + errors.append("METADATA does not require dnspython") +else: + spec = dnspython[0] + if "dnssec" not in spec or "idna" not in spec: + errors.append(f"dnspython requirement is missing the dnssec/idna extras: {spec!r}") + m = re.search(r">=(\d+)\.(\d+)\.(\d+)", spec) + if not m or tuple(int(x) for x in m.groups()) < (2, 8, 0): + errors.append(f"dnspython floor is below the 2.8.0 security floor: {spec!r}") + +if "Typing :: Typed" not in metadata: + errors.append("METADATA missing the 'Typing :: Typed' classifier") +if "Topic :: Security" not in metadata: + warnings.append("METADATA missing the 'Topic :: Security' classifier") +if f"# {'recursive-resolver'}" not in metadata: + warnings.append("METADATA description does not contain the README heading") +for url in ("Homepage", "Repository", "Issues"): + if f"Project-URL: {url}," not in metadata: + warnings.append(f"METADATA missing Project-URL: {url}") + +if not SDIST.exists(): + print(f"FATAL: sdist missing: {SDIST}") + sys.exit(1) +with tarfile.open(SDIST) as tf: + sdist_names = tf.getnames() + +prefix = f"recursive_resolver-{VERSION}/" +sdist_rel = {n[len(prefix):] for n in sdist_names if n.startswith(prefix)} + +expected_sdist = { + "pyproject.toml", + "README.md", + "LICENSE", + "CHANGELOG.md", + "SECURITY.md", + "CONTRIBUTING.md", + "PKG-INFO", + "src/recursive_resolver/__init__.py", + "src/recursive_resolver/resolver.py", + "src/recursive_resolver/dnssec.py", + "src/recursive_resolver/py.typed", +} +missing_sdist = expected_sdist - sdist_rel +if missing_sdist: + errors.append(f"sdist missing required files: {sorted(missing_sdist)}") + +if not any(n.startswith("tests/") and n.endswith(".py") for n in sdist_rel): + warnings.append("sdist contains no tests/*.py") + +bad_sdist = [n for n in sdist_rel if n.endswith(".pyc") or "__pycache__" in n] +if bad_sdist: + errors.append(f"sdist contains bytecode: {bad_sdist[:3]}") + +print() +print(f" Wheel: {WHEEL.name} ({WHEEL.stat().st_size // 1024} KB, {wheel_file_count} files)") +print(f" Sdist: {SDIST.name} ({SDIST.stat().st_size // 1024} KB, {len(sdist_rel)} entries)") +print() +print(" METADATA highlights:") +for key in ("Name", "Version", "Requires-Python", "License-Expression", + "License", "Description-Content-Type"): + v = meta(key) + if v: + print(f" {key:28s} {v}") +for d in deps: + print(f" {'Requires-Dist':28s} {d}") +print() + +if warnings: + print(" ⚠ Warnings:") + for w in warnings: + print(f" {w}") + print() + +if errors: + print(" ✗ Errors:") + for e in errors: + print(f" {e}") + print() + sys.exit(1) + +print(" ✓ All artifact checks passed") +PYEOF + +echo +confirm "Artifacts look right?" + +# ── 4. TestPyPI + smoke install ─────────────────────────────────────────── +if [[ "${SKIP_TESTPYPI:-0}" != "1" ]]; then + say "→ TestPyPI upload" + echo "Get a token at https://test.pypi.org/manage/account/token/" + echo "(Account-scoped on a first release; project-scoped afterwards.)" + read_token "TestPyPI" TESTPYPI_TOKEN + # Passed by name, not by value: "-e VAR=secret" would put the token in the + # docker client's argv, where any local process can read it from ps. + export TWINE_PASSWORD="${TESTPYPI_TOKEN}" + + in_container \ + -e TWINE_USERNAME=__token__ \ + -e TWINE_PASSWORD \ + "${PYTHON_IMAGE}" bash -c ' + set -euo pipefail + pip install --quiet --no-cache-dir --root-user-action=ignore --target /tmp/pip twine + export PYTHONPATH=/tmp/pip + python -m twine upload --skip-existing --repository-url https://test.pypi.org/legacy/ dist/* + ' + ok "Uploaded to TestPyPI" + echo " https://test.pypi.org/project/${PKG_NAME}/${VERSION}/" + + say "→ Smoke-installing ${PKG_NAME}==${VERSION} from TestPyPI and resolving for real" + # Only the package under test comes from TestPyPI; dnspython, cryptography + # and idna are resolved from real PyPI. The retry loop covers index + # propagation lag after upload (~30s). + docker run --rm -t "${PYTHON_IMAGE}" bash -c " + set -euo pipefail + for i in 1 2 3 4 5; do + if pip install --quiet --no-cache-dir \ + --index-url https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ \ + '${PKG_NAME}==${VERSION}'; then + break + fi + echo 'index not yet propagated, retrying in 10s…' + sleep 10 + done + python - <<'SMOKE' +import recursive_resolver +from recursive_resolver import RecursiveResolver, ValidationState + +assert recursive_resolver.__version__ == '${VERSION}', recursive_resolver.__version__ + +resolver = RecursiveResolver(max_resolution_time=25) + +# A signed zone must validate. +signed = resolver.resolve_answer('cloudflare.com', 'A') +assert signed.dnssec is ValidationState.SECURE, signed.dnssec +assert signed.records + +# A multi-chunk DKIM key must join with no separator. +dkim = resolver.resolve_answer('zendesk1._domainkey.zendesk.com', 'TXT') +value = dkim.text_values()[0] +assert '\" \"' not in value, value[:80] +assert 'p=' in value + +print('smoke ok: version', recursive_resolver.__version__) +print(' cloudflare.com A ->', signed.records[0], '(' + signed.dnssec.value + ')') +print(' DKIM key joined ->', value[:48] + '…') +SMOKE + recursive-resolver --version + " + ok "Smoke install passed" +fi + +# ── 5. PyPI ─────────────────────────────────────────────────────────────── +echo +warn "The next step is irreversible: PyPI version numbers can never be reused." +confirm "Publish ${PKG_NAME} ${VERSION} to real PyPI?" + +say "→ PyPI upload" +echo "Get a token at https://pypi.org/manage/account/token/" +read_token "PyPI" PYPI_TOKEN +export TWINE_PASSWORD="${PYPI_TOKEN}" + +in_container \ + -e TWINE_USERNAME=__token__ \ + -e TWINE_PASSWORD \ + "${PYTHON_IMAGE}" bash -c ' + set -euo pipefail + pip install --quiet --no-cache-dir --root-user-action=ignore --target /tmp/pip twine + export PYTHONPATH=/tmp/pip + # No --skip-existing here (unlike TestPyPI, where it makes retries + # painless): on PyPI it would turn "this version is already published" + # into a silent success, and we would go on to tag and cut a GitHub + # release for artifacts PyPI never accepted. + python -m twine upload dist/* + ' +echo +ok "${PKG_NAME} ${VERSION} released to PyPI" +echo " https://pypi.org/project/${PKG_NAME}/${VERSION}/" + +# ── 6. GitHub release ───────────────────────────────────────────────────── +echo +if ! command -v gh >/dev/null; then + warn "gh CLI not found: skipping the GitHub release." + echo "Tag it by hand when you're ready:" + echo " git tag -a ${TAG} -m 'Release ${TAG}' && git push origin ${TAG}" + exit 0 +fi + +confirm "Tag ${TAG} and create the GitHub release?" + +say "→ Tagging ${TAG} at the commit the artifacts were built from" +if [[ "$(git -C "${REPO_DIR}" rev-parse HEAD)" != "${BUILD_SHA}" ]]; then + warn "HEAD has moved since the build (${BUILD_SHA} -> $(git -C "${REPO_DIR}" rev-parse --short HEAD))" + warn "the tag will point at the commit that was actually built and published" +fi +git -C "${REPO_DIR}" tag -a "${TAG}" -m "Release ${TAG}" "${BUILD_SHA}" +git -C "${REPO_DIR}" push origin "${TAG}" + +say "→ Creating the GitHub release with the artifacts attached" +NOTES_FILE="$(mktemp)" +trap 'rm -f "${NOTES_FILE}"' EXIT +# Take this version's section out of the changelog as the release notes. +awk -v ver="${VERSION}" ' + $0 ~ "^## \\[" ver "\\]" { inside = 1; next } + inside && /^## \[/ { exit } + inside { print } +' "${REPO_DIR}/CHANGELOG.md" > "${NOTES_FILE}" +[[ -s "${NOTES_FILE}" ]] || die "extracted release notes for ${VERSION} are empty" +printf '\n---\n\nInstall: `pip install %s==%s`\n' "${PKG_NAME}" "${VERSION}" >> "${NOTES_FILE}" + +REPO_SLUG="$(git -C "${REPO_DIR}" remote get-url origin \ + | sed -E 's#\.git$##' \ + | sed -E 's#.*[:/]([^/]+/[^/]+)$#\1#')" + +gh release create "${TAG}" "${REPO_DIR}"/dist/* \ + --title "${TAG}" \ + --notes-file "${NOTES_FILE}" \ + --repo "${REPO_SLUG}" + +echo +ok "GitHub release ${TAG} created" +echo " https://github.com/${REPO_SLUG}/releases/tag/${TAG}" diff --git a/src/recursive_resolver/__init__.py b/src/recursive_resolver/__init__.py index 919b999..9b525e9 100644 --- a/src/recursive_resolver/__init__.py +++ b/src/recursive_resolver/__init__.py @@ -1,21 +1,38 @@ -"""recursive-resolver: Pure-Python DNS recursive resolver — iterates from root servers, bypassing all caches.""" +"""recursive-resolver: Pure-Python DNS recursive resolver that iterates from the root servers.""" +from .addresses import AddressFilter +from .budget import Limits +from .cache import DNSCache +from .dnssec import ValidationState from .exceptions import ( CNAMELoopError, + DNSSECError, + DNSSECInsecureError, + DNSSECUnavailableError, + DNSSECValidationError, + InvalidNameError, MaxDepthError, NoAnswerError, NXDOMAINError, + QueryBudgetExceededError, ResolutionTimeoutError, ResolverError, ServfailError, + UnsupportedRdtypeError, ) -from .resolver import RecursiveResolver, TraceStep +from .resolver import Answer, RecursiveResolver, TraceStep __version__ = "0.1.0" __all__ = [ "RecursiveResolver", + "Answer", "TraceStep", + "ValidationState", + "Limits", + "DNSCache", + "AddressFilter", + # Exceptions "ResolverError", "NXDOMAINError", "NoAnswerError", @@ -23,5 +40,12 @@ "ResolutionTimeoutError", "CNAMELoopError", "ServfailError", + "InvalidNameError", + "UnsupportedRdtypeError", + "QueryBudgetExceededError", + "DNSSECError", + "DNSSECValidationError", + "DNSSECInsecureError", + "DNSSECUnavailableError", "__version__", ] diff --git a/src/recursive_resolver/__main__.py b/src/recursive_resolver/__main__.py index fe234a3..2fc4c92 100644 --- a/src/recursive_resolver/__main__.py +++ b/src/recursive_resolver/__main__.py @@ -2,4 +2,5 @@ from .cli import main -raise SystemExit(main()) +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/recursive_resolver/addresses.py b/src/recursive_resolver/addresses.py new file mode 100644 index 0000000..4c42e73 --- /dev/null +++ b/src/recursive_resolver/addresses.py @@ -0,0 +1,139 @@ +"""Address sanity checks for nameserver IPs. + +A referral's glue records are attacker-controlled data, and so are the A/AAAA +records returned when resolving a glueless NS hostname. Without filtering, any +domain owner can point their zone's nameservers at ``127.0.0.1``, RFC1918 +space, or a cloud metadata endpoint and turn a resolver into an unauthenticated +internal-network prober (a DNS-driven SSRF). + +Classification comes from Python's :mod:`ipaddress`, which tracks the IANA +special-purpose address registries: an address is refused unless it is globally +routable and none of loopback, link-local, multicast, reserved or private. That +is deliberately not a hand-maintained CIDR list: it stays correct as IANA +allocates, and it already covers CGNAT, the documentation prefixes, +``0.0.0.0/8``, ``240.0.0.0/4``, benchmarking space, Teredo, 6to4, NAT64 and the +IPv4-mapped range. + +A short built-in list then adds the handful of ranges that ``is_global`` +still considers routable but that no legitimate public nameserver occupies: +the one that matters most is Azure's ``168.63.129.16``, which is a real public-looking +address serving instance metadata. + +The order of the checks below (specific categories before ``is_private``, since +loopback and link-local are subsets of it in Python's model), the ``is_global`` +catch-all for shared address space, and the idea of naming the cloud-metadata +endpoints explicitly are adapted from ``core/services/ssrf.py`` in +https://github.com/suitenumerique/messages: Copyright (c) 2025 Direction +Interministérielle du Numérique, Gouvernement Français, MIT Licensed. +""" + +from __future__ import annotations + +import ipaddress +from collections.abc import Iterable + +IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address +IPNetwork = ipaddress.IPv4Network | ipaddress.IPv6Network + +# Ranges Python's ipaddress module still reports as globally routable, but which +# no public nameserver legitimately occupies. Each is cited so the list can be +# audited rather than trusted. +_BUILTIN_BLOCKED_NETWORKS: tuple[str, ...] = ( + # Azure Instance Metadata Service and platform DNS. A genuinely public + # address, so no generic classification catches it. + "168.63.129.16/32", + # 6to4 relay anycast, deprecated by RFC 7526 but still routed in places. + "192.88.99.0/24", + # ORCHIDv2 (RFC 7343): cryptographic identifiers, never a real host. + "2001:20::/28", + # Deprecated IPv6 site-local (RFC 3879); some networks still route it. + "fec0::/10", +) + +# Named purely so a rejection can say *why*. Every one of these is already +# caught by the classification above; listing them makes the logs legible. +CLOUD_METADATA_ADDRESSES: frozenset[str] = frozenset( + { + "169.254.169.254", # AWS, GCP, Azure, DigitalOcean, Oracle + "fd00:ec2::254", # AWS IMDS over IPv6 + "100.100.100.200", # Alibaba Cloud + "192.0.0.192", # Oracle Cloud (legacy) + "168.63.129.16", # Azure wireserver + } +) + + +def _parse_networks(networks: Iterable[str]) -> tuple[IPNetwork, ...]: + return tuple(ipaddress.ip_network(n) for n in networks) + + +_BUILTIN_PARSED = _parse_networks(_BUILTIN_BLOCKED_NETWORKS) + + +class AddressFilter: + """Decides whether an IP address may be used as a nameserver. + + Args: + extra_blocked_networks: Further CIDRs to refuse, such as your own + internal ranges. These are added to the built-in rules, never + substituted for them: a blocklist that quietly shrinks when you + extend it is a bad way to build a security control. + allow_private: If True, accept any syntactically valid address. Only set + this for split-horizon or lab setups where you fully trust every + zone you will ever resolve: it disables the SSRF control entirely. + """ + + def __init__( + self, + extra_blocked_networks: Iterable[str] | None = None, + allow_private: bool = False, + ) -> None: + self.allow_private = allow_private + self._networks: tuple[IPNetwork, ...] = _BUILTIN_PARSED + _parse_networks(extra_blocked_networks or ()) + + def rejection_reason(self, address: str) -> str | None: + """Return why ``address`` is unusable as a nameserver, or None if it is fine.""" + try: + addr: IPAddress = ipaddress.ip_address(address) + except ValueError: + return "not a valid IP address" + + if self.allow_private: + return None + + # An IPv4-mapped IPv6 address (::ffff:a.b.c.d) must be judged on its + # embedded IPv4 address, or it is a trivial bypass of every v4 rule. + mapped = getattr(addr, "ipv4_mapped", None) + if mapped is not None: + addr = mapped + + if str(addr) in CLOUD_METADATA_ADDRESSES: + return "cloud metadata endpoint" + if addr.is_loopback: + return "loopback address" + if addr.is_link_local: + return "link-local address" + if addr.is_multicast: + return "multicast address" + if addr.is_unspecified: + return "unspecified address" + if addr.is_reserved: + return "reserved address" + if addr.is_private: + return "private address" + # Catches what the specific tests above miss, notably CGNAT + # (100.64.0.0/10), which is neither private nor reserved here. + if not addr.is_global: + return "not globally routable" + for network in self._networks: + if addr.version == network.version and addr in network: + return f"blocked range {network}" + return None + + def is_allowed(self, address: str) -> bool: + """Return True if ``address`` is a routable public IP we may query.""" + return self.rejection_reason(address) is None + + def filter(self, addresses: Iterable[str]) -> list[str]: + """Return only the addresses that are allowed, preserving order.""" + return [a for a in addresses if self.is_allowed(a)] diff --git a/src/recursive_resolver/budget.py b/src/recursive_resolver/budget.py new file mode 100644 index 0000000..9350348 --- /dev/null +++ b/src/recursive_resolver/budget.py @@ -0,0 +1,185 @@ +"""Per-resolution work budget. + +A single ``resolve()`` call fans out: referrals lead to more referrals, and a +glueless referral makes us resolve each NS hostname, each of which is itself a +full resolution that may fan out again. Bounding only the recursion *depth* +leaves the branching factor unbounded: a hostile zone that answers every +query with a glueless referral to 50 fresh NS names can provoke tens of +thousands of upstream queries from one call, which is the NXNSAttack +(CVE-2020-8616, CVE-2020-12662) and the Non-Responsive Delegation attack +(CVE-2022-3204). + +Every production resolver therefore carries a counter that is *shared with +sub-resolutions*. This module is that counter. Reference values: + +=================== ================================== ======= +Resolver Setting Value +=================== ================================== ======= +BIND 9 ``max-recursion-queries`` 32 +PowerDNS Recursor ``max-qperq`` 50 +Unbound ``MAX_TARGET_COUNT`` 64 +Unbound ``MAX_TARGET_NX`` 5 +PowerDNS Recursor ``max-ns-per-resolve`` 13 +Unbound ``MAX_REFERRAL_COUNT`` 130 +=================== ================================== ======= +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field + +from .exceptions import QueryBudgetExceededError + +# Unbound's MAX_TARGET_COUNT. Higher than BIND's 32 because we always walk from +# the root on a cold delegation cache, which costs a few extra queries per name. +DEFAULT_MAX_QUERIES = 64 + +# Unbound's MAX_TARGET_NX. The core NXNSAttack control: an attacker's NS names +# deliberately do not resolve, so cap how many failures we will absorb. +DEFAULT_MAX_NX_TARGETS = 5 + +# Unbound's MAX_REFERRAL_COUNT. +DEFAULT_MAX_REFERRALS = 130 + +# PowerDNS's max-ns-per-resolve. +DEFAULT_MAX_NS_PER_REFERRAL = 13 + +# DNSSEC crypto work limits (KeyTrap, CVE-2023-50387 / CVE-2023-50868). A +# malicious zone can publish many DNSKEYs and many colliding RRSIGs so that a +# validator performs O(keys x signatures) expensive operations for a single +# answer. +# +# The quadratic blowup is already killed per-RRset by the tag trimming in +# dnssec.py (at most MAX_RRSIGS_PER_RRSET attempts however many keys and +# signatures a zone publishes); this is the defence-in-depth global bound. +# +# PowerDNS uses 30, but it validates against a warm cache. We walk from the +# root on a cold cache, where the measured worst case across deep signed +# chains, including ones reached through a CNAME, is 19. +# 96 leaves ~5x headroom while still bounding an attacker to a few +# milliseconds of CPU per query. +DEFAULT_MAX_SIGNATURE_VALIDATIONS = 96 +DEFAULT_MAX_NSEC3_HASHES = 600 + + +@dataclass(frozen=True) +class Limits: + """Immutable hardening limits, shared by every resolution a resolver runs. + + These exist because of specific attacks rather than because anyone wants to + tune them, and they move as a set: raising ``max_queries`` without raising + ``max_referrals`` just moves which counter fires first. Grouping them keeps + the resolver constructor honest about that, and lets a limit be added for + the next CVE without changing its signature. + + The defaults are the reference values in the table above. Raise them only + if you have measured a legitimate name that needs the headroom; every one + of them is a bound on what a hostile zone can make you do. + + Args: + max_queries: Total upstream DNS queries allowed for one ``resolve()``, + including every sub-resolution of an NS hostname. + max_ns_per_referral: NS hostnames chased when a referral arrives + without usable glue. + max_nx_targets: NS-hostname resolutions allowed to fail before the + call is abandoned. The core NXNSAttack control. + max_referrals: Referrals allowed to be followed in one call, summed + over the whole call tree. Within a single ``_resolve_iterative`` + frame the binding limit is the resolver's ``max_depth`` (20 by + default), which is far below this: ``max_referrals`` bounds the + fan-out across NS-hostname sub-resolutions, not the depth of any + one delegation walk. + max_signature_validations: RRSIG verifications allowed in one call + (KeyTrap). + max_nsec3_hashes: NSEC3 hash computations allowed in one call + (KeyTrap). + """ + + max_queries: int = DEFAULT_MAX_QUERIES + max_ns_per_referral: int = DEFAULT_MAX_NS_PER_REFERRAL + max_nx_targets: int = DEFAULT_MAX_NX_TARGETS + max_referrals: int = DEFAULT_MAX_REFERRALS + max_signature_validations: int = DEFAULT_MAX_SIGNATURE_VALIDATIONS + max_nsec3_hashes: int = DEFAULT_MAX_NSEC3_HASHES + + def budget(self, deadline: float) -> QueryBudget: + """Return a fresh per-resolution counter enforcing these limits.""" + return QueryBudget( + max_queries=self.max_queries, + max_nx_targets=self.max_nx_targets, + max_referrals=self.max_referrals, + max_signature_validations=self.max_signature_validations, + max_nsec3_hashes=self.max_nsec3_hashes, + deadline=deadline, + ) + + +@dataclass +class QueryBudget: + """Mutable work budget shared across a resolution and all its sub-resolutions. + + Args: + max_queries: Total upstream DNS queries allowed for the whole call. + max_nx_targets: Total NS-hostname resolutions allowed to fail. + max_referrals: Total referrals allowed to be followed. + max_signature_validations: Total RRSIG verifications allowed (KeyTrap). + max_nsec3_hashes: Total NSEC3 hash computations allowed (KeyTrap). + deadline: Absolute ``time.monotonic()`` value after which the + resolution is abandoned. + """ + + max_queries: int = DEFAULT_MAX_QUERIES + max_nx_targets: int = DEFAULT_MAX_NX_TARGETS + max_referrals: int = DEFAULT_MAX_REFERRALS + max_signature_validations: int = DEFAULT_MAX_SIGNATURE_VALIDATIONS + max_nsec3_hashes: int = DEFAULT_MAX_NSEC3_HASHES + deadline: float = float("inf") + + queries_sent: int = field(default=0, init=False) + nx_targets: int = field(default=0, init=False) + referrals_followed: int = field(default=0, init=False) + signature_validations: int = field(default=0, init=False) + nsec3_hashes: int = field(default=0, init=False) + + def spend_query(self, qname: str, rdtype: str) -> None: + """Account for one outgoing query. Raises when the budget is exhausted.""" + if self.queries_sent >= self.max_queries: + raise QueryBudgetExceededError(qname, rdtype, "queries", self.max_queries) + self.queries_sent += 1 + + def note_referral(self, qname: str, rdtype: str) -> None: + """Account for one followed referral.""" + if self.referrals_followed >= self.max_referrals: + raise QueryBudgetExceededError(qname, rdtype, "referrals", self.max_referrals) + self.referrals_followed += 1 + + def note_nx_target(self, qname: str, rdtype: str) -> None: + """Account for one NS hostname that failed to resolve.""" + self.nx_targets += 1 + if self.nx_targets > self.max_nx_targets: + raise QueryBudgetExceededError(qname, rdtype, "failed NS targets", self.max_nx_targets) + + def spend_signature_validation(self) -> None: + """Account for one signature verification (KeyTrap control).""" + self.signature_validations += 1 + if self.signature_validations > self.max_signature_validations: + raise QueryBudgetExceededError("", "RRSIG", "signature validations", self.max_signature_validations) + + def spend_nsec3_hash(self) -> None: + """Account for one NSEC3 hash computation (KeyTrap control).""" + self.nsec3_hashes += 1 + if self.nsec3_hashes > self.max_nsec3_hashes: + raise QueryBudgetExceededError("", "NSEC3", "NSEC3 hash computations", self.max_nsec3_hashes) + + def remaining_queries(self) -> int: + """Number of queries still permitted.""" + return max(0, self.max_queries - self.queries_sent) + + def expired(self) -> bool: + """True if the wall-clock deadline has passed.""" + return time.monotonic() >= self.deadline + + def time_remaining(self) -> float: + """Seconds left before the deadline (never negative).""" + return max(0.0, self.deadline - time.monotonic()) diff --git a/src/recursive_resolver/cache.py b/src/recursive_resolver/cache.py index bee74fd..78c3999 100644 --- a/src/recursive_resolver/cache.py +++ b/src/recursive_resolver/cache.py @@ -1,25 +1,116 @@ -"""Thread-safe DNS cache with TTL expiry and negative caching.""" +"""Thread-safe DNS cache with TTL expiry, negative caching and delegation caching. + +Three kinds of entry share one LRU: + +``answer`` + A final RRset for a (name, type, class) tuple. +``negative`` + An NXDOMAIN (keyed by *name* only, per RFC 2308 §5) or a NODATA (keyed by + name and type). +``delegation`` + The nameservers for a zone cut. Caching these is what stops every single + resolution from starting with a query to a root server. + +Keys are :class:`dns.name.Name` objects rather than strings, so lookups are +correctly case-insensitive and immune to the ``str.lower()`` / IDNA mismatches +that plague string keys. +""" from __future__ import annotations import threading import time -from dataclasses import dataclass +from collections import OrderedDict +from dataclasses import dataclass, field, replace from typing import Any +import dns.name +import dns.rdataclass +import dns.rdatatype +import dns.rrset + +# Named delegation-cache levels. Only the levels with a reason to exist are +# named; any label depth still works as an integer if you have an unusual need. +# The number is a label depth: the root is 0, ``com.`` is 1, ``example.com.`` +# is 2. +CACHE_DEPTHS: dict[str, int | None] = { + # Cache nothing. Every lookup walks from a root server. + "none": -1, + # Cache only the delegations the root hands out, so lookups start at the + # TLD servers and never touch a root server, while everything below the TLD + # is re-resolved every time. TLD delegations change very rarely, which makes + # this a good production setting when answer freshness matters. + "tld": 1, + # Cache every zone cut. + "all": None, +} + + +def resolve_cache_depth(value: int | str | None) -> int | None: + """Turn a level name or an integer into a label depth. + + Accepts any key of :data:`CACHE_DEPTHS`, an integer depth, or None for + unlimited. + """ + if value is None or isinstance(value, int): + return value + text = value.strip().lower() + if text in CACHE_DEPTHS: + return CACHE_DEPTHS[text] + try: + return int(text) + except ValueError: + raise ValueError( + f"unknown cache depth {value!r}; use an integer or one of: {', '.join(CACHE_DEPTHS)}" + ) from None + + +# Entry-kind tags used to namespace the shared LRU. +_ANSWER = "A" +_NODATA = "ND" +_NXDOMAIN = "NX" +_DELEGATION = "DG" + +CacheKey = tuple[str, dns.name.Name, int, int] + @dataclass class CacheEntry: """A single cached DNS response.""" - rrset: Any # dns.rrset.RRset or None for negative entries + rrset: Any # dns.rrset.RRset, Delegation, or None for NXDOMAIN expiry: float # time.monotonic() value when this entry expires is_negative: bool = False + secure: bool = False # DNSSEC-authenticated + + +@dataclass +class Delegation: + """A cached zone cut: the nameservers authoritative for ``zone``. + + ``ds`` carries the validated DS rdataset for the zone so a resumed + resolution can rebuild the chain of trust without depending on a separate + DNSKEY cache still being warm. + """ + + zone: dns.name.Name + addresses: list[str] = field(default_factory=list) + ns_names: list[str] = field(default_factory=list) + secure: bool = False + ds: Any = None @dataclass class CacheStats: - """Cache hit/miss statistics.""" + """Cache hit/miss statistics for *answer* lookups only. + + ``hits`` and ``misses`` are counted by :meth:`DNSCache.get_answer`. The + negative and delegation lookups (``get_nxdomain``, ``get_nodata``, + ``get_delegation``, ``closest_delegation``) are deliberately not counted, + even though in an iterative resolver they are the majority of lookups: this + is the "did the cache answer the user's question outright" rate, not a + whole-cache rate. ``evictions`` does cover the shared LRU as a whole. + """ hits: int = 0 misses: int = 0 @@ -27,7 +118,7 @@ class CacheStats: @property def hit_rate(self) -> float: - """Return the cache hit rate as a float between 0.0 and 1.0.""" + """Return the answer-lookup hit rate as a float between 0.0 and 1.0.""" total = self.hits + self.misses if total == 0: return 0.0 @@ -37,108 +128,255 @@ def hit_rate(self) -> float: class DNSCache: """Thread-safe DNS cache with TTL-based expiry and negative caching. - Uses time.monotonic() for TTL tracking (immune to system clock adjustments). + Uses ``time.monotonic()`` for TTL tracking (immune to system clock changes) + and an :class:`~collections.OrderedDict` for O(1) LRU maintenance. Args: max_size: Maximum number of entries. 0 means unlimited. - min_ttl: Minimum TTL in seconds (clamps short TTLs). - max_ttl: Maximum TTL in seconds (clamps long TTLs). - negative_ttl: TTL for negative cache entries (NXDOMAIN/NODATA) in seconds. + min_ttl: Floor applied to record TTLs, in seconds. 0 honours the wire + TTL exactly, which is what you want when freshness matters: + key rotation, GSLB failover. Negative entries are not floored; + see :meth:`_clamp`. + max_ttl: Ceiling applied to record TTLs, in seconds. + negative_ttl: Fallback TTL for negative entries when the authority + section carries no usable SOA. + max_negative_ttl: Ceiling applied to SOA-derived negative TTLs. + max_delegation_depth: How deep to cache zone cuts. Either a label depth + (root = 0, ``com.`` = 1, ``example.com.`` = 2) or one of the names + in :data:`CACHE_DEPTHS`: ``"tld"``, ``"all"``, ``"none"``. + ``None`` caches every level. """ def __init__( self, max_size: int = 10000, - min_ttl: int = 10, + min_ttl: int = 0, max_ttl: int = 86400, negative_ttl: int = 300, + max_negative_ttl: int = 3600, + max_delegation_depth: int | str | None = None, ) -> None: self.max_size = max_size self.min_ttl = min_ttl self.max_ttl = max_ttl self.negative_ttl = negative_ttl - self._cache: dict[tuple[str, str, str], CacheEntry] = {} - self._access_order: list[tuple[str, str, str]] = [] + self.max_negative_ttl = max_negative_ttl + self.max_delegation_depth = resolve_cache_depth(max_delegation_depth) + self._cache: OrderedDict[CacheKey, CacheEntry] = OrderedDict() self._lock = threading.Lock() self.stats = CacheStats() - def get(self, qname: str, rdtype: str, rdclass: str = "IN") -> CacheEntry | None: - """Look up an entry in the cache. + # ── internal helpers ──────────────────────────────────────────────── + + @staticmethod + def _name(name: dns.name.Name | str) -> dns.name.Name: + if isinstance(name, dns.name.Name): + return name + return dns.name.from_text(name) + + @staticmethod + def _rdtype(rdtype: int | str) -> int: + if isinstance(rdtype, int): + return rdtype + return int(dns.rdatatype.from_text(rdtype)) + + def _clamp(self, ttl: int, negative: bool = False) -> int: + """Clamp a TTL to this cache's bounds. + + ``min_ttl`` is a floor on *record* TTLs only. Negative entries have + their own controls (``negative_ttl``, ``max_negative_ttl``) and + deliberately no floor: raising ``min_ttl`` to keep a warm answer cache + must not also hold an NXDOMAIN past what the zone's SOA asked for, or a + name that has just been created stays missing for longer than its + operator intended. + """ + if negative: + return min(int(ttl), self.max_negative_ttl) + return max(self.min_ttl, min(int(ttl), self.max_ttl)) + + def _get(self, key: CacheKey) -> CacheEntry | None: + """Fetch and LRU-touch a key. Must be called under the lock.""" + entry = self._cache.get(key) + if entry is None: + return None + if time.monotonic() >= entry.expiry: + del self._cache[key] + return None + self._cache.move_to_end(key) + return entry + + def _put(self, key: CacheKey, entry: CacheEntry) -> None: + """Insert a key. Must be called under the lock.""" + if self.max_size > 0 and key not in self._cache and len(self._cache) >= self.max_size: + self._cache.popitem(last=False) + self.stats.evictions += 1 + self._cache[key] = entry + self._cache.move_to_end(key) + + # ── answers ───────────────────────────────────────────────────────── + + @staticmethod + def _isolate(rrset: Any) -> Any: + """Return a copy of an RRset, so cache and caller never share one. + + A :class:`dns.rrset.RRset` is a mutable container. Handing the same + object to the cache and to a caller means an ``add`` or a ``ttl`` + assignment anywhere silently rewrites what every later lookup returns, + across every thread. Copying is cheap because the rdata objects inside + are immutable and stay shared; only the container is duplicated. - Returns the CacheEntry if found and not expired, otherwise None. - Expired entries are removed on access. + A :class:`Delegation` needs the same treatment for the same reason: its + ``addresses`` and ``ns_names`` are mutable lists, so a caller that + appends to one would rewrite the cached zone cut for every thread. """ - key = (qname.lower(), rdtype.upper(), rdclass.upper()) + if isinstance(rrset, dns.rrset.RRset): + return rrset.copy() + if isinstance(rrset, Delegation): + return replace(rrset, addresses=list(rrset.addresses), ns_names=list(rrset.ns_names)) + return rrset + + def get_answer( + self, qname: dns.name.Name | str, rdtype: int | str, rdclass: int = dns.rdataclass.IN + ) -> CacheEntry | None: + """Look up a positive answer. Returns None on miss or expiry. + + The returned entry carries a private copy of the RRset; mutating it + cannot affect the cache or any other caller. + """ + key: CacheKey = (_ANSWER, self._name(qname), self._rdtype(rdtype), rdclass) with self._lock: - entry = self._cache.get(key) + entry = self._get(key) if entry is None: self.stats.misses += 1 return None - if time.monotonic() >= entry.expiry: - del self._cache[key] - if key in self._access_order: - self._access_order.remove(key) - self.stats.misses += 1 - return None - # Move to end of access order (most recently used) - if key in self._access_order: - self._access_order.remove(key) - self._access_order.append(key) self.stats.hits += 1 - return entry + return replace(entry, rrset=self._isolate(entry.rrset)) - def put( + def put_answer( self, - qname: str, - rdtype: str, + qname: dns.name.Name | str, + rdtype: int | str, rrset: Any, - ttl: int | None = None, - rdclass: str = "IN", - is_negative: bool = False, + ttl: int, + rdclass: int = dns.rdataclass.IN, + secure: bool = False, ) -> None: - """Store an entry in the cache. - - Args: - qname: The query name. - rdtype: The record type (e.g. "A", "AAAA"). - rrset: The dns.rrset.RRset to cache (or None for negative entries). - ttl: TTL in seconds. If None, uses negative_ttl for negative entries or min_ttl. - rdclass: The record class (default "IN"). - is_negative: Whether this is a negative cache entry (NXDOMAIN/NODATA). + """Store a positive answer, taking a private copy of the RRset.""" + key: CacheKey = (_ANSWER, self._name(qname), self._rdtype(rdtype), rdclass) + entry = CacheEntry(rrset=self._isolate(rrset), expiry=time.monotonic() + self._clamp(ttl), secure=secure) + with self._lock: + self._put(key, entry) + + # ── negative answers ──────────────────────────────────────────────── + + def get_nxdomain(self, qname: dns.name.Name | str) -> CacheEntry | None: + """Look up a cached NXDOMAIN for this exact name.""" + key: CacheKey = (_NXDOMAIN, self._name(qname), 0, 0) + with self._lock: + return self._get(key) + + def get_nxdomain_ancestor(self, qname: dns.name.Name | str) -> dns.name.Name | None: + """Return a cached NXDOMAIN ancestor of ``qname``, if any. + + Implements RFC 8020 / ``harden-below-nxdomain``: if ``foo.example.com`` + does not exist, nothing below it can exist either. This is the primary + defence against random-subdomain (water torture) floods. """ - key = (qname.lower(), rdtype.upper(), rdclass.upper()) + name = self._name(qname) + with self._lock: + current = name + while True: + if self._get((_NXDOMAIN, current, 0, 0)) is not None: + return current + if current == dns.name.root: + return None + try: + current = current.parent() + except dns.name.NoParent: # pragma: no cover - guarded by the root check + return None - if ttl is None: - ttl = self.negative_ttl if is_negative else self.min_ttl - ttl = max(self.min_ttl, min(ttl, self.max_ttl)) + def put_nxdomain(self, qname: dns.name.Name | str, ttl: int | None = None) -> None: + """Store an NXDOMAIN, keyed by name only (RFC 2308 §5).""" + key: CacheKey = (_NXDOMAIN, self._name(qname), 0, 0) + effective = self.negative_ttl if ttl is None else ttl + expiry = time.monotonic() + self._clamp(effective, negative=True) + entry = CacheEntry(rrset=None, expiry=expiry, is_negative=True) + with self._lock: + self._put(key, entry) - expiry = time.monotonic() + ttl - entry = CacheEntry(rrset=rrset, expiry=expiry, is_negative=is_negative) + def get_nodata( + self, qname: dns.name.Name | str, rdtype: int | str, rdclass: int = dns.rdataclass.IN + ) -> CacheEntry | None: + """Look up a cached NODATA for this name and type.""" + key: CacheKey = (_NODATA, self._name(qname), self._rdtype(rdtype), rdclass) + with self._lock: + return self._get(key) + def put_nodata( + self, + qname: dns.name.Name | str, + rdtype: int | str, + ttl: int | None = None, + rdclass: int = dns.rdataclass.IN, + ) -> None: + """Store a NODATA for this name and type.""" + key: CacheKey = (_NODATA, self._name(qname), self._rdtype(rdtype), rdclass) + effective = self.negative_ttl if ttl is None else ttl + expiry = time.monotonic() + self._clamp(effective, negative=True) + entry = CacheEntry(rrset=None, expiry=expiry, is_negative=True) with self._lock: - # Evict if at capacity - if self.max_size > 0 and len(self._cache) >= self.max_size and key not in self._cache: - self._evict_one() - - self._cache[key] = entry - if key in self._access_order: - self._access_order.remove(key) - self._access_order.append(key) - - def _evict_one(self) -> None: - """Evict the least recently used entry. Must be called under lock.""" - if self._access_order: - oldest_key = self._access_order.pop(0) - self._cache.pop(oldest_key, None) - self.stats.evictions += 1 + self._put(key, entry) + + # ── delegations ───────────────────────────────────────────────────── + + def put_delegation(self, delegation: Delegation, ttl: int) -> None: + """Cache the nameservers for a zone cut, subject to the depth limit.""" + depth = len(delegation.zone) - 1 # dns.name length includes the root label + if self.max_delegation_depth is not None and depth > self.max_delegation_depth: + return + key: CacheKey = (_DELEGATION, delegation.zone, 0, 0) + entry = CacheEntry(rrset=self._isolate(delegation), expiry=time.monotonic() + self._clamp(ttl)) + with self._lock: + self._put(key, entry) + + def get_delegation(self, zone: dns.name.Name) -> Delegation | None: + """Look up the cached delegation for exactly this zone.""" + with self._lock: + entry = self._get((_DELEGATION, zone, 0, 0)) + if entry is None: + return None + delegation: Delegation = entry.rrset + return self._isolate(delegation) # type: ignore[no-any-return] + + def closest_delegation(self, qname: dns.name.Name) -> Delegation | None: + """Return the deepest cached delegation that is an ancestor of ``qname``. + + This is what lets a resolution start partway down the tree instead of + at a root server on every single call. + """ + with self._lock: + current = qname + while True: + entry = self._get((_DELEGATION, current, 0, 0)) + if entry is not None: + delegation: Delegation = entry.rrset + if delegation.addresses: + return self._isolate(delegation) # type: ignore[no-any-return] + if current == dns.name.root: + return None + try: + current = current.parent() + except dns.name.NoParent: # pragma: no cover - guarded by the root check + return None + + # ── maintenance ───────────────────────────────────────────────────── def clear(self) -> None: """Remove all entries from the cache.""" with self._lock: self._cache.clear() - self._access_order.clear() def __len__(self) -> int: - """Return the number of entries in the cache (including potentially expired ones).""" + """Return the number of entries in the cache (including expired ones).""" with self._lock: return len(self._cache) diff --git a/src/recursive_resolver/cli.py b/src/recursive_resolver/cli.py index 976b321..d423fad 100644 --- a/src/recursive_resolver/cli.py +++ b/src/recursive_resolver/cli.py @@ -1,20 +1,45 @@ -"""Command-line interface for recursive-resolver (DNS recursive resolution from root servers).""" +"""Command-line interface for recursive-resolver.""" from __future__ import annotations import argparse import json import logging +import os import sys -from . import RecursiveResolver, ResolverError, __version__ +from . import Answer, RecursiveResolver, ResolverError, ValidationState, __version__ +from .resolver import DEFAULT_EDNS_PAYLOAD +# Wording follows delv, BIND's validating lookup utility, so the states read the +# same across tools. delv prints these on stdout, but it emits full records; +# our plain output is bare values, so the note goes to stderr and stdout stays +# pipeable. "2>/dev/null" is then an explicit opt-out. +_DNSSEC_NOTES = { + ValidationState.SECURE: "; fully validated", + ValidationState.INSECURE: "; unsigned answer", + ValidationState.BOGUS: "; validation failed", +} -def main(argv: list[str] | None = None) -> int: - """Entry point for the recursive-resolver CLI.""" + +def _note_dnssec(answer: Answer, args: argparse.Namespace) -> None: + """Report the DNSSEC verdict on stderr, so plain output is not silently unvalidated. + + Only for the plain output paths; the JSON payload carries the state itself. + """ + if args.no_dnssec: + # Without this the default Answer state would render as "unsigned + # answer", which claims a proof we never went looking for. + print("; dnssec validation disabled", file=sys.stderr) + return + print(_DNSSEC_NOTES[answer.dnssec], file=sys.stderr) + + +def build_parser() -> argparse.ArgumentParser: + """Build the CLI argument parser.""" parser = argparse.ArgumentParser( prog="recursive-resolver", - description="Resolve DNS records iteratively from root servers.", + description="Resolve DNS records iteratively from the root servers, with DNSSEC validation.", ) parser.add_argument("domain", help="Domain name to resolve (or IP for PTR)") parser.add_argument( @@ -23,109 +48,196 @@ def main(argv: list[str] | None = None) -> int: default="A", help="Record type: A, AAAA, MX, TXT, NS, SOA, PTR, CNAME, SRV, CAA, ... (default: A)", ) + parser.add_argument("--trace", action="store_true", help="Show the full delegation trace") + parser.add_argument("--json", action="store_true", dest="json_output", help="Output results as JSON") parser.add_argument( - "--trace", - action="store_true", - help="Show the full delegation trace", - ) - parser.add_argument( - "--json", + "--text", action="store_true", - dest="json_output", - help="Output results as JSON", + help="Print TXT-like records as concatenated character-strings (correct for DKIM/SPF)", ) + parser.add_argument("--timeout", type=float, default=2.0, help="Per-query timeout in seconds (default: 2.0)") + parser.add_argument("--max-depth", type=int, default=20, help="Maximum delegation depth (default: 20)") parser.add_argument( - "--timeout", - type=float, - default=5.0, - help="Per-query timeout in seconds (default: 5.0)", + "--max-time", type=float, default=15.0, help="Max total resolution time in seconds (default: 15.0)" ) parser.add_argument( - "--max-depth", + "--edns-payload", type=int, - default=20, - help="Maximum delegation depth (default: 20)", + default=DEFAULT_EDNS_PAYLOAD, + help=f"Advertised EDNS0 UDP payload size (default: {DEFAULT_EDNS_PAYLOAD})", ) + parser.add_argument("--no-cache", action="store_true", help="Disable all caching") parser.add_argument( - "--max-time", - type=float, - default=30.0, - help="Max total resolution time in seconds (default: 30.0)", - ) - parser.add_argument( - "--no-cache", + "--no-cache-answers", action="store_true", - help="Disable response caching", + help="Do not cache answers (keeps delegation caching, for maximum freshness)", ) parser.add_argument( - "--ipv6", - action="store_true", - help="Enable IPv6 for queries (default: IPv4 only)", + "--cache-depth", + default=None, + metavar="LEVEL", + help="How deep to cache zone cuts: tld, all, none, or a label depth", ) - parser.add_argument( - "-v", - "--verbose", - action="store_true", - help="Enable debug logging", + # Mutually exclusive: with both set the resolver would be built with + # validation off and validation required, and every lookup would fail with + # DNSSECInsecureError instead of a usage message. + dnssec_group = parser.add_mutually_exclusive_group() + dnssec_group.add_argument("--no-dnssec", action="store_true", help="Disable DNSSEC validation") + dnssec_group.add_argument( + "--require-dnssec", action="store_true", help="Fail unless the answer is DNSSEC-authenticated" ) parser.add_argument( - "--version", - action="version", - version=f"%(prog)s {__version__}", + "--allow-private", + action="store_true", + help="Allow private/loopback nameserver addresses (split-horizon DNS only)", ) + parser.add_argument("--ipv6", action="store_true", help="Enable IPv6 for queries (default: IPv4 only)") + parser.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging") + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + return parser - args = parser.parse_args(argv) + +def main(argv: list[str] | None = None) -> int: + """Entry point for the recursive-resolver CLI.""" + args = build_parser().parse_args(argv) if args.verbose: - logging.basicConfig( - level=logging.DEBUG, - format="%(name)s %(levelname)s %(message)s", - stream=sys.stderr, - ) + logging.basicConfig(level=logging.DEBUG, format="%(name)s %(levelname)s %(message)s", stream=sys.stderr) - resolver = RecursiveResolver( - timeout=args.timeout, - max_depth=args.max_depth, - cache_enabled=not args.no_cache, - ipv4_only=not args.ipv6, - max_resolution_time=args.max_time, - ) + try: + resolver = RecursiveResolver( + timeout=args.timeout, + max_depth=args.max_depth, + cache_enabled=not args.no_cache, + cache_answers=not args.no_cache_answers, + max_delegation_cache_depth=args.cache_depth, + ipv4_only=not args.ipv6, + max_resolution_time=args.max_time, + edns_payload=args.edns_payload, + dnssec=not args.no_dnssec, + require_dnssec=args.require_dnssec, + allow_private_addresses=args.allow_private, + ) + except (ResolverError, ValueError) as exc: + print(f"{type(exc).__name__}: {exc}", file=sys.stderr) + return 2 try: if args.trace: - trace = resolver.resolve_with_trace(args.domain, args.rdtype) - if args.json_output: - data = [ - { - "server": step.server, - "qname": step.qname, - "rdtype": step.rdtype, - "response_type": step.response_type, - "detail": step.detail, - "rcode": step.rcode, - } - for step in trace - ] - print(json.dumps(data, indent=2)) - else: - for step in trace: - print(f"{step.server:20s} {step.qname:30s} {step.response_type:10s} {step.detail}") - else: - answers = resolver.resolve(args.domain, args.rdtype) - if args.json_output: - print(json.dumps(answers, indent=2)) - else: - for answer in answers: - print(answer) - except ResolverError as e: + return _run_trace(resolver, args) + return _run_query(resolver, args) + except BrokenPipeError: + # The reader closed the pipe early ("... | head -1"). Point stdout at + # /dev/null so the interpreter's shutdown flush does not raise again + # and print "Exception ignored" over the user's terminal. dup2 makes + # the descriptor a duplicate, so the original is closed straight after. + devnull = os.open(os.devnull, os.O_WRONLY) + try: + os.dup2(devnull, sys.stdout.fileno()) + finally: + # The equality case cannot arise here: os.open only returns a free + # descriptor, and had stdout's own descriptor been closed the write + # above would have raised EBADF rather than BrokenPipeError. Kept + # anyway so the close is unconditionally safe. + if devnull != sys.stdout.fileno(): # pragma: no branch + os.close(devnull) + return 0 + except ResolverError as exc: if args.json_output: - print(json.dumps({"error": type(e).__name__, "message": str(e)}), file=sys.stderr) + print(json.dumps({"error": type(exc).__name__, "message": str(exc)}), file=sys.stderr) else: - print(f"{type(e).__name__}: {e}", file=sys.stderr) + print(f"{type(exc).__name__}: {exc}", file=sys.stderr) return 1 + +def _values_for(answer: Answer, args: argparse.Namespace) -> list[str] | None: + """The records to print, honouring ``--text``; None if ``--text`` does not apply. + + Shared by the query and trace paths so that ``--text`` means the same thing + in both: printing ``answer.records`` for a TXT lookup reintroduces the `" "` + seam between character-strings that ``text_values()`` exists to remove, and + that seam is what breaks DKIM and SPF. + """ + if not args.text: + return answer.records + try: + return answer.text_values() + except TypeError as exc: + print(f"--text: {exc}", file=sys.stderr) + return None + + +def _run_query(resolver: RecursiveResolver, args: argparse.Namespace) -> int: + answer = resolver.resolve_answer(args.domain, args.rdtype) + values = _values_for(answer, args) + if values is None: + return 2 + + if args.json_output: + print( + json.dumps( + { + "qname": str(answer.qname), + "canonical_name": str(answer.canonical_name), + "records": values, + "ttl": answer.ttl, + "dnssec": answer.dnssec.value, + }, + indent=2, + ) + ) + else: + _note_dnssec(answer, args) + for value in values: + print(value) return 0 -if __name__ == "__main__": - sys.exit(main()) +def _run_trace(resolver: RecursiveResolver, args: argparse.Namespace) -> int: + answer, trace = resolver.trace_answer(args.domain, args.rdtype) + values = None + if answer is not None: + values = _values_for(answer, args) + if values is None: + return 2 + failure = None + if answer is None: + last = trace[-1].response_type if trace else "no response" + failure = f"Resolution failed after {len(trace)} step(s); last response: {last}" + + if args.json_output: + payload = { + "trace": [ + { + "server": step.server, + "qname": step.qname, + "rdtype": step.rdtype, + "response_type": step.response_type, + "detail": step.detail, + "rcode": step.rcode, + "zone": step.zone, + "dnssec": step.dnssec, + } + for step in trace + ], + "records": values, + "dnssec": answer.dnssec.value if answer else None, + } + if failure is not None: + # Otherwise the payload is success-shaped with two nulls, and the + # only statement that anything went wrong is English on stderr. + # Same key names as the error object main() emits. + payload["error"] = "ResolutionIncomplete" + payload["message"] = failure + print(json.dumps(payload, indent=2)) + else: + for step in trace: + print(f"{step.server:20s} {step.qname:30s} {step.response_type:10s} {step.dnssec:9s} {step.detail}") + if answer is not None: + _note_dnssec(answer, args) + for record in values or []: + print(record) + if failure is not None: + print(failure, file=sys.stderr) + return 1 + return 0 diff --git a/src/recursive_resolver/dnssec.py b/src/recursive_resolver/dnssec.py new file mode 100644 index 0000000..caca4d7 --- /dev/null +++ b/src/recursive_resolver/dnssec.py @@ -0,0 +1,613 @@ +"""DNSSEC validation: chain of trust, RRSIG verification and denial of existence. + +The validator walks down from the IANA root trust anchors +(:data:`~recursive_resolver.roots.ROOT_TRUST_ANCHORS`). At every zone cut it +establishes one of three states, following RFC 4035: + +``SECURE`` + The zone's DNSKEY chains back to the root anchor and the data is signed. +``INSECURE`` + Some ancestor proved, with an NSEC/NSEC3 record, that no DS exists: the + zone is legitimately unsigned. This is the common case: most domains are + not signed. +``BOGUS`` + The zone claims to be signed but the signatures, the delegation chain or + the denial-of-existence proof do not validate. The data must not be used. + +Only ``BOGUS`` is an error. Treating ``INSECURE`` as an error would reject the +majority of the internet, so that is opt-in via ``require_dnssec=True``. +""" + +from __future__ import annotations + +import base64 +import logging +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +import dns.dnssec +import dns.exception +import dns.name +import dns.rdata +import dns.rdataclass +import dns.rdataset +import dns.rdatatype +import dns.rrset +from dns.rdtypes.ANY.NSEC3 import b32_normal_to_hex + +from .roots import ROOT_TRUST_ANCHORS + +logger = logging.getLogger(__name__) + +# DNSKEY flag bits (RFC 4034 §2.1.1). +FLAG_ZONE_KEY = 0x0100 +FLAG_SECURE_ENTRY_POINT = 0x0001 + +# NSEC3 flags (RFC 5155 §3.1.2). +NSEC3_OPT_OUT = 0x01 + +# Reject absurd NSEC3 iteration counts outright rather than burning CPU on +# them. RFC 9276 requires zones to publish 0; .com/.net/.org already do, and +# common ccTLDs stay in single digits, so 100 rejects only abuse. +MAX_NSEC3_ITERATIONS = 100 + +# KeyTrap (CVE-2023-50387) hardening. The attack publishes many DNSKEYs and +# many RRSIGs that collide on key tag and algorithm, so a validator that tries +# every signature against every candidate key performs quadratic work for one +# answer. PowerDNS caps both at 2 per (tag, algorithm). +MAX_KEYS_PER_TAG = 2 +MAX_RRSIGS_PER_TAG = 2 +MAX_RRSIGS_PER_RRSET = 8 +MAX_DS_PER_ZONE = 8 + + +class ValidationState(Enum): + """The DNSSEC status of a response.""" + + SECURE = "secure" + INSECURE = "insecure" + BOGUS = "bogus" + + +def algorithm_supported(algorithm: int) -> bool: + """True if signatures made with this DNSSEC algorithm can be verified here. + + Which algorithms work depends on the installed dnspython and cryptography, + so this is a property of the build rather than a fixed list. A zone signed + only with an algorithm we lack is unverifiable, which RFC 4035 §5.2 says to + treat as unsigned rather than as an attack. + """ + try: + dns.dnssec.get_algorithm_cls(algorithm) + except Exception: + return False + return True + + +def cryptography_available() -> bool: + """True if the optional `cryptography` dependency is importable.""" + try: + import cryptography # noqa: F401 + except ImportError: + return False + return True + + +def _types_in_bitmap(rdata: Any) -> set[int]: + """Return the set of rdatatypes present in an NSEC/NSEC3 type bitmap.""" + types: set[int] = set() + for window, bitmap in rdata.windows: + for i, byte in enumerate(bitmap): + for j in range(8): + if byte & (0x80 >> j): + types.add(window * 256 + i * 8 + j) + return types + + +def find_rrsig(rrsets: list[dns.rrset.RRset], name: dns.name.Name, covered: int) -> dns.rrset.RRset | None: + """Find the RRSIG RRset covering rdatatype ``covered`` at owner ``name``.""" + for rrset in rrsets: + if rrset.rdtype != dns.rdatatype.RRSIG or rrset.name != name: + continue + if any(rr.type_covered == covered for rr in rrset): + return rrset + return None + + +def _rrset_of(rrsets: list[dns.rrset.RRset], name: dns.name.Name, rdtype: int) -> dns.rrset.RRset | None: + for rrset in rrsets: + if rrset.name == name and rrset.rdtype == rdtype and rrset.rdclass == dns.rdataclass.IN: + return rrset + return None + + +def _trim_keyring(keys: dict[dns.name.Name, Any]) -> dict[dns.name.Name, Any]: + """Keep at most MAX_KEYS_PER_TAG keys per (key tag, algorithm). + + Colliding key tags are the core of KeyTrap: without this a single answer can + force one signature check per published key. + """ + trimmed: dict[dns.name.Name, Any] = {} + for name, keyset in keys.items(): + if keyset is None: + continue + seen: dict[tuple[int, int], int] = {} + kept = dns.rrset.RRset(name, dns.rdataclass.IN, dns.rdatatype.DNSKEY) + for key in keyset: + try: + tag = dns.dnssec.key_id(key) + except Exception: # pragma: no cover - malformed key + continue + slot = (tag, int(key.algorithm)) + if seen.get(slot, 0) >= MAX_KEYS_PER_TAG: + continue + seen[slot] = seen.get(slot, 0) + 1 + kept.add(key) + kept.ttl = getattr(keyset, "ttl", 0) + trimmed[name] = kept + return trimmed + + +def _trim_rrsigs(rrsig_rrset: dns.rrset.RRset) -> list[Any]: + """Keep a bounded, tag-diverse subset of the RRSIGs to try.""" + seen: dict[tuple[int, int], int] = {} + out: list[Any] = [] + for rrsig in rrsig_rrset: + slot = (int(rrsig.key_tag), int(rrsig.algorithm)) + if seen.get(slot, 0) >= MAX_RRSIGS_PER_TAG: + continue + seen[slot] = seen.get(slot, 0) + 1 + out.append(rrsig) + if len(out) >= MAX_RRSIGS_PER_RRSET: + break + return out + + +@dataclass +class ZoneKeys: + """Validated DNSKEY material for one zone.""" + + zone: dns.name.Name + dnskey_rrset: dns.rrset.RRset | None + state: ValidationState + expiry: float = field(default=0.0) + + def as_keyring(self) -> dict[dns.name.Name, Any]: + return {self.zone: self.dnskey_rrset} + + +class DNSSECValidator: + """Validates DNSSEC signatures and denial-of-existence proofs. + + Args: + trust_anchors: DS records for the root, in presentation format. + Must be non-empty: an empty anchor set can never validate anything, + so it is rejected rather than silently treated as "use the + defaults". + max_nsec3_iterations: Reject NSEC3 records above this iteration count. + + Raises: + ValueError: If ``trust_anchors`` is empty. + """ + + def __init__( + self, + trust_anchors: tuple[str, ...] = ROOT_TRUST_ANCHORS, + max_nsec3_iterations: int = MAX_NSEC3_ITERATIONS, + ) -> None: + if not trust_anchors: + raise ValueError("trust_anchors must not be empty; omit it to use the IANA root anchors") + self.max_nsec3_iterations = max_nsec3_iterations + self._root_ds = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.DS) + for anchor in trust_anchors: + self._root_ds.add(dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DS, anchor), ttl=86400) + + # ── signature validation ──────────────────────────────────────────── + + def validate_rrset( + self, + rrset: dns.rrset.RRset, + rrsig_rrset: dns.rrset.RRset | None, + keys: dict[dns.name.Name, Any], + now: float | None = None, + budget: Any = None, + ) -> bool: + """Verify ``rrset`` against its RRSIG using ``keys``. + + Signatures are tried one at a time, against a tag-trimmed keyring, and + each attempt is charged to the budget. Letting dnspython loop over the + full cross-product internally would leave the KeyTrap work unbounded. + """ + if rrsig_rrset is None: + return False + trimmed_keys = _trim_keyring(keys) + if not trimmed_keys: + return False + for rrsig in _trim_rrsigs(rrsig_rrset): + if budget is not None: + budget.spend_signature_validation() + try: + dns.dnssec.validate_rrsig(rrset, rrsig, trimmed_keys, now=now) + except Exception as exc: + logger.debug("RRSIG (tag %s) failed for %s: %s", rrsig.key_tag, rrset.name, exc) + continue + return True + return False + + def validate_dnskey( + self, + zone: dns.name.Name, + dnskey_rrset: dns.rrset.RRset, + rrsig_rrset: dns.rrset.RRset | None, + ds_rdataset: dns.rdataset.Rdataset, + budget: Any = None, + ) -> ValidationState: + """Verify a zone's DNSKEY RRset against the DS records held by its parent. + + At least one DS must match a Secure Entry Point key, and that key must + have signed the DNSKEY RRset itself (RFC 4035 §5.2). + + Returns: + SECURE when that holds. INSECURE when the DS RRset names only + digest types or signing algorithms this build cannot compute: + RFC 4035 §5.2 and RFC 6840 §5.2 say a child we have no way to + check must be treated as unsigned, not as forged. BOGUS otherwise. + """ + matched: list[Any] = [] + # Digest computations we completed, versus ones we could not perform. + # Only "we could never compute one" means unverifiable; a zone that + # publishes no usable key at all is broken, not merely unsupported. + compared = 0 + undigestable = 0 + # Bound the DS x DNSKEY digest cross-product (KeyTrap). + candidate_ds = list(ds_rdataset)[:MAX_DS_PER_ZONE] + for key in dnskey_rrset: + if not key.flags & FLAG_ZONE_KEY: + continue + for ds in candidate_ds: + try: + computed = dns.dnssec.make_ds(zone, key, ds.digest_type, validating=True) + except Exception as exc: + logger.debug("Cannot compute DS for %s (digest type %s): %s", zone, ds.digest_type, exc) + undigestable += 1 + continue + compared += 1 + if computed == ds: + matched.append(key) + break + + if not matched: + if undigestable and not compared: + logger.debug("No usable DS digest type for %s; treating the zone as unsigned", zone) + return ValidationState.INSECURE + logger.debug("No DS record matches any DNSKEY for %s", zone) + return ValidationState.BOGUS + + # A key we matched but whose signatures we cannot verify leaves us in + # the same position: unable to check the zone, so unable to call it + # forged. + if not any(algorithm_supported(key.algorithm) for key in matched): + logger.debug("DS for %s names only unsupported algorithms; treating the zone as unsigned", zone) + return ValidationState.INSECURE + + # The DNSKEY RRset must be self-signed by one of the DS-matched keys. + keyring = {zone: dnskey_rrset} + if self.validate_rrset(dnskey_rrset, rrsig_rrset, keyring, budget=budget): + return ValidationState.SECURE + return ValidationState.BOGUS + + def validate_root_dnskey( + self, dnskey_rrset: dns.rrset.RRset, rrsig_rrset: dns.rrset.RRset | None, budget: Any = None + ) -> bool: + """Verify the root DNSKEY RRset against the built-in trust anchors. + + The root is the anchor, so there is no "treat as unsigned" option here: + anything short of a full match is a failure. + """ + state = self.validate_dnskey(dns.name.root, dnskey_rrset, rrsig_rrset, self._root_ds, budget=budget) + return state is ValidationState.SECURE + + def validate_ds( + self, + child: dns.name.Name, + authority: list[dns.rrset.RRset], + parent_keys: dict[dns.name.Name, Any], + budget: Any = None, + ) -> tuple[ValidationState, dns.rdataset.Rdataset | None]: + """Establish whether ``child`` is a signed delegation. + + Returns ``(SECURE, ds_rdataset)`` when a validated DS exists, + ``(INSECURE, None)`` when the parent proves no DS exists, and + ``(BOGUS, None)`` when neither can be established. + """ + ds_rrset = _rrset_of(authority, child, dns.rdatatype.DS) + if ds_rrset is not None: + rrsig = find_rrsig(authority, child, dns.rdatatype.DS) + if not self.validate_rrset(ds_rrset, rrsig, parent_keys, budget=budget): + return ValidationState.BOGUS, None + rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.DS) + for rr in ds_rrset: + rdataset.add(rr, ttl=ds_rrset.ttl) + return ValidationState.SECURE, rdataset + + # No DS: the parent must prove its absence, or the delegation is bogus. + if self.prove_no_ds(child, authority, parent_keys, budget=budget): + return ValidationState.INSECURE, None + return ValidationState.BOGUS, None + + # ── denial of existence ───────────────────────────────────────────── + + def _validated_nsec_rrsets( + self, authority: list[dns.rrset.RRset], keys: dict[dns.name.Name, Any], rdtype: int, budget: Any = None + ) -> list[dns.rrset.RRset]: + """Return the NSEC (or NSEC3) RRsets in ``authority`` whose RRSIGs validate.""" + out: list[dns.rrset.RRset] = [] + for rrset in authority: + if rrset.rdtype != rdtype or rrset.rdclass != dns.rdataclass.IN: + continue + rrsig = find_rrsig(authority, rrset.name, rdtype) + if self.validate_rrset(rrset, rrsig, keys, budget=budget): + out.append(rrset) + return out + + @staticmethod + def _nsec_covers(owner: dns.name.Name, next_name: dns.name.Name, target: dns.name.Name) -> bool: + """True if ``target`` falls strictly between ``owner`` and ``next_name``.""" + if owner == next_name: + # A single-NSEC zone covers everything except the owner itself. + return bool(target != owner) + if owner < next_name: + return bool(owner < target < next_name) + # The last NSEC in the zone wraps around to the apex. + return bool(target > owner or target < next_name) + + def prove_no_ds( + self, + child: dns.name.Name, + authority: list[dns.rrset.RRset], + keys: dict[dns.name.Name, Any], + budget: Any = None, + ) -> bool: + """Prove that no DS record exists for ``child`` (an insecure delegation).""" + # NSEC: a record matching the child whose bitmap has NS but not DS. + for nsec in self._validated_nsec_rrsets(authority, keys, dns.rdatatype.NSEC, budget): + if nsec.name != child: + continue + types = _types_in_bitmap(nsec[0]) + if dns.rdatatype.DS not in types and dns.rdatatype.NS in types: + return True + + # NSEC3: either a matching record without the DS bit, or an opt-out + # NSEC3 covering the child (RFC 5155 §8.9). + nsec3s = self._validated_nsec_rrsets(authority, keys, dns.rdatatype.NSEC3, budget) + for nsec3 in nsec3s: + params = nsec3[0] + if params.iterations > self.max_nsec3_iterations: + logger.debug("NSEC3 iteration count %d exceeds cap", params.iterations) + return False + hashed = self._nsec3_owner(child, nsec3.name, params, budget) + if hashed is None: + continue + if hashed == nsec3.name: + types = _types_in_bitmap(params) + if dns.rdatatype.DS not in types and dns.rdatatype.NS in types: + return True + + # Opt-out: an NSEC3 covering the child's hash, with the opt-out flag. + for nsec3 in nsec3s: + params = nsec3[0] + if not params.flags & NSEC3_OPT_OUT: + continue + if self._nsec3_covers(child, nsec3, params, budget): + return True + return False + + def _nsec3_owner( + self, name: dns.name.Name, nsec3_owner: dns.name.Name, params: Any, budget: Any = None + ) -> dns.name.Name | None: + """Hash ``name`` with ``params`` and return the resulting NSEC3 owner name.""" + if budget is not None: + budget.spend_nsec3_hash() + try: + digest = dns.dnssec.nsec3_hash(name, params.salt, params.iterations, params.algorithm) + except Exception as exc: + logger.debug("NSEC3 hashing failed for %s: %s", name, exc) + return None + zone = nsec3_owner.parent() if len(nsec3_owner) > 1 else dns.name.root + try: + return dns.name.from_text(digest, origin=zone) + except dns.exception.DNSException: # pragma: no cover - digest is always a valid label + return None + + def _nsec3_covers(self, name: dns.name.Name, nsec3: dns.rrset.RRset, params: Any, budget: Any = None) -> bool: + """True if the NSEC3 record covers (but does not match) ``name``'s hash. + + ``params`` supplies the zone-wide hashing parameters (salt, iterations, + algorithm). The next-hashed-owner interval, however, belongs to *this* + record: reading it from ``params`` would compare against a different + record's range and get the answer wrong whenever a proof spans several + NSEC3 records, which is the normal case for NXDOMAIN and opt-out. + """ + hashed = self._nsec3_owner(name, nsec3.name, params, budget) + if hashed is None: + return False + try: + # The next-hashed-owner is stored as raw bytes; render it in the + # same base32hex alphabet dnspython uses for the owner name. + encoded = base64.b32encode(nsec3[0].next).translate(b32_normal_to_hex).decode("ascii") + zone = nsec3.name.parent() if len(nsec3.name) > 1 else dns.name.root + next_label = dns.name.from_text(encoded, origin=zone) + except (dns.exception.DNSException, ValueError, TypeError) as exc: + logger.debug("Cannot decode NSEC3 next-hashed-owner: %s", exc) + return False + return self._nsec_covers(nsec3.name, next_label, hashed) + + def prove_nxdomain( + self, + qname: dns.name.Name, + authority: list[dns.rrset.RRset], + keys: dict[dns.name.Name, Any], + budget: Any = None, + ) -> bool: + """Prove that ``qname`` does not exist at all.""" + nsecs = self._validated_nsec_rrsets(authority, keys, dns.rdatatype.NSEC, budget) + if nsecs: + name_denied = any(self._nsec_covers(n.name, n[0].next, qname) for n in nsecs) + if not name_denied: + return False + # A wildcard could still synthesise the name, so its absence must + # also be proven (RFC 4035 §5.4). + return self._wildcard_denied_nsec(qname, nsecs) + + nsec3s = self._validated_nsec_rrsets(authority, keys, dns.rdatatype.NSEC3, budget) + if not nsec3s: + return False + params = nsec3s[0][0] + if params.iterations > self.max_nsec3_iterations: + return False + closest = self._closest_encloser_nsec3(qname, nsec3s, params, budget) + if closest is None: + return False + # The next closer name must be covered, and so must the wildcard at + # the closest encloser. + next_closer = self._next_closer(qname, closest) + if next_closer is None: + return False + if not any(self._nsec3_covers(next_closer, n, params, budget) for n in nsec3s): + return False + # A wildcard at the closest encloser would have synthesised the name, + # so its non-existence must be covered too (RFC 5155 §8.4). + wildcard = dns.name.Name((b"*",) + closest.labels) + return any(self._nsec3_covers(wildcard, n, params, budget) for n in nsec3s) + + def prove_wildcard( + self, + qname: dns.name.Name, + labels: int, + authority: list[dns.rrset.RRset], + keys: dict[dns.name.Name, Any], + budget: Any = None, + ) -> bool: + """Prove a wildcard-expanded answer was legitimate (RFC 4035 §5.3.4). + + An RRSIG whose ``labels`` count is smaller than the owner name's label + count says the data was synthesised from a wildcard. The signature + alone does not make that answer trustworthy: it verifies against the + reconstructed ``*.`` name, so the *same* signature + verifies for every name the wildcard could cover. Without the extra + proof below, anyone able to replay a zone's genuine wildcard record + under a different owner name gets it validated, overriding whatever + explicit data that name really holds. + + What must still be shown is that ``qname`` itself does not exist. Had + it existed, the server was obliged to answer from the exact name, so a + wildcard answer for a name that does exist is a substitution. + + Args: + qname: The name that was queried. + labels: The RRSIG ``labels`` field, which fixes the closest + encloser and therefore which wildcard was used. + """ + # NSEC: some record must cover qname itself, i.e. assert that nothing + # exists between its two neighbours where qname would sort. + nsecs = self._validated_nsec_rrsets(authority, keys, dns.rdatatype.NSEC, budget) + if any(self._nsec_covers(n.name, n[0].next, qname) for n in nsecs): + return True + + # NSEC3: the closest encloser follows from the labels count, and the + # name one label below it (the "next closer") must be covered. + nsec3s = self._validated_nsec_rrsets(authority, keys, dns.rdatatype.NSEC3, budget) + if not nsec3s: + return False + extra = len(qname.labels) - 1 - labels + if extra < 1: + return False + closest = dns.name.Name(qname.labels[extra:]) + next_closer = self._next_closer(qname, closest) + if next_closer is None: # pragma: no cover - extra >= 1 guarantees a next closer + return False + params = nsec3s[0][0] + if params.iterations > self.max_nsec3_iterations: + return False + return any(self._nsec3_covers(next_closer, n, params, budget) for n in nsec3s) + + def _wildcard_denied_nsec(self, qname: dns.name.Name, nsecs: list[dns.rrset.RRset]) -> bool: + """Check that no wildcard could have synthesised ``qname``. + + Known limitation: rather than deriving the closest encloser, this + accepts a covering NSEC for a wildcard at any ancestor of ``qname``, + which is weaker than RFC 4035 §5.4 strictly requires. Exploiting the + difference needs valid RRSIGs from the zone's own key: i.e. control of + the zone, so it is a completeness gap, not a third-party attack. + """ + for i in range(1, len(qname.labels)): + wildcard = dns.name.Name((b"*",) + qname.labels[i:]) + for nsec in nsecs: + if self._nsec_covers(nsec.name, nsec[0].next, wildcard): + return True + return False + + def _closest_encloser_nsec3( + self, qname: dns.name.Name, nsec3s: list[dns.rrset.RRset], params: Any, budget: Any = None + ) -> dns.name.Name | None: + """Find the deepest ancestor of ``qname`` with a matching NSEC3 record.""" + current = qname + while True: + for nsec3 in nsec3s: + if self._nsec3_owner(current, nsec3.name, params, budget) == nsec3.name: + return current + if current == dns.name.root: + return None + try: + current = current.parent() + except dns.name.NoParent: # pragma: no cover - guarded above + return None + + @staticmethod + def _next_closer(qname: dns.name.Name, closest: dns.name.Name) -> dns.name.Name | None: + """The name one label longer than ``closest`` on the path to ``qname``.""" + extra = len(qname.labels) - len(closest.labels) + if extra < 1: + return None + return dns.name.Name(qname.labels[extra - 1 :]) + + def prove_nodata( + self, + qname: dns.name.Name, + rdtype: int, + authority: list[dns.rrset.RRset], + keys: dict[dns.name.Name, Any], + budget: Any = None, + ) -> bool: + """Prove that ``qname`` exists but has no records of type ``rdtype``.""" + for nsec in self._validated_nsec_rrsets(authority, keys, dns.rdatatype.NSEC, budget): + if nsec.name != qname: + continue + types = _types_in_bitmap(nsec[0]) + if rdtype not in types and dns.rdatatype.CNAME not in types: + return True + + nsec3s = self._validated_nsec_rrsets(authority, keys, dns.rdatatype.NSEC3, budget) + if not nsec3s: + return False + params = nsec3s[0][0] + if params.iterations > self.max_nsec3_iterations: + return False + for nsec3 in nsec3s: + if self._nsec3_owner(qname, nsec3.name, params, budget) != nsec3.name: + continue + types = _types_in_bitmap(nsec3[0]) + if rdtype not in types and dns.rdatatype.CNAME not in types: + return True + + # Opt-out NODATA for an unsigned delegation below an NSEC3 zone. When + # `closest` is a proper ancestor of `qname` a next-closer name always + # exists, so the two conditions collapse into one. + closest = self._closest_encloser_nsec3(qname, nsec3s, params, budget) + next_closer = self._next_closer(qname, closest) if closest is not None else None + if next_closer is not None: + for nsec3 in nsec3s: + if nsec3[0].flags & NSEC3_OPT_OUT and self._nsec3_covers(next_closer, nsec3, params, budget): + return True + return False diff --git a/src/recursive_resolver/exceptions.py b/src/recursive_resolver/exceptions.py index a62e7c2..f95cf85 100644 --- a/src/recursive_resolver/exceptions.py +++ b/src/recursive_resolver/exceptions.py @@ -1,4 +1,10 @@ -"""Exception hierarchy for the recursive DNS resolver.""" +"""Exception hierarchy for the recursive DNS resolver. + +Every error raised out of :meth:`RecursiveResolver.resolve` is a +:class:`ResolverError`. Nothing from dnspython is allowed to escape: input +validation errors, malformed-response errors and DNSSEC failures are all +translated into the classes below. +""" from __future__ import annotations @@ -39,7 +45,7 @@ def __init__(self, qname: str, rdtype: str, max_depth: int) -> None: class ResolutionTimeoutError(ResolverError): - """All nameservers timed out during resolution.""" + """All nameservers timed out, or the overall deadline elapsed.""" def __init__(self, qname: str, rdtype: str) -> None: super().__init__(f"All nameservers timed out for {qname}/{rdtype}", qname=qname, rdtype=rdtype) @@ -65,3 +71,80 @@ def __init__(self, qname: str, rdtype: str, rcode: int | None = None) -> None: if rcode is not None: msg += f" (rcode={rcode})" super().__init__(msg, qname=qname, rdtype=rdtype) + + +class InvalidNameError(ResolverError): + """The query name is not a valid DNS name. + + Raised for oversized labels, oversized names, empty interior labels and + names that cannot be IDNA-encoded. Previously these surfaced as spurious + timeouts because they were raised while building the query. + """ + + def __init__(self, qname: str, reason: str) -> None: + self.reason = reason + super().__init__(f"Invalid domain name {qname!r}: {reason}", qname=qname) + + +class UnsupportedRdtypeError(ResolverError): + """The requested record type is unknown or not supported.""" + + def __init__(self, rdtype: str, reason: str = "unknown record type") -> None: + super().__init__(f"Unsupported record type {rdtype!r}: {reason}", rdtype=rdtype) + + +class QueryBudgetExceededError(ResolverError): + """The resolution exceeded its work budget. + + Guards against amplification attacks (NXNSAttack / Non-Responsive + Delegation) where a hostile zone provokes unbounded query fan-out. + """ + + def __init__(self, qname: str, rdtype: str, resource: str, limit: int) -> None: + self.resource = resource + self.limit = limit + super().__init__( + f"Query budget exceeded resolving {qname}/{rdtype}: {resource} limit of {limit} reached", + qname=qname, + rdtype=rdtype, + ) + + +class DNSSECError(ResolverError): + """Base class for DNSSEC validation problems.""" + + +class DNSSECValidationError(DNSSECError): + """DNSSEC validation failed: the data is bogus. + + The zone is signed but the signatures, the delegation chain or the + denial-of-existence proof did not validate. Treat this as a possible + attack: the data must not be used. + """ + + def __init__(self, qname: str, rdtype: str, reason: str) -> None: + self.reason = reason + super().__init__(f"DNSSEC validation failed for {qname}/{rdtype}: {reason}", qname=qname, rdtype=rdtype) + + +class DNSSECInsecureError(DNSSECError): + """The answer is unsigned and ``require_dnssec=True`` was requested.""" + + def __init__(self, qname: str, rdtype: str, reason: str = "zone is not signed") -> None: + self.reason = reason + super().__init__( + f"DNSSEC-authenticated data required for {qname}/{rdtype} but {reason}", + qname=qname, + rdtype=rdtype, + ) + + +class DNSSECUnavailableError(ResolverError): + """DNSSEC validation was requested but the `cryptography` package is missing.""" + + def __init__(self) -> None: + super().__init__( + "DNSSEC validation requires the 'cryptography' package. " + "Install it with: pip install 'recursive-resolver[dnssec]' " + "(or construct the resolver with dnssec=False)." + ) diff --git a/tests/__init__.py b/src/recursive_resolver/py.typed similarity index 100% rename from tests/__init__.py rename to src/recursive_resolver/py.typed diff --git a/src/recursive_resolver/resolver.py b/src/recursive_resolver/resolver.py index 6df0e91..658ccf6 100644 --- a/src/recursive_resolver/resolver.py +++ b/src/recursive_resolver/resolver.py @@ -1,40 +1,83 @@ """Core iterative DNS resolution engine. -Implements true recursive (iterative) resolution starting from root servers. -Uses dnspython only for wire-format parsing and UDP/TCP transport. +Implements true recursive (iterative) resolution starting from root servers, +with DNSSEC validation, a shared per-resolution work budget, strict +downward-progress and bailiwick rules, and address filtering on nameserver IPs. + +dnspython is used for wire-format parsing, UDP/TCP transport and DNSSEC +primitives; the iteration algorithm itself is implemented here. """ from __future__ import annotations +import errno import ipaddress import logging -import random +import secrets +import threading import time -from dataclasses import dataclass +from collections import OrderedDict +from dataclasses import dataclass, field, replace from typing import Any -import dns.edns +import dns.exception import dns.flags import dns.message import dns.name import dns.query import dns.rcode +import dns.rdataclass +import dns.rdataset import dns.rdatatype import dns.reversename +import dns.rrset -from .cache import DNSCache +from .addresses import AddressFilter +from .budget import Limits, QueryBudget +from .cache import Delegation, DNSCache +from .dnssec import DNSSECValidator, ValidationState, ZoneKeys, cryptography_available, find_rrsig from .exceptions import ( CNAMELoopError, + DNSSECInsecureError, + DNSSECUnavailableError, + DNSSECValidationError, + InvalidNameError, MaxDepthError, NoAnswerError, NXDOMAINError, + QueryBudgetExceededError, ResolutionTimeoutError, + ResolverError, ServfailError, + UnsupportedRdtypeError, ) from .roots import get_root_addresses +from .singleflight import SingleFlight logger = logging.getLogger(__name__) +# Server ordering and referral sampling are anti-attacker controls: their whole +# purpose is to deny a hostile zone any say in which server we talk to. The +# Mersenne Twister behind `random` is seeded predictably and its state is +# recoverable from enough observed output, so a CSPRNG is used instead. At this +# call rate the cost is not measurable. +_RANDOM = secrets.SystemRandom() + +# Errnos meaning "this address family or host is simply not reachable from +# here": retrying is pointless, so the server is abandoned immediately. +_FATAL_ERRNOS = frozenset({errno.EHOSTUNREACH, errno.ENETUNREACH, errno.EAFNOSUPPORT, errno.EADDRNOTAVAIL}) + +# Post-DNS-Flag-Day-2020 consensus UDP payload size. Unbound, PowerDNS and +# Knot all default to 1232; 4096 invites IP fragmentation, which is both a +# cache-poisoning vector and a common cause of silent blackholing. +DEFAULT_EDNS_PAYLOAD = 1232 + +# Ceiling on validated zone keys held across resolutions. A backstop against a +# resolver being walked through millions of signed zones, not a tuning knob: +# an entry is a few hundred bytes, and 2048 zones is far past any real working +# set. +MAX_KEY_CACHE_SIZE = 2048 + @dataclass class TraceStep: @@ -46,6 +89,67 @@ class TraceStep: response_type: str # "answer", "referral", "cname", "nxdomain", "nodata", "error" detail: str = "" rcode: int = 0 + zone: str = "" + dnssec: str = "" + + +@dataclass +class Answer: + """A resolved DNS answer, with the raw records preserved. + + ``records`` gives presentation-format strings for convenience, but anything + that must round-trip exactly should use :attr:`rrset` or + :meth:`text_values`. + """ + + qname: dns.name.Name + canonical_name: dns.name.Name + rdtype: dns.rdatatype.RdataType + rrset: dns.rrset.RRset + ttl: int + dnssec: ValidationState = ValidationState.INSECURE + cname_chain: list[dns.name.Name] = field(default_factory=list) + + @property + def records(self) -> list[str]: + """Presentation-format strings, one per record.""" + return [str(rr) for rr in self.rrset] + + @property + def secure(self) -> bool: + """True if the answer was DNSSEC-validated.""" + return self.dnssec is ValidationState.SECURE + + def text_values(self) -> list[str]: + """Character-string values, one per record, correctly concatenated. + + A TXT record may be split into several ```` chunks of + at most 255 octets. RFC 6376 §3.6.2.2 (DKIM) and RFC 7208 §3.3 (SPF) + require them to be joined with *no* separator. Presentation format + instead renders them as ``"chunk1" "chunk2"``, so any consumer that + strips quotes naively corrupts the value: for an RSA-2048 DKIM key, + always split in two, that means a key that never verifies. This method + does the right thing. + + Raises: + TypeError: if the record type has no character-string content. + """ + values: list[str] = [] + for rr in self.rrset: + strings = getattr(rr, "strings", None) + if strings is None: + raise TypeError(f"{dns.rdatatype.to_text(self.rrset.rdtype)} records have no character-string content") + values.append(b"".join(strings).decode("utf-8", "surrogateescape")) + return values + + +@dataclass +class _Context: + """State shared across one resolve() call and all its sub-resolutions.""" + + budget: QueryBudget + trace: list[TraceStep] | None = None + dnssec: bool = True class RecursiveResolver: @@ -55,29 +159,78 @@ class RecursiveResolver: timeout: Per-query UDP/TCP timeout in seconds. max_depth: Maximum delegation depth before raising MaxDepthError. max_cname_chain: Maximum CNAME follows before raising CNAMELoopError. - cache_enabled: Whether to enable response caching. - use_tcp_fallback: Whether to fall back to TCP when response is truncated. - max_retries: Number of retries per nameserver on timeout. + cache_enabled: Master switch for all caching. + cache_answers: Cache final answer RRsets. Set False when freshness + matters (key rotation, GSLB failover) while still caching + delegations. + max_delegation_cache_depth: How deep to cache zone cuts. Either a label + depth (root=0, ``com.``=1, ``example.com.``=2) or a name: + ``"tld"`` caches only the root's delegations, so lookups start at + the TLD servers and never touch a root server while everything + below is re-resolved; ``"all"`` (the default) keeps every cut; + ``"none"`` disables delegation caching. Any other label depth can be + given as an integer. + min_ttl: Floor applied to cached TTLs. 0 honours the wire TTL exactly. + use_tcp_fallback: Fall back to TCP when a response is truncated. + max_retries: Retries per nameserver. Each retry also downgrades EDNS. ipv4_only: Only use IPv4 addresses for queries. - max_resolution_time: Hard cap on the total wall-clock time for a single - resolve() call, in seconds. Covers all sub-queries, retries, - glueless sub-resolutions, and CNAME chasing. Default: 30s. + max_resolution_time: Hard cap on total wall-clock time per resolve(). + The default covers a cold walk from the root that has to resolve + glueless NS hostnames and survive a dead nameserver; with a warm + delegation cache an ordinary lookup finishes in a fraction of it. + limits: Hardening limits against hostile zones (query fan-out, + NXNSAttack, KeyTrap). See :class:`~recursive_resolver.Limits`; the + defaults are the values production resolvers ship. + edns_payload: Advertised EDNS0 UDP payload size. + dnssec: Validate DNSSEC. Bogus data raises DNSSECValidationError; + legitimately unsigned zones resolve normally. + require_dnssec: Additionally reject unsigned (insecure) answers. + require_authoritative: Only accept answers and negative responses that + have the AA bit set. + allow_private_addresses: Permit private/loopback nameserver addresses. + Leave False unless you run split-horizon DNS you fully trust. + extra_blocked_networks: Further CIDRs to refuse, added to the built-in + rules rather than replacing them. + idna_codec: IDNA codec for unicode names. Defaults to IDNA 2008 + (practical), matching browsers and mail systems. + trust_anchors: DS records for the root, in presentation format. + ``None`` uses the IANA anchors. Override only to validate against a + private root, or in tests. An empty tuple is rejected rather than + treated as ``None``. + + Most arguments are readable as an attribute of the same name. The cache and + address settings are not: they are applied to :attr:`cache` and + :attr:`address_filter`, which are live objects you are meant to interact + with. Assigning to any of these after construction is unsupported: it + cannot affect a call already in flight, and nothing revalidates it. Build a + new resolver instead. + + Instances are thread-safe. Share one across a thread pool to get the shared + cache and the deduplication of concurrent identical queries. """ - SUPPORTED_TYPES = frozenset( - {"A", "AAAA", "CNAME", "MX", "TXT", "SOA", "PTR", "NS", "SRV", "CAA", "DNSKEY", "DS", "NAPTR"} - ) - def __init__( self, - timeout: float = 5.0, + timeout: float = 2.0, max_depth: int = 20, max_cname_chain: int = 10, cache_enabled: bool = True, + cache_answers: bool = True, + max_delegation_cache_depth: int | str | None = None, + min_ttl: int = 0, use_tcp_fallback: bool = True, max_retries: int = 2, ipv4_only: bool = True, - max_resolution_time: float = 30.0, + max_resolution_time: float = 15.0, + limits: Limits | None = None, + edns_payload: int = DEFAULT_EDNS_PAYLOAD, + dnssec: bool = True, + require_dnssec: bool = False, + require_authoritative: bool = True, + allow_private_addresses: bool = False, + extra_blocked_networks: list[str] | None = None, + idna_codec: Any = None, + trust_anchors: tuple[str, ...] | None = None, ) -> None: self.timeout = timeout self.max_depth = max_depth @@ -86,421 +239,1194 @@ def __init__( self.max_retries = max_retries self.ipv4_only = ipv4_only self.max_resolution_time = max_resolution_time - self.cache = DNSCache() if cache_enabled else None - self._root_addresses = get_root_addresses(ipv4_only=ipv4_only) + self.limits = limits if limits is not None else Limits() + self.edns_payload = edns_payload + self.require_dnssec = require_dnssec + self.require_authoritative = require_authoritative + self.cache_answers = cache_answers + self._key_cache_size = MAX_KEY_CACHE_SIZE + + if dnssec and not cryptography_available(): + raise DNSSECUnavailableError() + self.dnssec = dnssec + self._validator: DNSSECValidator | None = None + if dnssec: + # `is not None`, not truthiness: an explicit empty tuple is a + # caller error, and DNSSECValidator rejects it. Falling back to the + # IANA anchors there would be the opposite of what was asked for. + self._validator = ( + DNSSECValidator(trust_anchors=trust_anchors) if trust_anchors is not None else DNSSECValidator() + ) + + self.address_filter = AddressFilter(extra_blocked_networks, allow_private=allow_private_addresses) + self.cache = ( + DNSCache(min_ttl=min_ttl, max_delegation_depth=max_delegation_cache_depth) if cache_enabled else None + ) + self.idna_codec = idna_codec if idna_codec is not None else self._default_idna_codec() + + self._root_addresses = self.address_filter.filter(get_root_addresses(ipv4_only=ipv4_only)) + self._key_cache: OrderedDict[dns.name.Name, ZoneKeys] = OrderedDict() + self._key_lock = threading.Lock() + self._singleflight: SingleFlight[Answer] = SingleFlight() + + @staticmethod + def _default_idna_codec() -> Any: + """IDNA 2008 if available, else IDNA 2003 with a warning. + + IDNA 2003 maps German ``ß`` to ``ss``, so ``straße.de`` would be + resolved as the entirely different domain ``strasse.de``. Browsers and + mail systems use IDNA 2008/UTS-46, so we must too. + """ + codec = getattr(dns.name, "IDNA_2008_Practical", None) + if codec is not None and getattr(dns.name, "have_idna_2008", False): + return codec + logger.warning( + "IDNA 2008 unavailable (install the 'idna' package); falling back to IDNA 2003, " + "which resolves some internationalised names to different domains." + ) + return dns.name.IDNA_2003 + + # ── public API ────────────────────────────────────────────────────── def resolve(self, qname: str, rdtype: str = "A") -> list[str]: """Resolve a DNS query iteratively from root servers. Args: - qname: The domain name to query (or IP address for PTR lookups). + qname: The domain name to query (or an IP address for PTR lookups). rdtype: The record type (e.g. "A", "AAAA", "MX", "TXT"). Returns: - A list of string representations of the answer records. + Presentation-format strings for the answer records. For TXT-like + records that must round-trip exactly, prefer + :meth:`resolve_answer` and ``Answer.text_values()``. Raises: - NXDOMAINError: The domain does not exist. - NoAnswerError: The domain exists but has no records of the requested type. - MaxDepthError: Resolution exceeded maximum delegation depth. - ResolutionTimeoutError: All nameservers timed out. - CNAMELoopError: A CNAME loop was detected. - ServfailError: All nameservers returned errors. + ResolverError: any resolution failure; see the exceptions module. """ - qname = self._normalize_qname(qname, rdtype) - rdtype = rdtype.upper() - deadline = time.monotonic() + self.max_resolution_time + return self.resolve_answer(qname, rdtype).records - result = self._resolve_iterative(qname, rdtype, depth=0, cname_chain=[], deadline=deadline) - return result + def resolve_rrset(self, qname: str, rdtype: str = "A") -> dns.rrset.RRset: + """Resolve and return the raw dnspython RRset, preserving rdata structure.""" + return self.resolve_answer(qname, rdtype).rrset - def resolve_with_trace(self, qname: str, rdtype: str = "A") -> list[TraceStep]: - """Resolve a DNS query and return a trace of all resolution steps. + def resolve_answer(self, qname: str, rdtype: str = "A") -> Answer: + """Resolve and return the full :class:`Answer`, including DNSSEC status. - Args: - qname: The domain name to query. - rdtype: The record type. + The :class:`Answer` is the caller's own: concurrent callers collapsed + into one upstream resolution each get a separate copy, so nothing they + do to it can be seen by another thread or by the cache. + """ + name = self._normalize_qname(qname, rdtype) + rdtype_int = self._parse_rdtype(rdtype) + key = (name, rdtype_int) - Returns: - A list of TraceStep objects showing each step of the resolution. + def work() -> Answer: + ctx = self._new_context() + return self._resolve_entry(name, rdtype_int, ctx) + + answer = self._singleflight.do(key, work, wait_timeout=self.max_resolution_time) + return replace(answer, rrset=answer.rrset.copy(), cname_chain=list(answer.cname_chain)) + + def trace_answer(self, qname: str, rdtype: str = "A") -> tuple[Answer | None, list[TraceStep]]: + """Resolve, returning both the answer and the trace. + + The answer is ``None`` if resolution failed; the trace is still + returned so the failure can be diagnosed. """ - qname = self._normalize_qname(qname, rdtype) - rdtype = rdtype.upper() - deadline = time.monotonic() + self.max_resolution_time + name = self._normalize_qname(qname, rdtype) + rdtype_int = self._parse_rdtype(rdtype) trace: list[TraceStep] = [] - self._resolve_iterative(qname, rdtype, depth=0, cname_chain=[], trace=trace, deadline=deadline) - return trace - - def _normalize_qname(self, qname: str, rdtype: str) -> str: - """Normalize the query name. Auto-converts IP addresses for PTR lookups.""" - if rdtype.upper() == "PTR": + ctx = self._new_context(trace=trace) + try: + answer = self._resolve_entry(name, rdtype_int, ctx) + except ResolverError: + return None, trace + return answer, trace + + # ── input handling ────────────────────────────────────────────────── + + def _new_context(self, trace: list[TraceStep] | None = None) -> _Context: + budget = self.limits.budget(deadline=time.monotonic() + self.max_resolution_time) + return _Context(budget=budget, trace=trace, dnssec=self.dnssec) + + def _parse_rdtype(self, rdtype: str) -> dns.rdatatype.RdataType: + """Validate and convert a record type, raising UnsupportedRdtypeError.""" + text = rdtype.strip().upper() + if not text: + raise UnsupportedRdtypeError(rdtype, "empty record type") + try: + value = dns.rdatatype.from_text(text) + except dns.rdatatype.UnknownRdatatype as exc: + raise UnsupportedRdtypeError(rdtype, "unknown record type") from exc + except ValueError as exc: + # "TYPEnnnnn" parses as a generic type but is range-checked + # separately, so a number above 65535 raises a plain ValueError + # rather than UnknownRdatatype. Nothing from dnspython may escape. + raise UnsupportedRdtypeError(rdtype, "record type out of range") from exc + if dns.rdatatype.is_metatype(value) and value != dns.rdatatype.ANY: + raise UnsupportedRdtypeError(rdtype, "meta record types cannot be queried") + return value + + def _normalize_qname(self, qname: str, rdtype: str) -> dns.name.Name: + """Validate the query name, IDNA-encoding it and handling PTR reversal.""" + if not isinstance(qname, str) or not qname.strip(): + raise InvalidNameError(str(qname), "empty name") + + if rdtype.strip().upper() == "PTR": try: - addr = ipaddress.ip_address(qname) - return str(dns.reversename.from_address(str(addr))) + addr = ipaddress.ip_address(qname.strip()) except ValueError: pass - # Ensure FQDN - if not qname.endswith("."): - qname = qname + "." - return qname - - def _check_deadline(self, deadline: float, qname: str, rdtype: str) -> None: - """Raise ResolutionTimeoutError if the overall resolution deadline has passed.""" - if time.monotonic() >= deadline: - raise ResolutionTimeoutError(qname, rdtype) - - def _effective_timeout(self, deadline: float) -> float: - """Return per-query timeout clamped to the remaining resolution time.""" - remaining = deadline - time.monotonic() - if remaining <= 0: - return 0.0 - return min(self.timeout, remaining) + else: + return dns.reversename.from_address(str(addr)) + + try: + name = dns.name.from_text(qname, idna_codec=self.idna_codec) + except dns.name.LabelTooLong as exc: + raise InvalidNameError(qname, "a label exceeds 63 octets") from exc + except dns.name.NameTooLong as exc: + raise InvalidNameError(qname, "name exceeds 255 octets") from exc + except dns.name.EmptyLabel as exc: + raise InvalidNameError(qname, "empty label") from exc + except dns.exception.DNSException as exc: + raise InvalidNameError(qname, str(exc) or type(exc).__name__) from exc + except UnicodeError as exc: + raise InvalidNameError(qname, f"IDNA encoding failed: {exc}") from exc + + if not name.is_absolute(): + name = name.concatenate(dns.name.root) + return name + + # ── resolution ────────────────────────────────────────────────────── + + def _resolve_entry(self, qname: dns.name.Name, rdtype: dns.rdatatype.RdataType, ctx: _Context) -> Answer: + answer = self._resolve_iterative(qname, rdtype, ctx, depth=0, cname_chain=[]) + if self.require_dnssec and answer.dnssec is not ValidationState.SECURE: + raise DNSSECInsecureError(str(qname), dns.rdatatype.to_text(rdtype)) + return answer + + def _check_cache(self, qname: dns.name.Name, rdtype: dns.rdatatype.RdataType) -> Answer | None: + """Consult the cache for a positive or negative entry.""" + if self.cache is None: + return None + + nx = self.cache.get_nxdomain_ancestor(qname) + if nx is not None: + # RFC 8020: nothing exists below a non-existent name. + raise NXDOMAINError(str(qname), dns.rdatatype.to_text(rdtype)) + + if self.cache.get_nodata(qname, rdtype) is not None: + raise NoAnswerError(str(qname), dns.rdatatype.to_text(rdtype)) + + if not self.cache_answers: + return None + entry = self.cache.get_answer(qname, rdtype) + if entry is None or entry.rrset is None: + return None + return Answer( + qname=qname, + canonical_name=qname, + rdtype=rdtype, + rrset=entry.rrset, + ttl=entry.rrset.ttl, + dnssec=ValidationState.SECURE if entry.secure else ValidationState.INSECURE, + ) + + def _starting_point( + self, qname: dns.name.Name, rdtype: dns.rdatatype.RdataType + ) -> tuple[dns.name.Name, list[str], ValidationState, Any]: + """Pick where to begin: a cached delegation if we have one, else the root. + + A DS record is published by the *parent* zone, so a cached delegation + for the qname itself would send the query to the child, which does not + hold its own DS and correctly answers NODATA. Start one label up. + """ + lookup = qname + if rdtype == dns.rdatatype.DS and qname != dns.name.root: + lookup = qname.parent() + if self.cache is not None: + delegation = self.cache.closest_delegation(lookup) + if delegation is not None: + addresses = self.address_filter.filter(delegation.addresses) + if addresses: + state = ValidationState.SECURE if delegation.secure else ValidationState.INSECURE + # Resuming a secure chain needs either the zone's DS or + # cached keys; without one of them we cannot prove the link + # back to the root, so restart from the root rather than + # silently downgrade to insecure. + unprovable = delegation.ds is None and self._cached_keys(delegation.zone) is None + if self.dnssec and state is ValidationState.SECURE and unprovable: + return dns.name.root, list(self._root_addresses), ValidationState.SECURE, None + return delegation.zone, addresses, state, delegation.ds + return dns.name.root, list(self._root_addresses), ValidationState.SECURE, None def _resolve_iterative( self, - qname: str, - rdtype: str, + qname: dns.name.Name, + rdtype: dns.rdatatype.RdataType, + ctx: _Context, depth: int, - cname_chain: list[str], - trace: list[TraceStep] | None = None, - deadline: float = float("inf"), - ) -> list[str]: - """Core iterative resolution loop. - - Starts from root servers and follows delegations until an answer is found. - """ + cname_chain: list[dns.name.Name], + ) -> Answer: + """Core iterative resolution loop.""" + rdtype_text = dns.rdatatype.to_text(rdtype) if depth > self.max_depth: - raise MaxDepthError(qname, rdtype, self.max_depth) - self._check_deadline(deadline, qname, rdtype) - - # Check cache - if self.cache is not None: - cached = self.cache.get(qname, rdtype) - if cached is not None: - if cached.is_negative: - if cached.rrset is None: - raise NXDOMAINError(qname) - raise NoAnswerError(qname, rdtype) - if cached.rrset is not None: - return [str(rr) for rr in cached.rrset] - - current_nameservers = list(self._root_addresses) - # Track NS names from the most recent referral for stale-glue fallback - pending_ns_names: list[str] = [] - # Limit NXDOMAIN retries to avoid O(n) queries on large NS sets + raise MaxDepthError(str(qname), rdtype_text, self.max_depth) + if ctx.budget.expired(): + raise ResolutionTimeoutError(str(qname), rdtype_text) + + cached = self._check_cache(qname, rdtype) + if cached is not None: + return cached + + current_zone, current_nameservers, chain_state, current_ds = self._starting_point(qname, rdtype) + # `current_nameservers` is pruned as servers fail, but DNSSEC key + # fetches must keep talking to the zone's full server set: otherwise a + # single-nameserver signed zone returning NXDOMAIN leaves us with an + # empty list and we report a DNSSEC failure of our own making. + zone_nameservers = list(current_nameservers) + pending_ns_names: list[dns.name.Name] = [] + tried_addresses: set[str] = set() nxdomain_retries_left = 2 for _ in range(self.max_depth): - self._check_deadline(deadline, qname, rdtype) - response, server = self._send_query(qname, rdtype, current_nameservers, deadline) + if ctx.budget.expired(): + raise ResolutionTimeoutError(str(qname), rdtype_text) + + tried_addresses.update(current_nameservers) + response, server = self._send_query(qname, rdtype, current_nameservers, ctx) if response is None: - # All nameservers timed out — try resolving NS names as fallback - # (handles stale/dead glue IPs from referrals) - if pending_ns_names: - resolved_ips = self._resolve_glueless(pending_ns_names, depth + 1, max_ns=2, deadline=deadline) - if resolved_ips: - current_nameservers = resolved_ips - pending_ns_names = [] - continue - raise ResolutionTimeoutError(qname, rdtype) - - classification = self._classify_response(response, qname, rdtype) - - if trace is not None: - trace.append( - TraceStep( - server=server, - qname=qname, - rdtype=rdtype, - response_type=classification["type"], - detail=classification.get("detail", ""), - rcode=response.rcode(), - ) + fallback = self._fallback_nameservers(pending_ns_names, tried_addresses, ctx, depth) + if fallback: + current_nameservers = fallback + continue + raise ResolutionTimeoutError(str(qname), rdtype_text) + + classification = self._classify_response(response, qname, rdtype, current_zone) + kind = classification["type"] + self._record_trace(ctx, server, qname, rdtype_text, classification, response, current_zone, chain_state) + + if kind == "answer": + return self._handle_answer( + response, + classification, + qname, + rdtype, + ctx, + current_zone, + zone_nameservers, + chain_state, + current_ds, + cname_chain, ) - if classification["type"] == "answer": - answers = [str(rr) for rr in classification["rrset"]] - if self.cache is not None: - self.cache.put(qname, rdtype, classification["rrset"], ttl=classification["ttl"]) - return answers - - elif classification["type"] == "cname": - cname_target = classification["target"] - if cname_target in cname_chain: - raise CNAMELoopError(qname, cname_chain + [cname_target]) - if len(cname_chain) >= self.max_cname_chain: - raise CNAMELoopError(qname, cname_chain + [cname_target]) - # Cache the CNAME record itself - if self.cache is not None and "cname_rrset" in classification: - self.cache.put(qname, "CNAME", classification["cname_rrset"], ttl=classification.get("ttl", 300)) - # Restart resolution from root for the CNAME target - return self._resolve_iterative( - cname_target, rdtype, depth + 1, cname_chain + [qname], trace=trace, deadline=deadline + if kind == "cname": + return self._handle_cname( + response, + classification, + qname, + rdtype, + ctx, + depth, + cname_chain, + current_zone, + zone_nameservers, + chain_state, + current_ds, ) - elif classification["type"] == "referral": - ns_names = classification["ns_names"] - glue_ips = classification["glue_ips"] - # Always remember NS names for stale-glue fallback - pending_ns_names = ns_names - # Reset NXDOMAIN retry counter for each new delegation level + if kind == "referral": + ctx.budget.note_referral(str(qname), rdtype_text) + child_zone: dns.name.Name = classification["zone"] + chain_state, current_ds = self._advance_dnssec( + response, + child_zone, + current_zone, + zone_nameservers, + chain_state, + current_ds, + ctx, + ) + ns_names: list[dns.name.Name] = classification["ns_names"] + # An NS name inside the zone it serves can only be reached via + # glue: resolving it independently needs the very servers we are + # trying to find. Prefer the out-of-zone names for both the + # glueless path and any later fallback. + resolvable = [n for n in ns_names if not n.is_subdomain(child_zone)] or ns_names + pending_ns_names = resolvable + glue = self._select_glue(response, ns_names, current_zone, child_zone) + if not glue: + glue = self._resolve_ns_names(resolvable, ctx, depth + 1, limit=self.limits.max_ns_per_referral) + if not glue: + raise ServfailError(str(qname), rdtype_text) + self._cache_delegation(child_zone, ns_names, glue, response, chain_state, current_ds) + current_zone = child_zone + current_nameservers = glue + zone_nameservers = list(glue) nxdomain_retries_left = 2 + continue - if glue_ips: - current_nameservers = glue_ips - else: - # Glueless referral — resolve NS hostnames - resolved_ips = self._resolve_glueless(ns_names, depth + 1, deadline=deadline) - if resolved_ips: - current_nameservers = resolved_ips - else: - raise ServfailError(qname, rdtype) - - elif classification["type"] == "nxdomain": - # Try a few other nameservers before accepting NXDOMAIN - # (handles inconsistent TLD nameservers, e.g. one of several - # .ir servers returning NXDOMAIN while others have the zone) + if kind == "nxdomain": current_nameservers = [ns for ns in current_nameservers if ns != server] if current_nameservers and nxdomain_retries_left > 0: nxdomain_retries_left -= 1 - logger.debug( - "NXDOMAIN from %s for %s/%s, trying %d remaining servers (%d retries left)", - server, - qname, - rdtype, - len(current_nameservers), - nxdomain_retries_left, - ) + logger.debug("NXDOMAIN from %s for %s/%s, trying siblings", server, qname, rdtype_text) continue + self._verify_denial( + response, + qname, + rdtype, + ctx, + current_zone, + zone_nameservers, + chain_state, + current_ds, + negative="nxdomain", + ) if self.cache is not None: - self.cache.put(qname, rdtype, None, is_negative=True) - raise NXDOMAINError(qname, rdtype) + self.cache.put_nxdomain(qname, self._negative_ttl(response, qname)) + raise NXDOMAINError(str(qname), rdtype_text) - elif classification["type"] == "nodata": + if kind == "nodata": + self._verify_denial( + response, + qname, + rdtype, + ctx, + current_zone, + zone_nameservers, + chain_state, + current_ds, + negative="nodata", + ) if self.cache is not None: - self.cache.put(qname, rdtype, "NODATA", is_negative=True) - raise NoAnswerError(qname, rdtype) - - elif classification["type"] == "error": - # Remove the failing server and try others - current_nameservers = [ns for ns in current_nameservers if ns != server] - if not current_nameservers: - raise ServfailError(qname, rdtype, rcode=response.rcode()) - continue - - raise MaxDepthError(qname, rdtype, self.max_depth) - - def _send_query( - self, qname: str, rdtype: str, nameservers: list[str], deadline: float = float("inf") - ) -> tuple[dns.message.Message | None, str]: - """Send a DNS query to a list of nameservers. - - Shuffles the nameserver list and tries each with retries. - Returns (response, server_ip) or (None, "") if all fail. - """ - servers = list(nameservers) - random.shuffle(servers) - - rdtype_int = dns.rdatatype.from_text(rdtype) - - for server in servers: - for attempt in range(self.max_retries + 1): - qry_timeout = self._effective_timeout(deadline) - if qry_timeout <= 0: - return None, "" - - try: - query = dns.message.make_query(qname, rdtype_int, use_edns=0, payload=4096) - # RD=0: we iterate ourselves - query.flags &= ~dns.flags.RD - - if self.use_tcp_fallback: - response, used_tcp = dns.query.udp_with_fallback(query, server, timeout=qry_timeout) - else: - response = dns.query.udp(query, server, timeout=qry_timeout) - - # FORMERR often means the server doesn't like EDNS0 — retry without it - if response.rcode() == dns.rcode.FORMERR: - plain_response = self._send_query_plain(qname, rdtype_int, server, deadline) - if plain_response is not None: - return plain_response, server - break - - return response, server - - except dns.query.BadResponse: - # Try without EDNS (some servers don't support it) - plain_response = self._send_query_plain(qname, rdtype_int, server, deadline) - if plain_response is not None: - return plain_response, server - break - - except (dns.exception.Timeout, OSError): - logger.debug("Timeout querying %s for %s/%s (attempt %d)", server, qname, rdtype, attempt + 1) + self.cache.put_nodata(qname, rdtype, self._negative_ttl(response, qname)) + raise NoAnswerError(str(qname), rdtype_text) + + # "error" / "lame": drop this server and try another. + current_nameservers = [ns for ns in current_nameservers if ns != server] + if not current_nameservers: + # Every address we have refuses or errors. A stale delegation + # naming a provider that no longer serves the zone looks exactly + # like this, so resolve the referral's other NS names before + # giving up: one of them is usually the live one. + fallback = self._fallback_nameservers(pending_ns_names, tried_addresses, ctx, depth) + if fallback: + current_nameservers = fallback continue + raise ServfailError(str(qname), rdtype_text, rcode=response.rcode()) - except Exception as e: - logger.debug("Error querying %s for %s/%s: %s", server, qname, rdtype, e) - break - - return None, "" + raise MaxDepthError(str(qname), rdtype_text, self.max_depth) - def _send_query_plain( - self, qname: str, rdtype_int: dns.rdatatype.RdataType, server: str, deadline: float = float("inf") - ) -> dns.message.Message | None: - """Send a query without EDNS0 (fallback for servers that reject EDNS).""" - qry_timeout = self._effective_timeout(deadline) - if qry_timeout <= 0: - return None - try: - query = dns.message.make_query(qname, rdtype_int) - query.flags &= ~dns.flags.RD - return dns.query.udp(query, server, timeout=qry_timeout) - except Exception: - return None + def _record_trace( + self, + ctx: _Context, + server: str, + qname: dns.name.Name, + rdtype_text: str, + classification: dict[str, Any], + response: dns.message.Message, + zone: dns.name.Name, + state: ValidationState, + ) -> None: + if ctx.trace is None: + return + ctx.trace.append( + TraceStep( + server=server, + qname=str(qname), + rdtype=rdtype_text, + response_type=classification["type"], + detail=classification.get("detail", ""), + rcode=response.rcode(), + zone=str(zone), + dnssec=state.value, + ) + ) + + def _handle_answer( + self, + response: dns.message.Message, + classification: dict[str, Any], + qname: dns.name.Name, + rdtype: dns.rdatatype.RdataType, + ctx: _Context, + zone: dns.name.Name, + nameservers: list[str], + state: ValidationState, + ds: Any, + cname_chain: list[dns.name.Name], + ) -> Answer: + rrset: dns.rrset.RRset = classification["rrset"] + validated = self._validate_answer(response, rrset, qname, rdtype, ctx, zone, nameservers, state, ds) + if self.cache is not None and self.cache_answers: + self.cache.put_answer(qname, rdtype, rrset, rrset.ttl, secure=validated is ValidationState.SECURE) + return Answer( + qname=qname, + canonical_name=qname, + rdtype=rdtype, + rrset=rrset, + ttl=rrset.ttl, + dnssec=validated, + cname_chain=list(cname_chain), + ) + + def _handle_cname( + self, + response: dns.message.Message, + classification: dict[str, Any], + qname: dns.name.Name, + rdtype: dns.rdatatype.RdataType, + ctx: _Context, + depth: int, + cname_chain: list[dns.name.Name], + zone: dns.name.Name, + nameservers: list[str], + state: ValidationState, + ds: Any, + ) -> Answer: + rdtype_text = dns.rdatatype.to_text(rdtype) + target: dns.name.Name = classification["target"] + cname_rrset: dns.rrset.RRset = classification["cname_rrset"] + + if target in cname_chain or target == qname: + raise CNAMELoopError(str(qname), [str(n) for n in cname_chain + [target]]) + if len(cname_chain) >= self.max_cname_chain: + raise CNAMELoopError(str(qname), [str(n) for n in cname_chain + [target]]) + + cname_state = self._validate_answer( + response, cname_rrset, qname, dns.rdatatype.CNAME, ctx, zone, nameservers, state, ds + ) + if self.cache is not None and self.cache_answers: + self.cache.put_answer( + qname, + dns.rdatatype.CNAME, + cname_rrset, + cname_rrset.ttl, + secure=cname_state is ValidationState.SECURE, + ) + + # The target's records may already be present in this very response + # (the normal case for CDN and ESP zones). Using them avoids a second + # full walk from the root. + # + # Only when the target is inside the answering server's zone. A server + # is authoritative for its own bailiwick and nothing else, so a CNAME + # pointing out of the zone plus an inline record for that foreign + # target is an attempt to write another zone's data: the classic + # cross-zone cache-poisoning trick. Answers for a foreign target must + # be fetched from the servers that are actually authoritative for it, + # which the fall-through below does. + inline = self._find_answer_rrset(response, target, rdtype) if target.is_subdomain(zone) else None + if inline is not None: + inline_state = self._validate_answer(response, inline, target, rdtype, ctx, zone, nameservers, state, ds) + if self.cache is not None and self.cache_answers: + self.cache.put_answer(target, rdtype, inline, inline.ttl, secure=inline_state is ValidationState.SECURE) + return Answer( + qname=qname, + canonical_name=target, + rdtype=rdtype, + rrset=inline, + ttl=inline.ttl, + dnssec=self._weakest(cname_state, inline_state), + cname_chain=[*cname_chain, qname], + ) + + logger.debug("Following CNAME %s -> %s for %s", qname, target, rdtype_text) + answer = self._resolve_iterative(target, rdtype, ctx, depth + 1, [*cname_chain, qname]) + return Answer( + qname=qname, + canonical_name=answer.canonical_name, + rdtype=rdtype, + rrset=answer.rrset, + ttl=answer.ttl, + dnssec=self._weakest(cname_state, answer.dnssec), + cname_chain=[*cname_chain, qname, *answer.cname_chain], + ) + + @staticmethod + def _weakest(a: ValidationState, b: ValidationState) -> ValidationState: + """The weaker of two validation states (BOGUS < INSECURE < SECURE).""" + order = {ValidationState.BOGUS: 0, ValidationState.INSECURE: 1, ValidationState.SECURE: 2} + return a if order[a] <= order[b] else b + + # ── response classification ───────────────────────────────────────── + + @staticmethod + def _find_answer_rrset(response: dns.message.Message, name: dns.name.Name, rdtype: int) -> dns.rrset.RRset | None: + for rrset in response.answer: + if rrset.name == name and rrset.rdtype == rdtype and rrset.rdclass == dns.rdataclass.IN: + return rrset + return None - def _classify_response(self, response: dns.message.Message, qname: str, rdtype: str) -> dict[str, Any]: - """Classify a DNS response into answer, cname, referral, nxdomain, nodata, or error.""" + def _classify_response( + self, + response: dns.message.Message, + qname: dns.name.Name, + rdtype: dns.rdatatype.RdataType, + current_zone: dns.name.Name, + ) -> dict[str, Any]: + """Classify a response into answer, cname, referral, nxdomain, nodata or error.""" rcode = response.rcode() - rdtype_int = dns.rdatatype.from_text(rdtype) - qname_obj = dns.name.from_text(qname) + authoritative = bool(response.flags & dns.flags.AA) - # Server error (but not NXDOMAIN — handled below after checking for CNAME) if rcode not in (dns.rcode.NOERROR, dns.rcode.NXDOMAIN): - return {"type": "error", "rcode": rcode} - - # Check for answer in answer section - for rrset in response.answer: - if rrset.name == qname_obj and rrset.rdtype == rdtype_int: - return { - "type": "answer", - "rrset": rrset, - "ttl": rrset.ttl, - } - - # Check for CNAME in answer section — must come before NXDOMAIN check - # because a server may return NXDOMAIN + CNAME when the CNAME target - # is outside its zone (the target may resolve fine via other servers) - for rrset in response.answer: - if rrset.name == qname_obj and rrset.rdtype == dns.rdatatype.CNAME: - target = str(rrset[0].target) + return {"type": "error", "rcode": rcode, "detail": dns.rcode.to_text(rcode)} + + # A direct answer of the requested type, in the IN class. + answer_rrset = self._find_answer_rrset(response, qname, rdtype) + if answer_rrset is not None: + if rcode == dns.rcode.NXDOMAIN: + # NXDOMAIN with data in the answer section is a protocol + # violation; do not treat the data as authoritative. + return {"type": "error", "rcode": rcode, "detail": "NXDOMAIN with answer records"} + if self.require_authoritative and not authoritative: + return {"type": "error", "rcode": rcode, "detail": "answer without AA bit"} + return {"type": "answer", "rrset": answer_rrset, "ttl": answer_rrset.ttl} + + # A DS is published by the parent, so a parent that responds with a + # referral may carry the DS in the authority section. Take it from + # there: following the referral would land on the child, which does not + # hold its own DS. + if rdtype == dns.rdatatype.DS and rcode == dns.rcode.NOERROR: + for rrset in response.authority: + if rrset.name == qname and rrset.rdtype == dns.rdatatype.DS and rrset.rdclass == dns.rdataclass.IN: + return {"type": "answer", "rrset": rrset, "ttl": rrset.ttl} + + # A CNAME must be considered before NXDOMAIN: some servers return + # NXDOMAIN alongside a CNAME whose target lives outside their zone. + if rdtype != dns.rdatatype.CNAME: + cname_rrset = self._find_answer_rrset(response, qname, dns.rdatatype.CNAME) + if cname_rrset is not None: return { "type": "cname", - "target": target, - "cname_rrset": rrset, - "ttl": rrset.ttl, + "target": cname_rrset[0].target, + "cname_rrset": cname_rrset, + "ttl": cname_rrset.ttl, + "detail": f"-> {cname_rrset[0].target}", } - # NXDOMAIN (only if no CNAME was found above) if rcode == dns.rcode.NXDOMAIN: + if self.require_authoritative and not authoritative: + return {"type": "error", "rcode": rcode, "detail": "NXDOMAIN without AA bit"} return {"type": "nxdomain"} - # Check for referral (NS in authority, no answer) - ns_names: list[str] = [] + # An SOA owning the qname or an ancestor marks a negative answer + # (RFC 2308), which takes precedence over any NS records also present. for rrset in response.authority: - if rrset.rdtype == dns.rdatatype.NS: - for rr in rrset: - ns_names.append(str(rr.target)) + if rrset.rdtype == dns.rdatatype.SOA and qname.is_subdomain(rrset.name): + if self.require_authoritative and not authoritative: + return {"type": "error", "rcode": rcode, "detail": "NODATA without AA bit"} + return {"type": "nodata"} - if ns_names: - # The zone being delegated is the owner of the NS rrset - delegated_zone: dns.name.Name | None = None - for rrset in response.authority: - if rrset.rdtype == dns.rdatatype.NS: - delegated_zone = rrset.name - break - - # Referral validation: the qname must be at or below the delegated zone. - # This prevents a malicious server from redirecting us to unrelated zones - # (e.g., evil.com's NS returning a referral for bank.com). - if delegated_zone is not None and not qname_obj.is_subdomain(delegated_zone): - logger.debug( - "Ignoring referral to %s (qname %s is not a subdomain)", - delegated_zone, - qname, - ) - return {"type": "error", "rcode": rcode} - - glue_ips = self._extract_glue(response, ns_names, delegated_zone) - detail = f"NS: {', '.join(ns_names[:3])}" - if glue_ips: - detail += f" (glue: {', '.join(glue_ips[:3])})" - return { - "type": "referral", - "ns_names": ns_names, - "glue_ips": glue_ips, - "detail": detail, - } - - # NODATA: NOERROR but no answer and no referral + referral = self._find_referral(response, qname, current_zone, rdtype) + if referral is not None: + return referral + + if self.require_authoritative and not authoritative: + return {"type": "error", "rcode": rcode, "detail": "empty response without AA bit"} return {"type": "nodata"} - def _extract_glue( + def _find_referral( self, response: dns.message.Message, - ns_names: list[str], - delegated_zone: dns.name.Name | None = None, - ) -> list[str]: - """Extract glue A/AAAA records from the additional section for the given NS names. - - Applies bailiwick checking: only accepts glue records for NS hostnames - that are at or below the parent zone (the zone of the responding server). - This prevents cache poisoning from out-of-bailiwick glue injected by - a malicious authority server, while still accepting legitimate sibling - glue (e.g. a.gtld-servers.net. as glue for .com from root servers). + qname: dns.name.Name, + current_zone: dns.name.Name, + rdtype: dns.rdatatype.RdataType = dns.rdatatype.A, + ) -> dict[str, Any] | None: + """Locate a valid downward referral, enforcing strict progress. + + The delegated zone must be a *proper* subdomain of the zone we asked + (``dns.name.is_subdomain`` includes equality, so a naive check lets a + server refer us back to its own zone forever) and the qname must lie + at or below it. Anything else is a lame or hostile response. """ - glue_ips: list[str] = [] - ns_name_objs = {dns.name.from_text(ns) for ns in ns_names} + best: dns.rrset.RRset | None = None + for rrset in response.authority: + if rrset.rdtype != dns.rdatatype.NS or rrset.rdclass != dns.rdataclass.IN: + continue + owner = rrset.name + if owner == current_zone or not owner.is_subdomain(current_zone): + logger.debug("Ignoring non-descending referral to %s from zone %s", owner, current_zone) + continue + if not qname.is_subdomain(owner): + logger.debug("Ignoring referral to %s (qname %s is not below it)", owner, qname) + continue + if rdtype == dns.rdatatype.DS and owner == qname: + # The parent delegates this name but did not hand us the DS; + # descending would ask the child for a record it never holds. + logger.debug("Not following the %s delegation for a DS query", owner) + continue + if best is None or len(owner) > len(best.name): + best = rrset + + if best is None: + return None + + ns_names = [rr.target for rr in best] + if len(ns_names) > self.limits.max_ns_per_referral: + # Random sample rather than a prefix: a deterministic slice is + # attacker-orderable (PowerDNS uses the same approach). + ns_names = _RANDOM.sample(ns_names, self.limits.max_ns_per_referral) + detail = f"NS: {', '.join(str(n) for n in ns_names[:3])}" + return { + "type": "referral", + "zone": best.name, + "ns_names": ns_names, + "ttl": best.ttl, + "detail": detail, + } + + def _select_glue( + self, + response: dns.message.Message, + ns_names: list[dns.name.Name], + current_zone: dns.name.Name, + child_zone: dns.name.Name, + ) -> list[str]: + """Extract usable glue addresses from the additional section. - # The parent zone is what the responding server is authoritative for. - # Glue is in-bailiwick if the NS hostname is under this parent zone. - parent_zone = delegated_zone.parent() if delegated_zone is not None else None + Bailiwick is judged against the zone we *asked*: never against a zone + name taken from the response, which the attacker controls. Glue for a + name outside the responding server's bailiwick is discarded; the + hostname is resolved independently instead. + All addresses are then passed through the nameserver address filter, + which is what stops a hostile zone from pointing us at 127.0.0.1 or + the cloud metadata endpoint. + """ + wanted = set(ns_names) + addresses: list[str] = [] for rrset in response.additional: - if rrset.name not in ns_name_objs: + if rrset.name not in wanted or rrset.rdclass != dns.rdataclass.IN: continue - if rrset.rdtype != dns.rdatatype.A and (rrset.rdtype != dns.rdatatype.AAAA or self.ipv4_only): + if rrset.rdtype != dns.rdatatype.A and not (rrset.rdtype == dns.rdatatype.AAAA and not self.ipv4_only): continue - # Bailiwick check: only accept glue if the NS hostname - # is within the parent zone (in-bailiwick) - if parent_zone is not None and not rrset.name.is_subdomain(parent_zone): - logger.debug( - "Rejected out-of-bailiwick glue for %s (parent zone: %s)", - rrset.name, - parent_zone, - ) + if not rrset.name.is_subdomain(current_zone) and not rrset.name.is_subdomain(child_zone): + logger.debug("Rejecting out-of-bailiwick glue for %s (zone %s)", rrset.name, current_zone) continue - for rr in rrset: - glue_ips.append(str(rr.address)) + addresses.extend(str(rr.address) for rr in rrset) - return glue_ips + allowed = self.address_filter.filter(addresses) + if len(allowed) != len(addresses): + rejected = set(addresses) - set(allowed) + logger.warning("Rejected non-public glue addresses for zone %s: %s", child_zone, sorted(rejected)) + return allowed - def _resolve_glueless( - self, ns_names: list[str], depth: int, max_ns: int = 0, deadline: float = float("inf") + def _cache_delegation( + self, + zone: dns.name.Name, + ns_names: list[dns.name.Name], + addresses: list[str], + response: dns.message.Message, + state: ValidationState, + ds: Any = None, + ) -> None: + if self.cache is None or not addresses: + return + ttl = 0 + for rrset in response.authority: + if rrset.rdtype == dns.rdatatype.NS and rrset.name == zone: + ttl = rrset.ttl + break + if ttl <= 0: + return + self.cache.put_delegation( + Delegation( + zone=zone, + addresses=list(addresses), + ns_names=[str(n) for n in ns_names], + secure=state is ValidationState.SECURE, + ds=ds, + ), + ttl=ttl, + ) + + # ── glueless NS resolution ────────────────────────────────────────── + + def _fallback_nameservers( + self, + ns_names: list[dns.name.Name], + tried: set[str], + ctx: _Context, + depth: int, ) -> list[str]: - """Resolve NS hostnames that had no glue records in the referral. - - Resolves up to max_ns NS names (all if 0) and collects all their IPs. - Does NOT stop at the first success — the resolved IP may point to a - dead server, so we need alternatives for _send_query to try. + """Resolve a referral's NS names, returning only addresses not yet tried. - Args: - ns_names: List of NS hostnames to resolve. - depth: Current recursion depth. - max_ns: Maximum number of NS names to try (0 = all). - deadline: Absolute monotonic deadline for the overall resolution. + Used when the addresses we already hold are dead or refuse: stale glue, + or a parent delegation still naming a provider that has dropped the + zone. Bounded by the same per-referral cap as the ordinary glueless + path, and by the shared budget. """ - resolved_ips: list[str] = [] - names_to_try = ns_names[:max_ns] if max_ns > 0 else ns_names - for ns_name in names_to_try: - if time.monotonic() >= deadline: + if not ns_names: + return [] + resolved = self._resolve_ns_names(ns_names, ctx, depth + 1, limit=self.limits.max_ns_per_referral) + fresh = [address for address in resolved if address not in tried] + if fresh: + logger.debug("Falling back to %d freshly resolved nameserver address(es)", len(fresh)) + return fresh + + def _resolve_ns_names(self, ns_names: list[dns.name.Name], ctx: _Context, depth: int, limit: int) -> list[str]: + """Resolve NS hostnames that had no usable glue. + + Bounded by ``limit`` names and by the shared NX-target budget: an + attacker's NS names deliberately fail to resolve, and absorbing an + unbounded number of those failures is the NXNSAttack amplification + primitive. + """ + resolved: list[str] = [] + for ns_name in ns_names[:limit]: + if ctx.budget.expired() or ctx.budget.remaining_queries() <= 0: break - if not ns_name.endswith("."): - ns_name = ns_name + "." try: - ips = self._resolve_iterative(ns_name, "A", depth=depth, cname_chain=[], deadline=deadline) - resolved_ips.extend(ips) - except Exception: - logger.debug("Failed to resolve glueless NS %s", ns_name) + answer = self._resolve_iterative(ns_name, dns.rdatatype.A, ctx, depth, []) + except QueryBudgetExceededError: + raise + except ResolverError as exc: + logger.debug("Failed to resolve glueless NS %s: %s", ns_name, exc) + ctx.budget.note_nx_target(str(ns_name), "A") + continue + resolved.extend(str(rr.address) for rr in answer.rrset) + return self.address_filter.filter(resolved) + + # ── DNSSEC ────────────────────────────────────────────────────────── + + def _cached_keys(self, zone: dns.name.Name) -> ZoneKeys | None: + with self._key_lock: + keys = self._key_cache.get(zone) + if keys is None: + return None + if keys.expiry and time.monotonic() >= keys.expiry: + del self._key_cache[zone] + return None + self._key_cache.move_to_end(zone) + return keys + + def _store_keys(self, keys: ZoneKeys, ttl: int) -> None: + keys.expiry = time.monotonic() + max(60, min(ttl, 86400)) + with self._key_lock: + # Bounded LRU: a long-running verifier sees a effectively unlimited + # number of signed zones, and a DNSKEY RRset is not small. + if keys.zone not in self._key_cache and len(self._key_cache) >= self._key_cache_size: + self._key_cache.popitem(last=False) + self._key_cache[keys.zone] = keys + self._key_cache.move_to_end(keys.zone) + + def _get_zone_keys(self, zone: dns.name.Name, ds: Any, nameservers: list[str], ctx: _Context) -> ZoneKeys: + """Fetch and validate a zone's DNSKEY RRset. + + Raises: + ResolutionTimeoutError: if the DNSKEY could not be fetched at all. + Being unable to *retrieve* validation material is a resolution + failure, not evidence of tampering: reporting it as BOGUS + would turn every slow nameserver into an apparent attack. + """ + assert self._validator is not None + cached = self._cached_keys(zone) + if cached is not None: + return cached + + response, _server = self._send_query(zone, dns.rdatatype.DNSKEY, nameservers, ctx) + if response is None: + raise ResolutionTimeoutError(str(zone), "DNSKEY") + + dnskey_rrset = self._find_answer_rrset(response, zone, dns.rdatatype.DNSKEY) + if dnskey_rrset is None: + return ZoneKeys(zone, None, ValidationState.BOGUS) + rrsig = find_rrsig(list(response.answer), zone, dns.rdatatype.DNSKEY) + + if zone == dns.name.root: + root_ok = self._validator.validate_root_dnskey(dnskey_rrset, rrsig, budget=ctx.budget) + state = ValidationState.SECURE if root_ok else ValidationState.BOGUS + elif ds is None: + state = ValidationState.BOGUS + else: + # May be INSECURE: a zone signed only with algorithms this build + # cannot verify is unverifiable rather than forged. + state = self._validator.validate_dnskey(zone, dnskey_rrset, rrsig, ds, budget=ctx.budget) + + secure = state is ValidationState.SECURE + keys = ZoneKeys(zone, dnskey_rrset if secure else None, state) + if secure: + self._store_keys(keys, dnskey_rrset.ttl) + return keys + + def _descend_chain( + self, + target: dns.name.Name, + zone: dns.name.Name, + ds: Any, + nameservers: list[str], + ctx: _Context, + ) -> tuple[dns.name.Name, Any, ValidationState]: + """Extend the chain of trust from ``zone`` down to ``target``. + + A nameserver that is authoritative for both a parent and a child zone + answers straight from the child, so we never see the intermediate + referral and our idea of the current zone lags behind the zone that + actually signed the data. ``.cz`` serving ``nic.cz`` and Nominet + serving both ``uk`` and ``co.uk`` are the common real-world cases. + + Here we walk the missing labels one at a time, asking the servers we + are already talking to for each intermediate DS. + """ + assert self._validator is not None + if target == zone or not target.is_subdomain(zone): + return zone, ds, ValidationState.SECURE + + while zone != target: + index = len(target.labels) - len(zone.labels) - 1 + next_zone = dns.name.Name(target.labels[index:]) + + keys = self._get_zone_keys(zone, ds, nameservers, ctx) + if keys.state is ValidationState.INSECURE: + return zone, None, ValidationState.INSECURE + if keys.state is not ValidationState.SECURE or keys.dnskey_rrset is None: + return zone, None, ValidationState.BOGUS + + response, _server = self._send_query(next_zone, dns.rdatatype.DS, nameservers, ctx) + if response is None: + # Could not retrieve the DS: indeterminate, not tampered with. + raise ResolutionTimeoutError(str(next_zone), "DS") + + records = list(response.answer) + list(response.authority) + state, next_ds = self._validator.validate_ds(next_zone, records, keys.as_keyring(), budget=ctx.budget) + if state is not ValidationState.SECURE: + return next_zone, None, state + zone, ds = next_zone, next_ds + + return zone, ds, ValidationState.SECURE + + def _align_zone( + self, + signer: dns.name.Name | None, + zone: dns.name.Name, + ds: Any, + nameservers: list[str], + ctx: _Context, + ) -> tuple[dns.name.Name, Any, ValidationState]: + """Move the chain forward to whichever zone actually signed the data.""" + if signer is None or signer == zone or not signer.is_subdomain(zone): + return zone, ds, ValidationState.SECURE + return self._descend_chain(signer, zone, ds, nameservers, ctx) + + @staticmethod + def _authority_signer(response: dns.message.Message) -> dns.name.Name | None: + """The signer of the DS/NSEC/NSEC3 records in the authority section.""" + for rrset in response.authority: + if rrset.rdtype != dns.rdatatype.RRSIG: continue - return resolved_ips + for rr in rrset: + if rr.type_covered in (dns.rdatatype.DS, dns.rdatatype.NSEC, dns.rdatatype.NSEC3): + signer: dns.name.Name = rr.signer + return signer + return None + + def _advance_dnssec( + self, + response: dns.message.Message, + child_zone: dns.name.Name, + parent_zone: dns.name.Name, + nameservers: list[str], + state: ValidationState, + parent_ds: Any, + ctx: _Context, + ) -> tuple[ValidationState, Any]: + """Establish the DNSSEC state of a child zone from its parent's referral.""" + if not ctx.dnssec or self._validator is None or state is not ValidationState.SECURE: + return ValidationState.INSECURE, None + + # The referral may have been answered from a zone below the one we + # believe we are querying; catch up before validating the DS. + signer = self._authority_signer(response) + parent_zone, parent_ds, aligned = self._align_zone(signer, parent_zone, parent_ds, nameservers, ctx) + if aligned is ValidationState.INSECURE: + return ValidationState.INSECURE, None + if aligned is ValidationState.BOGUS: + raise DNSSECValidationError(str(child_zone), "DS", f"broken chain of trust at {parent_zone}") + + if child_zone == parent_zone: + return ValidationState.SECURE, parent_ds + + parent_keys = self._get_zone_keys(parent_zone, parent_ds, nameservers, ctx) + if parent_keys.state is ValidationState.INSECURE: + return ValidationState.INSECURE, None + if parent_keys.state is not ValidationState.SECURE or parent_keys.dnskey_rrset is None: + raise DNSSECValidationError(str(child_zone), "DS", f"cannot establish DNSKEY for parent zone {parent_zone}") + + child_state, ds = self._validator.validate_ds( + child_zone, list(response.authority), parent_keys.as_keyring(), budget=ctx.budget + ) + if child_state is ValidationState.BOGUS: + raise DNSSECValidationError( + str(child_zone), "DS", f"delegation from {parent_zone} is neither signed nor provably unsigned" + ) + return child_state, ds + + def _validate_answer( + self, + response: dns.message.Message, + rrset: dns.rrset.RRset, + qname: dns.name.Name, + rdtype: dns.rdatatype.RdataType, + ctx: _Context, + zone: dns.name.Name, + nameservers: list[str], + state: ValidationState, + ds: Any, + ) -> ValidationState: + """Validate an answer RRset, returning its DNSSEC state.""" + if not ctx.dnssec or self._validator is None or state is not ValidationState.SECURE: + return ValidationState.INSECURE + + rrsig = find_rrsig(list(response.answer), rrset.name, rdtype) + if rrsig is None: + raise DNSSECValidationError( + str(qname), dns.rdatatype.to_text(rdtype), "zone is signed but the answer carries no RRSIG" + ) + + # The signer tells us which zone really produced this data; a server + # authoritative for both a parent and a child answers from the child + # without ever emitting the intermediate referral. + zone, ds, aligned = self._align_zone(rrsig[0].signer, zone, ds, nameservers, ctx) + if aligned is ValidationState.INSECURE: + return ValidationState.INSECURE + if aligned is ValidationState.BOGUS: + raise DNSSECValidationError(str(qname), dns.rdatatype.to_text(rdtype), f"broken chain of trust at {zone}") + + keys = self._get_zone_keys(zone, ds, nameservers, ctx) + if keys.state is ValidationState.INSECURE: + # The zone is signed with algorithms we cannot verify, so we have + # no more assurance about this answer than for an unsigned zone. + return ValidationState.INSECURE + if keys.state is not ValidationState.SECURE or keys.dnskey_rrset is None: + raise DNSSECValidationError(str(qname), dns.rdatatype.to_text(rdtype), f"no valid DNSKEY for zone {zone}") + + keyring = keys.as_keyring() + if not self._validator.validate_rrset(rrset, rrsig, keyring, budget=ctx.budget): + raise DNSSECValidationError(str(qname), dns.rdatatype.to_text(rdtype), "RRSIG did not verify") + + # A signature covering fewer labels than the owner name has means the + # answer was synthesised from a wildcard. That signature verifies for + # every name the wildcard could cover, so on its own it lets a replayed + # wildcard record stand in for a name that has different real data. + # RFC 4035 §5.3.4 requires a proof that no closer match exists. + if rrsig[0].labels < len(rrset.name.labels) - 1: + proven = self._validator.prove_wildcard( + rrset.name, rrsig[0].labels, list(response.authority), keyring, budget=ctx.budget + ) + if not proven: + raise DNSSECValidationError( + str(qname), + dns.rdatatype.to_text(rdtype), + "wildcard-expanded answer without a proof that no closer match exists", + ) + return ValidationState.SECURE + + def _verify_denial( + self, + response: dns.message.Message, + qname: dns.name.Name, + rdtype: dns.rdatatype.RdataType, + ctx: _Context, + zone: dns.name.Name, + nameservers: list[str], + state: ValidationState, + ds: Any, + negative: str, + ) -> None: + """Validate the denial-of-existence proof for a negative answer.""" + if not ctx.dnssec or self._validator is None or state is not ValidationState.SECURE: + return + + signer = self._authority_signer(response) + zone, ds, aligned = self._align_zone(signer, zone, ds, nameservers, ctx) + if aligned is ValidationState.INSECURE: + return + if aligned is ValidationState.BOGUS: + raise DNSSECValidationError(str(qname), dns.rdatatype.to_text(rdtype), f"broken chain of trust at {zone}") + + keys = self._get_zone_keys(zone, ds, nameservers, ctx) + if keys.state is ValidationState.INSECURE: + return + if keys.state is not ValidationState.SECURE or keys.dnskey_rrset is None: + raise DNSSECValidationError(str(qname), dns.rdatatype.to_text(rdtype), f"no valid DNSKEY for zone {zone}") + keyring = keys.as_keyring() + authority = list(response.authority) + if negative == "nxdomain": + proven = self._validator.prove_nxdomain(qname, authority, keyring, budget=ctx.budget) + else: + proven = self._validator.prove_nodata(qname, rdtype, authority, keyring, budget=ctx.budget) + if not proven: + raise DNSSECValidationError( + str(qname), dns.rdatatype.to_text(rdtype), f"unproven {negative} in a signed zone" + ) + + @staticmethod + def _negative_ttl(response: dns.message.Message, qname: dns.name.Name) -> int | None: + """Derive the negative-caching TTL from the authority SOA (RFC 2308 §5).""" + for rrset in response.authority: + if rrset.rdtype == dns.rdatatype.SOA and qname.is_subdomain(rrset.name): + soa = rrset[0] + return int(min(rrset.ttl, soa.minimum)) + return None + + # ── transport ─────────────────────────────────────────────────────── + + def _order_servers(self, nameservers: list[str]) -> list[str]: + servers = self.address_filter.filter(nameservers) + _RANDOM.shuffle(servers) + return servers + + def _effective_timeout(self, ctx: _Context) -> float: + return min(self.timeout, ctx.budget.time_remaining()) + + def _send_query( + self, qname: dns.name.Name, rdtype: dns.rdatatype.RdataType, nameservers: list[str], ctx: _Context + ) -> tuple[dns.message.Message | None, str]: + """Send a query to a set of nameservers, sweeping breadth-first. + + Every server is tried once before any is retried. Retrying one server + three times before touching the next means a single dead nameserver + costs ``max_retries + 1`` full timeouts while a healthy sibling sits + unused: enough, at the default budget, to fail a resolution that would + otherwise have succeeded immediately. + + Each successive sweep also degrades EDNS: full payload, then 512 bytes, + then no EDNS at all. Without the payload step a path with a broken PMTU + blackholes every large response identically and no amount of retrying + recovers: the classic silent failure for large TXT answers. TCP + fallback does not help there, because nothing arrives to carry the TC + bit. + """ + rdtype_text = dns.rdatatype.to_text(rdtype) + servers = self._order_servers(nameservers) + abandoned: set[str] = set() + # Servers that answered in a way meaning "I do not speak EDNS". + no_edns: set[str] = set() + + for attempt in range(self.max_retries + 1): + for server in servers: + if server in abandoned: + continue + + timeout = self._effective_timeout(ctx) + if timeout <= 0: + return None, "" + payload = None if server in no_edns else self._payload_for_attempt(attempt) + + ctx.budget.spend_query(str(qname), rdtype_text) + try: + response = self._query_once(qname, rdtype, server, payload, timeout, ctx) + except _RetryableError as exc: + logger.debug( + "No answer from %s for %s/%s (sweep %d): %s", server, qname, rdtype_text, attempt + 1, exc + ) + continue + except _FatalServerError as exc: + logger.debug("Abandoning %s for %s/%s: %s", server, qname, rdtype_text, exc) + abandoned.add(server) + continue + except _MalformedResponseError as exc: + # A mangled response often means the server dislikes EDNS. + logger.debug("Malformed response from %s for %s/%s: %s", server, qname, rdtype_text, exc) + if payload is not None: + no_edns.add(server) + else: + abandoned.add(server) + continue + + rcode = response.rcode() + if payload is not None and rcode in _EDNS_UNSUPPORTED_RCODES: + # FORMERR / NOTIMP / SERVFAIL / BADVERS are all emitted by + # servers and middleboxes that cannot cope with an OPT + # record. Retry this one without EDNS on the next sweep. + logger.debug("rcode %s from %s, dropping EDNS for it", dns.rcode.to_text(rcode), server) + no_edns.add(server) + continue + return response, server + + if len(abandoned) == len(servers): + break + + return None, "" + + def _payload_for_attempt(self, attempt: int) -> int | None: + """EDNS payload for a retry attempt; ``None`` means no EDNS at all.""" + if attempt <= 0: + return self.edns_payload + if attempt == 1: + return min(512, self.edns_payload) + return None + + def _query_once( + self, + qname: dns.name.Name, + rdtype: dns.rdatatype.RdataType, + server: str, + payload: int | None, + timeout: float, + ctx: _Context, + ) -> dns.message.Message: + """Send one query, translating dnspython failures into internal signals.""" + try: + if payload is None: + query = dns.message.make_query(qname, rdtype, use_edns=False) + else: + want_dnssec = ctx.dnssec + query = dns.message.make_query(qname, rdtype, use_edns=0, payload=payload, want_dnssec=want_dnssec) + # RD=0: we iterate ourselves rather than asking the server to. + query.flags &= ~dns.flags.RD + except dns.exception.DNSException as exc: # pragma: no cover - name is pre-validated + raise _FatalServerError(f"cannot build query: {exc}") from exc + + try: + if self.use_tcp_fallback: + response, _used_tcp = dns.query.udp_with_fallback(query, server, timeout=timeout) + else: + # raise_on_truncation is essential: without it a truncated + # response is silently returned with a partial answer section. + response = dns.query.udp(query, server, timeout=timeout, raise_on_truncation=True) + except dns.message.Truncated as exc: + raise _RetryableError("truncated response and TCP fallback is disabled") from exc + except dns.exception.Timeout as exc: + raise _RetryableError(str(exc)) from exc + except EOFError as exc: + # dnspython raises this when a TCP peer closes mid-response. + raise _RetryableError(f"connection closed: {exc}") from exc + except OSError as exc: + if exc.errno in _FATAL_ERRNOS: + raise _FatalServerError(str(exc)) from exc + raise _RetryableError(str(exc)) from exc + except dns.query.UnexpectedSource as exc: + # A stray or spoofed packet. Do not let it downgrade our EDNS or + # permanently retire an otherwise healthy nameserver. + raise _RetryableError(f"unexpected response source: {exc}") from exc + except dns.exception.FormError as exc: + raise _MalformedResponseError(str(exc)) from exc + except dns.exception.DNSException as exc: + raise _MalformedResponseError(str(exc)) from exc + + return response + + +class _RetryableError(Exception): + """Internal: the query should be retried against the same server.""" + + +class _FatalServerError(Exception): + """Internal: this server is unusable; move on immediately.""" + + +class _MalformedResponseError(Exception): + """Internal: the response could not be parsed or did not match the query.""" + + +_EDNS_UNSUPPORTED_RCODES = frozenset( + { + int(dns.rcode.FORMERR), + int(dns.rcode.NOTIMP), + int(dns.rcode.SERVFAIL), + 16, # BADVERS / BADSIG: the EDNS extended rcode for "bad OPT version" + } +) diff --git a/src/recursive_resolver/roots.py b/src/recursive_resolver/roots.py index 7f92c41..443773a 100644 --- a/src/recursive_resolver/roots.py +++ b/src/recursive_resolver/roots.py @@ -1,11 +1,12 @@ -"""Hardcoded root server hints (13 servers, IPv4 + IPv6). +"""Root server hints and the DNSSEC root trust anchors. -Source: https://www.internic.net/domain/named.root +Root hints source: https://www.internic.net/domain/named.root +Trust anchor source: https://data.iana.org/root-anchors/root-anchors.xml """ from __future__ import annotations -# Root server name -> (IPv4, IPv6) +# Root server name -> (IPv4, IPv6). Verified against named.root. ROOT_SERVERS: dict[str, tuple[str, str]] = { "a.root-servers.net": ("198.41.0.4", "2001:503:ba3e::2:30"), "b.root-servers.net": ("170.247.170.2", "2801:1b8:10::b"), @@ -22,6 +23,17 @@ "m.root-servers.net": ("202.12.27.33", "2001:dc3::35"), } +# DS records for the root zone, in presentation format. +# +# Only currently-valid anchors are listed: KSK-2017 (tag 20326, valid from +# 2017-02-02) and KSK-2024 (tag 38696, valid from 2024-07-18). +# +# Format: keytag algorithm digest_type digest +ROOT_TRUST_ANCHORS: tuple[str, ...] = ( + "20326 8 2 E06D44B80B8F1D39A95C0B0D7C65D08458E880409BBC683457104237C7F8EC8D", + "38696 8 2 683D2D0ACB8C9B712A1948B27F741219298D0A450D612C483AF444A4C0FB2B16", +) + def get_root_addresses(ipv4_only: bool = True) -> list[str]: """Return a list of root server IP addresses. diff --git a/src/recursive_resolver/singleflight.py b/src/recursive_resolver/singleflight.py new file mode 100644 index 0000000..5f19f69 --- /dev/null +++ b/src/recursive_resolver/singleflight.py @@ -0,0 +1,109 @@ +"""Single-flight deduplication of concurrent identical resolutions. + +Without this, N threads asking for the same name at the same moment each run a +full independent walk down the delegation tree: an N-times multiplier on +upstream query load, and on load against the root servers in particular. +Modelled on aiodnsresolver's ``in_progress`` map: the first caller does the +work, the rest wait and share its outcome, including its exception. +""" + +from __future__ import annotations + +import threading +from collections.abc import Callable +from typing import Any, Generic, TypeVar + +T = TypeVar("T") + + +def _per_waiter(error: BaseException) -> BaseException: + """A distinct instance of ``error``, so no two threads share one object. + + ``raise`` writes ``__traceback__`` onto the exception it is handed, so + giving every waiter the leader's instance has several threads appending to + one traceback at once, and a caller ends up reading frames from threads it + never ran. + + ``copy.copy`` cannot do this. These exceptions build their message in + ``__init__`` out of several arguments while ``args`` holds only the + finished string, so copy re-invokes ``__init__`` with the wrong arity: it + raises ``TypeError`` for most of them and silently double-wraps the message + for the rest. Bypassing ``__init__`` and carrying ``args``, ``__dict__`` + and ``__cause__`` across reproduces every :class:`ResolverError` exactly, + which is all this class is ever asked to transport. + + ``OSError`` keeps ``errno`` and ``strerror`` in C-level state that is + populated at construction and would not survive, so it is passed through + untouched: a correct shared object beats a lossy private one. Anything else + that cannot be rebuilt is returned as-is for the same reason. The leader + catches ``BaseException``, so what arrives here is not a closed set, and + the caller must get the real failure rather than an error from this + function -- which is exactly what disqualified ``copy.copy``. + """ + if isinstance(error, OSError): + return error + try: + clone = error.__class__.__new__(error.__class__) + clone.args = error.args + clone.__dict__.update(error.__dict__) + clone.__cause__ = error.__cause__ + except Exception: # noqa: BLE001 - a messy traceback beats losing the error + return error + return clone + + +class _Call(Generic[T]): + __slots__ = ("event", "value", "error") + + def __init__(self) -> None: + self.event = threading.Event() + self.value: T | None = None + self.error: BaseException | None = None + + +class SingleFlight(Generic[T]): + """Collapse concurrent calls sharing a key into one execution.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._calls: dict[Any, _Call[T]] = {} + + def do(self, key: Any, fn: Callable[[], T], wait_timeout: float | None = None) -> T: + """Run ``fn`` for ``key``, or wait for an in-flight call with the same key. + + If the leader raises, every waiter receives the same exception: a + failure is shared, not retried N times in a stampede. + """ + with self._lock: + call = self._calls.get(key) + if call is None: + call = _Call[T]() + self._calls[key] = call + leader = True + else: + leader = False + + if leader: + try: + call.value = fn() + except BaseException as exc: # noqa: BLE001 - re-raised below and to waiters + call.error = exc + raise + finally: + with self._lock: + self._calls.pop(key, None) + call.event.set() + return call.value # type: ignore[return-value] + + if not call.event.wait(timeout=wait_timeout): + # The leader is taking too long; do the work ourselves rather than + # blocking indefinitely on it. + return fn() + if call.error is not None: + raise _per_waiter(call.error) + return call.value # type: ignore[return-value] + + def in_flight(self) -> int: + """Number of distinct keys currently being resolved.""" + with self._lock: + return len(self._calls) diff --git a/tests/conftest.py b/tests/conftest.py index 489c4fd..6b19215 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,32 +1,128 @@ -"""Shared fixtures and CLI options for pytest.""" +"""Shared fixtures, CLI options and DNS message builders for the test suite.""" from __future__ import annotations +import dns.flags +import dns.message +import dns.name +import dns.rcode +import dns.rdata +import dns.rdataclass +import dns.rdatatype +import dns.rrset import pytest +from recursive_resolver import RecursiveResolver + +# Every supported type that a plain list of apex domains can exercise. PTR +# needs an IP, and SRV/NAPTR need _service._proto labels, so they are covered by +# the unit and integration suites instead. +DEFAULT_CSV_TYPES = "A,AAAA,MX,TXT,NS,SOA,CAA,CNAME,DS,DNSKEY" + +ROOT_REFERRAL_NS = "a.gtld-servers.net." +ROOT_REFERRAL_IP = "192.5.6.30" + def pytest_addoption(parser: pytest.Parser) -> None: - parser.addoption( - "--csv", - action="store", - default=None, - help="Path to a CSV file with domains for bulk testing", - ) + parser.addoption("--csv", action="store", default=None, help="Path to a CSV file with domains for bulk testing") parser.addoption( "--types", action="store", - default="A,MX", - help="Comma-separated record types to test (default: A,MX)", - ) - parser.addoption( - "--sample", - action="store", - type=int, - default=0, - help="Randomly sample N domains from the CSV (0 = all)", + default=DEFAULT_CSV_TYPES, + help=f"Comma-separated record types for the bulk harness (default: {DEFAULT_CSV_TYPES})", ) + parser.addoption("--sample", action="store", type=int, default=0, help="Randomly sample N domains (0 = all)") @pytest.fixture def csv_path(request: pytest.FixtureRequest) -> str | None: return request.config.getoption("--csv") # type: ignore[return-value] + + +def make_response( + answer=None, + authority=None, + additional=None, + rcode=dns.rcode.NOERROR, + aa: bool = True, + tc: bool = False, + rdclass: int = dns.rdataclass.IN, +) -> dns.message.Message: + """Build a dns.message.Message for testing. + + Each section is a list of ``(name, ttl, rdtype, [rdata, ...])`` tuples. + ``aa`` defaults to True because the resolver now requires the AA bit on + answers and negative responses; referrals should pass ``aa=False``. + """ + response = dns.message.Message() + response.flags = dns.flags.QR + if aa: + response.flags |= dns.flags.AA + if tc: + response.flags |= dns.flags.TC + response.id = 0 + response.set_rcode(rcode) + + for section, records in [ + (response.answer, answer or []), + (response.authority, authority or []), + (response.additional, additional or []), + ]: + for name_str, ttl, rdtype_str, rdata_strs in records: + name = dns.name.from_text(name_str) + rdt = dns.rdatatype.from_text(rdtype_str) + rrset = dns.rrset.RRset(name, rdclass, rdt) + for rd_str in rdata_strs: + rrset.add(dns.rdata.from_text(rdclass, rdt, rd_str)) + rrset.ttl = ttl + section.append(rrset) + + return response + + +def referral(zone: str, ns_names: list[str], glue: dict[str, str] | None = None) -> dns.message.Message: + """Build a referral response (AA clear, NS in authority, glue in additional).""" + additional = [(name, 172800, "A", [ip]) for name, ip in (glue or {}).items()] + return make_response( + authority=[(zone, 172800, "NS", ns_names)], + additional=additional, + aa=False, + ) + + +def root_to_com() -> dns.message.Message: + """The standard root -> .com referral used by most tests.""" + return referral("com.", [ROOT_REFERRAL_NS], {ROOT_REFERRAL_NS: ROOT_REFERRAL_IP}) + + +def nodata(zone: str) -> dns.message.Message: + """A NODATA response: authoritative, SOA in authority, no answer.""" + return make_response( + authority=[(zone, 300, "SOA", [f"ns1.{zone} admin.{zone} 1 3600 900 604800 86400"])], + aa=True, + ) + + +def sequence(responses: list[tuple[dns.message.Message, str]]): + """Return a _send_query side effect that replays responses in order.""" + state = {"i": 0} + + def side_effect(qname, rdtype, nameservers, ctx): + i = state["i"] + if i < len(responses): + state["i"] += 1 + return responses[i] + return (None, "") + + return side_effect + + +def offline_resolver(**kwargs: object) -> RecursiveResolver: + """A resolver wired for unit tests: no DNSSEC, no cache, unless overridden. + + Shared rather than redefined per module so the defaults for offline unit + tests are set in one place. + """ + kwargs.setdefault("dnssec", False) + kwargs.setdefault("cache_enabled", False) + return RecursiveResolver(**kwargs) # type: ignore[arg-type] diff --git a/tests/signed_zone.py b/tests/signed_zone.py new file mode 100644 index 0000000..049992d --- /dev/null +++ b/tests/signed_zone.py @@ -0,0 +1,102 @@ +"""An in-memory signed zone, so DNSSEC success paths are testable offline. + +Validation logic that is only ever exercised against live DNS is logic that +cannot be tested deterministically, cannot be tested in CI without a network, +and cannot be tested at all for the cases the internet does not happen to +provide. This builds a real signed zone entirely in process, with real keys, real +signatures and real DS digests. + +ECDSA P-256 (algorithm 13) is used because key generation and signing are fast +enough to run inside a unit test. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import dns.dnssec +import dns.name +import dns.rdata +import dns.rdataclass +import dns.rdataset +import dns.rdatatype +import dns.rrset +from cryptography.hazmat.primitives.asymmetric import ec + +ALGORITHM = dns.dnssec.Algorithm.ECDSAP256SHA256 +DIGEST = dns.dnssec.DSDigest.SHA256 + +# A wide signature validity window keeps the fixture stable over time. +INCEPTION = 1_600_000_000 +EXPIRATION = 4_000_000_000 + + +def rrset_of(name: str, rdtype: str, *rdatas: str, ttl: int = 300) -> dns.rrset.RRset: + """Build an IN-class RRset from presentation-format rdata.""" + rdt = dns.rdatatype.from_text(rdtype) + out = dns.rrset.RRset(dns.name.from_text(name), dns.rdataclass.IN, rdt) + for rdata in rdatas: + out.add(dns.rdata.from_text(dns.rdataclass.IN, rdt, rdata)) + out.ttl = ttl + return out + + +@dataclass +class SignedZone: + """A zone with a working key, able to sign RRsets and yield its own DS.""" + + name: dns.name.Name + private_key: ec.EllipticCurvePrivateKey + dnskey: dns.rdata.Rdata + dnskey_rrset: dns.rrset.RRset + + @classmethod + def create(cls, zone: str) -> SignedZone: + name = dns.name.from_text(zone) + private_key = ec.generate_private_key(ec.SECP256R1()) + # flags=257: zone key with the Secure Entry Point bit set. + dnskey = dns.dnssec.make_dnskey(private_key.public_key(), ALGORITHM, flags=257) + dnskey_rrset = dns.rrset.RRset(name, dns.rdataclass.IN, dns.rdatatype.DNSKEY) + dnskey_rrset.add(dnskey) + dnskey_rrset.ttl = 3600 + return cls(name=name, private_key=private_key, dnskey=dnskey, dnskey_rrset=dnskey_rrset) + + def sign(self, rrset: dns.rrset.RRset) -> dns.rrset.RRset: + """Return the RRSIG RRset covering ``rrset``, signed by this zone.""" + rrsig = dns.dnssec.sign( + rrset, + self.private_key, + self.name, + self.dnskey, + inception=INCEPTION, + expiration=EXPIRATION, + ) + out = dns.rrset.RRset(rrset.name, dns.rdataclass.IN, dns.rdatatype.RRSIG) + out.add(rrsig) + out.ttl = rrset.ttl + return out + + def signed_dnskey(self) -> dns.rrset.RRset: + """The RRSIG over this zone's own DNSKEY RRset (the self-signature).""" + return self.sign(self.dnskey_rrset) + + def ds_rdataset(self, child: SignedZone) -> dns.rdataset.Rdataset: + """The DS rdataset this zone would publish for ``child``.""" + rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.DS) + rdataset.add(dns.dnssec.make_ds(child.name, child.dnskey, DIGEST), ttl=3600) + return rdataset + + def ds_rrset(self, child: SignedZone) -> dns.rrset.RRset: + """The DS RRset for ``child``, as it appears in this zone.""" + out = dns.rrset.RRset(child.name, dns.rdataclass.IN, dns.rdatatype.DS) + out.add(dns.dnssec.make_ds(child.name, child.dnskey, DIGEST)) + out.ttl = 3600 + return out + + def keyring(self) -> dict[dns.name.Name, dns.rrset.RRset]: + return {self.name: self.dnskey_rrset} + + def anchor_text(self) -> str: + """This zone's DS in presentation format, for use as a trust anchor.""" + ds = dns.dnssec.make_ds(self.name, self.dnskey, DIGEST) + return ds.to_text() diff --git a/tests/test_cache.py b/tests/test_cache.py index fb373f6..6d5df4a 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,4 +1,4 @@ -"""Tests for the DNS cache module.""" +"""Tests for the DNS cache: answers, negative entries and delegations.""" from __future__ import annotations @@ -6,157 +6,370 @@ import time from unittest.mock import patch -from recursive_resolver.cache import DNSCache +import dns.name +import dns.rdatatype +import pytest +from conftest import make_response, referral, root_to_com +from recursive_resolver import NoAnswerError, NXDOMAINError, RecursiveResolver +from recursive_resolver.cache import Delegation, DNSCache -class TestDNSCache: +EXAMPLE = dns.name.from_text("example.com.") +A = dns.rdatatype.A + + +class TestAnswerCache: def test_put_and_get(self) -> None: cache = DNSCache() - cache.put("example.com", "A", rrset="fake-rrset", ttl=300) - entry = cache.get("example.com", "A") + cache.put_answer(EXAMPLE, A, "rrset", ttl=300) + entry = cache.get_answer(EXAMPLE, A) assert entry is not None - assert entry.rrset == "fake-rrset" - assert entry.is_negative is False + assert entry.rrset == "rrset" - def test_get_missing_key(self) -> None: - cache = DNSCache() - entry = cache.get("nonexistent.com", "A") - assert entry is None + def test_miss(self) -> None: + assert DNSCache().get_answer("nothing.example.", A) is None - def test_case_insensitive(self) -> None: + def test_case_insensitive_and_idn_safe(self) -> None: + """Name-keyed entries are immune to str.lower()/IDNA mismatches.""" cache = DNSCache() - cache.put("Example.COM", "a", rrset="rrset", ttl=300) - entry = cache.get("example.com", "A") - assert entry is not None - assert entry.rrset == "rrset" + cache.put_answer(dns.name.from_text("Bücher.de."), A, "rrset", ttl=300) + assert cache.get_answer(dns.name.from_text("bücher.de."), A) is not None + assert cache.get_answer(dns.name.from_text("xn--bcher-kva.de."), A) is not None def test_expiry(self) -> None: - cache = DNSCache(min_ttl=1) + cache = DNSCache() now = time.monotonic() with patch("recursive_resolver.cache.time.monotonic", return_value=now): - cache.put("example.com", "A", rrset="rrset", ttl=1) - - # Before expiry - with patch("recursive_resolver.cache.time.monotonic", return_value=now + 0.5): - entry = cache.get("example.com", "A") - assert entry is not None - - # After expiry - with patch("recursive_resolver.cache.time.monotonic", return_value=now + 2.0): - entry = cache.get("example.com", "A") - assert entry is None - - def test_negative_caching(self) -> None: - cache = DNSCache(negative_ttl=60) - cache.put("nxdomain.example.com", "A", rrset=None, is_negative=True) - entry = cache.get("nxdomain.example.com", "A") - assert entry is not None - assert entry.is_negative is True - assert entry.rrset is None + cache.put_answer(EXAMPLE, A, "rrset", ttl=10) + with patch("recursive_resolver.cache.time.monotonic", return_value=now + 5): + assert cache.get_answer(EXAMPLE, A) is not None + with patch("recursive_resolver.cache.time.monotonic", return_value=now + 11): + assert cache.get_answer(EXAMPLE, A) is None - def test_stats_tracking(self) -> None: + def test_ttl_zero_is_honoured_by_default(self) -> None: + """min_ttl defaults to 0 so TTL-0 records are genuinely not cached.""" cache = DNSCache() - assert cache.stats.hits == 0 - assert cache.stats.misses == 0 - assert cache.stats.hit_rate == 0.0 - - cache.put("example.com", "A", rrset="rrset", ttl=300) - - # Hit - cache.get("example.com", "A") - assert cache.stats.hits == 1 - assert cache.stats.misses == 0 - assert cache.stats.hit_rate == 1.0 - - # Miss - cache.get("other.com", "A") - assert cache.stats.hits == 1 - assert cache.stats.misses == 1 - assert cache.stats.hit_rate == 0.5 - - def test_lru_eviction(self) -> None: - cache = DNSCache(max_size=2) - cache.put("a.com", "A", rrset="a", ttl=300) - cache.put("b.com", "A", rrset="b", ttl=300) - # Access a.com so b.com is LRU - cache.get("a.com", "A") - # Adding c.com should evict b.com (least recently used) - cache.put("c.com", "A", rrset="c", ttl=300) - - assert cache.get("a.com", "A") is not None - assert cache.get("c.com", "A") is not None - # b.com was evicted (LRU when a.com was accessed making b.com least recent) - # Actually: after put a, put b, get a: order is [b, a]. Evict b. - assert cache.stats.evictions == 1 - - def test_ttl_clamping_min(self) -> None: - cache = DNSCache(min_ttl=60) now = time.monotonic() with patch("recursive_resolver.cache.time.monotonic", return_value=now): - cache.put("example.com", "A", rrset="rrset", ttl=5) # below min - - # Should still be alive at 30s (because min_ttl=60 was applied) - with patch("recursive_resolver.cache.time.monotonic", return_value=now + 30): - entry = cache.get("example.com", "A") - assert entry is not None + cache.put_answer(EXAMPLE, A, "rrset", ttl=0) + assert cache.get_answer(EXAMPLE, A) is None - def test_ttl_clamping_max(self) -> None: + def test_ttl_ceiling(self) -> None: cache = DNSCache(max_ttl=600) now = time.monotonic() with patch("recursive_resolver.cache.time.monotonic", return_value=now): - cache.put("example.com", "A", rrset="rrset", ttl=99999) - - # Should be expired after max_ttl + cache.put_answer(EXAMPLE, A, "rrset", ttl=99999) with patch("recursive_resolver.cache.time.monotonic", return_value=now + 601): - entry = cache.get("example.com", "A") - assert entry is None + assert cache.get_answer(EXAMPLE, A) is None - def test_clear(self) -> None: + def test_lru_eviction(self) -> None: + cache = DNSCache(max_size=2) + a, b, c = (dns.name.from_text(f"{x}.com.") for x in "abc") + cache.put_answer(a, A, "a", ttl=300) + cache.put_answer(b, A, "b", ttl=300) + cache.get_answer(a, A) # a becomes most-recently-used + cache.put_answer(c, A, "c", ttl=300) + assert cache.get_answer(a, A) is not None + assert cache.get_answer(c, A) is not None + assert cache.stats.evictions == 1 + + def test_stats(self) -> None: cache = DNSCache() - cache.put("example.com", "A", rrset="rrset", ttl=300) - assert len(cache) == 1 - cache.clear() - assert len(cache) == 0 + cache.put_answer(EXAMPLE, A, "rrset", ttl=300) + cache.get_answer(EXAMPLE, A) + cache.get_answer(dns.name.from_text("other.com."), A) + assert (cache.stats.hits, cache.stats.misses) == (1, 1) + assert cache.stats.hit_rate == 0.5 def test_thread_safety(self) -> None: cache = DNSCache(max_size=100) errors: list[Exception] = [] - def writer(start: int) -> None: + def worker(start: int) -> None: try: - for i in range(50): - cache.put(f"domain-{start + i}.com", "A", rrset=f"rrset-{start + i}", ttl=300) - except Exception as e: - errors.append(e) + for i in range(200): + name = dns.name.from_text(f"d{(start + i) % 150}.com.") + cache.put_answer(name, A, f"v{i}", ttl=300) + cache.get_answer(name, A) + except Exception as exc: # pragma: no cover + errors.append(exc) - def reader() -> None: - try: - for i in range(100): - cache.get(f"domain-{i}.com", "A") - except Exception as e: - errors.append(e) - - threads = [ - threading.Thread(target=writer, args=(0,)), - threading.Thread(target=writer, args=(50,)), - threading.Thread(target=reader), - threading.Thread(target=reader), - ] + threads = [threading.Thread(target=worker, args=(n * 50,)) for n in range(6)] for t in threads: t.start() for t in threads: t.join() + assert errors == [] + + +class TestNegativeCache: + def test_nxdomain_is_keyed_by_name_not_type(self) -> None: + """RFC 2308 §5: NXDOMAIN applies to the name, whatever the type.""" + cache = DNSCache() + cache.put_nxdomain(EXAMPLE, ttl=300) + assert cache.get_nxdomain(EXAMPLE) is not None + assert cache.get_nxdomain_ancestor(EXAMPLE) == EXAMPLE + + def test_harden_below_nxdomain(self) -> None: + """RFC 8020: nothing can exist below a non-existent name.""" + cache = DNSCache() + cache.put_nxdomain(dns.name.from_text("gone.example.com."), ttl=300) + found = cache.get_nxdomain_ancestor(dns.name.from_text("deep.under.gone.example.com.")) + assert found == dns.name.from_text("gone.example.com.") + + def test_unrelated_names_are_not_covered(self) -> None: + cache = DNSCache() + cache.put_nxdomain(dns.name.from_text("gone.example.com."), ttl=300) + assert cache.get_nxdomain_ancestor(dns.name.from_text("live.example.com.")) is None + + def test_nodata_is_keyed_by_name_and_type(self) -> None: + cache = DNSCache() + cache.put_nodata(EXAMPLE, dns.rdatatype.MX, ttl=300) + assert cache.get_nodata(EXAMPLE, dns.rdatatype.MX) is not None + assert cache.get_nodata(EXAMPLE, A) is None + + def test_min_ttl_does_not_extend_negative_caching(self) -> None: + """min_ttl floors record TTLs only, per its documented contract. - assert len(errors) == 0 + A warm answer cache is no reason to hold an NXDOMAIN past what the + zone's SOA asked for: a name created a minute ago would keep resolving + as missing. + """ + cache = DNSCache(min_ttl=600) + now = time.monotonic() + with patch("recursive_resolver.cache.time.monotonic", return_value=now): + cache.put_nxdomain(EXAMPLE, ttl=30) + cache.put_nodata(EXAMPLE, dns.rdatatype.MX, ttl=30) + with patch("recursive_resolver.cache.time.monotonic", return_value=now + 31): + assert cache.get_nxdomain(EXAMPLE) is None + assert cache.get_nodata(EXAMPLE, dns.rdatatype.MX) is None - def test_different_rdtypes_separate_entries(self) -> None: + def test_negative_ttl_ceiling(self) -> None: + cache = DNSCache(max_negative_ttl=60) + now = time.monotonic() + with patch("recursive_resolver.cache.time.monotonic", return_value=now): + cache.put_nxdomain(EXAMPLE, ttl=99999) + with patch("recursive_resolver.cache.time.monotonic", return_value=now + 61): + assert cache.get_nxdomain(EXAMPLE) is None + + +class TestDelegationCache: + def test_put_and_closest(self) -> None: cache = DNSCache() - cache.put("example.com", "A", rrset="a-rrset", ttl=300) - cache.put("example.com", "AAAA", rrset="aaaa-rrset", ttl=300) - - a_entry = cache.get("example.com", "A") - aaaa_entry = cache.get("example.com", "AAAA") - assert a_entry is not None - assert aaaa_entry is not None - assert a_entry.rrset == "a-rrset" - assert aaaa_entry.rrset == "aaaa-rrset" + cache.put_delegation(Delegation(zone=dns.name.from_text("com."), addresses=["1.2.3.4"]), ttl=3600) + found = cache.closest_delegation(dns.name.from_text("deep.sub.example.com.")) + assert found is not None + assert found.zone == dns.name.from_text("com.") + + def test_deepest_delegation_wins(self) -> None: + cache = DNSCache() + cache.put_delegation(Delegation(zone=dns.name.from_text("com."), addresses=["1.2.3.4"]), ttl=3600) + cache.put_delegation(Delegation(zone=EXAMPLE, addresses=["5.6.7.8"]), ttl=3600) + found = cache.closest_delegation(dns.name.from_text("www.example.com.")) + assert found is not None and found.zone == EXAMPLE + + def test_depth_limit_keeps_only_tlds(self) -> None: + """max_delegation_depth=1 caches root->TLD cuts and nothing deeper.""" + cache = DNSCache(max_delegation_depth=1) + cache.put_delegation(Delegation(zone=dns.name.from_text("com."), addresses=["1.2.3.4"]), ttl=3600) + cache.put_delegation(Delegation(zone=EXAMPLE, addresses=["5.6.7.8"]), ttl=3600) + assert cache.get_delegation(dns.name.from_text("com.")) is not None + assert cache.get_delegation(EXAMPLE) is None + + def test_a_cached_delegation_is_isolated_from_its_producer_and_its_readers(self) -> None: + """Delegation holds mutable lists; sharing them across threads is the hazard. + + Answers already go through _isolate. Without the same treatment here, a + caller appending to `delegation.addresses` would silently rewrite the + cached zone cut for every other thread. + """ + cache = DNSCache() + original = Delegation(zone=EXAMPLE, addresses=["1.2.3.4"], ns_names=["ns1.example.com."]) + cache.put_delegation(original, ttl=3600) + + # The producer must not be able to reach back into the stored entry. + original.addresses.append("6.6.6.6") + original.ns_names.append("evil.example.com.") + + first = cache.get_delegation(EXAMPLE) + assert first is not None + assert first.addresses == ["1.2.3.4"] + assert first.ns_names == ["ns1.example.com."] + + # Nor may one reader's mutation be visible to the next, on either list. + first.addresses.append("7.7.7.7") + first.ns_names.append("ns2.example.com.") + second = cache.closest_delegation(dns.name.from_text("www.example.com.")) + assert second is not None + assert second.addresses == ["1.2.3.4"] + assert second.ns_names == ["ns1.example.com."] + + def test_empty_delegation_is_not_returned(self) -> None: + cache = DNSCache() + cache.put_delegation(Delegation(zone=dns.name.from_text("com."), addresses=[]), ttl=3600) + assert cache.closest_delegation(dns.name.from_text("x.com.")) is None + + def test_clear(self) -> None: + cache = DNSCache() + cache.put_answer(EXAMPLE, A, "rrset", ttl=300) + assert len(cache) == 1 + cache.clear() + assert len(cache) == 0 + + +class TestResolverCacheIntegration: + def test_delegation_cache_avoids_the_root_on_the_second_lookup(self) -> None: + """The whole point: a warm cache must not re-query a root server.""" + resolver = RecursiveResolver(dnssec=False, cache_enabled=True, cache_answers=False) + servers_asked: list[str] = [] + + def send(qname, rdtype, nameservers, ctx): + servers_asked.append(nameservers[0]) + if nameservers[0] in resolver._root_addresses: + return root_to_com(), nameservers[0] + if nameservers[0] == "192.5.6.30": + return referral("example.com.", ["ns1.example.com."], {"ns1.example.com.": "1.2.3.4"}), "192.5.6.30" + return make_response(answer=[(str(qname), 300, "A", ["9.9.9.9"])]), "1.2.3.4" + + with patch.object(resolver, "_send_query", side_effect=send): + resolver.resolve("a.example.com", "A") + first = len(servers_asked) + resolver.resolve("b.example.com", "A") + + second_round = servers_asked[first:] + assert not any(s in resolver._root_addresses for s in second_round), "second lookup still hit a root server" + + def test_answer_cache_hit_avoids_all_queries(self) -> None: + resolver = RecursiveResolver(dnssec=False, cache_enabled=True) + calls = 0 + + def send(qname, rdtype, nameservers, ctx): + nonlocal calls + calls += 1 + if calls == 1: + return root_to_com(), "198.41.0.4" + if calls == 2: + return referral("example.com.", ["ns1.example.com."], {"ns1.example.com.": "1.2.3.4"}), "192.5.6.30" + return make_response(answer=[("example.com.", 300, "A", ["9.9.9.9"])]), "1.2.3.4" + + with patch.object(resolver, "_send_query", side_effect=send): + assert resolver.resolve("example.com", "A") == ["9.9.9.9"] + assert calls == 3 + assert resolver.resolve("example.com", "A") == ["9.9.9.9"] + assert calls == 3 + + def test_cache_answers_false_still_refetches(self) -> None: + resolver = RecursiveResolver(dnssec=False, cache_enabled=True, cache_answers=False) + calls = 0 + + def send(qname, rdtype, nameservers, ctx): + nonlocal calls + calls += 1 + if nameservers[0] in resolver._root_addresses: + return root_to_com(), nameservers[0] + if nameservers[0] == "192.5.6.30": + return referral("example.com.", ["ns1.example.com."], {"ns1.example.com.": "1.2.3.4"}), "192.5.6.30" + return make_response(answer=[("example.com.", 300, "A", ["9.9.9.9"])]), "1.2.3.4" + + with patch.object(resolver, "_send_query", side_effect=send): + resolver.resolve("example.com", "A") + before = calls + resolver.resolve("example.com", "A") + assert calls > before, "answers must not be cached when cache_answers=False" + + def test_nxdomain_below_cached_nxdomain_costs_nothing(self) -> None: + resolver = RecursiveResolver(dnssec=False, cache_enabled=True) + resolver.cache.put_nxdomain(dns.name.from_text("gone.example.com."), ttl=300) + with patch.object(resolver, "_send_query") as send, pytest.raises(NXDOMAINError): + resolver.resolve("host.gone.example.com", "A") + send.assert_not_called() + + def test_negative_ttl_comes_from_the_soa(self) -> None: + """RFC 2308 §5: use min(SOA TTL, SOA MINIMUM), not a fixed constant.""" + resolver = RecursiveResolver(dnssec=False, cache_enabled=True) + response = make_response( + authority=[("example.com.", 900, "SOA", ["ns1.example.com. a.example.com. 1 3600 900 604800 120"])], + aa=True, + ) + assert resolver._negative_ttl(response, EXAMPLE) == 120 + + def test_nodata_is_cached(self) -> None: + resolver = RecursiveResolver(dnssec=False, cache_enabled=True) + resolver.cache.put_nodata(EXAMPLE, dns.rdatatype.MX, ttl=300) + with patch.object(resolver, "_send_query") as send, pytest.raises(NoAnswerError): + resolver.resolve("example.com", "MX") + send.assert_not_called() + + +class TestNamedCacheDepths: + """Zone-cut caching depth can be named instead of given as a number.""" + + def test_names_map_to_label_depths(self) -> None: + from recursive_resolver.cache import resolve_cache_depth + + assert resolve_cache_depth("tld") == 1 + assert resolve_cache_depth("all") is None + assert resolve_cache_depth("none") == -1 + + def test_integers_and_none_pass_through(self) -> None: + from recursive_resolver.cache import resolve_cache_depth + + assert resolve_cache_depth(3) == 3 + assert resolve_cache_depth(None) is None + + def test_names_are_case_and_space_insensitive(self) -> None: + from recursive_resolver.cache import resolve_cache_depth + + assert resolve_cache_depth(" TLD ") == 1 + + def test_an_unknown_name_is_rejected(self) -> None: + from recursive_resolver.cache import resolve_cache_depth + + with pytest.raises(ValueError, match="unknown cache depth"): + resolve_cache_depth("sometimes") + + @pytest.mark.parametrize( + ("level", "expected"), + [ + ("none", []), + ("tld", ["com."]), + ("all", ["com.", "co.uk.", "example.com.", "mail.example.com."]), + ], + ) + def test_each_level_keeps_the_right_cuts(self, level: str, expected: list[str]) -> None: + cache = DNSCache(max_delegation_depth=level) + kept = [] + for zone in ("com.", "co.uk.", "example.com.", "mail.example.com."): + name = dns.name.from_text(zone) + cache.put_delegation(Delegation(zone=name, addresses=["9.9.9.9"]), ttl=3600) + if cache.get_delegation(name) is not None: + kept.append(zone) + assert kept == expected + + def test_tld_level_still_skips_the_root_on_a_second_lookup(self) -> None: + """The point of the setting: never query a root server, re-resolve the rest.""" + resolver = RecursiveResolver(dnssec=False, cache_enabled=True, max_delegation_cache_depth="tld") + servers: list[str] = [] + + def send(qname, rdtype, nameservers, ctx): + servers.append(nameservers[0]) + if nameservers[0] in resolver._root_addresses: + return root_to_com(), nameservers[0] + if nameservers[0] == "192.5.6.30": + return referral("example.com.", ["ns1.example.com."], {"ns1.example.com.": "9.9.9.9"}), "192.5.6.30" + return make_response(answer=[(str(qname), 300, "A", ["1.2.3.4"])]), "9.9.9.9" + + with patch.object(resolver, "_send_query", side_effect=send): + resolver.resolve("a.example.com", "A") + first = len(servers) + resolver.resolve("b.example.com", "A") + + second = servers[first:] + assert not any(s in resolver._root_addresses for s in second), "should not re-query a root server" + # The cut below the TLD was not kept, so it is walked again. + assert "192.5.6.30" in second + + def test_the_resolver_accepts_a_level_name(self) -> None: + resolver = RecursiveResolver(dnssec=False, max_delegation_cache_depth="tld") + assert resolver.cache is not None + assert resolver.cache.max_delegation_depth == 1 diff --git a/tests/test_check_authors.py b/tests/test_check_authors.py new file mode 100644 index 0000000..3f1fda4 --- /dev/null +++ b/tests/test_check_authors.py @@ -0,0 +1,160 @@ +"""The CI check that keeps AI tools out of the author fields. + +The hard part is not catching the tools, it is not catching people. Claude, +Jean-Claude, Devin, Gemini and Mistral are all real names, and this project is +French, so a check keyed on given names would reject genuine contributors. +""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "check_authors.py" +_spec = importlib.util.spec_from_file_location("check_authors", SCRIPT) +assert _spec is not None and _spec.loader is not None +check_authors = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(check_authors) + + +class TestPeopleAreNotBlocked: + @pytest.mark.parametrize( + "identity", + [ + "Claude Dubois ", + "Claude Monet ", + "Jean-Claude Vandamme ", + "Claude Shannon ", + "Claude ", + "Devin Smith ", + "Frédéric Mistral ", + "Gemini Rossi ", + "Cursor Jones ", + "Llama Del Rey ", + ], + ) + def test_a_human_name_alone_never_trips_the_check(self, identity: str) -> None: + assert check_authors.MATCHER.search(identity) is None, identity + + def test_an_ordinary_co_author_trailer_passes(self) -> None: + message = "Fix a bug\n\nCo-authored-by: Claude Dubois \n" + assert check_authors.check_message(message, "test") == [] + + +class TestToolsAreBlocked: + @pytest.mark.parametrize( + "identity", + [ + "Claude Opus 5 ", + "Claude ", + "Claude Code ", + "GitHub Copilot ", + "ChatGPT ", + "openai-codex[bot] ", + "Cursor Agent ", + "Devin AI ", + "Gemini CLI ", + "GPT-4o ", + "Aider ", + ], + ) + def test_a_tool_identity_is_caught(self, identity: str) -> None: + assert check_authors.MATCHER.search(identity) is not None, identity + + def test_a_tool_co_author_trailer_is_caught(self) -> None: + message = "Fix a bug\n\nCo-authored-by: Claude Opus 5 \n" + assert check_authors.check_message(message, "commit") != [] + + def test_only_co_author_lines_are_scanned(self) -> None: + """A subject may legitimately mention a tool.""" + assert check_authors.check_message("Add codex support to the parser", "commit") == [] + + +class TestAgainstARealRepository: + @staticmethod + def _repo(tmp_path: Path) -> Path: + run = lambda *a: subprocess.run(["git", "-C", str(tmp_path), *a], check=True, capture_output=True) # noqa: E731 + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True, capture_output=True) + run("config", "user.name", "Real Person") + run("config", "user.email", "real@example.com") + run("commit", "-q", "--allow-empty", "-m", "base") + return tmp_path + + def _check(self, repo: Path, rev_range: str) -> int: + result = subprocess.run([sys.executable, str(SCRIPT), rev_range], cwd=repo, capture_output=True, text=True) + return result.returncode + + def test_a_clean_history_passes(self, tmp_path: Path) -> None: + repo = self._repo(tmp_path) + subprocess.run( + ["git", "-C", str(repo), "commit", "-q", "--allow-empty", "-m", "good change"], + check=True, + capture_output=True, + ) + assert self._check(repo, "HEAD~1..HEAD") == 0 + + def test_a_tool_trailer_fails(self, tmp_path: Path) -> None: + repo = self._repo(tmp_path) + subprocess.run( + [ + "git", + "-C", + str(repo), + "commit", + "-q", + "--allow-empty", + "-m", + "change\n\nCo-authored-by: Claude ", + ], + check=True, + capture_output=True, + ) + assert self._check(repo, "HEAD~1..HEAD") == 1 + + def test_a_tool_author_fails(self, tmp_path: Path) -> None: + repo = self._repo(tmp_path) + subprocess.run( + [ + "git", + "-C", + str(repo), + "-c", + "user.name=Claude Code", + "-c", + "user.email=noreply@anthropic.com", + "commit", + "-q", + "--allow-empty", + "-m", + "change", + ], + check=True, + capture_output=True, + ) + assert self._check(repo, "HEAD~1..HEAD") == 1 + + def test_a_contributor_named_claude_passes(self, tmp_path: Path) -> None: + repo = self._repo(tmp_path) + subprocess.run( + [ + "git", + "-C", + str(repo), + "-c", + "user.name=Claude Dubois", + "-c", + "user.email=claude.dubois@example.fr", + "commit", + "-q", + "--allow-empty", + "-m", + "a genuine change", + ], + check=True, + capture_output=True, + ) + assert self._check(repo, "HEAD~1..HEAD") == 0 diff --git a/tests/test_cli.py b/tests/test_cli.py index 7fa8f47..126d2df 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,224 +1,300 @@ -"""Tests for the CLI interface.""" +"""Tests for the command-line interface.""" from __future__ import annotations import json -from unittest import mock +import os +from unittest.mock import patch import pytest -from recursive_resolver import NXDOMAINError, __version__ +from recursive_resolver import Answer, NXDOMAINError, TraceStep, ValidationState, __version__ from recursive_resolver.cli import main -from recursive_resolver.resolver import TraceStep -class TestCLIBasic: - """Test CLI argument parsing and basic output.""" - - def test_version(self, capsys: pytest.CaptureFixture[str]) -> None: - with pytest.raises(SystemExit, match="0"): +def _answer(records: str = "1.2.3.4", rdtype: str = "A", dnssec=ValidationState.SECURE) -> Answer: + import dns.name + import dns.rdata + import dns.rdataclass + import dns.rdatatype + import dns.rrset + + rdt = dns.rdatatype.from_text(rdtype) + rrset = dns.rrset.RRset(dns.name.from_text("example.com."), dns.rdataclass.IN, rdt) + rrset.add(dns.rdata.from_text(dns.rdataclass.IN, rdt, records)) + rrset.ttl = 300 + return Answer( + qname=dns.name.from_text("example.com."), + canonical_name=dns.name.from_text("example.com."), + rdtype=rdt, + rrset=rrset, + ttl=300, + dnssec=dnssec, + ) + + +class TestBasicInvocation: + def test_plain_output(self, capsys: pytest.CaptureFixture) -> None: + with patch("recursive_resolver.cli.RecursiveResolver") as cls: + cls.return_value.resolve_answer.return_value = _answer() + assert main(["example.com"]) == 0 + assert capsys.readouterr().out.strip() == "1.2.3.4" + + def test_json_output(self, capsys: pytest.CaptureFixture) -> None: + with patch("recursive_resolver.cli.RecursiveResolver") as cls: + cls.return_value.resolve_answer.return_value = _answer() + assert main(["--json", "example.com"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["records"] == ["1.2.3.4"] + assert payload["dnssec"] == "secure" + assert payload["ttl"] == 300 + + def test_record_type_argument(self) -> None: + with patch("recursive_resolver.cli.RecursiveResolver") as cls: + cls.return_value.resolve_answer.return_value = _answer("10 mail.example.com.", "MX") + assert main(["example.com", "MX"]) == 0 + cls.return_value.resolve_answer.assert_called_once_with("example.com", "MX") + + def test_version(self, capsys: pytest.CaptureFixture) -> None: + with pytest.raises(SystemExit) as exc: main(["--version"]) + assert exc.value.code == 0 assert __version__ in capsys.readouterr().out - def test_help(self, capsys: pytest.CaptureFixture[str]) -> None: - with pytest.raises(SystemExit, match="0"): - main(["--help"]) - out = capsys.readouterr().out - assert "recursive-resolver" in out - assert "domain" in out - - def test_no_args(self, capsys: pytest.CaptureFixture[str]) -> None: - with pytest.raises(SystemExit, match="2"): + def test_missing_argument_exits(self) -> None: + with pytest.raises(SystemExit): main([]) - @mock.patch("recursive_resolver.cli.RecursiveResolver") - def test_simple_resolve(self, mock_cls: mock.MagicMock, capsys: pytest.CaptureFixture[str]) -> None: - mock_cls.return_value.resolve.return_value = ["93.184.216.34"] - ret = main(["example.com"]) - assert ret == 0 - assert "93.184.216.34" in capsys.readouterr().out - mock_cls.return_value.resolve.assert_called_once_with("example.com", "A") - - @mock.patch("recursive_resolver.cli.RecursiveResolver") - def test_explicit_rdtype(self, mock_cls: mock.MagicMock, capsys: pytest.CaptureFixture[str]) -> None: - mock_cls.return_value.resolve.return_value = ["10 mail.example.com."] - ret = main(["example.com", "MX"]) - assert ret == 0 - assert "10 mail.example.com." in capsys.readouterr().out - mock_cls.return_value.resolve.assert_called_once_with("example.com", "MX") - - @mock.patch("recursive_resolver.cli.RecursiveResolver") - def test_multiple_results(self, mock_cls: mock.MagicMock, capsys: pytest.CaptureFixture[str]) -> None: - mock_cls.return_value.resolve.return_value = ["1.2.3.4", "5.6.7.8"] - ret = main(["example.com"]) - assert ret == 0 - out = capsys.readouterr().out - assert "1.2.3.4" in out - assert "5.6.7.8" in out - - -class TestCLIJson: - """Test JSON output mode.""" - - @mock.patch("recursive_resolver.cli.RecursiveResolver") - def test_json_output(self, mock_cls: mock.MagicMock, capsys: pytest.CaptureFixture[str]) -> None: - mock_cls.return_value.resolve.return_value = ["93.184.216.34"] - ret = main(["--json", "example.com"]) - assert ret == 0 - data = json.loads(capsys.readouterr().out) - assert data == ["93.184.216.34"] - - @mock.patch("recursive_resolver.cli.RecursiveResolver") - def test_json_error(self, mock_cls: mock.MagicMock, capsys: pytest.CaptureFixture[str]) -> None: - mock_cls.return_value.resolve.side_effect = NXDOMAINError("no.example.com", "A") - ret = main(["--json", "no.example.com"]) - assert ret == 1 - data = json.loads(capsys.readouterr().err) - assert data["error"] == "NXDOMAINError" - assert "message" in data - - -class TestCLITrace: - """Test --trace mode.""" - - @mock.patch("recursive_resolver.cli.RecursiveResolver") - def test_trace_text(self, mock_cls: mock.MagicMock, capsys: pytest.CaptureFixture[str]) -> None: - mock_cls.return_value.resolve_with_trace.return_value = [ - TraceStep( - server="198.41.0.4", - qname="example.com.", - rdtype="A", - response_type="referral", - detail="NS: a.gtld-servers.net.", - rcode=0, - ), - TraceStep( - server="192.5.6.30", - qname="example.com.", - rdtype="A", - response_type="answer", - detail="93.184.216.34", - rcode=0, - ), - ] - ret = main(["--trace", "example.com"]) - assert ret == 0 - out = capsys.readouterr().out - assert "198.41.0.4" in out - assert "referral" in out - assert "answer" in out - - @mock.patch("recursive_resolver.cli.RecursiveResolver") - def test_trace_json(self, mock_cls: mock.MagicMock, capsys: pytest.CaptureFixture[str]) -> None: - mock_cls.return_value.resolve_with_trace.return_value = [ - TraceStep( - server="198.41.0.4", - qname="example.com.", - rdtype="A", - response_type="referral", - detail="NS: a.gtld-servers.net.", - rcode=0, - ), - ] - ret = main(["--trace", "--json", "example.com"]) - assert ret == 0 - data = json.loads(capsys.readouterr().out) - assert isinstance(data, list) - assert data[0]["server"] == "198.41.0.4" - assert data[0]["response_type"] == "referral" - - -class TestCLIErrors: - """Test error handling.""" - - @mock.patch("recursive_resolver.cli.RecursiveResolver") - def test_nxdomain_error(self, mock_cls: mock.MagicMock, capsys: pytest.CaptureFixture[str]) -> None: - mock_cls.return_value.resolve.side_effect = NXDOMAINError("no.example.com", "A") - ret = main(["no.example.com"]) - assert ret == 1 - err = capsys.readouterr().err - assert "NXDOMAINError" in err - - @mock.patch("recursive_resolver.cli.RecursiveResolver") - def test_trace_error(self, mock_cls: mock.MagicMock, capsys: pytest.CaptureFixture[str]) -> None: - mock_cls.return_value.resolve_with_trace.side_effect = NXDOMAINError("no.example.com", "A") - ret = main(["--trace", "no.example.com"]) - assert ret == 1 - err = capsys.readouterr().err - assert "NXDOMAINError" in err - - -class TestCLIOptions: - """Test that CLI options are passed through to the resolver.""" - - @mock.patch("recursive_resolver.cli.RecursiveResolver") - def test_timeout_option(self, mock_cls: mock.MagicMock) -> None: - mock_cls.return_value.resolve.return_value = [] - main(["--timeout", "10", "example.com"]) - mock_cls.assert_called_once_with( - timeout=10.0, - max_depth=20, - cache_enabled=True, - ipv4_only=True, - max_resolution_time=30.0, - ) - - @mock.patch("recursive_resolver.cli.RecursiveResolver") - def test_no_cache_option(self, mock_cls: mock.MagicMock) -> None: - mock_cls.return_value.resolve.return_value = [] - main(["--no-cache", "example.com"]) - mock_cls.assert_called_once_with( - timeout=5.0, - max_depth=20, - cache_enabled=False, - ipv4_only=True, - max_resolution_time=30.0, - ) - - @mock.patch("recursive_resolver.cli.RecursiveResolver") - def test_ipv6_option(self, mock_cls: mock.MagicMock) -> None: - mock_cls.return_value.resolve.return_value = [] - main(["--ipv6", "example.com"]) - mock_cls.assert_called_once_with( - timeout=5.0, - max_depth=20, - cache_enabled=True, - ipv4_only=False, - max_resolution_time=30.0, - ) - - @mock.patch("recursive_resolver.cli.RecursiveResolver") - def test_max_time_option(self, mock_cls: mock.MagicMock) -> None: - mock_cls.return_value.resolve.return_value = [] - main(["--max-time", "15", "example.com"]) - mock_cls.assert_called_once_with( - timeout=5.0, - max_depth=20, - cache_enabled=True, - ipv4_only=True, - max_resolution_time=15.0, - ) - - @mock.patch("recursive_resolver.cli.RecursiveResolver") - def test_max_depth_option(self, mock_cls: mock.MagicMock) -> None: - mock_cls.return_value.resolve.return_value = [] - main(["--max-depth", "5", "example.com"]) - mock_cls.assert_called_once_with( - timeout=5.0, - max_depth=5, - cache_enabled=True, - ipv4_only=True, - max_resolution_time=30.0, - ) - @mock.patch("recursive_resolver.cli.RecursiveResolver") - def test_combined_options(self, mock_cls: mock.MagicMock) -> None: - mock_cls.return_value.resolve.return_value = [] - main(["--timeout", "8", "--max-depth", "10", "--no-cache", "--ipv6", "--max-time", "20", "example.com"]) - mock_cls.assert_called_once_with( - timeout=8.0, - max_depth=10, - cache_enabled=False, - ipv4_only=False, - max_resolution_time=20.0, +class TestErrorHandling: + def test_resolver_error_returns_1(self, capsys: pytest.CaptureFixture) -> None: + with patch("recursive_resolver.cli.RecursiveResolver") as cls: + cls.return_value.resolve_answer.side_effect = NXDOMAINError("nope.com.") + assert main(["nope.com"]) == 1 + assert "NXDOMAINError" in capsys.readouterr().err + + def test_json_error_output(self, capsys: pytest.CaptureFixture) -> None: + with patch("recursive_resolver.cli.RecursiveResolver") as cls: + cls.return_value.resolve_answer.side_effect = NXDOMAINError("nope.com.") + assert main(["--json", "nope.com"]) == 1 + payload = json.loads(capsys.readouterr().err) + assert payload["error"] == "NXDOMAINError" + + def test_unknown_rdtype_is_reported_cleanly(self, capsys: pytest.CaptureFixture) -> None: + """Regression: this used to print a raw dnspython traceback.""" + # --no-dnssec: these three tests build a *real* RecursiveResolver, and + # with the CLI default of dnssec=True the constructor raises + # DNSSECUnavailableError wherever cryptography is missing. main() would + # then return 2 from the constructor handler and the assertion would + # fail for a reason that has nothing to do with what is under test. + assert main(["--no-dnssec", "example.com", "BOGUSTYPE"]) == 1 + assert "UnsupportedRdtypeError" in capsys.readouterr().err + + def test_invalid_name_is_reported_cleanly(self, capsys: pytest.CaptureFixture) -> None: + assert main(["--no-dnssec", "foo..com"]) == 1 + assert "InvalidNameError" in capsys.readouterr().err + + def test_a_closed_pipe_exits_quietly(self) -> None: + """`recursive-resolver example.com | head -1` must not print a traceback.""" + # Only dup2 is patched: os.open and os.close stay real, so the handler + # leaking the devnull descriptor would show up as a leak here too. + with ( + patch("recursive_resolver.cli.RecursiveResolver"), + patch("recursive_resolver.cli._run_query", side_effect=BrokenPipeError), + patch("recursive_resolver.cli.os.dup2") as dup2, + ): + assert main(["--no-dnssec", "example.com"]) == 0 + assert dup2.called, "stdout was not redirected, so the shutdown flush will raise again" + devnull_fd = dup2.call_args.args[0] + with pytest.raises(OSError): + os.fstat(devnull_fd) # already closed: the handler did not leak it + + def test_contradictory_dnssec_flags_are_a_usage_error(self, capsys: pytest.CaptureFixture) -> None: + """Rejected at parse time rather than failing every lookup later.""" + with pytest.raises(SystemExit) as exc: + main(["--no-dnssec", "--require-dnssec", "example.com"]) + assert exc.value.code == 2 + assert "not allowed with" in capsys.readouterr().err + + +class TestTextMode: + def test_text_mode_concatenates_txt_chunks(self, capsys: pytest.CaptureFixture) -> None: + with patch("recursive_resolver.cli.RecursiveResolver") as cls: + cls.return_value.resolve_answer.return_value = _answer('"part-one" "part-two"', "TXT") + assert main(["--text", "example.com", "TXT"]) == 0 + assert capsys.readouterr().out.strip() == "part-onepart-two" + + def test_default_mode_keeps_presentation_format(self, capsys: pytest.CaptureFixture) -> None: + with patch("recursive_resolver.cli.RecursiveResolver") as cls: + cls.return_value.resolve_answer.return_value = _answer('"part-one" "part-two"', "TXT") + assert main(["example.com", "TXT"]) == 0 + assert '" "' in capsys.readouterr().out + + def test_text_mode_on_non_text_type_errors(self, capsys: pytest.CaptureFixture) -> None: + with patch("recursive_resolver.cli.RecursiveResolver") as cls: + cls.return_value.resolve_answer.return_value = _answer() + assert main(["--text", "example.com"]) == 2 + assert "character-string" in capsys.readouterr().err + + +class TestTrace: + def test_trace_output(self, capsys: pytest.CaptureFixture) -> None: + step = TraceStep( + server="198.41.0.4", + qname="example.com.", + rdtype="A", + response_type="referral", + detail="NS: a.gtld-servers.net.", + zone=".", + dnssec="secure", ) + with patch("recursive_resolver.cli.RecursiveResolver") as cls: + cls.return_value.trace_answer.return_value = (_answer(), [step]) + assert main(["--trace", "example.com"]) == 0 + out = capsys.readouterr().out + assert "198.41.0.4" in out and "referral" in out + + def test_trace_json(self, capsys: pytest.CaptureFixture) -> None: + step = TraceStep(server="1.2.3.4", qname="example.com.", rdtype="A", response_type="answer") + with patch("recursive_resolver.cli.RecursiveResolver") as cls: + cls.return_value.trace_answer.return_value = (_answer(), [step]) + assert main(["--trace", "--json", "example.com"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["trace"][0]["server"] == "1.2.3.4" + assert payload["records"] == ["1.2.3.4"] + + def test_trace_failure_returns_1(self) -> None: + with patch("recursive_resolver.cli.RecursiveResolver") as cls: + cls.return_value.trace_answer.return_value = (None, []) + assert main(["--trace", "nope.com"]) == 1 + + def test_trace_json_failure_says_so_in_the_payload(self, capsys: pytest.CaptureFixture) -> None: + """A failed trace must not emit a success-shaped payload full of nulls.""" + with patch("recursive_resolver.cli.RecursiveResolver") as cls: + cls.return_value.trace_answer.return_value = (None, []) + assert main(["--trace", "--json", "nope.com"]) == 1 + payload = json.loads(capsys.readouterr().out) + assert payload["error"] == "ResolutionIncomplete" + assert "no response" in payload["message"] + assert payload["records"] is None and payload["dnssec"] is None + + def test_trace_honours_text_mode(self, capsys: pytest.CaptureFixture) -> None: + """--text must mean the same thing with --trace as without it.""" + step = TraceStep(server="1.2.3.4", qname="example.com.", rdtype="A", response_type="answer") + with patch("recursive_resolver.cli.RecursiveResolver") as cls: + cls.return_value.trace_answer.return_value = (_answer('"chunk1" "chunk2"', "TXT"), [step]) + assert main(["--trace", "--text", "example.com", "TXT"]) == 0 + out = capsys.readouterr().out + assert "chunk1chunk2" in out + assert '" "' not in out + + def test_trace_text_mode_on_a_non_text_type_errors(self, capsys: pytest.CaptureFixture) -> None: + step = TraceStep(server="1.2.3.4", qname="example.com.", rdtype="A", response_type="answer") + with patch("recursive_resolver.cli.RecursiveResolver") as cls: + cls.return_value.trace_answer.return_value = (_answer(), [step]) + assert main(["--trace", "--text", "example.com"]) == 2 + assert "character-string" in capsys.readouterr().err + + +class TestOptionPlumbing: + def test_security_options_reach_the_resolver(self) -> None: + with patch("recursive_resolver.cli.RecursiveResolver") as cls: + cls.return_value.resolve_answer.return_value = _answer() + main( + [ + "--no-dnssec", + "--allow-private", + "--no-cache", + "--cache-depth", + "tld", + "--edns-payload", + "512", + "example.com", + ] + ) + kwargs = cls.call_args.kwargs + assert kwargs["dnssec"] is False + assert kwargs["allow_private_addresses"] is True + assert kwargs["cache_enabled"] is False + assert kwargs["max_delegation_cache_depth"] == "tld" + assert kwargs["edns_payload"] == 512 + + def test_defaults_are_secure(self) -> None: + with patch("recursive_resolver.cli.RecursiveResolver") as cls: + cls.return_value.resolve_answer.return_value = _answer() + main(["example.com"]) + kwargs = cls.call_args.kwargs + assert kwargs["dnssec"] is True + assert kwargs["allow_private_addresses"] is False + assert kwargs["cache_enabled"] is True + + def test_cache_depth_accepts_a_plain_number_too(self) -> None: + with patch("recursive_resolver.cli.RecursiveResolver") as cls: + cls.return_value.resolve_answer.return_value = _answer() + main(["--cache-depth", "2", "example.com"]) + assert cls.call_args.kwargs["max_delegation_cache_depth"] == "2" + + def test_an_unknown_cache_depth_is_reported_cleanly(self, capsys: pytest.CaptureFixture) -> None: + # --no-dnssec for the same reason as above: the DNSSEC availability + # check runs before cache construction, so without it the message on + # stderr would be DNSSECUnavailableError, not the cache-depth one. + assert main(["--no-dnssec", "--cache-depth", "sometimes", "example.com"]) == 2 + assert "unknown cache depth" in capsys.readouterr().err + + +class TestDNSSECVerdictOnStderr: + """Plain output must never look validated when it is not. + + The verdict goes to stderr so that stdout stays a clean list of values; + the wording matches delv, BIND's validating lookup utility. + """ + + def _run(self, argv: list[str], answer: Answer, capsys: pytest.CaptureFixture) -> tuple[str, str]: + with patch("recursive_resolver.cli.RecursiveResolver") as cls: + cls.return_value.resolve_answer.return_value = answer + assert main(argv) == 0 + captured = capsys.readouterr() + return captured.out, captured.err + + @pytest.mark.parametrize( + ("state", "note"), + [ + (ValidationState.SECURE, "; fully validated"), + (ValidationState.INSECURE, "; unsigned answer"), + (ValidationState.BOGUS, "; validation failed"), + ], + ) + def test_each_state_is_reported(self, state: ValidationState, note: str, capsys: pytest.CaptureFixture) -> None: + out, err = self._run(["example.com"], _answer(dnssec=state), capsys) + assert err.strip() == note + assert out.strip() == "1.2.3.4" + + def test_stdout_carries_only_values(self, capsys: pytest.CaptureFixture) -> None: + """Piping the output must not require filtering out a comment line.""" + out, _ = self._run(["example.com"], _answer(), capsys) + assert out == "1.2.3.4\n" + + def test_no_dnssec_says_so_rather_than_claiming_unsigned(self, capsys: pytest.CaptureFixture) -> None: + """--no-dnssec leaves the default INSECURE state, which is not a proof.""" + _, err = self._run(["--no-dnssec", "example.com"], _answer(dnssec=ValidationState.INSECURE), capsys) + assert err.strip() == "; dnssec validation disabled" + + def test_json_output_has_no_note(self, capsys: pytest.CaptureFixture) -> None: + out, err = self._run(["--json", "example.com"], _answer(), capsys) + assert err == "" + assert json.loads(out)["dnssec"] == "secure" + + def test_text_mode_still_reports(self, capsys: pytest.CaptureFixture) -> None: + out, err = self._run(["--text", "example.com", "TXT"], _answer('"v=spf1 -all"', "TXT"), capsys) + assert err.strip() == "; fully validated" + assert out.strip() == "v=spf1 -all" + + def test_trace_mode_reports_before_the_records(self, capsys: pytest.CaptureFixture) -> None: + answer = _answer() + step = TraceStep(server="1.2.3.4", qname="example.com.", rdtype="A", response_type="answer") + with patch("recursive_resolver.cli.RecursiveResolver") as cls: + cls.return_value.trace_answer.return_value = (answer, [step]) + assert main(["--trace", "example.com"]) == 0 + captured = capsys.readouterr() + assert captured.err.strip() == "; fully validated" + assert captured.out.strip().endswith("1.2.3.4") diff --git a/tests/test_csv.py b/tests/test_csv.py index 6e86cbf..e1af789 100644 --- a/tests/test_csv.py +++ b/tests/test_csv.py @@ -1,4 +1,25 @@ -"""Bulk CSV testing: compare our resolver results against dig.""" +"""Bulk differential testing: compare this resolver against dig on real domains. + +Which record types are comparable +--------------------------------- +Any type can be *asked* of an apex domain, and a type the domain does not +publish yields NODATA on both sides, still a meaningful agreement. So the +default set covers every type the resolver supports that is answerable from a +plain list of domain names: + + A, AAAA, MX, TXT, NS, SOA, CAA, CNAME, DS, DNSKEY + +Three supported types are excluded from the default because a list of apex +domains cannot exercise them, not because they are untested (they have unit and +integration coverage of their own): + +``PTR`` + Needs an IP address or an ``in-addr.arpa``/``ip6.arpa`` name, not a domain. +``SRV`` and ``NAPTR`` + Live under ``_service._proto`` labels that do not exist at an apex. + +Point ``--types`` at any subset, or supply a purpose-built CSV to cover those. +""" from __future__ import annotations @@ -53,7 +74,7 @@ def _normalize_txt(records: set[str]) -> set[str]: for q in quoted: out.add(q.lower()) else: - # Bare value (no quotes) — keep as-is + # Bare value (no quotes): keep as-is out.add(record.lower()) return out @@ -113,7 +134,10 @@ def test_csv_bulk(csv_path: str | None, request: pytest.FixtureRequest) -> None: rdtypes = [t.strip().upper() for t in request.config.getoption("--types").split(",")] sample_size = request.config.getoption("--sample") - resolver = RecursiveResolver(timeout=5.0, ipv4_only=True) + # DNSSEC is off for bulk comparison: `dig +short` does not validate + # either, so enabling it would compare two different things (and reject + # the many real domains with broken DNSSEC). + resolver = RecursiveResolver(timeout=3.0, ipv4_only=True, dnssec=False) with open(csv_path) as f: reader = csv.DictReader(f) @@ -175,7 +199,7 @@ def run_one(domain: str, rdtype: str) -> None: futures.append(pool.submit(run_one, domain, rdtype)) concurrent.futures.wait(futures) - # Classify results — overall and per-type + # Classify results: overall and per-type counts: dict[str, int] = {} per_type: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) failures: list[dict] = [] @@ -201,7 +225,7 @@ def run_one(domain: str, rdtype: str) -> None: type_total = sum(tc.values()) type_matches = tc.get("exact", 0) + tc.get("overlap", 0) + tc.get("both_empty", 0) + tc.get("dig_empty", 0) type_rate = type_matches / type_total if type_total else 0 - print(f" {rdtype:6s}: {type_matches}/{type_total} ({type_rate:.1%}) — {dict(tc)}") + print(f" {rdtype:6s}: {type_matches}/{type_total} ({type_rate:.1%}): {dict(tc)}") if failures: print(f"\nTotal failures: {len(failures)}") diff --git a/tests/test_dnssec.py b/tests/test_dnssec.py new file mode 100644 index 0000000..2c61768 --- /dev/null +++ b/tests/test_dnssec.py @@ -0,0 +1,930 @@ +"""Unit tests for the DNSSEC validator (no network).""" + +from __future__ import annotations + +import base64 +from unittest.mock import patch + +import dns.dnssec +import dns.name +import dns.rdata +import dns.rdataclass +import dns.rdataset +import dns.rdatatype +import dns.rrset +import pytest +from dns.rdtypes.ANY.NSEC3 import b32_normal_to_hex + +from recursive_resolver import ValidationState +from recursive_resolver.dnssec import ( + MAX_NSEC3_ITERATIONS, + NSEC3_OPT_OUT, + DNSSECValidator, + _types_in_bitmap, + cryptography_available, + find_rrsig, +) +from recursive_resolver.roots import ROOT_TRUST_ANCHORS + + +def rrset(name: str, rdtype: str, *rdatas: str, ttl: int = 300) -> dns.rrset.RRset: + rdt = dns.rdatatype.from_text(rdtype) + out = dns.rrset.RRset(dns.name.from_text(name), dns.rdataclass.IN, rdt) + for rd in rdatas: + out.add(dns.rdata.from_text(dns.rdataclass.IN, rdt, rd)) + out.ttl = ttl + return out + + +class TestTrustAnchors: + def test_only_current_anchors_are_shipped(self) -> None: + """KSK-2010 (tag 19036) was retired in 2019 and must not be trusted.""" + tags = {int(a.split()[0]) for a in ROOT_TRUST_ANCHORS} + assert tags == {20326, 38696} + + def test_anchors_parse_as_ds_records(self) -> None: + for anchor in ROOT_TRUST_ANCHORS: + rd = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DS, anchor) + assert rd.algorithm == 8 + assert rd.digest_type == 2 + + def test_validator_loads_anchors(self) -> None: + validator = DNSSECValidator() + assert len(validator._root_ds) == 2 + + def test_an_empty_anchor_set_is_rejected(self) -> None: + """() is a caller error, not a synonym for "use the IANA defaults".""" + with pytest.raises(ValueError, match="must not be empty"): + DNSSECValidator(trust_anchors=()) + + +class TestTypeBitmap: + def test_decodes_nsec_bitmap(self) -> None: + nsec = rrset("example.com.", "NSEC", "next.example.com. A MX RRSIG NSEC") + types = _types_in_bitmap(nsec[0]) + assert dns.rdatatype.A in types + assert dns.rdatatype.MX in types + assert dns.rdatatype.NSEC in types + assert dns.rdatatype.AAAA not in types + + def test_decodes_high_window_types(self) -> None: + nsec = rrset("example.com.", "NSEC", "next.example.com. A TYPE300") + types = _types_in_bitmap(nsec[0]) + assert 300 in types + + +class TestNSECCovering: + def test_covers_name_between_owner_and_next(self) -> None: + v = DNSSECValidator() + owner = dns.name.from_text("a.example.com.") + nxt = dns.name.from_text("c.example.com.") + assert v._nsec_covers(owner, nxt, dns.name.from_text("b.example.com.")) + + def test_does_not_cover_outside_the_interval(self) -> None: + v = DNSSECValidator() + owner = dns.name.from_text("a.example.com.") + nxt = dns.name.from_text("c.example.com.") + assert not v._nsec_covers(owner, nxt, dns.name.from_text("z.example.com.")) + + def test_does_not_cover_the_owner_itself(self) -> None: + v = DNSSECValidator() + owner = dns.name.from_text("a.example.com.") + nxt = dns.name.from_text("c.example.com.") + assert not v._nsec_covers(owner, nxt, owner) + + def test_wraparound_at_the_end_of_the_zone(self) -> None: + """The last NSEC wraps back to the apex.""" + v = DNSSECValidator() + owner = dns.name.from_text("z.example.com.") + nxt = dns.name.from_text("example.com.") + assert v._nsec_covers(owner, nxt, dns.name.from_text("zz.example.com.")) + + +class TestDenialOfExistence: + def test_nodata_proven_by_matching_nsec(self) -> None: + v = DNSSECValidator() + nsec = rrset("example.com.", "NSEC", "next.example.com. A RRSIG NSEC") + # Bypass signature checking by stubbing the validated-NSEC extractor. + v._validated_nsec_rrsets = lambda authority, keys, rdtype, budget=None: ( # type: ignore[method-assign] + [nsec] if rdtype == dns.rdatatype.NSEC else [] + ) + assert v.prove_nodata(dns.name.from_text("example.com."), dns.rdatatype.MX, [], {}) + + def test_nodata_not_proven_when_the_type_is_present(self) -> None: + v = DNSSECValidator() + nsec = rrset("example.com.", "NSEC", "next.example.com. A MX RRSIG NSEC") + v._validated_nsec_rrsets = lambda authority, keys, rdtype, budget=None: ( # type: ignore[method-assign] + [nsec] if rdtype == dns.rdatatype.NSEC else [] + ) + assert not v.prove_nodata(dns.name.from_text("example.com."), dns.rdatatype.MX, [], {}) + + def test_nodata_not_proven_when_a_cname_exists(self) -> None: + """A CNAME at the name means the answer should have been a CNAME.""" + v = DNSSECValidator() + nsec = rrset("example.com.", "NSEC", "next.example.com. CNAME RRSIG NSEC") + v._validated_nsec_rrsets = lambda authority, keys, rdtype, budget=None: ( # type: ignore[method-assign] + [nsec] if rdtype == dns.rdatatype.NSEC else [] + ) + assert not v.prove_nodata(dns.name.from_text("example.com."), dns.rdatatype.MX, [], {}) + + def test_no_ds_proven_by_nsec_without_the_ds_bit(self) -> None: + v = DNSSECValidator() + nsec = rrset("child.example.com.", "NSEC", "next.example.com. NS RRSIG NSEC") + v._validated_nsec_rrsets = lambda authority, keys, rdtype, budget=None: ( # type: ignore[method-assign] + [nsec] if rdtype == dns.rdatatype.NSEC else [] + ) + assert v.prove_no_ds(dns.name.from_text("child.example.com."), [], {}) + + def test_no_ds_rejected_when_the_ds_bit_is_set(self) -> None: + """A DS bit means the zone IS signed; claiming otherwise is a downgrade.""" + v = DNSSECValidator() + nsec = rrset("child.example.com.", "NSEC", "next.example.com. NS DS RRSIG NSEC") + v._validated_nsec_rrsets = lambda authority, keys, rdtype, budget=None: ( # type: ignore[method-assign] + [nsec] if rdtype == dns.rdatatype.NSEC else [] + ) + assert not v.prove_no_ds(dns.name.from_text("child.example.com."), [], {}) + + def test_nxdomain_needs_a_wildcard_denial_too(self) -> None: + """Covering the name alone is not enough; a wildcard could synthesise it.""" + v = DNSSECValidator() + covering = rrset("a.example.com.", "NSEC", "c.example.com. A RRSIG NSEC") + v._validated_nsec_rrsets = lambda authority, keys, rdtype, budget=None: ( # type: ignore[method-assign] + [covering] if rdtype == dns.rdatatype.NSEC else [] + ) + # b.example.com is covered, but *.example.com is not denied. + assert not v.prove_nxdomain(dns.name.from_text("b.example.com."), [], {}) + + def test_nxdomain_proven_with_both_denials(self) -> None: + v = DNSSECValidator() + covering = rrset("a.example.com.", "NSEC", "c.example.com. A RRSIG NSEC") + wildcard = rrset("!.example.com.", "NSEC", "+.example.com. A RRSIG NSEC") + v._validated_nsec_rrsets = lambda authority, keys, rdtype, budget=None: ( # type: ignore[method-assign] + [covering, wildcard] if rdtype == dns.rdatatype.NSEC else [] + ) + assert v.prove_nxdomain(dns.name.from_text("b.example.com."), [], {}) + + def test_unsigned_authority_proves_nothing(self) -> None: + v = DNSSECValidator() + assert not v.prove_nxdomain(dns.name.from_text("b.example.com."), [], {}) + assert not v.prove_nodata(dns.name.from_text("b.example.com."), dns.rdatatype.A, [], {}) + assert not v.prove_no_ds(dns.name.from_text("b.example.com."), [], {}) + + +class TestNSEC3: + @staticmethod + def _nsec3(zone: str, owner_hash: str, next_hash_b32: str, types: str, flags: int = 0, iterations: int = 10): + return rrset(f"{owner_hash}.{zone}", "NSEC3", f"1 {flags} {iterations} AABBCCDD {next_hash_b32} {types}") + + def test_iteration_cap_rejects_absurd_values(self) -> None: + v = DNSSECValidator() + nsec3 = self._nsec3("example.com.", "AAAA", "ZZZZ", "NS", iterations=MAX_NSEC3_ITERATIONS + 1) + v._validated_nsec_rrsets = lambda authority, keys, rdtype, budget=None: ( # type: ignore[method-assign] + [nsec3] if rdtype == dns.rdatatype.NSEC3 else [] + ) + assert not v.prove_no_ds(dns.name.from_text("child.example.com."), [], {}) + + def test_opt_out_flag_is_recognised(self) -> None: + nsec3 = self._nsec3("example.com.", "AAAA", "ZZZZ", "NS", flags=NSEC3_OPT_OUT) + assert nsec3[0].flags & NSEC3_OPT_OUT + + def test_next_hash_round_trips_through_base32hex(self) -> None: + """The decoder must match dnspython's own presentation encoding.""" + nsec3 = self._nsec3("example.com.", "AAAA", "2VPTU5TIMAMQTTGL4LUU9KG21E0AOR3S", "A RRSIG") + params = nsec3[0] + encoded = base64.b32encode(params.next).translate(b32_normal_to_hex).decode("ascii") + assert encoded.lower() == params.to_text().split()[4].lower() + + +class TestFindRRSIG: + def test_finds_the_covering_rrsig(self) -> None: + sig = rrset( + "example.com.", + "RRSIG", + "A 8 2 300 20990101000000 20200101000000 12345 example.com. AAAA", + ) + assert find_rrsig([sig], dns.name.from_text("example.com."), dns.rdatatype.A) is sig + + def test_ignores_rrsigs_covering_other_types(self) -> None: + sig = rrset( + "example.com.", + "RRSIG", + "MX 8 2 300 20990101000000 20200101000000 12345 example.com. AAAA", + ) + assert find_rrsig([sig], dns.name.from_text("example.com."), dns.rdatatype.A) is None + + def test_ignores_rrsigs_at_other_names(self) -> None: + sig = rrset( + "other.com.", + "RRSIG", + "A 8 2 300 20990101000000 20200101000000 12345 other.com. AAAA", + ) + assert find_rrsig([sig], dns.name.from_text("example.com."), dns.rdatatype.A) is None + + +class TestValidatorBasics: + def test_missing_rrsig_never_validates(self) -> None: + v = DNSSECValidator() + assert v.validate_rrset(rrset("example.com.", "A", "1.2.3.4"), None, {}) is False + + def test_garbage_signature_is_rejected(self) -> None: + v = DNSSECValidator() + data = rrset("example.com.", "A", "1.2.3.4") + sig = rrset( + "example.com.", + "RRSIG", + "A 8 2 300 20990101000000 20200101000000 12345 example.com. AAAA", + ) + assert v.validate_rrset(data, sig, {dns.name.from_text("example.com."): data}) is False + + def test_validation_states(self) -> None: + assert {s.value for s in ValidationState} == {"secure", "insecure", "bogus"} + + def test_cryptography_is_available_in_the_test_env(self) -> None: + assert cryptography_available() is True + + +class TestDSValidation: + def test_missing_ds_without_proof_is_bogus(self) -> None: + v = DNSSECValidator() + state, ds = v.validate_ds(dns.name.from_text("child.example.com."), [], {}) + assert state is ValidationState.BOGUS + assert ds is None + + def test_unverifiable_ds_is_bogus(self) -> None: + v = DNSSECValidator() + ds = rrset("child.example.com.", "DS", "12345 8 2 " + "AB" * 32) + state, out = v.validate_ds(dns.name.from_text("child.example.com."), [ds], {}) + assert state is ValidationState.BOGUS + assert out is None + + +@pytest.mark.skipif(not cryptography_available(), reason="cryptography not installed") +class TestDNSKEYValidation: + def test_dnskey_not_matching_any_ds_is_rejected(self) -> None: + v = DNSSECValidator() + # A syntactically valid but unrelated key. + key = rrset( + "example.com.", + "DNSKEY", + "257 3 8 " + "AwEAAaz/tAm8yTn4Mfeh5eyI96WSVexTBAvkMgJzkKTOiW1vkIbzxeF3+/4RgWOq7HrxRixHlFlExOLAJr5emLvN7S" + "WXgnLh4+B5xQlNVz8Og8kvArMtNROxVQuCaSnIDdD5LKyWbRd2n9WGe2R8PzgCmr3EgVLrjyBxWezF0jLHwVN8efS3" + "rCj/EWgvIWgb9tarpVUDK/b58Da+sqqls3eNbuv7pr+eoZG+SrDK6nWeL3c6H5Apxz7LjVc1uTIdsIXxuOLYA4/ilB" + "mSVIzuDWfdRUfhHdY6+cn8HFRm+2hM8AnXGXws9555KrUB5qihylGa8subX2Nn6UwNR1AkUTV74bU=", + ) + ds_set = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.DS) + ds_set.add(dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DS, "65000 8 2 " + "AB" * 32), ttl=300) + assert v.validate_dnskey(dns.name.from_text("example.com."), key, None, ds_set) is ValidationState.BOGUS + + +class TestKeyTrapHardening: + """CVE-2023-50387 / CVE-2023-50868: bound the crypto work one answer can cost.""" + + @staticmethod + def _dnskey_rrset(count: int) -> dns.rrset.RRset: + """A DNSKEY RRset with `count` distinct keys (many sharing a tag in practice).""" + out = dns.rrset.RRset(dns.name.from_text("evil.test."), dns.rdataclass.IN, dns.rdatatype.DNSKEY) + base = ( + "AwEAAaz/tAm8yTn4Mfeh5eyI96WSVexTBAvkMgJzkKTOiW1vkIbzxeF3+/4RgWOq7HrxRixHlFlExOLAJr5emLvN" + "7SWXgnLh4+B5xQlNVz8Og8kvArMtNROxVQuCaSnIDdD5LKyWbRd2n9WGe2R8PzgCmr3EgVLrjyBxWezF0jLHwVN8" + "efS3rCj/EWgvIWgb9tarpVUDK/b58Da+sqqls3eNbuv7pr+eoZG+SrDK6nWeL3c6H5Apxz7LjVc1uTIdsIXxuOLY" + "A4/ilBmSVIzuDWfdRUfhHdY6+cn8HFRm+2hM8AnXGXws9555KrUB5qihylGa8subX2Nn6UwNR1AkUTV74bU=" + ) + for i in range(count): + flags = 256 if i % 2 else 257 + out.add(dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DNSKEY, f"{flags} 3 8 {base}")) + out.ttl = 300 + return out + + def test_keyring_is_trimmed_per_tag(self) -> None: + """However many colliding keys a zone publishes, only a few are tried.""" + from recursive_resolver.dnssec import MAX_KEYS_PER_TAG, _trim_keyring + + keys = self._dnskey_rrset(40) + trimmed = _trim_keyring({dns.name.from_text("evil.test."): keys}) + counts: dict[tuple[int, int], int] = {} + for key in next(iter(trimmed.values())): + slot = (dns.dnssec.key_id(key), int(key.algorithm)) + counts[slot] = counts.get(slot, 0) + 1 + assert counts, "trimming must not drop every key" + assert max(counts.values()) <= MAX_KEYS_PER_TAG + + def test_rrsigs_are_trimmed(self) -> None: + from recursive_resolver.dnssec import MAX_RRSIGS_PER_RRSET, MAX_RRSIGS_PER_TAG, _trim_rrsigs + + sigs = dns.rrset.RRset(dns.name.from_text("evil.test."), dns.rdataclass.IN, dns.rdatatype.RRSIG) + for tag in range(40): + for _ in range(3): + sigs.add( + dns.rdata.from_text( + dns.rdataclass.IN, + dns.rdatatype.RRSIG, + f"A 8 2 300 20990101000000 20200101000000 {tag} evil.test. AAAA", + ) + ) + kept = _trim_rrsigs(sigs) + assert len(kept) <= MAX_RRSIGS_PER_RRSET + per_tag: dict[tuple[int, int], int] = {} + for sig in kept: + slot = (int(sig.key_tag), int(sig.algorithm)) + per_tag[slot] = per_tag.get(slot, 0) + 1 + assert max(per_tag.values()) <= MAX_RRSIGS_PER_TAG + + def test_signature_validations_are_charged_to_the_budget(self) -> None: + from recursive_resolver.budget import QueryBudget + from recursive_resolver.dnssec import MAX_RRSIGS_PER_RRSET + + v = DNSSECValidator() + data = rrset("evil.test.", "A", "1.2.3.4") + sigs = dns.rrset.RRset(dns.name.from_text("evil.test."), dns.rdataclass.IN, dns.rdatatype.RRSIG) + for tag in range(50): + sigs.add( + dns.rdata.from_text( + dns.rdataclass.IN, + dns.rdatatype.RRSIG, + f"A 8 2 300 20990101000000 20200101000000 {tag} evil.test. AAAA", + ) + ) + budget = QueryBudget() + keys = {dns.name.from_text("evil.test."): self._dnskey_rrset(40)} + assert v.validate_rrset(data, sigs, keys, budget=budget) is False + assert 0 < budget.signature_validations <= MAX_RRSIGS_PER_RRSET + + def test_budget_exhaustion_raises(self) -> None: + from recursive_resolver.budget import QueryBudget + from recursive_resolver.exceptions import QueryBudgetExceededError + + budget = QueryBudget(max_signature_validations=3) + for _ in range(3): + budget.spend_signature_validation() + with pytest.raises(QueryBudgetExceededError): + budget.spend_signature_validation() + + def test_nsec3_hashes_are_charged_to_the_budget(self) -> None: + from recursive_resolver.budget import QueryBudget + from recursive_resolver.exceptions import QueryBudgetExceededError + + budget = QueryBudget(max_nsec3_hashes=2) + v = DNSSECValidator() + owner = dns.name.from_text("AAAA.example.com.") + nsec3 = rrset("AAAA.example.com.", "NSEC3", "1 0 10 AABBCCDD 2VPTU5TIMAMQTTGL4LUU9KG21E0AOR3S A") + for _ in range(2): + v._nsec3_owner(dns.name.from_text("x.example.com."), owner, nsec3[0], budget) + with pytest.raises(QueryBudgetExceededError): + v._nsec3_owner(dns.name.from_text("y.example.com."), owner, nsec3[0], budget) + + def test_nsec3_iteration_cap_matches_rfc9276_practice(self) -> None: + from recursive_resolver.dnssec import MAX_NSEC3_ITERATIONS + + assert MAX_NSEC3_ITERATIONS <= 100 + + def test_ds_cross_product_is_bounded(self) -> None: + """A zone publishing hundreds of DS records cannot force unbounded digests.""" + from recursive_resolver.dnssec import MAX_DS_PER_ZONE + + v = DNSSECValidator() + ds_set = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.DS) + for tag in range(500): + ds_set.add(dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DS, f"{tag} 8 2 " + "AB" * 32), ttl=300) + + digests = 0 + real_make_ds = dns.dnssec.make_ds + + def counting(*args, **kwargs): + nonlocal digests + digests += 1 + return real_make_ds(*args, **kwargs) + + keys = self._dnskey_rrset(2) + try: + dns.dnssec.make_ds = counting + v.validate_dnskey(dns.name.from_text("evil.test."), keys, None, ds_set) + finally: + dns.dnssec.make_ds = real_make_ds + assert digests <= len(keys) * MAX_DS_PER_ZONE + + +def _nsec3(zone: str, owner_hash: str, next_b32: str, types: str, flags: int = 0, iterations: int = 10): + return rrset(f"{owner_hash}.{zone}", "NSEC3", f"1 {flags} {iterations} AABBCCDD {next_b32} {types}") + + +B32HEX = "0123456789ABCDEFGHIJKLMNOPQRSTUV" + + +def _successor(digest: str) -> str: + """The immediately following base32hex hash of the same length.""" + chars = list(digest) + for i in range(len(chars) - 1, -1, -1): + idx = B32HEX.index(chars[i]) + if idx + 1 < len(B32HEX): + chars[i] = B32HEX[idx + 1] + return "".join(chars) + chars[i] = B32HEX[0] + return "".join(chars) + + +def _stub_nsec(v, nsecs=(), nsec3s=()): + """Bypass signature checking so denial logic can be tested in isolation.""" + v._validated_nsec_rrsets = lambda authority, keys, rdtype, budget=None: ( # type: ignore[method-assign] + list(nsecs) if rdtype == dns.rdatatype.NSEC else list(nsec3s) + ) + + +class TestValidatorEdgeCases: + def test_cryptography_unavailable_is_reported(self) -> None: + import builtins + + from recursive_resolver.dnssec import cryptography_available + + real_import = builtins.__import__ + + def blocked(name, *args, **kwargs): + if name == "cryptography": + raise ImportError("blocked") + return real_import(name, *args, **kwargs) + + with patch.object(builtins, "__import__", blocked): + assert cryptography_available() is False + + def test_trim_keyring_skips_empty_keysets(self) -> None: + from recursive_resolver.dnssec import _trim_keyring + + assert _trim_keyring({dns.name.from_text("a.test."): None}) == {} + + def test_trim_keyring_skips_unhashable_keys(self) -> None: + from recursive_resolver.dnssec import _trim_keyring + + keys = TestKeyTrapHardening._dnskey_rrset(2) + with patch("dns.dnssec.key_id", side_effect=ValueError("bad key")): + trimmed = _trim_keyring({dns.name.from_text("evil.test."): keys}) + assert len(next(iter(trimmed.values()))) == 0 + + def test_validate_rrset_with_no_usable_keys(self) -> None: + v = DNSSECValidator() + data = rrset("example.com.", "A", "1.2.3.4") + sig = rrset("example.com.", "RRSIG", "A 8 2 300 20990101000000 20200101000000 12345 example.com. AAAA") + assert v.validate_rrset(data, sig, {}) is False + + def test_dnskey_without_the_zone_flag_is_ignored(self) -> None: + v = DNSSECValidator() + keys = dns.rrset.RRset(dns.name.from_text("evil.test."), dns.rdataclass.IN, dns.rdatatype.DNSKEY) + # flags=0 means "not a zone key" + keys.add(dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DNSKEY, "0 3 8 AwEAAaz/tAm8yTn4Mfeh5eyI96WS")) + keys.ttl = 300 + ds = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.DS) + ds.add(dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DS, "12345 8 2 " + "AB" * 32), ttl=300) + assert v.validate_dnskey(dns.name.from_text("evil.test."), keys, None, ds) is ValidationState.BOGUS + + def test_a_ds_we_cannot_digest_leaves_the_zone_insecure(self) -> None: + """RFC 4035 §5.2: a child we have no way to check is unsigned, not forged. + + Reporting BOGUS here would reject a legitimately signed zone outright + just because this build lacks its digest algorithm. + """ + v = DNSSECValidator() + keys = TestKeyTrapHardening._dnskey_rrset(1) + ds = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.DS) + ds.add(dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DS, "12345 8 2 " + "AB" * 32), ttl=300) + with patch("dns.dnssec.make_ds", side_effect=ValueError("unsupported digest")): + assert v.validate_dnskey(dns.name.from_text("evil.test."), keys, None, ds) is ValidationState.INSECURE + + def test_single_nsec_zone_covers_everything_but_its_owner(self) -> None: + v = DNSSECValidator() + owner = dns.name.from_text("example.com.") + assert v._nsec_covers(owner, owner, dns.name.from_text("x.example.com.")) is True + assert v._nsec_covers(owner, owner, owner) is False + + def test_nsec3_hash_failure_is_handled(self) -> None: + v = DNSSECValidator() + n3 = _nsec3("example.com.", "AAAA", "ZZZZ", "NS") + with patch("dns.dnssec.nsec3_hash", side_effect=ValueError("bad salt")): + assert v._nsec3_owner(dns.name.from_text("x.example.com."), n3.name, n3[0]) is None + assert v._nsec3_covers(dns.name.from_text("x.example.com."), n3, n3[0]) is False + + def test_undecodable_next_hash_is_handled(self) -> None: + v = DNSSECValidator() + n3 = _nsec3("example.com.", "AAAA", "ZZZZ", "NS") + with patch("base64.b32encode", side_effect=TypeError("bad bytes")): + assert v._nsec3_covers(dns.name.from_text("x.example.com."), n3, n3[0]) is False + + +class TestNoDSProofBranches: + def test_nsec3_matching_record_without_ds_bit(self) -> None: + v = DNSSECValidator() + child = dns.name.from_text("child.example.com.") + digest = dns.dnssec.nsec3_hash(child, "AABBCCDD", 10, 1) + n3 = _nsec3("example.com.", digest, "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", "NS RRSIG") + _stub_nsec(v, nsec3s=[n3]) + assert v.prove_no_ds(child, [], {}) is True + + def test_nsec3_matching_record_with_ds_bit_is_rejected(self) -> None: + v = DNSSECValidator() + child = dns.name.from_text("child.example.com.") + digest = dns.dnssec.nsec3_hash(child, "AABBCCDD", 10, 1) + n3 = _nsec3("example.com.", digest, "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", "NS DS RRSIG") + _stub_nsec(v, nsec3s=[n3]) + assert v.prove_no_ds(child, [], {}) is False + + def test_nsec3_non_matching_record_is_skipped(self) -> None: + v = DNSSECValidator() + n3 = _nsec3("example.com.", "00000000000000000000000000000000", "11111111111111111111111111111111", "NS") + _stub_nsec(v, nsec3s=[n3]) + assert v.prove_no_ds(dns.name.from_text("child.example.com."), [], {}) is False + + def test_nsec3_opt_out_covering_record_proves_no_ds(self) -> None: + """.com uses NSEC3 opt-out; the child's hash is merely covered.""" + v = DNSSECValidator() + child = dns.name.from_text("child.example.com.") + digest = dns.dnssec.nsec3_hash(child, "AABBCCDD", 10, 1) + lo = "0" * 32 + hi = "V" * 32 + assert lo < digest < hi + n3 = _nsec3("example.com.", lo, hi, "NS", flags=NSEC3_OPT_OUT) + _stub_nsec(v, nsec3s=[n3]) + assert v.prove_no_ds(child, [], {}) is True + + def test_nsec_owner_mismatch_is_skipped(self) -> None: + v = DNSSECValidator() + nsec = rrset("other.example.com.", "NSEC", "next.example.com. NS RRSIG NSEC") + _stub_nsec(v, nsecs=[nsec]) + assert v.prove_no_ds(dns.name.from_text("child.example.com."), [], {}) is False + + +class TestNXDOMAINProofBranches: + def test_nsec_not_covering_the_name(self) -> None: + v = DNSSECValidator() + nsec = rrset("x.example.com.", "NSEC", "y.example.com. A RRSIG NSEC") + _stub_nsec(v, nsecs=[nsec]) + assert v.prove_nxdomain(dns.name.from_text("zz.example.com."), [], {}) is False + + def test_nsec3_iteration_cap(self) -> None: + v = DNSSECValidator() + n3 = _nsec3("example.com.", "AAAA", "ZZZZ", "A", iterations=MAX_NSEC3_ITERATIONS + 1) + _stub_nsec(v, nsec3s=[n3]) + assert v.prove_nxdomain(dns.name.from_text("x.example.com."), [], {}) is False + + def test_nsec3_without_a_closest_encloser(self) -> None: + v = DNSSECValidator() + n3 = _nsec3("example.com.", "00000000000000000000000000000000", "11111111111111111111111111111111", "A") + _stub_nsec(v, nsec3s=[n3]) + assert v.prove_nxdomain(dns.name.from_text("x.example.com."), [], {}) is False + + def test_nsec3_closest_encloser_without_a_covered_next_closer(self) -> None: + v = DNSSECValidator() + apex = dns.name.from_text("example.com.") + qname = dns.name.from_text("x.example.com.") + apex_hash = dns.dnssec.nsec3_hash(apex, "AABBCCDD", 10, 1) + qname_hash = dns.dnssec.nsec3_hash(qname, "AABBCCDD", 10, 1) + # An interval spanning only the apex hash and its immediate successor: + # it matches the closest encloser but covers no other name. + n3 = _nsec3("example.com.", apex_hash, _successor(apex_hash), "A") + assert not (apex_hash < qname_hash < _successor(apex_hash)) + _stub_nsec(v, nsec3s=[n3]) + assert v.prove_nxdomain(qname, [], {}) is False + + def test_next_closer_of_an_equal_name_is_none(self) -> None: + v = DNSSECValidator() + apex = dns.name.from_text("example.com.") + assert v._next_closer(apex, apex) is None + + def test_closest_encloser_search_terminates_at_the_root(self) -> None: + v = DNSSECValidator() + n3 = _nsec3("example.com.", "00000000000000000000000000000000", "11111111111111111111111111111111", "A") + assert v._closest_encloser_nsec3(dns.name.from_text("a.b.c."), [n3], n3[0]) is None + + +class TestNODATAProofBranches: + def test_nsec_owner_mismatch_is_skipped(self) -> None: + v = DNSSECValidator() + nsec = rrset("other.example.com.", "NSEC", "next.example.com. A RRSIG NSEC") + _stub_nsec(v, nsecs=[nsec]) + assert v.prove_nodata(dns.name.from_text("x.example.com."), dns.rdatatype.MX, [], {}) is False + + def test_nsec3_matching_record_without_the_type(self) -> None: + v = DNSSECValidator() + qname = dns.name.from_text("x.example.com.") + digest = dns.dnssec.nsec3_hash(qname, "AABBCCDD", 10, 1) + n3 = _nsec3("example.com.", digest, "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", "A RRSIG") + _stub_nsec(v, nsec3s=[n3]) + assert v.prove_nodata(qname, dns.rdatatype.MX, [], {}) is True + + def test_nsec3_matching_record_with_the_type_present(self) -> None: + v = DNSSECValidator() + qname = dns.name.from_text("x.example.com.") + digest = dns.dnssec.nsec3_hash(qname, "AABBCCDD", 10, 1) + n3 = _nsec3("example.com.", digest, "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", "A MX RRSIG") + _stub_nsec(v, nsec3s=[n3]) + assert v.prove_nodata(qname, dns.rdatatype.MX, [], {}) is False + + def test_nsec3_iteration_cap(self) -> None: + v = DNSSECValidator() + n3 = _nsec3("example.com.", "AAAA", "ZZZZ", "A", iterations=MAX_NSEC3_ITERATIONS + 1) + _stub_nsec(v, nsec3s=[n3]) + assert v.prove_nodata(dns.name.from_text("x.example.com."), dns.rdatatype.MX, [], {}) is False + + def test_nsec3_opt_out_covers_an_unsigned_delegation(self) -> None: + v = DNSSECValidator() + qname = dns.name.from_text("deep.sub.example.com.") + apex = dns.name.from_text("example.com.") + apex_hash = dns.dnssec.nsec3_hash(apex, "AABBCCDD", 10, 1) + next_closer = dns.name.from_text("sub.example.com.") + nc_hash = dns.dnssec.nsec3_hash(next_closer, "AABBCCDD", 10, 1) + matching = _nsec3("example.com.", apex_hash, "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", "A RRSIG") + lo, hi = "0" * 32, "V" * 32 + assert lo < nc_hash < hi + covering = _nsec3("example.com.", lo, hi, "NS", flags=NSEC3_OPT_OUT) + _stub_nsec(v, nsec3s=[matching, covering]) + assert v.prove_nodata(qname, dns.rdatatype.MX, [], {}) is True + + +class TestTrimmingGuards: + """The per-tag caps themselves, exercised at their boundaries.""" + + def test_colliding_key_tags_are_capped(self) -> None: + from recursive_resolver.dnssec import MAX_KEYS_PER_TAG, _trim_keyring + + keys = dns.rrset.RRset(dns.name.from_text("evil.test."), dns.rdataclass.IN, dns.rdatatype.DNSKEY) + for i in range(10): + keys.add( + dns.rdata.from_text( + dns.rdataclass.IN, dns.rdatatype.DNSKEY, f"257 3 8 AwEAAaz{i}tAm8yTn4Mfeh5eyI96WSVexTBAvk" + ) + ) + keys.ttl = 300 + assert len(keys) == 10, "distinct payloads so the RRset does not dedupe" + # Force every key to report the same tag: the KeyTrap shape. + with patch("dns.dnssec.key_id", return_value=4242): + trimmed = _trim_keyring({dns.name.from_text("evil.test."): keys}) + assert len(next(iter(trimmed.values()))) == MAX_KEYS_PER_TAG + + def test_colliding_rrsig_tags_are_capped(self) -> None: + from recursive_resolver.dnssec import MAX_RRSIGS_PER_TAG, _trim_rrsigs + + sigs = dns.rrset.RRset(dns.name.from_text("evil.test."), dns.rdataclass.IN, dns.rdatatype.RRSIG) + # Same key tag, distinct signature bytes so the RRset does not dedupe. + for i in range(6): + sigs.add( + dns.rdata.from_text( + dns.rdataclass.IN, + dns.rdatatype.RRSIG, + f"A 8 2 300 20990101000000 20200101000000 777 evil.test. {'A' * 4}{'BCDE'[i % 4]}{i}==", + ) + ) + assert len(_trim_rrsigs(sigs)) == MAX_RRSIGS_PER_TAG + + def test_unhashable_name_is_skipped_in_the_no_ds_proof(self) -> None: + v = DNSSECValidator() + n3 = _nsec3("example.com.", "AAAA", "ZZZZ", "NS") + _stub_nsec(v, nsec3s=[n3]) + with patch("dns.dnssec.nsec3_hash", side_effect=ValueError("bad salt")): + assert v.prove_no_ds(dns.name.from_text("child.example.com."), [], {}) is False + + def test_undecodable_next_hash_in_covering(self) -> None: + """A next-hashed-owner that is not decodable must fail closed.""" + + class _BadRdata: + next = "not-bytes" # b32encode expects bytes and will raise + salt = "AABBCCDD" + iterations = 10 + algorithm = 1 + flags = 0 + + class _BadRRset: + name = dns.name.from_text("AAAA.example.com.") + + def __getitem__(self, index): + return _BadRdata() + + v = DNSSECValidator() + assert v._nsec3_covers(dns.name.from_text("x.example.com."), _BadRRset(), _BadRdata()) is False + + def test_nxdomain_where_the_qname_is_its_own_closest_encloser(self) -> None: + """No next-closer name exists, so no NXDOMAIN proof is possible.""" + v = DNSSECValidator() + qname = dns.name.from_text("x.example.com.") + digest = dns.dnssec.nsec3_hash(qname, "AABBCCDD", 10, 1) + n3 = _nsec3("example.com.", digest, _successor(digest), "A") + _stub_nsec(v, nsec3s=[n3]) + assert v.prove_nxdomain(qname, [], {}) is False + + +class TestOptOutNodataEdges: + def test_no_closest_encloser_means_no_opt_out_proof(self) -> None: + v = DNSSECValidator() + n3 = _nsec3("example.com.", "00000000000000000000000000000000", "11111111111111111111111111111111", "A") + _stub_nsec(v, nsec3s=[n3]) + assert v.prove_nodata(dns.name.from_text("x.example.com."), dns.rdatatype.MX, [], {}) is False + + def test_qname_as_its_own_closest_encloser_has_no_next_closer(self) -> None: + """A matching NSEC3 that does list the type: no NODATA, no opt-out either.""" + v = DNSSECValidator() + qname = dns.name.from_text("x.example.com.") + digest = dns.dnssec.nsec3_hash(qname, "AABBCCDD", 10, 1) + n3 = _nsec3("example.com.", digest, _successor(digest), "A MX RRSIG") + _stub_nsec(v, nsec3s=[n3]) + assert v.prove_nodata(qname, dns.rdatatype.MX, [], {}) is False + + def test_next_closer_not_covered_by_any_opt_out_record(self) -> None: + """A closest encloser exists but nothing opts the next-closer name out.""" + v = DNSSECValidator() + qname = dns.name.from_text("deep.sub.example.com.") + apex = dns.name.from_text("example.com.") + apex_hash = dns.dnssec.nsec3_hash(apex, "AABBCCDD", 10, 1) + # Matches the closest encloser, but its interval is empty and it has no + # opt-out flag, so the next-closer name is never covered. + n3 = _nsec3("example.com.", apex_hash, _successor(apex_hash), "A RRSIG") + _stub_nsec(v, nsec3s=[n3]) + assert v.prove_nodata(qname, dns.rdatatype.MX, [], {}) is False + + +class TestWildcardProofNSEC3: + """The NSEC3 half of the RFC 4035 §5.3.4 wildcard proof. + + ``.com``, ``.net`` and ``.org`` are NSEC3 zones, so this is the path most + real wildcard answers take. + """ + + QNAME = dns.name.from_text("anything.example.test.") + LABELS = 2 # the answer came from *.example.test. + + def test_a_covering_nsec3_proves_the_expansion(self) -> None: + v = DNSSECValidator() + covering = _nsec3( + "example.test.", "00000000000000000000000000000000", "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", "A RRSIG" + ) + _stub_nsec(v, nsec3s=[covering]) + assert v.prove_wildcard(self.QNAME, self.LABELS, [], {}) is True + + def test_an_nsec3_matching_the_next_closer_is_not_covering_it(self) -> None: + """A record whose owner IS the hash proves the name exists, not that it does not.""" + v = DNSSECValidator() + digest = dns.dnssec.nsec3_hash(self.QNAME, "AABBCCDD", 10, 1) + matching = _nsec3("example.test.", digest, "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", "A RRSIG") + _stub_nsec(v, nsec3s=[matching]) + assert v.prove_wildcard(self.QNAME, self.LABELS, [], {}) is False + + def test_no_denial_records_at_all_proves_nothing(self) -> None: + v = DNSSECValidator() + _stub_nsec(v) + assert v.prove_wildcard(self.QNAME, self.LABELS, [], {}) is False + + def test_a_labels_count_leaving_no_next_closer_is_refused(self) -> None: + """labels >= the owner's label count is not a wildcard expansion at all.""" + v = DNSSECValidator() + covering = _nsec3( + "example.test.", "00000000000000000000000000000000", "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", "A RRSIG" + ) + _stub_nsec(v, nsec3s=[covering]) + assert v.prove_wildcard(self.QNAME, 3, [], {}) is False + + +def _predecessor(digest: str) -> str: + """The immediately preceding base32hex hash of the same length.""" + chars = list(digest) + for i in range(len(chars) - 1, -1, -1): + idx = B32HEX.index(chars[i]) + if idx > 0: + chars[i] = B32HEX[idx - 1] + return "".join(chars) + chars[i] = B32HEX[-1] + return "".join(chars) + + +class TestNSEC3NXDOMAINProof: + """RFC 5155 §8.4 needs three things, and dropping any one must fail. + + `.com`, `.net` and `.org` are NSEC3 zones, so this is the path most real + NXDOMAIN proofs take. Only the NSEC variant was covered before. + """ + + ZONE = "example.com." + QNAME = dns.name.from_text("missing.example.com.") + SALT, ITER, ALG = "AABBCCDD", 10, 1 + + def _hash(self, name: str, iterations: int | None = None) -> str: + return dns.dnssec.nsec3_hash( + dns.name.from_text(name), self.SALT, self.ITER if iterations is None else iterations, self.ALG + ) + + def _matching(self, name: str, iterations: int | None = None): + """An NSEC3 whose owner IS the hash of ``name``: proof it exists.""" + iterations = self.ITER if iterations is None else iterations + digest = self._hash(name, iterations) + return _nsec3(self.ZONE, digest, _successor(digest), "NS SOA RRSIG DNSKEY NSEC3PARAM", iterations=iterations) + + def _covering(self, name: str, iterations: int | None = None): + """An NSEC3 spanning exactly the hash of ``name``: proof it does not exist.""" + iterations = self.ITER if iterations is None else iterations + digest = self._hash(name, iterations) + return _nsec3(self.ZONE, _predecessor(digest), _successor(digest), "A RRSIG", iterations=iterations) + + def _complete_proof(self, iterations: int | None = None) -> list: + return [ + self._matching("example.com.", iterations), + self._covering("missing.example.com.", iterations), + self._covering("*.example.com.", iterations), + ] + + def test_a_complete_proof_is_accepted(self) -> None: + v = DNSSECValidator() + _stub_nsec( + v, + nsec3s=[ + self._matching("example.com."), # closest encloser + self._covering("missing.example.com."), # next closer absent + self._covering("*.example.com."), # no wildcard either + ], + ) + assert v.prove_nxdomain(self.QNAME, [], {}) is True + + def test_without_the_next_closer_covered_it_is_refused(self) -> None: + v = DNSSECValidator() + _stub_nsec(v, nsec3s=[self._matching("example.com."), self._covering("*.example.com.")]) + assert v.prove_nxdomain(self.QNAME, [], {}) is False + + def test_without_the_wildcard_covered_it_is_refused(self) -> None: + """Otherwise a name a wildcard would have answered is denied as nonexistent.""" + v = DNSSECValidator() + _stub_nsec(v, nsec3s=[self._matching("example.com."), self._covering("missing.example.com.")]) + assert v.prove_nxdomain(self.QNAME, [], {}) is False + + def test_without_a_closest_encloser_it_is_refused(self) -> None: + v = DNSSECValidator() + _stub_nsec(v, nsec3s=[self._covering("missing.example.com."), self._covering("*.example.com.")]) + assert v.prove_nxdomain(self.QNAME, [], {}) is False + + def test_an_absurd_iteration_count_is_refused_despite_a_complete_proof(self) -> None: + """The cap must be what fails this, not a missing part of the proof. + + The hashes are recomputed at each iteration count, so both proofs are + genuinely complete and only the cap differs between them. + """ + + def attempt(iterations: int) -> bool: + v = DNSSECValidator() + _stub_nsec(v, nsec3s=self._complete_proof(iterations)) + return v.prove_nxdomain(self.QNAME, [], {}) + + assert attempt(self.ITER) is True + assert attempt(MAX_NSEC3_ITERATIONS + 1) is False + + +class TestNSEC3IterationCapActuallyBites: + """Each cap must be what rejects the proof, not a coincidentally broken one. + + The earlier cap test asserted only ``not proven`` on input that failed for + an unrelated reason, so removing the cap left it green. These assert the + proof succeeds at a sane iteration count and fails at an absurd one, with + nothing else changed. + """ + + ZONE = "example.com." + SALT, ALG = "AABBCCDD", 1 + + def _digest(self, name: str, iterations: int) -> str: + return dns.dnssec.nsec3_hash(dns.name.from_text(name), self.SALT, iterations, self.ALG) + + def test_prove_no_ds_cap(self) -> None: + child = dns.name.from_text("child.example.com.") + + def attempt(iterations: int) -> bool: + v = DNSSECValidator() + digest = self._digest("child.example.com.", iterations) + n3 = _nsec3(self.ZONE, digest, _successor(digest), "NS RRSIG", iterations=iterations) + _stub_nsec(v, nsec3s=[n3]) + return v.prove_no_ds(child, [], {}) + + assert attempt(10) is True + assert attempt(MAX_NSEC3_ITERATIONS + 1) is False + + def test_prove_nodata_cap(self) -> None: + qname = dns.name.from_text("nodata.example.com.") + + def attempt(iterations: int) -> bool: + v = DNSSECValidator() + digest = self._digest("nodata.example.com.", iterations) + n3 = _nsec3(self.ZONE, digest, _successor(digest), "A RRSIG", iterations=iterations) + _stub_nsec(v, nsec3s=[n3]) + return v.prove_nodata(qname, dns.rdatatype.MX, [], {}) + + assert attempt(10) is True + assert attempt(MAX_NSEC3_ITERATIONS + 1) is False + + def test_prove_wildcard_cap(self) -> None: + """The wildcard proof hashes too, so it needs the same guard as the others.""" + qname = dns.name.from_text("anything.example.com.") + + def attempt(iterations: int) -> bool: + v = DNSSECValidator() + covering = _nsec3( + self.ZONE, + "00000000000000000000000000000000", + "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", + "A RRSIG", + iterations=iterations, + ) + _stub_nsec(v, nsec3s=[covering]) + return v.prove_wildcard(qname, 2, [], {}) + + assert attempt(10) is True + assert attempt(MAX_NSEC3_ITERATIONS + 1) is False diff --git a/tests/test_dnssec_chain.py b/tests/test_dnssec_chain.py new file mode 100644 index 0000000..6be96dd --- /dev/null +++ b/tests/test_dnssec_chain.py @@ -0,0 +1,534 @@ +"""The resolver's DNSSEC chain-of-trust plumbing. + +Each branch here decides whether data is trusted, so each is driven explicitly. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import dns.name +import dns.rdata +import dns.rdataclass +import dns.rdataset +import dns.rdatatype +import dns.rrset +import pytest +from conftest import make_response + +from recursive_resolver import DNSSECValidationError, RecursiveResolver, ValidationState +from recursive_resolver.dnssec import ZoneKeys + +ROOT = dns.name.root +COM = dns.name.from_text("com.") +EXAMPLE = dns.name.from_text("example.com.") +DEEP = dns.name.from_text("a.b.example.com.") + + +def _ds_rdataset() -> dns.rdataset.Rdataset: + """A syntactically valid DS rdataset that matches no real key.""" + rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.DS) + rdataset.add(dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DS, "12345 8 2 " + "ab" * 32), ttl=300) + return rdataset + + +SENTINEL_DS = _ds_rdataset() + + +def _dnssec_resolver(**kwargs) -> RecursiveResolver: + kwargs.setdefault("cache_enabled", False) + return RecursiveResolver(dnssec=True, **kwargs) + + +def _keys(zone: dns.name.Name, state: ValidationState = ValidationState.SECURE) -> ZoneKeys: + rrset = dns.rrset.RRset(zone, dns.rdataclass.IN, dns.rdatatype.DNSKEY) + rrset.ttl = 300 + return ZoneKeys(zone, rrset if state is ValidationState.SECURE else None, state) + + +class TestZoneKeyFetch: + def test_no_response_is_a_resolution_failure(self) -> None: + """Not BOGUS: see TestIndeterminateVsBogus for the reasoning.""" + from recursive_resolver import ResolutionTimeoutError + + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with ( + patch.object(resolver, "_send_query", return_value=(None, "")), + pytest.raises(ResolutionTimeoutError), + ): + resolver._get_zone_keys(EXAMPLE, SENTINEL_DS, ["9.9.9.9"], ctx) + + def test_response_without_a_dnskey_is_bogus(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with patch.object(resolver, "_send_query", return_value=(make_response(), "9.9.9.9")): + keys = resolver._get_zone_keys(EXAMPLE, SENTINEL_DS, ["9.9.9.9"], ctx) + assert keys.state is ValidationState.BOGUS + + def test_no_ds_for_a_non_root_zone_is_bogus(self) -> None: + """Without a DS there is nothing to anchor the zone's keys to.""" + resolver = _dnssec_resolver() + ctx = resolver._new_context() + response = make_response(answer=[("example.com.", 300, "DNSKEY", ["257 3 8 AwEAAaz/tAm8yTn4Mfeh5eyI96WS"])]) + with patch.object(resolver, "_send_query", return_value=(response, "9.9.9.9")): + keys = resolver._get_zone_keys(EXAMPLE, None, ["9.9.9.9"], ctx) + assert keys.state is ValidationState.BOGUS + + def test_cached_keys_short_circuit_the_query(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + resolver._store_keys(_keys(EXAMPLE), 3600) + with patch.object(resolver, "_send_query") as send: + keys = resolver._get_zone_keys(EXAMPLE, SENTINEL_DS, ["9.9.9.9"], ctx) + send.assert_not_called() + assert keys.state is ValidationState.SECURE + + +class TestDescendChain: + def test_no_descent_needed(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + zone, ds, state = resolver._descend_chain(COM, COM, SENTINEL_DS, ["9.9.9.9"], ctx) + assert (zone, ds, state) == (COM, SENTINEL_DS, ValidationState.SECURE) + + def test_target_outside_the_current_zone_is_a_no_op(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + other = dns.name.from_text("example.org.") + zone, _ds, state = resolver._descend_chain(other, COM, SENTINEL_DS, ["9.9.9.9"], ctx) + assert zone == COM and state is ValidationState.SECURE + + def test_unusable_parent_keys_abort(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with patch.object(resolver, "_get_zone_keys", return_value=_keys(COM, ValidationState.BOGUS)): + _zone, ds, state = resolver._descend_chain(EXAMPLE, COM, SENTINEL_DS, ["9.9.9.9"], ctx) + assert state is ValidationState.BOGUS and ds is None + + def test_unfetchable_ds_is_a_resolution_failure(self) -> None: + """Not BOGUS: see TestIndeterminateVsBogus for the reasoning.""" + from recursive_resolver import ResolutionTimeoutError + + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with ( + patch.object(resolver, "_get_zone_keys", return_value=_keys(COM)), + patch.object(resolver, "_send_query", return_value=(None, "")), + pytest.raises(ResolutionTimeoutError), + ): + resolver._descend_chain(EXAMPLE, COM, SENTINEL_DS, ["9.9.9.9"], ctx) + + def test_insecure_intermediate_stops_the_descent(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with ( + patch.object(resolver, "_get_zone_keys", return_value=_keys(COM)), + patch.object(resolver, "_send_query", return_value=(make_response(), "9.9.9.9")), + patch.object(resolver._validator, "validate_ds", return_value=(ValidationState.INSECURE, None)), + ): + zone, ds, state = resolver._descend_chain(EXAMPLE, COM, SENTINEL_DS, ["9.9.9.9"], ctx) + assert (zone, ds, state) == (EXAMPLE, None, ValidationState.INSECURE) + + def test_multi_label_descent_walks_one_label_at_a_time(self) -> None: + """uk. -> co.uk. -> bbc.co.uk. when one server serves several zones.""" + resolver = _dnssec_resolver() + ctx = resolver._new_context() + visited: list[str] = [] + + def send(zone, rdtype, nameservers, ctx_): + visited.append(str(zone)) + return make_response(), "9.9.9.9" + + with ( + patch.object(resolver, "_get_zone_keys", side_effect=lambda z, d, n, c: _keys(z)), + patch.object(resolver, "_send_query", side_effect=send), + patch.object(resolver._validator, "validate_ds", return_value=(ValidationState.SECURE, SENTINEL_DS)), + ): + zone, _ds, state = resolver._descend_chain(DEEP, COM, SENTINEL_DS, ["9.9.9.9"], ctx) + assert zone == DEEP and state is ValidationState.SECURE + assert visited == ["example.com.", "b.example.com.", "a.b.example.com."] + + +class TestAdvanceDNSSEC: + def test_insecure_parent_stays_insecure(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + state, ds = resolver._advance_dnssec( + make_response(), EXAMPLE, COM, ["9.9.9.9"], ValidationState.INSECURE, None, ctx + ) + assert (state, ds) == (ValidationState.INSECURE, None) + + def test_alignment_to_an_insecure_zone(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with ( + patch.object(resolver, "_authority_signer", return_value=EXAMPLE), + patch.object(resolver, "_descend_chain", return_value=(EXAMPLE, None, ValidationState.INSECURE)), + ): + state, ds = resolver._advance_dnssec( + make_response(), DEEP, COM, ["9.9.9.9"], ValidationState.SECURE, None, ctx + ) + assert (state, ds) == (ValidationState.INSECURE, None) + + def test_alignment_to_a_bogus_zone_raises(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with ( + patch.object(resolver, "_authority_signer", return_value=EXAMPLE), + patch.object(resolver, "_descend_chain", return_value=(EXAMPLE, None, ValidationState.BOGUS)), + pytest.raises(DNSSECValidationError, match="broken chain"), + ): + resolver._advance_dnssec(make_response(), DEEP, COM, ["9.9.9.9"], ValidationState.SECURE, None, ctx) + + def test_child_equal_to_the_aligned_parent_is_already_proven(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with ( + patch.object(resolver, "_authority_signer", return_value=EXAMPLE), + patch.object(resolver, "_descend_chain", return_value=(EXAMPLE, SENTINEL_DS, ValidationState.SECURE)), + ): + state, ds = resolver._advance_dnssec( + make_response(), EXAMPLE, COM, ["9.9.9.9"], ValidationState.SECURE, None, ctx + ) + assert (state, ds) == (ValidationState.SECURE, SENTINEL_DS) + + def test_unusable_parent_keys_raise(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with ( + patch.object(resolver, "_get_zone_keys", return_value=_keys(COM, ValidationState.BOGUS)), + pytest.raises(DNSSECValidationError, match="cannot establish DNSKEY"), + ): + resolver._advance_dnssec(make_response(), EXAMPLE, COM, ["9.9.9.9"], ValidationState.SECURE, None, ctx) + + def test_bogus_ds_raises(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with ( + patch.object(resolver, "_get_zone_keys", return_value=_keys(COM)), + patch.object(resolver._validator, "validate_ds", return_value=(ValidationState.BOGUS, None)), + pytest.raises(DNSSECValidationError, match="neither signed nor provably unsigned"), + ): + resolver._advance_dnssec(make_response(), EXAMPLE, COM, ["9.9.9.9"], ValidationState.SECURE, None, ctx) + + def test_authority_signer_extraction(self) -> None: + resolver = _dnssec_resolver() + response = make_response( + authority=[ + ("example.com.", 300, "NSEC3PARAM", ["1 0 10 AABBCCDD"]), + ("example.com.", 300, "RRSIG", ["DS 8 2 300 20990101000000 20200101000000 1 com. AAAA"]), + ], + aa=False, + ) + assert resolver._authority_signer(response) == COM + + def test_authority_signer_absent(self) -> None: + assert _dnssec_resolver()._authority_signer(make_response()) is None + + def test_authority_signer_ignores_unrelated_rrsigs(self) -> None: + resolver = _dnssec_resolver() + response = make_response( + authority=[("example.com.", 300, "RRSIG", ["A 8 2 300 20990101000000 20200101000000 1 com. AAAA"])], + aa=False, + ) + assert resolver._authority_signer(response) is None + + +class TestAnswerValidation: + @staticmethod + def _answer_with_sig() -> tuple[dns.rrset.RRset, object]: + response = make_response( + answer=[ + ("example.com.", 300, "A", ["1.2.3.4"]), + ("example.com.", 300, "RRSIG", ["A 8 2 300 20990101000000 20200101000000 1 example.com. AAAA"]), + ] + ) + rrset = response.answer[0] + return rrset, response + + def test_insecure_chain_skips_validation(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + rrset, response = self._answer_with_sig() + state = resolver._validate_answer( + response, rrset, EXAMPLE, dns.rdatatype.A, ctx, EXAMPLE, ["9.9.9.9"], ValidationState.INSECURE, None + ) + assert state is ValidationState.INSECURE + + def test_missing_rrsig_in_a_signed_zone_is_bogus(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + response = make_response(answer=[("example.com.", 300, "A", ["1.2.3.4"])]) + with pytest.raises(DNSSECValidationError, match="carries no RRSIG"): + resolver._validate_answer( + response, + response.answer[0], + EXAMPLE, + dns.rdatatype.A, + ctx, + EXAMPLE, + ["9.9.9.9"], + ValidationState.SECURE, + SENTINEL_DS, + ) + + def test_alignment_to_insecure_returns_insecure(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + rrset, response = self._answer_with_sig() + with patch.object(resolver, "_align_zone", return_value=(EXAMPLE, None, ValidationState.INSECURE)): + state = resolver._validate_answer( + response, rrset, EXAMPLE, dns.rdatatype.A, ctx, COM, ["9.9.9.9"], ValidationState.SECURE, None + ) + assert state is ValidationState.INSECURE + + def test_alignment_to_bogus_raises(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + rrset, response = self._answer_with_sig() + with ( + patch.object(resolver, "_align_zone", return_value=(EXAMPLE, None, ValidationState.BOGUS)), + pytest.raises(DNSSECValidationError, match="broken chain"), + ): + resolver._validate_answer( + response, rrset, EXAMPLE, dns.rdatatype.A, ctx, COM, ["9.9.9.9"], ValidationState.SECURE, None + ) + + def test_unusable_keys_raise(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + rrset, response = self._answer_with_sig() + with ( + patch.object(resolver, "_get_zone_keys", return_value=_keys(EXAMPLE, ValidationState.BOGUS)), + pytest.raises(DNSSECValidationError, match="no valid DNSKEY"), + ): + resolver._validate_answer( + response, + rrset, + EXAMPLE, + dns.rdatatype.A, + ctx, + EXAMPLE, + ["9.9.9.9"], + ValidationState.SECURE, + SENTINEL_DS, + ) + + +class TestDenialValidation: + def test_insecure_chain_skips_the_proof(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + resolver._verify_denial( + make_response(), + EXAMPLE, + dns.rdatatype.A, + ctx, + EXAMPLE, + ["9.9.9.9"], + ValidationState.INSECURE, + None, + negative="nxdomain", + ) + + def test_alignment_to_insecure_skips_the_proof(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with patch.object(resolver, "_align_zone", return_value=(EXAMPLE, None, ValidationState.INSECURE)): + resolver._verify_denial( + make_response(), + EXAMPLE, + dns.rdatatype.A, + ctx, + COM, + ["9.9.9.9"], + ValidationState.SECURE, + None, + negative="nxdomain", + ) + + def test_alignment_to_bogus_raises(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with ( + patch.object(resolver, "_align_zone", return_value=(EXAMPLE, None, ValidationState.BOGUS)), + pytest.raises(DNSSECValidationError, match="broken chain"), + ): + resolver._verify_denial( + make_response(), + EXAMPLE, + dns.rdatatype.A, + ctx, + COM, + ["9.9.9.9"], + ValidationState.SECURE, + None, + negative="nxdomain", + ) + + @pytest.mark.parametrize("negative", ["nxdomain", "nodata"]) + def test_unproven_denial_is_bogus(self, negative: str) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with ( + patch.object(resolver, "_get_zone_keys", return_value=_keys(EXAMPLE)), + pytest.raises(DNSSECValidationError, match=f"unproven {negative}"), + ): + resolver._verify_denial( + make_response(), + EXAMPLE, + dns.rdatatype.A, + ctx, + EXAMPLE, + ["9.9.9.9"], + ValidationState.SECURE, + SENTINEL_DS, + negative=negative, + ) + + def test_unusable_keys_raise(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with ( + patch.object(resolver, "_get_zone_keys", return_value=_keys(EXAMPLE, ValidationState.BOGUS)), + pytest.raises(DNSSECValidationError, match="no valid DNSKEY"), + ): + resolver._verify_denial( + make_response(), + EXAMPLE, + dns.rdatatype.A, + ctx, + EXAMPLE, + ["9.9.9.9"], + ValidationState.SECURE, + SENTINEL_DS, + negative="nodata", + ) + + @pytest.mark.parametrize("negative", ["nxdomain", "nodata"]) + def test_a_valid_proof_is_accepted(self, negative: str) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + prover = "prove_nxdomain" if negative == "nxdomain" else "prove_nodata" + with ( + patch.object(resolver, "_get_zone_keys", return_value=_keys(EXAMPLE)), + patch.object(resolver._validator, prover, return_value=True), + ): + resolver._verify_denial( + make_response(), + EXAMPLE, + dns.rdatatype.A, + ctx, + EXAMPLE, + ["9.9.9.9"], + ValidationState.SECURE, + SENTINEL_DS, + negative=negative, + ) + + +class TestIndeterminateVsBogus: + """Being unable to fetch validation material is not evidence of tampering. + + Reporting an unreachable nameserver as BOGUS would make every transient + network fault look like an attack, and would make DNSSEC verdicts + non-deterministic under load, which is exactly what a differential run + against live DNS exposed. + """ + + def test_unfetchable_dnskey_is_a_timeout_not_bogus(self) -> None: + from recursive_resolver import ResolutionTimeoutError + + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with ( + patch.object(resolver, "_send_query", return_value=(None, "")), + pytest.raises(ResolutionTimeoutError), + ): + resolver._get_zone_keys(EXAMPLE, SENTINEL_DS, ["9.9.9.9"], ctx) + + def test_unfetchable_ds_is_a_timeout_not_bogus(self) -> None: + from recursive_resolver import ResolutionTimeoutError + + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with ( + patch.object(resolver, "_get_zone_keys", return_value=_keys(COM)), + patch.object(resolver, "_send_query", return_value=(None, "")), + pytest.raises(ResolutionTimeoutError), + ): + resolver._descend_chain(EXAMPLE, COM, SENTINEL_DS, ["9.9.9.9"], ctx) + + def test_a_retrievable_but_invalid_dnskey_is_still_bogus(self) -> None: + """The distinction is retrieval failure versus verification failure.""" + resolver = _dnssec_resolver() + ctx = resolver._new_context() + response = make_response(answer=[("example.com.", 300, "DNSKEY", ["257 3 8 AwEAAaz/tAm8yTn4Mfeh5eyI96WS"])]) + with patch.object(resolver, "_send_query", return_value=(response, "9.9.9.9")): + keys = resolver._get_zone_keys(EXAMPLE, SENTINEL_DS, ["9.9.9.9"], ctx) + assert keys.state is ValidationState.BOGUS + + def test_a_missing_dnskey_rrset_is_still_bogus(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with patch.object(resolver, "_send_query", return_value=(make_response(), "9.9.9.9")): + keys = resolver._get_zone_keys(EXAMPLE, SENTINEL_DS, ["9.9.9.9"], ctx) + assert keys.state is ValidationState.BOGUS + + +class TestUnverifiableZoneKeysPropagateAsInsecure: + """A zone whose keys we cannot verify is unsigned, not forged. + + RFC 4035 §5.2. Every place that consults a zone's keys has to carry the + distinction, otherwise a legitimately signed domain using an algorithm this + build lacks would be rejected outright rather than merely unauthenticated. + """ + + def test_the_descent_stops_and_reports_insecure(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with patch.object(resolver, "_get_zone_keys", return_value=_keys(COM, ValidationState.INSECURE)): + zone, ds, state = resolver._descend_chain(EXAMPLE, COM, SENTINEL_DS, ["9.9.9.9"], ctx) + assert state is ValidationState.INSECURE + assert ds is None + assert zone == COM + + def test_a_ds_lookup_under_unverifiable_parent_keys_is_insecure(self) -> None: + """Contrast with test_unusable_parent_keys_raise, which is BOGUS.""" + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with ( + patch.object(resolver, "_get_zone_keys", return_value=_keys(COM, ValidationState.INSECURE)), + patch.object(resolver, "_descend_chain", return_value=(COM, SENTINEL_DS, ValidationState.SECURE)), + ): + state, ds = resolver._advance_dnssec( + make_response(), EXAMPLE, COM, ["9.9.9.9"], ValidationState.SECURE, None, ctx + ) + assert state is ValidationState.INSECURE + assert ds is None + + +class TestUnsupportedSigningAlgorithm: + """A DS that matches a key whose algorithm we cannot verify. + + The digest computes and matches, so nothing looks wrong, but we still have + no way to check a single signature the zone makes. + """ + + def test_a_matched_key_with_an_unverifiable_algorithm_is_insecure(self) -> None: + from recursive_resolver.dnssec import DNSSECValidator, algorithm_supported + + # Algorithm 12 (ECCGOST) is withdrawn and unimplemented here, but its + # DS digest is still computable, so the DS genuinely matches. + assert algorithm_supported(12) is False + zone = dns.name.from_text("gost.test.") + keys = dns.rrset.RRset(zone, dns.rdataclass.IN, dns.rdatatype.DNSKEY) + key = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DNSKEY, "257 3 12 " + "A" * 64) + keys.add(key) + keys.ttl = 300 + + ds_rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.DS) + ds_rdataset.add(dns.dnssec.make_ds(zone, key, "SHA256"), ttl=300) + + assert DNSSECValidator().validate_dnskey(zone, keys, None, ds_rdataset) is ValidationState.INSECURE diff --git a/tests/test_dnssec_signed.py b/tests/test_dnssec_signed.py new file mode 100644 index 0000000..c776fd0 --- /dev/null +++ b/tests/test_dnssec_signed.py @@ -0,0 +1,623 @@ +"""DNSSEC validation against a real, locally-signed zone. + +Everything here uses genuine keys, signatures and DS digests generated in +process, so the success paths are covered deterministically and without a +network, including cases live DNS does not reliably provide. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import dns.name +import dns.rcode +import dns.rdataclass +import dns.rdatatype +import dns.rrset +import pytest +from conftest import make_response +from signed_zone import SignedZone, rrset_of + +from recursive_resolver import ( + DNSSECInsecureError, + DNSSECValidationError, + NXDOMAINError, + RecursiveResolver, + ValidationState, +) +from recursive_resolver.budget import QueryBudget +from recursive_resolver.dnssec import DNSSECValidator + +EXAMPLE = dns.name.from_text("example.test.") + + +@pytest.fixture(scope="module") +def zone() -> SignedZone: + return SignedZone.create("example.test.") + + +@pytest.fixture(scope="module") +def parent() -> SignedZone: + return SignedZone.create("test.") + + +class TestSignatureValidation: + def test_a_genuine_signature_validates(self, zone: SignedZone) -> None: + data = rrset_of("example.test.", "A", "192.0.2.1") + assert DNSSECValidator().validate_rrset(data, zone.sign(data), zone.keyring()) is True + + def test_a_signature_over_different_data_does_not_validate(self, zone: SignedZone) -> None: + signed = rrset_of("example.test.", "A", "192.0.2.1") + tampered = rrset_of("example.test.", "A", "198.51.100.66") + assert DNSSECValidator().validate_rrset(tampered, zone.sign(signed), zone.keyring()) is False + + def test_a_foreign_key_does_not_validate(self, zone: SignedZone, parent: SignedZone) -> None: + data = rrset_of("example.test.", "A", "192.0.2.1") + assert DNSSECValidator().validate_rrset(data, zone.sign(data), parent.keyring()) is False + + def test_validation_is_charged_to_the_budget(self, zone: SignedZone) -> None: + data = rrset_of("example.test.", "A", "192.0.2.1") + budget = QueryBudget() + DNSSECValidator().validate_rrset(data, zone.sign(data), zone.keyring(), budget=budget) + assert budget.signature_validations == 1 + + +class TestChainOfTrust: + def test_a_dnskey_matching_its_ds_validates(self, zone: SignedZone, parent: SignedZone) -> None: + validator = DNSSECValidator() + assert ( + validator.validate_dnskey(zone.name, zone.dnskey_rrset, zone.signed_dnskey(), parent.ds_rdataset(zone)) + is ValidationState.SECURE + ) + + def test_a_dnskey_not_matching_the_ds_is_rejected(self, zone: SignedZone, parent: SignedZone) -> None: + """A comparable DS that simply does not match is forgery, not an unknown algorithm.""" + other = SignedZone.create("example.test.") + validator = DNSSECValidator() + assert ( + validator.validate_dnskey(zone.name, zone.dnskey_rrset, zone.signed_dnskey(), parent.ds_rdataset(other)) + is ValidationState.BOGUS + ) + + def test_an_unsigned_dnskey_rrset_is_rejected(self, zone: SignedZone, parent: SignedZone) -> None: + """The DS must match a key that actually signed the DNSKEY RRset.""" + validator = DNSSECValidator() + assert ( + validator.validate_dnskey(zone.name, zone.dnskey_rrset, None, parent.ds_rdataset(zone)) + is ValidationState.BOGUS + ) + + def test_the_root_anchor_path(self) -> None: + """validate_root_dnskey, against a locally-generated root anchor.""" + root_zone = SignedZone.create(".") + validator = DNSSECValidator(trust_anchors=(root_zone.anchor_text(),)) + assert validator.validate_root_dnskey(root_zone.dnskey_rrset, root_zone.signed_dnskey()) is True + + def test_a_root_dnskey_not_matching_the_anchor_is_rejected(self) -> None: + real_root = SignedZone.create(".") + impostor = SignedZone.create(".") + validator = DNSSECValidator(trust_anchors=(real_root.anchor_text(),)) + assert validator.validate_root_dnskey(impostor.dnskey_rrset, impostor.signed_dnskey()) is False + + def test_a_signed_ds_yields_a_secure_delegation(self, zone: SignedZone, parent: SignedZone) -> None: + ds = parent.ds_rrset(zone) + authority = [ds, parent.sign(ds)] + state, rdataset = DNSSECValidator().validate_ds(zone.name, authority, parent.keyring()) + assert state is ValidationState.SECURE + assert rdataset is not None and len(rdataset) == 1 + + def test_an_unsigned_ds_is_bogus(self, zone: SignedZone, parent: SignedZone) -> None: + state, rdataset = DNSSECValidator().validate_ds(zone.name, [parent.ds_rrset(zone)], parent.keyring()) + assert state is ValidationState.BOGUS + assert rdataset is None + + def test_a_signed_nsec_proving_no_ds_yields_an_insecure_delegation(self, parent: SignedZone) -> None: + """The ordinary unsigned-child case: NS present, DS absent.""" + nsec = rrset_of("example.test.", "NSEC", "next.test. NS RRSIG NSEC") + state, rdataset = DNSSECValidator().validate_ds(EXAMPLE, [nsec, parent.sign(nsec)], parent.keyring()) + assert state is ValidationState.INSECURE + assert rdataset is None + + +class TestSignedDenialOfExistence: + def test_signed_nsec_proves_nodata(self, zone: SignedZone) -> None: + nsec = rrset_of("example.test.", "NSEC", "next.test. A RRSIG NSEC") + authority = [nsec, zone.sign(nsec)] + assert DNSSECValidator().prove_nodata(EXAMPLE, dns.rdatatype.MX, authority, zone.keyring()) is True + + def test_an_unsigned_nsec_proves_nothing(self, zone: SignedZone) -> None: + nsec = rrset_of("example.test.", "NSEC", "next.test. A RRSIG NSEC") + assert DNSSECValidator().prove_nodata(EXAMPLE, dns.rdatatype.MX, [nsec], zone.keyring()) is False + + def test_signed_nsec_proves_nxdomain(self, zone: SignedZone) -> None: + covering = rrset_of("a.example.test.", "NSEC", "c.example.test. A RRSIG NSEC") + wildcard = rrset_of("!.example.test.", "NSEC", "+.example.test. A RRSIG NSEC") + authority = [covering, zone.sign(covering), wildcard, zone.sign(wildcard)] + target = dns.name.from_text("b.example.test.") + assert DNSSECValidator().prove_nxdomain(target, authority, zone.keyring()) is True + + def test_signed_nsec_proving_no_ds(self, zone: SignedZone) -> None: + nsec = rrset_of("child.example.test.", "NSEC", "next.example.test. NS RRSIG NSEC") + authority = [nsec, zone.sign(nsec)] + child = dns.name.from_text("child.example.test.") + assert DNSSECValidator().prove_no_ds(child, authority, zone.keyring()) is True + + +class TestResolverAgainstASignedZone: + """Drive the resolver end to end over a locally-signed delegation chain.""" + + @staticmethod + def _resolver(root: SignedZone) -> RecursiveResolver: + """A resolver anchored on the locally-generated root instead of IANA's.""" + return RecursiveResolver( + cache_enabled=True, + max_resolution_time=5, + trust_anchors=(root.anchor_text(),), + ) + + def test_a_fully_signed_chain_validates_secure(self) -> None: + root = SignedZone.create(".") + tld = SignedZone.create("test.") + leaf = SignedZone.create("example.test.") + + answer = rrset_of("example.test.", "A", "192.0.2.10") + root_ds = root.ds_rrset(tld) + tld_ds = tld.ds_rrset(leaf) + + # Responses are held in a dict keyed by (qname, rdtype), which keeps the + # mock readable as the chain grows. + def dnskey_response(zone: SignedZone): + response = make_response() + response.answer.append(zone.dnskey_rrset) + response.answer.append(zone.signed_dnskey()) + return response + + def referral(zone_name: str, ds_rrset, signer: SignedZone): + response = make_response( + authority=[(zone_name, 3600, "NS", [f"ns.{zone_name}"])], + additional=[(f"ns.{zone_name}", 3600, "A", ["9.9.9.9"])], + aa=False, + ) + response.authority.append(ds_rrset) + response.authority.append(signer.sign(ds_rrset)) + return response + + answer_response = make_response() + answer_response.answer.append(answer) + answer_response.answer.append(leaf.sign(answer)) + + table = { + (dns.name.root, dns.rdatatype.DNSKEY): dnskey_response(root), + (tld.name, dns.rdatatype.DNSKEY): dnskey_response(tld), + (leaf.name, dns.rdatatype.DNSKEY): dnskey_response(leaf), + } + state = {"step": 0} + + def send(qname, rdtype, nameservers, ctx): + key = (qname, rdtype) + if key in table: + return table[key], "9.9.9.9" + state["step"] += 1 + if state["step"] == 1: + return referral("test.", root_ds, root), "198.41.0.4" + if state["step"] == 2: + return referral("example.test.", tld_ds, tld), "9.9.9.9" + return answer_response, "9.9.9.9" + + resolver = self._resolver(root) + with patch.object(resolver, "_send_query", side_effect=send): + result = resolver.resolve_answer("example.test.", "A") + + assert result.records == ["192.0.2.10"] + assert result.dnssec is ValidationState.SECURE + assert result.secure is True + # The validated keys must have been cached for reuse. + assert resolver._cached_keys(leaf.name) is not None + + def test_a_tampered_answer_in_a_signed_chain_is_rejected(self) -> None: + root = SignedZone.create(".") + leaf = SignedZone.create("test.") + genuine = rrset_of("test.", "A", "192.0.2.10") + forged = rrset_of("test.", "A", "198.51.100.66") + + def dnskey_response(zone: SignedZone): + response = make_response() + response.answer.append(zone.dnskey_rrset) + response.answer.append(zone.signed_dnskey()) + return response + + ds = root.ds_rrset(leaf) + referral = make_response( + authority=[("test.", 3600, "NS", ["ns.test."])], + additional=[("ns.test.", 3600, "A", ["9.9.9.9"])], + aa=False, + ) + referral.authority.append(ds) + referral.authority.append(root.sign(ds)) + + # The attacker swaps the data but keeps the genuine signature. + tampered = make_response() + tampered.answer.append(forged) + tampered.answer.append(leaf.sign(genuine)) + + table = { + (dns.name.root, dns.rdatatype.DNSKEY): dnskey_response(root), + (leaf.name, dns.rdatatype.DNSKEY): dnskey_response(leaf), + } + state = {"step": 0} + + def send(qname, rdtype, nameservers, ctx): + if (qname, rdtype) in table: + return table[(qname, rdtype)], "9.9.9.9" + state["step"] += 1 + return (referral, "198.41.0.4") if state["step"] == 1 else (tampered, "9.9.9.9") + + resolver = self._resolver(root) + with ( + patch.object(resolver, "_send_query", side_effect=send), + pytest.raises(DNSSECValidationError, match="RRSIG did not verify"), + ): + resolver.resolve("test.", "A") + + def test_require_dnssec_rejects_an_unsigned_answer(self) -> None: + resolver = RecursiveResolver(dnssec=False, cache_enabled=False, require_dnssec=True) + response = make_response(answer=[("example.com.", 300, "A", ["1.2.3.4"])]) + chain = [ + ( + make_response( + authority=[("com.", 3600, "NS", ["a.gtld.net."])], + additional=[("a.gtld.net.", 3600, "A", ["192.5.6.30"])], + aa=False, + ), + "198.41.0.4", + ), + ( + make_response( + authority=[("example.com.", 3600, "NS", ["ns.example.com."])], + additional=[("ns.example.com.", 3600, "A", ["9.9.9.9"])], + aa=False, + ), + "192.5.6.30", + ), + (response, "9.9.9.9"), + ] + step = {"i": 0} + + def send(qname, rdtype, nameservers, ctx): + i = step["i"] + step["i"] += 1 + return chain[i] if i < len(chain) else (None, "") + + with ( + patch.object(resolver, "_send_query", side_effect=send), + pytest.raises(DNSSECInsecureError), + ): + resolver.resolve("example.com", "A") + + +class TestWildcardExpansion: + """RFC 4035 §5.3.4: a wildcard answer needs a proof that nothing closer exists. + + An RRSIG whose ``labels`` count is below the owner's verifies against the + reconstructed ``*.``, so the same signature verifies for + every name the wildcard could cover. Without the extra proof, a genuine + wildcard record replayed under another owner name validates, overriding + whatever explicit data that name really holds. + """ + + @staticmethod + def _zone_with_wildcard(): + zone = SignedZone.create("example.test.") + wild = rrset_of("*.example.test.", "A", "192.0.2.99") + return zone, wild, zone.sign(wild) + + @staticmethod + def _covering_nsec(zone: SignedZone): + """One NSEC from the apex to z., covering both the qname and the wildcard.""" + nsec = rrset_of("example.test.", "NSEC", "z.example.test. A RRSIG NSEC") + return [nsec, zone.sign(nsec)] + + def test_the_signature_alone_verifies_for_a_foreign_owner(self) -> None: + """The premise: crypto cannot distinguish these, which is why proof is needed.""" + zone, _, wild_sig = self._zone_with_wildcard() + forged = rrset_of("secure.example.test.", "A", "192.0.2.99") + assert DNSSECValidator().validate_rrset(forged, wild_sig, zone.keyring()) is True + + def test_a_wildcard_answer_with_a_covering_proof_is_accepted(self) -> None: + zone, _, wild_sig = self._zone_with_wildcard() + qname = dns.name.from_text("anything.example.test.") + assert DNSSECValidator().prove_wildcard(qname, wild_sig[0].labels, self._covering_nsec(zone), zone.keyring()) + + def test_a_wildcard_answer_with_no_proof_at_all_is_refused(self) -> None: + zone, _, wild_sig = self._zone_with_wildcard() + qname = dns.name.from_text("anything.example.test.") + assert not DNSSECValidator().prove_wildcard(qname, wild_sig[0].labels, [], zone.keyring()) + + def test_an_unsigned_proof_proves_nothing(self) -> None: + zone, _, wild_sig = self._zone_with_wildcard() + nsec = rrset_of("example.test.", "NSEC", "z.example.test. A RRSIG NSEC") + qname = dns.name.from_text("anything.example.test.") + assert not DNSSECValidator().prove_wildcard(qname, wild_sig[0].labels, [nsec], zone.keyring()) + + def test_a_proof_that_does_not_cover_the_name_is_refused(self) -> None: + """An NSEC from a different part of the zone must not satisfy the check.""" + zone, _, wild_sig = self._zone_with_wildcard() + nsec = rrset_of("aa.example.test.", "NSEC", "ab.example.test. A RRSIG NSEC") + qname = dns.name.from_text("zz.example.test.") + assert not DNSSECValidator().prove_wildcard(qname, wild_sig[0].labels, [nsec, zone.sign(nsec)], zone.keyring()) + + @staticmethod + def _wildcard_chain(*, proof: bool): + """A signed zone answering `victim.test.` from its `*.test.` wildcard. + + With ``proof=False`` this is the attack: the wildcard's data and + signature are presented under a name that really has its own, different + record, and no denial proof accompanies them. On the wire the RRSIG + owner must match the RRset it covers, so the owner is rewritten too; the + rdata (labels=2) is untouched, and that is what still verifies. + """ + root = SignedZone.create(".") + leaf = SignedZone.create("test.") + wild = rrset_of("*.test.", "A", "192.0.2.99") + wild_sig = leaf.sign(wild) + answer = rrset_of("victim.test.", "A", "192.0.2.99") + answer_sig = dns.rrset.RRset(dns.name.from_text("victim.test."), dns.rdataclass.IN, dns.rdatatype.RRSIG) + answer_sig.add(wild_sig[0]) + answer_sig.ttl = wild_sig.ttl + + def dnskey_response(zone: SignedZone): + response = make_response() + response.answer.append(zone.dnskey_rrset) + response.answer.append(zone.signed_dnskey()) + return response + + ds = root.ds_rrset(leaf) + delegation = make_response( + authority=[("test.", 3600, "NS", ["ns.test."])], + additional=[("ns.test.", 3600, "A", ["9.9.9.9"])], + aa=False, + ) + delegation.authority.append(ds) + delegation.authority.append(root.sign(ds)) + + answer_response = make_response() + answer_response.answer.append(answer) + answer_response.answer.append(answer_sig) + if proof: + # Covers `victim.test.`, so no closer match could have existed. + nsec = rrset_of("test.", "NSEC", "z.test. A RRSIG NSEC") + answer_response.authority.append(nsec) + answer_response.authority.append(leaf.sign(nsec)) + + table = { + (dns.name.root, dns.rdatatype.DNSKEY): dnskey_response(root), + (leaf.name, dns.rdatatype.DNSKEY): dnskey_response(leaf), + } + state = {"step": 0} + + def send(qname, rdtype, nameservers, ctx): + if (qname, rdtype) in table: + return table[(qname, rdtype)], "9.9.9.9" + state["step"] += 1 + if state["step"] == 1: + return delegation, "198.41.0.4" + return answer_response, "9.9.9.9" + + resolver = RecursiveResolver(max_resolution_time=5, trust_anchors=(root.anchor_text(),)) + return resolver, send + + def test_a_proven_wildcard_answer_validates_secure(self) -> None: + """A legitimate wildcard answer carrying its proof must still work.""" + resolver, send = self._wildcard_chain(proof=True) + with patch.object(resolver, "_send_query", side_effect=send): + result = resolver.resolve_answer("victim.test.", "A") + assert result.records == ["192.0.2.99"] + assert result.dnssec is ValidationState.SECURE + + def test_the_resolver_refuses_an_unproven_wildcard_answer(self) -> None: + """End to end: a replayed wildcard signature must not validate as SECURE.""" + resolver, send = self._wildcard_chain(proof=False) + with ( + patch.object(resolver, "_send_query", side_effect=send), + pytest.raises(DNSSECValidationError, match="wildcard"), + ): + resolver.resolve("victim.test.", "A") + + +class TestSignedDenialThroughTheResolveLoop: + """The denial proof must be demanded by the resolve loop, not just testable. + + `_verify_denial` is well covered on its own, but nothing drove a signed + negative answer through `resolve()`, so the wiring that connects the two + was unguarded: deleting the call broke no test. + """ + + @staticmethod + def _chain(negative_response): + root = SignedZone.create(".") + leaf = SignedZone.create("test.") + ds = root.ds_rrset(leaf) + + def dnskey_response(zone: SignedZone): + response = make_response() + response.answer.append(zone.dnskey_rrset) + response.answer.append(zone.signed_dnskey()) + return response + + delegation = make_response( + authority=[("test.", 3600, "NS", ["ns.test."])], + additional=[("ns.test.", 3600, "A", ["9.9.9.9"])], + aa=False, + ) + delegation.authority.append(ds) + delegation.authority.append(root.sign(ds)) + + table = { + (dns.name.root, dns.rdatatype.DNSKEY): dnskey_response(root), + (leaf.name, dns.rdatatype.DNSKEY): dnskey_response(leaf), + } + state = {"step": 0} + + def send(qname, rdtype, nameservers, ctx): + if (qname, rdtype) in table: + return table[(qname, rdtype)], "9.9.9.9" + state["step"] += 1 + if state["step"] == 1: + return delegation, "198.41.0.4" + return negative_response(leaf), "9.9.9.9" + + resolver = RecursiveResolver(max_resolution_time=5, trust_anchors=(root.anchor_text(),)) + return resolver, send + + @staticmethod + def _nxdomain(leaf: SignedZone, *, proof: bool): + response = make_response( + authority=[("test.", 300, "SOA", ["ns.test. a.test. 1 3600 900 604800 300"])], + rcode=dns.rcode.NXDOMAIN, + ) + response.authority.append(leaf.sign(response.authority[0])) + if proof: + # One NSEC from the apex covers both the missing name and the + # wildcard that could otherwise have synthesised it. + nsec = rrset_of("test.", "NSEC", "z.test. A RRSIG NSEC") + response.authority.append(nsec) + response.authority.append(leaf.sign(nsec)) + return response + + @staticmethod + def _nodata(leaf: SignedZone, *, proof: bool): + response = make_response( + authority=[("test.", 300, "SOA", ["ns.test. a.test. 1 3600 900 604800 300"])], + ) + response.authority.append(leaf.sign(response.authority[0])) + if proof: + nsec = rrset_of("missing.test.", "NSEC", "z.test. A RRSIG NSEC") + response.authority.append(nsec) + response.authority.append(leaf.sign(nsec)) + return response + + def test_a_proven_nxdomain_is_accepted(self) -> None: + resolver, send = self._chain(lambda leaf: self._nxdomain(leaf, proof=True)) + with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(NXDOMAINError): + resolver.resolve("missing.test.", "A") + + def test_an_unproven_nxdomain_in_a_signed_zone_is_refused(self) -> None: + resolver, send = self._chain(lambda leaf: self._nxdomain(leaf, proof=False)) + with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(DNSSECValidationError): + resolver.resolve("missing.test.", "A") + + def test_an_unproven_nodata_in_a_signed_zone_is_refused(self) -> None: + resolver, send = self._chain(lambda leaf: self._nodata(leaf, proof=False)) + with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(DNSSECValidationError): + resolver.resolve("missing.test.", "MX") + + +class TestUnverifiableZoneResolvesAsUnsigned: + """RFC 4035 §5.2 end to end, on every path that consults a zone's keys. + + A zone signed only with an algorithm this build cannot verify must resolve + exactly like an unsigned zone: an answer, marked INSECURE. Reporting BOGUS + would take a legitimately signed domain off the air for our users. + """ + + @staticmethod + def _chain(final_response_for): + """Root -> test. -> answer, with `test.` signed but unverifiable.""" + root = SignedZone.create(".") + leaf = SignedZone.create("test.") + ds = root.ds_rrset(leaf) + + def dnskey_response(zone: SignedZone): + response = make_response() + response.answer.append(zone.dnskey_rrset) + response.answer.append(zone.signed_dnskey()) + return response + + delegation = make_response( + authority=[("test.", 3600, "NS", ["ns.test."])], + additional=[("ns.test.", 3600, "A", ["9.9.9.9"])], + aa=False, + ) + delegation.authority.append(ds) + delegation.authority.append(root.sign(ds)) + + table = { + (dns.name.root, dns.rdatatype.DNSKEY): dnskey_response(root), + (leaf.name, dns.rdatatype.DNSKEY): dnskey_response(leaf), + } + state = {"step": 0} + + def send(qname, rdtype, nameservers, ctx): + if (qname, rdtype) in table: + return table[(qname, rdtype)], "9.9.9.9" + state["step"] += 1 + if state["step"] == 1: + return delegation, "198.41.0.4" + return final_response_for(leaf), "9.9.9.9" + + resolver = RecursiveResolver(max_resolution_time=5, trust_anchors=(root.anchor_text(),)) + return resolver, send, leaf + + @staticmethod + def _unverifiable(resolver: RecursiveResolver, leaf: SignedZone): + """Make only the leaf zone's keys unverifiable; the root stays sound.""" + real = resolver._validator.validate_dnskey + + def stub(zone, dnskey_rrset, rrsig_rrset, ds_rdataset, budget=None): + if zone == leaf.name: + return ValidationState.INSECURE + return real(zone, dnskey_rrset, rrsig_rrset, ds_rdataset, budget=budget) + + return patch.object(resolver._validator, "validate_dnskey", side_effect=stub) + + def test_a_positive_answer_is_insecure(self) -> None: + def answer(leaf: SignedZone): + response = make_response() + rrset = rrset_of("test.", "A", "192.0.2.10") + response.answer.append(rrset) + response.answer.append(leaf.sign(rrset)) + return response + + resolver, send, leaf = self._chain(answer) + with patch.object(resolver, "_send_query", side_effect=send), self._unverifiable(resolver, leaf): + result = resolver.resolve_answer("test.", "A") + assert result.records == ["192.0.2.10"] + assert result.dnssec is ValidationState.INSECURE + + def test_an_nxdomain_is_accepted_without_a_proof(self) -> None: + """We cannot check the denial either, so it must not be called forged.""" + + def nxdomain(leaf: SignedZone): + response = make_response( + authority=[("test.", 300, "SOA", ["ns.test. a.test. 1 3600 900 604800 300"])], + rcode=dns.rcode.NXDOMAIN, + ) + response.authority.append(leaf.sign(response.authority[0])) + return response + + resolver, send, leaf = self._chain(nxdomain) + with ( + patch.object(resolver, "_send_query", side_effect=send), + self._unverifiable(resolver, leaf), + pytest.raises(NXDOMAINError), + ): + resolver.resolve("missing.test.", "A") + + def test_require_dnssec_still_refuses_it(self) -> None: + """Insecure is not secure: a caller demanding authentication gets none.""" + + def answer(leaf: SignedZone): + response = make_response() + rrset = rrset_of("test.", "A", "192.0.2.10") + response.answer.append(rrset) + response.answer.append(leaf.sign(rrset)) + return response + + resolver, send, leaf = self._chain(answer) + resolver.require_dnssec = True + with ( + patch.object(resolver, "_send_query", side_effect=send), + self._unverifiable(resolver, leaf), + pytest.raises(DNSSECInsecureError), + ): + resolver.resolve("test.", "A") diff --git a/tests/test_integration.py b/tests/test_integration.py index ea6fe74..faae977 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -7,82 +7,197 @@ import pytest -from recursive_resolver import NXDOMAINError, RecursiveResolver, TraceStep +from recursive_resolver import ( + DNSSECInsecureError, + DNSSECValidationError, + NXDOMAINError, + RecursiveResolver, + TraceStep, + ValidationState, +) pytestmark = pytest.mark.integration @pytest.fixture def resolver() -> RecursiveResolver: - return RecursiveResolver(timeout=5.0, ipv4_only=True) + return RecursiveResolver(timeout=3.0, ipv4_only=True, max_resolution_time=20.0) class TestRealResolution: def test_a_record(self, resolver: RecursiveResolver) -> None: - result = resolver.resolve("sylvainzimmer.com", "A") - assert len(result) > 0 - # Should be valid IPv4 addresses - for ip in result: - parts = ip.split(".") - assert len(parts) == 4 + result = resolver.resolve("example.com", "A") + assert result and all(len(ip.split(".")) == 4 for ip in result) def test_aaaa_record(self, resolver: RecursiveResolver) -> None: - result = resolver.resolve("google.com", "AAAA") - assert len(result) > 0 - # IPv6 addresses contain colons - for ip in result: - assert ":" in ip + assert all(":" in ip for ip in resolver.resolve("google.com", "AAAA")) def test_mx_record(self, resolver: RecursiveResolver) -> None: - result = resolver.resolve("google.com", "MX") - assert len(result) > 0 - # MX records have priority + hostname - for mx in result: - parts = mx.split() - assert len(parts) == 2 - assert parts[0].isdigit() + for mx in resolver.resolve("google.com", "MX"): + priority, host = mx.split() + assert priority.isdigit() and host.endswith(".") def test_txt_record(self, resolver: RecursiveResolver) -> None: - result = resolver.resolve("google.com", "TXT") - assert len(result) > 0 + assert resolver.resolve("google.com", "TXT") def test_ns_record(self, resolver: RecursiveResolver) -> None: - result = resolver.resolve("google.com", "NS") - assert len(result) > 0 + assert resolver.resolve("google.com", "NS") + + def test_soa_record(self, resolver: RecursiveResolver) -> None: + assert resolver.resolve("google.com", "SOA") + + def test_caa_record(self, resolver: RecursiveResolver) -> None: + assert resolver.resolve("cloudflare.com", "CAA") def test_cname_chain(self, resolver: RecursiveResolver) -> None: - # www.github.com is a well-known CNAME - result = resolver.resolve("www.github.com", "A") - assert len(result) > 0 + assert resolver.resolve("www.github.com", "A") + + def test_ptr_record(self, resolver: RecursiveResolver) -> None: + result = resolver.resolve("8.8.8.8", "PTR") + assert any("google" in r.lower() or "dns" in r.lower() for r in result) def test_nxdomain(self, resolver: RecursiveResolver) -> None: with pytest.raises(NXDOMAINError): resolver.resolve("this-domain-definitely-does-not-exist-xyz123.com", "A") - def test_ptr_record(self, resolver: RecursiveResolver) -> None: - result = resolver.resolve("8.8.8.8", "PTR") - assert len(result) > 0 - # Should resolve to dns.google or similar - assert any("google" in r.lower() or "dns" in r.lower() for r in result) + def test_tld_apex(self, resolver: RecursiveResolver) -> None: + assert resolver.resolve("com", "NS") - def test_soa_record(self, resolver: RecursiveResolver) -> None: - result = resolver.resolve("google.com", "SOA") - assert len(result) > 0 + def test_root_apex(self, resolver: RecursiveResolver) -> None: + assert resolver.resolve(".", "NS") + + def test_idn_unicode_and_punycode_agree(self, resolver: RecursiveResolver) -> None: + assert resolver.resolve("bücher.de", "A") == resolver.resolve("xn--bcher-kva.de", "A") + + +class TestRealDNSSEC: + """DNSSEC must accept valid zones, reject bogus ones and allow unsigned ones.""" + + @pytest.mark.parametrize("domain", ["cloudflare.com", "ietf.org", "nlnetlabs.nl", "internetsociety.org"]) + def test_signed_zones_validate(self, resolver: RecursiveResolver, domain: str) -> None: + assert resolver.resolve_answer(domain, "A").dnssec is ValidationState.SECURE + @pytest.mark.parametrize("domain", ["google.com", "github.com", "amazon.com"]) + def test_unsigned_zones_resolve_as_insecure(self, resolver: RecursiveResolver, domain: str) -> None: + assert resolver.resolve_answer(domain, "A").dnssec is ValidationState.INSECURE + + @pytest.mark.parametrize("domain", ["dnssec-failed.org", "rhybar.cz", "bogus.nlnetlabs.nl"]) + def test_bogus_zones_are_rejected(self, resolver: RecursiveResolver, domain: str) -> None: + with pytest.raises(DNSSECValidationError): + resolver.resolve(domain, "A") + + def test_zone_cut_shared_with_parent(self, resolver: RecursiveResolver) -> None: + """.cz serves nic.cz from the same servers, hiding the intermediate cut.""" + assert resolver.resolve_answer("nic.cz", "A").dnssec is ValidationState.SECURE + + def test_multi_label_zone_cut(self, resolver: RecursiveResolver) -> None: + """uk. and co.uk. are served by the same nameservers.""" + assert resolver.resolve("bbc.co.uk", "A") + + def test_require_dnssec_rejects_unsigned(self) -> None: + strict = RecursiveResolver(require_dnssec=True, max_resolution_time=20.0) + with pytest.raises(DNSSECInsecureError): + strict.resolve("google.com", "A") + + def test_require_dnssec_accepts_signed(self) -> None: + strict = RecursiveResolver(require_dnssec=True, max_resolution_time=20.0) + assert strict.resolve("cloudflare.com", "A") + + def test_dnssec_can_be_disabled(self) -> None: + plain = RecursiveResolver(dnssec=False, max_resolution_time=20.0) + assert plain.resolve_answer("dnssec-failed.org", "A").dnssec is ValidationState.INSECURE + + +class TestRealDKIM: + """The headline use case: fetching DKIM keys correctly.""" + + @pytest.mark.parametrize( + "selector", + ["s1._domainkey.stripe.com", "k1._domainkey.mailchimp.com", "zendesk1._domainkey.zendesk.com"], + ) + def test_dkim_key_is_retrievable(self, resolver: RecursiveResolver, selector: str) -> None: + answer = resolver.resolve_answer(selector, "TXT") + values = answer.text_values() + assert values and any("k=rsa" in v or "v=DKIM1" in v for v in values) + + def test_multi_chunk_key_has_no_separator(self, resolver: RecursiveResolver) -> None: + """An RSA-2048 key is split across chunks; joining must add nothing.""" + answer = resolver.resolve_answer("zendesk1._domainkey.zendesk.com", "TXT") + value = answer.text_values()[0] + assert '" "' not in value + assert " " not in value.split("p=")[1], "base64 key must not contain a chunk seam" + + def test_dmarc_record(self, resolver: RecursiveResolver) -> None: + assert any("DMARC1" in v for v in resolver.resolve_answer("_dmarc.google.com", "TXT").text_values()) + + +class TestRealTraceAndCache: def test_trace(self, resolver: RecursiveResolver) -> None: - trace = resolver.resolve_with_trace("example.com", "A") - assert len(trace) >= 2 # At least root referral + answer + answer, trace = resolver.trace_answer("example.com", "A") + assert answer is not None + assert len(trace) >= 2 assert all(isinstance(step, TraceStep) for step in trace) - # First step should be from a root server assert trace[0].response_type == "referral" - # Last step should be an answer assert trace[-1].response_type == "answer" def test_cache_speedup(self, resolver: RecursiveResolver) -> None: - # First query populates cache - result1 = resolver.resolve("example.com", "A") - # Second query should hit cache - result2 = resolver.resolve("example.com", "A") - assert result1 == result2 + first = resolver.resolve("example.com", "A") + assert resolver.resolve("example.com", "A") == first assert resolver.cache is not None assert resolver.cache.stats.hits > 0 + + def test_delegation_cache_skips_the_root(self, resolver: RecursiveResolver) -> None: + """A second lookup under the same TLD must not re-query a root server.""" + resolver.resolve("example.com", "A") + _answer, trace = resolver.trace_answer("iana.org", "A") + resolver.resolve("wikipedia.org", "A") + _answer2, trace2 = resolver.trace_answer("en.wikipedia.org", "A") + assert trace2, "expected a trace" + assert trace2[0].server not in resolver._root_addresses + + +class TestRealDowngradeResistance: + """An INSECURE verdict must be earned by a validated proof, never assumed. + + If a missing DS were enough on its own, an attacker who can strip records + could downgrade any signed zone to unsigned and then serve forged data. + """ + + def test_insecure_requires_a_validated_no_ds_proof(self, resolver: RecursiveResolver) -> None: + import dns.flags + import dns.message + import dns.name + import dns.query + import dns.rdatatype + + from recursive_resolver.dnssec import ValidationState + + # google.com is genuinely unsigned: .com carries no DS for it, only an + # NSEC3 opt-out proof that none exists. + assert resolver.resolve_answer("google.com", "A").dnssec is ValidationState.INSECURE + + plain = RecursiveResolver(dnssec=False, max_resolution_time=20.0) + com_ns = plain.resolve("a.gtld-servers.net", "A")[0] + query = dns.message.make_query("google.com", dns.rdatatype.A, use_edns=0, payload=1232, want_dnssec=True) + query.flags &= ~dns.flags.RD + referral = dns.query.udp_with_fallback(query, com_ns, timeout=5)[0] + + com = dns.name.from_text("com.") + google = dns.name.from_text("google.com.") + keys = resolver._cached_keys(com) + assert keys is not None and keys.state is ValidationState.SECURE, "com. keys should be validated by now" + keyring = keys.as_keyring() + + # With the real proof: insecure. + state, _ds = resolver._validator.validate_ds(google, list(referral.authority), keyring) + assert state is ValidationState.INSECURE + + # Proof stripped entirely: bogus, not a silent downgrade. + stripped = [s for s in referral.authority if s.rdtype not in (dns.rdatatype.NSEC3, dns.rdatatype.RRSIG)] + state, _ds = resolver._validator.validate_ds(google, stripped, keyring) + assert state is ValidationState.BOGUS + + # Proof present but unsigned (forged): bogus. + unsigned = [s for s in referral.authority if s.rdtype != dns.rdatatype.RRSIG] + state, _ds = resolver._validator.validate_ds(google, unsigned, keyring) + assert state is ValidationState.BOGUS diff --git a/tests/test_internals.py b/tests/test_internals.py new file mode 100644 index 0000000..5d738c6 --- /dev/null +++ b/tests/test_internals.py @@ -0,0 +1,729 @@ +"""Coverage of construction, configuration and the less-travelled error paths. + +Error paths in a resolver are exactly the paths a hostile zone drives, so they +are exercised explicitly rather than left to chance. +""" + +from __future__ import annotations + +import subprocess +import sys +import threading +import time +from unittest.mock import patch + +import dns.exception +import dns.name +import dns.rcode +import dns.rdatatype +import pytest +from conftest import make_response, offline_resolver, referral, root_to_com + +from recursive_resolver import ( + AddressFilter, + CNAMELoopError, + DNSSECUnavailableError, + Limits, + NXDOMAINError, + QueryBudgetExceededError, + RecursiveResolver, + ResolutionTimeoutError, + ServfailError, + UnsupportedRdtypeError, + ValidationState, +) +from recursive_resolver.budget import QueryBudget +from recursive_resolver.cache import CacheStats, Delegation, DNSCache +from recursive_resolver.dnssec import ZoneKeys +from recursive_resolver.roots import ROOT_SERVERS, get_root_addresses +from recursive_resolver.singleflight import SingleFlight + +EXAMPLE = dns.name.from_text("example.com.") + + +class TestEntryPoints: + def test_module_execution(self) -> None: + """`python -m recursive_resolver` must work as an entry point.""" + result = subprocess.run( + [sys.executable, "-m", "recursive_resolver", "--version"], + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0 + assert "recursive-resolver" in result.stdout + + def test_importing_main_does_not_run_the_cli(self) -> None: + """Importing the module must not exit the interpreter.""" + result = subprocess.run( + [sys.executable, "-c", "import recursive_resolver.__main__; print('imported')"], + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0 + assert "imported" in result.stdout + + +class TestRoots: + def test_ipv4_only(self) -> None: + addresses = get_root_addresses(ipv4_only=True) + assert len(addresses) == 13 + assert all(":" not in a for a in addresses) + + def test_dual_stack(self) -> None: + addresses = get_root_addresses(ipv4_only=False) + assert len(addresses) == 26 + assert sum(":" in a for a in addresses) == 13 + + def test_every_root_letter_is_present(self) -> None: + assert sorted(name[0] for name in ROOT_SERVERS) == list("abcdefghijklm") + + +class TestAddressFilterEdges: + def test_allow_private_still_rejects_garbage(self) -> None: + assert AddressFilter(allow_private=True).is_allowed("not-an-ip") is False + + def test_allow_private_accepts_loopback(self) -> None: + assert AddressFilter(allow_private=True).is_allowed("127.0.0.1") is True + + def test_extra_networks_are_additive(self) -> None: + f = AddressFilter(extra_blocked_networks=["8.8.8.0/24"]) + assert f.is_allowed("8.8.8.8") is False + assert f.is_allowed("192.88.99.1") is False + assert f.is_allowed("1.1.1.1") is True + + def test_multicast_and_unspecified_are_rejected(self) -> None: + f = AddressFilter() + assert f.is_allowed("224.0.0.1") is False + assert f.is_allowed("::") is False + + +class TestBudgetEdges: + def test_referral_budget(self) -> None: + budget = QueryBudget(max_referrals=2) + budget.note_referral("a.", "A") + budget.note_referral("a.", "A") + with pytest.raises(QueryBudgetExceededError) as exc: + budget.note_referral("a.", "A") + assert "referrals" in str(exc.value) + + def test_remaining_and_time(self) -> None: + budget = QueryBudget(max_queries=3, deadline=time.monotonic() + 60) + assert budget.remaining_queries() == 3 + budget.spend_query("a.", "A") + assert budget.remaining_queries() == 2 + assert budget.expired() is False + assert budget.time_remaining() > 0 + + def test_expired_budget(self) -> None: + budget = QueryBudget(deadline=time.monotonic() - 1) + assert budget.expired() is True + assert budget.time_remaining() == 0.0 + + +class TestCacheEdges: + def test_hit_rate_of_an_untouched_cache(self) -> None: + assert CacheStats().hit_rate == 0.0 + + def test_rdtype_accepts_text(self) -> None: + cache = DNSCache() + cache.put_answer(EXAMPLE, "MX", "rrset", ttl=300) + assert cache.get_answer(EXAMPLE, "MX") is not None + + def test_qname_accepts_text(self) -> None: + cache = DNSCache() + cache.put_answer("example.com.", dns.rdatatype.A, "rrset", ttl=300) + assert cache.get_answer(EXAMPLE, dns.rdatatype.A) is not None + + def test_delegation_lookup_for_an_unknown_zone(self) -> None: + assert DNSCache().get_delegation(EXAMPLE) is None + + def test_delegation_depth_none_caches_every_level(self) -> None: + cache = DNSCache(max_delegation_depth=None) + deep = dns.name.from_text("a.b.c.example.com.") + cache.put_delegation(Delegation(zone=deep, addresses=["9.9.9.9"]), ttl=300) + assert cache.get_delegation(deep) is not None + + +class TestSingleFlightEdges: + def test_slow_leader_does_not_block_a_waiter_forever(self) -> None: + sf: SingleFlight[str] = SingleFlight() + release = threading.Event() + started = threading.Event() + + def slow() -> str: + started.set() + release.wait(timeout=5) + return "leader" + + leader = threading.Thread(target=lambda: sf.do("k", slow)) + leader.start() + started.wait(timeout=2) + # The waiter gives up on the leader and does the work itself. + assert sf.do("k", lambda: "self-served", wait_timeout=0.05) == "self-served" + release.set() + leader.join() + + +class TestConstruction: + def test_dnssec_without_cryptography_is_refused_clearly(self) -> None: + with ( + patch("recursive_resolver.resolver.cryptography_available", return_value=False), + pytest.raises(DNSSECUnavailableError) as exc, + ): + RecursiveResolver(dnssec=True) + assert "cryptography" in str(exc.value) + + def test_dnssec_off_works_without_cryptography(self) -> None: + with patch("recursive_resolver.resolver.cryptography_available", return_value=False): + assert RecursiveResolver(dnssec=False) is not None + + def test_an_empty_trust_anchor_tuple_is_not_a_silent_fallback(self) -> None: + """() must not quietly mean "use the IANA anchors", which is its opposite.""" + with pytest.raises(ValueError, match="must not be empty"): + RecursiveResolver(dnssec=True, trust_anchors=()) + + def test_trust_anchors_none_uses_the_iana_defaults(self) -> None: + assert len(RecursiveResolver(dnssec=True, trust_anchors=None)._validator._root_ds) == 2 + + def test_idna_2008_is_preferred(self) -> None: + assert RecursiveResolver._default_idna_codec() is dns.name.IDNA_2008_Practical + + def test_idna_2003_fallback_warns(self, caplog: pytest.LogCaptureFixture) -> None: + """Without the idna package we must say so: the results differ.""" + with patch.object(dns.name, "have_idna_2008", False), caplog.at_level("WARNING"): + codec = RecursiveResolver._default_idna_codec() + assert codec is dns.name.IDNA_2003 + assert "IDNA 2008 unavailable" in caplog.text + + def test_ipv6_mode_includes_v6_roots(self) -> None: + resolver = offline_resolver(ipv4_only=False) + assert any(":" in a for a in resolver._root_addresses) + + +class TestInputValidationEdges: + def test_meta_rdtypes_are_refused(self) -> None: + with pytest.raises(UnsupportedRdtypeError) as exc: + offline_resolver().resolve("example.com", "AXFR") + assert "meta" in str(exc.value) + + def test_any_is_permitted(self) -> None: + assert offline_resolver()._parse_rdtype("ANY") == dns.rdatatype.ANY + + def test_relative_names_are_made_absolute(self) -> None: + assert offline_resolver()._normalize_qname("example.com", "A").is_absolute() + + def test_non_string_input(self) -> None: + from recursive_resolver import InvalidNameError + + with pytest.raises(InvalidNameError): + offline_resolver().resolve(None, "A") # type: ignore[arg-type] + + def test_idna_failure_is_reported_as_an_invalid_name(self) -> None: + from recursive_resolver import InvalidNameError + + resolver = offline_resolver() + with ( + patch("dns.name.from_text", side_effect=UnicodeError("bad idna")), + pytest.raises(InvalidNameError) as exc, + ): + resolver.resolve("bad­.example", "A") + assert "IDNA" in str(exc.value) + + def test_other_dns_errors_are_reported_as_invalid_names(self) -> None: + from recursive_resolver import InvalidNameError + + resolver = offline_resolver() + with ( + patch("dns.name.from_text", side_effect=dns.exception.SyntaxError("nope")), + pytest.raises(InvalidNameError), + ): + resolver.resolve("weird.example", "A") + + +class TestLoopErrorPaths: + def test_deadline_inside_the_delegation_loop(self) -> None: + resolver = offline_resolver(max_resolution_time=0.05) + calls = 0 + + def send(qname, rdtype, nameservers, ctx): + nonlocal calls + calls += 1 + time.sleep(0.06) + return root_to_com(), "198.41.0.4" + + with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(ResolutionTimeoutError): + resolver.resolve("example.com", "A") + + def test_stale_glue_falls_back_to_resolving_ns_names(self) -> None: + """Dead glue must not end the resolution while a live NS name exists.""" + resolver = offline_resolver() + + def send(qname, rdtype, nameservers, ctx): + if qname == dns.name.from_text("ns2.otherdns.net."): + return make_response(answer=[("ns2.otherdns.net.", 300, "A", ["9.9.9.9"])]), "1.1.1.1" + if nameservers and nameservers[0] in resolver._root_addresses: + return root_to_com(), nameservers[0] + if nameservers == ["192.5.6.30"]: + return ( + referral( + "example.com.", + ["ns1.example.com.", "ns2.otherdns.net."], + {"ns1.example.com.": "203.0.113.9"}, # filtered: TEST-NET-3 + ), + "192.5.6.30", + ) + if nameservers == ["9.9.9.9"]: + return make_response(answer=[("example.com.", 300, "A", ["1.2.3.4"])]), "9.9.9.9" + return (None, "") + + with patch.object(resolver, "_send_query", side_effect=send): + assert resolver.resolve("example.com", "A") == ["1.2.3.4"] + + def test_dead_glue_with_no_alternative_fails_boundedly(self) -> None: + """Dead glue plus an unresolvable NS name must fail without spinning.""" + from recursive_resolver import ResolverError + + resolver = offline_resolver() + calls = 0 + + def send(qname, rdtype, nameservers, ctx): + nonlocal calls + calls += 1 + if nameservers and nameservers[0] in resolver._root_addresses: + return root_to_com(), nameservers[0] + if nameservers == ["192.5.6.30"]: + return referral("example.com.", ["ns1.example.com."], {"ns1.example.com.": "9.9.9.9"}), "192.5.6.30" + return (None, "") + + with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(ResolverError): + resolver.resolve("example.com", "A") + assert calls < 200, f"stale-glue fallback was not bounded: {calls} queries" + + def test_cname_chain_limit(self) -> None: + resolver = offline_resolver(max_cname_chain=2) + + def send(qname, rdtype, nameservers, ctx): + if nameservers and nameservers[0] in resolver._root_addresses: + return root_to_com(), nameservers[0] + if nameservers == ["192.5.6.30"]: + return referral("example.com.", ["ns1.example.com."], {"ns1.example.com.": "9.9.9.9"}), "192.5.6.30" + n = int(str(qname).split(".")[0][1:]) + return make_response(answer=[(str(qname), 300, "CNAME", [f"c{n + 1}.example.com."])]), "9.9.9.9" + + with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(CNAMELoopError): + resolver.resolve("c0.example.com", "A") + + def test_nxdomain_without_aa_is_not_trusted(self) -> None: + resolver = offline_resolver() + classification = resolver._classify_response( + make_response(rcode=dns.rcode.NXDOMAIN, aa=False), EXAMPLE, dns.rdatatype.A, EXAMPLE + ) + assert classification["type"] == "error" + assert "AA" in classification["detail"] + + def test_nodata_without_aa_is_not_trusted(self) -> None: + resolver = offline_resolver() + response = make_response( + authority=[("example.com.", 300, "SOA", ["ns1.example.com. a.example.com. 1 3600 900 604800 86400"])], + aa=False, + ) + classification = resolver._classify_response(response, EXAMPLE, dns.rdatatype.A, EXAMPLE) + assert classification["type"] == "error" + + def test_require_authoritative_can_be_relaxed(self) -> None: + resolver = offline_resolver(require_authoritative=False) + classification = resolver._classify_response( + make_response(answer=[("example.com.", 300, "A", ["1.2.3.4"])], aa=False), + EXAMPLE, + dns.rdatatype.A, + EXAMPLE, + ) + assert classification["type"] == "answer" + + def test_negative_ttl_without_an_soa(self) -> None: + assert offline_resolver()._negative_ttl(make_response(), EXAMPLE) is None + + def test_delegation_is_not_cached_without_a_ttl(self) -> None: + resolver = RecursiveResolver(dnssec=False, cache_enabled=True) + response = make_response(authority=[("example.com.", 0, "NS", ["ns1.example.com."])], aa=False) + resolver._cache_delegation(EXAMPLE, [], ["9.9.9.9"], response, ValidationState.INSECURE) + assert resolver.cache.get_delegation(EXAMPLE) is None + + def test_delegation_is_not_cached_without_addresses(self) -> None: + resolver = RecursiveResolver(dnssec=False, cache_enabled=True) + response = make_response(authority=[("example.com.", 300, "NS", ["ns1.example.com."])], aa=False) + resolver._cache_delegation(EXAMPLE, [], [], response, ValidationState.INSECURE) + assert resolver.cache.get_delegation(EXAMPLE) is None + + def test_ns_resolution_stops_when_the_budget_is_gone(self) -> None: + resolver = offline_resolver(limits=Limits(max_queries=1)) + ctx = resolver._new_context() + ctx.budget.spend_query("x.", "A") + assert resolver._resolve_ns_names([dns.name.from_text("ns.example.com.")], ctx, 1, limit=5) == [] + + def test_all_servers_erroring_raises_servfail(self) -> None: + resolver = offline_resolver() + + def send(qname, rdtype, nameservers, ctx): + return make_response(rcode=dns.rcode.REFUSED, aa=False), nameservers[0] + + with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(ServfailError): + resolver.resolve("example.com", "A") + + +class TestZoneKeyCache: + def test_expired_keys_are_discarded(self) -> None: + resolver = RecursiveResolver(dnssec=True) + zone = dns.name.from_text("example.com.") + keys = ZoneKeys(zone, None, ValidationState.SECURE) + resolver._store_keys(keys, 3600) + assert resolver._cached_keys(zone) is not None + keys.expiry = time.monotonic() - 1 + assert resolver._cached_keys(zone) is None + assert zone not in resolver._key_cache + + def test_unknown_zone(self) -> None: + assert RecursiveResolver(dnssec=True)._cached_keys(dns.name.from_text("nope.test.")) is None + + +class TestNXDOMAINCaching: + def test_nxdomain_below_a_cached_nxdomain_is_free(self) -> None: + resolver = RecursiveResolver(dnssec=False, cache_enabled=True) + resolver.cache.put_nxdomain(dns.name.from_text("gone.example.com."), ttl=300) + with patch.object(resolver, "_send_query") as send, pytest.raises(NXDOMAINError): + resolver.resolve("a.b.gone.example.com", "MX") + send.assert_not_called() + + +class TestFinalBranches: + """The last few guards, each driven deliberately.""" + + def test_module_main_runs_in_process(self) -> None: + """runpy executes __main__ in-process so the guard itself is exercised.""" + import runpy + + with patch.object(sys, "argv", ["recursive-resolver", "--version"]), pytest.raises(SystemExit) as exc: + runpy.run_module("recursive_resolver", run_name="__main__") + assert exc.value.code == 0 + + def test_verbose_enables_debug_logging(self) -> None: + from test_cli import _answer as _cli_answer + + from recursive_resolver.cli import main + + with ( + patch("recursive_resolver.cli.RecursiveResolver") as cls, + patch("recursive_resolver.cli.logging.basicConfig") as basic_config, + ): + cls.return_value.resolve_answer.return_value = _cli_answer() + main(["-v", "example.com"]) + basic_config.assert_called_once() + + def test_construction_failure_exits_2(self, capsys: pytest.CaptureFixture) -> None: + from recursive_resolver.cli import main + + with patch("recursive_resolver.cli.RecursiveResolver", side_effect=DNSSECUnavailableError()): + assert main(["example.com"]) == 2 + assert "DNSSECUnavailableError" in capsys.readouterr().err + + def test_relative_names_are_absolutised(self) -> None: + """Defensive guard: dnspython normally returns an absolute name.""" + resolver = offline_resolver() + relative = dns.name.from_text("example.com", origin=None) + assert not relative.is_absolute() + with patch("dns.name.from_text", return_value=relative): + assert resolver._normalize_qname("example.com", "A").is_absolute() + + def test_deadline_expiring_mid_loop(self) -> None: + """The deadline is re-checked before every query, not just at entry.""" + resolver = offline_resolver() + expired = iter([False, False, True]) + + with ( + patch.object(resolver, "_send_query", return_value=(root_to_com(), "198.41.0.4")), + patch( + "recursive_resolver.budget.QueryBudget.expired", + side_effect=lambda: next(expired, True), + ), + pytest.raises(ResolutionTimeoutError), + ): + resolver.resolve("example.com", "A") + + def test_an_already_expired_budget_short_circuits(self) -> None: + """A sub-resolution entered after the deadline must not send anything.""" + resolver = offline_resolver() + with ( + patch("recursive_resolver.budget.QueryBudget.expired", return_value=True), + patch.object(resolver, "_send_query") as send, + pytest.raises(ResolutionTimeoutError), + ): + resolver.resolve("example.com", "A") + send.assert_not_called() + + def test_dead_glue_falls_back_to_a_live_ns_name(self) -> None: + """Glue that passes the address filter but never answers.""" + resolver = offline_resolver() + + def send(qname, rdtype, nameservers, ctx): + # The in-zone NS name fails cheaply; the sibling one resolves. + if qname == dns.name.from_text("ns1.example.com."): + return make_response(rcode=dns.rcode.NXDOMAIN, aa=True), "192.5.6.30" + if qname == dns.name.from_text("ns2.otherdns.net."): + return make_response(answer=[("ns2.otherdns.net.", 300, "A", ["9.9.9.9"])]), "1.1.1.1" + if nameservers and nameservers[0] in resolver._root_addresses: + return root_to_com(), nameservers[0] + if nameservers == ["192.5.6.30"]: + return ( + referral( + "example.com.", + ["ns1.example.com.", "ns2.otherdns.net."], + {"ns1.example.com.": "5.5.5.5"}, # routable, passes the filter, never answers + ), + "192.5.6.30", + ) + if nameservers == ["9.9.9.9"]: + return make_response(answer=[("example.com.", 300, "A", ["1.2.3.4"])]), "9.9.9.9" + return (None, "") # the dead glue never answers + + with patch.object(resolver, "_send_query", side_effect=send): + assert resolver.resolve("example.com", "A") == ["1.2.3.4"] + + def test_additional_records_for_other_names_are_ignored(self) -> None: + """Only glue for the referral's own NS names may be used.""" + resolver = offline_resolver() + response = make_response( + authority=[("example.com.", 300, "NS", ["ns1.example.com."])], + additional=[("unrelated.example.com.", 300, "A", ["9.9.9.9"])], + aa=False, + ) + glue = resolver._select_glue( + response, [dns.name.from_text("ns1.example.com.")], dns.name.from_text("com."), EXAMPLE + ) + assert glue == [] + + def test_non_address_additional_records_are_ignored(self) -> None: + resolver = offline_resolver() + response = make_response( + authority=[("example.com.", 300, "NS", ["ns1.example.com."])], + additional=[("ns1.example.com.", 300, "TXT", ['"not an address"'])], + aa=False, + ) + glue = resolver._select_glue( + response, [dns.name.from_text("ns1.example.com.")], dns.name.from_text("com."), EXAMPLE + ) + assert glue == [] + + def test_aaaa_glue_is_ignored_in_ipv4_only_mode(self) -> None: + resolver = offline_resolver(ipv4_only=True) + response = make_response( + authority=[("example.com.", 300, "NS", ["ns1.example.com."])], + additional=[("ns1.example.com.", 300, "AAAA", ["2001:500:2::c"])], + aa=False, + ) + glue = resolver._select_glue( + response, [dns.name.from_text("ns1.example.com.")], dns.name.from_text("com."), EXAMPLE + ) + assert glue == [] + + def test_aaaa_glue_is_used_in_dual_stack_mode(self) -> None: + resolver = offline_resolver(ipv4_only=False) + response = make_response( + authority=[("example.com.", 300, "NS", ["ns1.example.com."])], + additional=[("ns1.example.com.", 300, "AAAA", ["2001:500:2::c"])], + aa=False, + ) + glue = resolver._select_glue( + response, [dns.name.from_text("ns1.example.com.")], dns.name.from_text("com."), EXAMPLE + ) + assert glue == ["2001:500:2::c"] + + +class TestBranchCompleteness: + """Remaining conditional arms, each with a real scenario behind it.""" + + def test_importing_main_normally_skips_the_cli(self) -> None: + import importlib + + sys.modules.pop("recursive_resolver.__main__", None) + module = importlib.import_module("recursive_resolver.__main__") + assert module.main is not None + + def test_cached_delegation_with_only_filtered_addresses_restarts_from_root(self) -> None: + """A poisoned cache entry must not strand the resolution.""" + resolver = RecursiveResolver(dnssec=False, cache_enabled=True) + resolver.cache.put_delegation( + Delegation(zone=dns.name.from_text("com."), addresses=["127.0.0.1", "10.0.0.1"]), ttl=3600 + ) + zone, servers, _state, _ds = resolver._starting_point(EXAMPLE, dns.rdatatype.A) + assert zone == dns.name.root + assert servers == resolver._root_addresses + + def test_explicit_cname_query_skips_cname_chasing(self) -> None: + """Asking for a CNAME means the CNAME is the answer, not a redirect.""" + resolver = offline_resolver() + classification = resolver._classify_response( + make_response(rcode=dns.rcode.NXDOMAIN, aa=True), EXAMPLE, dns.rdatatype.CNAME, EXAMPLE + ) + assert classification["type"] == "nxdomain" + + def test_delegation_with_no_matching_ns_rrset_is_not_cached(self) -> None: + resolver = RecursiveResolver(dnssec=False, cache_enabled=True) + response = make_response( + authority=[ + ("other.com.", 300, "NS", ["ns1.other.com."]), + ("example.com.", 300, "SOA", ["ns1.example.com. a.example.com. 1 3600 900 604800 86400"]), + ], + aa=False, + ) + resolver._cache_delegation(EXAMPLE, [], ["9.9.9.9"], response, ValidationState.INSECURE) + assert resolver.cache.get_delegation(EXAMPLE) is None + + def test_negative_ttl_ignores_unrelated_authority_records(self) -> None: + resolver = offline_resolver() + response = make_response( + authority=[ + ("example.com.", 300, "NS", ["ns1.example.com."]), + ("other.test.", 300, "SOA", ["ns1.other.test. a.other.test. 1 3600 900 604800 60"]), + ], + aa=False, + ) + assert resolver._negative_ttl(response, EXAMPLE) is None + + +class TestDSQueries: + """A DS record is published by the parent zone, never by the child.""" + + def test_warm_delegation_cache_does_not_route_ds_to_the_child(self) -> None: + """Regression: an earlier A lookup cached the child's delegation, and the + DS query was then sent to the child, which correctly answers NODATA.""" + resolver = RecursiveResolver(dnssec=False, cache_enabled=True) + resolver.cache.put_delegation(Delegation(zone=EXAMPLE, addresses=["5.5.5.5"]), ttl=3600) + zone, servers, _state, _ds = resolver._starting_point(EXAMPLE, dns.rdatatype.DS) + assert zone != EXAMPLE, "a DS query must not start at the child zone" + assert servers != ["5.5.5.5"] + + def test_a_query_still_uses_the_child_delegation(self) -> None: + resolver = RecursiveResolver(dnssec=False, cache_enabled=True) + resolver.cache.put_delegation(Delegation(zone=EXAMPLE, addresses=["5.5.5.5"]), ttl=3600) + zone, servers, _state, _ds = resolver._starting_point(EXAMPLE, dns.rdatatype.A) + assert zone == EXAMPLE + assert servers == ["5.5.5.5"] + + def test_ds_query_uses_the_parents_cached_delegation(self) -> None: + resolver = RecursiveResolver(dnssec=False, cache_enabled=True) + resolver.cache.put_delegation(Delegation(zone=dns.name.from_text("com."), addresses=["192.5.6.30"]), ttl=3600) + resolver.cache.put_delegation(Delegation(zone=EXAMPLE, addresses=["5.5.5.5"]), ttl=3600) + zone, servers, _state, _ds = resolver._starting_point(EXAMPLE, dns.rdatatype.DS) + assert zone == dns.name.from_text("com.") + assert servers == ["192.5.6.30"] + + def test_ds_at_the_root_has_no_parent_to_climb_to(self) -> None: + resolver = RecursiveResolver(dnssec=False, cache_enabled=True) + zone, _servers, _state, _ds = resolver._starting_point(dns.name.root, dns.rdatatype.DS) + assert zone == dns.name.root + + def test_ds_in_a_referral_authority_is_the_answer(self) -> None: + """Parents that reply with a referral still carry the DS in authority.""" + resolver = offline_resolver() + response = make_response( + authority=[ + ("example.com.", 300, "DS", ["2371 13 2 " + "ab" * 32]), + ("example.com.", 300, "NS", ["ns1.example.com."]), + ], + aa=False, + ) + classification = resolver._classify_response(response, EXAMPLE, dns.rdatatype.DS, dns.name.from_text("com.")) + assert classification["type"] == "answer" + assert classification["rrset"].rdtype == dns.rdatatype.DS + + def test_a_ds_query_never_descends_past_the_zone_cut(self) -> None: + """Without a DS present, the parent has told us there is none.""" + resolver = offline_resolver() + response = make_response( + authority=[("example.com.", 300, "NS", ["ns1.example.com."])], + additional=[("ns1.example.com.", 300, "A", ["9.9.9.9"])], + aa=False, + ) + assert resolver._find_referral(response, EXAMPLE, dns.name.from_text("com."), dns.rdatatype.DS) is None + # The same response is a normal referral for any other type. + assert resolver._find_referral(response, EXAMPLE, dns.name.from_text("com."), dns.rdatatype.A) is not None + + def test_a_ds_query_below_the_cut_still_follows_referrals(self) -> None: + """Only a delegation for the qname itself is off limits.""" + resolver = offline_resolver() + response = make_response( + authority=[("example.com.", 300, "NS", ["ns1.example.com."])], + additional=[("ns1.example.com.", 300, "A", ["9.9.9.9"])], + aa=False, + ) + found = resolver._find_referral( + response, dns.name.from_text("sub.example.com."), dns.name.from_text("com."), dns.rdatatype.DS + ) + assert found is not None + + def test_ds_query_with_no_ds_in_the_authority(self) -> None: + """The parent delegates but publishes no DS: the zone is unsigned.""" + resolver = offline_resolver() + response = make_response( + authority=[ + ("example.com.", 300, "NS", ["ns1.example.com."]), + ("example.com.", 300, "NSEC3PARAM", ["1 0 10 AABBCCDD"]), + ], + aa=True, + ) + classification = resolver._classify_response(response, EXAMPLE, dns.rdatatype.DS, dns.name.from_text("com.")) + assert classification["type"] == "nodata" + + +class TestStaleDelegationFallback: + """A parent that still names a provider which has dropped the zone. + + This is common in the wild, and it is not a timeout: the stale servers + answer promptly with REFUSED. Without a fallback to the referral's other NS + names, the zone becomes unresolvable even though a working nameserver is + named in the very same referral. + """ + + def test_refused_servers_fall_back_to_the_other_ns_names(self) -> None: + resolver = offline_resolver() + stale, live = "5.5.5.5", "9.9.9.9" + + def send(qname, rdtype, nameservers, ctx): + # The out-of-zone NS name resolves to the live server. + if qname == dns.name.from_text("live.example.net."): + return make_response(answer=[("live.example.net.", 300, "A", [live])]), "1.1.1.1" + if nameservers and nameservers[0] in resolver._root_addresses: + return root_to_com(), nameservers[0] + if nameservers == ["192.5.6.30"]: + # Glue only for the in-zone name, which points at the provider + # that no longer serves the zone. + return ( + referral( + "example.com.", + ["ns-stale.example.com.", "live.example.net."], + {"ns-stale.example.com.": stale}, + ), + "192.5.6.30", + ) + if nameservers == [stale]: + return make_response(rcode=dns.rcode.REFUSED, aa=False), stale + if nameservers == [live]: + return make_response(answer=[("example.com.", 300, "A", ["1.2.3.4"])]), live + return (None, "") + + with patch.object(resolver, "_send_query", side_effect=send): + assert resolver.resolve("example.com", "A") == ["1.2.3.4"] + + def test_the_fallback_never_re_tries_addresses_already_used(self) -> None: + resolver = offline_resolver() + ctx = resolver._new_context() + with patch.object(resolver, "_resolve_ns_names", return_value=["9.9.9.9", "1.1.1.1"]): + fresh = resolver._fallback_nameservers([dns.name.from_text("ns.example.net.")], {"9.9.9.9"}, ctx, 0) + assert fresh == ["1.1.1.1"] + + def test_no_ns_names_means_no_fallback(self) -> None: + resolver = offline_resolver() + ctx = resolver._new_context() + assert resolver._fallback_nameservers([], set(), ctx, 0) == [] diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 8608b61..72a71a0 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -4,1808 +4,379 @@ from unittest.mock import patch -import dns.flags -import dns.message import dns.name import dns.rcode -import dns.rdata -import dns.rdataclass import dns.rdatatype -import dns.rrset import pytest +from conftest import make_response, referral, root_to_com, sequence -from recursive_resolver.exceptions import ( +from recursive_resolver import ( CNAMELoopError, + InvalidNameError, MaxDepthError, NoAnswerError, NXDOMAINError, + RecursiveResolver, ResolutionTimeoutError, ServfailError, + TraceStep, + UnsupportedRdtypeError, + ValidationState, ) -from recursive_resolver.resolver import RecursiveResolver, TraceStep - - -def _make_response( - qname: str = "example.com.", - rdtype: int = dns.rdatatype.A, - rcode: int = dns.rcode.NOERROR, - answer: list[tuple[str, int, str, list[str]]] | None = None, - authority: list[tuple[str, int, str, list[str]]] | None = None, - additional: list[tuple[str, int, str, list[str]]] | None = None, -) -> dns.message.Message: - """Build a dns.message.Message for testing. - - answer/authority/additional: list of (name, ttl, rdtype_str, [rdata_str, ...]) - """ - response = dns.message.Message() - response.flags = dns.flags.QR - response.id = 0 - response.set_rcode(rcode) - - for section, records in [ - (response.answer, answer or []), - (response.authority, authority or []), - (response.additional, additional or []), - ]: - for name_str, ttl, rdtype_str, rdata_strs in records: - name = dns.name.from_text(name_str) - rdt = dns.rdatatype.from_text(rdtype_str) - rrset = dns.rrset.RRset(name, dns.rdataclass.IN, rdt) - for rd_str in rdata_strs: - rd = dns.rdata.from_text(dns.rdataclass.IN, rdt, rd_str) - rrset.add(rd) - rrset.update_ttl(ttl) - section.append(rrset) - - return response - - -def _mock_send_sequence(responses: list[tuple[dns.message.Message, str]]): - """Create a side_effect function that returns responses in sequence.""" - call_count = 0 - - def side_effect(qname, rdtype, nameservers, deadline=0.0): - nonlocal call_count - if call_count < len(responses): - result = responses[call_count] - call_count += 1 - return result - return (None, "") - - return side_effect - - -class TestResolverSimpleA: - """Test simple A record resolution through delegation chain.""" - def test_simple_a_resolution(self) -> None: - resolver = RecursiveResolver(cache_enabled=False) - - # Root -> .com referral - root_response = _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ) - # .com -> example.com referral - com_response = _make_response( - authority=[("example.com.", 172800, "NS", ["ns1.example.com."])], - additional=[("ns1.example.com.", 172800, "A", ["93.184.216.34"])], - ) - # example.com -> answer - answer_response = _make_response( - answer=[("example.com.", 300, "A", ["93.184.216.34"])], - ) - - responses = [ - (root_response, "198.41.0.4"), - (com_response, "192.5.6.30"), - (answer_response, "93.184.216.34"), - ] - - with patch.object(resolver, "_send_query", side_effect=_mock_send_sequence(responses)): - result = resolver.resolve("example.com", "A") - assert result == ["93.184.216.34"] - - -class TestResolverCNAME: - """Test CNAME following.""" - - def test_cname_following(self) -> None: - resolver = RecursiveResolver(cache_enabled=False) - - call_count = 0 - - def mock_send(qname, rdtype, nameservers, deadline=0.0): - nonlocal call_count - call_count += 1 - - if qname == "www.example.com." and rdtype == "A": - if call_count == 1: - # Root referral - return ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ) - elif call_count == 2: - # .com referral - return ( - _make_response( - authority=[("example.com.", 172800, "NS", ["ns1.example.com."])], - additional=[("ns1.example.com.", 172800, "A", ["93.184.216.34"])], - ), - "192.5.6.30", - ) - elif call_count == 3: - # CNAME answer - return ( - _make_response( - answer=[("www.example.com.", 300, "CNAME", ["example.com."])], - ), - "93.184.216.34", - ) - - if qname == "example.com." and rdtype == "A": - if call_count == 4: - # Root referral for canonical name - return ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ) - elif call_count == 5: - # .com referral - return ( - _make_response( - authority=[("example.com.", 172800, "NS", ["ns1.example.com."])], - additional=[("ns1.example.com.", 172800, "A", ["93.184.216.34"])], - ), - "192.5.6.30", - ) - elif call_count == 6: - # Final answer - return ( - _make_response( - answer=[("example.com.", 300, "A", ["93.184.216.34"])], - ), - "93.184.216.34", - ) - - return (None, "") - with patch.object(resolver, "_send_query", side_effect=mock_send): - result = resolver.resolve("www.example.com", "A") - assert result == ["93.184.216.34"] +def _resolver(**kwargs) -> RecursiveResolver: + kwargs.setdefault("dnssec", False) + kwargs.setdefault("cache_enabled", False) + return RecursiveResolver(**kwargs) -class TestResolverCNAMELoop: - """Test CNAME loop detection.""" +def _chain(final: object) -> list: + """root -> com -> example.com -> final.""" + return [ + (root_to_com(), "198.41.0.4"), + (referral("example.com.", ["ns1.example.com."], {"ns1.example.com.": "1.2.3.4"}), "192.5.6.30"), + (final, "1.2.3.4"), + ] - def test_cname_loop_raises(self) -> None: - resolver = RecursiveResolver(cache_enabled=False, max_cname_chain=5) - def mock_send(qname, rdtype, nameservers, deadline=0.0): - if qname == "a.example.com.": - return ( - _make_response( - answer=[("a.example.com.", 300, "CNAME", ["b.example.com."])], - ), - "1.2.3.4", - ) - elif qname == "b.example.com.": - return ( - _make_response( - answer=[("b.example.com.", 300, "CNAME", ["a.example.com."])], - ), - "1.2.3.4", - ) - # For referrals, just return direct answers - return ( - _make_response( - authority=[("example.com.", 172800, "NS", ["ns1.example.com."])], - additional=[("ns1.example.com.", 172800, "A", ["1.2.3.4"])], - ), - "198.41.0.4", - ) - - with patch.object(resolver, "_send_query", side_effect=mock_send), pytest.raises(CNAMELoopError): - resolver.resolve("a.example.com", "A") - - -class TestResolverNXDOMAIN: - """Test NXDOMAIN handling.""" +class TestBasicResolution: + def test_simple_a_resolution(self) -> None: + resolver = _resolver() + responses = _chain(make_response(answer=[("example.com.", 300, "A", ["93.184.216.34"])])) + with patch.object(resolver, "_send_query", side_effect=sequence(responses)): + assert resolver.resolve("example.com", "A") == ["93.184.216.34"] - def test_nxdomain_raises(self) -> None: - resolver = RecursiveResolver(cache_enabled=False) + def test_mx_resolution(self) -> None: + resolver = _resolver() + responses = _chain(make_response(answer=[("example.com.", 300, "MX", ["10 mail.example.com."])])) + with patch.object(resolver, "_send_query", side_effect=sequence(responses)): + assert resolver.resolve("example.com", "MX") == ["10 mail.example.com."] + def test_nxdomain(self) -> None: + resolver = _resolver() responses = [ - # Root referral - ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ), - # NXDOMAIN from .com server - (_make_response(rcode=dns.rcode.NXDOMAIN), "192.5.6.30"), + (root_to_com(), "198.41.0.4"), + (make_response(rcode=dns.rcode.NXDOMAIN, aa=True), "192.5.6.30"), ] - - with ( - patch.object(resolver, "_send_query", side_effect=_mock_send_sequence(responses)), - pytest.raises(NXDOMAINError), - ): + with patch.object(resolver, "_send_query", side_effect=sequence(responses)), pytest.raises(NXDOMAINError): resolver.resolve("nonexistent.com", "A") - -class TestResolverNODATA: - """Test NODATA handling.""" - - def test_nodata_raises(self) -> None: - resolver = RecursiveResolver(cache_enabled=False) - - responses = [ - # Root referral - ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ), - # .com referral - ( - _make_response( - authority=[("example.com.", 172800, "NS", ["ns1.example.com."])], - additional=[("ns1.example.com.", 172800, "A", ["93.184.216.34"])], - ), - "192.5.6.30", - ), - # Empty response (NODATA): no answer, no authority NS - ( - _make_response( - authority=[ - ("example.com.", 300, "SOA", ["ns1.example.com. admin.example.com. 1 3600 900 604800 86400"]) - ], - ), - "93.184.216.34", - ), - ] - - with ( - patch.object(resolver, "_send_query", side_effect=_mock_send_sequence(responses)), - pytest.raises(NoAnswerError), - ): + def test_nodata(self) -> None: + resolver = _resolver() + soa = make_response( + authority=[("example.com.", 300, "SOA", ["ns1.example.com. a.example.com. 1 3600 900 604800 86400"])], + aa=True, + ) + with patch.object(resolver, "_send_query", side_effect=sequence(_chain(soa))), pytest.raises(NoAnswerError): resolver.resolve("example.com", "AAAA") - -class TestResolverGlueless: - """Test glueless referral handling.""" - - def test_glueless_referral(self) -> None: - resolver = RecursiveResolver(cache_enabled=False) - - call_count = 0 - - def mock_send(qname, rdtype, nameservers, deadline=0.0): - nonlocal call_count - call_count += 1 - - # Main query: example.com A - if qname == "example.com." and rdtype == "A": - if call_count == 1: - # Root: referral to .com - return ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ) - if call_count == 2: - # .com: glueless referral (NS in different zone) - return ( - _make_response( - authority=[("example.com.", 172800, "NS", ["ns1.otherdns.net."])], - ), - "192.5.6.30", - ) - # After glueless resolution, we query ns1.otherdns.net IP - return ( - _make_response( - answer=[("example.com.", 300, "A", ["1.2.3.4"])], - ), - "10.0.0.1", - ) - - # Sub-resolution: ns1.otherdns.net A - if qname == "ns1.otherdns.net." and rdtype == "A": - if "198.41.0.4" in nameservers or len(nameservers) > 5: - # Root referral - return ( - _make_response( - authority=[("net.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ) - if "192.5.6.30" in nameservers: - # .net referral - return ( - _make_response( - authority=[("otherdns.net.", 172800, "NS", ["ns1.otherdns.net."])], - additional=[("ns1.otherdns.net.", 172800, "A", ["10.0.0.1"])], - ), - "192.5.6.30", - ) - return ( - _make_response( - answer=[("ns1.otherdns.net.", 300, "A", ["10.0.0.1"])], - ), - "10.0.0.1", - ) - - return (None, "") - - with patch.object(resolver, "_send_query", side_effect=mock_send): - result = resolver.resolve("example.com", "A") - assert result == ["1.2.3.4"] - - -class TestResolverMaxDepth: - """Test max depth enforcement.""" - - def test_max_depth_raises(self) -> None: - resolver = RecursiveResolver(cache_enabled=False, max_depth=3) - - def mock_send(qname, rdtype, nameservers, deadline=0.0): - # Always return a referral, never an answer - return ( - _make_response( - authority=[("sub.example.com.", 172800, "NS", ["ns1.example.com."])], - additional=[("ns1.example.com.", 172800, "A", ["1.2.3.4"])], - ), - "1.2.3.4", - ) - - with patch.object(resolver, "_send_query", side_effect=mock_send), pytest.raises(MaxDepthError): - resolver.resolve("example.com", "A") - - -class TestResolverTimeout: - """Test timeout handling and fallback to next server.""" - def test_all_servers_timeout(self) -> None: - resolver = RecursiveResolver(cache_enabled=False) - - def mock_send(qname, rdtype, nameservers, deadline=0.0): - return (None, "") - - with ( - patch.object(resolver, "_send_query", side_effect=mock_send), - pytest.raises(ResolutionTimeoutError), - ): + resolver = _resolver() + with patch.object(resolver, "_send_query", return_value=(None, "")), pytest.raises(ResolutionTimeoutError): resolver.resolve("example.com", "A") + def test_max_depth(self) -> None: + resolver = _resolver(max_depth=3) -class TestResolverServfail: - """Test SERVFAIL handling.""" - - def test_servfail_raises(self) -> None: - resolver = RecursiveResolver(cache_enabled=False) - - responses = [ - # Root referral - ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ), - # SERVFAIL - (_make_response(rcode=dns.rcode.SERVFAIL), "192.5.6.30"), - ] + def send(qname, rdtype, nameservers, ctx): + # Always descend one more label, never answer. + zone = dns.name.from_text("sub." + str(qname)) + return referral(str(zone), ["ns1.example.com."], {"ns1.example.com.": "1.2.3.4"}), "1.2.3.4" - with ( - patch.object(resolver, "_send_query", side_effect=_mock_send_sequence(responses)), - pytest.raises(ServfailError), - ): + with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(MaxDepthError): resolver.resolve("example.com", "A") -class TestResolverServfailRetry: - """Test that SERVFAIL from one NS falls back to another.""" - - def test_servfail_retries_next_ns(self) -> None: - resolver = RecursiveResolver(cache_enabled=False) - - call_count = 0 - - def mock_send(qname, rdtype, nameservers, deadline=0.0): - nonlocal call_count - call_count += 1 - - if qname == "example.com." and rdtype == "A": - if call_count == 1: - # Root referral - return ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ) - if call_count == 2: - # .com referral with two NS glue IPs - return ( - _make_response( - authority=[ - ("example.com.", 172800, "NS", ["ns1.example.com.", "ns2.example.com."]), - ], - additional=[ - ("ns1.example.com.", 172800, "A", ["1.1.1.1"]), - ("ns2.example.com.", 172800, "A", ["2.2.2.2"]), - ], - ), - "192.5.6.30", - ) - if call_count == 3: - # First NS returns SERVFAIL - return (_make_response(rcode=dns.rcode.SERVFAIL), "1.1.1.1") - if call_count == 4: - # Second NS returns the answer - return ( - _make_response( - answer=[("example.com.", 300, "A", ["93.184.216.34"])], - ), - "2.2.2.2", - ) - - return (None, "") - - with patch.object(resolver, "_send_query", side_effect=mock_send): - result = resolver.resolve("example.com", "A") - assert result == ["93.184.216.34"] - assert call_count == 4 # root, referral, servfail, answer - - -class TestResolverFormErr: - """Test FORMERR triggers EDNS fallback.""" - - def test_formerr_retries_without_edns(self) -> None: - resolver = RecursiveResolver(cache_enabled=False) - - call_count = 0 - - def mock_send(qname, rdtype, nameservers, deadline=0.0): - nonlocal call_count - call_count += 1 - - if call_count == 1: - # Root referral - return ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ) - if call_count == 2: - # Referral to final NS - return ( - _make_response( - authority=[("example.com.", 172800, "NS", ["ns1.example.com."])], - additional=[("ns1.example.com.", 172800, "A", ["1.2.3.4"])], - ), - "192.5.6.30", - ) - if call_count == 3: - # FORMERR — server doesn't like EDNS0 - return (_make_response(rcode=dns.rcode.FORMERR), "1.2.3.4") - # Should not reach here since _send_query handles FORMERR internally - return (None, "") - - # We need to test at a lower level: _send_query should handle FORMERR - # by calling _send_query_plain. Let's mock at the dns.query level instead. - plain_response = _make_response( - answer=[("example.com.", 300, "A", ["93.184.216.34"])], +class TestCNAME: + def test_cname_followed_across_zones(self) -> None: + resolver = _resolver() + + def send(qname, rdtype, nameservers, ctx): + if nameservers and nameservers[0] == "198.41.0.4": + return root_to_com(), "198.41.0.4" + if nameservers == ["192.5.6.30"]: + zone = "example.com." if str(qname).endswith("example.com.") else "other.com." + return referral(zone, [f"ns1.{zone}"], {f"ns1.{zone}": "1.2.3.4"}), "192.5.6.30" + if qname == dns.name.from_text("www.example.com."): + return make_response(answer=[("www.example.com.", 300, "CNAME", ["target.other.com."])]), "1.2.3.4" + return make_response(answer=[("target.other.com.", 300, "A", ["5.6.7.8"])]), "1.2.3.4" + + with patch.object(resolver, "_send_query", side_effect=send): + answer = resolver.resolve_answer("www.example.com", "A") + assert answer.records == ["5.6.7.8"] + assert answer.canonical_name == dns.name.from_text("target.other.com.") + + def test_inline_cname_target_avoids_a_second_walk(self) -> None: + """A CNAME answered together with its target must not restart from root.""" + resolver = _resolver() + inline = make_response( + answer=[ + ("www.example.com.", 300, "CNAME", ["cdn.example.com."]), + ("cdn.example.com.", 300, "A", ["9.9.9.9"]), + ] ) + responses = [ + (root_to_com(), "198.41.0.4"), + (referral("example.com.", ["ns1.example.com."], {"ns1.example.com.": "1.2.3.4"}), "192.5.6.30"), + (inline, "1.2.3.4"), + ] + side_effect = sequence(responses) + calls = 0 - with ( - patch.object( - resolver, - "_send_query", - side_effect=_mock_send_sequence( - [ - ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ), - ( - _make_response( - authority=[("example.com.", 172800, "NS", ["ns1.example.com."])], - additional=[("ns1.example.com.", 172800, "A", ["1.2.3.4"])], - ), - "192.5.6.30", - ), - # Final answer (after FORMERR is handled internally by _send_query) - (plain_response, "1.2.3.4"), - ] - ), - ), - ): - result = resolver.resolve("example.com", "A") - assert result == ["93.184.216.34"] - - -class TestResolverStaleGlueFallback: - """Test fallback to glueless resolution when glue IPs are stale/dead. - - When a referral provides glue IPs that are all unreachable, the resolver - should fall back to resolving NS hostnames from scratch. - """ - - def test_stale_glue_falls_back_to_ns_resolution(self) -> None: - resolver = RecursiveResolver(cache_enabled=False) - - call_count = 0 - - def mock_send(qname, rdtype, nameservers, deadline=0.0): - nonlocal call_count - call_count += 1 - - if qname == "example.com." and rdtype == "A": - if call_count == 1: - # Root referral - return ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ) - if call_count == 2: - # .com referral with stale glue (dead IP) + glueless NS - return ( - _make_response( - authority=[ - ( - "example.com.", - 172800, - "NS", - [ - "ns1.example.com.", - "ns2.otherdns.net.", - ], - ) - ], - additional=[ - # Stale glue: this IP is dead - ("ns1.example.com.", 172800, "A", ["10.0.0.1"]), - ], - ), - "192.5.6.30", - ) - if call_count == 3: - # Query to stale glue IP fails: _send_query returns None - # because only 10.0.0.1 is in the nameservers list - assert "10.0.0.1" in nameservers - return (None, "") - # After glueless fallback resolves ns2.otherdns.net -> 5.6.7.8 - if "5.6.7.8" in nameservers: - return ( - _make_response( - answer=[("example.com.", 300, "A", ["93.184.216.34"])], - ), - "5.6.7.8", - ) - - # Sub-resolution of ns2.otherdns.net - if qname == "ns2.otherdns.net." and rdtype == "A": - if "198.41.0.4" in nameservers or len(nameservers) > 5: - return ( - _make_response( - authority=[("net.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ) - if "192.5.6.30" in nameservers: - return ( - _make_response( - authority=[("otherdns.net.", 172800, "NS", ["ns1.otherdns.net."])], - additional=[("ns1.otherdns.net.", 172800, "A", ["5.5.5.5"])], - ), - "192.5.6.30", - ) - return ( - _make_response( - answer=[("ns2.otherdns.net.", 300, "A", ["5.6.7.8"])], - ), - "5.5.5.5", - ) - - # Also need ns1.example.com resolution (but it will fail) - if qname == "ns1.example.com." and rdtype == "A": - return (None, "") - - return (None, "") - - with patch.object(resolver, "_send_query", side_effect=mock_send): - result = resolver.resolve("example.com", "A") - assert result == ["93.184.216.34"] - - -class TestResolverNXDOMAINRetry: - """Test that NXDOMAIN from one NS retries with sibling nameservers. - - Some TLD nameservers are inconsistent — one may return NXDOMAIN while - others correctly refer the domain. The resolver should try other servers - before accepting NXDOMAIN. - """ - - def test_nxdomain_retries_sibling_ns(self) -> None: - resolver = RecursiveResolver(cache_enabled=False) - - call_count = 0 - - def mock_send(qname, rdtype, nameservers, deadline=0.0): - nonlocal call_count - call_count += 1 - - if call_count == 1: - # Root referral to .ir with multiple NS - return ( - _make_response( - authority=[("ir.", 172800, "NS", ["a.nic.ir.", "b.nic.ir."])], - additional=[ - ("a.nic.ir.", 172800, "A", ["1.1.1.1"]), - ("b.nic.ir.", 172800, "A", ["2.2.2.2"]), - ], - ), - "198.41.0.4", - ) - if call_count == 2: - # First .ir NS returns NXDOMAIN (stale/inconsistent) - return (_make_response(rcode=dns.rcode.NXDOMAIN), "1.1.1.1") - if call_count == 3: - # Second .ir NS correctly refers to the domain's NS - return ( - _make_response( - authority=[("example.ir.", 172800, "NS", ["ns1.example.ir."])], - additional=[("ns1.example.ir.", 172800, "A", ["3.3.3.3"])], - ), - "2.2.2.2", - ) - if call_count == 4: - # Domain's NS returns the answer - return ( - _make_response( - answer=[("example.ir.", 300, "A", ["4.4.4.4"])], - ), - "3.3.3.3", - ) + def counting(*args, **kwargs): + nonlocal calls + calls += 1 + return side_effect(*args, **kwargs) - return (None, "") + with patch.object(resolver, "_send_query", side_effect=counting): + answer = resolver.resolve_answer("www.example.com", "A") - with patch.object(resolver, "_send_query", side_effect=mock_send): - result = resolver.resolve("example.ir", "A") - assert result == ["4.4.4.4"] - assert call_count == 4 + assert answer.records == ["9.9.9.9"] + assert calls == 3, "should not have re-walked from the root" - def test_nxdomain_all_servers_agree_raises(self) -> None: - """If all nameservers return NXDOMAIN, it should still raise.""" - resolver = RecursiveResolver(cache_enabled=False) + def test_cname_loop_detected(self) -> None: + resolver = _resolver(max_cname_chain=5) - call_count = 0 + def send(qname, rdtype, nameservers, ctx): + if nameservers and nameservers[0] == "198.41.0.4": + return root_to_com(), "198.41.0.4" + if nameservers == ["192.5.6.30"]: + return referral("example.com.", ["ns1.example.com."], {"ns1.example.com.": "1.2.3.4"}), "192.5.6.30" + target = "b.example.com." if str(qname).startswith("a.") else "a.example.com." + return make_response(answer=[(str(qname), 300, "CNAME", [target])]), "1.2.3.4" - def mock_send(qname, rdtype, nameservers, deadline=0.0): - nonlocal call_count - call_count += 1 + with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(CNAMELoopError): + resolver.resolve("a.example.com", "A") - if call_count == 1: - # Root referral with two NS + def test_nxdomain_with_cname_follows_the_cname(self) -> None: + """Some servers return NXDOMAIN plus a CNAME whose target is elsewhere.""" + resolver = _resolver() + + def send(qname, rdtype, nameservers, ctx): + if nameservers and nameservers[0] == "198.41.0.4": + return root_to_com(), "198.41.0.4" + if nameservers == ["192.5.6.30"]: + zone = "example.com." if str(qname).endswith("example.com.") else "other.com." + return referral(zone, [f"ns1.{zone}"], {f"ns1.{zone}": "1.2.3.4"}), "192.5.6.30" + if qname == dns.name.from_text("sub.example.com."): return ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net.", "b.gtld-servers.net."])], - additional=[ - ("a.gtld-servers.net.", 172800, "A", ["1.1.1.1"]), - ("b.gtld-servers.net.", 172800, "A", ["2.2.2.2"]), - ], + make_response( + rcode=dns.rcode.NXDOMAIN, + answer=[("sub.example.com.", 300, "CNAME", ["target.other.com."])], ), - "198.41.0.4", + "1.2.3.4", ) - # Both .com servers return NXDOMAIN - return (_make_response(rcode=dns.rcode.NXDOMAIN), nameservers[0] if nameservers else "1.1.1.1") - - with patch.object(resolver, "_send_query", side_effect=mock_send), pytest.raises(NXDOMAINError): - resolver.resolve("nonexistent.com", "A") - - -class TestResolverPTR: - """Test PTR auto-reverse.""" + return make_response(answer=[("target.other.com.", 300, "A", ["5.6.7.8"])]), "1.2.3.4" + + with patch.object(resolver, "_send_query", side_effect=send): + assert resolver.resolve("sub.example.com", "A") == ["5.6.7.8"] + + +class TestInputValidation: + """Bad input must raise a precise error, never a spurious timeout.""" + + @pytest.mark.parametrize( + "name", + ["a" * 64 + ".com", "foo..com", "", " "], + ) + def test_invalid_names_raise_invalid_name_error(self, name: str) -> None: + resolver = _resolver() + with pytest.raises(InvalidNameError): + resolver.resolve(name, "A") + + def test_name_too_long(self) -> None: + resolver = _resolver() + with pytest.raises(InvalidNameError): + resolver.resolve(".".join(["abcdefghij"] * 30), "A") + + @pytest.mark.parametrize( + "rdtype", + [ + "BOGUSTYPE", + "", + "TXTT", + # "TYPEnnnnn" parses as a generic type but is range-checked + # separately, so dnspython raises a bare ValueError here rather + # than UnknownRdatatype. Nothing from dnspython may escape. + "TYPE70000", + "TYPE99999", + ], + ) + def test_unknown_rdtype_raises(self, rdtype: str) -> None: + resolver = _resolver() + with pytest.raises(UnsupportedRdtypeError): + resolver.resolve("example.com", rdtype) + + def test_invalid_input_makes_no_queries(self) -> None: + resolver = _resolver() + with patch.object(resolver, "_send_query") as send, pytest.raises(InvalidNameError): + resolver.resolve("foo..com", "A") + send.assert_not_called() + + def test_idna_2008_is_used(self) -> None: + """IDNA 2003 maps 'ß' to 'ss', resolving an entirely different domain.""" + resolver = _resolver() + name = resolver._normalize_qname("straße.de", "A") + assert str(name) == "xn--strae-oqa.de.", f"got {name} (IDNA 2003 would give strasse.de.)" + + def test_unicode_and_punycode_agree(self) -> None: + resolver = _resolver() + assert resolver._normalize_qname("bücher.de", "A") == resolver._normalize_qname("xn--bcher-kva.de", "A") def test_ptr_auto_reverse(self) -> None: - resolver = RecursiveResolver(cache_enabled=False) - - call_count = 0 - - def mock_send(qname, rdtype, nameservers, deadline=0.0): - nonlocal call_count - call_count += 1 - - # Main PTR query - if qname == "8.8.8.8.in-addr.arpa." and rdtype == "PTR": - assert qname == "8.8.8.8.in-addr.arpa." - if call_count == 1: - return ( - _make_response( - authority=[("in-addr.arpa.", 172800, "NS", ["ns1.arpa."])], - additional=[("ns1.arpa.", 172800, "A", ["1.2.3.4"])], - ), - "198.41.0.4", - ) - if call_count == 2: - # Glueless referral: ns1.google.com. is out of bailiwick for in-addr.arpa. - return ( - _make_response( - authority=[("8.in-addr.arpa.", 172800, "NS", ["ns1.google.com."])], - ), - "1.2.3.4", - ) - # After glueless resolution of ns1.google.com -> 8.8.8.8 - return ( - _make_response( - answer=[("8.8.8.8.in-addr.arpa.", 300, "PTR", ["dns.google."])], - ), - "8.8.8.8", - ) - - # Sub-resolution: ns1.google.com A (glueless) - if qname == "ns1.google.com." and rdtype == "A": - if "198.41.0.4" in nameservers or len(nameservers) > 5: - return ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ) - if "192.5.6.30" in nameservers: - return ( - _make_response( - authority=[("google.com.", 172800, "NS", ["ns1.google.com."])], - additional=[("ns1.google.com.", 172800, "A", ["8.8.8.8"])], - ), - "192.5.6.30", - ) - return ( - _make_response( - answer=[("ns1.google.com.", 300, "A", ["8.8.8.8"])], - ), - "8.8.8.8", - ) - - return (None, "") - - with patch.object(resolver, "_send_query", side_effect=mock_send): - result = resolver.resolve("8.8.8.8", "PTR") - assert result == ["dns.google."] - - -class TestResolverCache: - """Test that caching works.""" - - def test_cache_hit_avoids_queries(self) -> None: - resolver = RecursiveResolver(cache_enabled=True) - - call_count = 0 - - def mock_send(qname, rdtype, nameservers, deadline=0.0): - nonlocal call_count - call_count += 1 - if call_count == 1: - return ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ) - if call_count == 2: - return ( - _make_response( - authority=[("example.com.", 172800, "NS", ["ns1.example.com."])], - additional=[("ns1.example.com.", 172800, "A", ["93.184.216.34"])], - ), - "192.5.6.30", - ) - if call_count == 3: - return ( - _make_response( - answer=[("example.com.", 300, "A", ["93.184.216.34"])], - ), - "93.184.216.34", - ) - # Should not be called again - return (None, "") - - with patch.object(resolver, "_send_query", side_effect=mock_send): - result1 = resolver.resolve("example.com", "A") - assert result1 == ["93.184.216.34"] - assert call_count == 3 - - # Second resolve should use cache - result2 = resolver.resolve("example.com", "A") - assert result2 == ["93.184.216.34"] - assert call_count == 3 # No additional queries - - -class TestResolverMX: - """Test MX record resolution.""" - - def test_mx_resolution(self) -> None: - resolver = RecursiveResolver(cache_enabled=False) - + resolver = _resolver() + assert str(resolver._normalize_qname("8.8.8.8", "PTR")) == "8.8.8.8.in-addr.arpa." + + def test_ptr_ipv6_auto_reverse(self) -> None: + resolver = _resolver() + assert str(resolver._normalize_qname("2001:4860:4860::8888", "PTR")).endswith("ip6.arpa.") + + def test_ptr_passthrough_for_arpa_names(self) -> None: + resolver = _resolver() + assert str(resolver._normalize_qname("8.8.8.8.in-addr.arpa", "PTR")) == "8.8.8.8.in-addr.arpa." + + +class TestAnswerAPI: + """The Answer object, especially the DKIM-critical TXT handling.""" + + def test_text_values_concatenates_chunks_without_separator(self) -> None: + """RFC 6376: multi-chunk TXT strings join with no separator.""" + resolver = _resolver() + long_key = "v=DKIM1; k=rsa; p=" + "A" * 230 + second = "B" * 100 + response = make_response(answer=[("s._domainkey.example.com.", 300, "TXT", [f'"{long_key}" "{second}"'])]) + with patch.object(resolver, "_send_query", side_effect=sequence(_chain(response))): + answer = resolver.resolve_answer("s._domainkey.example.com", "TXT") + + assert answer.text_values() == [long_key + second] + # Presentation format keeps the seam, which is exactly the DKIM trap. + assert '" "' in answer.records[0] + + def test_text_values_rejects_non_character_string_types(self) -> None: + resolver = _resolver() + response = make_response(answer=[("example.com.", 300, "A", ["1.2.3.4"])]) + with patch.object(resolver, "_send_query", side_effect=sequence(_chain(response))): + answer = resolver.resolve_answer("example.com", "A") + with pytest.raises(TypeError): + answer.text_values() + + def test_resolve_rrset_returns_raw_rdata(self) -> None: + resolver = _resolver() + response = make_response(answer=[("example.com.", 300, "TXT", ['"chunk-a" "chunk-b"'])]) + with patch.object(resolver, "_send_query", side_effect=sequence(_chain(response))): + rrset = resolver.resolve_rrset("example.com", "TXT") + assert rrset[0].strings == (b"chunk-a", b"chunk-b") + + def test_answer_reports_dnssec_state(self) -> None: + resolver = _resolver() + response = make_response(answer=[("example.com.", 300, "A", ["1.2.3.4"])]) + with patch.object(resolver, "_send_query", side_effect=sequence(_chain(response))): + answer = resolver.resolve_answer("example.com", "A") + assert answer.dnssec is ValidationState.INSECURE + assert answer.secure is False + assert answer.ttl == 300 + + +class TestTrace: + def test_trace_records_every_step(self) -> None: + resolver = _resolver() + response = make_response(answer=[("example.com.", 300, "A", ["1.2.3.4"])]) + with patch.object(resolver, "_send_query", side_effect=sequence(_chain(response))): + _, trace = resolver.trace_answer("example.com", "A") + assert [s.response_type for s in trace] == ["referral", "referral", "answer"] + assert all(isinstance(s, TraceStep) for s in trace) + assert trace[0].zone == "." + + def test_trace_answer_returns_both(self) -> None: + """Regression: the trace API used to throw the answer away.""" + resolver = _resolver() + response = make_response(answer=[("example.com.", 300, "A", ["1.2.3.4"])]) + with patch.object(resolver, "_send_query", side_effect=sequence(_chain(response))): + answer, trace = resolver.trace_answer("example.com", "A") + assert answer is not None + assert answer.records == ["1.2.3.4"] + assert len(trace) == 3 + + def test_trace_survives_failure(self) -> None: + resolver = _resolver() responses = [ - ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ), - ( - _make_response( - authority=[("example.com.", 172800, "NS", ["ns1.example.com."])], - additional=[("ns1.example.com.", 172800, "A", ["93.184.216.34"])], - ), - "192.5.6.30", - ), - ( - _make_response( - answer=[("example.com.", 300, "MX", ["10 mail.example.com."])], - ), - "93.184.216.34", - ), + (root_to_com(), "198.41.0.4"), + (make_response(rcode=dns.rcode.NXDOMAIN, aa=True), "192.5.6.30"), ] - - with patch.object(resolver, "_send_query", side_effect=_mock_send_sequence(responses)): - result = resolver.resolve("example.com", "MX") - assert result == ["10 mail.example.com."] - - -class TestResolverTXT: - """Test TXT record resolution.""" - - def test_txt_resolution(self) -> None: - resolver = RecursiveResolver(cache_enabled=False) - - responses = [ - ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ), - ( - _make_response( - authority=[("example.com.", 172800, "NS", ["ns1.example.com."])], - additional=[("ns1.example.com.", 172800, "A", ["93.184.216.34"])], - ), - "192.5.6.30", - ), - ( - _make_response( - answer=[("example.com.", 300, "TXT", ['"v=spf1 -all"'])], - ), - "93.184.216.34", - ), - ] - - with patch.object(resolver, "_send_query", side_effect=_mock_send_sequence(responses)): - result = resolver.resolve("example.com", "TXT") - assert len(result) == 1 - - -class TestResolverNXDOMAINWithCNAME: - """Test that NXDOMAIN + CNAME in answer section follows the CNAME. - - Some authoritative servers return rcode=3 (NXDOMAIN) along with a CNAME - in the answer section when the CNAME target is outside their zone. The - CNAME target may resolve fine via other servers, so we must follow it - instead of raising NXDOMAINError. - """ - - def test_nxdomain_with_cname_follows_cname(self) -> None: - resolver = RecursiveResolver(cache_enabled=False) - - call_count = 0 - - def mock_send(qname, rdtype, nameservers, deadline=0.0): - nonlocal call_count - call_count += 1 - - # First query: sub.example.com A - if qname == "sub.example.com." and rdtype == "A": - if call_count == 1: - # Root referral - return ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ) - if call_count == 2: - # .com referral - return ( - _make_response( - authority=[("example.com.", 172800, "NS", ["ns1.example.com."])], - additional=[("ns1.example.com.", 172800, "A", ["1.1.1.1"])], - ), - "192.5.6.30", - ) - if call_count == 3: - # NS returns NXDOMAIN rcode BUT includes a CNAME in the answer - # This happens when the CNAME target is outside the server's zone - return ( - _make_response( - rcode=dns.rcode.NXDOMAIN, - answer=[("sub.example.com.", 300, "CNAME", ["target.other.com."])], - ), - "1.1.1.1", - ) - - # Second query: resolve the CNAME target from root - if qname == "target.other.com." and rdtype == "A": - if call_count == 4: - return ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ) - if call_count == 5: - return ( - _make_response( - authority=[("other.com.", 172800, "NS", ["ns1.other.com."])], - additional=[("ns1.other.com.", 172800, "A", ["2.2.2.2"])], - ), - "192.5.6.30", - ) - if call_count == 6: - return ( - _make_response( - answer=[("target.other.com.", 300, "A", ["5.6.7.8"])], - ), - "2.2.2.2", - ) - + with patch.object(resolver, "_send_query", side_effect=sequence(responses)): + answer, trace = resolver.trace_answer("nope.com", "A") + assert answer is None + assert trace[-1].response_type == "nxdomain" + + +class TestGlueless: + def test_glueless_referral_resolves_ns_hostname(self) -> None: + resolver = _resolver() + + def send(qname, rdtype, nameservers, ctx): + if qname == dns.name.from_text("ns1.otherdns.net."): + return make_response(answer=[("ns1.otherdns.net.", 300, "A", ["9.9.9.9"])]), "1.1.1.1" + if nameservers and nameservers[0] == "198.41.0.4": + return root_to_com(), "198.41.0.4" + if nameservers == ["192.5.6.30"]: + return referral("example.com.", ["ns1.otherdns.net."]), "192.5.6.30" + return make_response(answer=[("example.com.", 300, "A", ["1.2.3.4"])]), nameservers[0] + + with patch.object(resolver, "_send_query", side_effect=send): + assert resolver.resolve("example.com", "A") == ["1.2.3.4"] + + def test_all_glueless_ns_failing_raises_servfail(self) -> None: + resolver = _resolver() + + def send(qname, rdtype, nameservers, ctx): + if nameservers and nameservers[0] == "198.41.0.4": + return root_to_com(), "198.41.0.4" + if nameservers == ["192.5.6.30"]: + return referral("example.com.", ["ns1.broken.net."]), "192.5.6.30" return (None, "") - with patch.object(resolver, "_send_query", side_effect=mock_send): - result = resolver.resolve("sub.example.com", "A") - assert result == ["5.6.7.8"] - assert call_count == 6 - - def test_pure_nxdomain_still_raises(self) -> None: - """NXDOMAIN without a CNAME should still raise NXDOMAINError.""" - resolver = RecursiveResolver(cache_enabled=False) - - responses = [ - ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ), - (_make_response(rcode=dns.rcode.NXDOMAIN), "192.5.6.30"), - ] - - with ( - patch.object(resolver, "_send_query", side_effect=_mock_send_sequence(responses)), - pytest.raises(NXDOMAINError), - ): - resolver.resolve("nonexistent.com", "A") - - -class TestResolverTrace: - """Test resolve_with_trace.""" - - def test_trace_returns_steps(self) -> None: - resolver = RecursiveResolver(cache_enabled=False) - - responses = [ - ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ), - ( - _make_response( - authority=[("example.com.", 172800, "NS", ["ns1.example.com."])], - additional=[("ns1.example.com.", 172800, "A", ["93.184.216.34"])], - ), - "192.5.6.30", - ), - ( - _make_response( - answer=[("example.com.", 300, "A", ["93.184.216.34"])], - ), - "93.184.216.34", - ), - ] - - with patch.object(resolver, "_send_query", side_effect=_mock_send_sequence(responses)): - trace = resolver.resolve_with_trace("example.com", "A") - assert len(trace) == 3 - assert trace[0].response_type == "referral" - assert trace[0].server == "198.41.0.4" - assert trace[1].response_type == "referral" - assert trace[2].response_type == "answer" - assert all(isinstance(s, TraceStep) for s in trace) - - -class TestResolverBailiwick: - """Test bailiwick checking for glue records. - - Out-of-bailiwick glue (NS hostname not under the parent zone) should be - rejected to prevent cache poisoning attacks, triggering glueless resolution. - """ - - def test_in_bailiwick_glue_accepted(self) -> None: - """Glue for ns1.example.com. under delegated zone example.com. (parent: com.) is accepted.""" - resolver = RecursiveResolver(cache_enabled=False) - - responses = [ - # Root -> .com (a.gtld-servers.net. is under root = in-bailiwick) - ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ), - # .com -> example.com (ns1.example.com. is under .com = in-bailiwick) - ( - _make_response( - authority=[("example.com.", 172800, "NS", ["ns1.example.com."])], - additional=[("ns1.example.com.", 172800, "A", ["93.184.216.34"])], - ), - "192.5.6.30", - ), - # Answer - ( - _make_response( - answer=[("example.com.", 300, "A", ["93.184.216.34"])], - ), - "93.184.216.34", - ), - ] - - with patch.object(resolver, "_send_query", side_effect=_mock_send_sequence(responses)): - result = resolver.resolve("example.com", "A") - assert result == ["93.184.216.34"] - - def test_out_of_bailiwick_glue_rejected(self) -> None: - """Glue for ns1.evil.net. in a .com referral is out-of-bailiwick and rejected. - - The resolver should ignore the poisoned glue and resolve the NS - hostname via glueless resolution instead. - """ - resolver = RecursiveResolver(cache_enabled=False) - - call_count = 0 - - def mock_send(qname, rdtype, nameservers, deadline=0.0): - nonlocal call_count - call_count += 1 - - if qname == "target.com." and rdtype == "A": - if call_count == 1: - # Root -> .com - return ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ) - if call_count == 2: - # .com -> target.com with NS ns1.evil.net. + out-of-bailiwick glue - # The glue for ns1.evil.net. should be REJECTED (evil.net. not under .com) - return ( - _make_response( - authority=[("target.com.", 172800, "NS", ["ns1.evil.net."])], - additional=[ - # This is poisoned glue — points to attacker IP - ("ns1.evil.net.", 172800, "A", ["6.6.6.6"]), - ], - ), - "192.5.6.30", - ) - # After glueless resolution finds the real IP (5.5.5.5) - if "5.5.5.5" in nameservers: - return ( - _make_response( - answer=[("target.com.", 300, "A", ["1.2.3.4"])], - ), - "5.5.5.5", - ) - - # Glueless resolution of ns1.evil.net -> 5.5.5.5 (the real IP) - if qname == "ns1.evil.net." and rdtype == "A": - if "198.41.0.4" in nameservers or len(nameservers) > 5: - return ( - _make_response( - authority=[("net.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ) - if "192.5.6.30" in nameservers: - return ( - _make_response( - authority=[("evil.net.", 172800, "NS", ["ns1.evil.net."])], - additional=[("ns1.evil.net.", 172800, "A", ["5.5.5.5"])], - ), - "192.5.6.30", - ) - return ( - _make_response( - answer=[("ns1.evil.net.", 300, "A", ["5.5.5.5"])], - ), - "5.5.5.5", - ) - - return (None, "") - - with patch.object(resolver, "_send_query", side_effect=mock_send): - result = resolver.resolve("target.com", "A") - assert result == ["1.2.3.4"] - # The poisoned IP 6.6.6.6 should never have been used - # Instead, glueless resolution found the real IP 5.5.5.5 - - -class TestResolverNegativeCacheHit: - """Test negative cache entries are returned correctly.""" - - def test_nxdomain_from_cache(self) -> None: - """NXDOMAIN cached on first resolve should raise on second without queries.""" - resolver = RecursiveResolver(cache_enabled=True) - - call_count = 0 - - def mock_send(qname, rdtype, nameservers, deadline=0.0): - nonlocal call_count - call_count += 1 - # Return NXDOMAIN - return (_make_response(rcode=dns.rcode.NXDOMAIN), nameservers[0]) - - with patch.object(resolver, "_send_query", side_effect=mock_send): - with pytest.raises(NXDOMAINError): - resolver.resolve("nope.com", "A") - first_calls = call_count - - # Second call should hit cache, no new queries - with pytest.raises(NXDOMAINError): - resolver.resolve("nope.com", "A") - assert call_count == first_calls - - def test_nodata_from_cache(self) -> None: - """NODATA cached on first resolve should raise on second without queries.""" - resolver = RecursiveResolver(cache_enabled=True) - - call_count = 0 - - def mock_send(qname, rdtype, nameservers, deadline=0.0): - nonlocal call_count - call_count += 1 - if call_count == 1: - return ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ) - if call_count == 2: - return ( - _make_response( - authority=[("example.com.", 172800, "NS", ["ns1.example.com."])], - additional=[("ns1.example.com.", 172800, "A", ["1.2.3.4"])], - ), - "192.5.6.30", - ) - # NODATA: SOA in authority, no answer - return ( - _make_response( - authority=[ - ("example.com.", 300, "SOA", ["ns1.example.com. admin.example.com. 1 3600 900 604800 86400"]) - ], - ), - "1.2.3.4", - ) - - with patch.object(resolver, "_send_query", side_effect=mock_send): - with pytest.raises(NoAnswerError): - resolver.resolve("example.com", "AAAA") - first_calls = call_count - - with pytest.raises(NoAnswerError): - resolver.resolve("example.com", "AAAA") - assert call_count == first_calls - - -class TestResolverCNAMEChainLimit: - """Test CNAME chain length limit.""" - - def test_long_cname_chain_raises(self) -> None: - """CNAME chain exceeding max_cname_chain should raise CNAMELoopError.""" - resolver = RecursiveResolver(cache_enabled=False, max_cname_chain=3) - - def mock_send(qname, rdtype, nameservers, deadline=0.0): - # Each name CNAMEs to the next, forming a long chain - n = int(qname.split(".")[0].replace("c", "")) if qname.startswith("c") else 0 - next_name = f"c{n + 1}.example.com." - return ( - _make_response( - answer=[(qname, 300, "CNAME", [next_name])], - ), - "1.2.3.4", - ) - - with patch.object(resolver, "_send_query", side_effect=mock_send), pytest.raises(CNAMELoopError): - resolver.resolve("c0.example.com", "A") - - -class TestResolverGluelessAllFail: - """Test that glueless referral where all NS fail raises ServfailError.""" - - def test_all_glueless_ns_fail_raises(self) -> None: - resolver = RecursiveResolver(cache_enabled=False) - - call_count = 0 - - def mock_send(qname, rdtype, nameservers, deadline=0.0): - nonlocal call_count - call_count += 1 - - if qname == "example.com." and rdtype == "A": - if call_count == 1: - return ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ) - if call_count == 2: - # Glueless referral - return ( - _make_response( - authority=[("example.com.", 172800, "NS", ["ns1.broken.net."])], - ), - "192.5.6.30", - ) - - # Sub-resolution always fails - return (None, "") - - with patch.object(resolver, "_send_query", side_effect=mock_send), pytest.raises(ServfailError): + with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(ServfailError): resolver.resolve("example.com", "A") -class TestResolverGluelessMultipleNS: - """Test that glueless resolution tries all NS names, not just the first. - - Reproduces the bug where _resolve_glueless would stop after the first NS - that resolved to an IP, even if that IP pointed to a dead server. - For example: banon.fr had NS visioline.tv (working) and nssec.online.net - (dead IP 62.210.16.8). If nssec resolved first, the old code would use - only its dead IP and never try visioline.tv. - """ - - def test_second_ns_used_when_first_ip_dead(self) -> None: - """When the first glueless NS resolves to a dead IP, the second NS IP is also tried.""" - resolver = RecursiveResolver(cache_enabled=False) - - def mock_send(qname, rdtype, nameservers, deadline=0.0): - # Main query: target.example.com A - if qname == "target.example.com." and rdtype == "A": - if any(ns in nameservers for ns in resolver._root_addresses): - # Root referral -> .com - return ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ) - if "192.5.6.30" in nameservers: - # .com referral -> glueless (NS in different TLDs) - return ( - _make_response( - authority=[ - ( - "example.com.", - 172800, - "NS", - [ - "ns-dead.otherdns.net.", - "ns-alive.gooddns.org.", - ], - ) - ], - ), - "192.5.6.30", - ) - if "10.0.0.1" in nameservers: - # Working IP present — _send_query iterates and finds it - return ( - _make_response( - answer=[("target.example.com.", 300, "A", ["5.5.5.5"])], - ), - "10.0.0.1", - ) - if "10.0.0.99" in nameservers: - # Only dead IP — times out - return (None, "") - return (None, "") - - # Sub-resolution for ns-dead.otherdns.net -> resolves to dead IP - if qname == "ns-dead.otherdns.net." and rdtype == "A": - if any(ns in nameservers for ns in resolver._root_addresses): - return ( - _make_response( - authority=[("net.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ) - if "192.5.6.30" in nameservers: - return ( - _make_response( - authority=[("otherdns.net.", 172800, "NS", ["ns-dead.otherdns.net."])], - additional=[("ns-dead.otherdns.net.", 172800, "A", ["10.0.0.99"])], - ), - "192.5.6.30", - ) - return ( - _make_response( - answer=[("ns-dead.otherdns.net.", 300, "A", ["10.0.0.99"])], - ), - "10.0.0.99", - ) - - # Sub-resolution for ns-alive.gooddns.org -> resolves to working IP - if qname == "ns-alive.gooddns.org." and rdtype == "A": - if any(ns in nameservers for ns in resolver._root_addresses): - return ( - _make_response( - authority=[("org.", 172800, "NS", ["a0.org.afilias-nst.info."])], - additional=[("a0.org.afilias-nst.info.", 172800, "A", ["199.19.56.1"])], - ), - "198.41.0.4", - ) - if "199.19.56.1" in nameservers: - return ( - _make_response( - authority=[("gooddns.org.", 172800, "NS", ["ns-alive.gooddns.org."])], - additional=[("ns-alive.gooddns.org.", 172800, "A", ["10.0.0.1"])], - ), - "199.19.56.1", - ) - return ( - _make_response( - answer=[("ns-alive.gooddns.org.", 300, "A", ["10.0.0.1"])], - ), - "10.0.0.1", - ) - - return (None, "") - - with patch.object(resolver, "_send_query", side_effect=mock_send): - result = resolver.resolve("target.example.com", "A") - # The resolver must find 10.0.0.1 from the second NS and get the answer - assert result == ["5.5.5.5"] - - -class TestResolverDepthOverflow: - """Test that depth overflow in sub-resolution is handled.""" - - def test_depth_overflow_raises_max_depth(self) -> None: - """When _resolve_iterative is called with depth > max_depth, it raises immediately.""" - resolver = RecursiveResolver(cache_enabled=False, max_depth=2) - with pytest.raises(MaxDepthError): - resolver._resolve_iterative("deep.example.com.", "A", depth=3, cname_chain=[]) - - -class TestResolverSendQuery: - """Test _send_query with real dns.query mocking.""" - - def test_udp_with_fallback(self) -> None: - """Test basic UDP query flow.""" - resolver = RecursiveResolver(cache_enabled=False) - - response = _make_response( - answer=[("example.com.", 300, "A", ["1.2.3.4"])], - ) - - with patch("dns.query.udp_with_fallback", return_value=(response, False)): - result, server = resolver._send_query("example.com.", "A", ["1.1.1.1"]) - assert result is not None - assert server == "1.1.1.1" - - def test_timeout_retries_and_fails(self) -> None: - """All retries timeout -> returns (None, "").""" - resolver = RecursiveResolver(cache_enabled=False, max_retries=1) - - with patch("dns.query.udp_with_fallback", side_effect=dns.exception.Timeout): - result, server = resolver._send_query("example.com.", "A", ["1.1.1.1"]) - assert result is None - assert server == "" - - def test_formerr_triggers_plain_fallback(self) -> None: - """FORMERR response triggers plain (no-EDNS) fallback.""" - resolver = RecursiveResolver(cache_enabled=False) - - formerr_response = _make_response(rcode=dns.rcode.FORMERR) - plain_response = _make_response( - answer=[("example.com.", 300, "A", ["1.2.3.4"])], - ) - - with ( - patch("dns.query.udp_with_fallback", return_value=(formerr_response, False)), - patch.object(resolver, "_send_query_plain", return_value=plain_response) as mock_plain, - ): - result, server = resolver._send_query("example.com.", "A", ["1.1.1.1"]) - assert result is not None - mock_plain.assert_called_once() - - def test_bad_response_triggers_plain_fallback(self) -> None: - """BadResponse triggers plain (no-EDNS) fallback.""" - resolver = RecursiveResolver(cache_enabled=False) - - plain_response = _make_response( - answer=[("example.com.", 300, "A", ["1.2.3.4"])], - ) - - with ( - patch("dns.query.udp_with_fallback", side_effect=dns.query.BadResponse), - patch.object(resolver, "_send_query_plain", return_value=plain_response) as mock_plain, - ): - result, server = resolver._send_query("example.com.", "A", ["1.1.1.1"]) - assert result is not None - mock_plain.assert_called_once() - - def test_os_error_retries(self) -> None: - """OSError (network error) should trigger retries.""" - resolver = RecursiveResolver(cache_enabled=False, max_retries=1) +class TestNXDOMAINRetry: + def test_sibling_nameserver_is_tried_before_accepting_nxdomain(self) -> None: + resolver = _resolver() + calls = 0 - response = _make_response( - answer=[("example.com.", 300, "A", ["1.2.3.4"])], - ) - - call_count = 0 - - def side_effect(*args, **kwargs): - nonlocal call_count - call_count += 1 - if call_count == 1: - raise OSError("Network unreachable") - return (response, False) - - with patch("dns.query.udp_with_fallback", side_effect=side_effect): - result, server = resolver._send_query("example.com.", "A", ["1.1.1.1"]) - assert result is not None - assert call_count == 2 - - def test_generic_exception_skips_server(self) -> None: - """An unexpected exception should skip the server (no retry).""" - resolver = RecursiveResolver(cache_enabled=False, max_retries=2) - - with patch("dns.query.udp_with_fallback", side_effect=ValueError("unexpected")): - result, server = resolver._send_query("example.com.", "A", ["1.1.1.1"]) - assert result is None - - -class TestResolverSendQueryPlain: - """Test _send_query_plain (no-EDNS fallback).""" - - def test_plain_query_success(self) -> None: - resolver = RecursiveResolver(cache_enabled=False) - - response = _make_response( - answer=[("example.com.", 300, "A", ["1.2.3.4"])], - ) - - with patch("dns.query.udp", return_value=response): - result = resolver._send_query_plain("example.com.", dns.rdatatype.A, "1.1.1.1") - assert result is not None - - def test_plain_query_failure(self) -> None: - resolver = RecursiveResolver(cache_enabled=False) - - with patch("dns.query.udp", side_effect=dns.exception.Timeout): - result = resolver._send_query_plain("example.com.", dns.rdatatype.A, "1.1.1.1") - assert result is None - - -class TestResolverUdpOnly: - """Test resolver with TCP fallback disabled.""" - - def test_udp_only_mode(self) -> None: - resolver = RecursiveResolver(cache_enabled=False, use_tcp_fallback=False) - - response = _make_response( - answer=[("example.com.", 300, "A", ["1.2.3.4"])], - ) - - with patch("dns.query.udp", return_value=response): - result, server = resolver._send_query("example.com.", "A", ["1.1.1.1"]) - assert result is not None - - def test_formerr_plain_also_fails(self) -> None: - """When FORMERR and plain fallback also fails, skip to next server.""" - resolver = RecursiveResolver(cache_enabled=False) - - formerr_response = _make_response(rcode=dns.rcode.FORMERR) - - with ( - patch("dns.query.udp_with_fallback", return_value=(formerr_response, False)), - patch.object(resolver, "_send_query_plain", return_value=None), - ): - result, server = resolver._send_query("example.com.", "A", ["1.1.1.1"]) - assert result is None - - def test_bad_response_plain_also_fails(self) -> None: - """When BadResponse and plain fallback also fails, skip to next server.""" - resolver = RecursiveResolver(cache_enabled=False) - - with ( - patch("dns.query.udp_with_fallback", side_effect=dns.query.BadResponse), - patch.object(resolver, "_send_query_plain", return_value=None), - ): - result, server = resolver._send_query("example.com.", "A", ["1.1.1.1"]) - assert result is None - - -class TestResolverPTRNonIP: - """Test PTR with non-IP qname (already in arpa format).""" - - def test_ptr_with_arpa_qname(self) -> None: - """PTR query with an already-formatted arpa name should pass through.""" - resolver = RecursiveResolver(cache_enabled=False) - - def mock_send(qname, rdtype, nameservers, deadline=0.0): - # qname should have been passed through (not converted) - assert qname == "8.8.8.8.in-addr.arpa." - return ( - _make_response( - answer=[("8.8.8.8.in-addr.arpa.", 300, "PTR", ["dns.google."])], - ), - "1.2.3.4", - ) - - with patch.object(resolver, "_send_query", side_effect=mock_send): - result = resolver.resolve("8.8.8.8.in-addr.arpa", "PTR") - assert result == ["dns.google."] - - -class TestResolverReferralValidation: - """Test referral zone validation (anti-hijacking). - - A malicious nameserver should not be able to redirect resolution to - unrelated zones by injecting bogus NS records in the authority section. - """ - - def test_unrelated_referral_rejected(self) -> None: - """A referral to an unrelated zone should be treated as an error.""" - resolver = RecursiveResolver(cache_enabled=False) - - call_count = 0 - - def mock_send(qname, rdtype, nameservers, deadline=0.0): - nonlocal call_count - call_count += 1 - - if call_count == 1: - # Root -> .com + def send(qname, rdtype, nameservers, ctx): + nonlocal calls + calls += 1 + if calls == 1: return ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), + referral("ir.", ["a.nic.ir.", "b.nic.ir."], {"a.nic.ir.": "1.1.1.1", "b.nic.ir.": "2.2.2.2"}), "198.41.0.4", ) - if call_count == 2: - # .com -> example.com - return ( - _make_response( - authority=[("example.com.", 172800, "NS", ["ns1.example.com."])], - additional=[("ns1.example.com.", 172800, "A", ["1.1.1.1"])], - ), - "192.5.6.30", - ) - if call_count == 3: - # Malicious NS tries to redirect to bank.com (unrelated zone!) - # This should be rejected: www.example.com is not a subdomain of bank.com - return ( - _make_response( - authority=[("bank.com.", 172800, "NS", ["ns1.evil.com."])], - additional=[("ns1.evil.com.", 172800, "A", ["6.6.6.6"])], - ), - "1.1.1.1", - ) - # After rejection (treated as error), no servers left -> ServfailError - return (None, "") - - with patch.object(resolver, "_send_query", side_effect=mock_send), pytest.raises(ServfailError): - resolver.resolve("www.example.com", "A") + if calls == 2: + return make_response(rcode=dns.rcode.NXDOMAIN, aa=True), "1.1.1.1" + if calls == 3: + return referral("example.ir.", ["ns1.example.ir."], {"ns1.example.ir.": "3.3.3.3"}), "2.2.2.2" + return make_response(answer=[("example.ir.", 300, "A", ["4.4.4.4"])]), "3.3.3.3" - def test_valid_referral_accepted(self) -> None: - """A referral to a parent/matching zone should be accepted.""" - resolver = RecursiveResolver(cache_enabled=False) + with patch.object(resolver, "_send_query", side_effect=send): + assert resolver.resolve("example.ir", "A") == ["4.4.4.4"] - responses = [ - # Root -> .com - ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ), - # .com -> example.com (www.example.com IS a subdomain of example.com) - ( - _make_response( - authority=[("example.com.", 172800, "NS", ["ns1.example.com."])], - additional=[("ns1.example.com.", 172800, "A", ["1.1.1.1"])], - ), - "192.5.6.30", - ), - # Answer - ( - _make_response( - answer=[("www.example.com.", 300, "A", ["1.2.3.4"])], - ), - "1.1.1.1", - ), - ] - - with patch.object(resolver, "_send_query", side_effect=_mock_send_sequence(responses)): - result = resolver.resolve("www.example.com", "A") - assert result == ["1.2.3.4"] - - -class TestResolverDeadline: - """Test that max_resolution_time enforces a strict wall-clock deadline.""" +class TestDeadline: def test_deadline_aborts_slow_resolution(self) -> None: - """Resolution that exceeds max_resolution_time raises ResolutionTimeoutError.""" - import time as _time - - resolver = RecursiveResolver( - cache_enabled=False, - max_resolution_time=0.5, # 500ms deadline - timeout=5.0, # per-query timeout much larger than deadline - ) - - def slow_send(qname, rdtype, nameservers, deadline=float("inf")): - # Simulate a slow referral chain that burns through the deadline - _time.sleep(0.3) # Each step takes 300ms; 2 steps > 500ms deadline - return ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ) - - with patch.object(resolver, "_send_query", side_effect=slow_send), pytest.raises(ResolutionTimeoutError): - resolver.resolve("example.com", "A") - - def test_deadline_clamps_per_query_timeout(self) -> None: - """Per-query timeout should be clamped to remaining resolution time.""" import time as _time - resolver = RecursiveResolver( - cache_enabled=False, - max_resolution_time=30.0, - timeout=5.0, - ) - - deadline = _time.monotonic() + 2.0 # 2s remaining - effective = resolver._effective_timeout(deadline) - # Should be clamped to remaining (~2s), not the 5s per-query timeout - assert effective <= 2.1 - assert effective > 0 - - def test_effective_timeout_expired_returns_zero(self) -> None: - """If deadline has passed, _effective_timeout returns 0.""" - import time as _time - - resolver = RecursiveResolver(cache_enabled=False) - expired_deadline = _time.monotonic() - 1.0 # 1 second ago - assert resolver._effective_timeout(expired_deadline) == 0.0 - - def test_check_deadline_raises_when_expired(self) -> None: - """_check_deadline raises ResolutionTimeoutError after deadline.""" - import time as _time - - resolver = RecursiveResolver(cache_enabled=False) - expired_deadline = _time.monotonic() - 1.0 - with pytest.raises(ResolutionTimeoutError): - resolver._check_deadline(expired_deadline, "example.com.", "A") - - def test_check_deadline_passes_when_not_expired(self) -> None: - """_check_deadline does nothing when deadline is in the future.""" - import time as _time + resolver = _resolver(max_resolution_time=0.4, timeout=5.0) - resolver = RecursiveResolver(cache_enabled=False) - future_deadline = _time.monotonic() + 100.0 - resolver._check_deadline(future_deadline, "example.com.", "A") # should not raise + def slow(qname, rdtype, nameservers, ctx): + _time.sleep(0.25) + return root_to_com(), "198.41.0.4" - def test_fast_resolution_succeeds_within_deadline(self) -> None: - """A fast resolution should succeed even with a tight deadline.""" - resolver = RecursiveResolver( - cache_enabled=False, - max_resolution_time=5.0, - ) - - responses = [ - ( - _make_response( - authority=[("com.", 172800, "NS", ["a.gtld-servers.net."])], - additional=[("a.gtld-servers.net.", 172800, "A", ["192.5.6.30"])], - ), - "198.41.0.4", - ), - ( - _make_response( - authority=[("example.com.", 172800, "NS", ["ns1.example.com."])], - additional=[("ns1.example.com.", 172800, "A", ["93.184.216.34"])], - ), - "192.5.6.30", - ), - ( - _make_response( - answer=[("example.com.", 300, "A", ["93.184.216.34"])], - ), - "93.184.216.34", - ), - ] - - with patch.object(resolver, "_send_query", side_effect=_mock_send_sequence(responses)): - result = resolver.resolve("example.com", "A") - assert result == ["93.184.216.34"] - - def test_default_max_resolution_time(self) -> None: - """Default max_resolution_time should be 30 seconds.""" - resolver = RecursiveResolver() - assert resolver.max_resolution_time == 30.0 - - def test_send_query_returns_none_when_deadline_expired(self) -> None: - """_send_query should return (None, '') immediately when deadline is expired.""" - import time as _time + with patch.object(resolver, "_send_query", side_effect=slow), pytest.raises(ResolutionTimeoutError): + resolver.resolve("example.com", "A") - resolver = RecursiveResolver(cache_enabled=False) - expired_deadline = _time.monotonic() - 1.0 - result, server = resolver._send_query("example.com.", "A", ["1.1.1.1"], expired_deadline) - assert result is None - assert server == "" + def test_effective_timeout_is_clamped_to_the_deadline(self) -> None: + resolver = _resolver(timeout=5.0, max_resolution_time=1.0) + ctx = resolver._new_context() + assert 0 < resolver._effective_timeout(ctx) <= 1.01 diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..a51c2d1 --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,885 @@ +"""Regression tests for the security hardening. + +Every test here corresponds to a concrete defect found during the pre-release +audit. They are grouped by the attack they prevent. +""" + +from __future__ import annotations + +import contextlib +import dataclasses +from unittest.mock import patch + +import dns.dnssec +import dns.flags +import dns.name +import dns.rcode +import dns.rdata +import dns.rdataclass +import dns.rdataset +import dns.rdatatype +import dns.rrset +import pytest +from conftest import make_response, offline_resolver, referral, root_to_com + +from recursive_resolver import ( + AddressFilter, + DNSCache, + Limits, + NoAnswerError, + NXDOMAINError, + QueryBudgetExceededError, + RecursiveResolver, + ResolverError, + ServfailError, + ValidationState, +) + + +class TestSSRFProtection: + """Glue records are attacker-controlled; they must not steer us inward.""" + + @pytest.mark.parametrize( + "address", + [ + "127.0.0.1", # loopback + "10.0.0.5", # RFC1918 + "192.168.1.1", # RFC1918 + "172.16.0.1", # RFC1918 + "169.254.169.254", # cloud metadata endpoint + "0.0.0.0", # "this host" + "100.64.0.1", # CGNAT + "240.0.0.1", # reserved + "198.18.0.1", # benchmarking, RFC 2544 + "64:ff9b::1", # NAT64 well-known prefix + "2002:c000:204::1", # 6to4 + "2001::1", # Teredo + "::1", # IPv6 loopback + "fe80::1", # IPv6 link-local + "fc00::1", # IPv6 ULA + "::ffff:127.0.0.1", # IPv4-mapped loopback (bypass attempt) + "2001:db8::1", # documentation prefix + ], + ) + def test_private_addresses_are_rejected(self, address: str) -> None: + assert AddressFilter().is_allowed(address) is False + + @pytest.mark.parametrize( + "address", + [ + "8.8.8.8", + "198.41.0.4", # a.root-servers.net + "1.1.1.1", + "2001:500:2::c", # c.root-servers.net + "2606:4700::1111", + ], + ) + def test_public_addresses_are_allowed(self, address: str) -> None: + assert AddressFilter().is_allowed(address) is True + + @pytest.mark.parametrize( + ("address", "why"), + [ + # Public-looking addresses that no generic classification catches. + ("168.63.129.16", "Azure Instance Metadata Service and platform DNS"), + ("192.88.99.1", "6to4 relay anycast, deprecated by RFC 7526"), + ("2001:20::1", "ORCHIDv2 cryptographic identifiers, RFC 7343"), + ("fec0::1", "deprecated IPv6 site-local, RFC 3879"), + ], + ) + def test_ranges_is_global_alone_would_miss(self, address: str, why: str) -> None: + assert AddressFilter().is_allowed(address) is False, why + + @pytest.mark.parametrize( + "address", + [ + "169.254.169.254", # AWS, GCP, Azure, DigitalOcean, Oracle + "fd00:ec2::254", # AWS IMDS over IPv6 + "100.100.100.200", # Alibaba Cloud + "192.0.0.192", # Oracle Cloud (legacy) + "168.63.129.16", # Azure wireserver + ], + ) + def test_cloud_metadata_endpoints_are_named_in_the_reason(self, address: str) -> None: + """The rejection reason has to be legible in a log.""" + assert AddressFilter().rejection_reason(address) == "cloud metadata endpoint" + + @pytest.mark.parametrize( + ("address", "reason"), + [ + ("127.0.0.1", "loopback address"), + ("169.254.1.1", "link-local address"), + ("224.0.0.1", "multicast address"), + ("0.0.0.0", "unspecified address"), + ("240.0.0.1", "reserved address"), + ("10.0.0.1", "private address"), + ("100.64.0.1", "not globally routable"), + ("nonsense", "not a valid IP address"), + ], + ) + def test_rejection_reasons_are_specific(self, address: str, reason: str) -> None: + assert AddressFilter().rejection_reason(address) == reason + + def test_invalid_address_is_rejected(self) -> None: + assert AddressFilter().is_allowed("not-an-ip") is False + + def test_allow_private_opt_in(self) -> None: + assert AddressFilter(allow_private=True).is_allowed("127.0.0.1") is True + + def test_hostile_glue_is_never_queried(self) -> None: + """A zone pointing its glue at internal IPs must not be followed.""" + resolver = offline_resolver() + queried: list[list[str]] = [] + + def send(qname, rdtype, nameservers, ctx): + queried.append(list(nameservers)) + if len(queried) == 1: + return root_to_com(), "198.41.0.4" + return ( + referral( + "evil.com.", + ["ns1.evil.com."], + {"ns1.evil.com.": "127.0.0.1"}, + ), + "192.5.6.30", + ) + + with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(ResolverError): + resolver.resolve("evil.com", "A") + + every_address = {ip for step in queried for ip in step} + assert "127.0.0.1" not in every_address + assert "169.254.169.254" not in every_address + + def test_resolved_ns_addresses_are_filtered_too(self) -> None: + """Glueless NS hostnames resolving to private IPs are also rejected.""" + resolver = offline_resolver() + + def send(qname, rdtype, nameservers, ctx): + if qname == dns.name.from_text("ns.attacker.test."): + return make_response(answer=[("ns.attacker.test.", 300, "A", ["192.168.1.1"])]), "1.2.3.4" + if nameservers and nameservers[0] == "198.41.0.4": + return root_to_com(), "198.41.0.4" + return referral("victim.com.", ["ns.attacker.test."]), "192.5.6.30" + + with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(ResolverError): + resolver.resolve("victim.com", "A") + + +class TestQueryBudget: + """NXNSAttack / Non-Responsive Delegation amplification controls.""" + + def test_glueless_fanout_is_bounded(self) -> None: + """A hostile zone answering every query with 50 fresh NS names is capped.""" + resolver = offline_resolver(limits=Limits(max_queries=40), max_depth=10) + sent = 0 + + def send(qname, rdtype, nameservers, ctx): + nonlocal sent + sent += 1 + assert sent < 5000, "runaway fan-out" + label = str(qname).replace(".", "-")[:20] + names = [f"ns{i}-{label}.attacker.test." for i in range(50)] + return referral(str(qname), names), "1.2.3.4" + + with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(ResolverError): + resolver.resolve("victim.test", "A") + + # Without a budget this produced tens of thousands of queries. + assert sent <= 200, f"fan-out was not bounded: {sent} queries" + + def test_budget_counts_across_subresolutions(self) -> None: + resolver = offline_resolver(limits=Limits(max_queries=5)) + with pytest.raises(QueryBudgetExceededError): + ctx = resolver._new_context() + for _ in range(10): + ctx.budget.spend_query("example.com.", "A") + + def test_nx_target_budget(self) -> None: + resolver = offline_resolver(limits=Limits(max_nx_targets=2)) + ctx = resolver._new_context() + with pytest.raises(QueryBudgetExceededError): + for _ in range(5): + ctx.budget.note_nx_target("ns.example.com.", "A") + + def test_ns_names_per_referral_are_capped(self) -> None: + """Only a bounded, randomly sampled subset of NS names is chased.""" + resolver = offline_resolver(limits=Limits(max_ns_per_referral=3)) + response = referral("example.com.", [f"ns{i}.example.com." for i in range(50)]) + classification = resolver._classify_response( + response, dns.name.from_text("example.com."), dns.rdatatype.A, dns.name.root + ) + assert classification["type"] == "referral" + assert len(classification["ns_names"]) == 3 + + +class TestReferralValidation: + """Downward progress and bailiwick rules.""" + + def test_sideways_referral_is_rejected(self) -> None: + """A server must not refer us back to its own zone (com. -> com.).""" + resolver = offline_resolver() + response = referral("com.", ["a.gtld-servers.net."]) + classification = resolver._classify_response( + response, dns.name.from_text("foo.com."), dns.rdatatype.A, dns.name.from_text("com.") + ) + assert classification["type"] != "referral" + + def test_upward_referral_is_rejected(self) -> None: + """An ancestor zone in the authority section is not a valid referral.""" + resolver = offline_resolver() + response = referral(".", ["a.root-servers.net."]) + classification = resolver._classify_response( + response, dns.name.from_text("foo.example.com."), dns.rdatatype.A, dns.name.from_text("com.") + ) + assert classification["type"] != "referral" + + def test_upward_referral_does_not_crash(self) -> None: + """Regression: this used to raise dns.name.NoParent out of resolve().""" + resolver = offline_resolver() + + def send(qname, rdtype, nameservers, ctx): + return referral(".", ["a.root-servers.net."], {"a.root-servers.net.": "198.41.0.4"}), "1.2.3.4" + + # Must surface as a ResolverError, never dns.name.NoParent. + with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(ResolverError): + resolver.resolve("example.com", "A") + + def test_unrelated_referral_is_rejected(self) -> None: + """evil.com's server must not be able to answer for bank.com.""" + resolver = offline_resolver() + response = referral("bank.com.", ["ns1.evil.com."]) + classification = resolver._classify_response( + response, dns.name.from_text("www.evil.com."), dns.rdatatype.A, dns.name.from_text("com.") + ) + assert classification["type"] != "referral" + + def test_valid_downward_referral_is_accepted(self) -> None: + resolver = offline_resolver() + response = referral("example.com.", ["ns1.example.com."]) + classification = resolver._classify_response( + response, dns.name.from_text("www.example.com."), dns.rdatatype.A, dns.name.from_text("com.") + ) + assert classification["type"] == "referral" + assert classification["zone"] == dns.name.from_text("example.com.") + + def test_deepest_referral_wins(self) -> None: + resolver = offline_resolver() + response = make_response( + authority=[ + ("com.", 172800, "NS", ["a.gtld-servers.net."]), + ("sub.example.com.", 172800, "NS", ["ns1.example.com."]), + ("example.com.", 172800, "NS", ["ns2.example.com."]), + ], + aa=False, + ) + classification = resolver._classify_response( + response, dns.name.from_text("host.sub.example.com."), dns.rdatatype.A, dns.name.from_text("com.") + ) + assert classification["zone"] == dns.name.from_text("sub.example.com.") + + def test_out_of_bailiwick_glue_is_ignored(self) -> None: + resolver = offline_resolver() + response = referral("target.com.", ["ns1.evil.net."], {"ns1.evil.net.": "6.6.6.6"}) + glue = resolver._select_glue( + response, + [dns.name.from_text("ns1.evil.net.")], + dns.name.from_text("com."), + dns.name.from_text("target.com."), + ) + assert glue == [] + + def test_in_bailiwick_glue_is_accepted(self) -> None: + resolver = offline_resolver() + response = referral("example.com.", ["ns1.example.com."], {"ns1.example.com.": "9.9.9.9"}) + glue = resolver._select_glue( + response, + [dns.name.from_text("ns1.example.com.")], + dns.name.from_text("com."), + dns.name.from_text("example.com."), + ) + assert glue == ["9.9.9.9"] + + +class TestResponseValidation: + """Answer-acceptance rules.""" + + def test_nxdomain_with_answer_records_is_rejected(self) -> None: + """A protocol-violating NXDOMAIN carrying data must not be trusted.""" + resolver = offline_resolver() + response = make_response( + answer=[("example.com.", 300, "A", ["6.6.6.6"])], + rcode=dns.rcode.NXDOMAIN, + ) + classification = resolver._classify_response( + response, dns.name.from_text("example.com."), dns.rdatatype.A, dns.name.from_text("com.") + ) + assert classification["type"] == "error" + + def test_answer_without_aa_bit_is_rejected(self) -> None: + resolver = offline_resolver() + response = make_response(answer=[("example.com.", 300, "A", ["1.2.3.4"])], aa=False) + classification = resolver._classify_response( + response, dns.name.from_text("example.com."), dns.rdatatype.A, dns.name.from_text("example.com.") + ) + assert classification["type"] == "error" + + def test_answer_with_aa_bit_is_accepted(self) -> None: + resolver = offline_resolver() + response = make_response(answer=[("example.com.", 300, "A", ["1.2.3.4"])], aa=True) + classification = resolver._classify_response( + response, dns.name.from_text("example.com."), dns.rdatatype.A, dns.name.from_text("example.com.") + ) + assert classification["type"] == "answer" + + def test_wrong_rdclass_is_not_an_answer(self) -> None: + """A CHAOS-class RRset must not satisfy an IN-class query.""" + resolver = offline_resolver() + response = make_response( + answer=[("example.com.", 300, "TXT", ['"injected"'])], + rdclass=dns.rdataclass.CH, + ) + classification = resolver._classify_response( + response, dns.name.from_text("example.com."), dns.rdatatype.TXT, dns.name.from_text("example.com.") + ) + assert classification["type"] != "answer" + + def test_nodata_with_ns_in_authority_is_nodata_not_referral(self) -> None: + """Regression: this used to burn 20 queries and raise MaxDepthError.""" + resolver = RecursiveResolver(dnssec=False, cache_enabled=True) + sent = 0 + + def send(qname, rdtype, nameservers, ctx): + nonlocal sent + sent += 1 + if sent == 1: + return root_to_com(), "198.41.0.4" + if sent == 2: + return referral("example.com.", ["ns1.example.com."], {"ns1.example.com.": "1.2.3.4"}), "192.5.6.30" + return ( + make_response( + authority=[ + ("example.com.", 300, "SOA", ["ns1.example.com. a.example.com. 1 3600 900 604800 86400"]), + ("example.com.", 172800, "NS", ["ns1.example.com."]), + ], + aa=True, + ), + "1.2.3.4", + ) + + with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(NoAnswerError): + resolver.resolve("example.com", "MX") + + assert sent == 3, f"expected 3 queries, got {sent}" + # And it must be negatively cached so a repeat costs nothing. + with pytest.raises(NoAnswerError): + resolver.resolve("example.com", "MX") + assert sent == 3 + + +class TestTruncation: + """Truncated responses must never be treated as complete.""" + + def test_plain_udp_path_rejects_truncation(self) -> None: + """Regression: the no-EDNS fallback silently returned partial answers.""" + resolver = offline_resolver(use_tcp_fallback=False) + truncated = make_response(answer=[("example.com.", 300, "A", ["1.1.1.1"])], tc=True) + + with patch("dns.query.udp", side_effect=dns.message.Truncated(message=truncated)): + ctx = resolver._new_context() + response, server = resolver._send_query( + dns.name.from_text("example.com."), dns.rdatatype.A, ["1.2.3.4"], ctx + ) + assert response is None + assert server == "" + + +class TestEDNSDowngrade: + """The EDNS fallback ladder for broken servers and PMTU blackholes.""" + + def test_payload_ladder(self) -> None: + resolver = offline_resolver(edns_payload=1232) + assert resolver._payload_for_attempt(0) == 1232 + assert resolver._payload_for_attempt(1) == 512 + assert resolver._payload_for_attempt(2) is None + + @pytest.mark.parametrize( + "rcode", + [dns.rcode.FORMERR, dns.rcode.NOTIMP, dns.rcode.SERVFAIL, 16], + ) + def test_edns_unsupported_rcodes_trigger_plain_retry(self, rcode: int) -> None: + """FORMERR, NOTIMP, SERVFAIL and BADVERS all mean 'try without EDNS'.""" + resolver = offline_resolver() + payloads: list[int | None] = [] + good = make_response(answer=[("example.com.", 300, "A", ["1.2.3.4"])]) + + def query_once(qname, rdtype, server, payload, timeout, ctx): + payloads.append(payload) + if payload is not None and len(payloads) == 1: + return make_response(rcode=rcode, aa=False) + return good + + with patch.object(resolver, "_query_once", side_effect=query_once): + ctx = resolver._new_context() + response, _ = resolver._send_query(dns.name.from_text("example.com."), dns.rdatatype.A, ["1.2.3.4"], ctx) + + assert response is not None + assert payloads[0] == 1232 + assert payloads[-1] is None, "should have retried with EDNS disabled" + + def test_timeouts_downgrade_the_payload(self) -> None: + """A PMTU blackhole must be escaped by shrinking the advertised payload.""" + from recursive_resolver.resolver import _RetryableError + + resolver = offline_resolver(max_retries=2) + payloads: list[int | None] = [] + + def query_once(qname, rdtype, server, payload, timeout, ctx): + payloads.append(payload) + if payload is not None and payload > 512: + raise _RetryableError("blackholed") + return make_response(answer=[("example.com.", 300, "A", ["1.2.3.4"])]) + + with patch.object(resolver, "_query_once", side_effect=query_once): + ctx = resolver._new_context() + response, _ = resolver._send_query(dns.name.from_text("example.com."), dns.rdatatype.A, ["1.2.3.4"], ctx) + + assert response is not None + assert payloads == [1232, 512] + + +class TestSpoofingResistance: + def test_unexpected_source_does_not_downgrade_edns(self) -> None: + """A stray packet must not be an attacker-driven EDNS downgrade primitive.""" + from recursive_resolver.resolver import _RetryableError + + resolver = offline_resolver(max_retries=1) + payloads: list[int | None] = [] + + def query_once(qname, rdtype, server, payload, timeout, ctx): + payloads.append(payload) + raise _RetryableError("unexpected response source") + + with patch.object(resolver, "_query_once", side_effect=query_once): + ctx = resolver._new_context() + resolver._send_query(dns.name.from_text("example.com."), dns.rdatatype.A, ["1.2.3.4"], ctx) + + # Retries happen, but never a jump straight to no-EDNS on the first retry. + assert payloads[0] == 1232 + + def test_all_servers_failing_raises_servfail_not_success(self) -> None: + resolver = offline_resolver() + + def send(qname, rdtype, nameservers, ctx): + return make_response(rcode=dns.rcode.REFUSED, aa=False), nameservers[0] + + with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(ServfailError): + resolver.resolve("example.com", "A") + + +class TestDNSSECBookkeeping: + """Regressions where our own state-keeping produced spurious DNSSEC failures.""" + + def test_nxdomain_in_a_signed_single_ns_zone(self) -> None: + """The failing server is pruned before the denial is validated. + + With one nameserver that left an empty list, so the DNSKEY fetch had + nowhere to go and NXDOMAIN surfaced as DNSSECValidationError. + """ + from recursive_resolver.dnssec import ValidationState + + resolver = RecursiveResolver(dnssec=True, cache_enabled=False) + attempted: list[list[str]] = [] + + def send(qname, rdtype, nameservers, ctx): + attempted.append(list(nameservers)) + return (None, "") if not nameservers else (make_response(aa=True), nameservers[0]) + + ctx = resolver._new_context() + # The call may still fail (the stub serves no real signatures); what + # matters is that it never queries with an empty nameserver list. + # ResolverError only, not Exception: a bare suppress would also swallow + # an AttributeError or TypeError from a later refactor, and the two + # assertions below would still pass because `attempted` is populated by + # the first _send_query call. + with patch.object(resolver, "_send_query", side_effect=send), contextlib.suppress(ResolverError): + resolver._verify_denial( + make_response(rcode=dns.rcode.NXDOMAIN, aa=True), + dns.name.from_text("gone.example.com."), + dns.rdatatype.A, + ctx, + dns.name.from_text("example.com."), + ["9.9.9.9"], + ValidationState.SECURE, + object(), + negative="nxdomain", + ) + + assert attempted, "no DNSKEY fetch was attempted" + assert all(servers for servers in attempted), "a DNSSEC fetch was given an empty nameserver list" + + def test_zone_nameservers_survive_server_pruning(self) -> None: + """A signed zone whose only nameserver NXDOMAINs still yields NXDOMAIN.""" + resolver = RecursiveResolver(dnssec=False, cache_enabled=False) + calls = 0 + + def send(qname, rdtype, nameservers, ctx): + nonlocal calls + calls += 1 + assert nameservers, "queried with an empty nameserver list" + if calls == 1: + return root_to_com(), "198.41.0.4" + if calls == 2: + return referral("example.com.", ["ns1.example.com."], {"ns1.example.com.": "9.9.9.9"}), "192.5.6.30" + return make_response(rcode=dns.rcode.NXDOMAIN, aa=True), "9.9.9.9" + + with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(NXDOMAINError): + resolver.resolve("gone.example.com", "A") + + def test_cached_delegation_carries_its_ds(self) -> None: + """A resumed secure chain must not depend on a warm DNSKEY cache.""" + from recursive_resolver.cache import Delegation + + resolver = RecursiveResolver(dnssec=True, cache_enabled=True) + sentinel = object() + resolver.cache.put_delegation( + Delegation( + zone=dns.name.from_text("com."), + addresses=["192.5.6.30"], + secure=True, + ds=sentinel, + ), + ttl=3600, + ) + zone, servers, state, ds = resolver._starting_point(dns.name.from_text("example.com."), dns.rdatatype.A) + assert zone == dns.name.from_text("com.") + assert servers == ["192.5.6.30"] + assert ds is sentinel + + def test_secure_delegation_without_ds_restarts_from_root(self) -> None: + """Rather than silently downgrading an unprovable chain to insecure.""" + from recursive_resolver.cache import Delegation + + resolver = RecursiveResolver(dnssec=True, cache_enabled=True) + resolver.cache.put_delegation( + Delegation(zone=dns.name.from_text("com."), addresses=["192.5.6.30"], secure=True, ds=None), + ttl=3600, + ) + zone, servers, _state, _ds = resolver._starting_point(dns.name.from_text("example.com."), dns.rdatatype.A) + assert zone == dns.name.root + assert servers == resolver._root_addresses + + +class TestResourceBounds: + """Long-running processes must not accumulate state without limit.""" + + def test_zone_key_cache_is_bounded(self) -> None: + """A DKIM verifier sees unboundedly many signed zones over time.""" + from recursive_resolver.dnssec import ValidationState, ZoneKeys + + resolver = RecursiveResolver(dnssec=True) + resolver._key_cache_size = 64 + for i in range(5000): + resolver._store_keys(ZoneKeys(dns.name.from_text(f"z{i}.example."), None, ValidationState.SECURE), 3600) + assert len(resolver._key_cache) == 64 + + def test_zone_key_cache_evicts_least_recently_used(self) -> None: + from recursive_resolver.dnssec import ValidationState, ZoneKeys + + resolver = RecursiveResolver(dnssec=True) + resolver._key_cache_size = 2 + a, b, c = (dns.name.from_text(f"{n}.example.") for n in "abc") + for zone in (a, b): + resolver._store_keys(ZoneKeys(zone, None, ValidationState.SECURE), 3600) + resolver._cached_keys(a) # a becomes most recently used + resolver._store_keys(ZoneKeys(c, None, ValidationState.SECURE), 3600) + assert resolver._cached_keys(a) is not None + assert resolver._cached_keys(c) is not None + assert resolver._cached_keys(b) is None + + def test_cache_size_is_bounded(self) -> None: + resolver = RecursiveResolver(dnssec=False, cache_enabled=True) + resolver.cache.max_size = 32 + for i in range(2000): + resolver.cache.put_answer(dns.name.from_text(f"d{i}.example."), dns.rdatatype.A, "rrset", ttl=300) + assert len(resolver.cache) == 32 + + +class TestLimitsGrouping: + """The hardening limits are one object, because they only make sense as a set.""" + + def test_defaults_are_the_reference_values(self) -> None: + limits = Limits() + assert (limits.max_queries, limits.max_nx_targets, limits.max_referrals) == (64, 5, 130) + assert limits.max_ns_per_referral == 13 + assert (limits.max_signature_validations, limits.max_nsec3_hashes) == (96, 600) + + def test_a_resolver_without_limits_gets_the_defaults(self) -> None: + assert RecursiveResolver(dnssec=False).limits == Limits() + + def test_limits_are_immutable(self) -> None: + """A shared config object must not be mutable behind a running resolver.""" + limits = Limits() + with pytest.raises(dataclasses.FrozenInstanceError): + limits.max_queries = 10_000 # type: ignore[misc] + + def test_every_limit_reaches_the_budget(self) -> None: + limits = Limits( + max_queries=1, + max_ns_per_referral=2, + max_nx_targets=3, + max_referrals=4, + max_signature_validations=5, + max_nsec3_hashes=6, + ) + budget = offline_resolver(limits=limits)._new_context().budget + assert budget.max_queries == 1 + assert budget.max_nx_targets == 3 + assert budget.max_referrals == 4 + assert budget.max_signature_validations == 5 + assert budget.max_nsec3_hashes == 6 + + def test_each_resolution_gets_a_fresh_budget(self) -> None: + """Counters must not carry over between calls, or a resolver would decay.""" + resolver = offline_resolver(limits=Limits(max_queries=2)) + first = resolver._new_context().budget + first.spend_query("example.com.", "A") + assert resolver._new_context().budget.queries_sent == 0 + + +class TestCrossZoneCNAMEPoisoning: + """A server may only answer for its own bailiwick, CNAME targets included. + + A CNAME pointing out of the answering zone, bundled with an inline record + for that foreign target, is an attempt to write another zone's data. The + target must be re-resolved from the servers actually authoritative for it. + """ + + ATTACKER_IP = "192.5.6.30" + VICTIM_IP = "199.7.83.42" + REAL = '"v=DKIM1; k=rsa; p=REALKEY"' + FORGED = '"v=DKIM1; k=rsa; p=ATTACKERKEY"' + + def _send(self, resolver): + def send(qname, rdtype, nameservers, ctx): + if nameservers == resolver._root_addresses: + if str(qname).endswith("attacker.test."): + return referral("attacker.test.", ["ns.attacker.test."], {"ns.attacker.test.": self.ATTACKER_IP}), ( + "198.41.0.4" + ) + return referral("victim.example.", ["ns.victim.example."], {"ns.victim.example.": self.VICTIM_IP}), ( + "198.41.0.4" + ) + if self.ATTACKER_IP in nameservers: + return make_response( + answer=[ + ("lookup.attacker.test.", 300, "CNAME", ["dkim._domainkey.victim.example."]), + ("dkim._domainkey.victim.example.", 300, "TXT", [self.FORGED]), + ] + ), self.ATTACKER_IP + return make_response(answer=[("dkim._domainkey.victim.example.", 300, "TXT", [self.REAL])]), self.VICTIM_IP + + return send + + def test_an_inline_record_for_a_foreign_target_is_refused(self) -> None: + resolver = RecursiveResolver(dnssec=False) + with patch.object(resolver, "_send_query", side_effect=self._send(resolver)): + records = resolver.resolve("lookup.attacker.test", "TXT") + assert records == [self.REAL], "the attacker's inline record was trusted" + + def test_the_foreign_target_is_not_written_into_the_cache(self) -> None: + """The poisoning that mattered: a later, unrelated lookup must be clean.""" + resolver = RecursiveResolver(dnssec=False) + with patch.object(resolver, "_send_query", side_effect=self._send(resolver)): + resolver.resolve("lookup.attacker.test", "TXT") + legitimate = resolver.resolve("dkim._domainkey.victim.example", "TXT") + assert legitimate == [self.REAL] + + def test_an_inline_target_inside_the_zone_is_still_used(self) -> None: + """The optimisation must survive for the in-bailiwick CDN case.""" + resolver = RecursiveResolver(dnssec=False, cache_enabled=False) + calls = [] + + def send(qname, rdtype, nameservers, ctx): + calls.append(str(qname)) + if nameservers == resolver._root_addresses: + return referral("example.com.", ["ns.example.com."], {"ns.example.com.": "192.5.6.30"}), "198.41.0.4" + return make_response( + answer=[ + ("www.example.com.", 300, "CNAME", ["cdn.example.com."]), + ("cdn.example.com.", 300, "A", ["1.2.3.4"]), + ] + ), "192.5.6.30" + + with patch.object(resolver, "_send_query", side_effect=send): + assert resolver.resolve("www.example.com", "A") == ["1.2.3.4"] + assert calls.count("cdn.example.com.") == 0, "an in-bailiwick target should not be re-resolved" + + +class TestMappedAddressNormalisation: + """An IPv4-mapped IPv6 literal must be judged on its embedded IPv4 address. + + Loopback and RFC1918 are caught by Python's classification even unmapped, + so only the ranges we carry ourselves prove the normalisation is working. + """ + + @pytest.mark.parametrize( + "address", + [ + "::ffff:168.63.129.16", # Azure wireserver, a routable-looking address + "::ffff:169.254.169.254", # the cloud metadata endpoint + "::ffff:100.100.100.200", # Alibaba Cloud metadata + "::ffff:192.0.0.192", # Oracle Cloud metadata + "::ffff:192.88.99.1", # 6to4 relay anycast + "::ffff:100.64.0.1", # CGNAT + ], + ) + def test_a_mapped_address_is_refused(self, address: str) -> None: + assert AddressFilter().rejection_reason(address) is not None, address + + +class TestNegativeCacheScoping: + """RFC 8020 denies below an NXDOMAIN, so the cached name must be exact. + + Caching the NXDOMAIN one label up would deny every sibling of the missing + name: a self-inflicted outage on names that do exist. + """ + + def test_a_sibling_of_a_missing_name_is_not_denied(self) -> None: + resolver = RecursiveResolver(dnssec=False) + + def send(qname, rdtype, nameservers, ctx): + if nameservers == resolver._root_addresses: + return referral("example.com.", ["ns.example.com."], {"ns.example.com.": "192.5.6.30"}), "198.41.0.4" + if str(qname) == "missing.example.com.": + return make_response( + authority=[("example.com.", 300, "SOA", ["ns.example.com. a.example.com. 1 3600 900 604800 300"])], + rcode=dns.rcode.NXDOMAIN, + ), "192.5.6.30" + return make_response(answer=[(str(qname), 300, "A", ["1.2.3.4"])]), "192.5.6.30" + + with patch.object(resolver, "_send_query", side_effect=send): + with pytest.raises(NXDOMAINError): + resolver.resolve("missing.example.com", "A") + # The sibling must still resolve; and the parent must not be denied. + assert resolver.resolve("present.example.com", "A") == ["1.2.3.4"] + assert resolver.resolve("example.com", "A") == ["1.2.3.4"] + + def test_a_name_below_the_missing_name_is_denied(self) -> None: + """The other half of RFC 8020: below the NXDOMAIN really is denied.""" + resolver = RecursiveResolver(dnssec=False) + assert resolver.cache is not None + resolver.cache.put_nxdomain(dns.name.from_text("missing.example.com."), 300) + assert resolver.cache.get_nxdomain_ancestor(dns.name.from_text("a.b.missing.example.com.")) is not None + assert resolver.cache.get_nxdomain_ancestor(dns.name.from_text("present.example.com.")) is None + + +class TestWeakestState: + """A CNAME chain is only as trustworthy as its weakest link.""" + + @pytest.mark.parametrize( + ("a", "b"), + [ + (ValidationState.SECURE, ValidationState.INSECURE), + (ValidationState.INSECURE, ValidationState.SECURE), + (ValidationState.SECURE, ValidationState.BOGUS), + (ValidationState.INSECURE, ValidationState.BOGUS), + ], + ) + def test_the_weaker_state_wins(self, a: ValidationState, b: ValidationState) -> None: + resolver = RecursiveResolver(dnssec=False) + order = {ValidationState.BOGUS: 0, ValidationState.INSECURE: 1, ValidationState.SECURE: 2} + expected = a if order[a] <= order[b] else b + assert resolver._weakest(a, b) is expected + assert resolver._weakest(b, a) is expected + + def test_a_secure_leg_never_upgrades_an_insecure_one(self) -> None: + resolver = RecursiveResolver(dnssec=False) + assert resolver._weakest(ValidationState.SECURE, ValidationState.INSECURE) is ValidationState.INSECURE + + +class TestReturnedDataIsTheCallersOwn: + """A shared cache must never hand out a mutable object it still holds. + + `dns.rrset.RRset` is a mutable container. If the cache, the resolver and + every caller share one instance, a single `add` or `ttl` assignment + anywhere silently rewrites what later lookups return, in every thread. That + is the kind of corruption that is almost impossible to trace back. + """ + + @staticmethod + def offline_resolver(): + resolver = RecursiveResolver(dnssec=False) + + def send(qname, rdtype, nameservers, ctx): + if nameservers == resolver._root_addresses: + return referral("example.com.", ["ns.example.com."], {"ns.example.com.": "192.5.6.30"}), "198.41.0.4" + return make_response(answer=[("example.com.", 300, "A", ["1.2.3.4"])]), "192.5.6.30" + + return resolver, send + + def test_mutating_a_returned_rrset_does_not_poison_the_cache(self) -> None: + resolver, send = self.offline_resolver() + extra = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, "6.6.6.6") + with patch.object(resolver, "_send_query", side_effect=send): + first = resolver.resolve_answer("example.com", "A") + first.rrset.add(extra) + second = resolver.resolve_answer("example.com", "A") + assert second.records == ["1.2.3.4"] + + def test_two_callers_never_share_one_rrset(self) -> None: + resolver, send = self.offline_resolver() + with patch.object(resolver, "_send_query", side_effect=send): + first = resolver.resolve_answer("example.com", "A") + second = resolver.resolve_answer("example.com", "A") + assert first.rrset is not second.rrset + assert first.cname_chain is not second.cname_chain + + def test_mutating_a_stored_rrset_after_caching_it_does_not_reach_the_cache(self) -> None: + """The producer side: the cache copies on write as well as on read.""" + cache = DNSCache() + rrset = dns.rrset.from_text("example.com.", 300, "IN", "A", "1.2.3.4") + cache.put_answer("example.com.", "A", rrset, ttl=300) + rrset.add(dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, "6.6.6.6")) + entry = cache.get_answer("example.com.", "A") + assert entry is not None + assert [str(r) for r in entry.rrset] == ["1.2.3.4"] + + +class TestUnverifiableAlgorithmsAreInsecureNotBogus: + """RFC 4035 §5.2: a zone we have no way to check is unsigned, not forged. + + Reporting BOGUS would reject a legitimately signed zone outright merely + because this build lacks its algorithm, which is a self-inflicted outage. + """ + + def test_an_unsupported_algorithm_is_reported_as_unsupported(self) -> None: + from recursive_resolver.dnssec import algorithm_supported + + assert algorithm_supported(8) is True # RSASHA256 + assert algorithm_supported(13) is True # ECDSAP256SHA256 + assert algorithm_supported(252) is False # not a real signing algorithm + + def test_a_zone_whose_ds_we_cannot_digest_is_insecure(self) -> None: + from recursive_resolver.dnssec import DNSSECValidator + + validator = DNSSECValidator() + keys = dns.rrset.RRset(dns.name.from_text("z.test."), dns.rdataclass.IN, dns.rdatatype.DNSKEY) + keys.add(dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DNSKEY, "257 3 8 AwEAAaz/tAm8yTn4Mfeh5eyI96WS")) + keys.ttl = 300 + ds = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.DS) + ds.add(dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DS, "12345 8 2 " + "AB" * 32), ttl=300) + with patch("dns.dnssec.make_ds", side_effect=dns.dnssec.UnsupportedAlgorithm("no such digest")): + assert validator.validate_dnskey(dns.name.from_text("z.test."), keys, None, ds) is ValidationState.INSECURE + + def test_a_genuine_mismatch_is_still_bogus(self) -> None: + """The distinction must not weaken real forgery detection.""" + from recursive_resolver.dnssec import DNSSECValidator + + validator = DNSSECValidator() + keys = dns.rrset.RRset(dns.name.from_text("z.test."), dns.rdataclass.IN, dns.rdatatype.DNSKEY) + keys.add(dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DNSKEY, "257 3 8 AwEAAaz/tAm8yTn4Mfeh5eyI96WS")) + keys.ttl = 300 + ds = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.DS) + ds.add(dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DS, "12345 8 2 " + "AB" * 32), ttl=300) + assert validator.validate_dnskey(dns.name.from_text("z.test."), keys, None, ds) is ValidationState.BOGUS diff --git a/tests/test_singleflight.py b/tests/test_singleflight.py new file mode 100644 index 0000000..e621aca --- /dev/null +++ b/tests/test_singleflight.py @@ -0,0 +1,201 @@ +"""Tests for concurrent-resolution deduplication.""" + +from __future__ import annotations + +import threading +import time +from unittest.mock import patch + +import pytest +from conftest import make_response, referral, root_to_com + +from recursive_resolver import DNSSECValidationError, RecursiveResolver +from recursive_resolver.singleflight import SingleFlight, _Call + + +class TestSingleFlight: + def test_concurrent_calls_run_once(self) -> None: + sf: SingleFlight[int] = SingleFlight() + calls = 0 + started = threading.Event() + + def work() -> int: + nonlocal calls + calls += 1 + started.set() + time.sleep(0.2) + return 42 + + results: list[int] = [] + threads = [threading.Thread(target=lambda: results.append(sf.do("k", work))) for _ in range(8)] + threads[0].start() + started.wait(timeout=2) + for t in threads[1:]: + t.start() + for t in threads: + t.join() + + assert calls == 1 + assert results == [42] * 8 + + def test_different_keys_run_independently(self) -> None: + sf: SingleFlight[str] = SingleFlight() + assert sf.do("a", lambda: "A") == "A" + assert sf.do("b", lambda: "B") == "B" + + def test_failure_is_shared_with_waiters(self) -> None: + sf: SingleFlight[int] = SingleFlight() + started = threading.Event() + errors: list[BaseException] = [] + + def work() -> int: + started.set() + time.sleep(0.15) + raise ValueError("boom") + + def waiter() -> None: + try: + sf.do("k", work) + except BaseException as exc: # noqa: BLE001 + errors.append(exc) + + leader = threading.Thread(target=waiter) + leader.start() + started.wait(timeout=2) + others = [threading.Thread(target=waiter) for _ in range(3)] + for t in others: + t.start() + for t in [leader, *others]: + t.join() + + assert len(errors) == 4 + assert all(isinstance(e, ValueError) for e in errors) + + def test_each_waiter_gets_its_own_exception_instance(self) -> None: + """Sharing one object means threads racing on its __traceback__. + + The failure must still arrive with the right type, message and cause; + only the identity differs. + """ + cause = ValueError("underlying") + original = DNSSECValidationError("a.test.", "A", "bad signature") + original.__cause__ = cause + + sf: SingleFlight[int] = SingleFlight() + started = threading.Event() + seen: list[BaseException] = [] + lock = threading.Lock() + + def work() -> int: + started.set() + time.sleep(0.15) + raise original + + def run() -> None: + try: + sf.do("k", work) + except BaseException as exc: # noqa: BLE001 - recorded for inspection + with lock: + seen.append(exc) + + # Leader first, then waiters once it is known to be in flight, so the + # three really do attach rather than each becoming a leader in turn. + leader = threading.Thread(target=run) + leader.start() + started.wait(timeout=5) + others = [threading.Thread(target=run) for _ in range(3)] + for t in others: + t.start() + for t in [leader, *others]: + t.join(timeout=5) + + assert len(seen) == 4 + # The leader re-raises its own object; only the waiters are cloned. + clones = [e for e in seen if e is not original] + assert clones, "no waiter received a private instance" + assert len({id(e) for e in clones}) == len(clones), "two waiters shared one exception object" + for exc in clones: + assert type(exc) is DNSSECValidationError + assert str(exc) == str(original) + assert exc.__cause__ is cause + + def test_an_oserror_is_passed_through_rather_than_cloned(self) -> None: + """errno lives in C-level state a clone would drop; a lossy copy is worse.""" + original = OSError(9, "bad file descriptor") + sf: SingleFlight[int] = SingleFlight() + call = _Call[int]() + call.error = original + call.event.set() + sf._calls["k"] = call + + with pytest.raises(OSError) as exc: + sf.do("k", lambda: 0) + assert exc.value is original + assert exc.value.errno == 9 + + def test_an_uncopyable_exception_is_passed_through_rather_than_lost(self) -> None: + """The leader catches BaseException, so this is not a closed set. + + A waiter must receive the real failure; an error raised while trying to + give it a private instance would be strictly worse than sharing one. + """ + + class AwkwardError(Exception): + def __new__(cls, *args: object) -> AwkwardError: + raise TypeError("cannot be reconstructed") + + original = Exception.__new__(AwkwardError) + original.args = ("boom",) + + sf: SingleFlight[int] = SingleFlight() + call = _Call[int]() + call.error = original + call.event.set() + sf._calls["k"] = call + + with pytest.raises(AwkwardError) as exc: + sf.do("k", lambda: 0) + assert exc.value is original + assert str(exc.value) == "boom" + + def test_key_is_released_after_completion(self) -> None: + sf: SingleFlight[int] = SingleFlight() + sf.do("k", lambda: 1) + assert sf.in_flight() == 0 + with pytest.raises(ValueError): + sf.do("k", lambda: (_ for _ in ()).throw(ValueError())) + assert sf.in_flight() == 0 + + +class TestResolverDeduplication: + def test_thundering_herd_is_collapsed(self) -> None: + """24 threads asking for one name must not run 24 independent walks.""" + resolver = RecursiveResolver(dnssec=False, cache_enabled=True) + queries = 0 + lock = threading.Lock() + + def send(qname, rdtype, nameservers, ctx): + nonlocal queries + with lock: + queries += 1 + time.sleep(0.02) + if nameservers[0] in resolver._root_addresses: + return root_to_com(), nameservers[0] + if nameservers[0] == "192.5.6.30": + return referral("example.com.", ["ns1.example.com."], {"ns1.example.com.": "1.2.3.4"}), "192.5.6.30" + return make_response(answer=[("example.com.", 300, "A", ["9.9.9.9"])]), "1.2.3.4" + + results: list[list[str]] = [] + with patch.object(resolver, "_send_query", side_effect=send): + threads = [ + threading.Thread(target=lambda: results.append(resolver.resolve("example.com", "A"))) for _ in range(24) + ] + for t in threads: + t.start() + for t in threads: + t.join() + + assert all(r == ["9.9.9.9"] for r in results) + assert len(results) == 24 + # One full walk is 3 queries; without deduplication this was ~72. + assert queries <= 12, f"expected the herd to be collapsed, got {queries} queries" diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..b5104ff --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,292 @@ +"""Transport-layer behaviour: how dnspython failures are translated and retried. + +These are the paths a hostile or broken nameserver drives, so each one needs to +be exercised deliberately rather than assumed. +""" + +from __future__ import annotations + +import errno +from unittest.mock import patch + +import dns.exception +import dns.message +import dns.name +import dns.query +import dns.rdatatype +import pytest +from conftest import make_response + +from recursive_resolver import RecursiveResolver +from recursive_resolver import resolver as resolver_module +from recursive_resolver.resolver import _FatalServerError, _MalformedResponseError, _RetryableError + +QNAME = dns.name.from_text("example.com.") + + +def _resolver(**kwargs) -> RecursiveResolver: + kwargs.setdefault("dnssec", False) + kwargs.setdefault("cache_enabled", False) + return RecursiveResolver(**kwargs) + + +def _query_once(resolver: RecursiveResolver, payload: int | None = 1232): + ctx = resolver._new_context() + return resolver._query_once(QNAME, dns.rdatatype.A, "9.9.9.9", payload, 1.0, ctx) + + +class TestExceptionTranslation: + """Every dnspython failure must map to a deliberate internal signal.""" + + def test_timeout_is_retryable(self) -> None: + resolver = _resolver() + with patch("dns.query.udp_with_fallback", side_effect=dns.exception.Timeout), pytest.raises(_RetryableError): + _query_once(resolver) + + def test_eof_is_retryable(self) -> None: + """dnspython raises EOFError when a TCP peer closes mid-response.""" + resolver = _resolver() + with patch("dns.query.udp_with_fallback", side_effect=EOFError("EOF")), pytest.raises(_RetryableError): + _query_once(resolver) + + @pytest.mark.parametrize("code", [errno.EHOSTUNREACH, errno.ENETUNREACH, errno.EAFNOSUPPORT, errno.EADDRNOTAVAIL]) + def test_unreachable_errnos_abandon_the_server(self, code: int) -> None: + """No address family here means retrying is pointless.""" + resolver = _resolver() + with ( + patch("dns.query.udp_with_fallback", side_effect=OSError(code, "unreachable")), + pytest.raises(_FatalServerError), + ): + _query_once(resolver) + + def test_other_oserrors_are_retryable(self) -> None: + resolver = _resolver() + with ( + patch("dns.query.udp_with_fallback", side_effect=OSError(errno.ECONNREFUSED, "refused")), + pytest.raises(_RetryableError), + ): + _query_once(resolver) + + def test_unexpected_source_is_retryable_not_malformed(self) -> None: + """A stray packet must not be treated as 'this server speaks bad DNS'.""" + resolver = _resolver() + with ( + patch("dns.query.udp_with_fallback", side_effect=dns.query.UnexpectedSource("bad source")), + pytest.raises(_RetryableError), + ): + _query_once(resolver) + + def test_form_error_is_malformed(self) -> None: + resolver = _resolver() + with ( + patch("dns.query.udp_with_fallback", side_effect=dns.exception.FormError("bad wire")), + pytest.raises(_MalformedResponseError), + ): + _query_once(resolver) + + def test_bad_response_is_malformed(self) -> None: + """BadResponse (ID/question mismatch) is a FormError subclass.""" + resolver = _resolver() + with ( + patch("dns.query.udp_with_fallback", side_effect=dns.query.BadResponse), + pytest.raises(_MalformedResponseError), + ): + _query_once(resolver) + + def test_other_dns_exceptions_are_malformed(self) -> None: + resolver = _resolver() + with ( + patch("dns.query.udp_with_fallback", side_effect=dns.exception.SyntaxError("nope")), + pytest.raises(_MalformedResponseError), + ): + _query_once(resolver) + + def test_truncation_without_tcp_fallback_is_retryable(self) -> None: + resolver = _resolver(use_tcp_fallback=False) + truncated = make_response(answer=[("example.com.", 300, "A", ["1.1.1.1"])], tc=True) + with ( + patch("dns.query.udp", side_effect=dns.message.Truncated(message=truncated)), + pytest.raises(_RetryableError), + ): + _query_once(resolver) + + def test_plain_udp_path_requests_truncation_errors(self) -> None: + """The no-EDNS path must not silently accept a partial answer.""" + resolver = _resolver(use_tcp_fallback=False) + good = make_response(answer=[("example.com.", 300, "A", ["1.1.1.1"])]) + with patch("dns.query.udp", return_value=good) as udp: + _query_once(resolver, payload=None) + assert udp.call_args.kwargs["raise_on_truncation"] is True + + def test_no_edns_query_carries_no_opt_record(self) -> None: + resolver = _resolver(use_tcp_fallback=False) + good = make_response(answer=[("example.com.", 300, "A", ["1.1.1.1"])]) + with patch("dns.query.udp", return_value=good) as udp: + _query_once(resolver, payload=None) + assert udp.call_args.args[0].edns == -1, "EDNS should be disabled on the plain path" + + def test_rd_bit_is_always_cleared(self) -> None: + """We iterate ourselves; asking a server to recurse would be wrong.""" + import dns.flags + + resolver = _resolver() + good = make_response(answer=[("example.com.", 300, "A", ["1.1.1.1"])]) + with patch("dns.query.udp_with_fallback", return_value=(good, False)) as udp: + _query_once(resolver) + assert not (udp.call_args.args[0].flags & dns.flags.RD) + + +class TestServerRotation: + def test_fatal_error_moves_to_the_next_server(self) -> None: + # Server order is normally shuffled; pin it so the assertion is precise. + resolver = _resolver() + tried: list[str] = [] + + def query_once(qname, rdtype, server, payload, timeout, ctx): + tried.append(server) + if server == "1.1.1.1": + raise _FatalServerError("no route") + return make_response(answer=[("example.com.", 300, "A", ["1.2.3.4"])]) + + with ( + patch.object(resolver, "_query_once", side_effect=query_once), + patch.object(resolver_module._RANDOM, "shuffle", lambda seq: None), + ): + ctx = resolver._new_context() + response, server = resolver._send_query(QNAME, dns.rdatatype.A, ["1.1.1.1", "9.9.9.9"], ctx) + assert response is not None + assert server == "9.9.9.9" + assert tried.count("1.1.1.1") == 1, "a fatal server must not be retried" + + def test_malformed_response_downgrades_edns_once_then_gives_up(self) -> None: + resolver = _resolver(max_retries=3) + payloads: list[int | None] = [] + + def query_once(qname, rdtype, server, payload, timeout, ctx): + payloads.append(payload) + raise _MalformedResponseError("garbage") + + with patch.object(resolver, "_query_once", side_effect=query_once): + ctx = resolver._new_context() + response, _ = resolver._send_query(QNAME, dns.rdatatype.A, ["9.9.9.9"], ctx) + assert response is None + assert payloads == [1232, None], "one EDNS downgrade, then abandon the server" + + def test_deadline_stops_the_send_loop(self) -> None: + resolver = _resolver(max_resolution_time=0.0) + ctx = resolver._new_context() + response, server = resolver._send_query(QNAME, dns.rdatatype.A, ["9.9.9.9"], ctx) + assert response is None + assert server == "" + + def test_no_usable_servers_returns_nothing(self) -> None: + """Every candidate filtered out as non-public.""" + resolver = _resolver() + ctx = resolver._new_context() + response, server = resolver._send_query(QNAME, dns.rdatatype.A, ["127.0.0.1", "10.0.0.1"], ctx) + assert response is None + assert server == "" + + +class TestServerOrdering: + def test_servers_are_shuffled(self) -> None: + """Deterministic ordering would make one server absorb all first-tries.""" + resolver = _resolver() + candidates = [f"9.9.9.{i}" for i in range(1, 30)] + orders = {tuple(resolver._order_servers(candidates)) for _ in range(20)} + assert len(orders) > 1, "server order should vary between calls" + + def test_ordering_drops_non_public_addresses(self) -> None: + resolver = _resolver() + ordered = resolver._order_servers(["9.9.9.9", "127.0.0.1", "10.0.0.1", "1.1.1.1"]) + assert sorted(ordered) == ["1.1.1.1", "9.9.9.9"] + + +class TestBreadthFirstSweep: + """Every server is tried once before any is retried. + + Retrying one server to exhaustion before touching the next lets a single + dead nameserver consume the whole budget while a healthy sibling sits + unused, which is what made several stale-delegation domains unresolvable. + """ + + def test_a_dead_server_does_not_delay_a_healthy_one(self) -> None: + resolver = _resolver(max_retries=2) + order: list[str] = [] + + def query_once(qname, rdtype, server, payload, timeout, ctx): + order.append(server) + if server == "1.1.1.1": + raise _RetryableError("blackholed") + return make_response(answer=[("example.com.", 300, "A", ["1.2.3.4"])]) + + with ( + patch.object(resolver, "_query_once", side_effect=query_once), + patch.object(resolver_module._RANDOM, "shuffle", lambda seq: None), + ): + ctx = resolver._new_context() + response, server = resolver._send_query(QNAME, dns.rdatatype.A, ["1.1.1.1", "9.9.9.9"], ctx) + + assert response is not None and server == "9.9.9.9" + assert order == ["1.1.1.1", "9.9.9.9"], "the healthy server must be reached on the first sweep" + + def test_all_servers_abandoned_stops_immediately(self) -> None: + """No point sweeping again when every server is unreachable.""" + resolver = _resolver(max_retries=5) + calls = 0 + + def query_once(qname, rdtype, server, payload, timeout, ctx): + nonlocal calls + calls += 1 + raise _FatalServerError("no route to host") + + with patch.object(resolver, "_query_once", side_effect=query_once): + ctx = resolver._new_context() + response, _ = resolver._send_query(QNAME, dns.rdatatype.A, ["1.1.1.1", "9.9.9.9"], ctx) + + assert response is None + assert calls == 2, "each server abandoned once, no further sweeps" + + def test_edns_downgrade_is_remembered_per_server(self) -> None: + resolver = _resolver(max_retries=2) + seen: list[tuple[str, int | None]] = [] + + def query_once(qname, rdtype, server, payload, timeout, ctx): + seen.append((server, payload)) + if server == "1.1.1.1" and payload is not None: + return make_response(rcode=dns.rcode.NOTIMP, aa=False) + if server == "1.1.1.1": + return make_response(answer=[("example.com.", 300, "A", ["1.2.3.4"])]) + raise _RetryableError("quiet") + + with ( + patch.object(resolver, "_query_once", side_effect=query_once), + patch.object(resolver_module._RANDOM, "shuffle", lambda seq: None), + ): + ctx = resolver._new_context() + response, server = resolver._send_query(QNAME, dns.rdatatype.A, ["1.1.1.1", "9.9.9.9"], ctx) + + assert response is not None and server == "1.1.1.1" + assert ("1.1.1.1", None) in seen, "the EDNS-less retry must target the same server" + + def test_an_abandoned_server_is_skipped_on_later_sweeps(self) -> None: + """Once a server is unreachable, later sweeps must not query it again.""" + resolver = _resolver(max_retries=2) + attempts: list[str] = [] + + def query_once(qname, rdtype, server, payload, timeout, ctx): + attempts.append(server) + if server == "1.1.1.1": + raise _FatalServerError("no route to host") + raise _RetryableError("quiet") + + with ( + patch.object(resolver, "_query_once", side_effect=query_once), + patch.object(resolver_module._RANDOM, "shuffle", lambda seq: None), + ): + ctx = resolver._new_context() + response, _ = resolver._send_query(QNAME, dns.rdatatype.A, ["1.1.1.1", "9.9.9.9"], ctx) + + assert response is None + assert attempts.count("1.1.1.1") == 1, "the abandoned server must not be retried" + assert attempts.count("9.9.9.9") == 3, "the quiet server is retried on every sweep"