Skip to content

Commit 2b0f247

Browse files
committed
HTML-safe JSON serialization
1 parent 86e63eb commit 2b0f247

2 files changed

Lines changed: 88 additions & 2 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { Script } from 'node:vm'
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { buildSiteColorInitScript } from './studioColorHead'
4+
5+
const config = vi.hoisted(() => ({
6+
defaultColor: 'cobalt',
7+
storageKey: 'purestack-site-color',
8+
ids: ['cobalt', 'violet'],
9+
}))
10+
11+
vi.mock('./studioColorPresets', () => ({
12+
get DEFAULT_SITE_COLOR() {
13+
return config.defaultColor
14+
},
15+
get SITE_COLOR_STORAGE_KEY() {
16+
return config.storageKey
17+
},
18+
get studioColorPresets() {
19+
return config.ids.map((id) => ({ id }))
20+
},
21+
}))
22+
23+
function runStartup(stored: string | null, unavailable = false) {
24+
const attributes = new Map<string, string>()
25+
const getItem = vi.fn((key: string) => {
26+
expect(key).toBe(config.storageKey)
27+
if (unavailable) throw new Error('Storage unavailable')
28+
return stored
29+
})
30+
new Script(buildSiteColorInitScript()).runInNewContext({
31+
localStorage: { getItem },
32+
document: {
33+
documentElement: {
34+
setAttribute: (name: string, value: string) =>
35+
attributes.set(name, value),
36+
},
37+
},
38+
})
39+
expect(getItem).toHaveBeenCalledOnce()
40+
return attributes.get('data-site-color')
41+
}
42+
43+
describe('site color startup serialization', () => {
44+
beforeEach(() => {
45+
config.defaultColor = 'cobalt'
46+
config.storageKey = 'purestack-site-color'
47+
config.ids = ['cobalt', 'violet']
48+
})
49+
50+
it('keeps script-closing markup inside serialized configuration strings', () => {
51+
const payload =
52+
'</ScRiPt><script>throw new Error("injected")</script><!--\u2028\u2029"\\'
53+
config.defaultColor = payload
54+
config.storageKey = payload
55+
config.ids = [payload, 'violet']
56+
57+
expect(buildSiteColorInitScript()).not.toMatch(/[<\u2028\u2029]/)
58+
expect(runStartup(null)).toBe(payload)
59+
expect(runStartup(payload)).toBe(payload)
60+
expect(runStartup('violet')).toBe('violet')
61+
})
62+
63+
it.each([
64+
['violet', 'violet'],
65+
[null, 'cobalt'],
66+
['</script><script>throw new Error("injected")</script>', 'cobalt'],
67+
])('validates the stored color %j', (stored, expected) => {
68+
expect(runStartup(stored)).toBe(expected)
69+
})
70+
71+
it('applies the default when storage is unavailable', () => {
72+
expect(runStartup(null, true)).toBe('cobalt')
73+
})
74+
})

‎frontend/theme/studioColorHead.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,20 @@ import {
1212

1313
/** Runs before the stylesheet links and body, independently of the picker module. */
1414
export function buildSiteColorInitScript() {
15-
const ids = JSON.stringify(studioColorPresets.map((preset) => preset.id))
16-
return `(()=>{let c=${JSON.stringify(DEFAULT_SITE_COLOR)};try{let s=localStorage.getItem(${JSON.stringify(SITE_COLOR_STORAGE_KEY)});if(${ids}.includes(s))c=s}catch{}document.documentElement.setAttribute("data-site-color",c)})();`
15+
const ids = serializeInlineScriptValue(
16+
studioColorPresets.map((preset) => preset.id),
17+
)
18+
const defaultColor = serializeInlineScriptValue(DEFAULT_SITE_COLOR)
19+
const storageKey = serializeInlineScriptValue(SITE_COLOR_STORAGE_KEY)
20+
return `(()=>{let c=${defaultColor};try{let s=localStorage.getItem(${storageKey});if(${ids}.includes(s))c=s}catch{}document.documentElement.setAttribute("data-site-color",c)})();`
21+
}
22+
23+
/** JSON string escaping alone does not prevent an HTML </script> terminator. */
24+
function serializeInlineScriptValue(value: string | readonly string[]) {
25+
return JSON.stringify(value)
26+
.replace(/</g, '\\u003c')
27+
.replace(/\u2028/g, '\\u2028')
28+
.replace(/\u2029/g, '\\u2029')
1729
}
1830

1931
const criticalStyles = new Map<boolean, string>()

0 commit comments

Comments
 (0)