harden: validate webview event payloads (#2036)

Co-authored-by: LawyerLyu <georgelyutwitter@gmail.com>
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
吕盈辉律师
2026-05-19 21:26:01 +05:30
committed by GitHub
co-authored by LawyerLyu Claude Sonnet 4.6
parent 13500dbb37
commit 049d1ea7ab
3 changed files with 323 additions and 16 deletions
@@ -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();
});
});
@@ -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<string, EventHandler>();
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<string, unknown>;
ts?: number;
}): Promise<void> {
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();
});
});
+117 -16
View File
@@ -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<T>(
kind: string,
accountId: string,
payload: unknown,
schema: z.ZodType<T>
): 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;