From 215aad16e0f6ba10e521e00a67b107e90b783d61 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Wed, 1 Jul 2026 09:51:30 -0400 Subject: [PATCH] fix: fetch XSRF token from /antiforgery/token before POST --- backend/package.json | 2 - backend/src/services/listenarr.ts | 64 +++++++++++++++++-------------- 2 files changed, 36 insertions(+), 30 deletions(-) diff --git a/backend/package.json b/backend/package.json index 2c5f441..26041d9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,7 +19,6 @@ "@solana/spl-token": "^0.4.14", "@solana/web3.js": "^1.87.6", "axios": "^1.6.2", - "axios-cookiejar-support": "^5.0.5", "bcryptjs": "^2.4.3", "cors": "^2.8.5", "dotenv": "^16.3.1", @@ -31,7 +30,6 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "socket.io": "^4.7.3", - "tough-cookie": "^5.1.2", "tweetnacl": "^1.0.3", "ws": "^8.15.1" }, diff --git a/backend/src/services/listenarr.ts b/backend/src/services/listenarr.ts index 102fe7b..da7ecfe 100644 --- a/backend/src/services/listenarr.ts +++ b/backend/src/services/listenarr.ts @@ -1,34 +1,27 @@ -import axios, { type AxiosInstance } from "axios"; -import { wrapper } from "axios-cookiejar-support"; -import { CookieJar } from "tough-cookie"; +import axios from "axios"; const LISTENARR_URL = process.env.LISTENARR_URL || ""; const LISTENARR_API_KEY = process.env.LISTENARR_API_KEY || ""; -// ponytail: single shared cookie jar so CSRF cookies persist across requests -const jar = new CookieJar(); -const listenarrApi: AxiosInstance = wrapper( - axios.create({ - baseURL: LISTENARR_URL, - timeout: 15000, - headers: { - "Content-Type": "application/json", - ...(LISTENARR_API_KEY ? { "X-Api-Key": LISTENARR_API_KEY } : {}), - }, - withCredentials: true, - jar, - }), -); +const listenarrApi = axios.create({ + baseURL: LISTENARR_URL, + timeout: 15000, + headers: { + "Content-Type": "application/json", + ...(LISTENARR_API_KEY ? { "X-Api-Key": LISTENARR_API_KEY } : {}), + }, +}); -// ponytail: prime the antiforgery cookie on module load -let csrfPrimed = false; -async function ensureCsrfCookie(): Promise { - if (csrfPrimed || LISTENARR_API_KEY) return; +// ponytail: fetch CSRF token from Listenarr's /antiforgery/token endpoint +let cachedCsrfToken: string | null = null; +async function getCsrfToken(): Promise { + if (cachedCsrfToken) return cachedCsrfToken; try { - await listenarrApi.get("/api/v1/system/status"); - csrfPrimed = true; + const resp = await listenarrApi.get("/api/v1/antiforgery/token"); + cachedCsrfToken = resp.data?.token || null; + return cachedCsrfToken; } catch { - // ignore — we just need the cookie set + return null; } } @@ -62,8 +55,6 @@ export async function addAudiobook(options: { explicit?: boolean; abridged?: boolean; }): Promise { - await ensureCsrfCookie(); - // Defensive: ensure series/seriesNumber are always strings, not arrays const series = typeof options.series === "string" ? options.series : ""; const seriesNumber = typeof options.seriesNumber === "string" ? options.seriesNumber : ""; @@ -94,8 +85,25 @@ export async function addAudiobook(options: { qualityProfileId: 1, }; - const resp = await listenarrApi.post("/api/v1/library/add", body); - return resp.data; + const csrfToken = await getCsrfToken(); + const headers: Record = {}; + if (csrfToken) headers["X-XSRF-TOKEN"] = csrfToken; + + try { + const resp = await listenarrApi.post("/api/v1/library/add", body, { headers }); + return resp.data; + } catch (err: any) { + // ponytail: retry once on CSRF failure after refreshing token + if (err?.response?.status === 400 && /csrf|xsrf|antiforgery/i.test(JSON.stringify(err?.response?.data))) { + cachedCsrfToken = null; + const freshToken = await getCsrfToken(); + if (freshToken) { + const retryResp = await listenarrApi.post("/api/v1/library/add", body, { headers: { "X-XSRF-TOKEN": freshToken } }); + return retryResp.data; + } + } + throw err; + } } export async function getListenarrStatus(): Promise<{