Handle Seerr CSRF on request creation
This commit is contained in:
@@ -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(),
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user