openpencil/src/utils/svg-parser.ts
Kayshen Xu 12f8141c2e V0.1.2 (#19)
* Security hardening: fix critical and high-severity vulnerabilities (#18)

* feat(mcp): add sanitizeObject utility to strip prototype pollution keys

Recursively removes __proto__, constructor, and prototype keys from
parsed JSON objects to prevent prototype pollution attacks via
malicious .op files or batch_design DSL input.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(electron): validate file path in saveToPath IPC handler

Prevent path traversal attacks by checking for null bytes and restricting
file extensions to .op and .pen only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(mcp): guard against prototype pollution in document parsing and batch design

Sanitize JSON.parse output in openDocument() and parseJsonArg() to strip
__proto__, constructor, and prototype keys before processing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ai): sanitize debug logs and harden temp file handling

Filter credential patterns from debug tail before sending to client.
Set restrictive 0o700 permissions on temp directory. Validate attachment
media types against allowlist to prevent extension spoofing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ai): restrict environment variables passed to codex subprocess

Replace full process.env with explicit allowlist of PATH, HOME, TERM,
LANG, SHELL, TMPDIR, and OPENAI_*/CODEX_* prefixed vars only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(figma): add decompression size limits to prevent zip bombs

Enforce 100MB total unzipped size and 50MB per-image limits during
.fig file extraction to guard against malicious archives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(canvas): add dangerous SVG tags to skip list and fix ReDoS in getAttr

Strip script, foreignObject, animate, animateMotion, and set elements
during SVG import. Escape regex-special characters in style attribute
name lookup to prevent ReDoS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(security): add unit tests for security hardening fixes

27 tests covering: sanitizeObject prototype pollution stripping,
document-manager sanitization, batch-design DSL sanitization,
codex env allowlist, debug tail credential filtering, media type
validation, SVG skip tags, and ReDoS safety.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* feat(mcp): add live canvas sync and HTTP transport support

Introduce real-time MCP ↔ renderer sync via SSE (server/api/mcp endpoints,
use-mcp-sync hook, mcp-sync-state). Add StreamableHTTPServerTransport for
HTTP and dual stdio+http modes. Electron writes ~/.openpencil/.port for MCP
discovery. New design_prompt tool. Agent settings dialog gains transport
mode selector. Security: restrict Electron file writes to home/temp dirs.
Bump version to 0.1.2.

---------

Co-authored-by: RolandSherwin <RolandSherwin@protonmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:08:52 +08:00

381 lines
11 KiB
TypeScript

