diff --git a/CHANGELOG.md b/CHANGELOG.md index f6f741047..4a75e2cb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ ### Security - Evaluate `calc` expressions through `jsep` and an arithmetic allowlist that never compiles input into JavaScript, replacing the `expr-eval` dependency and its unpatched critical code-execution advisory (GHSA-q9v2-7m5w-4693). +- Escape layer names and other text properties in JSX and Tailwind JSX export, so text from a document can no longer add attributes or JavaScript expressions that the AI and MCP `render` and `replace` tools would execute, and names containing `&` no longer change when the JSX is rendered back. ## 0.15.1 — 2026-09-18 diff --git a/packages/core/src/io/formats/jsx/export.ts b/packages/core/src/io/formats/jsx/export.ts index 80f3a882b..f61d57999 100644 --- a/packages/core/src/io/formats/jsx/export.ts +++ b/packages/core/src/io/formats/jsx/export.ts @@ -308,9 +308,10 @@ function nodeToJSX(node: SceneNode, graph: SceneGraph, indent: number, format: J if (format === 'tailwind') { const classes = collectTailwindClasses(node, graph) - const nameAttr = node.name && node.name !== node.type ? ` data-name="${node.name}"` : '' - const classAttr = classes.length > 0 ? ` className="${classes.join(' ')}"` : '' - attrsStr = `${nameAttr}${classAttr}`.trim() + const attrs: [string, string][] = [] + if (node.name && node.name !== node.type) attrs.push(['data-name', node.name]) + if (classes.length > 0) attrs.push(['className', classes.join(' ')]) + attrsStr = attrs.map(([k, v]) => formatProp(k, v)).join(' ') } else { const props = collectProps(node, graph) attrsStr = props.map(([k, v]) => formatProp(k, v)).join(' ') diff --git a/packages/core/src/io/formats/jsx/helpers.ts b/packages/core/src/io/formats/jsx/helpers.ts index 4e021ceeb..8fec44504 100644 --- a/packages/core/src/io/formats/jsx/helpers.ts +++ b/packages/core/src/io/formats/jsx/helpers.ts @@ -50,8 +50,15 @@ export function escapeJSXText(text: string): string { return text.replace(/[{}<>&]/g, (c) => JSX_ENTITY[c]) } +/** + * JSX attribute strings end at `"` and decode `&` entities, so other strings become + * expression containers with a JavaScript string literal. + */ +const LITERAL_ATTRIBUTE = /^[^"&]*$/ + export function formatProp(key: string, value: unknown): string { - if (typeof value === 'string') return `${key}="${value}"` + if (typeof value === 'string') + return LITERAL_ATTRIBUTE.test(value) ? `${key}="${value}"` : `${key}={${JSON.stringify(value)}}` if (typeof value === 'number') return `${key}={${value}}` if (typeof value === 'boolean') return value ? key : `${key}={false}` return `${key}={${JSON.stringify(value)}}` diff --git a/tests/engine/render/jsx/tailwind.test.ts b/tests/engine/render/jsx/tailwind.test.ts index 5d6d04d81..b460b38e8 100644 --- a/tests/engine/render/jsx/tailwind.test.ts +++ b/tests/engine/render/jsx/tailwind.test.ts @@ -17,6 +17,18 @@ function tw(graph: SceneGraph, nodeId: string) { } describe('Tailwind JSX export', () => { + test('layer names cannot add attributes', () => { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + name: 'a" onClick={alert(1)} x="', + width: 100, + height: 48 + }) + expect(tw(graph, node.id)).toStartWith( + '