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); } diff --git a/web/__test__/components/ApiStatus.test.ts b/web/__test__/components/ApiStatus.test.ts new file mode 100644 index 0000000000..9900dbba69 --- /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'; + +import ApiStatus from '~/components/ApiStatus/ApiStatus.vue'; + +const mockWebguiUnraidApiCommand = vi.fn(); + +vi.mock('~/composables/services/webgui', () => ({ + WebguiUnraidApiCommand: (...args: unknown[]) => mockWebguiUnraidApiCommand(...args), +})); + +const mockServerStore = { csrf: '' }; +vi.mock('~/store/server', () => ({ + useServerStore: () => mockServerStore, +})); + +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) => {