From c3eb51f3d2f32d12fbcd4bb3da24232249697f79 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Wed, 24 Jun 2026 02:40:43 -0400 Subject: [PATCH] =?UTF-8?q?refactor(frontend):=20Phase=205=20=C2=A78.2=20?= =?UTF-8?q?=E2=80=94=20sanitize=20sidebar=20SVG=20icons=20+=20AI=20shortcu?= =?UTF-8?q?t=20a11y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per docs/plans/FRONTEND_UX_TAILWIND_CLEANUP_PLAN.md §8.2 (A1/A8, A2). - Sidebar icons were injected via dangerouslySetInnerHTML. Core icons are trusted constants, but plugin-contributed nav items flow through the same path and could supply arbitrary SVG. Added utils/sanitizeSvg.js: an allowlist sanitizer (DOMParser-based, caches results) that keeps only safe SVG shape elements/attributes and drops scripts/event handlers/external refs. Wired both Sidebar icon sites through it — zero visual change (verified via screenshot). - AI Alt+A shortcut: added a visible title tooltip + aria-keyshortcuts on the chat bubble (discoverable), and made the global handler skippable via localStorage 'sk_ai_shortcut' = 'off'. Verified: npm run build passes; 0 lint errors; dashboard screenshot confirms sidebar icons still render. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/components/Sidebar.jsx | 5 +- frontend/src/components/ai/AIAssistant.jsx | 6 +- frontend/src/components/ai/ChatBubble.jsx | 2 + frontend/src/utils/sanitizeSvg.js | 70 ++++++++++++++++++++++ 4 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 frontend/src/utils/sanitizeSvg.js diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index c1eec932..eae9fdf9 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -9,6 +9,7 @@ import WorkspaceSwitcher from './WorkspaceSwitcher'; import NotificationBell from './NotificationBell'; import { SIDEBAR_CATEGORIES, CATEGORY_LABELS, SIDEBAR_PRESETS, getHiddenItemIds, getVisibleItems, applyWorkspaceNavPermissions } from './sidebarItems'; import { useContributions } from '../plugins/contributions'; +import { sanitizeSvgInner } from '../utils/sanitizeSvg'; const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {} }) => { const { user, logout, updateUser } = useAuth(); @@ -222,7 +223,7 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => { > {item.label} @@ -246,7 +247,7 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => { > {sub.label} diff --git a/frontend/src/components/ai/AIAssistant.jsx b/frontend/src/components/ai/AIAssistant.jsx index cab7935f..8080b89d 100644 --- a/frontend/src/components/ai/AIAssistant.jsx +++ b/frontend/src/components/ai/AIAssistant.jsx @@ -11,8 +11,12 @@ const AIAssistant = () => { const { isAuthenticated } = useAuth(); const { isOpen, toggle, isStreaming, unread, pageContext } = useServerkitAI(); - // Alt+A toggles the assistant (ignored while typing in a field). + // Alt+A toggles the assistant (ignored while typing in a field). Users who + // find the global shortcut intrusive can disable it by setting + // localStorage 'sk_ai_shortcut' to 'off'; the bubble's title/aria-label + // keeps the binding discoverable for everyone else. useEffect(() => { + if (localStorage.getItem('sk_ai_shortcut') === 'off') return undefined; const onKeyDown = (e) => { if (!e.altKey || (e.key !== 'a' && e.key !== 'A')) return; const t = document.activeElement; diff --git a/frontend/src/components/ai/ChatBubble.jsx b/frontend/src/components/ai/ChatBubble.jsx index c3be6afb..b6ec8b0a 100644 --- a/frontend/src/components/ai/ChatBubble.jsx +++ b/frontend/src/components/ai/ChatBubble.jsx @@ -13,6 +13,8 @@ const ChatBubble = ({ open, unread, streaming, raised, onToggle }) => ( ].filter(Boolean).join(' ')} onClick={onToggle} aria-label={open ? 'Close assistant (Alt+A)' : 'Open assistant (Alt+A)'} + title={open ? 'Close assistant (Alt+A)' : 'Open assistant (Alt+A)'} + aria-keyshortcuts="Alt+A" aria-expanded={open} aria-controls="sk-ai-drawer" > diff --git a/frontend/src/utils/sanitizeSvg.js b/frontend/src/utils/sanitizeSvg.js new file mode 100644 index 00000000..cc33838a --- /dev/null +++ b/frontend/src/utils/sanitizeSvg.js @@ -0,0 +1,70 @@ +// Allowlist sanitizer for the inline SVG icon strings rendered in the sidebar. +// +// Core sidebar icons are trusted constants, but plugin-contributed nav items +// flow through the same render path and could supply arbitrary SVG (script, +// event handlers, , external refs). The icons are a small, fixed +// shape grammar, so we parse the string with the real XML parser and rebuild it +// keeping ONLY known-safe elements and attributes — anything else (incl. every +// on*/event handler) is dropped. Results are cached since icon strings are +// stable across renders. + +const ALLOWED_TAGS = new Set([ + 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', + 'g', 'defs', 'lineargradient', 'radialgradient', 'stop', 'clippath', + 'title', 'desc', 'tspan', 'text', 'svg', +]); + +const ALLOWED_ATTRS = new Set([ + 'd', 'x', 'y', 'width', 'height', 'cx', 'cy', 'r', 'rx', 'ry', + 'x1', 'y1', 'x2', 'y2', 'points', 'transform', 'viewbox', + 'preserveaspectratio', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', + 'stroke-linejoin', 'stroke-dasharray', 'stroke-dashoffset', 'opacity', + 'fill-opacity', 'stroke-opacity', 'fill-rule', 'clip-rule', 'clip-path', + 'offset', 'stop-color', 'stop-opacity', 'gradientunits', + 'gradienttransform', 'id', 'class', 'text-anchor', 'font-size', 'dx', 'dy', +]); + +const cache = new Map(); + +export function sanitizeSvgInner(svg) { + if (!svg || typeof svg !== 'string') return ''; + if (cache.has(svg)) return cache.get(svg); + if (typeof DOMParser === 'undefined') return ''; + + let result = ''; + try { + const doc = new DOMParser().parseFromString( + `${svg}`, + 'image/svg+xml' + ); + const root = doc.documentElement; + if (root && !root.getElementsByTagName('parsererror').length) { + scrub(root); + result = root.innerHTML; + } + } catch { + result = ''; + } + + cache.set(svg, result); + return result; +} + +function scrub(node) { + for (const child of Array.from(node.children)) { + const tag = child.tagName.toLowerCase(); + if (!ALLOWED_TAGS.has(tag)) { + child.remove(); + continue; + } + for (const attr of Array.from(child.attributes)) { + const name = attr.name.toLowerCase(); + if (name.startsWith('on') || !ALLOWED_ATTRS.has(name)) { + child.removeAttribute(attr.name); + } + } + scrub(child); + } +} + +export default sanitizeSvgInner;