From 049d1ea7abe677c3e13e06ade82d642bd748ad25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E7=9B=88=E8=BE=89=E5=BE=8B=E5=B8=88?= Date: Tue, 19 May 2026 23:56:01 +0800 Subject: [PATCH] harden: validate webview event payloads (#2036) Co-authored-by: LawyerLyu Co-authored-by: Claude Sonnet 4.6 (1M context) --- .../webviewAccountService.linkedin.test.ts | 23 +++ ...ewAccountService.payloadValidation.test.ts | 183 ++++++++++++++++++ app/src/services/webviewAccountService.ts | 133 +++++++++++-- 3 files changed, 323 insertions(+), 16 deletions(-) create mode 100644 app/src/services/__tests__/webviewAccountService.payloadValidation.test.ts diff --git a/app/src/services/__tests__/webviewAccountService.linkedin.test.ts b/app/src/services/__tests__/webviewAccountService.linkedin.test.ts index eb00193f2..9bf302194 100644 --- a/app/src/services/__tests__/webviewAccountService.linkedin.test.ts +++ b/app/src/services/__tests__/webviewAccountService.linkedin.test.ts @@ -235,4 +235,27 @@ describe('webviewAccountService — LinkedIn recipe events', () => { fireRecipeEvent({ kind: 'linkedin_requests', payload: { requests: [] } }) ).resolves.not.toThrow(); }); + + it('drops malformed linkedin_conversation payloads before memory ingest', async () => { + await expect( + fireRecipeEvent({ + kind: 'linkedin_conversation', + payload: { chatId: 123, day: '2025-05-08', messages: [{ from: 'Alice', body: 'Hi' }] }, + }) + ).resolves.not.toThrow(); + + expect(callCoreRpc).not.toHaveBeenCalled(); + }); + + it('drops malformed ingest payload messages before memory ingest', async () => { + await expect( + fireRecipeEvent({ + kind: 'ingest', + provider: 'whatsapp', + payload: { messages: [{ from: 'Alice', body: 42, timestamp: 'now' }] }, + }) + ).resolves.not.toThrow(); + + expect(callCoreRpc).not.toHaveBeenCalled(); + }); }); diff --git a/app/src/services/__tests__/webviewAccountService.payloadValidation.test.ts b/app/src/services/__tests__/webviewAccountService.payloadValidation.test.ts new file mode 100644 index 000000000..cda8b05db --- /dev/null +++ b/app/src/services/__tests__/webviewAccountService.payloadValidation.test.ts @@ -0,0 +1,183 @@ +/** + * Regression tests for parseRecipePayload validation (PR #2036). + * + * Exercises the meet_captions, meet_call_ended, ingest (valid path), and + * notify branches added in this PR — covering the paths that the malformed- + * payload tests in webviewAccountService.linkedin.test.ts intentionally skip. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { callCoreRpc } from '../coreRpcClient'; +import { ingestNotification } from '../notificationService'; +import { startWebviewAccountService, stopWebviewAccountService } from '../webviewAccountService'; + +// ── Tauri IPC mocks ────────────────────────────────────────────────────────── + +type EventHandler = (evt: { payload: unknown }) => void; +const listeners = new Map(); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn().mockResolvedValue(undefined), + isTauri: vi.fn().mockReturnValue(true), +})); + +vi.mock('@tauri-apps/api/event', () => ({ + listen: vi.fn(async (event: string, handler: EventHandler) => { + listeners.set(event, handler); + return () => listeners.delete(event); + }), +})); + +// ── Service dep mocks ──────────────────────────────────────────────────────── + +vi.mock('../api/threadApi', () => ({ threadApi: { createNewThread: vi.fn() } })); +vi.mock('../chatService', () => ({ chatSend: vi.fn() })); +vi.mock('../coreRpcClient', () => ({ callCoreRpc: vi.fn().mockResolvedValue({}) })); +vi.mock('../notificationService', () => ({ ingestNotification: vi.fn() })); +vi.mock('../../utils/tauriCommands/config', () => ({ + openhumanGetMeetSettings: vi.fn().mockResolvedValue({ result: {}, logs: [] }), +})); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const ACCOUNT_ID = 'acct-validation-test'; + +async function fireRecipeEvent(evt: { + kind: string; + provider?: string; + payload: Record; + ts?: number; +}): Promise { + const handler = listeners.get('webview:event'); + if (!handler) throw new Error('webview:event listener not attached'); + handler({ + payload: { account_id: ACCOUNT_ID, provider: 'test-provider', ts: Date.now(), ...evt }, + }); + await new Promise(r => setTimeout(r, 0)); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('webviewAccountService — recipe event payload validation', () => { + beforeEach(() => { + listeners.clear(); + vi.clearAllMocks(); + stopWebviewAccountService(); + startWebviewAccountService(); + vi.mocked(ingestNotification).mockResolvedValue({ id: 'notif-1' }); + vi.mocked(callCoreRpc).mockResolvedValue({} as never); + }); + + // ── meet_captions ────────────────────────────────────────────────────────── + + it('handles meet_captions with valid payload without throwing', async () => { + await expect( + fireRecipeEvent({ + kind: 'meet_captions', + payload: { + code: 'abc-defg-hij', + captions: [{ speaker: 'Alice', text: 'Hello there' }], + ts: Date.now(), + }, + }) + ).resolves.not.toThrow(); + }); + + it('drops malformed meet_captions payload (invalid code type) silently', async () => { + await expect( + fireRecipeEvent({ + kind: 'meet_captions', + // code must be a string; number should fail schema validation + payload: { code: 99, captions: [], ts: Date.now() }, + }) + ).resolves.not.toThrow(); + }); + + // ── meet_call_ended ──────────────────────────────────────────────────────── + + it('handles meet_call_ended with valid payload without throwing', async () => { + await expect( + fireRecipeEvent({ + kind: 'meet_call_ended', + payload: { code: 'abc-defg-hij', endedAt: Date.now(), reason: 'hangup' }, + }) + ).resolves.not.toThrow(); + }); + + it('drops malformed meet_call_ended payload (missing required endedAt) silently', async () => { + await expect( + fireRecipeEvent({ + kind: 'meet_call_ended', + // endedAt is required; omitting it (or wrong type) should fail validation + payload: { code: 'abc-defg-hij', endedAt: 'not-a-number' }, + }) + ).resolves.not.toThrow(); + }); + + // ── ingest (valid path) ──────────────────────────────────────────────────── + + it('dispatches messages and calls memory ingest on valid ingest payload', async () => { + await fireRecipeEvent({ + kind: 'ingest', + provider: 'slack', + payload: { + messages: [ + { id: 'msg-1', from: 'Alice', body: 'Hello!', unread: 1 }, + { id: 'msg-2', from: null, body: 'World', unread: 0 }, + ], + unread: 1, + snapshotKey: 'channel-C123', + }, + ts: 1000, + }); + + // persistIngestToMemory fires callCoreRpc for non-whatsapp providers + expect(callCoreRpc).toHaveBeenCalledWith( + expect.objectContaining({ method: 'openhuman.memory_doc_ingest' }) + ); + }); + + it('skips memory ingest when ingest payload has no messages', async () => { + await fireRecipeEvent({ + kind: 'ingest', + provider: 'slack', + payload: { messages: [], unread: 0 }, + ts: 2000, + }); + + expect(callCoreRpc).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'openhuman.memory_doc_ingest' }) + ); + }); + + // ── notify ───────────────────────────────────────────────────────────────── + + it('calls ingestNotification for valid notify payload with title and body', async () => { + await fireRecipeEvent({ + kind: 'notify', + payload: { title: 'New message', body: 'You have a new notification' }, + }); + + expect(ingestNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'New message', body: 'You have a new notification' }) + ); + }); + + it('skips ingestNotification when notify payload has no title or body', async () => { + await fireRecipeEvent({ kind: 'notify', payload: { title: '', body: '' } }); + + expect(ingestNotification).not.toHaveBeenCalled(); + }); + + it('drops malformed notify payload (invalid schema) silently', async () => { + await expect( + fireRecipeEvent({ + kind: 'notify', + // title must be string; number should fail schema validation + payload: { title: 123, body: 'hello' }, + }) + ).resolves.not.toThrow(); + + expect(ingestNotification).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/services/webviewAccountService.ts b/app/src/services/webviewAccountService.ts index 85552bb74..7d3a82c64 100644 --- a/app/src/services/webviewAccountService.ts +++ b/app/src/services/webviewAccountService.ts @@ -2,6 +2,7 @@ import * as Sentry from '@sentry/react'; import { invoke } from '@tauri-apps/api/core'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import debug from 'debug'; +import { z } from 'zod'; import { store } from '../store'; import { @@ -183,13 +184,85 @@ interface WebviewAccountBounds { height: number; } -interface RecipeNotifyPayload { - title?: string; - body?: string; - icon?: string | null; - tag?: string | null; - silent?: boolean; - [key: string]: unknown; +const IngestMessageSchema = z.object({ + id: z.string().optional(), + from: z.string().nullable().optional(), + sender: z.string().nullable().optional(), + to: z.string().nullable().optional(), + fromMe: z.boolean().optional(), + body: z.string().nullable().optional(), + type: z.string().nullable().optional(), + timestamp: z.number().nullable().optional(), + unread: z.number().optional(), +}); + +const IngestPayloadSchema = z + .object({ + messages: z.array(IngestMessageSchema).optional(), + unread: z.number().optional(), + snapshotKey: z.string().optional(), + provider: z.string().optional(), + chatId: z.string().optional(), + chatName: z.string().nullable().optional(), + day: z.string().optional(), + isSeed: z.boolean().optional(), + }) + .passthrough(); + +const LinkedInConversationPayloadSchema = z + .object({ + chatId: z.string(), + chatName: z.string().nullable().optional(), + day: z.string(), + messages: z.array(IngestMessageSchema), + isSeed: z.boolean().optional(), + }) + .passthrough(); + +const LinkedInRequestsPayloadSchema = z + .object({ + requests: z.array( + z.object({ name: z.string(), subtitle: z.string().nullable() }).passthrough() + ), + }) + .passthrough(); + +const MeetCaptionRowSchema = z.object({ speaker: z.string(), text: z.string() }).passthrough(); + +const MeetCallStartedPayloadSchema = z + .object({ code: z.string(), url: z.string().optional(), startedAt: z.number() }) + .passthrough(); + +const MeetCaptionsPayloadSchema = z + .object({ code: z.string(), captions: z.array(MeetCaptionRowSchema), ts: z.number() }) + .passthrough(); + +const MeetCallEndedPayloadSchema = z + .object({ code: z.string(), endedAt: z.number(), reason: z.string().optional() }) + .passthrough(); + +const RecipeNotifyPayloadSchema = z + .object({ + title: z.string().optional(), + body: z.string().optional(), + icon: z.string().nullable().optional(), + tag: z.string().nullable().optional(), + silent: z.boolean().optional(), + }) + .passthrough(); + +function parseRecipePayload( + kind: string, + accountId: string, + payload: unknown, + schema: z.ZodType +): T | null { + const parsed = schema.safeParse(payload); + if (!parsed.success) { + errLog('invalid webview:event payload kind=%s account=%s: %o', kind, accountId, parsed.error); + return null; + } + return parsed.data; } let unlisten: UnlistenFn | null = null; @@ -400,20 +473,37 @@ function handleRecipeEvent(evt: RecipeEventPayload) { // drive the live-captions → transcript pipeline. Everything is // accumulated in-memory here; persistence fires once on meet_call_ended. if (evt.kind === 'meet_call_started') { - handleMeetCallStarted(accountId, evt.payload as unknown as MeetCallStartedPayload); + const payload = parseRecipePayload( + evt.kind, + accountId, + evt.payload, + MeetCallStartedPayloadSchema + ); + if (!payload) return; + handleMeetCallStarted(accountId, payload); return; } if (evt.kind === 'meet_captions') { - handleMeetCaptions(accountId, evt.payload as unknown as MeetCaptionsPayload); + const payload = parseRecipePayload(evt.kind, accountId, evt.payload, MeetCaptionsPayloadSchema); + if (!payload) return; + handleMeetCaptions(accountId, payload); return; } if (evt.kind === 'meet_call_ended') { - void handleMeetCallEnded(accountId, evt.payload as unknown as MeetCallEndedPayload); + const payload = parseRecipePayload( + evt.kind, + accountId, + evt.payload, + MeetCallEndedPayloadSchema + ); + if (!payload) return; + void handleMeetCallEnded(accountId, payload); return; } if (evt.kind === 'ingest') { - const ingest = evt.payload as IngestPayload; + const ingest = parseRecipePayload(evt.kind, accountId, evt.payload, IngestPayloadSchema); + if (!ingest) return; const messages: IngestedMessage[] = (ingest.messages ?? []).map((m, idx) => ({ id: m.id ?? `${accountId}:${idx}`, from: m.from ?? m.sender ?? null, @@ -454,16 +544,26 @@ function handleRecipeEvent(evt: RecipeEventPayload) { } if (evt.kind === 'linkedin_conversation') { - void persistLinkedInConversation( + const payload = parseRecipePayload( + evt.kind, accountId, - evt.payload as unknown as LinkedInConversationPayload + evt.payload, + LinkedInConversationPayloadSchema ); + if (!payload) return; + void persistLinkedInConversation(accountId, payload); return; } if (evt.kind === 'linkedin_requests') { - const requests = (evt.payload as { requests: Array<{ name: string; subtitle: string | null }> }) - .requests; + const payload = parseRecipePayload( + evt.kind, + accountId, + evt.payload, + LinkedInRequestsPayloadSchema + ); + if (!payload) return; + const requests = payload.requests; if (requests && requests.length > 0) { log('linkedin: %d pending connection request(s) for account=%s', requests.length, accountId); } @@ -471,7 +571,8 @@ function handleRecipeEvent(evt: RecipeEventPayload) { } if (evt.kind === 'notify') { - const payload = evt.payload as RecipeNotifyPayload; + const payload = parseRecipePayload(evt.kind, accountId, evt.payload, RecipeNotifyPayloadSchema); + if (!payload) return; const title = String(payload.title ?? '').trim(); const body = String(payload.body ?? '').trim(); if (!title && !body) return;