mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-29 16:17:25 +09:00
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:
1 parent
d6b631ec34
commit
c3eb51f3d2
4 files changed
+80
-3
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user