@invisibleloop/pulse 0.20.37 → 0.20.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.20.37",
3
+ "version": "0.20.38",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -61,7 +61,7 @@
61
61
  "scripts": {
62
62
  "dev": "node --watch src/cli/dev.js",
63
63
  "build": "node scripts/build.js",
64
- "test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/navigate.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/cli/validate.test.js && node src/cli/diagnose.test.js && node src/cli/check-bundles.test.js && node src/cli/review.test.js && node src/cli/process-utils.test.js && node src/cli/stamp.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js && node scripts/css-purge.test.js && node src/mcp/mcp.test.js",
64
+ "test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/navigate.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/cli/validate.test.js && node src/cli/diagnose.test.js && node src/cli/check-bundles.test.js && node src/cli/review.test.js && node src/cli/process-utils.test.js && node src/cli/stamp.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js && node scripts/css-purge.test.js && node src/mcp/mcp.test.js && node src/mcp/guards.test.js",
65
65
  "typecheck": "tsc --noEmit",
66
66
  "docs": "node --watch src/cli/dev.js --root ../../docs --port 4000",
67
67
  "docs:start": "node ../../docs/server.js"
@@ -1 +1 @@
1
- 0.20.37
1
+ 0.20.38
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Input guards for MCP tools.
3
+ *
4
+ * The MCP server runs locally, but its tool arguments are chosen by an AI agent
5
+ * that may have read untrusted content (a fetched page, a pasted brief). These
6
+ * helpers keep a steered tool call inside the project and off internal networks.
7
+ */
8
+
9
+ import fs from 'fs'
10
+ import path from 'path'
11
+ import net from 'net'
12
+ import dns from 'dns/promises'
13
+
14
+ /**
15
+ * Resolve a caller-supplied file path and require it to live inside `root`.
16
+ * Relative paths resolve against `root`. Symlinks are followed before the check
17
+ * so a link inside the project cannot point outside it.
18
+ *
19
+ * @returns {string} absolute path inside root
20
+ * @throws {Error} when the path escapes root
21
+ */
22
+ export function resolveInRoot(root, file) {
23
+ const rootReal = fs.realpathSync(root)
24
+ const abs = path.resolve(root, file)
25
+
26
+ // realpath the deepest existing ancestor so not-yet-created files are still checked
27
+ let probe = abs
28
+ while (!fs.existsSync(probe)) {
29
+ const parent = path.dirname(probe)
30
+ if (parent === probe) break
31
+ probe = parent
32
+ }
33
+ const real = path.join(fs.realpathSync(probe), path.relative(probe, abs))
34
+
35
+ const rel = path.relative(rootReal, real)
36
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
37
+ throw new Error(`path must be inside the project (${root}): ${file}`)
38
+ }
39
+ return abs
40
+ }
41
+
42
+ /** True for http(s) URLs that point at this machine (localhost / 127.0.0.0/8 / ::1). */
43
+ export function isLoopbackUrl(rawUrl) {
44
+ let u
45
+ try { u = new URL(rawUrl) } catch { return false }
46
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return false
47
+ const host = u.hostname.replace(/^\[|\]$/g, '').toLowerCase()
48
+ return host === 'localhost' || host.endsWith('.localhost') || host === '::1' || /^127\./.test(host)
49
+ }
50
+
51
+ /** True when an IP literal is loopback, private, link-local, CGNAT, multicast or otherwise non-public. */
52
+ export function isPrivateAddress(ip) {
53
+ if (net.isIPv4(ip)) {
54
+ const [a, b] = ip.split('.').map(Number)
55
+ return a === 0 || a === 10 || a === 127 ||
56
+ (a === 100 && b >= 64 && b <= 127) ||
57
+ (a === 169 && b === 254) ||
58
+ (a === 172 && b >= 16 && b <= 31) ||
59
+ (a === 192 && b === 168) ||
60
+ a >= 224
61
+ }
62
+ if (net.isIPv6(ip)) {
63
+ const v = ip.toLowerCase()
64
+ const mapped = v.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)
65
+ if (mapped) return isPrivateAddress(mapped[1])
66
+ return v === '::' || v === '::1' ||
67
+ /^f[cd]/.test(v) || // fc00::/7 unique local
68
+ /^fe[89ab]/.test(v) || // fe80::/10 link-local
69
+ /^ff/.test(v) // multicast
70
+ }
71
+ return true // not an IP — treat as unsafe
72
+ }
73
+
74
+ /**
75
+ * Require a URL to be http(s) and resolve only to public addresses.
76
+ * Call before every request, including each redirect hop.
77
+ *
78
+ * @throws {Error} when the URL is not http(s) or resolves to a non-public address
79
+ */
80
+ export async function assertPublicUrl(rawUrl) {
81
+ const u = rawUrl instanceof URL ? rawUrl : new URL(rawUrl)
82
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
83
+ throw new Error(`only http(s) URLs are allowed (got ${u.protocol})`)
84
+ }
85
+ const host = u.hostname.replace(/^\[|\]$/g, '')
86
+ if (net.isIP(host)) {
87
+ if (isPrivateAddress(host)) throw new Error(`${host} is not a public address`)
88
+ return
89
+ }
90
+ const addrs = await dns.lookup(host, { all: true })
91
+ if (!addrs.length || addrs.some(a => isPrivateAddress(a.address))) {
92
+ throw new Error(`${host} resolves to a non-public address`)
93
+ }
94
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * MCP input guard tests — run with: node src/mcp/guards.test.js
3
+ */
4
+
5
+ import fs from 'node:fs'
6
+ import os from 'node:os'
7
+ import path from 'node:path'
8
+ import assert from 'node:assert/strict'
9
+ import { resolveInRoot, isLoopbackUrl, isPrivateAddress, assertPublicUrl } from './guards.js'
10
+
11
+ let passed = 0
12
+ let failed = 0
13
+
14
+ async function test(label, fn) {
15
+ try { await fn(); console.log(` ✓ ${label}`); passed++ }
16
+ catch (e) { console.log(` ✗ ${label}\n ${e.message}`); failed++ }
17
+ }
18
+
19
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-guards-'))
20
+ const root = path.join(base, 'project')
21
+ const outside = path.join(base, 'outside')
22
+ fs.mkdirSync(path.join(root, 'src', 'pages'), { recursive: true })
23
+ fs.mkdirSync(outside)
24
+ fs.writeFileSync(path.join(root, 'src', 'pages', 'a.js'), '')
25
+ fs.writeFileSync(path.join(outside, 'secret.js'), '')
26
+ fs.symlinkSync(outside, path.join(root, 'link'))
27
+
28
+ console.log('\nresolveInRoot\n')
29
+
30
+ await test('accepts absolute and relative paths inside the project', () => {
31
+ assert.equal(resolveInRoot(root, path.join(root, 'src/pages/a.js')), path.join(root, 'src/pages/a.js'))
32
+ assert.equal(resolveInRoot(root, 'src/pages/a.js'), path.join(root, 'src/pages/a.js'))
33
+ })
34
+ await test('accepts a not-yet-created file inside the project', () => {
35
+ assert.doesNotThrow(() => resolveInRoot(root, 'src/pages/new.js'))
36
+ })
37
+ await test('rejects absolute paths outside the project', () => {
38
+ assert.throws(() => resolveInRoot(root, path.join(outside, 'secret.js')), /inside the project/)
39
+ })
40
+ await test('rejects ../ traversal', () => {
41
+ assert.throws(() => resolveInRoot(root, '../outside/secret.js'), /inside the project/)
42
+ assert.throws(() => resolveInRoot(root, 'src/../../outside/secret.js'), /inside the project/)
43
+ })
44
+ await test('rejects a symlink inside the project that points outside it', () => {
45
+ assert.throws(() => resolveInRoot(root, 'link/secret.js'), /inside the project/)
46
+ })
47
+ await test('rejects a sibling directory sharing the root name prefix', () => {
48
+ fs.mkdirSync(root + '-evil')
49
+ assert.throws(() => resolveInRoot(root, root + '-evil/x.js'), /inside the project/)
50
+ })
51
+
52
+ console.log('\nisLoopbackUrl\n')
53
+
54
+ await test('allows localhost, 127.x and ::1', () => {
55
+ for (const u of ['http://localhost:3000/', 'http://127.0.0.1:3000/x', 'http://[::1]:3000/', 'http://app.localhost/'])
56
+ assert.equal(isLoopbackUrl(u), true, u)
57
+ })
58
+ await test('rejects remote hosts, lookalikes and non-http schemes', () => {
59
+ for (const u of ['http://example.com/', 'http://localhost.evil.com/', 'http://169.254.169.254/', 'file:///etc/passwd', 'ftp://localhost/', 'nonsense'])
60
+ assert.equal(isLoopbackUrl(u), false, u)
61
+ })
62
+
63
+ console.log('\nisPrivateAddress / assertPublicUrl\n')
64
+
65
+ await test('flags loopback, private, link-local, CGNAT and metadata addresses', () => {
66
+ for (const ip of ['127.0.0.1', '10.0.0.5', '172.16.0.1', '172.31.255.255', '192.168.1.1', '169.254.169.254', '100.64.0.1', '0.0.0.0', '::1', '::', 'fd00::1', 'fe80::1', '::ffff:10.0.0.1'])
67
+ assert.equal(isPrivateAddress(ip), true, ip)
68
+ })
69
+ await test('allows public addresses', () => {
70
+ for (const ip of ['8.8.8.8', '1.1.1.1', '172.32.0.1', '2606:4700:4700::1111'])
71
+ assert.equal(isPrivateAddress(ip), false, ip)
72
+ })
73
+ await test('assertPublicUrl rejects internal IP literals and non-http schemes', async () => {
74
+ await assert.rejects(assertPublicUrl('http://169.254.169.254/latest/meta-data/'), /not a public address/)
75
+ await assert.rejects(assertPublicUrl('http://127.0.0.1:3000/'), /not a public address/)
76
+ await assert.rejects(assertPublicUrl('http://[::1]/'), /not a public address/)
77
+ await assert.rejects(assertPublicUrl('file:///etc/passwd'), /only http/)
78
+ })
79
+ await test('assertPublicUrl rejects hostnames that resolve to loopback', async () => {
80
+ await assert.rejects(assertPublicUrl('http://localhost:3000/'), /non-public/)
81
+ })
82
+ await test('assertPublicUrl accepts a public IP literal', async () => {
83
+ await assertPublicUrl('https://1.1.1.1/')
84
+ })
85
+
86
+ fs.rmSync(base, { recursive: true, force: true })
87
+ fs.rmSync(root + '-evil', { recursive: true, force: true })
88
+
89
+ console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
90
+ if (failed > 0) process.exit(1)
package/src/mcp/server.js CHANGED
@@ -36,9 +36,11 @@ import { z } from 'zod'
36
36
  import path from 'path'
