Skip to content

Commit 11f2e52

Browse files
auge2uclaude
andcommitted
feat(perf+test): budget enforcement, sideEffects, security test suite
Performance (Phase 2B findings): - Add "sideEffects": false to themes/package.json — enables consumer bundlers (Vite/Rollup/webpack) to tree-shake unused theme exports - Enforce PERF_BUDGETS in generate-themes.ts — gzip size and property count now checked at generation time, not just as aspirational constants - Fix contrast: true hardcoding → null in generate script and type def (FoundationMeta.validation.contrast is now boolean | null per ADR-005) Testing (Phase 3A): - Add sanitize-security.test.ts (45 tests) — full coverage of every UNSAFE_CSS_VALUE pattern and the new formatCssValue() injection guard - Add validate-references.test.ts (13 tests) — covers validateReferences() which previously had zero test coverage Total: 141 tests (up from 83). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c993484 commit 11f2e52

8 files changed

Lines changed: 632 additions & 6 deletions

File tree

packages/themes/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"publishConfig": {
2424
"access": "public"
2525
},
26+
"sideEffects": false,
2627
"type": "module",
2728
"main": "./dist/index.cjs",
2829
"module": "./dist/index.js",

packages/themes/src/nihon-traditional/meta.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
],
5757
"validation": {
5858
"schema": true,
59-
"contrast": true,
59+
"contrast": null,
6060
"completeness": true
6161
}
6262
}

