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..2b53d1707f 100644 --- a/web/src/store/callbackActions.ts +++ b/web/src/store/callbackActions.ts @@ -25,16 +25,95 @@ 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 = 30 * 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) => { + localStorage.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, window.location.origin); + if (sender === undefined && senderUrl.pathname === '/Tools/Update') { + senderUrl.pathname = '/Tools'; + } + 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; + 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)); + }; + + const validateAndConsumeCallbackNonce = (payload: QueryPayloads): 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 = senderUrl.searchParams.get(callbackNonceParameter); + if (!nonce) { + // Browser callbacks produced by this store always use an absolute sender URL. + // 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; + } + + 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(); @@ -66,6 +145,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; }