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;