packages/themes/src/swiss-international/meta.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
],
5151
"validation": {
5252
"schema": true,
53-
"contrast": true,
53+
"contrast": null,
5454
"completeness": true
5555
}
5656
}
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
/**
2+
* Security-focused tests for CSS sanitization functions.
3+
*
4+
* Covers: sanitizeCssValue, formatCssValue, sanitizeCssComment, assertHex
5+
* These are the injection-prevention boundaries — every UNSAFE_CSS_VALUE pattern
6+
* must have an explicit test case that proves the guard fires.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { sanitizeCssValue, formatCssValue, sanitizeCssComment, assertHex } from '../utilities';
11+
12+
// ---------------------------------------------------------------------------
13+
// sanitizeCssValue — UNSAFE_CSS_VALUE pattern coverage
14+
// ---------------------------------------------------------------------------
15+
16+
describe('sanitizeCssValue — injection patterns blocked', () => {
17+
it('passes safe hex color values', () => {
18+
expect(sanitizeCssValue('#FF0000')).toBe('#FF0000');
19+
});
20+
21+
it('passes safe dimension values', () => {
22+
expect(sanitizeCssValue('8px')).toBe('8px');
23+
expect(sanitizeCssValue('1.5rem')).toBe('1.5rem');
24+
expect(sanitizeCssValue('100%')).toBe('100%');
25+
});
26+
27+
it('passes safe quoted font family strings', () => {
28+
expect(sanitizeCssValue('"Inter", sans-serif')).toBe('"Inter", sans-serif');
29+
});
30+
31+
it('blocks opening curly brace { (context escape attempt)', () => {
32+
expect(() => sanitizeCssValue('red { color: blue')).toThrow('Unsafe CSS value');
33+
});
34+
35+
it('blocks closing curly brace } (context escape attempt)', () => {
36+
expect(() => sanitizeCssValue('red } body { color: blue')).toThrow('Unsafe CSS value');
37+
});
38+
39+
it('blocks url() function (resource loading)', () => {
40+
expect(() => sanitizeCssValue('url(https://evil.com/image.png)')).toThrow('Unsafe CSS value');
41+
});
42+
43+
it('blocks url() with leading whitespace (bypass attempt)', () => {
44+
expect(() => sanitizeCssValue('url (https://evil.com)')).toThrow('Unsafe CSS value');
45+
});
46+
47+
it('blocks expression() function (IE CSS expression execution)', () => {
48+
expect(() => sanitizeCssValue('expression(alert(1))')).toThrow('Unsafe CSS value');
49+
});
50+
51+
it('blocks expression() with internal whitespace (bypass attempt)', () => {
52+
expect(() => sanitizeCssValue('expression (document.cookie)')).toThrow('Unsafe CSS value');
53+
});
54+
55+
it('blocks @import at-rule', () => {
56+
expect(() => sanitizeCssValue('@import url(evil.css)')).toThrow('Unsafe CSS value');
57+
});
58+
59+
it('blocks @charset at-rule', () => {
60+
expect(() => sanitizeCssValue('@charset "UTF-8"')).toThrow('Unsafe CSS value');
61+
});
62+
63+
it('blocks javascript: protocol', () => {
64+
expect(() => sanitizeCssValue('javascript:alert(1)')).toThrow('Unsafe CSS value');
65+
});
66+
67+
it('blocks javascript: with mixed case (bypass attempt)', () => {
68+
expect(() => sanitizeCssValue('JaVaScRiPt:alert(1)')).toThrow('Unsafe CSS value');
69+
});
70+
71+
it('blocks data: URI scheme', () => {
72+
expect(() => sanitizeCssValue('data:text/html,<script>alert(1)</script>')).toThrow('Unsafe CSS value');
73+
});
74+
75+
it('blocks behavior: (IE HTC behavior property)', () => {
76+
expect(() => sanitizeCssValue('behavior:url(evil.htc)')).toThrow('Unsafe CSS value');
77+
});
78+
79+
it('blocks behavior: with internal whitespace (bypass attempt)', () => {
80+
expect(() => sanitizeCssValue('behavior :url(evil.htc)')).toThrow('Unsafe CSS value');
81+
});
82+
83+
it('blocks -moz-binding: (Firefox XBL binding property)', () => {
84+
expect(() => sanitizeCssValue('-moz-binding:url(evil.xml)')).toThrow('Unsafe CSS value');
85+
});
86+
87+
it('blocks -moz-binding: with whitespace (bypass attempt)', () => {
88+
expect(() => sanitizeCssValue('-moz-binding :url(x.xml)')).toThrow('Unsafe CSS value');
89+
});
90+
91+
it('blocks semicolons (property injection)', () => {
92+
expect(() => sanitizeCssValue('red; background: blue')).toThrow('Unsafe CSS value');
93+
});
94+
95+
it('blocks </ sequence (HTML script tag injection)', () => {
96+
expect(() => sanitizeCssValue('red</script>')).toThrow('Unsafe CSS value');
97+
});
98+
99+
it('returns the original value when safe', () => {
100+
const safe = '#AABBCC';
101+
expect(sanitizeCssValue(safe)).toBe(safe);
102+
});
103+
104+
it('error message includes a truncated preview of the offending value', () => {
105+
let message = '';
106+
try {
107+
sanitizeCssValue('url(evil.com)');
108+
} catch (e) {
109+
message = (e as Error).message;
110+
}
111+
expect(message).toContain('url(evil.com)');
112+
});
113+
114+
it('error message truncates at 80 characters for extremely long inputs', () => {
115+
const longPayload = 'url(' + 'a'.repeat(200) + ')';
116+
let message = '';
117+
try {
118+
sanitizeCssValue(longPayload);
119+
} catch (e) {
120+
message = (e as Error).message;
121+
}
122+
// The slice in the implementation caps the preview at 80 chars
123+
const previewInMessage = message.replace('Unsafe CSS value detected: "', '').replace('"', '');
124+
expect(previewInMessage.length).toBeLessThanOrEqual(80);
125+
});
126+
});
127+
128+
// ---------------------------------------------------------------------------
129+
// formatCssValue — mixed reference/literal injection prevention
130+
// ---------------------------------------------------------------------------
131+
132+
describe('formatCssValue — reference resolution and injection prevention', () => {
133+
it('resolves a bare DTCG reference to a CSS var()', () => {
134+
expect(formatCssValue('{color.primary}')).toBe('var(--color-primary)');
135+
});
136+
137+
it('resolves a DTCG reference with a prefix', () => {
138+
expect(formatCssValue('{color.primary}', 'theme')).toBe('var(--theme-color-primary)');
139+
});
140+
141+
it('passes through a safe literal value unchanged', () => {
142+
expect(formatCssValue('#FF0000')).toBe('#FF0000');
143+
});
144+
145+
it('resolves multiple references in a composite value', () => {
146+
const result = formatCssValue('{color.start} {color.end}');
147+
expect(result).toBe('var(--color-start) var(--color-end)');
148+
});
149+
150+
it('handles a value mixing a reference and a safe literal', () => {
151+
// e.g. gradient with a reference and a fallback literal
152+
const result = formatCssValue('{color.primary} 10px');
153+
expect(result).toBe('var(--color-primary) 10px');
154+
});
155+
156+
it('blocks injection in the literal segment of a mixed value', () => {
157+
// The reference part is fine; the literal segment after the reference contains injection.
158+
// Pattern: "{color.primary}; background-image: url(evil.com)"
159+
expect(() =>
160+
formatCssValue('{color.primary}; background-image: url(evil.com)'),
161+
).toThrow('Unsafe CSS value');
162+
});
163+
164+
it('blocks semicolon injection in standalone literal values', () => {
165+
expect(() => formatCssValue('red; color: blue')).toThrow('Unsafe CSS value');
166+
});
167+
168+
it('blocks url() in standalone literal values', () => {
169+
expect(() => formatCssValue('url(https://attacker.com/x.png)')).toThrow('Unsafe CSS value');
170+
});
171+
172+
it('blocks {} in standalone literal values (raw curly braces outside reference syntax)', () => {
173+
// A stray { that is not part of a DTCG reference pattern
174+
expect(() => formatCssValue('invalid { css')).toThrow('Unsafe CSS value');
175+
});
176+
177+
it('handles an empty string without throwing', () => {
178+
expect(formatCssValue('')).toBe('');
179+
});
180+
});
181+
182+
// ---------------------------------------------------------------------------
183+
// sanitizeCssComment — comment injection prevention
184+
// ---------------------------------------------------------------------------
185+
186+
describe('sanitizeCssComment', () => {
187+
it('passes through safe comment text', () => {
188+
expect(sanitizeCssComment('Primary brand color')).toBe('Primary brand color');
189+
});
190+
191+
it('escapes comment-closing sequence */', () => {
192+
expect(sanitizeCssComment('evil */ body { color: red }')).toBe('evil * / body { color: red }');
193+
});
194+
195+
it('escapes multiple occurrences of */', () => {
196+
expect(sanitizeCssComment('*/ and another */')).toBe('* / and another * /');
197+
});
198+
199+
it('leaves solo asterisk intact', () => {
200+
expect(sanitizeCssComment('rating: 5*')).toBe('rating: 5*');
201+
});
202+
});
203+
204+
// ---------------------------------------------------------------------------
205+
// assertHex — input validation
206+
// ---------------------------------------------------------------------------
207+
208+
describe('assertHex', () => {
209+
it('accepts a valid 6-digit uppercase hex', () => {
210+
expect(() => assertHex('#FF0000')).not.toThrow();
211+
});
212+
213+
it('accepts a valid 6-digit lowercase hex', () => {
214+
expect(() => assertHex('#ff0000')).not.toThrow();
215+
});
216+
217+
it('accepts a valid mixed-case hex', () => {
218+
expect(() => assertHex('#aAbBcC')).not.toThrow();
219+
});
220+
221+
it('rejects a 3-digit shorthand hex', () => {
222+
expect(() => assertHex('#F00')).toThrow('Invalid hex color');
223+
});
224+
225+
it('rejects a hex without the hash prefix', () => {
226+
expect(() => assertHex('FF0000')).toThrow('Invalid hex color');
227+
});
228+
229+
it('rejects an 8-digit hex (RGBA)', () => {
230+
expect(() => assertHex('#FF0000FF')).toThrow('Invalid hex color');
231+
});
232+
233+
it('rejects non-hex characters', () => {
234+
expect(() => assertHex('#GGHHII')).toThrow('Invalid hex color');
235+
});
236+
237+
it('rejects an empty string', () => {
238+
expect(() => assertHex('')).toThrow('Invalid hex color');
239+
});
240+
});

0 commit comments

Comments
 (0)