Skip to content

Commit deddf10

Browse files
Martin Argalašclaude
andcommitted
fix: Overseerr CSRF protection compatibility
Admin and family account POST/PUT/DELETE requests now work when "Enable CSRF Protection" is turned on in Overseerr/Jellyseerr. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f631010 commit deddf10

2 files changed

Lines changed: 158 additions & 46 deletions

File tree

custom_components/arr_stack/config_flow.py

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -299,24 +299,45 @@ async def _test_overseerr_family(
299299
) -> str | None:
300300
if not email or not password:
301301
return None
302+
base = url.rstrip("/")
303+
login_url = f"{base}/api/v1/auth/local"
304+
payload = {"email": email, "password": password}
305+
timeout = aiohttp.ClientTimeout(total=8)
302306
try:
303-
async with session.post(
304-
f"{url.rstrip('/')}/api/v1/auth/local",
305-
json={"email": email, "password": password},
306-
headers={"Accept": "application/json"},
307-
timeout=aiohttp.ClientTimeout(total=8),
308-
ssl=ssl,
309-
) as r:
310-
if r.status in (401, 403):
311-
return "seerr_family_bad_credentials"
312-
if r.status != 200:
313-
return "seerr_family_login_failed"
314-
data = await r.json()
315-
if data.get("permissions", 0) & 2:
307+
async with aiohttp.ClientSession(
308+
cookie_jar=aiohttp.CookieJar(unsafe=True),
309+
timeout=timeout,
310+
) as sess:
311+
hdrs = {"Accept": "application/json", "Content-Type": "application/json"}
312+
# GET first to get CSRF cookies — Overseerr double-submit cookie pattern
313+
_csrf_value = None
314+
async with sess.get(f"{base}/api/v1/auth/me", ssl=ssl) as _:
315+
pass
316+
csrf_token = None
317+
for c in sess.cookie_jar:
318+
if c.key == "XSRF-TOKEN":
319+
csrf_token = c.value
320+
if c.key == "_csrf":
321+
_csrf_value = c.value
322+
# Explicitly pass cookies — aiohttp may not resend Secure-flagged cookies over http
323+
req_cookies = {}
324+
if csrf_token:
325+
hdrs["X-XSRF-TOKEN"] = csrf_token
326+
req_cookies["XSRF-TOKEN"] = csrf_token
327+
if _csrf_value:
328+
req_cookies["_csrf"] = _csrf_value
329+
async with sess.post(login_url, json=payload, headers=hdrs,
330+
cookies=req_cookies if req_cookies else None, ssl=ssl) as r:
331+
if r.status in (401, 403):
332+
return "seerr_family_bad_credentials"
333+
if r.status != 200:
334+
return "seerr_family_login_failed"
335+
body = await r.json()
336+
if body.get("permissions", 0) & 2:
316337
return "seerr_family_is_admin"
317338
return None
318339
except Exception as e:
319-
return _map_exc(e)
340+
return _log_exc("overseerr_family", login_url, e)
320341

321342

322343
async def _test_tautulli(session: aiohttp.ClientSession, url: str, key: str, ssl=None) -> str | None:

custom_components/arr_stack/views.py

Lines changed: 123 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ def __init__(self, hass) -> None:
9999
self._hass = hass
100100
# Dedikovaná session pro qBit — potřebuje cookie jar pro session auth
101101
self._qbit_session: aiohttp.ClientSession | None = None
102+
# Session pro admin Overseerr požadavky — potřebuje cookie jar pro CSRF token
103+
self._seerr_admin_session: aiohttp.ClientSession | None = None
102104
# Session pro rodinný Overseerr účet
103105
self._seerr_family_session: aiohttp.ClientSession | None = None
104106
# Cache pro auto-detekovanou Plex URL (když user nezadal manuální)
@@ -131,6 +133,65 @@ async def _qbit_login(self, session: aiohttp.ClientSession, ssl=None) -> None:
131133
}, ssl=ssl) as r:
132134
await r.read()
133135

