diff --git a/CHANGELOG.md b/CHANGELOG.md index a8c5734..4815d03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +## [0.1.9] - 2026-05-29 + +### Fixed +- Added `OPTIONS /debugbundle/browser` preflight handling plus matching CORS headers for explicitly allowed split-host browser relay traffic across the Django, Flask, and FastAPI relay helpers. + ## [0.1.6] - 2026-05-19 ### Added diff --git a/pyproject.toml b/pyproject.toml index 2cafff1..f94f52a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "debugbundle-python" -version = "0.1.8" +version = "0.1.9" description = "DebugBundle SDK for Python" readme = "README.md" requires-python = ">=3.10" diff --git a/src/debugbundle/integrations/relay_django.py b/src/debugbundle/integrations/relay_django.py index f30757a..beb0031 100644 --- a/src/debugbundle/integrations/relay_django.py +++ b/src/debugbundle/integrations/relay_django.py @@ -54,9 +54,14 @@ def view(request: Any) -> Any: ) if response.body is not None: - return JsonResponse(response.body, status=response.status, safe=False) + result = JsonResponse(response.body, status=response.status, safe=False) + else: + result = JsonResponse({}, status=response.status) - return JsonResponse({}, status=response.status) + for key, value in response.headers.items(): + result[key] = value + + return result return view diff --git a/src/debugbundle/integrations/relay_fastapi.py b/src/debugbundle/integrations/relay_fastapi.py index bc8c967..9c32d6b 100644 --- a/src/debugbundle/integrations/relay_fastapi.py +++ b/src/debugbundle/integrations/relay_fastapi.py @@ -42,7 +42,7 @@ def create_fastapi_relay_handler( ) def register(app: Any) -> None: - @app.post(route_path) + @app.api_route(route_path, methods=["POST", "OPTIONS"]) async def debugbundle_browser_relay(request: Request) -> Response: body = await request.body() headers = {str(key): str(value) for key, value in request.headers.items()} @@ -58,7 +58,7 @@ async def debugbundle_browser_relay(request: Request) -> Response: ) if response.body is not None: - return JSONResponse(content=response.body, status_code=response.status) - return Response(status_code=response.status) + return JSONResponse(content=response.body, status_code=response.status, headers=response.headers) + return Response(status_code=response.status, headers=response.headers) return register diff --git a/src/debugbundle/integrations/relay_flask.py b/src/debugbundle/integrations/relay_flask.py index 1a8e1f3..a690816 100644 --- a/src/debugbundle/integrations/relay_flask.py +++ b/src/debugbundle/integrations/relay_flask.py @@ -41,7 +41,7 @@ def create_flask_relay_handler( def register(app: Any) -> None: from flask import Response, request - @app.route(route_path, methods=["POST"]) + @app.route(route_path, methods=["POST", "OPTIONS"]) def debugbundle_browser_relay() -> Response: response = handler.handle( { @@ -58,6 +58,7 @@ def debugbundle_browser_relay() -> Response: return Response( body, status=response.status, + headers=response.headers, content_type="application/json", ) diff --git a/src/debugbundle/relay.py b/src/debugbundle/relay.py index 5f63da6..a2c7139 100644 --- a/src/debugbundle/relay.py +++ b/src/debugbundle/relay.py @@ -33,6 +33,7 @@ class BrowserRelayResponse: status: int body: dict[str, Any] | None = None + headers: dict[str, str] = field(default_factory=dict) @dataclass(frozen=True) @@ -78,47 +79,56 @@ def __post_init__(self) -> None: def handle(self, request: dict[str, Any]) -> BrowserRelayResponse: method = str(request.get("method", "POST")).upper() - if method != "POST": - return BrowserRelayResponse(405) - headers = _normalize_headers(request.get("headers") or {}) + source_origin = _source_origin(headers) if not self._is_origin_allowed(headers): return BrowserRelayResponse(403) + response_headers = _cors_headers(source_origin) if source_origin else {} + + def with_headers(response: BrowserRelayResponse) -> BrowserRelayResponse: + return BrowserRelayResponse(response.status, response.body, {**response_headers, **response.headers}) + + if method == "OPTIONS": + return with_headers(BrowserRelayResponse(204)) + + if method != "POST": + return with_headers(BrowserRelayResponse(405)) + if not _is_supported_content_type(headers.get("content-type")): - return BrowserRelayResponse( + return with_headers(BrowserRelayResponse( 400, {"accepted": 0, "rejected": 0, "errors": ["Relay requests must use Content-Type: application/json."]}, - ) + )) body: str = request.get("body", "") if len(body.encode("utf-8") if isinstance(body, str) else body) > self.max_body_bytes: - return BrowserRelayResponse(413) + return with_headers(BrowserRelayResponse(413)) ip_address: str | None = request.get("ipAddress") or request.get("ip_address") if self._is_rate_limited(ip_address): - return BrowserRelayResponse(429) + return with_headers(BrowserRelayResponse(429)) try: decoded = json.loads(body) except (json.JSONDecodeError, TypeError, ValueError): - return BrowserRelayResponse( + return with_headers(BrowserRelayResponse( 400, {"accepted": 0, "rejected": 0, "errors": ["Relay request body must be valid JSON."]}, - ) + )) if not isinstance(decoded, dict): - return BrowserRelayResponse( + return with_headers(BrowserRelayResponse( 400, {"accepted": 0, "rejected": 0, "errors": ["Relay request body must be valid JSON."]}, - ) + )) batch = decoded.get("batch") if not isinstance(batch, list): - return BrowserRelayResponse( + return with_headers(BrowserRelayResponse( 400, {"accepted": 0, "rejected": 0, "errors": ["Relay request body must include a batch array."]}, - ) + )) accepted_events: list[dict[str, Any]] = [] errors: list[str] = [] @@ -144,7 +154,7 @@ def handle(self, request: dict[str, Any]) -> BrowserRelayResponse: if accepted_events: try: if not self._deliver_events(accepted_events): - return BrowserRelayResponse(500) + return with_headers(BrowserRelayResponse(500)) if self.on_accept is not None: self.on_accept( @@ -156,18 +166,18 @@ def handle(self, request: dict[str, Any]) -> BrowserRelayResponse: ) ) except Exception: - return BrowserRelayResponse(500) + return with_headers(BrowserRelayResponse(500)) if errors: - return BrowserRelayResponse( + return with_headers(BrowserRelayResponse( 400, {"accepted": len(accepted_events), "rejected": len(errors), "errors": errors}, - ) + )) - return BrowserRelayResponse( + return with_headers(BrowserRelayResponse( 202, {"accepted": len(accepted_events), "rejected": 0, "errors": []}, - ) + )) def _deliver_events(self, accepted_events: list[dict[str, Any]]) -> bool: if self.project_mode is None: @@ -261,6 +271,16 @@ def _is_supported_content_type(content_type: str | None) -> bool: return isinstance(content_type, str) and "application/json" in content_type.lower() +def _cors_headers(origin: str) -> dict[str, str]: + return { + "access-control-allow-origin": origin, + "access-control-allow-methods": "POST, OPTIONS", + "access-control-allow-headers": "content-type", + "access-control-max-age": "600", + "vary": "Origin", + } + + def _source_origin(headers: dict[str, str]) -> str | None: origin = (headers.get("origin") or "").strip() if origin: diff --git a/tests/test_relay.py b/tests/test_relay.py index 72856dc..6e5b177 100644 --- a/tests/test_relay.py +++ b/tests/test_relay.py @@ -78,6 +78,29 @@ def test_rejects_non_post() -> None: assert response.status == 405 +def test_answers_allowed_cross_origin_preflight() -> None: + handler = BrowserRelayHandler(allowed_origins=["https://web.example.com"]) + response = handler.handle( + _make_request( + body="", + method="OPTIONS", + origin="https://web.example.com", + host="api.example.com", + ) + ) + assert response.status == 204 + assert response.headers["access-control-allow-origin"] == "https://web.example.com" + assert response.headers["access-control-allow-methods"] == "POST, OPTIONS" + + +def test_adds_cors_headers_to_accepted_cross_origin_posts() -> None: + handler = BrowserRelayHandler(allowed_origins=["https://web.example.com"]) + response = handler.handle(_make_request(origin="https://web.example.com", host="api.example.com")) + assert response.status == 202 + assert response.headers["access-control-allow-origin"] == "https://web.example.com" + assert response.headers["vary"] == "Origin" + + def test_rejects_invalid_origin() -> None: handler = BrowserRelayHandler(allowed_origins=["https://allowed.com"]) response = handler.handle(_make_request(origin="https://attacker.com"))