Skip to content

UN-4186 [FIX] Send the CSRF token from one shared axios interceptor instead of hand-written headers - #2306

Open
jaseemjaskp wants to merge 2 commits into
mainfrom
UN-4186-centralise-csrf-axios
Open

jaseemjaskp wants to merge 2 commits into
mainfrom
UN-4186-centralise-csrf-axios

Conversation

@jaseemjaskp

@jaseemjaskp jaseemjaskp commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

What

  • Add helpers/csrf.js: attachCsrfInterceptor sets X-CSRFToken on same-origin POST/PUT/PATCH/DELETE requests, reading the token from the session store with a csrftoken cookie fallback. getCsrfHeaders() covers transports that bypass axios.
  • Attach it in useAxiosPrivate (at instance creation) and to the global axios in App.jsx.
  • Remove every hand-written X-CSRFToken header in OSS (~110), plus the csrfToken locals and hook deps that only existed to feed them.
  • Move the TopNavBar org switch from raw axios onto axiosPrivate.
  • Document the intentional raw-axios callers (pre-session bootstrap: useSessionValid, SetOrg, FeatureFlagsData; and socket-logs-store, which also stops swallowing errors silently).
  • Add an orgApi(path) helper for /api/v1/unstract/<orgId>/… URLs, used in the files rewritten here.

Why

  • UN-4186 (epic UN-4182). The CSRF header was added by hand at ~220 call sites across OSS and cloud, so any new call that forgot it failed with a 403, and raw axios/fetch calls bypassed the 401 → logout handling.

How

  • The interceptor only acts on unsafe methods and same-origin URLs, so the token never leaves the origin. It reads the token per request and never overwrites a header the caller already set.
  • It is attached inside useMemo rather than the existing effect: a child handed axiosPrivate can fire a request from its own mount effect, which runs before the parent's effect.
  • The global attach in App.jsx mirrors the existing request-id interceptor, so the few calls that must stay on raw axios still get CSRF without hand-written headers.
  • The Upload shim's headers prop (Manage Documents) uses getCsrfHeaders(), since that path is fetch.
  • evaluateFeatureFlag / listFlags drop their csrfToken parameter; the only caller is updated and evaluateFeatureFlag has none.

Can this PR break any existing features. If yes, please list possible items. If no, please explain why. (PS: Admins do not merge the PR without this section filled)

  • Merge this before the paired cloud PR. The cloud PR removes the plugins' hand-written headers and imports helpers/csrf.js, so it depends on this. This PR is safe on its own: cloud plugins still send their own header, and the interceptor never overwrites it.
  • TopNavBar's org switch now uses axiosPrivate, so a 401 there logs the user out instead of only showing an alert. That is the intended behaviour for an expired session.
  • Otherwise no: every mutating request still carries the token, verified in the browser (see testing).

Database Migrations

  • None

Env Config

  • None

Relevant Docs

Related Issues or PRs

Dependencies Versions

  • None

Notes on Testing

  • New unit tests: csrf.test.js (22) and orgApi.test.js (3). OSS only: vitest 645/645 and bun run build pass.
  • With cloud plugins copied in: build passes; vitest 770/772. The 2 failures are in cascade-and-affordances.test.jsx and also fail on main (plugin CSS and existing defaultProps).
  • Browser, dev namespace via DevSpace (both branches synced): x-csrftoken present and 2xx on the fresh-login org set (including after Django rotated the token on login), socket-log POST, Prompt Studio project create / prompt create / prompt PATCH, PDF upload through Manage Documents, the full "Deploy as API" chain (export, workflow, endpoints, tool instance, api/deployment/ 201), and DELETE of deployment, workflow and project. GETs correctly carry no header.

Screenshots

  • N/A

Checklist

I have read and understood the Contribution Guidelines.

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.
@greptile-apps

greptile-apps Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

via Greptile

RetriggerConfidence Score: 5/5

[High risk] Centralizes CSRF token handling through a shared axios interceptor.

The PR appears safe to merge based on the changes reviewed.

Summary