37
37
  import fs from 'fs'
38
38
  import http from 'http'
39
+ import https from 'https'
39
40
  import { spawn, spawnSync } from 'child_process'
40
41
 
41
42
  import { loadPages } from '../cli/discover.js'
43
+ import { resolveInRoot, isLoopbackUrl, assertPublicUrl } from './guards.js'
42
44
  import { validateContent, validateFile, formatValidationResult } from '../cli/validate.js'
43
45
  import { readJournal, formatJournal, resolveEntries, formatResolveResult } from '../cli/diagnose.js'
44
46
  import { writeStamp, formatStampResult } from '../cli/stamp.js'
@@ -821,6 +823,9 @@ server.registerTool(
821
823
  return text('Error: provide only one of content or file, not both')
822
824
  }
823
825
 
826
+ if (file) {
827
+ try { file = resolveInRoot(ROOT, file) } catch (e) { return text(`Error: ${e.message}`) }
828
+ }
824
829
  const result = file ? await validateFile(file, ROOT) : await validateContent(content, ROOT)
825
830
  return text(formatValidationResult(result, { nextSteps: true }))
826
831
  }
@@ -1045,6 +1050,10 @@ The generated file uses @invisibleloop/pulse/testing (renderSync / render). Do N
1045
1050
  },
1046
1051
  },
