Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
Expand Down
125 changes: 110 additions & 15 deletions src/__tests__/useSharedCallback.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,37 @@
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'
}

beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks()

// 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.
vi.resetModules()
const mod = await import('../index')
useCallback = mod.useCallback

// Mock window.open
vi.spyOn(window, 'open').mockImplementation(() => null)

Expand Down Expand Up @@ -77,7 +97,9 @@ describe('useCallback', () => {
const url = new URL(urlString)

// Verify the decrypted data
const encryptedData = url.searchParams.get('data') || ''
const encryptedData = url.hash.startsWith('#data=')
? url.hash.slice('#data='.length)
: ''
const decryptedData = callback.parse(encryptedData)
expect(decryptedData).toEqual(testData)
})
Expand All @@ -98,7 +120,9 @@ describe('useCallback', () => {
const url = new URL(urlString)

// Verify the decrypted data
const encryptedData = url.searchParams.get('data') || ''
const encryptedData = url.hash.startsWith('#data=')
? url.hash.slice('#data='.length)
: ''
const decryptedData = callback.parse(encryptedData)
expect(decryptedData).toEqual(testData)
})
Expand All @@ -125,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.searchParams.get('data') || ''
const encryptedData = url.hash.startsWith('#data=')
? url.hash.slice('#data='.length)
: ''
const decryptedData = callback.parse(encryptedData)
expect(decryptedData).toEqual(testData)
} finally {
Expand Down Expand Up @@ -160,6 +186,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', () => {
Expand Down Expand Up @@ -265,6 +310,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 = `data=${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' }]
Expand Down Expand Up @@ -371,10 +436,12 @@ 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.startsWith('#data=')
? url.hash.slice('#data='.length)
: ''
const decryptedData = callback.parse(encryptedData)
expect(decryptedData).toEqual({
actions: testActions,
Expand All @@ -392,7 +459,9 @@ describe('useCallback', () => {
const generatedUrl = callback.generateUrl(targetUrl, testActions, sendType)
const url = new URL(generatedUrl)

const encryptedData = url.searchParams.get('data') || ''
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')
Expand All @@ -413,7 +482,9 @@ describe('useCallback', () => {
const generatedUrl = callback.generateUrl(targetUrl, testActions, sendType)
const url = new URL(generatedUrl)

const encryptedData = url.searchParams.get('data') || ''
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
Expand All @@ -438,7 +509,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('')
})
})

Expand All @@ -451,7 +522,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)', () => {
Expand All @@ -464,7 +535,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', () => {
Expand All @@ -474,7 +545,9 @@ 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.startsWith('#data=')
? url.hash.slice('#data='.length)
: ''
const decryptedData = callback.parse(encryptedData)

expect(decryptedData).toEqual({
Expand All @@ -483,6 +556,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', () => {
Expand Down Expand Up @@ -538,10 +633,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
})
})
})
})
37 changes: 33 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ const createEncryptedPayload = (
};

const _useCallback = (config: CallbackConfig) => {
const shouldUseHash = config.useHash !== false;

const send = (
url: string,
payload: SendPayloads,
Expand All @@ -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 = `data=${encodeURI(encryptedMessage)}`;
} else {
destinationUrl.searchParams.set("data", encodeURI(encryptedMessage));
}

if (redirectType === "newTab") {
window.open(destinationUrl.toString(), "_blank");
Expand Down Expand Up @@ -163,12 +169,31 @@ 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;

// Expect hash in the form `data=<encrypted>` for privacy mode.
if (hashWithoutHash.startsWith("data=")) {
hashData = hashWithoutHash.slice("data=".length);
}
}

const dataFromUrl = searchParamData || hashData;
return decodeURI(dataFromUrl);
} catch {
return "";
}
Expand Down Expand Up @@ -203,7 +228,11 @@ const _useCallback = (config: CallbackConfig) => {
);

const destinationUrl = new URL(url);
destinationUrl.searchParams.set("data", encodeURI(encryptedMessage));
if (shouldUseHash) {
destinationUrl.hash = `data=${encodeURI(encryptedMessage)}`;
} else {
destinationUrl.searchParams.set("data", encodeURI(encryptedMessage));
}

return destinationUrl.toString();
};
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}