fix(caldav): disable redirects on the sync/write-back DAVClient (SSRF) (#2663)

validate_caldav_url resolves and vets the initial host, but caldav's
niquests session follows 3xx redirects by default, so a validated public
URL can be redirected at request time to loopback/link-local/private
space, re-opening the SSRF the host check closes. The existing redirect
guard only covered the settings test-connection path.

Add a shared _build_dav_client helper that pins the session to zero
redirects (any 3xx then raises instead of silently following an
attacker-chosen Location), and route both the pull (_sync_blocking) and
write-back (_writeback_blocking) paths through it. Mirrors the
follow_redirects=False already used on the test-connection path.

Tests exercise the real DAVClient request path (a 302 toward an internal
host is refused, the sink is never contacted; the PROPFIND is asserted to
reach the public server first so the check can't pass vacuously), confirm
the helper disables redirects on the installed client, guard against a
raw DAVClient creeping back in, cover mixed public/internal DNS results
in both orderings, and add the resolves-to-no-usable-records fail-closed
branch.
This commit is contained in:
Joeseph Grey
2026-06-06 22:05:24 -06:00
committed by GitHub
parent 95c2dca4b5
commit f78539ba15
5 changed files with 172 additions and 4 deletions
+33
View File
@@ -82,6 +82,39 @@ def test_validate_caldav_url_fails_closed_when_hostname_does_not_resolve(monkeyp
caldav_sync.validate_caldav_url("https://calendar.example.com/dav")
def test_validate_caldav_url_fails_closed_when_host_resolves_to_no_usable_records(monkeypatch):
# Distinct from the OSError path above: here resolution *succeeds* but yields
# no usable A/AAAA records (the `if not addrs` branch). Fail closed there too
# rather than letting an un-vetted host through.
monkeypatch.setattr(caldav_sync, "_resolve_caldav_host_ips", lambda host: [])
with pytest.raises(ValueError, match="host does not resolve"):
caldav_sync.validate_caldav_url("https://calendar.example.com/dav")
@pytest.mark.parametrize(
"addrs",
[
["93.184.216.34", "127.0.0.1"], # public first, internal second
["127.0.0.1", "93.184.216.34"], # internal first, public second
],
)
def test_validate_caldav_url_blocks_mixed_dns_in_any_order(monkeypatch, addrs):
# A host that resolves to BOTH a public and an internal address must be
# rejected regardless of record order — every resolved address is checked,
# so one internal answer is enough to block. Defends DNS round-robin and a
# rebind that slips an internal A-record alongside a public one.
monkeypatch.delenv("ODYSSEUS_ALLOW_PRIVATE_CALDAV", raising=False)
monkeypatch.setattr(
caldav_sync,
"_resolve_caldav_host_ips",
lambda host: [ipaddress.ip_address(a) for a in addrs],
)
with pytest.raises(ValueError, match="host is not allowed"):
caldav_sync.validate_caldav_url("https://calendar.example.com/dav")
def test_sync_caldav_decrypts_stored_password_and_validates_url(monkeypatch):
monkeypatch.setattr(
caldav_sync,