136+
def _csrf_from_jar(self, session: aiohttp.ClientSession) -> tuple[dict, dict]:
137+
"""Extrahuje XSRF-TOKEN a _csrf z cookie jaru — vrátí (extra_headers, cookies)."""
138+
csrf_token = ""
139+
_csrf_value = ""
140+
for cookie in session.cookie_jar:
141+
if cookie.key == "XSRF-TOKEN":
142+
csrf_token = cookie.value
143+
if cookie.key == "_csrf":
144+
_csrf_value = cookie.value
145+
hdrs = {**({"X-XSRF-TOKEN": csrf_token} if csrf_token else {})}
146+
cookies = {}
147+
if csrf_token:
148+
cookies["XSRF-TOKEN"] = csrf_token
149+
if _csrf_value:
150+
cookies["_csrf"] = _csrf_value
151+
return hdrs, cookies
152+
153+
async def _seerr_admin_sess(self) -> aiohttp.ClientSession:
154+
"""Vrátí (nebo vytvoří) aiohttp session s cookie jar pro admin Overseerr mutace."""
155+
if self._seerr_admin_session is None or self._seerr_admin_session.closed:
156+
self._seerr_admin_session = aiohttp.ClientSession(
157+
cookie_jar=aiohttp.CookieJar(unsafe=True)
158+
)
159+
return self._seerr_admin_session
160+
161+
async def _seerr_csrf_hdrs(self, base: str, api_key: str, ssl) -> tuple[dict, dict]:
162+
"""Vrátí (headers, cookies) pro Overseerr POST/PUT/DELETE s CSRF ochranou.
163+
164+
Overseerr double-submit cookie pattern: GET nastaví XSRF-TOKEN + _csrf cookies,
165+
POST musí posílat oba cookies explicitně + X-XSRF-TOKEN header (aiohttp neposílá
166+
Secure-flagged cookies automaticky přes HTTP).
167+
"""
168+
sess = await self._seerr_admin_sess()
169+
hdrs_get = {"X-Api-Key": api_key, "Accept": "application/json"}
170+
try:
171+
async with sess.get(f"{base}/api/v1/auth/me", headers=hdrs_get, ssl=ssl) as _:
172+
pass
173+
except Exception:
174+
pass
175+
csrf_token = ""
176+
_csrf_value = ""
177+
for cookie in sess.cookie_jar:
178+
if cookie.key == "XSRF-TOKEN":
179+
csrf_token = cookie.value
180+
if cookie.key == "_csrf":
181+
_csrf_value = cookie.value
182+
hdrs = {
183+
"X-Api-Key": api_key,
184+
"Accept": "application/json",
185+
"Content-Type": "application/json",
186+
**({"X-XSRF-TOKEN": csrf_token} if csrf_token else {}),
187+
}
188+
cookies = {}
189+
if csrf_token:
190+
cookies["XSRF-TOKEN"] = csrf_token
191+
if _csrf_value:
192+
cookies["_csrf"] = _csrf_value
193+
return hdrs, cookies
194+
134195
async def _seerr_family_sess(self, ssl=None) -> aiohttp.ClientSession:
135196
"""Vrátí (nebo vytvoří) aiohttp session s cookie jar pro rodinný Overseerr účet."""
136197
if self._seerr_family_session is None or self._seerr_family_session.closed:
@@ -142,11 +203,31 @@ async def _seerr_family_sess(self, ssl=None) -> aiohttp.ClientSession:
142203

