Fix C1/C2: XSS via unsanitized style keys and animation attrs

C1: cssPropsToString emitted the camelToKebab'd style-object KEY
unsanitized while only sanitizing the VALUE. A malicious style key
containing a quote (reachable via AI update_props or deserialized
saved state) could close the style="..." attribute and inject a live
element. Now validates each key against a CSS property/custom-prop
allowlist and drops anything that doesn't match.

C2: buildDataAttrs (html-export.ts) interpolated props.animation and
props.animationDelay directly into data-animation="..."/
data-animation-delay="..." with no escaping, for every exported node.
Now routes both through escapeAttr.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 17:27:22 -07:00
parent 36c3b2f503
commit e5f30a4a56
4 changed files with 143 additions and 4 deletions
+66
View File
@@ -54,3 +54,69 @@ describe('cssPropsToString sanitizes emitted values (A5)', () => {
expect(withoutEntities).not.toContain(';');
});
});
describe('cssPropsToString sanitizes emitted KEYS (C1 -- style-object key breakout)', () => {
test('a key containing a double-quote is dropped entirely, not emitted', () => {
const out = cssPropsToString({
color: 'red',
'"><img src=x onerror=alert(1)>': '1',
} as any);
expect(out).not.toContain('"><img');
expect(out).not.toContain('onerror');
expect(out).toBe('color:red');
});
test('a key containing > is dropped', () => {
const out = cssPropsToString({ 'foo>bar': '1' } as any);
expect(out).toBe('');
});
test('a key containing < is dropped', () => {
const out = cssPropsToString({ 'foo<bar': '1' } as any);
expect(out).toBe('');
});
test('a key containing a semicolon is dropped', () => {
const out = cssPropsToString({ 'foo;bar': '1' } as any);
expect(out).toBe('');
});
test('a key containing a space is dropped', () => {
const out = cssPropsToString({ 'foo bar': '1' } as any);
expect(out).toBe('');
});
test('legit simple key "color" is preserved', () => {
const out = cssPropsToString({ color: 'red' } as any);
expect(out).toBe('color:red');
});
test('legit camelCase key backgroundColor -> background-color is preserved', () => {
const out = cssPropsToString({ backgroundColor: 'blue' } as any);
expect(out).toBe('background-color:blue');
});
test('a CSS custom property key (--custom-prop) is preserved', () => {
const out = cssPropsToString({ '--custom-prop': '10px' } as any);
expect(out).toBe('--custom-prop:10px');
});
test('a vendor-prefixed key (-webkit-...) is preserved', () => {
const out = cssPropsToString({ WebkitBoxShadow: '0 0 1px red' } as any);
expect(out).toBe('-webkit-box-shadow:0 0 1px red');
});
test('a digits-only key is dropped', () => {
const out = cssPropsToString({ '123': '1' } as any);
expect(out).toBe('');
});
test('other legit keys in the same object still emit even when a malicious key is dropped', () => {
const out = cssPropsToString({
color: 'red',
'"><script>alert(1)</script>': 'x',
backgroundColor: 'blue',
} as any);
expect(out).toBe('color:red;background-color:blue');
});
});