From b4ecb6ac8ebfd3a20bdff0a8c61cfe41010b5c1b Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 12 Dec 2025 14:06:40 -0500 Subject: [PATCH 1/2] feat: enhance callback data handling by supporting URL hash for encrypted data --- README.md | 19 ++++ src/__tests__/useSharedCallback.test.ts | 126 ++++++++++++++++++------ src/index.ts | 39 +++++++- src/types.ts | 6 ++ 4 files changed, 158 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 1f9828c..2bb6388 100644 --- a/README.md +++ b/README.md @@ -77,3 +77,22 @@ interface CallbackActionsStore { sendType: 'fromUpc' | 'forUpc'; } ``` + +### URL format configuration + +By default, encrypted callback data is now placed in the URL hash (fragment) rather than a query parameter to help prevent sensitive data from being sent in referrers. + +You can control this behavior via the `CallbackConfig` passed to `useCallback`: + +```ts +interface CallbackConfig { + encryptionKey: string; + /** + * When true (default), encrypted data is stored in the URL hash. + * Set to false to store encrypted data in the `data` query parameter instead. + */ + useHash?: boolean; +} +``` + +Parsing helpers (`parse`, `watcher`) support both formats and will read encrypted data from either the hash or the `data` query parameter. diff --git a/src/__tests__/useSharedCallback.test.ts b/src/__tests__/useSharedCallback.test.ts index 8aa1bbc..fa015eb 100644 --- a/src/__tests__/useSharedCallback.test.ts +++ b/src/__tests__/useSharedCallback.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { useCallback } from '../index' import { createSharedComposable } from '@vueuse/core' import AES from 'crypto-js/aes.js' import Utf8 from 'crypto-js/enc-utf8.js' import type { ExternalSignOut } from '../types' +let useCallback: any + describe('useCallback', () => { const mockConfig = { encryptionKey: 'test-key' @@ -12,20 +13,28 @@ describe('useCallback', () => { beforeEach(() => { vi.clearAllMocks() - // Mock window.open - vi.spyOn(window, 'open').mockImplementation(() => null) - - // Mock window.location - const mockUrl = new URL('http://test.com/Tools/Update') - const mockLocation = { - href: mockUrl.toString(), - replace: vi.fn(), - toString: () => mockUrl.toString(), - searchParams: mockUrl.searchParams - } - Object.defineProperty(window, 'location', { - value: mockLocation, - writable: true + vi.resetModules() + + // Re-import useCallback fresh for each test so configuration + // (including useHash) can vary per test despite createSharedComposable. + return import('../index').then((mod) => { + useCallback = mod.useCallback + }).then(() => { + // Mock window.open + vi.spyOn(window, 'open').mockImplementation(() => null) + + // Mock window.location + const mockUrl = new URL('http://test.com/Tools/Update') + const mockLocation = { + href: mockUrl.toString(), + replace: vi.fn(), + toString: () => mockUrl.toString(), + searchParams: mockUrl.searchParams + } + Object.defineProperty(window, 'location', { + value: mockLocation, + writable: true + }) }) }) @@ -77,7 +86,7 @@ describe('useCallback', () => { const url = new URL(urlString) // Verify the decrypted data - const encryptedData = url.searchParams.get('data') || '' + const encryptedData = url.hash ? url.hash.slice(1) : '' const decryptedData = callback.parse(encryptedData) expect(decryptedData).toEqual(testData) }) @@ -98,7 +107,7 @@ describe('useCallback', () => { const url = new URL(urlString) // Verify the decrypted data - const encryptedData = url.searchParams.get('data') || '' + const encryptedData = url.hash ? url.hash.slice(1) : '' const decryptedData = callback.parse(encryptedData) expect(decryptedData).toEqual(testData) }) @@ -125,7 +134,7 @@ describe('useCallback', () => { callback.send('http://test.com/Tools', testActions, null, 'test', 'http://test.com/Tools') const url = new URL(hrefValue) - const encryptedData = url.searchParams.get('data') || '' + const encryptedData = url.hash ? url.hash.slice(1) : '' const decryptedData = callback.parse(encryptedData) expect(decryptedData).toEqual(testData) } finally { @@ -160,6 +169,25 @@ describe('useCallback', () => { } } }) + + it('should support query parameter mode when useHash is false', () => { + const callback = useCallback({ encryptionKey: 'test-key', useHash: false }) + const testActions: ExternalSignOut[] = [{ type: 'signOut' }] + const testData = { + actions: testActions, + sender: 'http://test.com/Tools', + type: 'test' + } + + callback.send('http://test.com/Tools', testActions, 'newTab', 'test', 'http://test.com/Tools') + + const [[urlString]] = (window.open as any).mock.calls + const url = new URL(urlString) + + const encryptedData = url.searchParams.get('data') || '' + const decryptedData = callback.parse(encryptedData) + expect(decryptedData).toEqual(testData) + }) }) describe('parse function', () => { @@ -265,6 +293,26 @@ describe('useCallback', () => { expect(result).toEqual(testData) }) + it('should use baseUrl hash when provided', () => { + const callback = useCallback(mockConfig) + const testActions: ExternalSignOut[] = [{ type: 'signOut' }] + const testData = { + actions: testActions, + sender: 'http://test.com/Tools', + type: 'test' + } + + const stringifiedData = JSON.stringify(testData) + const encryptedData = AES.encrypt(stringifiedData, mockConfig.encryptionKey).toString() + const uriEncodedData = encodeURI(encryptedData) + + const url = new URL('http://test.com/Tools') + url.hash = uriEncodedData + + const result = callback.watcher({ baseUrl: url.toString() }) + expect(result).toEqual(testData) + }) + it('should use dataToParse when provided', () => { const callback = useCallback(mockConfig) const testActions: ExternalSignOut[] = [{ type: 'signOut' }] @@ -371,10 +419,10 @@ describe('useCallback', () => { const url = new URL(generatedUrl) expect(url.origin + url.pathname).toBe(targetUrl) - expect(url.searchParams.has('data')).toBe(true) + expect(url.hash).not.toBe('') // Verify the encrypted data can be decrypted - const encryptedData = url.searchParams.get('data') || '' + const encryptedData = url.hash ? url.hash.slice(1) : '' const decryptedData = callback.parse(encryptedData) expect(decryptedData).toEqual({ actions: testActions, @@ -392,7 +440,7 @@ describe('useCallback', () => { const generatedUrl = callback.generateUrl(targetUrl, testActions, sendType) const url = new URL(generatedUrl) - const encryptedData = url.searchParams.get('data') || '' + const encryptedData = url.hash ? url.hash.slice(1) : '' const decryptedData = callback.parse(encryptedData) // Should use window.location.href (mocked to 'http://test.com/Tools/Update') @@ -413,7 +461,7 @@ describe('useCallback', () => { const generatedUrl = callback.generateUrl(targetUrl, testActions, sendType) const url = new URL(generatedUrl) - const encryptedData = url.searchParams.get('data') || '' + const encryptedData = url.hash ? url.hash.slice(1) : '' const decryptedData = callback.parse(encryptedData) // Should use empty string when window is not available @@ -438,7 +486,7 @@ describe('useCallback', () => { const url = new URL(generatedUrl) expect(url.origin + url.pathname).toBe(targetUrl) - expect(url.searchParams.has('data')).toBe(true) + expect(url.hash).not.toBe('') }) }) @@ -451,7 +499,7 @@ describe('useCallback', () => { const url = new URL(generatedUrl) expect(url.searchParams.get('existing')).toBe('param') - expect(url.searchParams.has('data')).toBe(true) + expect(url.hash).not.toBe('') }) it('should preserve URL path in generateUrl (no normalization)', () => { @@ -464,7 +512,7 @@ describe('useCallback', () => { // generateUrl does not normalize URLs (unlike send which does) expect(url.pathname).toBe('/Tools/Update') - expect(url.searchParams.has('data')).toBe(true) + expect(url.hash).not.toBe('') }) it('should handle empty payload arrays', () => { @@ -474,7 +522,7 @@ describe('useCallback', () => { const generatedUrl = callback.generateUrl(targetUrl, emptyActions, 'forUpc', 'http://sender.com') const url = new URL(generatedUrl) - const encryptedData = url.searchParams.get('data') || '' + const encryptedData = url.hash ? url.hash.slice(1) : '' const decryptedData = callback.parse(encryptedData) expect(decryptedData).toEqual({ @@ -483,6 +531,28 @@ describe('useCallback', () => { type: 'forUpc' }) }) + + it('should support query parameter mode in generateUrl when useHash is false', () => { + const callback = useCallback({ encryptionKey: 'test-key', useHash: false }) + const testActions: ExternalSignOut[] = [{ type: 'signOut' }] + const targetUrl = 'http://test.com/c' + const sendType = 'forUpc' + const sender = 'http://test.com/Tools' + + const generatedUrl = callback.generateUrl(targetUrl, testActions, sendType, sender) + const url = new URL(generatedUrl) + + expect(url.origin + url.pathname).toBe(targetUrl) + expect(url.searchParams.has('data')).toBe(true) + + const encryptedData = url.searchParams.get('data') || '' + const decryptedData = callback.parse(encryptedData) + expect(decryptedData).toEqual({ + actions: testActions, + sender, + type: sendType + }) + }) }) describe('SSR compatibility', () => { @@ -538,10 +608,10 @@ describe('useCallback', () => { const url = new URL(generatedUrl) expect(url.origin + url.pathname).toBe(targetUrl) - expect(url.searchParams.has('data')).toBe(true) + expect(url.hash).not.toBe('') // Restore window global.window = originalWindow }) }) -}) \ No newline at end of file +}) diff --git a/src/index.ts b/src/index.ts index 1688227..b816ecc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -101,6 +101,8 @@ const createEncryptedPayload = ( }; const _useCallback = (config: CallbackConfig) => { + const shouldUseHash = config.useHash !== false; + const send = ( url: string, payload: SendPayloads, @@ -122,7 +124,11 @@ const _useCallback = (config: CallbackConfig) => { ); const destinationUrl = new URL(url.replace("/Tools/Update", "/Tools")); - destinationUrl.searchParams.set("data", encodeURI(encryptedMessage)); + if (shouldUseHash) { + destinationUrl.hash = encodeURI(encryptedMessage); + } else { + destinationUrl.searchParams.set("data", encodeURI(encryptedMessage)); + } if (redirectType === "newTab") { window.open(destinationUrl.toString(), "_blank"); @@ -163,12 +169,33 @@ const _useCallback = (config: CallbackConfig) => { } // If we have dataToParse, use it directly; otherwise parse from URL - const uriDecodedEncryptedData = options?.dataToParse + const uriDecodedEncryptedData = options?.dataToParse ? decodeURI(options.dataToParse) : (() => { try { const currentUrl = new URL(urlToParse); - return decodeURI(currentUrl.searchParams.get("data") ?? ""); + + // Prefer query param if present to maintain backward compatibility, + // but also support hash-based data for enhanced privacy. + const searchParamData = currentUrl.searchParams.get("data") ?? ""; + + let hashData = ""; + const rawHash = currentUrl.hash ?? ""; + if (rawHash) { + const hashWithoutHash = rawHash.startsWith("#") + ? rawHash.slice(1) + : rawHash; + + if (hashWithoutHash.includes("=")) { + const hashParams = new URLSearchParams(hashWithoutHash); + hashData = hashParams.get("data") ?? ""; + } else { + hashData = hashWithoutHash; + } + } + + const dataFromUrl = searchParamData || hashData; + return decodeURI(dataFromUrl); } catch { return ""; } @@ -203,7 +230,11 @@ const _useCallback = (config: CallbackConfig) => { ); const destinationUrl = new URL(url); - destinationUrl.searchParams.set("data", encodeURI(encryptedMessage)); + if (shouldUseHash) { + destinationUrl.hash = encodeURI(encryptedMessage); + } else { + destinationUrl.searchParams.set("data", encodeURI(encryptedMessage)); + } return destinationUrl.toString(); }; diff --git a/src/types.ts b/src/types.ts index 7a0988c..5837527 100644 --- a/src/types.ts +++ b/src/types.ts @@ -184,4 +184,10 @@ export interface WatcherOptions { export interface CallbackConfig { encryptionKey: string; + /** + * When true (default), encrypted callback data will be placed in the URL hash + * instead of a query parameter to avoid leaking data via referrers. + * Set to false to use a query parameter instead. + */ + useHash?: boolean; } From 9c93dcc197ef3305bd46aacc4e62d80ac622413f Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 12 Dec 2025 14:30:59 -0500 Subject: [PATCH 2/2] fix: update URL hash handling for encrypted data and improve test setup --- package.json | 7 ++- src/__tests__/useSharedCallback.test.ts | 81 ++++++++++++++++--------- src/index.ts | 12 ++-- 3 files changed, 62 insertions(+), 38 deletions(-) diff --git a/package.json b/package.json index aad99ed..ce01d8e 100644 --- a/package.json +++ b/package.json @@ -27,17 +27,18 @@ "@vueuse/core": "^10.9.0 || ^13.0.0" }, "devDependencies": { - "@vueuse/core": "^13.0.0", "@types/crypto-js": "^4.2.1", - "rimraf": "^6.0.0", "@vitest/coverage-v8": "^3.1.1", "@vue/test-utils": "^2.4.6", + "@vueuse/core": "^13.0.0", + "rimraf": "^6.0.0", "typescript": "^5.3.3", "vitest": "^3.1.1" }, - "packageManager": "pnpm@10.22.0+sha512.bf049efe995b28f527fd2b41ae0474ce29186f7edcb3bf545087bd61fbbebb2bf75362d1307fda09c2d288e1e499787ac12d4fcb617a974718a6051f2eee741c", + "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501", "pnpm": { "onlyBuiltDependencies": [ + "esbuild", "vue-demi" ] } diff --git a/src/__tests__/useSharedCallback.test.ts b/src/__tests__/useSharedCallback.test.ts index fa015eb..095fe14 100644 --- a/src/__tests__/useSharedCallback.test.ts +++ b/src/__tests__/useSharedCallback.test.ts @@ -11,30 +11,41 @@ describe('useCallback', () => { encryptionKey: 'test-key' } - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks() - vi.resetModules() + + // Ensure window exists (JS DOM or stubbed) before importing the module. + if (typeof window === 'undefined') { + vi.stubGlobal('window', { + location: { + href: 'http://test.com/Tools/Update', + toString: () => 'http://test.com/Tools/Update', + replace: vi.fn(), + }, + open: vi.fn(), + }) + } // Re-import useCallback fresh for each test so configuration // (including useHash) can vary per test despite createSharedComposable. - return import('../index').then((mod) => { - useCallback = mod.useCallback - }).then(() => { - // Mock window.open - vi.spyOn(window, 'open').mockImplementation(() => null) - - // Mock window.location - const mockUrl = new URL('http://test.com/Tools/Update') - const mockLocation = { - href: mockUrl.toString(), - replace: vi.fn(), - toString: () => mockUrl.toString(), - searchParams: mockUrl.searchParams - } - Object.defineProperty(window, 'location', { - value: mockLocation, - writable: true - }) + vi.resetModules() + const mod = await import('../index') + useCallback = mod.useCallback + + // Mock window.open + vi.spyOn(window, 'open').mockImplementation(() => null) + + // Mock window.location + const mockUrl = new URL('http://test.com/Tools/Update') + const mockLocation = { + href: mockUrl.toString(), + replace: vi.fn(), + toString: () => mockUrl.toString(), + searchParams: mockUrl.searchParams + } + Object.defineProperty(window, 'location', { + value: mockLocation, + writable: true }) }) @@ -86,7 +97,9 @@ describe('useCallback', () => { const url = new URL(urlString) // Verify the decrypted data - const encryptedData = url.hash ? url.hash.slice(1) : '' + const encryptedData = url.hash.startsWith('#data=') + ? url.hash.slice('#data='.length) + : '' const decryptedData = callback.parse(encryptedData) expect(decryptedData).toEqual(testData) }) @@ -107,7 +120,9 @@ describe('useCallback', () => { const url = new URL(urlString) // Verify the decrypted data - const encryptedData = url.hash ? url.hash.slice(1) : '' + const encryptedData = url.hash.startsWith('#data=') + ? url.hash.slice('#data='.length) + : '' const decryptedData = callback.parse(encryptedData) expect(decryptedData).toEqual(testData) }) @@ -134,7 +149,9 @@ describe('useCallback', () => { callback.send('http://test.com/Tools', testActions, null, 'test', 'http://test.com/Tools') const url = new URL(hrefValue) - const encryptedData = url.hash ? url.hash.slice(1) : '' + const encryptedData = url.hash.startsWith('#data=') + ? url.hash.slice('#data='.length) + : '' const decryptedData = callback.parse(encryptedData) expect(decryptedData).toEqual(testData) } finally { @@ -307,7 +324,7 @@ describe('useCallback', () => { const uriEncodedData = encodeURI(encryptedData) const url = new URL('http://test.com/Tools') - url.hash = uriEncodedData + url.hash = `data=${uriEncodedData}` const result = callback.watcher({ baseUrl: url.toString() }) expect(result).toEqual(testData) @@ -422,7 +439,9 @@ describe('useCallback', () => { expect(url.hash).not.toBe('') // Verify the encrypted data can be decrypted - const encryptedData = url.hash ? url.hash.slice(1) : '' + const encryptedData = url.hash.startsWith('#data=') + ? url.hash.slice('#data='.length) + : '' const decryptedData = callback.parse(encryptedData) expect(decryptedData).toEqual({ actions: testActions, @@ -440,7 +459,9 @@ describe('useCallback', () => { const generatedUrl = callback.generateUrl(targetUrl, testActions, sendType) const url = new URL(generatedUrl) - const encryptedData = url.hash ? url.hash.slice(1) : '' + const encryptedData = url.hash.startsWith('#data=') + ? url.hash.slice('#data='.length) + : '' const decryptedData = callback.parse(encryptedData) // Should use window.location.href (mocked to 'http://test.com/Tools/Update') @@ -461,7 +482,9 @@ describe('useCallback', () => { const generatedUrl = callback.generateUrl(targetUrl, testActions, sendType) const url = new URL(generatedUrl) - const encryptedData = url.hash ? url.hash.slice(1) : '' + const encryptedData = url.hash.startsWith('#data=') + ? url.hash.slice('#data='.length) + : '' const decryptedData = callback.parse(encryptedData) // Should use empty string when window is not available @@ -522,7 +545,9 @@ describe('useCallback', () => { const generatedUrl = callback.generateUrl(targetUrl, emptyActions, 'forUpc', 'http://sender.com') const url = new URL(generatedUrl) - const encryptedData = url.hash ? url.hash.slice(1) : '' + const encryptedData = url.hash.startsWith('#data=') + ? url.hash.slice('#data='.length) + : '' const decryptedData = callback.parse(encryptedData) expect(decryptedData).toEqual({ diff --git a/src/index.ts b/src/index.ts index b816ecc..86eb808 100644 --- a/src/index.ts +++ b/src/index.ts @@ -125,7 +125,7 @@ const _useCallback = (config: CallbackConfig) => { const destinationUrl = new URL(url.replace("/Tools/Update", "/Tools")); if (shouldUseHash) { - destinationUrl.hash = encodeURI(encryptedMessage); + destinationUrl.hash = `data=${encodeURI(encryptedMessage)}`; } else { destinationUrl.searchParams.set("data", encodeURI(encryptedMessage)); } @@ -186,11 +186,9 @@ const _useCallback = (config: CallbackConfig) => { ? rawHash.slice(1) : rawHash; - if (hashWithoutHash.includes("=")) { - const hashParams = new URLSearchParams(hashWithoutHash); - hashData = hashParams.get("data") ?? ""; - } else { - hashData = hashWithoutHash; + // Expect hash in the form `data=` for privacy mode. + if (hashWithoutHash.startsWith("data=")) { + hashData = hashWithoutHash.slice("data=".length); } } @@ -231,7 +229,7 @@ const _useCallback = (config: CallbackConfig) => { const destinationUrl = new URL(url); if (shouldUseHash) { - destinationUrl.hash = encodeURI(encryptedMessage); + destinationUrl.hash = `data=${encodeURI(encryptedMessage)}`; } else { destinationUrl.searchParams.set("data", encodeURI(encryptedMessage)); }