From f36c74a91104826b2f837c5e63795f47bf8eba2d Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 13 Jul 2026 23:55:05 -0400 Subject: [PATCH 1/3] fix(web): send CSRF token on fetch-based webGUI requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/__test__/components/ApiStatus.test.ts | 83 ++++++++++++++++++++++ web/__test__/composables/request.test.ts | 55 ++++++++++++++ web/src/components/ApiStatus/ApiStatus.vue | 12 +++- web/src/composables/services/request.ts | 21 ++++++ 4 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 web/__test__/components/ApiStatus.test.ts create mode 100644 web/__test__/composables/request.test.ts diff --git a/web/__test__/components/ApiStatus.test.ts b/web/__test__/components/ApiStatus.test.ts new file mode 100644 index 0000000000..62212ea2cd --- /dev/null +++ b/web/__test__/components/ApiStatus.test.ts @@ -0,0 +1,83 @@ +/** + * ApiStatus Component Test Coverage + * + * Regression coverage for the "wrong csrf_token" bug: the on-mount status + * check must use the page-global csrf_token when the server store has not yet + * hydrated its own `csrf` value, otherwise it sends a blank token and both + * fails the status read and logs a red error in syslog. + */ + +import { flushPromises, mount } from '@vue/test-utils'; + +import { createTestingPinia } from '@pinia/testing'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockWebguiUnraidApiCommand = vi.fn(); + +vi.mock('~/composables/services/webgui', () => ({ + WebguiUnraidApiCommand: (...args: unknown[]) => mockWebguiUnraidApiCommand(...args), +})); + +const mockServerStore = { csrf: '' }; +vi.mock('~/store/server', () => ({ + useServerStore: () => mockServerStore, +})); + +import ApiStatus from '~/components/ApiStatus/ApiStatus.vue'; + +const mountComponent = () => + mount(ApiStatus, { + global: { + plugins: [createTestingPinia({ createSpy: vi.fn })], + }, + }); + +describe('ApiStatus.vue', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockServerStore.csrf = ''; + mockWebguiUnraidApiCommand.mockResolvedValue({ result: 'API is running' }); + globalThis.csrf_token = 'global-token-123'; + }); + + afterEach(() => { + globalThis.csrf_token = ''; + }); + + it('sends the page-global csrf_token on mount when the store has not hydrated', async () => { + mountComponent(); + await flushPromises(); + + expect(mockWebguiUnraidApiCommand).toHaveBeenCalledWith({ + csrf_token: 'global-token-123', + command: 'status', + }); + }); + + it('never sends a blank csrf_token on mount', async () => { + mountComponent(); + await flushPromises(); + + const payload = mockWebguiUnraidApiCommand.mock.calls[0]?.[0]; + expect(payload.csrf_token).toBeTruthy(); + }); + + it('prefers the store csrf value once it is populated', async () => { + mockServerStore.csrf = 'store-token-456'; + mountComponent(); + await flushPromises(); + + expect(mockWebguiUnraidApiCommand).toHaveBeenCalledWith({ + csrf_token: 'store-token-456', + command: 'status', + }); + }); + + it('renders Running when the status result reports the service is running', async () => { + const wrapper = mountComponent(); + await flushPromises(); + + expect(wrapper.text()).toContain('Running'); + expect(wrapper.text()).not.toContain('Not Running'); + }); +}); diff --git a/web/__test__/composables/request.test.ts b/web/__test__/composables/request.test.ts new file mode 100644 index 0000000000..d9a97a38e0 --- /dev/null +++ b/web/__test__/composables/request.test.ts @@ -0,0 +1,55 @@ +/** + * request composable — global CSRF header attachment + * + * The webGUI CSRF gate rejects same-origin POSTs that lack a valid token. The + * shared wretch instance must attach the page-global csrf_token as an + * `x-csrf-token` header automatically (and only for same-origin requests). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { request } from '~/composables/services/request'; + +const okResponse = () => + new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + +describe('request csrf attachment', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn().mockResolvedValue(okResponse()); + vi.stubGlobal('fetch', fetchMock); + globalThis.csrf_token = 'token-abc'; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + globalThis.csrf_token = ''; + }); + + const headerFromCall = (index = 0) => { + const opts = fetchMock.mock.calls[index]?.[1] ?? {}; + return new Headers(opts.headers).get('x-csrf-token'); + }; + + it('attaches the page-global token on same-origin requests', async () => { + await request.url('/plugins/dynamix.my.servers/include/unraid-api.php').post(); + expect(headerFromCall()).toBe('token-abc'); + }); + + it('attaches the token on same-origin GET requests too', async () => { + await request.url('/plugins/dynamix.my.servers/data/server-state.php').get(); + expect(headerFromCall()).toBe('token-abc'); + }); + + it('does not attach the token to cross-origin requests', async () => { + await request.url('https://wanip4.unraid.net/').get(); + expect(headerFromCall()).toBeNull(); + }); + + it('attaches no header when the page global is unset', async () => { + globalThis.csrf_token = ''; + await request.url('/webGui/include/Notify.php').post(); + expect(headerFromCall()).toBeNull(); + }); +}); diff --git a/web/src/components/ApiStatus/ApiStatus.vue b/web/src/components/ApiStatus/ApiStatus.vue index 7da852d439..d0321c75bd 100644 --- a/web/src/components/ApiStatus/ApiStatus.vue +++ b/web/src/components/ApiStatus/ApiStatus.vue @@ -6,6 +6,14 @@ import { useServerStore } from '~/store/server'; const serverStore = useServerStore(); +/** + * The webGUI embeds `csrf_token` as a page global and validates every plugin + * request against it. Prefer that synchronously-available value; the server + * store's `csrf` is only populated after async state hydration, so relying on + * it in onMounted sends a blank token and trips a "wrong csrf_token" log error. + */ +const resolveCsrfToken = () => serverStore.csrf || globalThis.csrf_token || ''; + const apiStatus = ref(''); const isRunning = ref(false); const isLoading = ref(false); @@ -18,7 +26,7 @@ const checkStatus = async () => { statusMessage.value = ''; try { const response = await WebguiUnraidApiCommand({ - csrf_token: serverStore.csrf, + csrf_token: resolveCsrfToken(), command: 'status', }); @@ -53,7 +61,7 @@ const restartApi = async () => { try { const response = await WebguiUnraidApiCommand({ - csrf_token: serverStore.csrf, + csrf_token: resolveCsrfToken(), command: 'restart', }); diff --git a/web/src/composables/services/request.ts b/web/src/composables/services/request.ts index d145884849..22bbddec13 100644 --- a/web/src/composables/services/request.ts +++ b/web/src/composables/services/request.ts @@ -5,11 +5,32 @@ import queryString from 'wretch/addons/queryString'; import { useErrorsStore } from '~/store/errors'; +/** + * 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; + } +}; + export const request = wretch() .addon(formData) .addon(formUrl) .addon(queryString) .errorType('json') + .defer((w, url) => { + const token = globalThis.csrf_token; + return token && isSameOriginRequest(url) ? w.headers({ 'x-csrf-token': token }) : w; + }) .resolve((response) => { return response .error('Error', (error) => { From 3be5751d88f7dd025bd9d89de7e37c09c8bdac1b Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 14 Jul 2026 09:23:29 -0400 Subject: [PATCH 2/3] fix(unraid-ui): resolve Accordion.vue type errors blocking the web build 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. --- unraid-ui/src/components/common/accordion/Accordion.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/unraid-ui/src/components/common/accordion/Accordion.vue b/unraid-ui/src/components/common/accordion/Accordion.vue index a7fdeced85..2b8bb0b38a 100644 --- a/unraid-ui/src/components/common/accordion/Accordion.vue +++ b/unraid-ui/src/components/common/accordion/Accordion.vue @@ -5,7 +5,7 @@ import { AccordionRoot, AccordionTrigger, } from '@/components/ui/accordion'; -import { computed, ref, watch } from 'vue'; +import { ref, watch } from 'vue'; export interface AccordionItemData { value: string; @@ -31,7 +31,7 @@ const props = withDefaults(defineProps(), { }); const emit = defineEmits<{ - 'update:modelValue': [value: string | string[]]; + 'update:modelValue': [value: string | string[] | undefined]; }>(); const openValue = ref(props.modelValue ?? props.defaultValue); @@ -49,7 +49,7 @@ function isItemOpen(itemValue: string): boolean { return openValue.value === itemValue; } -function handleUpdate(value: string | string[]) { +function handleUpdate(value: string | string[] | undefined) { openValue.value = value; emit('update:modelValue', value); } From f1592eecd3548ed67330b91bdc94ce2f1c17f5d4 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 14 Jul 2026 09:28:05 -0400 Subject: [PATCH 3/3] style(web): apply prettier to ApiStatus.test.ts import order --- web/__test__/components/ApiStatus.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/__test__/components/ApiStatus.test.ts b/web/__test__/components/ApiStatus.test.ts index 62212ea2cd..9900dbba69 100644 --- a/web/__test__/components/ApiStatus.test.ts +++ b/web/__test__/components/ApiStatus.test.ts @@ -12,6 +12,8 @@ import { flushPromises, mount } from '@vue/test-utils'; import { createTestingPinia } from '@pinia/testing'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import ApiStatus from '~/components/ApiStatus/ApiStatus.vue'; + const mockWebguiUnraidApiCommand = vi.fn(); vi.mock('~/composables/services/webgui', () => ({ @@ -23,8 +25,6 @@ vi.mock('~/store/server', () => ({ useServerStore: () => mockServerStore, })); -import ApiStatus from '~/components/ApiStatus/ApiStatus.vue'; - const mountComponent = () => mount(ApiStatus, { global: {