1047
1052
  async ({ file }) => {
1053
+ try { file = resolveInRoot(ROOT, file) } catch (e) { return text(`Error: ${e.message}`) }
1054
+ if (!file.endsWith('.js') || file.endsWith('.test.js')) {
1055
+ return text('Error: file must be a spec .js file (not a .test.js file)')
1056
+ }
1048
1057
  if (!fs.existsSync(file)) {
1049
1058
  return text(`File not found: ${file}`)
1050
1059
  }
@@ -1207,6 +1216,9 @@ server.registerTool(
1207
1216
  inputSchema: { url: z.string().describe('Full URL, e.g. http://localhost:3000/about') },
1208
1217
  },
1209
1218
  ({ url }) => new Promise(resolve => {
1219
+ if (!isLoopbackUrl(url) || !url.startsWith('http:')) {
1220
+ return resolve(text('Error: pulse_fetch_page only fetches the local dev server (http://localhost:PORT/...)'))
1221
+ }
1210
1222
  const req = http.get(url, { timeout: 10_000 }, res => {
1211
1223
  const chunks = []
1212
1224
  res.on('data', d => chunks.push(d))
@@ -1399,7 +1411,7 @@ any feature build.
1399
1411
 
1400
1412
  let source = content
1401
1413
  if (file) {
1402
- if (!path.isAbsolute(file)) file = path.resolve(process.cwd(), file)
1414
+ try { file = resolveInRoot(ROOT, file) } catch (e) { return text(`Error: ${e.message}`) }
1403
1415
  if (!fs.existsSync(file)) return text(`File not found: ${file}`)
1404
1416
  source = fs.readFileSync(file, 'utf8')
1405
1417
  }
@@ -1575,12 +1587,15 @@ server.registerTool(
1575
1587
  const inSync = installed === staticAsset
1576
1588
 
1577
1589
  // Fetch latest from npm registry
1578
- const req = http.get('http://registry.npmjs.org/@invisibleloop/pulse/latest', { timeout: 5000 }, res => {
1590
+ const req = https.get('https://registry.npmjs.org/@invisibleloop/pulse/latest', { timeout: 5000 }, res => {
1579
1591
  const chunks = []
1580
1592
  res.on('data', d => chunks.push(d))
1581
1593
  res.on('end', () => {
1582
1594
  let latest = 'unknown'
1583
- try { latest = JSON.parse(Buffer.concat(chunks).toString()).version } catch { /* ignore */ }
1595
+ try {
1596
+ const v = JSON.parse(Buffer.concat(chunks).toString()).version
1597
+ if (/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(v)) latest = v // never echo an unvalidated string
1598
+ } catch { /* ignore */ }
1584
1599
 
1585
1600
  const lines = [
1586
1601
  `Installed package : v${installed}`,
@@ -3351,9 +3366,11 @@ Call it with the external URLs you plan to use, before your first Lighthouse run
3351
3366
  let blocked = null
3352
3367
  try {
3353
3368
  for (let hop = 0; hop < MAX_HOPS; hop++) {
3369
+ await assertPublicUrl(url) // every hop: block internal/metadata addresses
3354
3370
  const res = await fetch(url, { method: 'HEAD', redirect: 'manual', signal: AbortSignal.timeout(5000) })
3355
3371
  if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
3356
3372
  url = new URL(res.headers.get('location'), url)
3373
+ await assertPublicUrl(url)
3357
3374
  chain.push(url.origin)
3358
3375
  continue
3359
3376
  }
@@ -3362,6 +3379,7 @@ Call it with the external URLs you plan to use, before your first Lighthouse run
3362
3379
  const get = await fetch(url, { method: 'GET', redirect: 'manual', headers: { Range: 'bytes=0-0' }, signal: AbortSignal.timeout(5000) })
3363
3380
  if (get.status >= 300 && get.status < 400 && get.headers.get('location')) {
3364
3381
  url = new URL(get.headers.get('location'), url)
3382
+ await assertPublicUrl(url)
3365
3383
  chain.push(url.origin)
3366
3384
  continue
3367
3385
  }
@@ -3413,6 +3431,7 @@ Run this immediately after writing a theme file — before production build and
3413
3431
  let source = css || ''
3414
3432
 
3415
3433
  if (!source && file) {
3434
+ try { file = resolveInRoot(ROOT, file) } catch (e) { return text(`Error: ${e.message}`) }
3416
3435
  if (!fs.existsSync(file)) return text(`File not found: ${file}`)
3417
3436
  source = fs.readFileSync(file, 'utf8')
3418
3437
  }
@@ -148,7 +148,7 @@ export function renderToStream(spec, ctx = {}, nonce = '') {
148
148
  const mergedAllState = applyCtxExtras(mergeStoreKeys(spec, allServerState, ctx.store), ctx)
149
149
  if (spec.hydrate && Object.keys(mergedAllState).length > 0) {
150
150
  controller.enqueue(encode(
151
- `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${JSON.stringify(mergedAllState)};</script>`
151
+ `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${safeJson(mergedAllState)};</script>`
152
152
  ))
153
153
  }
154
154
 
@@ -275,7 +275,7 @@ export function buildStoreScript(spec, storeState, nonce, livePath = '') {
275
275
  // mount() connects only when the page actually subscribes to store keys.
276
276
  const liveFlag = livePath ? `window.__PULSE_LIVE__='${livePath}';` : ''
277
277
  if (storeState && Object.keys(storeState).length > 0) {
278
- return `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';${liveFlag}window.__PULSE_STORE__=${JSON.stringify(storeState)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
278
+ return `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';${liveFlag}window.__PULSE_STORE__=${safeJson(storeState)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
279
279
  }
280
280
  return `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';${liveFlag}</script>`
281
281
  }
@@ -373,7 +373,7 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
373
373
  const deferredStyleLinks = (meta.deferredStyles || []).length > 0
374
374
  ? `<script nonce="${nonce}">(function(){${
375
375
  (meta.deferredStyles || []).map(href =>
376
- `var l=document.createElement('link');l.rel='stylesheet';l.href='${esc(href)}';document.head.appendChild(l);`
376
+ `var l=document.createElement('link');l.rel='stylesheet';l.href=${safeJson(href)};document.head.appendChild(l);`
377
377
  ).join('')
378
378
  }})();</script>`
379
379
  : ''
@@ -383,14 +383,14 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
383
383
  .join('\n ')
384
384
 
385
385
  const schemaScript = meta.schema
386
- ? `<script type="application/ld+json">${JSON.stringify(meta.schema)}</script>`
386
+ ? `<script type="application/ld+json">${safeJson(meta.schema)}</script>`
387
387
  : ''
388
388
 
389
389
  // Serialise server state into the page so the client runtime can read it
390
390
  // without making a second request. Only needed when the page is hydrated —
391
391
  // purely server-rendered pages have no JS to read it.
392
392
  const serverStateScript = spec.hydrate && Object.keys(serverState).length > 0
393
- ? `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${JSON.stringify(serverState)};</script>`
393
+ ? `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${safeJson(serverState)};</script>`
394
394
  : ''
395
395
 
396
396
  const storeStateScript = buildStoreScript(spec, storeState, nonce, livePath)
@@ -677,6 +677,22 @@ function isBundle(hydratePath) {
677
677
  return typeof hydratePath === 'string' && hydratePath.startsWith('/dist/')
678
678
  }
679
679
 
680
+ /**
681
+ * JSON.stringify for embedding inside an inline <script>. Plain JSON.stringify
682
+ * leaves `<`, `>` and `&` alone, so a value containing "</script>" (or "<!--")
683
+ * ends the script element early and the rest is parsed as HTML. Escaping those
684
+ * as \uXXXX keeps the output valid JS/JSON with the same parsed value. U+2028/9
685
+ * are escaped too (line terminators in JS string literals before ES2019).
686
+ */
687
+ export function safeJson(value) {
688
+ return JSON.stringify(value)
689
+ ?.replace(/</g, '\\u003c')
690
+ .replace(/>/g, '\\u003e')
691
+ .replace(/&/g, '\\u0026')
692
+ .replace(/\u2028/g, '\\u2028')
693
+ .replace(/\u2029/g, '\\u2029')
694
+ }
695
+
680
696
  /**
681
697
  * Escape HTML special characters for safe attribute insertion.
682
698
  */
@@ -3,7 +3,7 @@
3
3
  * run: node src/runtime/ssr.test.js
4
4
  */
5
5
 
6
- import { renderToString, renderToStream, renderToNavStream, wrapDocument, withTimeout } from './ssr.js'
6
+ import { renderToString, renderToStream, renderToNavStream, wrapDocument, withTimeout, safeJson } from './ssr.js'
7
7
 
8
8
  // ---------------------------------------------------------------------------
9
9
  // Test runner
@@ -705,5 +705,46 @@ await test('stream.scope: meta arrives before slow deferred fetcher resolves', a
705
705
 
706
706
  // ---------------------------------------------------------------------------
707
707
 
708
+ console.log('\nScript-context serialisation\n')
709
+
710
+ const HOSTILE = '</script><img src=x onerror=alert(1)><!-- \u2028 & '
711
+
712
+ await test('safeJson never emits < > & or line separators, and round-trips the value', () => {
713
+ const value = { a: HOSTILE, nested: [HOSTILE], n: 1 }
714
+ const out = safeJson(value)
715
+ assert(!/[<>&\u2028\u2029]/.test(out), `unsafe character left in: ${out}`)
716
+ assert(JSON.stringify(JSON.parse(out)) === JSON.stringify(value), 'value changed in round trip')
717
+ })
718
+
719
+ await test('safeJson(undefined) stays undefined like JSON.stringify', () => {
720
+ assert(safeJson(undefined) === undefined)
721
+ })
722
+
723
+ await test('wrapDocument cannot be broken out of via server state, store state or schema', () => {
724
+ const { html } = wrapDocument({
725
+ content: '<p>x</p>',
726
+ spec: { hydrate: '/dist/x.boot-1.js', meta: { title: 't', schema: { name: HOSTILE } } },
727
+ serverState: { comment: HOSTILE },
728
+ storeState: { note: HOSTILE },
729
+ nonce: 'abc',
730
+ })
731
+ assert(!html.includes('</script><img'), 'script element was closed early by data')
732
+ assert(!html.includes('onerror=alert(1)><!--'), 'raw hostile payload reached the document')
733
+ const state = html.match(/window\.__PULSE_SERVER__ = (.*?);<\/script>/s)[1]
734
+ assert(JSON.stringify(JSON.parse(state)) === JSON.stringify({ comment: HOSTILE }), 'state changed in round trip')
735
+ })
736
+
737
+ await test('wrapDocument deferred style hrefs are emitted as safe JS strings', () => {
738
+ const { html } = wrapDocument({
739
+ content: '',
740
+ spec: { meta: { title: 't', deferredStyles: ["/a.css';alert(1);//", '/b\\'] } },
741
+ nonce: 'abc',
742
+ })
743
+ assert(html.includes(`l.href="/a.css';alert(1);//"`), 'href must be a JSON string literal')
744
+ assert(html.includes('l.href="/b\\\\"'), 'trailing backslash must be escaped')
745
+ })
746
+
747
+ // ---------------------------------------------------------------------------
748
+
708
749
  console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
709
750
  if (failed > 0) process.exit(1)
@@ -21,7 +21,7 @@ import { createRequire } from 'module'
21
21
 
22
22
  const _require = createRequire(import.meta.url)
23
23
  export const version = _require('../../package.json').version
24
- import { renderToString, renderToStream, renderToNavStream, wrapDocument, resolveServerState, buildStoreScript, buildHydrateScript, buildStylePreconnects } from '../runtime/ssr.js'
24
+ import { renderToString, renderToStream, renderToNavStream, wrapDocument, resolveServerState, buildStoreScript, buildHydrateScript, buildStylePreconnects, safeJson } from '../runtime/ssr.js'
25
25
  import { validateSpec } from '../spec/schema.js'
26
26
  import { validateStore, resolveStoreState } from '../store/index.js'
27
27
 
@@ -1422,7 +1422,7 @@ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = f
1422
1422
  ).join('')
1423
1423
  }})();</script>`
1424
1424
  : '',
1425
- meta.schema ? ` <script type="application/ld+json">${JSON.stringify(meta.schema)}</script>` : '',
1425
+ meta.schema ? ` <script type="application/ld+json">${safeJson(meta.schema)}</script>` : '',
1426
1426
  ].filter(Boolean).join('\n')
1427
1427
 
1428
1428
  const bodyAttr = [