@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 +2 -2
- package/public/.pulse-ui-version +1 -1
- package/src/mcp/guards.js +94 -0
- package/src/mcp/guards.test.js +90 -0
- package/src/mcp/server.js +22 -3
- package/src/runtime/ssr.js +21 -5
- package/src/runtime/ssr.test.js +42 -1
- package/src/server/index.js +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@invisibleloop/pulse",
|
|
3
|
-
"version": "0.20.
|
|
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"
|
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.20.
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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
|
}
|
package/src/runtime/ssr.js
CHANGED
|
@@ -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__ = ${
|
|
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__=${
|
|
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
|
|
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">${
|
|
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__ = ${
|
|
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
|
*/
|
package/src/runtime/ssr.test.js
CHANGED
|
@@ -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)
|
package/src/server/index.js
CHANGED
|
@@ -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">${
|
|
1425
|
+
meta.schema ? ` <script type="application/ld+json">${safeJson(meta.schema)}</script>` : '',
|
|
1426
1426
|
].filter(Boolean).join('\n')
|
|
1427
1427
|
|
|
1428
1428
|
const bodyAttr = [
|