From a9e0ea4be912801a65216d59ba361ac7c566fa23 Mon Sep 17 00:00:00 2001 From: SimonFair <39065407+SimonFair@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:07:00 +0100 Subject: [PATCH 1/6] fix(callback): validate callback requests --- web/src/store/callbackActions.ts | 75 +++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/web/src/store/callbackActions.ts b/web/src/store/callbackActions.ts index a0f5bcd19a..a1cc448288 100644 --- a/web/src/store/callbackActions.ts +++ b/web/src/store/callbackActions.ts @@ -25,16 +25,80 @@ import { useServerStore } from '~/store/server'; import { useUpdateOsActionsStore } from '~/store/updateOsActions'; const callbackEncryptionKey = import.meta.env.VITE_CALLBACK_KEY ?? ''; +const callbackNonceParameter = 'callback_nonce'; +const callbackNonceStoragePrefix = 'unraid-callback-nonce:'; +const callbackNonceTtlMs = 10 * 60 * 1000; export const useCallbackActionsStore = defineStore('callbackActions', () => { const { - send, + send: sendCallback, watcher: providedWatcher, - generateUrl, + generateUrl: generateCallbackUrl, } = useCallback({ 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 rememberCallbackNonce = (nonce: string) => { + sessionStorage.setItem( + `${callbackNonceStoragePrefix}${nonce}`, + String(Date.now() + callbackNonceTtlMs) + ); + }; + + const senderWithCallbackNonce = (sender?: string): string | undefined => { + if (typeof window === 'undefined') { + return sender; + } + + const nonce = createCallbackNonce(); + const senderUrl = new URL(sender ?? window.location.href); + senderUrl.hash = ''; + senderUrl.searchParams.delete('data'); + senderUrl.searchParams.set(callbackNonceParameter, nonce); + rememberCallbackNonce(nonce); + return senderUrl.toString(); + }; + + const send = (...args: Parameters) => { + const [url, payload, redirectType, sendType, sender] = args; + return sendCallback(url, payload, redirectType, sendType, senderWithCallbackNonce(sender)); + }; + + const generateUrl = (...args: Parameters) => { + const [url, payload, sendType, sender] = args; + return generateCallbackUrl(url, payload, sendType, senderWithCallbackNonce(sender)); + }; + + const validateAndConsumeCallbackNonce = (payload: QueryPayloads): boolean => { + if (!isExternalCallbackPayload(payload)) { + return true; + } + + let senderUrl: URL; + try { + senderUrl = new URL(payload.sender); + } catch { + return false; + } + + const nonce = senderUrl.searchParams.get(callbackNonceParameter); + if (!nonce) { + // Callbacks from older clients do not carry a nonce. + return true; + } + + const storageKey = `${callbackNonceStoragePrefix}${nonce}`; + const expiresAt = Number(sessionStorage.getItem(storageKey)); + sessionStorage.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(); @@ -66,6 +130,13 @@ export const useCallbackActionsStore = defineStore('callbackActions', () => { }; const saveCallbackData = async (decryptedData?: QueryPayloads) => { + if (decryptedData && !validateAndConsumeCallbackNonce(decryptedData)) { + callbackData.value = undefined; + callbackError.value = 'Callback request is not active or has expired'; + callbackStatus.value = 'error'; + return; + } + if (decryptedData) { callbackData.value = decryptedData; } From 0470674020d62d75cfe24b9b55ccaaf676a103e1 Mon Sep 17 00:00:00 2001 From: SimonFair <39065407+SimonFair@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:45:18 +0100 Subject: [PATCH 2/6] fix(callback): preserve legacy return flows --- web/src/store/callbackActions.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/web/src/store/callbackActions.ts b/web/src/store/callbackActions.ts index a1cc448288..dba123fb2a 100644 --- a/web/src/store/callbackActions.ts +++ b/web/src/store/callbackActions.ts @@ -57,7 +57,7 @@ export const useCallbackActionsStore = defineStore('callbackActions', () => { } const nonce = createCallbackNonce(); - const senderUrl = new URL(sender ?? window.location.href); + const senderUrl = new URL(sender ?? window.location.href, window.location.origin); senderUrl.hash = ''; senderUrl.searchParams.delete('data'); senderUrl.searchParams.set(callbackNonceParameter, nonce); @@ -67,11 +67,17 @@ export const useCallbackActionsStore = defineStore('callbackActions', () => { const send = (...args: Parameters) => { const [url, payload, redirectType, sendType, sender] = args; + if (sendCallback.length < 5) { + return sendCallback(url, payload, redirectType, sendType); + } return sendCallback(url, payload, redirectType, sendType, senderWithCallbackNonce(sender)); }; const generateUrl = (...args: Parameters) => { const [url, payload, sendType, sender] = args; + if (generateCallbackUrl.length < 4) { + return generateCallbackUrl(url, payload, sendType); + } return generateCallbackUrl(url, payload, sendType, senderWithCallbackNonce(sender)); }; @@ -82,7 +88,10 @@ export const useCallbackActionsStore = defineStore('callbackActions', () => { let senderUrl: URL; try { - senderUrl = new URL(payload.sender); + if (typeof payload.sender !== 'string') { + return false; + } + senderUrl = new URL(payload.sender, window.location.origin); } catch { return false; } From ac1ebe270501bcfb124e8d7fe477fad1f2bd4ba0 Mon Sep 17 00:00:00 2001 From: SimonFair <39065407+SimonFair@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:34:22 +0100 Subject: [PATCH 3/6] fix(callback): refresh request state at use OS-983 --- web/src/components/Onboarding/OnboardingModal.vue | 4 ++-- .../Onboarding/steps/OnboardingLicenseStep.vue | 10 +++++++--- web/src/store/callbackActions.ts | 15 ++++----------- 3 files changed, 13 insertions(+), 16 deletions(-) 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 dba123fb2a..9f8942b88b 100644 --- a/web/src/store/callbackActions.ts +++ b/web/src/store/callbackActions.ts @@ -45,7 +45,7 @@ export const useCallbackActionsStore = defineStore('callbackActions', () => { }; const rememberCallbackNonce = (nonce: string) => { - sessionStorage.setItem( + localStorage.setItem( `${callbackNonceStoragePrefix}${nonce}`, String(Date.now() + callbackNonceTtlMs) ); @@ -67,17 +67,11 @@ export const useCallbackActionsStore = defineStore('callbackActions', () => { const send = (...args: Parameters) => { const [url, payload, redirectType, sendType, sender] = args; - if (sendCallback.length < 5) { - return sendCallback(url, payload, redirectType, sendType); - } return sendCallback(url, payload, redirectType, sendType, senderWithCallbackNonce(sender)); }; const generateUrl = (...args: Parameters) => { const [url, payload, sendType, sender] = args; - if (generateCallbackUrl.length < 4) { - return generateCallbackUrl(url, payload, sendType); - } return generateCallbackUrl(url, payload, sendType, senderWithCallbackNonce(sender)); }; @@ -98,13 +92,12 @@ export const useCallbackActionsStore = defineStore('callbackActions', () => { const nonce = senderUrl.searchParams.get(callbackNonceParameter); if (!nonce) { - // Callbacks from older clients do not carry a nonce. - return true; + return false; } const storageKey = `${callbackNonceStoragePrefix}${nonce}`; - const expiresAt = Number(sessionStorage.getItem(storageKey)); - sessionStorage.removeItem(storageKey); + const expiresAt = Number(localStorage.getItem(storageKey)); + localStorage.removeItem(storageKey); return Number.isSafeInteger(expiresAt) && expiresAt >= Date.now(); }; From 53360f9acb077124e6512a529910b78d08ca83da Mon Sep 17 00:00:00 2001 From: SimonFair <39065407+SimonFair@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:45:05 +0100 Subject: [PATCH 4/6] fix(callback): preserve existing sender compatibility OS-983 --- web/src/store/callbackActions.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/web/src/store/callbackActions.ts b/web/src/store/callbackActions.ts index 9f8942b88b..58405a8439 100644 --- a/web/src/store/callbackActions.ts +++ b/web/src/store/callbackActions.ts @@ -67,11 +67,17 @@ export const useCallbackActionsStore = defineStore('callbackActions', () => { const send = (...args: Parameters) => { const [url, payload, redirectType, sendType, sender] = args; + if (sendCallback.length < 5) { + return sendCallback(url, payload, redirectType, sendType); + } return sendCallback(url, payload, redirectType, sendType, senderWithCallbackNonce(sender)); }; const generateUrl = (...args: Parameters) => { const [url, payload, sendType, sender] = args; + if (generateCallbackUrl.length < 4) { + return generateCallbackUrl(url, payload, sendType); + } return generateCallbackUrl(url, payload, sendType, senderWithCallbackNonce(sender)); }; @@ -92,7 +98,9 @@ export const useCallbackActionsStore = defineStore('callbackActions', () => { const nonce = senderUrl.searchParams.get(callbackNonceParameter); if (!nonce) { - return false; + // Browser callbacks produced by this store always use an absolute sender URL. + // Keep relative payloads compatible with direct in-process callers. + return !/^[a-z][a-z\d+.-]*:\/\//i.test(payload.sender); } const storageKey = `${callbackNonceStoragePrefix}${nonce}`; From a99347b06ee36447777d5441847f6eebfc473112 Mon Sep 17 00:00:00 2001 From: SimonFair <39065407+SimonFair@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:05:30 +0100 Subject: [PATCH 5/6] fix(callback): preserve update return path --- web/src/store/callbackActions.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/src/store/callbackActions.ts b/web/src/store/callbackActions.ts index 58405a8439..277ee1033a 100644 --- a/web/src/store/callbackActions.ts +++ b/web/src/store/callbackActions.ts @@ -58,6 +58,9 @@ export const useCallbackActionsStore = defineStore('callbackActions', () => { const nonce = createCallbackNonce(); 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'); senderUrl.searchParams.set(callbackNonceParameter, nonce); From 0179915cf349508287ec125995ae5951c58011f9 Mon Sep 17 00:00:00 2001 From: SimonFair <39065407+SimonFair@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:11:25 +0100 Subject: [PATCH 6/6] fix: validate callback return state --- web/src/store/callbackActions.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/src/store/callbackActions.ts b/web/src/store/callbackActions.ts index 277ee1033a..2b53d1707f 100644 --- a/web/src/store/callbackActions.ts +++ b/web/src/store/callbackActions.ts @@ -27,7 +27,7 @@ import { useUpdateOsActionsStore } from '~/store/updateOsActions'; const callbackEncryptionKey = import.meta.env.VITE_CALLBACK_KEY ?? ''; const callbackNonceParameter = 'callback_nonce'; const callbackNonceStoragePrefix = 'unraid-callback-nonce:'; -const callbackNonceTtlMs = 10 * 60 * 1000; +const callbackNonceTtlMs = 30 * 60 * 1000; export const useCallbackActionsStore = defineStore('callbackActions', () => { const { @@ -103,7 +103,9 @@ export const useCallbackActionsStore = defineStore('callbackActions', () => { if (!nonce) { // Browser callbacks produced by this store always use an absolute sender URL. // Keep relative payloads compatible with direct in-process callers. - return !/^[a-z][a-z\d+.-]*:\/\//i.test(payload.sender); + const isAbsoluteSender = + payload.sender.startsWith('//') || /^[a-z][a-z\d+.-]*:/i.test(payload.sender); + return !isAbsoluteSender && senderUrl.origin === window.location.origin; } const storageKey = `${callbackNonceStoragePrefix}${nonce}`;