The PR moves CSRF headers from individual frontend requests into interceptors, adds a helper for the non-Axios upload path, and routes the signed-in organization switch through useAxiosPrivate.

  • The update since the previous review accounts for external baseURL values when deciding whether to attach the token.
  • No new actionable issue was established.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Frontend request] --> B{Axios?}
  B -->|Yes| C[CSRF request interceptor]
  B -->|Upload fetch| D[getCsrfHeaders]
  C --> E{Unsafe method and same origin?}
  E -->|Yes| F[Read current token and set header if missing]
  E -->|No| G[Leave request unchanged]
  D --> H[Read current token for upload headers]
Loading

Reviews (2) · Last reviewed commit: "UN-4186 Respect axios baseURL in the CSR..."

Comment thread frontend/src/helpers/csrf.js Outdated
…lobal 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.
@github-actions

Copy link
Copy Markdown
Contributor

Frontend Lint Report (Biome)

✅ All checks passed! No linting or formatting issues found.

@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
✅ e2e-api-deployment e2e 3 0 0 0 8.3
✅ e2e-coowners e2e 1 0 0 0 1.3
✅ e2e-etl e2e 1 0 0 0 8.5
✅ e2e-login e2e 2 0 0 0 1.2
✅ e2e-prompt-studio e2e 1 0 0 0 9.6
✅ e2e-smoke e2e 2 0 0 0 1.3
✅ e2e-workflow e2e 1 0 0 0 10.5
❌ ui e2e 0 1 0 0 0.0
TOTAL 11 1 0 0 40.8

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
💤 Covered, but not exercised in this build
  • adapter-register-llm — Register and validate an LLM adapter. (covered by integration-backend; no result reported in this build)
  • workflow-author — Create a workflow; its source+destination endpoints materialise and are configurable. (covered by integration-backend; no result reported in this build)
  • api-deployment-provision — Deploying a workflow as an API mints a usable key and a resolvable endpoint. (covered by integration-backend; no result reported in this build)
  • api-deployment-auth — Unauthenticated or mis-scoped API-deployment calls are rejected before dispatch. (covered by integration-backend; no result reported in this build)
  • mcp-server-auth — Unauthenticated or mis-scoped hosted-MCP calls are rejected before any tool runs. (covered by integration-backend; no result reported in this build)
  • mcp-platform-auth — The org-scoped MCP endpoint stays behind the platform-API-key middleware; unauthenticated or mis-scoped calls reach no tool. (covered by integration-backend; no result reported in this build)
  • platform-key-whoami — A platform API key resolves its own organisation over the org-less whoami endpoint; the org comes from the key row, not the URL. (covered by integration-backend; no result reported in this build)
  • prompt-studio-author — Create a Prompt Studio project and add a prompt to it. (covered by integration-backend; no result reported in this build)
  • connector-register-test — Connector credentials are validated against the live system and stored encrypted. (covered by integration-backend; no result reported in this build)
  • usage-aggregate-read — Per-run token usage aggregates correctly and stays scoped to its organization. (covered by integration-backend; no result reported in this build)
✅ Covered critical paths
  • auth-login — covered by e2e-login
  • co-owner-manage — covered by e2e-coowners
  • workflow-create-execute — covered by e2e-workflow
  • api-deployment-run — covered by e2e-api-deployment
  • prompt-studio-fetch-response — covered by e2e-prompt-studio
  • pipeline-etl-execute — covered by e2e-etl
  • usage-token-tracking — covered by e2e-api-deployment
  • callback-result-delivery — covered by e2e-api-deployment

@vishnuszipstack vishnuszipstack left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the interceptor design and swept both repos for call sites that could have lost their token. No security or correctness defects found — this is solid work. A few cleanups inline, plus one test-coverage gap worth closing before merge.

