fix: escape string attributes in JSX export (#754)

Layer names and other string props were written into JSX attributes
verbatim. A `"` closed the attribute and let the rest of the name add
props or expression containers, which `render` and `replace` then
evaluate. Sucrase also decodes `&` entities in attribute strings, so
names containing entities changed on a round trip.

Strings containing `"` or `&` are now written as expression containers
holding a JavaScript string literal. Tailwind JSX export uses the same
helper for `data-name` and `className`.
This commit is contained in:
Danila Poyarkov 2026-09-25 15:50:36 +04:00 committed by GitHub
parent 2d9e83d58a
commit bb5f68880d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 42 additions and 4 deletions

View file

@ -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

View file

@ -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(' ')

View file

@ -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)}}`

View file

@ -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(
'<div data-name={"a\\" onClick={alert(1)} x=\\""} className='
)
})
test('basic rectangle — div with w/h', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {

View file

@ -147,3 +147,20 @@ describe('text props round-trip', () => {
expect(jsx).not.toMatch(/\bw=\{\d+\}/)
})
})
describe('attribute string round-trip', () => {
it.each([
// Unescaped, this name would inject `w={999}` into the exported frame.
'a" w={999} x="',
'Fish &amp; chips',
'Back\\slash',
'Two\nlines',
'Plain name'
])('keeps the layer name %p', async (name) => {
const g = makeSceneGraph()
const [source] = await renderJSX(g, '<Frame w={10} h={10} />')
getNodeOrThrow(g, source.id).name = name
const [result] = await renderJSX(g, sceneNodeToJSX(source.id, g))
expect(getNodeOrThrow(g, result.id)).toMatchObject({ name, width: 10 })
})
})