Confine CardDAV requests to configured origins
This commit is contained in:
166
tests/test_carddav_security.py
Normal file
166
tests/test_carddav_security.py
Normal file
@@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import threading
|
||||
import unittest
|
||||
from collections.abc import Iterator
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
|
||||
from govoplan_addresses.backend.carddav import (
|
||||
AddressCardDAVClient,
|
||||
AddressCardDAVError,
|
||||
absolute_dav_url,
|
||||
urllib_transport,
|
||||
)
|
||||
from govoplan_addresses.backend.router import api_discover_carddav_address_books
|
||||
from govoplan_addresses.backend.schemas import AddressCardDavDiscoveryRequest
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
host, port = server.server_address
|
||||
yield f"http://{host}:{port}"
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
class CardDAVUrlSecurityTests(unittest.TestCase):
|
||||
def test_discovery_href_must_remain_on_configured_origin(self) -> None:
|
||||
base_url = "https://dav.example.test/addressbooks/ada/"
|
||||
|
||||
self.assertEqual(
|
||||
absolute_dav_url(base_url, "/principals/users/ada/"),
|
||||
"https://dav.example.test/principals/users/ada/",
|
||||
)
|
||||
with self.assertRaisesRegex(AddressCardDAVError, "configured collection origin"):
|
||||
absolute_dav_url(base_url, "https://evil.example.test/steal/")
|
||||
with self.assertRaisesRegex(AddressCardDAVError, "query or fragment"):
|
||||
absolute_dav_url(base_url, "/principals/users/ada/?token=secret")
|
||||
|
||||
def test_object_href_must_remain_inside_configured_collection(self) -> None:
|
||||
client = AddressCardDAVClient(collection_url="https://dav.example.test/addressbooks/ada")
|
||||
|
||||
with self.assertRaisesRegex(AddressCardDAVError, "collection origin"):
|
||||
client.object_url("https://evil.example.test/steal.vcf")
|
||||
with self.assertRaisesRegex(AddressCardDAVError, "collection path"):
|
||||
client.object_url("https://dav.example.test/addressbooks/other/steal.vcf")
|
||||
with self.assertRaisesRegex(AddressCardDAVError, "collection path"):
|
||||
client.object_url("/addressbooks/ada/%2e%2e/other/steal.vcf")
|
||||
with self.assertRaisesRegex(AddressCardDAVError, "query or fragment"):
|
||||
client.object_url("/addressbooks/ada/contact.vcf?download=1")
|
||||
self.assertEqual(
|
||||
client.object_url("/addressbooks/ada/contact.vcf"),
|
||||
"https://dav.example.test/addressbooks/ada/contact.vcf",
|
||||
)
|
||||
|
||||
def test_transport_refuses_redirect_before_forwarding_authorization(self) -> None:
|
||||
forwarded_authorization: list[str | None] = []
|
||||
|
||||
class TargetHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
forwarded_authorization.append(self.headers.get("Authorization"))
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
with running_http_server(TargetHandler) as target_url:
|
||||
class RedirectHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
self.send_response(302)
|
||||
self.send_header("Location", f"{target_url}/stolen.vcf")
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
with running_http_server(RedirectHandler) as redirect_url:
|
||||
status, _headers, _body = urllib_transport(
|
||||
"GET",
|
||||
f"{redirect_url}/contact.vcf",
|
||||
{"Authorization": "Bearer top-secret"},
|
||||
None,
|
||||
2,
|
||||
)
|
||||
|
||||
self.assertEqual(status, 302)
|
||||
self.assertEqual(forwarded_authorization, [])
|
||||
|
||||
def test_transport_preserves_same_origin_redirects(self) -> None:
|
||||
forwarded_authorization: list[str | None] = []
|
||||
|
||||
class RedirectHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
if self.path == "/contact.vcf":
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "/redirected.vcf")
|
||||
self.end_headers()
|
||||
return
|
||||
forwarded_authorization.append(self.headers.get("Authorization"))
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"contact")
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
with running_http_server(RedirectHandler) as source_url:
|
||||
status, _headers, body = urllib_transport(
|
||||
"GET",
|
||||
f"{source_url}/contact.vcf",
|
||||
{"Authorization": "Bearer expected"},
|
||||
None,
|
||||
2,
|
||||
)
|
||||
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(body, b"contact")
|
||||
self.assertEqual(forwarded_authorization, ["Bearer expected"])
|
||||
|
||||
|
||||
class CardDAVDiscoveryAuthorizationTests(unittest.TestCase):
|
||||
def test_sync_read_alone_cannot_start_authenticated_discovery(self) -> None:
|
||||
principal = ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-read-only",
|
||||
membership_id="membership-read-only",
|
||||
tenant_id="tenant-1",
|
||||
scopes=frozenset({"addresses:sync:read"}),
|
||||
),
|
||||
account=object(),
|
||||
user=object(),
|
||||
)
|
||||
|
||||
with patch("govoplan_addresses.backend.router.discover_carddav_address_books") as discover:
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
api_discover_carddav_address_books(
|
||||
AddressCardDavDiscoveryRequest(
|
||||
url="https://dav.example.test/",
|
||||
auth_type="basic",
|
||||
username="reader",
|
||||
password="secret",
|
||||
),
|
||||
principal,
|
||||
object(), # type: ignore[arg-type] - scope rejection precedes session use
|
||||
)
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 403)
|
||||
self.assertEqual(raised.exception.detail, "Missing scope: addresses:sync:write")
|
||||
discover.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user