From 89c994be169c915aaa300916d08c97bdafb44369 Mon Sep 17 00:00:00 2001 From: Jaseem Jas Date: Tue, 29 Sep 2026 18:17:15 +0530 Subject: [PATCH 1/2] UN-4186 Centralise CSRF handling in a shared axios interceptor Add helpers/csrf.js (attachCsrfInterceptor, getCsrfHeaders) and attach it to useAxiosPrivate at instance creation and to the global axios in App.jsx. Remove ~110 hand-written X-CSRFToken headers, move the TopNavBar org switch onto axiosPrivate, document the intentional raw-axios callers, and add an orgApi() URL helper. --- frontend/src/App.jsx | 9 ++ .../src/components/agency/agency/Agency.jsx | 6 - .../agency/cards-list/CardsList.jsx | 3 - .../ds-settings-card/DsSettingsCard.jsx | 2 - .../src/components/agency/prompt/Prompt.jsx | 1 - .../agency/tool-settings/ToolSettings.jsx | 1 - .../AdapterSelectionModal.jsx | 3 - .../add-llm-profile/AddLlmProfile.jsx | 4 - .../combined-output/CombinedOutput.jsx | 3 - .../CustomDataSettings.jsx | 1 - .../custom-synonyms/CustomSynonyms.jsx | 1 - .../document-parser/DocumentParser.jsx | 4 - .../components/custom-tools/header/Header.jsx | 7 - .../list-of-tools/ListOfTools.jsx | 20 +-- .../manage-docs-modal/ManageDocsModal.jsx | 6 +- .../manage-llm-profiles/ManageLlmProfiles.jsx | 4 - .../OutputForDocModal.jsx | 3 - .../prompts-reorder/PromptsReorder.jsx | 2 - .../custom-tools/tool-ide/ToolIde.jsx | 3 - .../custom-tools/tools-main/ToolsMain.jsx | 1 - .../api-deployment/api-deployments-service.js | 2 - .../CreateApiDeploymentFromPromptStudio.jsx | 11 -- .../deployments/manage-keys/ManageKeys.jsx | 3 - .../src/components/groups/groups-service.js | 2 - .../components/helpers/auth/RequireAdmin.jsx | 6 +- .../input-output/configure-ds/ConfigureDs.jsx | 2 - .../DisplayLogsAndNotifications.jsx | 3 - .../navigations/top-nav-bar/TopNavBar.jsx | 8 +- .../top-nav-bar/TopNavBar.test.jsx | 10 +- .../etl-task-deploy/EtlTaskDeploy.jsx | 2 - .../log-modal/LogsModal.jsx | 3 - .../log-modal/fetchExecutionLogs.js | 3 - .../pipeline-service.js | 2 - .../pipelines/Pipelines.jsx | 5 - frontend/src/components/set-org/SetOrg.jsx | 8 +- .../api-key-manager/ApiKeyManager.jsx | 5 - .../settings/default-triad/DefaultTriad.jsx | 1 - .../settings/invite/InviteEditUser.jsx | 3 - .../settings/platform/PlatformSettings.jsx | 8 - .../src/components/settings/users/Users.jsx | 1 - .../tool-settings/ToolSettings.jsx | 12 +- .../ui/shims/antd-structure.test.jsx | 4 +- .../workflows/workflow/workflow-service.js | 22 --- frontend/src/helpers/FeatureFlagsData.js | 19 ++- frontend/src/helpers/csrf.js | 62 ++++++++ frontend/src/helpers/csrf.test.js | 137 ++++++++++++++++++ frontend/src/helpers/orgApi.js | 10 ++ frontend/src/helpers/orgApi.test.js | 26 ++++ frontend/src/hooks/useAxiosPrivate.js | 10 +- frontend/src/hooks/usePromptOutput.js | 3 - frontend/src/hooks/usePromptRun.js | 2 - frontend/src/hooks/useSessionValid.js | 15 +- frontend/src/pages/ConnectorsPage.jsx | 23 +-- frontend/src/store/socket-logs-store.js | 18 ++- 54 files changed, 304 insertions(+), 231 deletions(-) create mode 100644 frontend/src/helpers/csrf.js create mode 100644 frontend/src/helpers/csrf.test.js create mode 100644 frontend/src/helpers/orgApi.js create mode 100644 frontend/src/helpers/orgApi.test.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 03fb338174..ab1523c16b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,6 +12,7 @@ import CustomMarkdown from "./components/helpers/custom-markdown/CustomMarkdown. import { NotificationClearAll } from "./components/notification/NotificationClearAll.jsx"; import { NotificationIdLine } from "./components/notification/NotificationIdLine.jsx"; import { PageTitle } from "./components/widgets/page-title/PageTitle.jsx"; +import { attachCsrfInterceptor } from "./helpers/csrf.js"; import { THEME } from "./helpers/GetStaticData.js"; import { attachRequestIdInterceptor } from "./helpers/requestId.js"; import PostHogPageviewTracker from "./PostHogPageviewTracker.js"; @@ -26,6 +27,14 @@ if (!axios[GLOBAL_INTERCEPTOR_FLAG]) { axios[GLOBAL_INTERCEPTOR_FLAG] = true; } +// The few intentional raw-axios callers (pre-session bootstrap, background log +// writes) get CSRF here; everything else goes through useAxiosPrivate. +const CSRF_INTERCEPTOR_FLAG = Symbol.for("unstract.csrfInterceptor"); +if (!axios[CSRF_INTERCEPTOR_FLAG]) { + attachCsrfInterceptor(axios); + axios[CSRF_INTERCEPTOR_FLAG] = true; +} + let GoogleTagManagerHelper; try { const mod = await import( diff --git a/frontend/src/components/agency/agency/Agency.jsx b/frontend/src/components/agency/agency/Agency.jsx index bb008b1bc8..5e0da7897d 100644 --- a/frontend/src/components/agency/agency/Agency.jsx +++ b/frontend/src/components/agency/agency/Agency.jsx @@ -832,13 +832,11 @@ function Agency() { const handleWfExecutionApi = async (body) => { let header = { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }; if (shouldIncludeFile(body) && apiOpsPresent && fileList.length > 0) { body = getRequestBody(body); header = { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "multipart/form-data", }; } @@ -939,9 +937,6 @@ function Agency() { const deleteOptions = { method: "DELETE", url: getUrl(`tool_instance/${existingTool.id}/`), - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, }; await axiosPrivate(deleteOptions); } @@ -963,7 +958,6 @@ function Agency() { method: "POST", url: getUrl(`tool_instance/`), headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: body, diff --git a/frontend/src/components/agency/cards-list/CardsList.jsx b/frontend/src/components/agency/cards-list/CardsList.jsx index e8af9bb238..f0441cd531 100644 --- a/frontend/src/components/agency/cards-list/CardsList.jsx +++ b/frontend/src/components/agency/cards-list/CardsList.jsx @@ -35,9 +35,6 @@ const CardsList = ({ step, index, activeTool, moveItem }) => { const requestOptions = { method: "DELETE", url: `/api/v1/unstract/${sessionDetails?.orgId}/tool_instance/${toolSettings?.id}/`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, }; axiosPrivate(requestOptions) diff --git a/frontend/src/components/agency/ds-settings-card/DsSettingsCard.jsx b/frontend/src/components/agency/ds-settings-card/DsSettingsCard.jsx index 2ca1c4d083..f6ff31c295 100644 --- a/frontend/src/components/agency/ds-settings-card/DsSettingsCard.jsx +++ b/frontend/src/components/agency/ds-settings-card/DsSettingsCard.jsx @@ -141,7 +141,6 @@ function DsSettingsCard({ connType, endpointDetails, message }) { method: "PATCH", url: getUrl(`workflow/endpoint/${destination?.id}/`), headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: updatedData, @@ -178,7 +177,6 @@ function DsSettingsCard({ connType, endpointDetails, message }) { method: "PATCH", url: getUrl(`workflow/endpoint/${endpointDetails?.id}/`), headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: updatedData, diff --git a/frontend/src/components/agency/prompt/Prompt.jsx b/frontend/src/components/agency/prompt/Prompt.jsx index e6f2f306c5..8fb28a346d 100644 --- a/frontend/src/components/agency/prompt/Prompt.jsx +++ b/frontend/src/components/agency/prompt/Prompt.jsx @@ -46,7 +46,6 @@ function Prompt() { url: `/api/v1/unstract/${sessionDetails?.orgId}/workflow/${workflowId}/`, headers: { "Content-Type": "application/json", - "X-CSRFToken": sessionDetails?.csrfToken, }, data: body, }; diff --git a/frontend/src/components/agency/tool-settings/ToolSettings.jsx b/frontend/src/components/agency/tool-settings/ToolSettings.jsx index 7a3f30fbf2..63ff169156 100644 --- a/frontend/src/components/agency/tool-settings/ToolSettings.jsx +++ b/frontend/src/components/agency/tool-settings/ToolSettings.jsx @@ -89,7 +89,6 @@ function ToolSettings({ spec, isSpecLoading }) { url: `/api/v1/unstract/${sessionDetails?.orgId}/tool_instance/${toolSettings?.id}/`, headers: { "Content-Type": "application/json", - "X-CSRFToken": sessionDetails?.csrfToken, }, data: { metadata }, }; diff --git a/frontend/src/components/custom-tools/adapter-selection-modal/AdapterSelectionModal.jsx b/frontend/src/components/custom-tools/adapter-selection-modal/AdapterSelectionModal.jsx index fd6bd59435..7ddcf56cc6 100644 --- a/frontend/src/components/custom-tools/adapter-selection-modal/AdapterSelectionModal.jsx +++ b/frontend/src/components/custom-tools/adapter-selection-modal/AdapterSelectionModal.jsx @@ -52,9 +52,6 @@ function AdapterSelectionModal({ const requests = adapterTypes.map((type) => fetchAllPages(axiosPrivate, { url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, params: { adapter_type: type, }, diff --git a/frontend/src/components/custom-tools/add-llm-profile/AddLlmProfile.jsx b/frontend/src/components/custom-tools/add-llm-profile/AddLlmProfile.jsx index 71924bcc0d..0d7bc70eae 100644 --- a/frontend/src/components/custom-tools/add-llm-profile/AddLlmProfile.jsx +++ b/frontend/src/components/custom-tools/add-llm-profile/AddLlmProfile.jsx @@ -316,7 +316,6 @@ function AddLlmProfile({ method, url, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: formDetails, @@ -391,9 +390,6 @@ function AddLlmProfile({ const requestOptions = { method: "GET", url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/info/${value}/`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, }; axiosPrivate(requestOptions) diff --git a/frontend/src/components/custom-tools/combined-output/CombinedOutput.jsx b/frontend/src/components/custom-tools/combined-output/CombinedOutput.jsx index 776b9eea59..a36ec9ae56 100644 --- a/frontend/src/components/custom-tools/combined-output/CombinedOutput.jsx +++ b/frontend/src/components/custom-tools/combined-output/CombinedOutput.jsx @@ -211,9 +211,6 @@ function CombinedOutput({ docId, setFilledFields, selectedPrompts }) { const requestOptions = { method: "GET", url, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, }; const res = await axiosPrivate(requestOptions); return res; diff --git a/frontend/src/components/custom-tools/custom-data-settings/CustomDataSettings.jsx b/frontend/src/components/custom-tools/custom-data-settings/CustomDataSettings.jsx index 26c5817891..9d8beae331 100644 --- a/frontend/src/components/custom-tools/custom-data-settings/CustomDataSettings.jsx +++ b/frontend/src/components/custom-tools/custom-data-settings/CustomDataSettings.jsx @@ -155,7 +155,6 @@ function CustomDataSettings() { method: "PATCH", url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/${details?.tool_id}/`, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: body, diff --git a/frontend/src/components/custom-tools/custom-synonyms/CustomSynonyms.jsx b/frontend/src/components/custom-tools/custom-synonyms/CustomSynonyms.jsx index 44021630f1..7a840bc342 100644 --- a/frontend/src/components/custom-tools/custom-synonyms/CustomSynonyms.jsx +++ b/frontend/src/components/custom-tools/custom-synonyms/CustomSynonyms.jsx @@ -197,7 +197,6 @@ function CustomSynonyms() { method: "PATCH", url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/${details?.tool_id}/`, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: body, diff --git a/frontend/src/components/custom-tools/document-parser/DocumentParser.jsx b/frontend/src/components/custom-tools/document-parser/DocumentParser.jsx index c7d9fd1004..f265fa2482 100644 --- a/frontend/src/components/custom-tools/document-parser/DocumentParser.jsx +++ b/frontend/src/components/custom-tools/document-parser/DocumentParser.jsx @@ -198,7 +198,6 @@ function DocumentParser({ method: "PATCH", url, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: body, @@ -242,9 +241,6 @@ function DocumentParser({ const requestOptions = { method: "DELETE", url, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, }; axiosPrivate(requestOptions) diff --git a/frontend/src/components/custom-tools/header/Header.jsx b/frontend/src/components/custom-tools/header/Header.jsx index bae4bbdc4a..9b3db82d42 100644 --- a/frontend/src/components/custom-tools/header/Header.jsx +++ b/frontend/src/components/custom-tools/header/Header.jsx @@ -92,7 +92,6 @@ function Header({ method: "POST", url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/export/${details?.tool_id}`, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: body, @@ -149,9 +148,6 @@ function Header({ const requestOptions = { method: "GET", url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/export/${details?.tool_id}`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, }; setIsExportLoading(true); getAllUsers().then((users) => { @@ -220,9 +216,6 @@ function Header({ const requestOptions = { method: "GET", url: downloadUrl, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, responseType: "blob", }; diff --git a/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx b/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx index 62060e54af..df50ad5eae 100644 --- a/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx +++ b/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx @@ -99,14 +99,12 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { axiosPrivate({ method: "GET", url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/users/${id}`, - headers: { "X-CSRFToken": sessionDetails?.csrfToken }, }), addCoOwner: (id, userId) => axiosPrivate({ method: "POST", url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/${id}/owners/`, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: { user_id: userId }, @@ -115,10 +113,9 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { axiosPrivate({ method: "DELETE", url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/${id}/owners/${userId}/`, - headers: { "X-CSRFToken": sessionDetails?.csrfToken }, }), }), - [axiosPrivate, sessionDetails?.orgId, sessionDetails?.csrfToken], + [axiosPrivate, sessionDetails?.orgId], ); const { @@ -168,7 +165,6 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { return axiosPrivate({ method: "GET", url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/`, - headers: { "X-CSRFToken": sessionDetails?.csrfToken }, params, }) .then((res) => @@ -206,7 +202,6 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { }, [ sessionDetails?.orgId, - sessionDetails?.csrfToken, axiosPrivate, setPagination, setAlertDetails, @@ -235,7 +230,6 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { method, url, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: body, @@ -269,9 +263,6 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { const requestOptions = { method: "DELETE", url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/${tool.tool_id}`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, }; axiosPrivate(requestOptions) @@ -325,9 +316,6 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { const requestOptions = { method: "POST", url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/project-transfer/`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, data: formData, }; @@ -360,9 +348,6 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { const requestOptions = { method: "GET", url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/users/${promptProject?.tool_id}`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, }; setIsShareLoading(true); getAllUsers(); @@ -417,9 +402,6 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { const requestOptions = { method: "POST", url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/${adapter?.tool_id}/share/`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, data: { shared_users: userIds, shared_to_org: shareWithEveryone || false, diff --git a/frontend/src/components/custom-tools/manage-docs-modal/ManageDocsModal.jsx b/frontend/src/components/custom-tools/manage-docs-modal/ManageDocsModal.jsx index 5123867484..112e49bc28 100644 --- a/frontend/src/components/custom-tools/manage-docs-modal/ManageDocsModal.jsx +++ b/frontend/src/components/custom-tools/manage-docs-modal/ManageDocsModal.jsx @@ -10,6 +10,7 @@ import { Modal, Tooltip } from "@/components/ui/shims/antd-overlays"; import { Table, Upload } from "@/components/ui/shims/antd-structure"; import { Typography } from "@/components/ui/shims/antd-typography"; +import { getCsrfHeaders } from "../../../helpers/csrf"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; import { useAlertStore } from "../../../store/alert-store"; @@ -669,7 +670,6 @@ function ManageDocsModal({ method: "DELETE", url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/file/${details?.tool_id}`, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: body, @@ -746,9 +746,7 @@ function ManageDocsModal({ { cleanupResults.success.push("API deployment"); @@ -270,9 +266,6 @@ const CreateApiDeploymentFromPromptStudio = ({ axiosPrivate({ method: "DELETE", url: getUrl(`tool_instance/${createdResources.toolInstanceId}/`), - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, }) .then(() => { cleanupResults.success.push("Tool instance"); @@ -352,7 +345,6 @@ const CreateApiDeploymentFromPromptStudio = ({ method: "POST", url: getUrl(`prompt-studio/export/${toolDetails?.tool_id}`), headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: { @@ -384,7 +376,6 @@ const CreateApiDeploymentFromPromptStudio = ({ method: "POST", url: getUrl("tool_instance/"), headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: { @@ -412,7 +403,6 @@ const CreateApiDeploymentFromPromptStudio = ({ method: "PATCH", url: getUrl(`tool_instance/${toolInstanceResponse.data.id}/`), headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: { @@ -506,7 +496,6 @@ const CreateApiDeploymentFromPromptStudio = ({ method: "PATCH", url: getUrl(`workflow/endpoint/${endpoint.id}/`), headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: { diff --git a/frontend/src/components/deployments/manage-keys/ManageKeys.jsx b/frontend/src/components/deployments/manage-keys/ManageKeys.jsx index dbc83f3dfd..77dc028a9a 100644 --- a/frontend/src/components/deployments/manage-keys/ManageKeys.jsx +++ b/frontend/src/components/deployments/manage-keys/ManageKeys.jsx @@ -190,9 +190,6 @@ const ManageKeys = ({ const requestOptions = { method: "PUT", url: `/api/v1/unstract/${sessionDetails?.orgId}/api/keys/${record?.id}/`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, data: record, }; diff --git a/frontend/src/components/groups/groups-service.js b/frontend/src/components/groups/groups-service.js index 5cdbad81e1..c4039b0e0b 100644 --- a/frontend/src/components/groups/groups-service.js +++ b/frontend/src/components/groups/groups-service.js @@ -15,11 +15,9 @@ function groupsService() { const { sessionDetails } = useSessionStore(); const orgId = sessionDetails?.orgId?.replaceAll?.('"', "") ?? ""; const path = `/api/v1/unstract/${orgId}`; - const csrfToken = sessionDetails.csrfToken; const requestHeaders = { "Content-Type": "application/json", - "X-CSRFToken": csrfToken, }; return { diff --git a/frontend/src/components/helpers/auth/RequireAdmin.jsx b/frontend/src/components/helpers/auth/RequireAdmin.jsx index 856afaa007..51aa26919f 100644 --- a/frontend/src/components/helpers/auth/RequireAdmin.jsx +++ b/frontend/src/components/helpers/auth/RequireAdmin.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { Outlet } from "react-router-dom"; +import { orgApi } from "../../../helpers/orgApi"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useSessionStore } from "../../../store/session-store"; import { NotFound } from "../../error/NotFound/NotFound.jsx"; @@ -17,10 +18,7 @@ const RequireAdmin = () => { useEffect(() => { const verifyAdminStatus = async () => { try { - const res = await axiosPrivate.get( - `/api/v1/unstract/${orgId}/users/profile/`, - { headers: { "X-CSRFToken": sessionDetails?.csrfToken } }, - ); + const res = await axiosPrivate.get(orgApi("users/profile/")); const currentIsAdmin = res?.data?.user?.is_admin; const currentRole = res?.data?.user?.role; const updates = {}; diff --git a/frontend/src/components/input-output/configure-ds/ConfigureDs.jsx b/frontend/src/components/input-output/configure-ds/ConfigureDs.jsx index 5bb0d6f632..3dbd58c816 100644 --- a/frontend/src/components/input-output/configure-ds/ConfigureDs.jsx +++ b/frontend/src/components/input-output/configure-ds/ConfigureDs.jsx @@ -221,7 +221,6 @@ function ConfigureDs({ method: "POST", url, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: body, @@ -321,7 +320,6 @@ function ConfigureDs({ url, headers: { "Content-Type": "application/json", - "X-CSRFToken": sessionDetails?.csrfToken, }, data: body, }; diff --git a/frontend/src/components/logs-and-notifications/DisplayLogsAndNotifications.jsx b/frontend/src/components/logs-and-notifications/DisplayLogsAndNotifications.jsx index 81fe1e85c0..d22a9236e6 100644 --- a/frontend/src/components/logs-and-notifications/DisplayLogsAndNotifications.jsx +++ b/frontend/src/components/logs-and-notifications/DisplayLogsAndNotifications.jsx @@ -37,9 +37,6 @@ export function DisplayLogsAndNotifications() { const requestOptions = { method: "GET", url: `/api/v1/unstract/${sessionDetails?.orgId}/logs/`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, }; axiosPrivate(requestOptions) diff --git a/frontend/src/components/navigations/top-nav-bar/TopNavBar.jsx b/frontend/src/components/navigations/top-nav-bar/TopNavBar.jsx index 98fdbdd635..39326d7a7a 100644 --- a/frontend/src/components/navigations/top-nav-bar/TopNavBar.jsx +++ b/frontend/src/components/navigations/top-nav-bar/TopNavBar.jsx @@ -1,4 +1,3 @@ -import axios from "axios"; import { ChevronDown, LogIn, @@ -23,6 +22,7 @@ import { onboardCompleted, THEME, } from "../../../helpers/GetStaticData.js"; +import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate.js"; import useLogout from "../../../hooks/useLogout.js"; import "../../../layouts/page-layout/PageLayout.css"; import { useSessionStore } from "../../../store/session-store.js"; @@ -147,6 +147,7 @@ function TopNavBar({ isSimpleLayout, topNavBarOptions }) { const baseUrl = getBaseUrl(); const onBoardUrl = `${baseUrl}/${orgName}/onboard`; const logout = useLogout(); + const axiosPrivate = useAxiosPrivate(); const [showOnboardBanner, setShowOnboardBanner] = useState(false); const [reviewPageHeader, setReviewPageHeader] = useState(""); const { setAlertDetails } = useAlertStore(); @@ -234,12 +235,9 @@ function TopNavBar({ isSimpleLayout, topNavBarOptions }) { const requestOptions = { method: "POST", url: `/api/v1/organization/${selectedOrg}/set`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, }; try { - await axios(requestOptions); + await axiosPrivate(requestOptions); navigate("/"); window.location.reload(); } catch (err) { diff --git a/frontend/src/components/navigations/top-nav-bar/TopNavBar.test.jsx b/frontend/src/components/navigations/top-nav-bar/TopNavBar.test.jsx index e5ccd9d913..09aac3a26d 100644 --- a/frontend/src/components/navigations/top-nav-bar/TopNavBar.test.jsx +++ b/frontend/src/components/navigations/top-nav-bar/TopNavBar.test.jsx @@ -1,6 +1,5 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import axios from "axios"; import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -8,7 +7,10 @@ import { useSessionStore } from "../../../store/session-store.js"; import { ConfirmHost } from "../../widgets/confirm-modal/ConfirmHost.jsx"; import { resetConfirm } from "../../widgets/confirm-modal/confirmStore.js"; -vi.mock("axios"); +const axiosPrivate = vi.fn(); +vi.mock("../../../hooks/useAxiosPrivate.js", () => ({ + useAxiosPrivate: () => axiosPrivate, +})); /* * The logos are `*.svg?react` imports, which only become components once @@ -94,7 +96,7 @@ describe("TopNavBar Switch Org", () => { }); it("switches to the organization picked from the list", async () => { - axios.mockResolvedValue({}); + axiosPrivate.mockResolvedValue({}); const user = userEvent.setup(); renderTopNavBar(); @@ -105,7 +107,7 @@ describe("TopNavBar Switch Org", () => { ).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Confirm" })); - expect(axios).toHaveBeenCalledWith( + expect(axiosPrivate).toHaveBeenCalledWith( expect.objectContaining({ method: "POST", url: "/api/v1/organization/org-b/set", diff --git a/frontend/src/components/pipelines-or-deployments/etl-task-deploy/EtlTaskDeploy.jsx b/frontend/src/components/pipelines-or-deployments/etl-task-deploy/EtlTaskDeploy.jsx index 9b3076ab5d..7e898dca39 100644 --- a/frontend/src/components/pipelines-or-deployments/etl-task-deploy/EtlTaskDeploy.jsx +++ b/frontend/src/components/pipelines-or-deployments/etl-task-deploy/EtlTaskDeploy.jsx @@ -204,7 +204,6 @@ const EtlTaskDeploy = ({ url: `/api/v1/unstract/${sessionDetails?.orgId}/pipeline/${body?.id}/`, headers: { "Content-Type": "application/json", - "X-CSRFToken": sessionDetails?.csrfToken, }, data: body, }; @@ -239,7 +238,6 @@ const EtlTaskDeploy = ({ url: `/api/v1/unstract/${sessionDetails?.orgId}/pipeline/`, headers: { "Content-Type": "application/json", - "X-CSRFToken": sessionDetails?.csrfToken, }, data: body, }; diff --git a/frontend/src/components/pipelines-or-deployments/log-modal/LogsModal.jsx b/frontend/src/components/pipelines-or-deployments/log-modal/LogsModal.jsx index e010a6321c..2c2cdd86a2 100644 --- a/frontend/src/components/pipelines-or-deployments/log-modal/LogsModal.jsx +++ b/frontend/src/components/pipelines-or-deployments/log-modal/LogsModal.jsx @@ -31,9 +31,6 @@ const LogsModal = ({ const requestOptions = { method: "GET", url: `/api/v1/unstract/${sessionDetails?.orgId}/workflow/execution/${logId}/logs/`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, params: { page: page, page_size: pageSize, diff --git a/frontend/src/components/pipelines-or-deployments/log-modal/fetchExecutionLogs.js b/frontend/src/components/pipelines-or-deployments/log-modal/fetchExecutionLogs.js index 6faf2af4e2..2ff0d97e12 100644 --- a/frontend/src/components/pipelines-or-deployments/log-modal/fetchExecutionLogs.js +++ b/frontend/src/components/pipelines-or-deployments/log-modal/fetchExecutionLogs.js @@ -15,9 +15,6 @@ const fetchExecutionLogs = ( const requestOptions = { method: "GET", url: `/api/v1/unstract/${sessionDetails?.orgId}/pipeline/${selectedRow.id}/executions/`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, params: { page: page, page_size: pageSize, diff --git a/frontend/src/components/pipelines-or-deployments/pipeline-service.js b/frontend/src/components/pipelines-or-deployments/pipeline-service.js index 4858ab41ba..32f7a1f0e0 100644 --- a/frontend/src/components/pipelines-or-deployments/pipeline-service.js +++ b/frontend/src/components/pipelines-or-deployments/pipeline-service.js @@ -5,11 +5,9 @@ function pipelineService() { const axiosPrivate = useAxiosPrivate(); const { sessionDetails } = useSessionStore(); const path = `/api/v1/unstract/${sessionDetails?.orgId.replaceAll('"', "")}`; - const csrfToken = sessionDetails.csrfToken; const requestHeaders = { "Content-Type": "application/json", - "X-CSRFToken": csrfToken, }; return { diff --git a/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx b/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx index edde00911e..2f4d248673 100644 --- a/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx +++ b/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx @@ -230,7 +230,6 @@ function Pipelines({ type }) { url: `/api/v1/unstract/${sessionDetails?.orgId}/pipeline/execute/`, headers: { "Content-Type": "application/json", - "X-CSRFToken": sessionDetails?.csrfToken, }, data: body, }; @@ -253,7 +252,6 @@ function Pipelines({ type }) { url: `/api/v1/unstract/${sessionDetails?.orgId}/pipeline/${id}/`, headers: { "Content-Type": "application/json", - "X-CSRFToken": sessionDetails?.csrfToken, }, data: body, }; @@ -270,9 +268,6 @@ function Pipelines({ type }) { const requestOptions = { method: "DELETE", url: `/api/v1/unstract/${sessionDetails?.orgId}/pipeline/${id}/`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, }; axiosPrivate(requestOptions) .then(() => { diff --git a/frontend/src/components/set-org/SetOrg.jsx b/frontend/src/components/set-org/SetOrg.jsx index 25f8585c43..9517ef2b9f 100644 --- a/frontend/src/components/set-org/SetOrg.jsx +++ b/frontend/src/components/set-org/SetOrg.jsx @@ -1,4 +1,3 @@ -import Cookies from "js-cookie"; import { useEffect, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/shims/antd-button"; @@ -46,13 +45,12 @@ function SetOrg() { const handleContinue = (id) => { setLoading(true); setLoadingOrgId(id); - const csrfToken = Cookies.get("csrftoken"); + // Raw axios on purpose: this runs before an org session exists, so a 401 + // must surface here rather than trigger useAxiosPrivate's logout. CSRF + // comes from the global interceptor in App.jsx. const requestOptions = { method: "POST", url: `/api/v1/organization/${id}/set`, - headers: { - "X-CSRFToken": csrfToken, - }, }; axios(requestOptions) diff --git a/frontend/src/components/settings/api-key-manager/ApiKeyManager.jsx b/frontend/src/components/settings/api-key-manager/ApiKeyManager.jsx index 3d2c265425..c9ad96ac33 100644 --- a/frontend/src/components/settings/api-key-manager/ApiKeyManager.jsx +++ b/frontend/src/components/settings/api-key-manager/ApiKeyManager.jsx @@ -109,7 +109,6 @@ function ApiKeyManager({ method: "POST", url: `${basePath}/keys/`, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: transformCreatePayload(values), @@ -146,7 +145,6 @@ function ApiKeyManager({ method: "PATCH", url: `${basePath}/keys/${selectedKey?.id}/`, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: transformEditPayload(values), @@ -181,7 +179,6 @@ function ApiKeyManager({ method: "PATCH", url: `${basePath}/keys/${record?.id}/`, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: { is_active: !record?.is_active }, @@ -196,7 +193,6 @@ function ApiKeyManager({ axiosPrivate({ method: "POST", url: `${basePath}/keys/${record?.id}/rotate/`, - headers: { "X-CSRFToken": sessionDetails?.csrfToken }, }) .then((res) => { fetchKeys(); @@ -211,7 +207,6 @@ function ApiKeyManager({ axiosPrivate({ method: "DELETE", url: `${basePath}/keys/${record?.id}/`, - headers: { "X-CSRFToken": sessionDetails?.csrfToken }, }) .then(() => { fetchKeys(); diff --git a/frontend/src/components/settings/default-triad/DefaultTriad.jsx b/frontend/src/components/settings/default-triad/DefaultTriad.jsx index d7d1a56754..bba8e1a08f 100644 --- a/frontend/src/components/settings/default-triad/DefaultTriad.jsx +++ b/frontend/src/components/settings/default-triad/DefaultTriad.jsx @@ -149,7 +149,6 @@ function DefaultTriad() { ); const header = { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }; const requestOptions = { diff --git a/frontend/src/components/settings/invite/InviteEditUser.jsx b/frontend/src/components/settings/invite/InviteEditUser.jsx index a62ee67bf4..d52878ce0f 100644 --- a/frontend/src/components/settings/invite/InviteEditUser.jsx +++ b/frontend/src/components/settings/invite/InviteEditUser.jsx @@ -44,7 +44,6 @@ function InviteEditUser() { method: "GET", url: `/api/v1/unstract/${sessionDetails?.orgId}/roles`, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, }; @@ -71,7 +70,6 @@ function InviteEditUser() { url: `/api/v1/unstract/${sessionDetails?.orgId}/users/invite/`, data: { users: [value] }, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, }; @@ -99,7 +97,6 @@ function InviteEditUser() { url: `/api/v1/unstract/${sessionDetails?.orgId}/users/role/`, data: { ...user }, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, }; diff --git a/frontend/src/components/settings/platform/PlatformSettings.jsx b/frontend/src/components/settings/platform/PlatformSettings.jsx index 51131c115f..a842fee65f 100644 --- a/frontend/src/components/settings/platform/PlatformSettings.jsx +++ b/frontend/src/components/settings/platform/PlatformSettings.jsx @@ -167,7 +167,6 @@ function PlatformSettings() { method: "PATCH", url: `/api/v1/unstract/${sessionDetails?.orgId}/organization/settings`, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: { restrict_llm_adapter_creation: checked }, @@ -198,7 +197,6 @@ function PlatformSettings() { method: "PATCH", url: `/api/v1/unstract/${sessionDetails?.orgId}/organization/settings`, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: { restrict_connector_creation: checked }, @@ -238,7 +236,6 @@ function PlatformSettings() { method: "PATCH", url: `/api/v1/unstract/${sessionDetails?.orgId}/notifications/settings/`, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: { club_interval_seconds: batchIntervalMinutes * 60 }, @@ -348,7 +345,6 @@ function PlatformSettings() { method, url, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: body, @@ -381,9 +377,6 @@ function PlatformSettings() { const requestOptions = { method: "DELETE", url: `/api/v1/unstract/${sessionDetails?.orgId}/platform/keys/${keyDetails?.id}/`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, }; setDeletingIndex(index); @@ -419,7 +412,6 @@ function PlatformSettings() { method: "PUT", url: `/api/v1/unstract/${sessionDetails?.orgId}/platform/keys/${keyDetails?.id}/`, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: body, diff --git a/frontend/src/components/settings/users/Users.jsx b/frontend/src/components/settings/users/Users.jsx index da91c05820..d4ffcb69af 100644 --- a/frontend/src/components/settings/users/Users.jsx +++ b/frontend/src/components/settings/users/Users.jsx @@ -49,7 +49,6 @@ function Users() { url: `/api/v1/unstract/${sessionDetails?.orgId}/users/`, data: { emails: [selectedUserEmail?.email] }, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, }; diff --git a/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx b/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx index 9cc0beed92..80b2a2834e 100644 --- a/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx +++ b/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx @@ -78,14 +78,12 @@ function ToolSettings({ type }) { axiosPrivate({ method: "GET", url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/users/${id}/`, - headers: { "X-CSRFToken": sessionDetails?.csrfToken }, }), addCoOwner: (id, userId) => axiosPrivate({ method: "POST", url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/${id}/owners/`, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: { user_id: userId }, @@ -94,10 +92,9 @@ function ToolSettings({ type }) { axiosPrivate({ method: "DELETE", url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/${id}/owners/${userId}/`, - headers: { "X-CSRFToken": sessionDetails?.csrfToken }, }), }), - [sessionDetails?.orgId, sessionDetails?.csrfToken], + [sessionDetails?.orgId], ); const { @@ -219,7 +216,6 @@ function ToolSettings({ type }) { axiosPrivate({ method: "DELETE", url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/${adapter?.id}/`, - headers: { "X-CSRFToken": sessionDetails?.csrfToken }, }) .then(() => handleListRefresh()) .catch((err) => setAlertDetails(handleException(err))); @@ -251,9 +247,6 @@ function ToolSettings({ type }) { const requestOptions = { method: "GET", url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/users/${adapter.id}/`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, }; setIsShareLoading(true); getAllUsers(); @@ -308,9 +301,6 @@ function ToolSettings({ type }) { const requestOptions = { method: "POST", url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/${adapter?.id}/share/`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, data: { shared_users: userIds, shared_to_org: shareWithEveryone || false, diff --git a/frontend/src/components/ui/shims/antd-structure.test.jsx b/frontend/src/components/ui/shims/antd-structure.test.jsx index 575bef3106..bec5cd85e0 100644 --- a/frontend/src/components/ui/shims/antd-structure.test.jsx +++ b/frontend/src/components/ui/shims/antd-structure.test.jsx @@ -1233,7 +1233,7 @@ describe("antd-compatible structural shims (P4)", () => {

drop here

, @@ -1247,7 +1247,7 @@ describe("antd-compatible structural shims (P4)", () => { const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe("/api/v1/upload"); expect(init.method).toBe("POST"); - expect(init.headers).toEqual({ "X-CSRFToken": "tok" }); + expect(init.headers).toEqual({ "X-Test": "tok" }); expect(init.body.get("file")).toBe(file); }); diff --git a/frontend/src/components/workflows/workflow/workflow-service.js b/frontend/src/components/workflows/workflow/workflow-service.js index 0b771bf481..4edb7220cd 100644 --- a/frontend/src/components/workflows/workflow/workflow-service.js +++ b/frontend/src/components/workflows/workflow/workflow-service.js @@ -8,7 +8,6 @@ function workflowService() { const axiosPrivate = useAxiosPrivate(); const { sessionDetails } = useSessionStore(); const path = `/api/v1/unstract/${sessionDetails.orgId.replaceAll('"', "")}`; - const csrfToken = sessionDetails.csrfToken; return { // Feeds selectors, so it resolves to every workflow rather than one page. @@ -36,9 +35,6 @@ function workflowService() { options = { url: id ? `${path}/workflow/${id}/` : `${path}/workflow/`, method: id ? "PUT" : "POST", - headers: { - "X-CSRFToken": csrfToken, - }, data: { workflow_name: name, description, @@ -51,9 +47,6 @@ function workflowService() { options = { url: `${path}/workflow/${id}/`, method: "DELETE", - headers: { - "X-CSRFToken": csrfToken, - }, }; return axiosPrivate(options); }, @@ -61,9 +54,6 @@ function workflowService() { options = { url: `${path}/workflow/${id}/clear-file-marker/`, method: "POST", - headers: { - "X-CSRFToken": csrfToken, - }, }; return axiosPrivate(options); }, @@ -79,9 +69,6 @@ function workflowService() { options = { url: `${path}/workflow/${workflowId}/file-histories/${fileHistoryId}/`, method: "DELETE", - headers: { - "X-CSRFToken": csrfToken, - }, }; return axiosPrivate(options); }, @@ -90,7 +77,6 @@ function workflowService() { url: `${path}/workflow/${workflowId}/file-histories/clear/`, method: "POST", headers: { - "X-CSRFToken": csrfToken, "Content-Type": "application/json", }, data: filters, @@ -102,7 +88,6 @@ function workflowService() { url: `${path}/workflow/${workflowId}/file-histories/clear/`, method: "POST", headers: { - "X-CSRFToken": csrfToken, "Content-Type": "application/json", }, data: { ids }, @@ -127,9 +112,6 @@ function workflowService() { options = { url: `${path}/workflow/${id}/share/`, method: "POST", - headers: { - "X-CSRFToken": csrfToken, - }, data: { shared_users: sharedUsers, shared_to_org: shareWithEveryone, @@ -150,7 +132,6 @@ function workflowService() { url: `${path}/workflow/${id}/owners/`, method: "POST", headers: { - "X-CSRFToken": csrfToken, "Content-Type": "application/json", }, data: { user_id: userId }, @@ -161,9 +142,6 @@ function workflowService() { options = { url: `${path}/workflow/${id}/owners/${userId}/`, method: "DELETE", - headers: { - "X-CSRFToken": csrfToken, - }, }; return axiosPrivate(options); }, diff --git a/frontend/src/helpers/FeatureFlagsData.js b/frontend/src/helpers/FeatureFlagsData.js index f961b9c3bf..e625a04a6c 100644 --- a/frontend/src/helpers/FeatureFlagsData.js +++ b/frontend/src/helpers/FeatureFlagsData.js @@ -1,12 +1,11 @@ import axios from "axios"; -async function makeApiCall(method, url, data, csrfToken) { - const headers = { - "X-CSRFToken": csrfToken, - }; - +// Raw axios on purpose: flags are listed during session bootstrap +// (useSessionValid), before useAxiosPrivate's logout handling applies. CSRF +// comes from the global interceptor in App.jsx. +async function makeApiCall(method, url, data) { try { - const response = await axios({ method, url, data, headers }); + const response = await axios({ method, url, data }); return response?.data; } catch (error) { console.error(`Error making API call to ${url}: ${error}`); @@ -14,19 +13,19 @@ async function makeApiCall(method, url, data, csrfToken) { } } -export async function evaluateFeatureFlag(orgId, csrfToken, featureFlag) { +export async function evaluateFeatureFlag(orgId, featureFlag) { const url = `/api/v1/unstract/${orgId}/evaluate/`; const data = { flag_key: featureFlag, }; - const response = await makeApiCall("POST", url, data, csrfToken); + const response = await makeApiCall("POST", url, data); return response?.flag_status ?? false; } -export async function listFlags(orgId, csrfToken) { +export async function listFlags(orgId) { const url = `/api/v1/unstract/${orgId}/flags/`; - const response = await makeApiCall("GET", url, null, csrfToken); + const response = await makeApiCall("GET", url, null); return response.feature_flags.flags ?? {}; } diff --git a/frontend/src/helpers/csrf.js b/frontend/src/helpers/csrf.js new file mode 100644 index 0000000000..d584391ccb --- /dev/null +++ b/frontend/src/helpers/csrf.js @@ -0,0 +1,62 @@ +import Cookies from "js-cookie"; + +import { useSessionStore } from "../store/session-store"; + +const CSRF_HEADER = "X-CSRFToken"; +const CSRF_COOKIE = "csrftoken"; +const SAFE_METHODS = new Set(["get", "head", "options", "trace"]); + +// Session store first; the cookie covers the bootstrap calls that run before +// the session is hydrated (org selection, session validation). +const getCsrfToken = () => + useSessionStore.getState().sessionDetails?.csrfToken || + Cookies.get(CSRF_COOKIE); + +// The token must never leave our origin, so absolute URLs to other hosts are +// skipped. +const isSameOrigin = (url) => { + if (!url) { + return true; + } + try { + const origin = globalThis.location?.origin; + return new URL(url, origin).origin === origin; + } catch { + return false; + } +}; + +const isUnsafeMethod = (method) => + !SAFE_METHODS.has((method || "get").toLowerCase()); + +const setHeaderIfMissing = (headers, value) => { + if (typeof headers.set === "function") { + headers.set(CSRF_HEADER, value, false); + return; + } + if (!headers[CSRF_HEADER]) { + headers[CSRF_HEADER] = value; + } +}; + +const attachCsrfInterceptor = (axiosInstance) => { + return axiosInstance.interceptors.request.use((config) => { + if (!isUnsafeMethod(config.method) || !isSameOrigin(config.url)) { + return config; + } + const token = getCsrfToken(); + if (token) { + config.headers ??= {}; + setHeaderIfMissing(config.headers, token); + } + return config; + }); +}; + +// For transports that bypass axios (e.g. the Upload shim's `action` fetch). +const getCsrfHeaders = () => { + const token = getCsrfToken(); + return token ? { [CSRF_HEADER]: token } : {}; +}; + +export { attachCsrfInterceptor, CSRF_HEADER, getCsrfHeaders, getCsrfToken }; diff --git a/frontend/src/helpers/csrf.test.js b/frontend/src/helpers/csrf.test.js new file mode 100644 index 0000000000..c2b7b31882 --- /dev/null +++ b/frontend/src/helpers/csrf.test.js @@ -0,0 +1,137 @@ +import axios from "axios"; +import Cookies from "js-cookie"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useSessionStore } from "../store/session-store"; +import { + attachCsrfInterceptor, + CSRF_HEADER, + getCsrfHeaders, + getCsrfToken, +} from "./csrf"; + +const runRequestInterceptors = async (instance, config = {}) => { + let current = { ...config, headers: { ...config.headers } }; + for (const handler of instance.interceptors.request.handlers) { + if (handler?.fulfilled) { + current = await handler.fulfilled(current); + } + } + return current; +}; + +const withInterceptor = () => { + const instance = axios.create(); + attachCsrfInterceptor(instance); + return instance; +}; + +describe("csrf helpers", () => { + beforeEach(() => { + useSessionStore.setState({ sessionDetails: { csrfToken: "store-tok" } }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + useSessionStore.setState({ sessionDetails: {} }); + }); + + describe("getCsrfToken", () => { + it("prefers the session store", () => { + vi.spyOn(Cookies, "get").mockReturnValue("cookie-tok"); + expect(getCsrfToken()).toBe("store-tok"); + }); + + it("falls back to the csrftoken cookie before the session hydrates", () => { + useSessionStore.setState({ sessionDetails: {} }); + const get = vi.spyOn(Cookies, "get").mockReturnValue("cookie-tok"); + expect(getCsrfToken()).toBe("cookie-tok"); + expect(get).toHaveBeenCalledWith("csrftoken"); + }); + }); + + describe("attachCsrfInterceptor", () => { + it.each([ + "post", + "PUT", + "patch", + "delete", + ])("sets the header on %s", async (method) => { + const result = await runRequestInterceptors(withInterceptor(), { + method, + url: "/api/v1/unstract/org/items/", + }); + expect(result.headers[CSRF_HEADER]).toBe("store-tok"); + }); + + it.each([ + "get", + "head", + "options", + undefined, + ])("leaves safe method %s alone", async (method) => { + const result = await runRequestInterceptors(withInterceptor(), { + method, + url: "/api/v1/unstract/org/items/", + }); + expect(result.headers[CSRF_HEADER]).toBeUndefined(); + }); + + it("does not send the token to another origin", async () => { + const result = await runRequestInterceptors(withInterceptor(), { + method: "post", + url: "https://example.com/upload", + }); + expect(result.headers[CSRF_HEADER]).toBeUndefined(); + }); + + it("sends the token to an absolute same-origin URL", async () => { + const result = await runRequestInterceptors(withInterceptor(), { + method: "post", + url: `${globalThis.location.origin}/api/v1/items/`, + }); + expect(result.headers[CSRF_HEADER]).toBe("store-tok"); + }); + + it("does not overwrite a caller-supplied token", async () => { + const result = await runRequestInterceptors(withInterceptor(), { + method: "post", + url: "/api/v1/items/", + headers: { [CSRF_HEADER]: "explicit" }, + }); + expect(result.headers[CSRF_HEADER]).toBe("explicit"); + }); + + it("omits the header when no token is available", async () => { + useSessionStore.setState({ sessionDetails: {} }); + vi.spyOn(Cookies, "get").mockReturnValue(undefined); + const result = await runRequestInterceptors(withInterceptor(), { + method: "post", + url: "/api/v1/items/", + }); + expect(CSRF_HEADER in result.headers).toBe(false); + }); + + it("reads the token per request, not at attach time", async () => { + const instance = withInterceptor(); + useSessionStore.setState({ sessionDetails: { csrfToken: "rotated" } }); + const result = await runRequestInterceptors(instance, { + method: "post", + url: "/api/v1/items/", + }); + expect(result.headers[CSRF_HEADER]).toBe("rotated"); + }); + }); + + describe("getCsrfHeaders", () => { + it("returns the header object", () => { + expect(getCsrfHeaders()).toEqual({ [CSRF_HEADER]: "store-tok" }); + }); + + it("returns an empty object without a token", () => { + useSessionStore.setState({ sessionDetails: {} }); + vi.spyOn(Cookies, "get").mockReturnValue(undefined); + expect(getCsrfHeaders()).toEqual({}); + }); + }); +}); diff --git a/frontend/src/helpers/orgApi.js b/frontend/src/helpers/orgApi.js new file mode 100644 index 0000000000..136a8864c2 --- /dev/null +++ b/frontend/src/helpers/orgApi.js @@ -0,0 +1,10 @@ +import { useSessionStore } from "../store/session-store"; + +// Builds `/api/v1/unstract//` for the signed-in org. `orgId` is +// read at call time, so call this inside the request, not at module scope. +const orgApi = (path = "") => { + const { orgId } = useSessionStore.getState().sessionDetails ?? {}; + return `/api/v1/unstract/${orgId}/${path.replace(/^\/+/, "")}`; +}; + +export { orgApi }; diff --git a/frontend/src/helpers/orgApi.test.js b/frontend/src/helpers/orgApi.test.js new file mode 100644 index 0000000000..20a74163aa --- /dev/null +++ b/frontend/src/helpers/orgApi.test.js @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { useSessionStore } from "../store/session-store"; +import { orgApi } from "./orgApi"; + +describe("orgApi", () => { + afterEach(() => { + useSessionStore.setState({ sessionDetails: {} }); + }); + + it("prefixes the path with the signed-in org", () => { + useSessionStore.setState({ sessionDetails: { orgId: "org-1" } }); + expect(orgApi("items/")).toBe("/api/v1/unstract/org-1/items/"); + }); + + it("tolerates a leading slash", () => { + useSessionStore.setState({ sessionDetails: { orgId: "org-1" } }); + expect(orgApi("/items/42/")).toBe("/api/v1/unstract/org-1/items/42/"); + }); + + it("reads the org at call time", () => { + useSessionStore.setState({ sessionDetails: { orgId: "org-1" } }); + useSessionStore.setState({ sessionDetails: { orgId: "org-2" } }); + expect(orgApi("items/")).toBe("/api/v1/unstract/org-2/items/"); + }); +}); diff --git a/frontend/src/hooks/useAxiosPrivate.js b/frontend/src/hooks/useAxiosPrivate.js index ac57911d2d..4f7cfd58ca 100644 --- a/frontend/src/hooks/useAxiosPrivate.js +++ b/frontend/src/hooks/useAxiosPrivate.js @@ -1,12 +1,20 @@ import axios from "axios"; import { useEffect, useMemo } from "react"; +import { attachCsrfInterceptor } from "../helpers/csrf"; import { attachRequestIdInterceptor } from "../helpers/requestId"; import useLogout from "./useLogout"; function useAxiosPrivate() { const logout = useLogout(); - const axiosPrivate = useMemo(() => axios.create(), []); + // CSRF is attached at creation, not in the effect below: a child that is + // handed this instance can fire a request from its own mount effect, which + // runs before ours. + const axiosPrivate = useMemo(() => { + const instance = axios.create(); + attachCsrfInterceptor(instance); + return instance; + }, []); useEffect(() => { const requestInterceptor = attachRequestIdInterceptor(axiosPrivate); diff --git a/frontend/src/hooks/usePromptOutput.js b/frontend/src/hooks/usePromptOutput.js index 6f4ef40e4d..1254a7418e 100644 --- a/frontend/src/hooks/usePromptOutput.js +++ b/frontend/src/hooks/usePromptOutput.js @@ -236,9 +236,6 @@ const usePromptOutput = () => { const requestOptions = { method: "GET", url, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, }; return axiosPrivate(requestOptions) diff --git a/frontend/src/hooks/usePromptRun.js b/frontend/src/hooks/usePromptRun.js index eb97502d02..3cad6b0d8d 100644 --- a/frontend/src/hooks/usePromptRun.js +++ b/frontend/src/hooks/usePromptRun.js @@ -46,7 +46,6 @@ const usePromptRun = () => { method: "POST", url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/fetch_response/${details?.tool_id}`, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: body, @@ -115,7 +114,6 @@ const usePromptRun = () => { method: "POST", url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/bulk_fetch_response/${details?.tool_id}`, headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, data: body, diff --git a/frontend/src/hooks/useSessionValid.js b/frontend/src/hooks/useSessionValid.js index a2c2c0e092..7d8c625785 100644 --- a/frontend/src/hooks/useSessionValid.js +++ b/frontend/src/hooks/useSessionValid.js @@ -62,6 +62,10 @@ function useSessionValid() { } return false; }; + // Raw axios on purpose: this hook establishes the session, so a 401 here + // means "not signed in" and must not trigger useAxiosPrivate's logout. CSRF + // comes from the global interceptor in App.jsx (cookie fallback, since the + // session store is not hydrated yet). return async () => { let userAndOrgDetails = null; try { @@ -103,9 +107,6 @@ function useSessionValid() { // API to set the organization and get the user details requestOptions["method"] = "POST"; requestOptions["url"] = `/api/v1/organization/${orgId}/set`; - requestOptions["headers"] = { - "X-CSRFToken": csrfToken, - }; const setOrgRes = await axios(requestOptions).catch((error) => { if (error?.response && error?.response?.status === 403) { navigate("/", { state: null }); @@ -123,9 +124,6 @@ function useSessionValid() { requestOptions["method"] = "GET"; requestOptions["url"] = `/api/v1/unstract/${orgId}/users/profile/`; - requestOptions["headers"] = { - "X-CSRFToken": csrfToken, - }; const getUserInfo = await axios(requestOptions); userAndOrgDetails["isAdmin"] = getUserInfo?.data?.user?.is_admin; userAndOrgDetails["loginOnboardingMessage"] = @@ -142,9 +140,6 @@ function useSessionValid() { requestOptions["method"] = "GET"; requestOptions["url"] = `/api/v1/unstract/${orgId}/adapter/`; - requestOptions["headers"] = { - "X-CSRFToken": csrfToken, - }; const getAdapterDetails = await axios(requestOptions); const adapterTypes = [ ...new Set( @@ -157,7 +152,7 @@ function useSessionValid() { } if (isUnstract) { - const flags = await listFlags(orgId, csrfToken); + const flags = await listFlags(orgId); userAndOrgDetails["flags"] = flags; } diff --git a/frontend/src/pages/ConnectorsPage.jsx b/frontend/src/pages/ConnectorsPage.jsx index 71c242d67c..96d74a3af7 100644 --- a/frontend/src/pages/ConnectorsPage.jsx +++ b/frontend/src/pages/ConnectorsPage.jsx @@ -53,26 +53,21 @@ function ConnectorsPage() { () => ({ getAllUsers: () => axiosPrivate.get(getUrl("users/")), getSharedUsers: (id) => - axiosPrivate.get(getUrl(`connector/users/${id}/`), { - headers: { "X-CSRFToken": sessionDetails?.csrfToken }, - }), + axiosPrivate.get(getUrl(`connector/users/${id}/`), {}), addCoOwner: (id, userId) => axiosPrivate.post( getUrl(`connector/${id}/owners/`), { user_id: userId }, { headers: { - "X-CSRFToken": sessionDetails?.csrfToken, "Content-Type": "application/json", }, }, ), removeCoOwner: (id, userId) => - axiosPrivate.delete(getUrl(`connector/${id}/owners/${userId}/`), { - headers: { "X-CSRFToken": sessionDetails?.csrfToken }, - }), + axiosPrivate.delete(getUrl(`connector/${id}/owners/${userId}/`), {}), }), - [sessionDetails?.csrfToken], + [], ); const { @@ -190,11 +185,7 @@ function ConnectorsPage() { const handleDeleteConnector = async (_event, connector) => { try { - await axiosPrivate.delete(getUrl(`connector/${connector.id}/`), { - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, - }); + await axiosPrivate.delete(getUrl(`connector/${connector.id}/`), {}); setAlertDetails({ type: "success", content: "Connector deleted successfully", @@ -250,11 +241,7 @@ function ConnectorsPage() { await axiosPrivate.post( getUrl(`connector/${connector.id}/share/`), updateData, - { - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, - }, + {}, ); setShareModalVisible(false); setAlertDetails({ diff --git a/frontend/src/store/socket-logs-store.js b/frontend/src/store/socket-logs-store.js index c12433c0e8..5bf2e9f24f 100644 --- a/frontend/src/store/socket-logs-store.js +++ b/frontend/src/store/socket-logs-store.js @@ -1,6 +1,7 @@ import axios from "axios"; import { create } from "zustand"; +import { orgApi } from "../helpers/orgApi"; import { useSessionStore } from "./session-store"; const STORE_VARIABLES = { @@ -40,14 +41,19 @@ const useSocketLogsStore = create((setState, getState) => ({ ) { const requestOptions = { method: "POST", - url: `/api/v1/unstract/${sessionDetails?.orgId}/logs/`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, + url: orgApi("logs/"), data: { log: JSON.stringify(newLog) }, }; - axios(requestOptions).catch(() => { - // Intentionally empty: best-effort log persistence + // Raw axios on purpose: a store cannot use hooks, and a failed + // background log write must not log the user out. CSRF comes from the + // global interceptor in App.jsx. + axios(requestOptions).catch((err) => { + // Best-effort persistence, but leave a trace instead of failing + // silently. + console.warn("[socket-logs-store] Failed to persist notification", { + status: err?.response?.status, + message: err?.message, + }); }); } }); From 464d4484bd37c4dd5724ca360f2c7e5b7c77fb04 Mon Sep 17 00:00:00 2001 From: Jaseem Jas Date: Tue, 29 Sep 2026 19:23:44 +0530 Subject: [PATCH 2/2] UN-4186 Respect axios baseURL in the CSRF origin check and test the global install The origin guard now checks where axios will actually send the request (baseURL unless the URL is absolute), so an external baseURL never receives the token. Move the App.jsx global attach into an idempotent installGlobalCsrfInterceptor() and cover it with a test against the real default axios instance. --- frontend/src/App.jsx | 8 ++--- frontend/src/helpers/csrf.js | 33 ++++++++++++++++++-- frontend/src/helpers/csrf.test.js | 51 +++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ab1523c16b..cb9618a6fc 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,7 +12,7 @@ import CustomMarkdown from "./components/helpers/custom-markdown/CustomMarkdown. import { NotificationClearAll } from "./components/notification/NotificationClearAll.jsx"; import { NotificationIdLine } from "./components/notification/NotificationIdLine.jsx"; import { PageTitle } from "./components/widgets/page-title/PageTitle.jsx"; -import { attachCsrfInterceptor } from "./helpers/csrf.js"; +import { installGlobalCsrfInterceptor } from "./helpers/csrf.js"; import { THEME } from "./helpers/GetStaticData.js"; import { attachRequestIdInterceptor } from "./helpers/requestId.js"; import PostHogPageviewTracker from "./PostHogPageviewTracker.js"; @@ -29,11 +29,7 @@ if (!axios[GLOBAL_INTERCEPTOR_FLAG]) { // The few intentional raw-axios callers (pre-session bootstrap, background log // writes) get CSRF here; everything else goes through useAxiosPrivate. -const CSRF_INTERCEPTOR_FLAG = Symbol.for("unstract.csrfInterceptor"); -if (!axios[CSRF_INTERCEPTOR_FLAG]) { - attachCsrfInterceptor(axios); - axios[CSRF_INTERCEPTOR_FLAG] = true; -} +installGlobalCsrfInterceptor(axios); let GoogleTagManagerHelper; try { diff --git a/frontend/src/helpers/csrf.js b/frontend/src/helpers/csrf.js index d584391ccb..d77c3a005f 100644 --- a/frontend/src/helpers/csrf.js +++ b/frontend/src/helpers/csrf.js @@ -26,6 +26,14 @@ const isSameOrigin = (url) => { } }; +// Same rule as axios' isAbsoluteURL: a scheme or protocol-relative `//host`. +const ABSOLUTE_URL = /^([a-z][a-z\d+\-.]*:)?\/\//i; + +// Where axios will actually send the request: `baseURL` decides the origin +// unless the request URL is itself absolute. +const requestTarget = ({ url, baseURL }) => + baseURL && !ABSOLUTE_URL.test(url ?? "") ? baseURL : url; + const isUnsafeMethod = (method) => !SAFE_METHODS.has((method || "get").toLowerCase()); @@ -41,7 +49,10 @@ const setHeaderIfMissing = (headers, value) => { const attachCsrfInterceptor = (axiosInstance) => { return axiosInstance.interceptors.request.use((config) => { - if (!isUnsafeMethod(config.method) || !isSameOrigin(config.url)) { + if ( + !isUnsafeMethod(config.method) || + !isSameOrigin(requestTarget(config)) + ) { return config; } const token = getCsrfToken(); @@ -53,10 +64,28 @@ const attachCsrfInterceptor = (axiosInstance) => { }); }; +const GLOBAL_INSTALL_FLAG = Symbol.for("unstract.csrfInterceptor"); + +// For the global axios default, which the few intentional raw-axios callers +// use. Idempotent, since App.jsx can be re-evaluated by HMR. +const installGlobalCsrfInterceptor = (axiosInstance) => { + if (axiosInstance[GLOBAL_INSTALL_FLAG]) { + return; + } + attachCsrfInterceptor(axiosInstance); + axiosInstance[GLOBAL_INSTALL_FLAG] = true; +}; + // For transports that bypass axios (e.g. the Upload shim's `action` fetch). const getCsrfHeaders = () => { const token = getCsrfToken(); return token ? { [CSRF_HEADER]: token } : {}; }; -export { attachCsrfInterceptor, CSRF_HEADER, getCsrfHeaders, getCsrfToken }; +export { + attachCsrfInterceptor, + CSRF_HEADER, + getCsrfHeaders, + getCsrfToken, + installGlobalCsrfInterceptor, +}; diff --git a/frontend/src/helpers/csrf.test.js b/frontend/src/helpers/csrf.test.js index c2b7b31882..0018d522ef 100644 --- a/frontend/src/helpers/csrf.test.js +++ b/frontend/src/helpers/csrf.test.js @@ -8,6 +8,7 @@ import { CSRF_HEADER, getCsrfHeaders, getCsrfToken, + installGlobalCsrfInterceptor, } from "./csrf"; const runRequestInterceptors = async (instance, config = {}) => { @@ -85,6 +86,41 @@ describe("csrf helpers", () => { expect(result.headers[CSRF_HEADER]).toBeUndefined(); }); + it("does not send the token to a protocol-relative URL", async () => { + const result = await runRequestInterceptors(withInterceptor(), { + method: "post", + url: "//example.com/upload", + }); + expect(result.headers[CSRF_HEADER]).toBeUndefined(); + }); + + it("does not send the token when an external baseURL applies", async () => { + const result = await runRequestInterceptors(withInterceptor(), { + method: "post", + baseURL: "https://example.com/api", + url: "/items/", + }); + expect(result.headers[CSRF_HEADER]).toBeUndefined(); + }); + + it("sends the token with a same-origin baseURL", async () => { + const result = await runRequestInterceptors(withInterceptor(), { + method: "post", + baseURL: "/api/v1", + url: "items/", + }); + expect(result.headers[CSRF_HEADER]).toBe("store-tok"); + }); + + it("ignores an external baseURL when the URL is absolute same-origin", async () => { + const result = await runRequestInterceptors(withInterceptor(), { + method: "post", + baseURL: "https://example.com/api", + url: `${globalThis.location.origin}/api/v1/items/`, + }); + expect(result.headers[CSRF_HEADER]).toBe("store-tok"); + }); + it("sends the token to an absolute same-origin URL", async () => { const result = await runRequestInterceptors(withInterceptor(), { method: "post", @@ -123,6 +159,21 @@ describe("csrf helpers", () => { }); }); + describe("installGlobalCsrfInterceptor", () => { + it("makes the global axios send the token, attaching only once", async () => { + const before = axios.interceptors.request.handlers.length; + installGlobalCsrfInterceptor(axios); + installGlobalCsrfInterceptor(axios); + expect(axios.interceptors.request.handlers.length).toBe(before + 1); + + const result = await runRequestInterceptors(axios, { + method: "post", + url: "/api/v1/organization/org-1/set", + }); + expect(result.headers[CSRF_HEADER]).toBe("store-tok"); + }); + }); + describe("getCsrfHeaders", () => { it("returns the header object", () => { expect(getCsrfHeaders()).toEqual({ [CSRF_HEADER]: "store-tok" });