diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a158aa8b9..b87da0075a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1154,8 +1154,8 @@ importers: specifier: 3.0.7 version: 3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) '@unraid/shared-callbacks': - specifier: 3.1.0 - version: 3.1.0 + specifier: 3.4.0 + version: 3.4.0 '@unraid/ui': specifier: link:../unraid-ui version: link:../unraid-ui @@ -4973,8 +4973,8 @@ packages: cpu: [x64, arm64] os: [linux, darwin] - '@unraid/shared-callbacks@3.1.0': - resolution: {integrity: sha512-Zvz9nlvLSjbTstplvarbH2rwZrsl4ns7djpDmqPi4xH35PvYeK9ut1A/Hkd2Rbl/+nvAMF77d5ZzZkSTb4IoSg==} + '@unraid/shared-callbacks@3.4.0': + resolution: {integrity: sha512-9tCVlt+nFK9W/ZdwL+nBS8Mzc5RhoVeMJ+6y6okATB/rLJZMlgGxiU2K5sCo88Vn98FRa9TNHII/oMxp/23lGw==} '@unraid/tailwind-rem-to-rem@2.0.0': resolution: {integrity: sha512-zccpQx5fvEBkAB0JkRwwtyRrT9l26LsjkozLy44LGv0NdZGaxgscniIqJRM+OQj5pSpsWDzExebAtUKdE98Flg==} @@ -16269,7 +16269,7 @@ snapshots: - encoding - supports-color - '@unraid/shared-callbacks@3.1.0': + '@unraid/shared-callbacks@3.4.0': dependencies: crypto-js: 4.2.0 diff --git a/web/__test__/store/callbackActions.test.ts b/web/__test__/store/callbackActions.test.ts index d0e3ec958c..533651b667 100644 --- a/web/__test__/store/callbackActions.test.ts +++ b/web/__test__/store/callbackActions.test.ts @@ -36,13 +36,32 @@ const mockRefreshServerStateStatus = ref<'done' | 'ready' | 'refreshing' | 'time const mockInstall = vi.fn(); const mockRefreshServerState = vi.fn(); -vi.mock('@unraid/shared-callbacks', () => { +vi.mock('@unraid/shared-callbacks/client', () => { const mockWatcher = vi.fn(); - const mockSend = vi.fn(); + const mockSend = vi.fn( + ( + url: string, + payload: unknown, + redirectType: string | null | undefined, + sendType: string | undefined, + sender?: string, + nonce?: string + ) => ({ url, payload, redirectType, sendType, sender, nonce }) + ); + const mockGenerateUrl = vi.fn( + (url: string, payload: unknown, sendType: string | undefined, sender?: string, nonce?: string) => ({ + url, + payload, + sendType, + sender, + nonce, + }) + ); return { - useCallback: vi.fn(({ encryptionKey: _encryptionKey }) => ({ + createCallback: vi.fn(({ encryptionKey: _encryptionKey }) => ({ send: mockSend, + generateUrl: mockGenerateUrl, watcher: mockWatcher, parse: vi.fn(), })), @@ -146,9 +165,13 @@ describe('Callback Actions Store', () => { let store: ReturnType; let preventClose: { addPreventClose: Mock; removePreventClose: Mock }; let mockWatcher: Mock; + let mockSend: Mock; + let mockGenerateUrl: Mock; beforeEach(async () => { setActivePinia(createPinia()); + localStorage.clear(); + window.history.replaceState(null, '', '/'); mockAccountActionStatus.value = 'success'; mockKeyInstallStatus.value = 'success'; mockRefreshServerStateStatus.value = 'done'; @@ -161,9 +184,12 @@ describe('Callback Actions Store', () => { removePreventClose: vi.mocked(preventCloseModule.removePreventClose), }; - const { useCallback } = await import('@unraid/shared-callbacks'); + const { createCallback } = await import('@unraid/shared-callbacks/client'); - mockWatcher = vi.mocked(useCallback).mock.results[0].value.watcher; + const callbackMock = vi.mocked(createCallback).mock.results[0].value; + mockWatcher = callbackMock.watcher; + mockSend = callbackMock.send; + mockGenerateUrl = callbackMock.generateUrl; vi.clearAllMocks(); }); @@ -213,6 +239,186 @@ describe('Callback Actions Store', () => { }); }); + describe('Callback Return Paths', () => { + it('passes a dedicated nonce and cleans the default return path', () => { + window.history.replaceState(null, '', '/Tools/Update'); + + store.send( + 'https://example.com/callback', + [{ type: 'updateOs', sha256: 'test-sha256' } as ExternalUpdateOsAction], + undefined, + 'forUpc' + ); + + const sender = mockSend.mock.calls[0][4] as string; + const senderUrl = new URL(sender); + const nonce = mockSend.mock.calls[0][5] as string; + + expect(senderUrl.pathname).toBe('/Tools'); + expect(senderUrl.searchParams.get('data')).toBeNull(); + expect(senderUrl.searchParams.has('callback_nonce')).toBe(false); + expect(nonce).toMatch(/^[0-9a-f]{32}$/); + expect(localStorage.getItem(`unraid-callback-nonce:${nonce}`)).toBeTruthy(); + }); + + it('preserves explicit return details without adding a nonce parameter', () => { + const sender = 'https://example.com/return?state=ready#section'; + + store.send( + 'https://example.com/callback', + [{ type: 'updateOs', sha256: 'test-sha256' } as ExternalUpdateOsAction], + undefined, + 'forUpc', + sender + ); + + const returnedUrl = new URL(mockSend.mock.calls[0][4] as string); + + expect(returnedUrl.origin).toBe('https://example.com'); + expect(returnedUrl.pathname).toBe('/return'); + expect(returnedUrl.searchParams.get('state')).toBe('ready'); + expect(returnedUrl.hash).toBe(''); + expect(returnedUrl.searchParams.has('callback_nonce')).toBe(false); + expect(mockSend.mock.calls[0][5]).toMatch(/^[0-9a-f]{32}$/); + }); + + it('passes a dedicated nonce when creating a return link', () => { + const payload = [{ type: 'updateOs', sha256: 'test-sha256' } as ExternalUpdateOsAction]; + + store.generateUrl('https://example.com/callback', payload, 'forUpc'); + + const sender = mockGenerateUrl.mock.calls[0][3] as string; + const senderUrl = new URL(sender); + + const nonce = mockGenerateUrl.mock.calls[0][4] as string; + expect(senderUrl.searchParams.has('callback_nonce')).toBe(false); + expect(nonce).toMatch(/^[0-9a-f]{32}$/); + expect(localStorage.getItem(`unraid-callback-nonce:${nonce}`)).toBeTruthy(); + }); + }); + + describe('Callback Return Validation', () => { + const updatePayload = (sender: string, nonce?: string): QueryPayloads => ({ + type: 'forUpc', + actions: [ + { + type: 'updateOs', + sha256: 'test-sha256', + } as ExternalUpdateOsAction, + ], + sender, + nonce, + }); + + it('uses the fragment when the query value is blank', async () => { + window.history.replaceState(null, '', '/Tools?data=#data=fragment'); + mockWatcher.mockReturnValue(updatePayload('/Tools')); + + store.watcher(); + await nextTick(); + store.watcher(); + + expect(mockWatcher).toHaveBeenCalledTimes(1); + expect(store.callbackStatus).toBe('error'); + expect(store.callbackError).toBeDefined(); + }); + + it('consumes a dedicated return nonce once', async () => { + store.send( + 'https://example.com/callback', + [{ type: 'updateOs', sha256: 'test-sha256' } as ExternalUpdateOsAction], + undefined, + 'forUpc' + ); + const sender = mockSend.mock.calls[0][4] as string; + const nonce = mockSend.mock.calls[0][5] as string; + + await store.saveCallbackData(updatePayload(sender, nonce), { requireNonce: true }); + await store.saveCallbackData(updatePayload(sender, nonce), { requireNonce: true }); + + expect(localStorage.getItem(`unraid-callback-nonce:${nonce}`)).toBeNull(); + expect(store.callbackStatus).toBe('error'); + expect(store.callbackError).toBeDefined(); + }); + + it('round-trips the dedicated nonce through encrypted callback URLs', async () => { + const { createCallback } = await vi.importActual( + '@unraid/shared-callbacks/client' + ); + const callback = createCallback({ encryptionKey: 'test-callback-key' }); + mockGenerateUrl.mockImplementationOnce(callback.generateUrl); + const requestUrl = store.generateUrl( + 'https://example.com/callback', + [{ type: 'updateOs', sha256: 'test-sha256' }], + 'forUpc' + ); + const request = callback.watcher({ baseUrl: requestUrl }); + if (!request) { + throw new Error('Callback request was not parsed'); + } + expect(request.nonce).toMatch(/^[0-9a-f]{32}$/); + expect(new URL(request.sender).searchParams.has('callback_nonce')).toBe(false); + + const responseUrl = callback.generateUrl( + request.sender, + [{ type: 'updateOs', sha256: 'test-sha256' }], + 'forUpc', + 'https://example.com/callback', + request.nonce + ); + mockWatcher.mockReturnValue(callback.watcher({ baseUrl: responseUrl })); + window.history.replaceState(null, '', responseUrl); + store.watcher(); + await nextTick(); + + expect(store.callbackData?.nonce).toBe(request.nonce); + expect(store.callbackStatus).not.toBe('error'); + expect(localStorage.getItem(`unraid-callback-nonce:${request.nonce}`)).toBeNull(); + }); + + it('rejects a nonce carried only in the sender URL', async () => { + const nonce = 'b'.repeat(32); + localStorage.setItem(`unraid-callback-nonce:${nonce}`, String(Date.now() + 60_000)); + + await store.saveCallbackData(updatePayload(`/Tools?callback_nonce=${nonce}`), { + requireNonce: true, + }); + + expect(store.callbackStatus).toBe('error'); + expect(store.callbackData).toBeUndefined(); + expect(localStorage.getItem(`unraid-callback-nonce:${nonce}`)).toBeTruthy(); + }); + + it.each(['', 'unknown', 'c'.repeat(32)])('rejects an inactive nonce %j', async (nonce) => { + await store.saveCallbackData(updatePayload('/Tools', nonce), { requireNonce: true }); + + expect(store.callbackStatus).toBe('error'); + expect(store.callbackData).toBeUndefined(); + }); + + it('rejects an expired return nonce', async () => { + const nonce = 'a'.repeat(32); + localStorage.setItem(`unraid-callback-nonce:${nonce}`, String(Date.now() - 1)); + + await store.saveCallbackData(updatePayload('/Tools', nonce), { + requireNonce: true, + }); + + expect(store.callbackStatus).toBe('error'); + expect(store.callbackError).toBeDefined(); + expect(localStorage.getItem(`unraid-callback-nonce:${nonce}`)).toBeNull(); + }); + + it('rejects an external return path without a nonce', async () => { + await store.saveCallbackData(updatePayload('https://example.com/return'), { + requireNonce: true, + }); + + expect(store.callbackStatus).toBe('error'); + expect(store.callbackError).toBeDefined(); + }); + }); + describe('Save Callback Data', () => { it('should save data and trigger redirect when valid data provided', () => { const mockData: QueryPayloads = { diff --git a/web/__test__/store/updateOs.test.ts b/web/__test__/store/updateOs.test.ts index 940787e265..80134313c4 100644 --- a/web/__test__/store/updateOs.test.ts +++ b/web/__test__/store/updateOs.test.ts @@ -11,10 +11,11 @@ import { useUpdateOsStore } from '~/store/updateOs'; const mockSend = vi.fn(); -vi.mock('@unraid/shared-callbacks', () => ({ - useCallback: vi.fn(() => ({ +vi.mock('@unraid/shared-callbacks/client', () => ({ + createCallback: vi.fn(() => ({ send: mockSend, watcher: vi.fn(), + generateUrl: vi.fn(), })), })); @@ -153,7 +154,9 @@ describe('UpdateOs Store', () => { }, ], undefined, - 'forUpc' + 'forUpc', + 'https://littlebox.tail45affd.ts.net/Plugins', + expect.stringMatching(/^[0-9a-f]{32}$/) ); Object.defineProperty(window, 'location', { diff --git a/web/package.json b/web/package.json index 38706ad567..2f052eab07 100644 --- a/web/package.json +++ b/web/package.json @@ -117,7 +117,7 @@ "@nuxt/ui": "4.8.2", "@tanstack/vue-table": "8.21.3", "@tiptap/y-tiptap": "3.0.7", - "@unraid/shared-callbacks": "3.1.0", + "@unraid/shared-callbacks": "3.4.0", "@unraid/ui": "link:../unraid-ui", "@vue/apollo-composable": "4.2.2", "@vueuse/components": "13.8.0", diff --git a/web/src/components/Onboarding/OnboardingModal.vue b/web/src/components/Onboarding/OnboardingModal.vue index 729ea6bd6e..df9c7dfe30 100644 --- a/web/src/components/Onboarding/OnboardingModal.vue +++ b/web/src/components/Onboarding/OnboardingModal.vue @@ -69,7 +69,7 @@ const allowActivationSkip = computed( () => hasKeyfile.value || activationRequired.value || showActivationStep.value ); const showKeyfileHint = computed(() => activationRequired.value && hasKeyfile.value); -const activateHref = computed(() => purchaseStore.generateUrl('activate')); +const getActivateHref = () => purchaseStore.generateUrl('activate'); const activateExternal = computed(() => purchaseStore.openInNewTab); // Hardcoded step definitions - order matters for UI flow @@ -591,7 +591,7 @@ const currentStepProps = computed>(() => { modalDescription: modalDescription.value, docsButtons: docsButtons.value, canGoBack: canGoBack.value, - activateHref: activateHref.value, + activateHref: getActivateHref, activateExternal: activateExternal.value, allowSkip: allowActivationSkip.value, showKeyfileHint: showKeyfileHint.value, diff --git a/web/src/components/Onboarding/steps/OnboardingLicenseStep.vue b/web/src/components/Onboarding/steps/OnboardingLicenseStep.vue index e85e70a8ab..38e8ff826f 100644 --- a/web/src/components/Onboarding/steps/OnboardingLicenseStep.vue +++ b/web/src/components/Onboarding/steps/OnboardingLicenseStep.vue @@ -21,7 +21,7 @@ interface Props { onComplete?: () => void; onBack?: () => void; showBack?: boolean; - activateHref: string; + activateHref: string | (() => string); activateExternal?: boolean; allowSkip?: boolean; } @@ -88,14 +88,18 @@ const isSkipDialogOpen = ref(false); const isRefreshing = ref(false); // Methods +const resolveActivateHref = () => + typeof props.activateHref === 'function' ? props.activateHref() : props.activateHref; + const openActivate = () => { + const activateHref = resolveActivateHref(); if (props.activateExternal) { - const opened = window.open(props.activateHref, '_blank', 'noopener,noreferrer'); + const opened = window.open(activateHref, '_blank', 'noopener,noreferrer'); if (opened) { opened.opener = null; } } else { - window.location.href = props.activateHref; + window.location.href = activateHref; } }; diff --git a/web/src/store/callbackActions.ts b/web/src/store/callbackActions.ts index a0f5bcd19a..b2914ba009 100644 --- a/web/src/store/callbackActions.ts +++ b/web/src/store/callbackActions.ts @@ -1,7 +1,7 @@ import { computed, ref, watch } from 'vue'; import { defineStore } from 'pinia'; -import { useCallback } from '@unraid/shared-callbacks'; +import { createCallback } from '@unraid/shared-callbacks/client'; import type { ExternalActions, QueryPayloads } from '@unraid/shared-callbacks'; import type { CallbackStatus } from '~/store/callbackActions.helpers'; @@ -25,16 +25,128 @@ import { useServerStore } from '~/store/server'; import { useUpdateOsActionsStore } from '~/store/updateOsActions'; const callbackEncryptionKey = import.meta.env.VITE_CALLBACK_KEY ?? ''; +const callbackNonceStoragePrefix = 'unraid-callback-nonce:'; +const callbackNonceTtlMs = 30 * 60 * 1000; export const useCallbackActionsStore = defineStore('callbackActions', () => { const { - send, + send: sendCallback, watcher: providedWatcher, - generateUrl, - } = useCallback({ + generateUrl: generateCallbackUrl, + } = createCallback({ encryptionKey: callbackEncryptionKey, }); + const createCallbackNonce = (): string => { + const bytes = new Uint8Array(16); + window.crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + }; + + const createAndRememberCallbackNonce = (): string | undefined => { + if (typeof window === 'undefined') { + return undefined; + } + + const nonce = createCallbackNonce(); + localStorage.setItem( + `${callbackNonceStoragePrefix}${nonce}`, + String(Date.now() + callbackNonceTtlMs) + ); + return nonce; + }; + + const getCallbackSender = (sender?: string): string | undefined => { + if (typeof window === 'undefined') { + return sender; + } + + const senderUrl = new URL(sender ?? window.location.href, window.location.origin); + if (sender === undefined && senderUrl.pathname === '/Tools/Update') { + senderUrl.pathname = '/Tools'; + } + senderUrl.hash = ''; + senderUrl.searchParams.delete('data'); + return senderUrl.toString(); + }; + + const getCallbackDataInCurrentUrl = (): string | undefined => { + if (typeof window === 'undefined') { + return undefined; + } + + try { + const currentUrl = new URL(window.location.href); + const queryData = currentUrl.searchParams.get('data'); + if (queryData) { + return queryData; + } + return currentUrl.hash.startsWith('#data=') ? currentUrl.hash.slice('#data='.length) : undefined; + } catch { + return undefined; + } + }; + + const send = (...args: Parameters) => { + const [url, payload, redirectType, sendType, sender] = args; + return sendCallback( + url, + payload, + redirectType, + sendType, + getCallbackSender(sender), + createAndRememberCallbackNonce() + ); + }; + + const generateUrl = (...args: Parameters) => { + const [url, payload, sendType, sender] = args; + return generateCallbackUrl( + url, + payload, + sendType, + getCallbackSender(sender), + createAndRememberCallbackNonce() + ); + }; + + const validateAndConsumeCallbackNonce = (payload: QueryPayloads, requireNonce = false): boolean => { + if (!isExternalCallbackPayload(payload)) { + return true; + } + + let senderUrl: URL; + try { + if (typeof payload.sender !== 'string') { + return false; + } + senderUrl = new URL(payload.sender, window.location.origin); + } catch { + return false; + } + + const nonce = payload.nonce; + if (nonce === undefined) { + if (requireNonce) { + return false; + } + + // Keep relative payloads compatible with direct in-process callers. + const isAbsoluteSender = + payload.sender.startsWith('//') || /^[a-z][a-z\d+.-]*:/i.test(payload.sender); + return !isAbsoluteSender && senderUrl.origin === window.location.origin; + } + + if (typeof nonce !== 'string' || !/^[0-9a-f]{32}$/.test(nonce)) { + return false; + } + + const storageKey = `${callbackNonceStoragePrefix}${nonce}`; + const expiresAt = Number(localStorage.getItem(storageKey)); + localStorage.removeItem(storageKey); + return Number.isSafeInteger(expiresAt) && expiresAt >= Date.now(); + }; + // Lazy store initialization - call stores inside functions to avoid circular dependencies const getAccountStore = () => useAccountStore(); const getInstallKeyStore = () => useInstallKeyStore(); @@ -47,6 +159,7 @@ export const useCallbackActionsStore = defineStore('callbackActions', () => { const callbackActionsExecuting = ref(false); const callbackReconciliationPending = ref(false); const sendType = 'fromUpc'; + let lastWatchedCallbackData: string | undefined; const getCallbackActionErrorMessage = (error: unknown): string => { if (error instanceof Error) { @@ -59,13 +172,31 @@ export const useCallbackActionsStore = defineStore('callbackActions', () => { }; const watcher = () => { + const callbackDataInCurrentUrl = getCallbackDataInCurrentUrl(); + if (callbackDataInCurrentUrl && callbackDataInCurrentUrl === lastWatchedCallbackData) { + return; + } + const result = providedWatcher(); if (result) { - void saveCallbackData(result); + if (callbackDataInCurrentUrl) { + lastWatchedCallbackData = callbackDataInCurrentUrl; + } + void saveCallbackData(result, { requireNonce: Boolean(callbackDataInCurrentUrl) }); } }; - const saveCallbackData = async (decryptedData?: QueryPayloads) => { + const saveCallbackData = async ( + decryptedData?: QueryPayloads, + options: { requireNonce?: boolean } = {} + ) => { + if (decryptedData && !validateAndConsumeCallbackNonce(decryptedData, options.requireNonce)) { + callbackData.value = undefined; + callbackError.value = 'Callback request is not active or has expired'; + callbackStatus.value = 'error'; + return; + } + if (decryptedData) { callbackData.value = decryptedData; }