From f4f8eac3a36c762e195beedefa1d71f6bfd7be10 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 6 Mar 2026 00:47:59 -0600 Subject: [PATCH] fix(ui): resolve 3 critical security and UX issues 1. fix(security): prevent JSON DoS via size cap on auto-parse - Add MAX_JSON_AUTOPARSE_CHARS (20KB) to detectJson() - Prevents UI freeze from multi-MB JSON in assistant/tool messages - Addresses Aisle Security High severity CWE-400 2. fix(ux): prevent STT transcripts going to wrong session - Add cleanupChatModuleState() export in chat.ts - Call cleanup in applyTabSelection when leaving chat tab - Stops active recording to prevent voice input to unintended session - Addresses Greptile critical UX bug 3. fix(security): redact sensitive values in config diff panel - Add renderDiffValue() with stream-mode + sensitive-path checks - Use in diff panel rendering instead of raw truncateValue() - Prevents secrets from appearing during screen sharing - Addresses Aisle Security Medium severity CWE-200 --- ui/src/ui/app-settings.ts | 8 ++++++++ ui/src/ui/chat/grouped-render.ts | 13 +++++++++++++ ui/src/ui/views/chat.ts | 22 ++++++++++++++++++++++ ui/src/ui/views/config.ts | 25 +++++++++++++++++++++++-- 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index a1a7bdf945b..e8505830776 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -34,6 +34,7 @@ import { import { saveSettings, type UiSettings } from "./storage.ts"; import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition.ts"; import { resolveTheme, type ResolvedTheme, type ThemeMode, type ThemeName } from "./theme.ts"; +import { cleanupChatModuleState } from "./views/chat.ts"; let systemThemeCleanup: (() => void) | null = null; import type { AgentsListResult, AttentionItem } from "./types.ts"; @@ -365,9 +366,16 @@ function applyTabSelection( next: Tab, options: { refreshPolicy: "always" | "connected"; syncUrl?: boolean }, ) { + const prev = host.tab; if (host.tab !== next) { host.tab = next; } + + // Cleanup chat module state when navigating away from chat + if (prev === "chat" && next !== "chat") { + cleanupChatModuleState(); + } + if (next === "chat") { host.chatHasAutoScrolled = false; } diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index e92ad106939..79aae348697 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -501,12 +501,25 @@ function renderCollapsedToolCards( `; } +/** + * Max characters for auto-detecting and pretty-printing JSON. + * Prevents DoS from large JSON payloads in assistant/tool messages. + */ +const MAX_JSON_AUTOPARSE_CHARS = 20_000; + /** * Detect whether a trimmed string is a JSON object or array. * Must start with `{`/`[` and end with `}`/`]` and parse successfully. + * Size-capped to prevent render-loop DoS from large JSON messages. */ function detectJson(text: string): { parsed: unknown; pretty: string } | null { const t = text.trim(); + + // Enforce size cap to prevent UI freeze from multi-MB JSON payloads + if (t.length > MAX_JSON_AUTOPARSE_CHARS) { + return null; + } + if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) { try { const parsed = JSON.parse(t); diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 8048bc88670..fcf13c8047c 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -147,6 +147,28 @@ let searchOpen = false; let searchQuery = ""; let pinnedExpanded = false; +/** + * Cleanup module-level state when navigating away from chat view. + * Prevents STT recording from continuing after tab switch (which would + * send transcripts to the wrong session) and resets ephemeral UI state. + */ +export function cleanupChatModuleState() { + if (sttRecording) { + stopStt(); + sttRecording = false; + sttInterimText = ""; + } + slashMenuOpen = false; + slashMenuItems = []; + slashMenuIndex = 0; + slashMenuMode = "command"; + slashMenuCommand = null; + slashMenuArgItems = []; + searchOpen = false; + searchQuery = ""; + pinnedExpanded = false; +} + function adjustTextareaHeight(el: HTMLTextAreaElement) { el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, 150)}px`; diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index 9335f66f9a5..ea052c019cc 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -6,6 +6,7 @@ import type { ConfigUiHints } from "../types.ts"; import { countSensitiveConfigValues, humanize, + isSensitiveConfigPath, pathKey, REDACTED_PLACEHOLDER, schemaType, @@ -501,6 +502,26 @@ function truncateValue(value: unknown, maxLen = 40): string { return str.slice(0, maxLen - 3) + "..."; } +/** + * Render diff value with redaction when in stream mode or path is sensitive. + * Prevents secrets from appearing in diff panel during screen sharing. + */ +function renderDiffValue( + path: string, + value: unknown, + streamMode: boolean, + uiHints: ConfigUiHints, +): string { + const hint = uiHints[path]; + const sensitive = hint?.sensitive ?? isSensitiveConfigPath(path); + + if (streamMode && sensitive) { + return REDACTED_PLACEHOLDER; + } + + return truncateValue(value); +} + type ThemeOption = { id: ThemeName; label: string; description: string; icon: TemplateResult }; const THEME_OPTIONS: ThemeOption[] = [ { id: "claw", label: "Claw", description: "Chroma family", icon: icons.zap }, @@ -909,11 +930,11 @@ export function renderConfig(props: ConfigProps) {
${change.path}
${truncateValue(change.from)}${renderDiffValue(change.path, change.from, props.streamMode, props.uiHints)} ${truncateValue(change.to)}${renderDiffValue(change.path, change.to, props.streamMode, props.uiHints)}