143204
async def _seerr_family_login(self, session: aiohttp.ClientSession, ssl=None) -> None:
144205
"""Přihlásí se do Overseerr jako rodinný účet (nastaví session cookie)."""
145-
url = f"{self._cfg[CONF_SEERR_URL]}/api/v1/auth/local"
146-
async with session.post(url, json={
206+
base = self._cfg.get(CONF_SEERR_URL, "")
207+
# GET first to get CSRF cookies — Overseerr double-submit cookie pattern
208+
csrf_token = ""
209+
_csrf_value = ""
210+
try:
211+
async with session.get(f"{base}/api/v1/auth/me", ssl=ssl) as _:
212+
pass
213+
for cookie in session.cookie_jar:
214+
if cookie.key == "XSRF-TOKEN":
215+
csrf_token = cookie.value
216+
if cookie.key == "_csrf":
217+
_csrf_value = cookie.value
218+
except Exception:
219+
pass
220+
login_hdrs = {"Accept": "application/json", "Content-Type": "application/json"}
221+
req_cookies = {}
222+
if csrf_token:
223+
login_hdrs["X-XSRF-TOKEN"] = csrf_token
224+
req_cookies["XSRF-TOKEN"] = csrf_token
225+
if _csrf_value:
226+
req_cookies["_csrf"] = _csrf_value
227+
async with session.post(f"{base}/api/v1/auth/local", json={
147228
"email": self._cfg[CONF_SEERR_FAMILY_EMAIL],
148229
"password": self._cfg[CONF_SEERR_FAMILY_PASS],
149-
}, headers={"Accept": "application/json"}, ssl=ssl) as r:
230+
}, headers=login_hdrs, cookies=req_cookies if req_cookies else None, ssl=ssl) as r:
150231
await r.read()
151232

