mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
co-authored by
LawyerLyu
Claude Sonnet 4.6
parent
13500dbb37
commit
049d1ea7ab
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user