Skip to content

Commit 10f1d9d

Browse files
SebTardifsteipete
andauthored
fix(seo): escape JSON-LD script content to prevent breakout (#208)
* fix(security): harden JSON-LD script serialization Co-authored-by: Sebastien Tardif <sebtardif@ncf.ca> * chore: drop PR changelog entry --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
1 parent a6f92f3 commit 10f1d9d

4 files changed

Lines changed: 72 additions & 1 deletion

File tree

src/layouts/Layout.astro

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
import Analytics from '@vercel/analytics/astro';
3+
import { serializeJsonLd } from '../lib/serialize-json-ld';
34
import '@openclaw/design-system/tokens.css';
45
import '@openclaw/design-system/themes.css';
56
import '@openclaw/design-system/typography.css';
@@ -76,7 +77,7 @@ const structuredDataItems = structuredData ? Array.isArray(structuredData) ? str
7677
<title>{title}</title>
7778

7879
{structuredDataItems.map((item) => (
79-
<script type="application/ld+json" set:html={JSON.stringify(item)} />
80+
<script type="application/ld+json" set:html={serializeJsonLd(item)} />
8081
))}
8182

8283
<script is:inline>

src/lib/serialize-json-ld.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export function serializeJsonLd(value: Record<string, unknown>): string {
2+
// Script contents are HTML raw text: removing every tag opener prevents an
3+
// embedded </script> from ending the element while preserving JSON.parse.
4+
return JSON.stringify(value).replaceAll('<', '\\u003c');
5+
}

tests/assert-built-assets.mjs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ function readText(relativePath) {
1818
return readFileSync(repoPath(relativePath), 'utf8');
1919
}
2020

21+
function listHtmlFiles(relativeDir) {
22+
return readdirSync(repoPath(relativeDir), { withFileTypes: true }).flatMap((entry) => {
23+
const relativePath = path.join(relativeDir, entry.name);
24+
if (entry.isDirectory()) return listHtmlFiles(relativePath);
25+
return relativePath.endsWith('.html') ? [relativePath] : [];
26+
});
27+
}
28+
2129
function assertFileExists(relativePath) {
2230
assert.ok(existsSync(repoPath(relativePath)), `Expected ${relativePath} to exist`);
2331
}
@@ -32,6 +40,13 @@ function assertContains(text, expected, context) {
3240
assert.ok(text.includes(expected), `Expected ${context} to contain ${expected}`);
3341
}
3442

43+
function assertJsonLdIsParseable(html, context) {
44+
const scripts = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)];
45+
for (const [, contents] of scripts) {
46+
assert.doesNotThrow(() => JSON.parse(contents), `${context} must contain parseable JSON-LD`);
47+
}
48+
}
49+
3550
assertCopiedByteForByte('public/openclaw-logo-text-dark.png', 'dist/openclaw-logo-text-dark.png');
3651
assertCopiedByteForByte('public/logo.png', 'dist/logo.png');
3752
assertCopiedByteForByte('public/granola.png', 'dist/granola.png');
@@ -54,6 +69,10 @@ const homePage = readText('dist/index.html');
5469
const integrationsPage = readText('dist/integrations/index.html');
5570
const ecosystemPage = readText('dist/ecosystem/index.html');
5671

72+
for (const relativePath of listHtmlFiles('dist')) {
73+
assertJsonLdIsParseable(readText(relativePath), relativePath);
74+
}
75+
5776
assertContains(homePage, siX.path, 'dist/index.html');
5877
assertContains(homePage, siGooglechrome.path, 'dist/index.html');
5978
assertContains(homePage, siGmail.path, 'dist/index.html');

tests/serialize-json-ld.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import { serializeJsonLd } from '../src/lib/serialize-json-ld';
3+
4+
const breakoutPayloads = [
5+
'</script><script id="breakout">alert(1)</script>',
6+
'</ScRiPt><script>alert(1)</script>',
7+
'</script\n><script>alert(1)</script>',
8+
'<!--</script><script>alert(1)</script>-->',
9+
'<script><!--</script><img src=x onerror=alert(1)>',
10+
'<\\/script></script>',
11+
'plain text & > text — 日本語',
12+
];
13+
14+
describe('serializeJsonLd', () => {
15+
test.each(breakoutPayloads)('keeps script-breakout payload parseable: %s', (payload) => {
16+
const value = { nested: { payload }, list: [payload, { payload }] };
17+
const serialized = serializeJsonLd(value);
18+
19+
expect(serialized).not.toContain('<');
20+
expect(JSON.parse(serialized)).toEqual(value);
21+
});
22+
23+
test('emits a literal JSON Unicode escape instead of decoding it to a less-than sign', () => {
24+
expect(serializeJsonLd({ value: '<' })).toBe('{"value":"\\u003c"}');
25+
});
26+
27+
test('round-trips deterministic property payloads without raw tag openers', () => {
28+
let state = 0x5eed1234;
29+
const alphabet = '</script>ScRiPt!&—日本語\\u003c\n\t"';
30+
31+
for (let sample = 0; sample < 512; sample += 1) {
32+
let payload = '';
33+
for (let index = 0; index < 96; index += 1) {
34+
state ^= state << 13;
35+
state ^= state >>> 17;
36+
state ^= state << 5;
37+
payload += alphabet[Math.abs(state) % alphabet.length];
38+
}
39+
40+
const value = { sample, payload, nested: [{ payload }] };
41+
const serialized = serializeJsonLd(value);
42+
expect(serialized).not.toContain('<');
43+
expect(JSON.parse(serialized)).toEqual(value);
44+
}
45+
});
46+
});

0 commit comments

Comments
 (0)