From f5562d91f9f0443f498f6f04474d2a2eaa2fc054 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Mon, 6 Apr 2026 15:06:42 +0200 Subject: [PATCH] fix(dashboard): add Zod validation to SSE/WebSocket message handlers Generated by Hephaestus (Aegis dev agent) --- dashboard/src/api/resilient-websocket.ts | 4 +++- dashboard/src/api/schemas.ts | 23 +++++++++++++++++++ .../session/TerminalPassthrough.tsx | 21 +++++++++++++---- .../components/session/TranscriptViewer.tsx | 12 +++++++--- 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/dashboard/src/api/resilient-websocket.ts b/dashboard/src/api/resilient-websocket.ts index ec2be3d1..c58c46b1 100644 --- a/dashboard/src/api/resilient-websocket.ts +++ b/dashboard/src/api/resilient-websocket.ts @@ -63,7 +63,9 @@ export class ResilientWebSocket { this.ws.onmessage = (event: MessageEvent) => { if (this.destroyed) return; try { - this.callbacks.onMessage(JSON.parse(event.data as string)); + const raw = JSON.parse(event.data as string); + // Pass raw parsed data to callback; callback is responsible for validation + this.callbacks.onMessage(raw); } catch { // ignore malformed messages } diff --git a/dashboard/src/api/schemas.ts b/dashboard/src/api/schemas.ts index ae9b2694..207d764b 100644 --- a/dashboard/src/api/schemas.ts +++ b/dashboard/src/api/schemas.ts @@ -305,3 +305,26 @@ export const GlobalSSEEventSchema: z.ZodType = z.object({ id: z.number().optional(), data: z.record(z.string(), z.unknown()), }); + +// ── WebSocket Terminal Messages (Issue #1107) ───────────────────── + +const WsPaneMessageSchema = z.object({ + type: z.literal('pane'), + content: z.string(), +}); + +const WsStatusMessageSchema = z.object({ + type: z.literal('status'), + status: z.string(), +}); + +const WsErrorMessageSchema = z.object({ + type: z.literal('error'), + message: z.string(), +}); + +export const WsInboundMessageSchema = z.discriminatedUnion('type', [ + WsPaneMessageSchema, + WsStatusMessageSchema, + WsErrorMessageSchema, +]); diff --git a/dashboard/src/components/session/TerminalPassthrough.tsx b/dashboard/src/components/session/TerminalPassthrough.tsx index 0ddb138b..30fd5f8c 100644 --- a/dashboard/src/components/session/TerminalPassthrough.tsx +++ b/dashboard/src/components/session/TerminalPassthrough.tsx @@ -8,7 +8,8 @@ import { useStore } from '../../store/useStore'; import type { AppState } from '../../store/useStore'; import { useToastStore } from '../../store/useToastStore'; import { getSessionMessages, subscribeSSE } from '../../api/client'; -import type { ParsedEntry, WsInboundMessage, UIState } from '../../types'; +import type { ParsedEntry, UIState } from '../../types'; +import { SessionSSEEventDataSchema, WsInboundMessageSchema } from '../../api/schemas'; interface TerminalPassthroughProps { sessionId: string; @@ -120,9 +121,14 @@ export function TerminalPassthrough({ sessionId, status }: TerminalPassthroughPr useEffect(() => { const unsubscribe = subscribeSSE(sessionId, (e) => { try { - const raw = JSON.parse(e.data as string); - if (raw.event !== 'message') return; - const data: ParsedEntry = raw.data; + const result = SessionSSEEventDataSchema.safeParse(JSON.parse(e.data as string)); + if (!result.success) { + console.warn('SSE event failed validation', result.error.message); + return; + } + const parsed = result.data; + if (parsed.event !== 'message') return; + const data = parsed.data as unknown as ParsedEntry; setMessages(prev => { const key = dedupKey(data); if (seenKeys.current.has(key)) return prev; @@ -252,7 +258,12 @@ export function TerminalPassthrough({ sessionId, status }: TerminalPassthroughPr const url = getWsUrl(); const ws = new ResilientWebSocket(url, { onMessage: (data: unknown) => { - const msg = data as WsInboundMessage; + const result = WsInboundMessageSchema.safeParse(data); + if (!result.success) { + console.warn('WebSocket message failed validation', result.error.message); + return; + } + const msg = result.data; const term = xtermRef.current; if (!term) return; diff --git a/dashboard/src/components/session/TranscriptViewer.tsx b/dashboard/src/components/session/TranscriptViewer.tsx index 033ba630..c6dbdb75 100644 --- a/dashboard/src/components/session/TranscriptViewer.tsx +++ b/dashboard/src/components/session/TranscriptViewer.tsx @@ -4,6 +4,7 @@ import type { ParsedEntry } from '../../types'; import { getSessionMessages, subscribeSSE } from '../../api/client'; import { useStore } from '../../store/useStore'; import { MessageBubble } from './MessageBubble'; +import { SessionSSEEventDataSchema } from '../../api/schemas'; const MAX_SESSION_MESSAGES = 1000; @@ -66,11 +67,16 @@ export function TranscriptViewer({ sessionId }: TranscriptViewerProps) { useEffect(() => { const unsubscribe = subscribeSSE(sessionId, (e) => { try { - const raw = JSON.parse(e.data as string); + const result = SessionSSEEventDataSchema.safeParse(JSON.parse(e.data as string)); + if (!result.success) { + console.warn('SSE event failed validation', result.error.message); + return; + } + const parsed = result.data; // Issue #261: Only process message events; skip status, heartbeat, // approval, stall, dead, ended, hook, and subagent events. - if (raw.event !== 'message') return; - const data: ParsedEntry = raw.data; + if (parsed.event !== 'message') return; + const data = parsed.data as unknown as ParsedEntry; setMessages(prev => { const key = dedupKey(data); if (seenKeys.current.has(key)) return prev;