diff --git a/CHANGELOG.md b/CHANGELOG.md index ecb206b..3458797 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,12 +12,13 @@ Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorl - De bronnenpagina toont 21 actieve bronnen, 12 waaklijstwerkgevers en de actieve GeoNames-afstandsstatus; waaklijstbronnen vallen buiten bulkacties en geplande scans. - HTTP-, robots- en IMAP-verbindingen gebruiken expliciet de gepinde `certifi`-CA-bundel met verplichte hostnaam- en certificaatcontrole. Gemeente Mol en Ziekenhuis Geel werken daarmee zonder TLS-bypass. - Celery gebruikt expliciet queue `default`, zodat bronruns en andere standaardtaken daadwerkelijk door de productie-worker worden geconsumeerd. +- De fetcher controleert het werkelijk verbonden peer-IP en accepteert daardoor veilige publieke CDN-rotatie zonder de SSRF-blokkade voor private adressen te versoepelen. ### Verificatie - De 8 nieuwe actieve bronnen leverden samen 53 kaarten per cyclus en voltooiden elk twee livecycli zonder parserwarnings; de tweede cycli maakten geen nieuwe dubbelen aan. - GeoNames valideerde 2.781 unieke Belgische postcode-/gemeenterijen; bron- en afgeleide SHA-256-checksums zijn vastgelegd en het actieve profiel is opnieuw gescoord. -- 216 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,42% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen. +- 218 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,46% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen. - De Codex-browsercontrole bevestigde 21 actieve bronnen, 12 waaklijstkaarten, 8 nieuwe actieve werkgevers, één `h1`, een lege console en geen horizontale overflow op desktop of 390 px. ## 0.2.9 — 2026-07-22 diff --git a/apps/sources/services/fetcher.py b/apps/sources/services/fetcher.py index 9daea56..610debc 100644 --- a/apps/sources/services/fetcher.py +++ b/apps/sources/services/fetcher.py @@ -1,6 +1,7 @@ from __future__ import annotations import hashlib +import ipaddress from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime @@ -53,6 +54,38 @@ class FetchTimeoutError(FetchError): pass +def _connected_peer_ip( + response: httpx.Response, +) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None: + """Return the actual remote peer when httpcore exposes its network stream.""" + stream = response.extensions.get("network_stream") + if stream is None or not hasattr(stream, "get_extra_info"): + return None + try: + server_address = stream.get_extra_info("server_addr") + raw_address = server_address[0] if server_address else None + return ipaddress.ip_address(raw_address) if raw_address else None + except (IndexError, TypeError, ValueError, OSError): + return None + + +def _validate_connected_peer( + response: httpx.Response, + *, + before: ValidatedUrl, + after: ValidatedUrl, +) -> None: + peer_ip = _connected_peer_ip(response) + if peer_ip is not None: + if not peer_ip.is_global: + raise FetchError(f"Niet-publiek verbonden IP-adres geblokkeerd: {peer_ip}") + return + # Mock/custom transports do not always expose the peer socket. Keep the conservative + # DNS-overlap fallback for those transports. + if not set(before.addresses).intersection(after.addresses): + raise FetchError("DNS-rebindcontrole faalde bij het benaderen van bron.") + + def parse_retry_after(value: str | None) -> int | None: if not value: return None @@ -127,26 +160,22 @@ def fetch_url( verify=trusted_tls_context(), ) current_url = url - previous_validation: ValidatedUrl | None = None try: for _ in range(settings.FETCHER_MAX_REDIRECTS + 1): validation = validate_public_url( current_url, allow_nonstandard_ports=settings.FETCHER_ALLOW_NONSTANDARD_PORTS, ) - if ( - previous_validation is not None - and validation.hostname == previous_validation.hostname - and not set(validation.addresses).intersection(previous_validation.addresses) - ): - raise FetchError("DNS-rebindcontrole faalde bij redirect naar dezelfde host.") response = http_client.get(current_url, headers=headers) post_validation = validate_public_url( str(response.url) if response.url else current_url, allow_nonstandard_ports=settings.FETCHER_ALLOW_NONSTANDARD_PORTS, ) - if not set(validation.addresses).intersection(set(post_validation.addresses)): - raise FetchError("DNS-rebindcontrole faalde bij het benaderen van bron.") + _validate_connected_peer( + response, + before=validation, + after=post_validation, + ) if response.status_code in {301, 302, 303, 307, 308}: location = response.headers.get("location") if not location: @@ -155,7 +184,6 @@ def fetch_url( next_decision = assess_url(current_url, source=source) if not next_decision.allowed: raise PolicyBlockedError(next_decision.reason) - previous_validation = validation continue if response.status_code == 304: return FetchedDocument(url, current_url, 304, dict(response.headers), b"") diff --git a/docs/ai/PROJECT_STATE.md b/docs/ai/PROJECT_STATE.md index c7af5ae..22a3d51 100644 --- a/docs/ai/PROJECT_STATE.md +++ b/docs/ai/PROJECT_STATE.md @@ -30,6 +30,7 @@ Release-audit op 2026-07-21: - backlog- en repositoryvalidatie geslaagd; - de lockfile gebruikt publieke PyPI-bronnen in plaats van een niet-overdraagbare interne registry; - releaseblokkers hersteld in template-rendering, JSON/BOM-verwerking, leases, feedback, dossierautorisatie, ATS-provenance, employer-resolutie en DNS-rebindcontrole; +- de productie-fetcher valideert het verbonden peer-IP, zodat legitieme CDN-DNS-rotatie bij DAF en Xplore Group niet als rebind wordt afgewezen terwijl private peers geblokkeerd blijven; - een echte Dockerimagebuild op de Unraid-server, migratiecontrole en live LAN-smoke voor liveness, readiness, login en static assets zijn geslaagd; - deploymenthardening voorkomt gelijktijdige migraties door worker/scheduler, schakelt de Gunicorn-controlsocket uit voor de read-only container en houdt de lokale healthcheckhost toegestaan. @@ -119,14 +120,15 @@ VR-126-herverificatie op 2026-07-22: VR-201/VR-205-herverificatie op 2026-07-22: -- 216 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd; -- 84,42% branch-aware codedekking; +- 218 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd; +- 84,46% branch-aware codedekking; - Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`; - GeoNames België 2026-04-03 is onder CC BY 4.0 met checksums gedocumenteerd, 2.781 unieke invoerrijen zijn gevalideerd en 2400 Mol is opgelost naar het benaderende postcodecentrum 51.1919, 5.1166; - Gemeente Mol en zeven bijkomende Kempen-werkgevers passeerden elk twee livecycli zonder parserwarnings; samen leverden zij 53 kaarten per cyclus; - twaalf regionale werkgevers staan afzonderlijk als niet-scanbare waaklijst in de UI, ook wanneer zij momenteel geen vacature hebben; - Codex-browsercontrole bevestigde 21 actieve bronnen, 12 waaklijstkaarten, de GeoNames-status, één `h1`, geen horizontale overflow op desktop of 390 px en geen consolefouten; - de Unraid-deploy gebruikt expliciet Celery-queue `default`, overeenkomstig de queues die de productie-worker consumeert; +- de fetcher valideert het werkelijk verbonden publieke peer-IP, zodat legitieme CDN-rotatie van DAF en Xplore Group veilig wordt ondersteund; - taakledger bevat 44 taken: 38 `done`, 3 `blocked-external`, 3 `deferred` en 0 `ready`. ## Deploymentstatus @@ -320,7 +322,6 @@ Deze blokkeren onafhankelijke code niet: - Unraid-domein, DNS, TLS en reverse proxy; - containerregistry en credentials; - actieve/voor deze repository geregistreerde Gitea Actions-runner; -- keuze/verkrijging van een gelicenseerde actuele Belgische postcode-/gemeentedataset. ## Bekende verificatiebeperking van de aangeleverde basis diff --git a/docs/quality/THREAT_MODEL.md b/docs/quality/THREAT_MODEL.md index 64f2886..0c014b1 100644 --- a/docs/quality/THREAT_MODEL.md +++ b/docs/quality/THREAT_MODEL.md @@ -47,7 +47,7 @@ Django/Celery | ID | Dreiging | Impact | Mitigaties | Verificatie | |---|---|---|---|---| | T-01 | SSRF via bron- of vacature-URL | toegang tot LAN, metadata of beheerinterfaces | schema/host/IP/poortcontrole, DNS-resolutie, redirecthercontrole, denylist, geen credentials in URL | `tests/security/test_url_security.py`, `test_policy.py`, fetchertests | -| T-02 | DNS rebinding/tijd-van-controle | request bereikt ander IP dan beoordeeld | iedere fetch en redirect valideert opnieuw DNS-resolutie en vereist consistente IP-set-overlap | `VR-115`, `tests/unit/test_fetcher.py`, aanvullende fetcher negative tests | +| T-02 | DNS rebinding/tijd-van-controle | request bereikt ander IP dan beoordeeld | iedere fetch en redirect valideert DNS opnieuw; het werkelijk verbonden peer-IP wordt als publiek gecontroleerd wanneer de transportlaag dit exposeert, anders geldt conservatieve DNS-set-overlap | `VR-115`, `tests/unit/test_fetcher.py`, aanvullende fetcher negative tests | | T-03 | XSS/HTML-injectie | sessiediefstal of ongewenste navigatie | Bleach allowlist, platte tekst, template autoescaping, CSP/securityheaders | `tests/security/test_sanitize.py`, viewtests | | T-04 | Prompt injection in vacature | AI verandert beleid of voert tools uit | vacaturetekst in data-envelope, schema-output, evidence-validatie, cachefouten met foutcategorie, geen toolrechten, deterministische harde regels, AI optioneel | AI-tests, ADR-0003, `VR-109` evaluaties | | T-05 | Malafide/oversized document | geheugen/CPU/diskuitputting | contenttype-, byte-, timeout- en redirectlimieten; korte retentie | fetchertests, source-runmetrics | @@ -112,7 +112,7 @@ Een wijziging aan netwerk, mail, AI, rendering, auth, exports of uploads vereist ## Resterende risico's -- Een applicatielaag kan DNS rebinding niet volledig uitsluiten zonder transportniveau-IP-pinning; dit staat in `VR-115`. +- De fetcher controleert het werkelijk verbonden peer-IP wanneer httpcore dat exposeert. Een custom transport dat geen peerinformatie aanbiedt valt terug op DNS-set-overlap; volledige IP-pinning blijft een aanvullend egress-hardeningniveau. - Brute-force of herhaalde foutieve mutatiepogingen blijven mogelijk, maar zijn beperkt door in-memory rate limiting en blokkades op login/manual-import. - Bronvoorwaarden vereisen menselijke/externe review per domein; automatisering kan dat niet juridisch beslissen. - Fuzzy dedupe en featureheuristieken kunnen inhoudelijk verkeerd zijn; provenance, feedback en benchmarkevaluaties beperken maar elimineren dit niet. diff --git a/tests/unit/test_fetcher.py b/tests/unit/test_fetcher.py index 47ebb68..e545b30 100644 --- a/tests/unit/test_fetcher.py +++ b/tests/unit/test_fetcher.py @@ -76,28 +76,83 @@ def test_fetcher_respects_rate_limit_and_retry_after(monkeypatch): def test_fetcher_rejects_dns_rebinding(monkeypatch): _patch_dns(monkeypatch) + calls = 0 def validate(url: str, **kwargs): - if "/final" in url: - return ValidatedUrl( - url=url, hostname="example.org", port=443, addresses=("198.51.100.12",) + nonlocal calls + calls += 1 + address = "93.184.216.34" if calls == 1 else "198.51.100.12" + return ValidatedUrl(url=url, hostname="example.org", port=443, addresses=(address,)) + + monkeypatch.setattr("apps.sources.services.fetcher.validate_public_url", validate) + + client = httpx.Client( + transport=httpx.MockTransport( + lambda request: httpx.Response( + 200, + headers={"content-type": "text/html; charset=utf-8"}, + content=b"Vacature", ) - return ValidatedUrl(url=url, hostname="example.org", port=443, addresses=("93.184.216.34",)) + ) + ) + with pytest.raises(FetchError, match="DNS-rebindcontrole"): + fetch_url("https://example.org/start", client=client) + client.close() + + +class _PeerStream: + def __init__(self, address: str) -> None: + self.address = address + + def get_extra_info(self, key: str): + return (self.address, 443) if key == "server_addr" else None + + +def test_fetcher_accepts_public_cdn_rotation_when_connected_peer_is_public(monkeypatch): + calls = 0 + + def validate(url: str, **kwargs): + nonlocal calls + calls += 1 + address = "150.171.109.35" if calls == 1 else "150.171.109.36" + return ValidatedUrl(url=url, hostname="cdn.example.org", port=443, addresses=(address,)) monkeypatch.setattr("apps.sources.services.fetcher.validate_public_url", validate) def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/start": - return httpx.Response(302, headers={"location": "/final"}) + status_code = 302 if request.url.path == "/start" else 200 + headers = {"location": "/jobs"} if status_code == 302 else {"content-type": "text/html"} return httpx.Response( - 200, - headers={"content-type": "text/html; charset=utf-8"}, - content=b"Vacature", + status_code, + headers=headers, + content=b"" if status_code == 302 else b"Vacature", + extensions={"network_stream": _PeerStream("150.171.109.36")}, ) client = httpx.Client(transport=httpx.MockTransport(handler)) - with pytest.raises(FetchError, match="DNS-rebindcontrole"): - fetch_url("https://example.org/start", client=client) + + document = fetch_url("https://cdn.example.org/start", client=client) + + assert document.status_code == 200 + assert document.final_url == "https://cdn.example.org/jobs" + client.close() + + +def test_fetcher_rejects_private_connected_peer(monkeypatch): + _patch_dns(monkeypatch) + client = httpx.Client( + transport=httpx.MockTransport( + lambda request: httpx.Response( + 200, + headers={"content-type": "text/html"}, + content=b"Vacature", + extensions={"network_stream": _PeerStream("10.0.0.5")}, + ) + ) + ) + + with pytest.raises(FetchError, match="Niet-publiek verbonden IP-adres"): + fetch_url("https://example.org/jobs", client=client) client.close()