import type { PenNode } from '@/types/pen'
import type { PenFill, PenStroke } from '@/types/styles'
import { generateId } from '@/stores/document-store'
/** Inherited style context passed from parent SVG/g elements */
interface StyleCtx {
fill: string | null
stroke: string | null
strokeWidth: number
}
/**
* Parse an SVG string into editable PenNode array.
* Handles fill/stroke inheritance, path coordinate scaling, and viewBox transforms.
*/
export function parseSvgToNodes(
svgText: string,
maxDim = 400,
): PenNode[] {
const parser = new DOMParser()
const doc = parser.parseFromString(svgText, 'image/svg+xml')
const svg = doc.querySelector('svg')
if (!svg) return []
// Resolve source dimensions from viewBox or width/height
const vb = svg.getAttribute('viewBox')?.split(/[\s,]+/).map(Number)
const svgW = parseFloat(svg.getAttribute('width') ?? '') || vb?.[2] || 100
const svgH = parseFloat(svg.getAttribute('height') ?? '') || vb?.[3] || 100
// Compute scale to fit within maxDim
const scale =
svgW > maxDim || svgH > maxDim ? maxDim / Math.max(svgW, svgH) : 1
// Build inherited style context from <svg> attributes
const rootCtx: StyleCtx = {
fill: svg.getAttribute('fill'),
stroke: svg.getAttribute('stroke'),
strokeWidth: parseFloat(svg.getAttribute('stroke-width') ?? '') || 1,
}
const nodes = parseChildren(svg, scale, rootCtx)
if (nodes.length === 0) return []
if (nodes.length === 1) return nodes
// Wrap multiple elements in a frame
return [
{
id: generateId(),
type: 'frame',
name: 'SVG',
width: Math.round(svgW * scale),
height: Math.round(svgH * scale),
layout: 'none' as const,
children: nodes,
},
]
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
const SKIP_TAGS = new Set([
'defs', 'style', 'title', 'desc', 'metadata',
'clippath', 'mask', 'filter', 'lineargradient', 'radialgradient',
'symbol', 'marker', 'pattern',
'script', 'foreignobject', 'animate', 'animatemotion', 'set',
])
function parseChildren(
parent: Element,
scale: number,
ctx: StyleCtx,
): PenNode[] {
const nodes: PenNode[] = []
for (const el of parent.children) {
const node = parseElement(el, scale, ctx)
if (node) nodes.push(node)
}
return nodes
}
function parseElement(
el: Element,
scale: number,
parentCtx: StyleCtx,
): PenNode | null {
const tag = el.tagName.toLowerCase()
if (SKIP_TAGS.has(tag)) return null
// Merge this element's style context with parent
const ctx = mergeStyleCtx(parentCtx, el)
// Handle <g> by recursing with inherited styles
if (tag === 'g') {
const children = parseChildren(el, scale, ctx)
if (children.length === 0) return null
if (children.length === 1) return children[0]
return {
id: generateId(),
type: 'frame',
name: el.getAttribute('id') ?? 'Group',
layout: 'none' as const,
children,
}
}
// Resolve fill & stroke from element + inherited context
const fill = resolveFill(el, ctx)
const stroke = resolveStroke(el, ctx, scale)
const opacity = parseFloat(getAttr(el, 'opacity') ?? '1')
const base = {
id: generateId(),
opacity: opacity !== 1 ? opacity : undefined,
}
switch (tag) {
case 'path': {
const d = el.getAttribute('d')
if (!d) return null
const scaledD = scaleSvgPath(d, scale)
const bbox = getPathBBox(scaledD)
return {
...base,
type: 'path' as const,
name: el.getAttribute('id') ?? 'Path',
d: scaledD,
x: bbox.x,
y: bbox.y,
width: Math.round(bbox.w),
height: Math.round(bbox.h),
fill,
stroke,
}
}
case 'rect': {
const x = num(el, 'x') * scale
const y = num(el, 'y') * scale
const w = num(el, 'width') * scale
const h = num(el, 'height') * scale
const rx = num(el, 'rx') * scale
return {
...base,
type: 'rectangle' as const,
name: el.getAttribute('id') ?? 'Rectangle',
x, y,
width: Math.round(w),
height: Math.round(h),
cornerRadius: rx || undefined,
fill,
stroke,
}
}
case 'circle': {
const cx = num(el, 'cx') * scale
const cy = num(el, 'cy') * scale
const r = num(el, 'r') * scale
return {
...base,
type: 'ellipse' as const,
name: el.getAttribute('id') ?? 'Circle',
x: cx - r, y: cy - r,
width: Math.round(r * 2),
height: Math.round(r * 2),
fill,
stroke,
}
}
case 'ellipse': {
const cx = num(el, 'cx') * scale
const cy = num(el, 'cy') * scale
const rx = num(el, 'rx') * scale
const ry = num(el, 'ry') * scale
return {
...base,
type: 'ellipse' as const,
name: el.getAttribute('id') ?? 'Ellipse',
x: cx - rx, y: cy - ry,
width: Math.round(rx * 2),
height: Math.round(ry * 2),
fill,
stroke,
}
}
case 'line': {
return {
...base,
type: 'line' as const,
name: el.getAttribute('id') ?? 'Line',
x: num(el, 'x1') * scale,
y: num(el, 'y1') * scale,
x2: num(el, 'x2') * scale,
y2: num(el, 'y2') * scale,
stroke: stroke ?? { thickness: 1, fill: [{ type: 'solid', color: '#000000' }] },
}
}
case 'polygon':
case 'polyline': {
const pts = el.getAttribute('points')
if (!pts) return null
const scaledD = scaleSvgPath(pointsToD(pts, tag === 'polygon'), scale)
const bbox = getPathBBox(scaledD)
return {
...base,
type: 'path' as const,
name: el.getAttribute('id') ?? (tag === 'polygon' ? 'Polygon' : 'Polyline'),
d: scaledD,
x: bbox.x,
y: bbox.y,
width: Math.round(bbox.w),
height: Math.round(bbox.h),
fill: tag === 'polygon' ? fill : noFill(),
stroke,
}
}
default:
return null
}
}
// ---------------------------------------------------------------------------
// Style resolution with inheritance
// ---------------------------------------------------------------------------
/** Read an attribute from the element, checking inline `style` first */
function getAttr(el: Element, name: string): string | null {
// Check inline style first (higher priority)
const style = el.getAttribute('style')
if (style) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const m = style.match(new RegExp(`${escaped}\\s*:\\s*([^;]+)`))
if (m) return m[1].trim()
}
return el.getAttribute(name)
}
/** Build style context by merging parent context with this element's attributes */
function mergeStyleCtx(parent: StyleCtx, el: Element): StyleCtx {
return {
fill: getAttr(el, 'fill') ?? parent.fill,
stroke: getAttr(el, 'stroke') ?? parent.stroke,
strokeWidth: parseFloat(getAttr(el, 'stroke-width') ?? '') || parent.strokeWidth,
}
}
/** Normalize a color string: resolve currentColor, inherit → black */
function normalizeColor(raw: string): string {
if (raw === 'currentColor' || raw === 'inherit') return '#000000'
return raw
}
/** Explicit "no fill" — transparent so canvas DEFAULT_FILL won't apply */
function noFill(): PenFill[] {
return [{ type: 'solid', color: 'transparent' }]
}
/** Resolve fill from element attribute + inherited context → PenFill[] */
function resolveFill(el: Element, ctx: StyleCtx): PenFill[] | undefined {
const raw = getAttr(el, 'fill') ?? ctx.fill
// Explicit "none" → transparent fill
if (raw === 'none') return noFill()
// url(#gradient) references — not supported, use black fallback
if (raw && raw.startsWith('url(')) return [{ type: 'solid', color: '#000000' }]
// Has a color
if (raw) return [{ type: 'solid', color: normalizeColor(raw) }]
// SVG spec default: black fill
return [{ type: 'solid', color: '#000000' }]
}
/** Resolve stroke from element attribute + inherited context → PenStroke */
function resolveStroke(el: Element, ctx: StyleCtx, scale: number): PenStroke | undefined {
const raw = getAttr(el, 'stroke') ?? ctx.stroke
if (!raw || raw === 'none') return undefined
if (raw.startsWith('url(')) return undefined
const width = (parseFloat(getAttr(el, 'stroke-width') ?? '') || ctx.strokeWidth) * scale
return {
thickness: width,
fill: [{ type: 'solid', color: normalizeColor(raw) }],
}
}
// ---------------------------------------------------------------------------
// SVG path data scaling
// ---------------------------------------------------------------------------
/**
* Scale all coordinates in an SVG path `d` string by a factor.
* Handles M/L/C/S/Q/T/H/V/A commands (both absolute and relative).
* For arc (A) commands, only rx/ry/x/y are scaled — flags and rotation are preserved.
*/
function scaleSvgPath(d: string, scale: number): string {
if (scale === 1) return d
// Tokenize into commands and numbers
const tokens = d.match(/[a-zA-Z]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g)
if (!tokens) return d
let result = ''
let cmd = ''
let paramIdx = 0
for (const tok of tokens) {
if (/[a-zA-Z]/.test(tok)) {
cmd = tok
paramIdx = 0
result += tok
continue
}
const n = parseFloat(tok)
const upper = cmd.toUpperCase()
if (upper === 'A') {
// Arc: rx ry x-rotation large-arc-flag sweep-flag x y (7 params per arc)
const pos = paramIdx % 7
// Scale rx(0), ry(1), x(5), y(6); keep rotation(2), flags(3,4) unchanged
const shouldScale = pos === 0 || pos === 1 || pos === 5 || pos === 6
result += ' ' + (shouldScale ? n * scale : n)
} else {
// All other commands: every param is a coordinate → scale it
result += ' ' + n * scale
}
paramIdx++
}
return result.trim()
}
// ---------------------------------------------------------------------------
// Path bounding box (uses browser SVG engine for accuracy)
// ---------------------------------------------------------------------------
/** Compute the bounding box of an SVG path d string via the browser's SVG DOM */
function getPathBBox(d: string): { x: number; y: number; w: number; h: number } {
try {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', d)
svg.appendChild(path)
svg.style.position = 'absolute'
svg.style.width = '0'
svg.style.height = '0'
svg.style.overflow = 'hidden'
document.body.appendChild(svg)
const bbox = path.getBBox()
document.body.removeChild(svg)
return { x: bbox.x, y: bbox.y, w: bbox.width, h: bbox.height }
} catch {
return { x: 0, y: 0, w: 100, h: 100 }
}
}
// ---------------------------------------------------------------------------
// Utility
// ---------------------------------------------------------------------------
function num(el: Element, attr: string): number {
return parseFloat(getAttr(el, attr) ?? '0') || 0
}
function pointsToD(points: string, close: boolean): string {
const nums = points.trim().split(/[\s,]+/).map(Number)
if (nums.length < 2) return ''
let d = `M${nums[0]} ${nums[1]}`
for (let i = 2; i < nums.length - 1; i += 2) {
d += `L${nums[i]} ${nums[i + 1]}`
}
if (close) d += 'Z'
return d
}