diff --git a/.github/workflows/static_quality.yml b/.github/workflows/static_quality.yml index f94be61d..fbdf2fb5 100644 --- a/.github/workflows/static_quality.yml +++ b/.github/workflows/static_quality.yml @@ -20,6 +20,9 @@ on: - ".github/workflows/publish_release.yml" - "packages/**/CHANGELOG.md" +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/.github/workflows/test_e2e.yml b/.github/workflows/test_e2e.yml index 34ad2704..cde13a84 100644 --- a/.github/workflows/test_e2e.yml +++ b/.github/workflows/test_e2e.yml @@ -17,6 +17,9 @@ on: - "packages/**/CHANGELOG.md" workflow_dispatch: +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/.github/workflows/test_unit.yml b/.github/workflows/test_unit.yml index 6077be70..ed5d1a77 100644 --- a/.github/workflows/test_unit.yml +++ b/.github/workflows/test_unit.yml @@ -27,6 +27,9 @@ on: default: "main" type: string +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/examples/analytics-dashboard-react/public/icon-dark-32x32.png b/examples/analytics-dashboard-react/public/icon-dark-32x32.png deleted file mode 100644 index eaa25d57..00000000 Binary files a/examples/analytics-dashboard-react/public/icon-dark-32x32.png and /dev/null differ diff --git a/examples/analytics-dashboard-react/public/icon-light-32x32.png b/examples/analytics-dashboard-react/public/icon-light-32x32.png deleted file mode 100644 index eaa25d57..00000000 Binary files a/examples/analytics-dashboard-react/public/icon-light-32x32.png and /dev/null differ diff --git a/packages/core/src/__tests__/storage-source.test.ts b/packages/core/src/__tests__/storage-source.test.ts index f711d5c0..145b6e50 100644 --- a/packages/core/src/__tests__/storage-source.test.ts +++ b/packages/core/src/__tests__/storage-source.test.ts @@ -100,14 +100,58 @@ describe('createAskableStorageSource', () => { }); it('reads sessionStorage when specified', async () => { - setItems(sessionStorage, { sessionId: 'xyz' }); + setItems(sessionStorage, { viewMode: 'grid' }); const ctx = createAskableContext(); ctx.registerSource('sess', createAskableStorageSource({ storage: 'sessionStorage' })); const resolved = await ctx.resolveSource('sess'); const data = resolved.data as { storageType: string; items: Record }; expect(data.storageType).toBe('sessionStorage'); - expect(data.items.sessionId).toBe('xyz'); + expect(data.items.viewMode).toBe('grid'); + ctx.destroy(); + }); + + it('masks secret-looking keys by default', async () => { + setItems(localStorage, { + authToken: 'jwt-abc', + api_key: 'sk-123', + userPassword: 'hunter2', + sessionId: 'xyz', + theme: 'dark', + }); + const ctx = createAskableContext(); + ctx.registerSource('ls', createAskableStorageSource()); + + const resolved = await ctx.resolveSource('ls'); + const data = resolved.data as { items: Record }; + expect(data.items.authToken).toBe('***'); + expect(data.items.api_key).toBe('***'); + expect(data.items.userPassword).toBe('***'); + expect(data.items.sessionId).toBe('***'); + expect(data.items.theme).toBe('dark'); + ctx.destroy(); + }); + + it('masks secret-looking keys even when explicitly listed in keys', async () => { + setItems(localStorage, { accessToken: 'abc', theme: 'dark' }); + const ctx = createAskableContext(); + ctx.registerSource('ls', createAskableStorageSource({ keys: ['accessToken', 'theme'] })); + + const resolved = await ctx.resolveSource('ls'); + const data = resolved.data as { items: Record }; + expect(data.items.accessToken).toBe('***'); + expect(data.items.theme).toBe('dark'); + ctx.destroy(); + }); + + it('captures secret-looking keys when maskSensitiveKeys is false', async () => { + setItems(localStorage, { authToken: 'jwt-abc' }); + const ctx = createAskableContext(); + ctx.registerSource('ls', createAskableStorageSource({ maskSensitiveKeys: false })); + + const resolved = await ctx.resolveSource('ls'); + const data = resolved.data as { items: Record }; + expect(data.items.authToken).toBe('jwt-abc'); ctx.destroy(); }); diff --git a/packages/core/src/storage-source.ts b/packages/core/src/storage-source.ts index 94ed63bc..9d17d2f2 100644 --- a/packages/core/src/storage-source.ts +++ b/packages/core/src/storage-source.ts @@ -38,6 +38,15 @@ export interface AskableCreateStorageSourceOptions { * Matched values are replaced with `"***"`. */ maskKeys?: string[]; + /** + * Mask values whose keys look secret-bearing — matching token, secret, + * password, auth, jwt, api key, credential, session id, cookie, or private — + * in addition to `maskKeys`. This is a safety net so credentials in + * localStorage are not serialized into AI context by default. Set to `false` + * only when you are certain the captured storage holds no secrets. + * @default true + */ + maskSensitiveKeys?: boolean; /** * Custom transformer applied to the captured items before returning. * Can be used for field-level sanitization or restructuring. @@ -77,6 +86,13 @@ function keyMatchesList(key: string, patterns: string[]): boolean { return patterns.some((p) => matchesGlob(key, p)); } +const SENSITIVE_KEY_PATTERN = /token|secret|passw(or)?d|auth|jwt|api[-_]?key|credential|session[-_]?id|cookie|private/i; + +function shouldMask(key: string, maskKeys: string[], maskSensitiveKeys: boolean): boolean { + if (maskKeys.length > 0 && keyMatchesList(key, maskKeys)) return true; + return maskSensitiveKeys && SENSITIVE_KEY_PATTERN.test(key); +} + function parseValue(raw: string | null, parseJSON: boolean): unknown { if (raw === null) return null; if (!parseJSON) return raw; @@ -96,6 +112,7 @@ function buildSnapshot( omitKeys = [], parseJSON = true, maskKeys = [], + maskSensitiveKeys = true, sanitize, } = options; @@ -114,7 +131,7 @@ function buildSnapshot( for (let i = 0; i < store.length; i++) { const k = store.key(i); if (k && matchesGlob(k, pattern) && !keyMatchesList(k, omitKeys)) { - items[k] = maskKeys && keyMatchesList(k, maskKeys) + items[k] = shouldMask(k, maskKeys, maskSensitiveKeys) ? '***' : parseValue(store.getItem(k), parseJSON); } @@ -123,7 +140,7 @@ function buildSnapshot( if (!keyMatchesList(pattern, omitKeys)) { const raw = store.getItem(pattern); if (raw !== null) { - items[pattern] = maskKeys && keyMatchesList(pattern, maskKeys) + items[pattern] = shouldMask(pattern, maskKeys, maskSensitiveKeys) ? '***' : parseValue(raw, parseJSON); } @@ -134,7 +151,7 @@ function buildSnapshot( for (let i = 0; i < store.length; i++) { const k = store.key(i); if (!k || keyMatchesList(k, omitKeys)) continue; - items[k] = maskKeys && keyMatchesList(k, maskKeys) + items[k] = shouldMask(k, maskKeys, maskSensitiveKeys) ? '***' : parseValue(store.getItem(k), parseJSON); } diff --git a/packages/mcp/src/__tests__/index.test.ts b/packages/mcp/src/__tests__/index.test.ts index c8ea9b41..22d48b07 100644 --- a/packages/mcp/src/__tests__/index.test.ts +++ b/packages/mcp/src/__tests__/index.test.ts @@ -1174,6 +1174,30 @@ describe('createAskableMcpPageBridge', () => { ); }); + it('drops the response instead of posting with a wildcard target origin', async () => { + const packet = createWebContextPacket({ capture: { mode: 'region' } }); + const provider: AskableMcpContextProvider = { + getContext: vi.fn().mockResolvedValue(packet), + }; + const fakeWindow = new FakePageBridgeWindow(); + (fakeWindow as unknown as { location: undefined }).location = undefined; + createAskableMcpPageBridge({ + provider, + window: fakeWindow, + allowedOrigins: () => true, + }); + + fakeWindow.emit({ + protocol: ASKABLE_MCP_PAGE_BRIDGE_PROTOCOL, + version: ASKABLE_MCP_PAGE_BRIDGE_VERSION, + type: 'get_current_context', + requestId: 'req-no-origin', + }, ''); + await flushPageBridge(); + + expect(fakeWindow.posted).toHaveLength(0); + }); + it('removes the page bridge listener on dispose', async () => { const provider: AskableMcpContextProvider = { getContext: vi.fn(), diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index b90f687e..7f2845bb 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -631,6 +631,9 @@ async function handleAskableMcpPageBridgeMessage( return; } + const targetOrigin = resolveAskableMcpPageBridgeTargetOrigin(event, options, bridgeWindow); + if (!targetOrigin) return; + try { const contextOptions = getAskableMcpPageBridgeContextOptions(request.options); const packet = await options.provider.getContext(contextOptions); @@ -640,7 +643,7 @@ async function handleAskableMcpPageBridgeMessage( ...createAskableMcpPageBridgeResponseBase(request), type: `${request.type}:error`, error: { message: 'Context packet has not been redacted. Set requireRedacted: false to allow, or redact the packet before sending.' }, - }, resolveAskableMcpPageBridgeTargetOrigin(event, options)); + }, targetOrigin); return; } @@ -672,14 +675,14 @@ async function handleAskableMcpPageBridgeMessage( }; } - bridgeWindow.postMessage(response, resolveAskableMcpPageBridgeTargetOrigin(event, options)); + bridgeWindow.postMessage(response, targetOrigin); } catch (error) { options.onError?.(error, event); bridgeWindow.postMessage({ ...createAskableMcpPageBridgeResponseBase(request), type: `${request.type}:error`, error: { message: 'Askable MCP page bridge failed.' }, - }, resolveAskableMcpPageBridgeTargetOrigin(event, options)); + }, targetOrigin); } } @@ -801,8 +804,12 @@ function createAskableMcpPageBridgeResponseBase( function resolveAskableMcpPageBridgeTargetOrigin( event: MessageEvent, options: AskableMcpPageBridgeOptions, -): string { - return options.targetOrigin ?? (event.origin || '*'); + bridgeWindow: AskableMcpPageBridgeWindow, +): string | null { + if (options.targetOrigin) return options.targetOrigin; + // Never fall back to '*': context packets must not be broadcast to + // arbitrary origins. Without a known origin, drop the response instead. + return event.origin || bridgeWindow.location?.origin || null; } function isRecord(value: unknown): value is Record {