Handle Seerr CSRF on request creation
Magent CI/CD / verify (push) Canceled after 2m30s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s

This commit is contained in:
2026-08-31 15:11:57 +12:00
parent a55369190b
commit 547ed754e6
3 changed files with 91 additions and 2 deletions
+20 -1
View File
@@ -62,6 +62,24 @@ class ApiClient:
return f"{payload[:500]}..." return f"{payload[:500]}..."
return payload 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( async def _request(
self, self,
method: str, method: str,
@@ -89,7 +107,8 @@ class ApiClient:
) )
try: try:
async with httpx.AsyncClient(timeout=timeout_seconds) as client: async with httpx.AsyncClient(timeout=timeout_seconds) as client:
response = await client.request( response = await self._send_request(
client,
method, method,
url, url,
headers=self.headers(), headers=self.headers(),
+35 -1
View File
@@ -1,10 +1,44 @@
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from urllib.parse import quote from urllib.parse import quote, unquote, urlsplit
import httpx import httpx
from .base import ApiClient from .base import ApiClient
class JellyseerrClient(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]]: async def get_status(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v1/status") return await self.get("/api/v1/status")
+36
View File
@@ -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: async def test_request_destination_only_offers_live_sonarr_profiles(self) -> None:
runtime = SimpleNamespace( runtime = SimpleNamespace(
sonarr_base_url="http://sonarr.test", sonarr_base_url="http://sonarr.test",