152233
# ── Router ───────────────────────────────────────────────────────────
@@ -1333,44 +1414,47 @@ async def _enrich(req: dict) -> None:
13331414
if path == "request_delete" and method == "POST":
13341415
body = await request.json()
13351416
req_id = body.get("requestId")
1336-
async with http.delete(
1337-
f"{base}/api/v1/request/{req_id}", headers=hdrs,
1338-
ssl=ssl,
1417+
adsess = await self._seerr_admin_sess()
1418+
csrf_hdrs, csrf_cookies = await self._seerr_csrf_hdrs(base, cfg.get(CONF_SEERR_KEY, ""), ssl)
1419+
async with adsess.delete(
1420+
f"{base}/api/v1/request/{req_id}", headers=csrf_hdrs,
1421+
cookies=csrf_cookies or None, ssl=ssl,
13391422
) as r:
13401423
return web.json_response({"ok": r.status in (200, 204)})
13411424

13421425
if path.startswith("request/") and method == "PUT":
13431426
req_id = path.split("/", 1)[1]
13441427
body = await request.json()
1345-
async with http.put(
1428+
adsess = await self._seerr_admin_sess()
1429+
csrf_hdrs, csrf_cookies = await self._seerr_csrf_hdrs(base, cfg.get(CONF_SEERR_KEY, ""), ssl)
1430+
async with adsess.put(
13461431
f"{base}/api/v1/request/{req_id}",
1347-
headers={**hdrs, "Content-Type": "application/json"},
1348-
json=body,
1349-
ssl=ssl,
1432+
headers=csrf_hdrs, cookies=csrf_cookies or None,
1433+
json=body, ssl=ssl,
13501434
) as r:
13511435
return web.Response(body=await r.read(), content_type="application/json", status=r.status)
13521436

13531437
if path == "approve" and method == "POST":
13541438
body = await request.json()
13551439
req_id = body.get("requestId")
13561440
server_settings = {k: v for k, v in body.items() if k != "requestId" and v is not None}
1441+
adsess = await self._seerr_admin_sess()
1442+
csrf_hdrs, csrf_cookies = await self._seerr_csrf_hdrs(base, cfg.get(CONF_SEERR_KEY, ""), ssl)
13571443
# Step 1: update request with server settings if provided
13581444
if server_settings:
13591445
_LOGGER.debug("arr_stack approve → updating requestId=%s settings=%s", req_id, server_settings)
1360-
async with http.put(
1446+
async with adsess.put(
13611447
f"{base}/api/v1/request/{req_id}",
1362-
headers={**hdrs, "Content-Type": "application/json"},
1363-
json=server_settings,
1364-
ssl=ssl,
1448+
headers=csrf_hdrs, cookies=csrf_cookies or None,
1449+
json=server_settings, ssl=ssl,
13651450
) as r_put:
13661451
put_status = r_put.status
13671452
_LOGGER.debug("arr_stack approve → PUT status=%s", put_status)
13681453
# Step 2: approve
1369-
async with http.post(
1454+
async with adsess.post(
13701455
f"{base}/api/v1/request/{req_id}/approve",
1371-
headers={**hdrs, "Content-Type": "application/json"},
1372-
json={},
1373-
ssl=ssl,
1456+
headers=csrf_hdrs, cookies=csrf_cookies or None,
1457+
json={}, ssl=ssl,
13741458
) as r:
13751459
resp_body = await r.read()
13761460
_LOGGER.debug("arr_stack approve ← status=%s body=%s", r.status, resp_body[:300])
@@ -1383,9 +1467,11 @@ async def _enrich(req: dict) -> None:
13831467
if path == "decline" and method == "POST":
13841468
body = await request.json()
13851469
req_id = body.get("requestId")
1386-
async with http.post(
1470+
adsess = await self._seerr_admin_sess()
1471+
csrf_hdrs, csrf_cookies = await self._seerr_csrf_hdrs(base, cfg.get(CONF_SEERR_KEY, ""), ssl)
1472+
async with adsess.post(
13871473
f"{base}/api/v1/request/{req_id}/decline",
1388-
headers=hdrs,
1474+
headers=csrf_hdrs, cookies=csrf_cookies or None,
13891475
ssl=ssl,
13901476
) as r:
13911477
return web.Response(
@@ -1410,20 +1496,24 @@ async def _enrich(req: dict) -> None:
14101496
# Rodinný účet — použij session cookie místo admin API klíče
14111497
if user_mode == "family" and self._cfg.get(CONF_SEERR_FAMILY_EMAIL):
14121498
fs = await self._seerr_family_sess(ssl=ssl)
1499+
fam_hdrs = {"Accept": "application/json", "Content-Type": "application/json"}
1500+
csrf_xtra, csrf_ck = self._csrf_from_jar(fs)
1501+
fam_hdrs.update(csrf_xtra)
14131502
async with fs.post(
14141503
f"{base}/api/v1/request",
1415-
json=body,
1416-
headers={"Accept": "application/json", "Content-Type": "application/json"},
1417-
ssl=ssl,
1504+
json=body, headers=fam_hdrs,
1505+
cookies=csrf_ck or None, ssl=ssl,
14181506
) as r:
1419-
if r.status == 401:
1420-
# Session vypršela — znovu se přihlásit a zkusit
1507+
if r.status in (401, 403):
1508+
# Session vypršela nebo CSRF mismatch — znovu přihlásit a zkusit
14211509
await self._seerr_family_login(fs, ssl=ssl)
1510+
csrf_xtra2, csrf_ck2 = self._csrf_from_jar(fs)
1511+
fam_hdrs2 = {"Accept": "application/json", "Content-Type": "application/json"}
1512+
fam_hdrs2.update(csrf_xtra2)
14221513
async with fs.post(
14231514
f"{base}/api/v1/request",
1424-
json=body,
1425-
headers={"Accept": "application/json", "Content-Type": "application/json"},
1426-
ssl=ssl,
1515+
json=body, headers=fam_hdrs2,
1516+
cookies=csrf_ck2 or None, ssl=ssl,
14271517
) as r2:
14281518
return web.Response(
14291519
body=await r2.read(),
@@ -1437,11 +1527,12 @@ async def _enrich(req: dict) -> None:
14371527
)
14381528

14391529
# Admin — použij API klíč (auto-approve)
1440-
async with http.post(
1530+
adsess = await self._seerr_admin_sess()
1531+
csrf_hdrs, csrf_cookies = await self._seerr_csrf_hdrs(base, cfg.get(CONF_SEERR_KEY, ""), ssl)
1532+
async with adsess.post(
14411533
f"{base}/api/v1/request",
1442-
headers={**hdrs, "Content-Type": "application/json"},
1443-
json=body,
1444-
ssl=ssl,
1534+
headers=csrf_hdrs, cookies=csrf_cookies or None,
1535+
json=body, ssl=ssl,
14451536
) as r:
14461537
return web.Response(
14471538
body=await r.read(),

0 commit comments

Comments
 (0)