Skip to content

fix(web): send CSRF token on fetch-based webGUI requests#2039

Open
elibosley wants to merge 3 commits into
mainfrom
fix/api-status-csrf-token
Open

fix(web): send CSRF token on fetch-based webGUI requests#2039
elibosley wants to merge 3 commits into
mainfrom
fix/api-status-csrf-token

Conversation

@elibosley

@elibosley elibosley commented Jul 14, 2026

Copy link
Copy Markdown
Member

Problem

Settings → Management Access → Unraid API Status shows Not Running on first page load even though the API is running, and syslog logs a red error on every load:

webGUI: error: /plugins/dynamix.my.servers/include/unraid-api.php - wrong csrf_token

Pressing Refresh Status flips it to Running, but revisiting the page reproduces both the false status and the log error. Reported independently by multiple users on the 7.3.2 forum thread.

Root cause

ApiStatus.vue runs its status check in onMounted using serverStore.csrf. That store value starts empty ('') and is only populated after async server-state hydration completes. The webGUI's CSRF gate (local_prepend.php) validates every same-origin POST against $var['csrf_token'].

The previous jQuery-based status call got its token auto-injected by the webGUI's $.ajaxPrefilter. The new fetch/wretch client injects nothing, so the first-paint POST carried a blank token. The gate rejected it, logged the error, and exited before unraid-api.php ran — so the response was empty and the panel rendered "Not Running". By the time the user clicks Refresh, hydration has populated the token, so it succeeds.

This is not a webGUI CSRF regression — a blank token was always rejected. The trigger is the status panel moving from jQuery (auto-CSRF) to fetch (no auto-CSRF).

Fix

  • ApiStatus.vue — resolve the token from the synchronously-available page global globalThis.csrf_token (same source Apollo and onboarding already use), falling back to the store.
  • request.ts — attach x-csrf-token on every same-origin request from the shared wretch client, mirroring what Apollo and $.ajaxPrefilter already do, so no fetch-based webGUI caller has to remember the token. Same-origin only, so the token is never sent to external hosts.

Tests

  • __test__/components/ApiStatus.test.ts — global token used on mount when the store is empty, never-blank token, store preferred once hydrated, renders "Running".
  • __test__/composables/request.test.ts — header attached on same-origin GET/POST, not attached cross-origin, not attached when the page global is unset.

Local: eslint clean, vue-tsc clean, full web suite green.

Note

