fix(web): MermaidDiagram XSS, isSafeId allows @, rawMermaid typed

- Add DOMPurify.sanitize() to MermaidDiagram.tsx dangerouslySetInnerHTML
  (was rendering AI-generated SVG unsanitized — the highest-risk surface)
- Add @ to isSafeId regex for scoped npm packages (@angular/core, etc.)
- Add rawMermaid to ProcessData interface, remove (process as any) casts
- Update security tests: @scope/pkg now accepted, @angular/core added

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jreakin 2026-03-23 05:01:57 -05:00
parent 8919b80efc
commit 4c2b3b86f0
5 changed files with 9 additions and 6 deletions

View file

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import mermaid from 'mermaid';
import DOMPurify from 'dompurify';
import { AlertTriangle, Maximize2 } from 'lucide-react';
import { ProcessFlowModal } from './ProcessFlowModal';
import type { ProcessData } from '../lib/mermaid-generator';
@ -140,7 +141,7 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
<div
ref={containerRef}
className="flex items-center justify-center p-4 overflow-auto max-h-[400px]"
dangerouslySetInnerHTML={{ __html: svg }}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } }) }}
/>
</div>
</div>

View file

@ -138,8 +138,8 @@ export const ProcessFlowModal = ({ process, onClose, onFocusInGraph, isFullScree
const renderDiagram = async () => {
try {
// Check if we have raw mermaid code (from AI chat) or need to generate it
const mermaidCode = (process as any).rawMermaid
? (process as any).rawMermaid
const mermaidCode = process.rawMermaid
? process.rawMermaid
: generateProcessMermaid(process);
const id = `mermaid-${Date.now()}`;

View file

@ -12,7 +12,7 @@ import { ProcessFlowModal } from './ProcessFlowModal';
import type { ProcessData, ProcessStep } from '../lib/mermaid-generator';
/** Validate that an ID contains only expected node identifier characters (no Cypher metacharacters or spaces) */
const isSafeId = (id: string): boolean => /^[a-zA-Z0-9_:.\-/]+$/.test(id);
const isSafeId = (id: string): boolean => /^[a-zA-Z0-9_:.\-/@]+$/.test(id);
export const ProcessesPanel = () => {
const { graph, runQuery, setHighlightedNodeIds, highlightedNodeIds } = useAppState();

View file

@ -26,6 +26,7 @@ export interface ProcessData {
steps: ProcessStep[];
edges?: ProcessEdge[]; // CALLS edges between steps for branching
clusters?: string[];
rawMermaid?: string; // AI-generated mermaid code (sanitized before rendering)
}
/**

View file

@ -18,7 +18,7 @@ const validRelType = (t: string): boolean =>
(REL_TYPES as readonly string[]).includes(t);
const isSafeId = (id: string): boolean =>
/^[a-zA-Z0-9_:.\-/]+$/.test(id);
/^[a-zA-Z0-9_:.\-/@]+$/.test(id);
const isWriteQuery = (cypher: string): boolean =>
/\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|DETACH)\b/.test(cypher.toUpperCase());
@ -104,13 +104,14 @@ describe('isSafeId – identifier allowlist regex', () => {
['community id', 'comm_5'],
['file path id', 'File:src/index.ts'],
['nested path id', 'Function:src/utils/helpers.ts:doStuff'],
['scoped npm package', 'Module:@scope/pkg'],
['angular-style id', 'Module:@angular/core'],
])('accepts valid ID: %s', (_desc, id) => {
expect(isSafeId(id)).toBe(true);
});
it.each([
['with spaces', 'Process:my process'],
['with @ symbol', 'Module:@scope/pkg'],
])('rejects ID with unsafe chars: %s', (_desc, id) => {
expect(isSafeId(id)).toBe(false);
});