refactor(frontend): Phase 5 §8.2 — sanitize sidebar SVG icons + AI shortcut a11y

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) <noreply@anthropic.com>
This commit is contained in:
Juan DenisandClaude Opus 4.8 committed 2026-06-24 02:40:43 -04:00
1 parent d6b631ec34
commit c3eb51f3d2
4 files changed
+80 -3

No files matched your search

+3 -2
View File
@@ -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 = () => {
>
<svg className="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
aria-hidden="true" focusable="false"
dangerouslySetInnerHTML={{ __html: item.icon }}
dangerouslySetInnerHTML={{ __html: sanitizeSvgInner(item.icon) }}
/>
{item.label}
</NavLink>
@@ -246,7 +247,7 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {
>
<svg className="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
aria-hidden="true" focusable="false"
dangerouslySetInnerHTML={{ __html: sub.icon }}
dangerouslySetInnerHTML={{ __html: sanitizeSvgInner(sub.icon) }}
/>
{sub.label}
</NavLink>
+5 -1
View File
@@ -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;
@@ -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"
>
+70
View File
@@ -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, <foreignObject>, 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 xmlns="http://www.w3.org/2000/svg">${svg}</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;