There is a companion webGUI-side PR (returns an explicit 403 on CSRF rejection instead of a silent empty 200, so a blocked read can't masquerade as "service down"). The two are independent; this client-side change alone removes the red log and fixes first paint.

Summary by CodeRabbit

  • Bug Fixes
    • Improved CSRF token resolution for API status checks and restart requests, ensuring a valid token is used on mount.
    • CSRF request headers are now attached only for same-origin requests and when a page-level CSRF token is available.
    • Accordion update:modelValue now supports undefined values.
  • Tests
    • Added Vitest coverage for CSRF header attachment in the request layer.
    • Added Vitest coverage for ApiStatus CSRF handling and “Running” status rendering.

The API Status panel (Settings → Management Access) rendered "Not Running"
on first paint and logged a red `wrong csrf_token` error in syslog, even
though the API was running. Pressing "Refresh Status" worked, but revisiting
the page reproduced it.

Root cause: ApiStatus.vue runs its status check in onMounted using
`serverStore.csrf`, which is empty until async server-state hydration
completes. The old jQuery status call got its token auto-injected by the
webGUI's `$.ajaxPrefilter`; the new fetch/wretch client injects nothing, so
the first-paint POST carried a blank token. local_prepend.php rejected it,
logged the error, and exited before unraid-api.php ran — so the response was
empty and the panel showed "Not Running".

- ApiStatus.vue: resolve the token from the synchronously-available page
  global (`globalThis.csrf_token`) with the store as fallback.
- request.ts: attach `x-csrf-token` on every same-origin request from the
  shared wretch client (mirroring Apollo and `$.ajaxPrefilter`), so no
  fetch-based webGUI caller has to remember the token. Same-origin only, so
  the token is never sent to external hosts.

Adds tests for both.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 845974be-3de6-4009-b328-22cafd9166fd

📥 Commits

Reviewing files that changed from the base of the PR and between 3be5751 and f1592ee.

📒 Files selected for processing (1)
  • web/__test__/components/ApiStatus.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • web/test/components/ApiStatus.test.ts

Walkthrough

ApiStatus now resolves CSRF tokens from server-store or page-global state, while the request composable adds tokens to same-origin requests. New Vitest suites verify token selection, header gating, and running-status rendering. Accordion model updates now accept undefined.

Changes

CSRF propagation

Layer / File(s) Summary
Same-origin CSRF header injection
web/src/composables/services/request.ts, web/__test__/composables/request.test.ts
Same-origin requests receive x-csrf-token from globalThis.csrf_token; cross-origin and empty-token cases remain unmodified and are tested.
ApiStatus token resolution
web/src/components/ApiStatus/ApiStatus.vue, web/__test__/components/ApiStatus.test.ts
Status and restart requests resolve tokens from server-store or page-global state, with tests covering fallback, preference, blank-token prevention, and “Running” output.

Accordion typing

Layer / File(s) Summary
Optional Accordion model updates
unraid-ui/src/components/common/accordion/Accordion.vue
The update:modelValue event and update handler now accept undefined, and the unused computed import is removed.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ApiStatus
  participant request
  participant fetch
  ApiStatus->>request: Initiate API request
  request->>request: Check target origin
  request->>fetch: Add x-csrf-token for same-origin request
Loading

Poem

I’m a rabbit with a token to spare,
CSRF hops safely through the air.
Store or page, the choice is bright,
Same-origin headers guard the byte.
Tests thump paws: “Running!” tonight!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding CSRF token handling for fetch-based webGUI requests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/api-status-csrf-token

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 68.75000% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.90%. Comparing base (2679fda) to head (f1592ee).

Files with missing lines Patch % Lines
...d-ui/src/components/common/accordion/Accordion.vue 0.00% 2 Missing ⚠️
web/src/composables/services/request.ts 81.81% 2 Missing ⚠️
web/src/components/ApiStatus/ApiStatus.vue 66.66% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2039      +/-   ##
==========================================
+ Coverage   52.77%   52.90%   +0.13%     
==========================================
  Files        1035     1035              
  Lines       72060    72071      +11     
  Branches     8303     8318      +15     
==========================================
+ Hits        38031    38132     +101     
+ Misses      33903    33812      -91     
- Partials      126      127       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@web/src/composables/services/request.ts`:
- Around line 8-23: Update isSameOriginRequest so its catch branch returns false
when URL parsing or location resolution fails. Preserve the existing same-origin
comparison for successfully parsed URLs, ensuring CSRF tokens are not attached
when the request origin cannot be verified.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 1bae1867-cbc9-4607-a4e2-282bcf9ff2e3

📥 Commits

Reviewing files that changed from the base of the PR and between 2679fda and f36c74a.

📒 Files selected for processing (4)
  • web/__test__/components/ApiStatus.test.ts
  • web/__test__/composables/request.test.ts
  • web/src/components/ApiStatus/ApiStatus.vue
  • web/src/composables/services/request.ts

Comment on lines +8 to +23
/**
* The webGUI CSRF gate (local_prepend.php) validates same-origin POSTs against
* `$var['csrf_token']`, accepting the token via an `x-csrf-token` header for
* XHR/fetch callers. jQuery callers get this for free via `$.ajaxPrefilter`, and
* the Apollo client already sets the header itself — attach it here so every
* fetch-based webGUI request is covered too, rather than each caller having to
* remember to pass the token. Restricted to same-origin requests so the token is
* never sent to external hosts.
*/
const isSameOriginRequest = (url: string): boolean => {
try {
return new URL(url, globalThis.location?.href).origin === globalThis.location?.origin;
} catch {
return true;
}
};

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.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the target file and inspect nearby context.
ast-grep outline web/src/composables/services/request.ts --view expanded || true
echo '--- FILE ---'
cat -n web/src/composables/services/request.ts | sed -n '1,220p'

echo '--- SEARCH CALLERS / URL SHAPES ---'
rg -n "isSameOriginRequest|csrf_token|x-csrf-token|new URL\(" web/src -S

Repository: unraid/api

Length of output: 7783


🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-files 'web/src/composables/services/request.ts' 'web/src/**' | sed -n '1,20p'
echo '---'
wc -l web/src/composables/services/request.ts
echo '---'
cat -n web/src/composables/services/request.ts | sed -n '1,220p'

Repository: unraid/api

Length of output: 3333


🏁 Script executed:

node - <<'JS'
const cases = [
  ['relative with base href', ['/foo', 'https://example.com/app/']],
  ['absolute', ['https://api.example.com/x', 'https://example.com/app/']],
  ['relative no base', ['/foo', undefined]],
  ['bad url', ['http://[bad', 'https://example.com/app/']],
  ['empty string', ['', 'https://example.com/app/']],
  ['non-string', [null, 'https://example.com/app/']],
];
for (const [label, [u, b]] of cases) {
  try {
    const r = new URL(u, b);
    console.log(label, '=>', r.origin, r.href);
  } catch (e) {
    console.log(label, '=> THROW', e.message);
  }
}
JS

Repository: unraid/api

Length of output: 472


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "from ['\"].*composables/services/request['\"]|from ['\"]~/composables/services/request['\"]|request\." web/src -S

Repository: unraid/api

Length of output: 1525


Fail closed on parse errors. isSameOriginRequest already blocks absolute external URLs, but the catch still treats malformed or SSR/non-browser URLs as same-origin and attaches x-csrf-token. Return false there to keep the “never sent to external hosts” rule intact.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/composables/services/request.ts` around lines 8 - 23, Update
isSameOriginRequest so its catch branch returns false when URL parsing or
location resolution fails. Preserve the existing same-origin comparison for
successfully parsed URLs, ensuring CSRF tokens are not attached when the request
origin cannot be verified.

The `Build Web App` CI step (strict vue-tsc) failed on two pre-existing type
errors in the Accordion component, which blocked artifact/plugin builds for
PRs:

- `computed` was imported but never used (TS6133).
- The `update:modelValue` handler did not accept `undefined`, but
  AccordionRoot emits `string | string[] | undefined` (TS2322).

Drop the unused import and widen the emit/handler to include `undefined`,
which reflects the real "nothing selected" model value.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@unraid-ui/src/components/common/accordion/Accordion.vue`:
- Line 34: Update the watcher around openValue to propagate externally cleared
modelValue values, including undefined, instead of ignoring them. Ensure both
the single-value and array-value branches assign the incoming undefined so
openValue is cleared and the accordion closes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 6fd3a370-5d36-4d92-bea4-e91cd1983e32

📥 Commits

Reviewing files that changed from the base of the PR and between f36c74a and 3be5751.

📒 Files selected for processing (1)
  • unraid-ui/src/components/common/accordion/Accordion.vue


const emit = defineEmits<{
'update:modelValue': [value: string | string[]];
'update:modelValue': [value: string | string[] | undefined];

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Synchronize externally cleared model values.

Now that undefined is a valid update payload, the watcher at Line [42] must also assign undefined. Otherwise, changing a controlled modelValue from a selected value to undefined leaves openValue stale and the accordion remains open.

   (val) => {
-    if (val !== undefined) openValue.value = val;
+    openValue.value = val;
   }

Also applies to: 52-52

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@unraid-ui/src/components/common/accordion/Accordion.vue` at line 34, Update
the watcher around openValue to propagate externally cleared modelValue values,
including undefined, instead of ignoring them. Ensure both the single-value and
array-value branches assign the incoming undefined so openValue is cleared and
the accordion closes.

@github-actions

Copy link
Copy Markdown
Contributor

🚀 Storybook has been deployed to staging: https://unraid-ui-storybook-staging.unraid-workers.workers.dev

@github-actions

Copy link
Copy Markdown
Contributor

🚀 Storybook has been deployed to staging: https://unraid-ui-storybook-staging.unraid-workers.workers.dev

@github-actions

Copy link
Copy Markdown
Contributor

This plugin has been deployed to Cloudflare R2 and is available for testing.
Download it at this URL:

https://preview.dl.unraid.net/unraid-api/tag/PR2039/dynamix.unraid.net.plg

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.

1 participant