Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 18 additions & 52 deletions backend/api/dav.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

router = APIRouter(prefix="/dav", tags=["dav"])

IMPLEMENTED_DAV_METHODS = ("OPTIONS", "PROPFIND")
_DAV_AUTHORIZATION_PATH_MAX_CHARACTERS = 8192
_HEX_DIGITS = frozenset("0123456789abcdefABCDEF")
_DAV_STRUCTURAL_OCTETS = frozenset(
Expand Down Expand Up @@ -209,73 +210,38 @@ async def _handle_project_propfind(

@router.api_route(
"/{path:path}",
methods=["PROPFIND", "REPORT", "MKCOL", "GET", "PUT", "DELETE", "OPTIONS"],
methods=list(IMPLEMENTED_DAV_METHODS),
)
async def dav_handler(
request: Request,
path: str,
auth_context: AuthContext = Depends(get_auth_context),
db: AsyncSession = Depends(get_db),
):
"""
Route the authenticated DAV surface that is implemented for this slice.
) -> Response:
"""Serve only the authenticated DAV capabilities implemented in production.

Collection discovery is served from the server-side project registry.
Provider-backed writeback stays fail-closed until source capability and
ETag/If-Match enforcement are available through signed writeback intents.
Project collection discovery is available through ``PROPFIND``. Unsupported
writeback and richer DAV verbs are deliberately not registered, so clients
receive ``405 Method Not Allowed`` instead of a misleading advertised
capability that can only return ``501 Not Implemented``.
"""
normalized_path = _normalize_dav_authorization_path(path)
_ensure_dav_owner_scope(normalized_path, auth_context)
safe_path = repr(normalized_path)[1:-1]
logger.info("DAV Request: %s /%s", request.method, safe_path)

if request.method == "OPTIONS":
headers = {
"DAV": "1, 2, 3, calendar-access, addressbook",
"Allow": (
"OPTIONS, GET, HEAD, POST, PUT, DELETE, TRACE, COPY, MOVE, MKCOL, "
"PROPFIND, PROPPATCH, LOCK, UNLOCK, REPORT"
),
}
return Response(status_code=200, headers=headers)

if request.method == "PROPFIND":
return await _handle_project_propfind(
request=request,
path=normalized_path,
auth_context=auth_context,
db=db,
)

if request.method == "PUT":
body = await request.body()
logger.info("DAV PUT received %s bytes at /%s", len(body), safe_path)
logger.warning(
"DAV PUT rejected at /%s: provider-backed DAV writeback is not "
"implemented; signed writeback-intent API is required",
safe_path,
)
return Response(
content=(
"Provider-backed DAV writeback is not implemented; use signed "
"writeback-intent APIs until source, capability, and "
"ETag/If-Match checks are enforced."
),
media_type="text/plain",
status_code=501,
status_code=200,
headers={
"DAV": "1",
"Allow": ", ".join(IMPLEMENTED_DAV_METHODS),
},
)

logger.warning(
"DAV %s rejected at /%s: method is not implemented for the "
"provider-backed DAV gateway",
request.method,
safe_path,
)
return Response(
content=(
"Provider-backed DAV method is not implemented; use supported "
"PROPFIND/OPTIONS discovery or signed writeback-intent APIs."
),
media_type="text/plain",
status_code=501,
return await _handle_project_propfind(
request=request,
path=normalized_path,
auth_context=auth_context,
db=db,
)
74 changes: 40 additions & 34 deletions backend/tests/test_dav_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,19 @@ def test_dav_route_uses_signed_session_dependency():
assert response.status_code == 401


def test_dav_options(dev_auth_dependency_overrides):
def test_dav_options_advertises_only_implemented_capabilities(
dev_auth_dependency_overrides,
):
with TestClient(app) as client:
response = client.options("/dav/user123/projects/", headers=AUTH_HEADERS)
assert response.status_code == 200
assert "calendar-access" in response.headers.get("DAV", "")

assert response.status_code == 200
assert response.headers["DAV"] == "1"
assert {
method.strip()
for method in response.headers["Allow"].split(",")
if method.strip()
} == {"OPTIONS", "PROPFIND"}


def test_dav_rejects_different_user_path(dev_auth_dependency_overrides):
Expand Down Expand Up @@ -207,42 +215,40 @@ def test_dav_propfind_escapes_path_values(
ET.fromstring(response.text)


def test_dav_put(dev_auth_dependency_overrides, caplog):
import logging

caplog.set_level(logging.WARNING, logger="api.dav")
with TestClient(app) as client:
response = client.put(
"/dav/user123/projects/file.ics",
content=b"BEGIN:VCALENDAR\r\nEND:VCALENDAR",
headers=AUTH_HEADERS,
)
assert response.status_code == 501
assert "Provider-backed DAV writeback is not implemented" in response.text
assert "etag" not in {header.lower() for header in response.headers}
assert any(
"provider-backed DAV writeback is not implemented" in record.getMessage()
for record in caplog.records
)


def test_dav_unsupported_method_logs_reason(dev_auth_dependency_overrides, caplog):
import logging

caplog.set_level(logging.WARNING, logger="api.dav")
@pytest.mark.parametrize(
"method",
[
"GET",
"PUT",
"DELETE",
"MKCOL",
"REPORT",
"PROPPATCH",
"COPY",
"MOVE",
"LOCK",
"UNLOCK",
],
)
def test_dav_unimplemented_methods_are_not_registered(
dev_auth_dependency_overrides,
method,
):
with TestClient(app) as client:
response = client.delete(
response = client.request(
method,
"/dav/user123/projects/file.ics",
content=b"BEGIN:VCALENDAR\r\nEND:VCALENDAR" if method == "PUT" else None,
headers=AUTH_HEADERS,
)

assert response.status_code == 501
assert "Provider-backed DAV method is not implemented" in response.text
assert any(
"method is not implemented for the provider-backed DAV gateway"
in record.getMessage()
for record in caplog.records
)
assert response.status_code == 405
assert "etag" not in {header.lower() for header in response.headers}
assert {
allowed.strip()
for allowed in response.headers["Allow"].split(",")
if allowed.strip()
} == {"OPTIONS", "PROPFIND"}


def test_dav_log_injection_prevention(dev_auth_dependency_overrides, caplog):
Expand Down