diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 03fb338174..cb9618a6fc 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 { installGlobalCsrfInterceptor } from "./helpers/csrf.js"; import { THEME } from "./helpers/GetStaticData.js"; import { attachRequestIdInterceptor } from "./helpers/requestId.js"; import PostHogPageviewTracker from "./PostHogPageviewTracker.js"; @@ -26,6 +27,10 @@ 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. +installGlobalCsrfInterceptor(axios); + 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..d77c3a005f --- /dev/null +++ b/frontend/src/helpers/csrf.js @@ -0,0 +1,91 @@ +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; + } +}; + +// 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()); + +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(requestTarget(config)) + ) { + return config; + } + const token = getCsrfToken(); + if (token) { + config.headers ??= {}; + setHeaderIfMissing(config.headers, token); + } + return config; + }); +}; + +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, + installGlobalCsrfInterceptor, +}; diff --git a/frontend/src/helpers/csrf.test.js b/frontend/src/helpers/csrf.test.js new file mode 100644 index 0000000000..0018d522ef --- /dev/null +++ b/frontend/src/helpers/csrf.test.js @@ -0,0 +1,188 @@ +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, + installGlobalCsrfInterceptor, +} 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("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", + 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("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" }); + }); + + 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, + }); }); } });