From 547ed754e6698573543709645a6fdbfd1b6c23f2 Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Mon, 31 Aug 2026 15:11:57 +1200 Subject: [PATCH] Handle Seerr CSRF on request creation --- backend/app/clients/base.py | 21 +++++++++++++++- backend/app/clients/jellyseerr.py | 36 ++++++++++++++++++++++++++- backend/tests/test_backend_quality.py | 36 +++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/backend/app/clients/base.py b/backend/app/clients/base.py index 86819a7..dac0a75 100644 --- a/backend/app/clients/base.py +++ b/backend/app/clients/base.py @@ -62,6 +62,24 @@ class ApiClient: return f"{payload[:500]}..." return payload + async def _send_request( + self, + client: httpx.AsyncClient, + method: str, + url: str, + *, + headers: Dict[str, str], + params: Optional[Dict[str, Any]], + payload: Optional[Dict[str, Any]], + ) -> httpx.Response: + return await client.request( + method, + url, + headers=headers, + params=params, + json=payload, + ) + async def _request( self, method: str, @@ -89,7 +107,8 @@ class ApiClient: ) try: async with httpx.AsyncClient(timeout=timeout_seconds) as client: - response = await client.request( + response = await self._send_request( + client, method, url, headers=self.headers(), diff --git a/backend/app/clients/jellyseerr.py b/backend/app/clients/jellyseerr.py index b83691b..84b3956 100644 --- a/backend/app/clients/jellyseerr.py +++ b/backend/app/clients/jellyseerr.py @@ -1,10 +1,44 @@ from typing import Any, Dict, Optional -from urllib.parse import quote +from urllib.parse import quote, unquote, urlsplit import httpx from .base import ApiClient class JellyseerrClient(ApiClient): + async def _send_request( + self, + client: httpx.AsyncClient, + method: str, + url: str, + *, + headers: Dict[str, str], + params: Optional[Dict[str, Any]], + payload: Optional[Dict[str, Any]], + ) -> httpx.Response: + request_headers = dict(headers) + if method.upper() in {"POST", "PUT", "PATCH", "DELETE"} and self.base_url: + # Seerr's optional CSRF protection also applies to API-key writes. + # Seed its secret/token cookie pair, then echo the readable token in + # the header Seerr's own web client uses. + csrf_response = await client.get( + f"{self.base_url}/api/v1/auth/me", + headers=self.headers(), + ) + csrf_response.raise_for_status() + csrf_token = client.cookies.get("XSRF-TOKEN") + if csrf_token: + request_headers["XSRF-TOKEN"] = unquote(csrf_token) + parsed_base = urlsplit(self.base_url) + request_headers["Origin"] = f"{parsed_base.scheme}://{parsed_base.netloc}" + return await super()._send_request( + client, + method, + url, + headers=request_headers, + params=params, + payload=payload, + ) + async def get_status(self) -> Optional[Dict[str, Any]]: return await self.get("/api/v1/status") diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index 3868d9b..2e819f7 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -423,6 +423,42 @@ class RequestCreationFlowTests(unittest.IsolatedAsyncioTestCase): }, ) + async def test_seerr_write_completes_csrf_cookie_handshake(self) -> None: + observed: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + observed.append(request) + if request.method == "GET": + return httpx.Response( + 200, + headers=[ + ("set-cookie", "_csrf=secret-value; Path=/; Secure; HttpOnly; SameSite=Strict"), + ("set-cookie", "XSRF-TOKEN=csrf%2Etoken; Path=/; Secure; SameSite=Strict"), + ], + json={"id": 1}, + ) + return httpx.Response(201, json={"id": 42}) + + transport = httpx.MockTransport(handler) + seerr = requests_router.JellyseerrClient("https://seerr.test", "api-key") + async with httpx.AsyncClient(transport=transport) as client: + response = await seerr._send_request( + client, + "POST", + "https://seerr.test/api/v1/request", + headers=seerr.headers(), + params=None, + payload={"mediaType": "movie", "mediaId": 209112}, + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual([request.method for request in observed], ["GET", "POST"]) + write_request = observed[1] + self.assertEqual(write_request.headers.get("XSRF-TOKEN"), "csrf.token") + self.assertEqual(write_request.headers.get("Origin"), "https://seerr.test") + self.assertIn("_csrf=secret-value", write_request.headers.get("Cookie", "")) + self.assertIn("XSRF-TOKEN=csrf%2Etoken", write_request.headers.get("Cookie", "")) + async def test_request_destination_only_offers_live_sonarr_profiles(self) -> None: runtime = SimpleNamespace( sonarr_base_url="http://sonarr.test",