fix: validate connected peer for rotating CDNs
deploy / deploy (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-22 05:56:30 +02:00
parent e234641c36
commit 7d3d026d1d
5 changed files with 112 additions and 27 deletions
+2 -1
View File
@@ -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. - 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. - 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. - 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 ### 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. - 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. - 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. - 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 ## 0.2.9 — 2026-07-22
+38 -10
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import ipaddress
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
@@ -53,6 +54,38 @@ class FetchTimeoutError(FetchError):
pass 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: def parse_retry_after(value: str | None) -> int | None:
if not value: if not value:
return None return None
@@ -127,26 +160,22 @@ def fetch_url(
verify=trusted_tls_context(), verify=trusted_tls_context(),
) )
current_url = url current_url = url
previous_validation: ValidatedUrl | None = None
try: try:
for _ in range(settings.FETCHER_MAX_REDIRECTS + 1): for _ in range(settings.FETCHER_MAX_REDIRECTS + 1):
validation = validate_public_url( validation = validate_public_url(
current_url, current_url,
allow_nonstandard_ports=settings.FETCHER_ALLOW_NONSTANDARD_PORTS, 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) response = http_client.get(current_url, headers=headers)
post_validation = validate_public_url( post_validation = validate_public_url(
str(response.url) if response.url else current_url, str(response.url) if response.url else current_url,
allow_nonstandard_ports=settings.FETCHER_ALLOW_NONSTANDARD_PORTS, allow_nonstandard_ports=settings.FETCHER_ALLOW_NONSTANDARD_PORTS,
) )
if not set(validation.addresses).intersection(set(post_validation.addresses)): _validate_connected_peer(
raise FetchError("DNS-rebindcontrole faalde bij het benaderen van bron.") response,
before=validation,
after=post_validation,
)
if response.status_code in {301, 302, 303, 307, 308}: if response.status_code in {301, 302, 303, 307, 308}:
location = response.headers.get("location") location = response.headers.get("location")
if not location: if not location:
@@ -155,7 +184,6 @@ def fetch_url(
next_decision = assess_url(current_url, source=source) next_decision = assess_url(current_url, source=source)
if not next_decision.allowed: if not next_decision.allowed:
raise PolicyBlockedError(next_decision.reason) raise PolicyBlockedError(next_decision.reason)
previous_validation = validation
continue continue
if response.status_code == 304: if response.status_code == 304:
return FetchedDocument(url, current_url, 304, dict(response.headers), b"") return FetchedDocument(url, current_url, 304, dict(response.headers), b"")
+4 -3
View File
@@ -30,6 +30,7 @@ Release-audit op 2026-07-21:
- backlog- en repositoryvalidatie geslaagd; - backlog- en repositoryvalidatie geslaagd;
- de lockfile gebruikt publieke PyPI-bronnen in plaats van een niet-overdraagbare interne registry; - 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; - 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; - 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. - 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: VR-201/VR-205-herverificatie op 2026-07-22:
- 216 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd; - 218 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
- 84,42% branch-aware codedekking; - 84,46% branch-aware codedekking;
- Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`; - 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; - 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; - 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; - 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; - 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 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`. - taakledger bevat 44 taken: 38 `done`, 3 `blocked-external`, 3 `deferred` en 0 `ready`.
## Deploymentstatus ## Deploymentstatus
@@ -320,7 +322,6 @@ Deze blokkeren onafhankelijke code niet:
- Unraid-domein, DNS, TLS en reverse proxy; - Unraid-domein, DNS, TLS en reverse proxy;
- containerregistry en credentials; - containerregistry en credentials;
- actieve/voor deze repository geregistreerde Gitea Actions-runner; - actieve/voor deze repository geregistreerde Gitea Actions-runner;
- keuze/verkrijging van een gelicenseerde actuele Belgische postcode-/gemeentedataset.
## Bekende verificatiebeperking van de aangeleverde basis ## Bekende verificatiebeperking van de aangeleverde basis
+2 -2
View File
@@ -47,7 +47,7 @@ Django/Celery
| ID | Dreiging | Impact | Mitigaties | Verificatie | | 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-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-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-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 | | 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 ## 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. - 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. - 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. - Fuzzy dedupe en featureheuristieken kunnen inhoudelijk verkeerd zijn; provenance, feedback en benchmarkevaluaties beperken maar elimineren dit niet.
+66 -11
View File
@@ -76,28 +76,83 @@ def test_fetcher_respects_rate_limit_and_retry_after(monkeypatch):
def test_fetcher_rejects_dns_rebinding(monkeypatch): def test_fetcher_rejects_dns_rebinding(monkeypatch):
_patch_dns(monkeypatch) _patch_dns(monkeypatch)
calls = 0
def validate(url: str, **kwargs): def validate(url: str, **kwargs):
if "/final" in url: nonlocal calls
return ValidatedUrl( calls += 1
url=url, hostname="example.org", port=443, addresses=("198.51.100.12",) 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) monkeypatch.setattr("apps.sources.services.fetcher.validate_public_url", validate)
def handler(request: httpx.Request) -> httpx.Response: def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/start": status_code = 302 if request.url.path == "/start" else 200
return httpx.Response(302, headers={"location": "/final"}) headers = {"location": "/jobs"} if status_code == 302 else {"content-type": "text/html"}
return httpx.Response( return httpx.Response(
200, status_code,
headers={"content-type": "text/html; charset=utf-8"}, headers=headers,
content=b"Vacature", content=b"" if status_code == 302 else b"Vacature",
extensions={"network_stream": _PeerStream("150.171.109.36")},
) )
client = httpx.Client(transport=httpx.MockTransport(handler)) 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() client.close()