Verified rather than assumed

  • Interceptor semantics against the pinned axios 1.16, read out of node_modules rather than from memory: Axios.prototype.request lowercases config.method and converts config.headers to an AxiosHeaders before building the interceptor chain (axios.cjs:4948, :4955), and AxiosHeaders.set(name, value, false) only writes when the key is absent. So both load-bearing claims — "method is always present and lowercase" and "never overwrite a caller's header" — hold in production, not just in the tests.
  • Nothing lost its token. Exactly one axios.create() in prod code, intercepted at creation. Every remaining raw-axios call site in OSS and cloud resolves to a relative path, so the same-origin guard can never skip a real request. The only two non-axios transports — the Upload shim's action fetch here, and the lookup draft keepalive PATCH in the cloud PR — both use getCsrfHeaders().
  • The same-origin guard can't misfire: getBaseUrl() is window.location.origin unconditionally, and there is no axios.defaults.baseURL anywhere in the tree.
  • The cookie fallback is genuinely readable: CSRF_COOKIE_HTTPONLY and CSRF_USE_SESSIONS are both at Django defaults, so Cookies.get("csrftoken") really does work during bootstrap, as the comment claims.
  • vitest 645/645 passes on this branch, matching the PR notes. Biome is net −5 diagnostics vs. the PR base with no new unused variables — the single exception is ConnectorsPage.jsx, see the inline comment.
  • Merge order is right, and the window between the two merges is safe: cloud main's hand-written headers keep working against OSS-with-interceptor precisely because the interceptor doesn't overwrite.

One design note — no action needed to merge

getCsrfToken() prefers sessionDetails.csrfToken over the live cookie. That value is captured at useSessionValid.js:105, before the POST /organization/{id}/set, so if the backend ever rotates the token in that request the store holds a stale value that now wins over the fresh cookie.

This is not a regression — every hand-written header read the same stale store value, and useLogout does a full page reload, so it can't leak across sessions. But the cookie is the value Django actually compares against, so cookie-first (store as fallback) would be strictly more robust, and would let you drop the csrfToken plumbing from sessionDetails entirely. Related: axios 1.16 ships xsrfCookieName / xsrfHeaderName / withXSRFToken, which would cover most of helpers/csrf.js — but that's only worth a look if you go cookie-first, since the built-in never consults the store.

Follow-up ticket, not this PR

orgApi() is now the documented way to build org-scoped URLs, but the paired cloud PR (Zipstack/unstract-cloud#1805) touches ~25 files that still hand-roll /api/v1/unstract/${orgId}/…. The dev guide is ahead of the code — worth a ticket so it doesn't drift.

} from "./csrf";

const runRequestInterceptors = async (instance, config = {}) => {
let current = { ...config, headers: { ...config.headers } };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The security-relevant assertion doesn't cover the branch that actually runs.

runRequestInterceptors builds headers as a plain object, so all 22 tests take the !headers.set fallback in setHeaderIfMissing. In production axios has already converted config.headers to an AxiosHeaders before request interceptors run (Axios.prototype.request does config.headers = AxiosHeaders.concat(...), axios.cjs:4955), so the live path is headers.set(CSRF_HEADER, value, false) — currently untested. That includes "does not overwrite a caller-supplied token", which is the one behaviour most worth locking down here.

I read AxiosHeaders.set in the pinned 1.16 and the rewrite === false semantics are correct, so this is a coverage gap rather than a bug. Suggestion:

import axios, { AxiosHeaders } from "axios";
// ...
let current = { ...config, headers: new AxiosHeaders(config.headers) };

Note the assertions need to change with it — AxiosHeaders normalises keys, so result.headers[CSRF_HEADER] reads undefined and you'd want result.headers.get(CSRF_HEADER). Keeping one explicit plain-object test alongside would cover both branches.

axiosPrivate.get(getUrl(`connector/users/${id}/`), {
headers: { "X-CSRFToken": sessionDetails?.csrfToken },
}),
axiosPrivate.get(getUrl(`connector/users/${id}/`), {}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leftover empty config object from the mechanical edit — the argument does nothing now. Four sites in this file: here, line 68, line 188, and line 244 (which becomes post(url, updateData, {})). Worth dropping so the next reader doesn't wonder what config was intended.

axiosPrivate.delete(getUrl(`connector/${id}/owners/${userId}/`), {}),
}),
[sessionDetails?.csrfToken],
[],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This went from [sessionDetails?.csrfToken] to [], but the closure still captures axiosPrivate and getUrl.

It's the one file where Biome got worse on this branch: useExhaustiveDependencies for ConnectorsPage.jsx goes 4 → 6 diagnostics vs. the PR base (I diffed the full biome check src output both ways — everything else is net −5). [axiosPrivate, getUrl] is both more correct and quieter.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants