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:
parent
2d9e83d58a
commit
bb5f68880d
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(' ')
|
||||
|
|
|
|||
|
|
@ -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)}}`
|
||||
|
|
|
|||
|
|
@ -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), {
|
||||
|
|
|
|||
|
|
@ -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 & 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 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue