@invisibleloop/pulse 0.20.48 → 0.20.49
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 +99 -25
- package/src/mcp/server.js +13 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@invisibleloop/pulse",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.49",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "AI-first frontend framework. The spec is the source of truth.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -98,6 +98,6 @@
|
|
|
98
98
|
},
|
|
99
99
|
"devDependencies": {
|
|
100
100
|
"@types/node": "^26.6.5",
|
|
101
|
-
"typescript": "^
|
|
101
|
+
"typescript": "^7.0.2"
|
|
102
102
|
}
|
|
103
103
|
}
|
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.20.
|
|
1
|
+
0.20.49
|
package/src/mcp/guards.js
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
import fs from 'fs'
|
|
10
10
|
import path from 'path'
|
|
11
11
|
import net from 'net'
|
|
12
|
-
import dns from 'dns
|
|
12
|
+
import dns from 'dns'
|
|
13
|
+
import http from 'http'
|
|
14
|
+
import https from 'https'
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* Resolve a caller-supplied file path and require it to live inside `root`.
|
|
@@ -23,14 +25,21 @@ export function resolveInRoot(root, file) {
|
|
|
23
25
|
const rootReal = fs.realpathSync(root)
|
|
24
26
|
const abs = path.resolve(root, file)
|
|
25
27
|
|
|
26
|
-
// realpath the deepest existing ancestor so not-yet-created files are still checked
|
|
28
|
+
// realpath the deepest existing ancestor so not-yet-created files are still checked.
|
|
29
|
+
// lstat, not existsSync: existsSync follows symlinks, so a dangling link looks
|
|
30
|
+
// "missing" and the walk would skip past it — then a later write through the link
|
|
31
|
+
// creates the file at the link's (outside-the-project) target.
|
|
32
|
+
const lexists = (f) => { try { fs.lstatSync(f); return true } catch { return false } }
|
|
27
33
|
let probe = abs
|
|
28
|
-
while (!
|
|
34
|
+
while (!lexists(probe)) {
|
|
29
35
|
const parent = path.dirname(probe)
|
|
30
36
|
if (parent === probe) break
|
|
31
37
|
probe = parent
|
|
32
38
|
}
|
|
33
|
-
|
|
39
|
+
let realProbe
|
|
40
|
+
try { realProbe = fs.realpathSync(probe) }
|
|
41
|
+
catch { throw new Error(`path goes through a broken symlink: ${file}`) }
|
|
42
|
+
const real = path.join(realProbe, path.relative(probe, abs))
|
|
34
43
|
|
|
35
44
|
const rel = path.relative(rootReal, real)
|
|
36
45
|
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
@@ -39,35 +48,48 @@ export function resolveInRoot(root, file) {
|
|
|
39
48
|
return abs
|
|
40
49
|
}
|
|
41
50
|
|
|
51
|
+
/**
|
|
52
|
+
* True when `p` is strictly inside directory `dir` (lexical check on already-joined paths).
|
|
53
|
+
* A bare startsWith(dir) is wrong: "/proj/src/pages-old/x.js" starts with "/proj/src/pages".
|
|
54
|
+
*/
|
|
55
|
+
export function isInsideDir(dir, p) {
|
|
56
|
+
return p.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep)
|
|
57
|
+
}
|
|
58
|
+
|
|
42
59
|
/** True for http(s) URLs that point at this machine (localhost / 127.0.0.0/8 / ::1). */
|
|
43
60
|
export function isLoopbackUrl(rawUrl) {
|
|
44
61
|
let u
|
|
45
62
|
try { u = new URL(rawUrl) } catch { return false }
|
|
46
63
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false
|
|
47
64
|
const host = u.hostname.replace(/^\[|\]$/g, '').toLowerCase()
|
|
48
|
-
|
|
65
|
+
// 127.x counts only as a real IPv4 literal: a hostname such as "127.evil.com" or
|
|
66
|
+
// "127.0.0.1.nip.io" also starts with "127." but resolves anywhere.
|
|
67
|
+
return host === 'localhost' || host.endsWith('.localhost') || host === '::1' ||
|
|
68
|
+
(net.isIPv4(host) && host.startsWith('127.'))
|
|
49
69
|
}
|
|
50
70
|
|
|
51
|
-
|
|
71
|
+
// Everything that is not a routable public unicast address. IPv6 forms that embed
|
|
72
|
+
// an IPv4 address are covered too: URL parsing rewrites "[::ffff:127.0.0.1]" to
|
|
73
|
+
// "::ffff:7f00:1", which a dotted-quad check does not recognise. net.BlockList applies
|
|
74
|
+
// the IPv4 rules to IPv4-mapped IPv6 addresses itself (so ::ffff:0:0/96 must NOT be
|
|
75
|
+
// listed — it would match every IPv4 address); IPv4-compatible (::/96), NAT64
|
|
76
|
+
// (64:ff9b::/96) and 6to4 (2002::/16) are blocked outright.
|
|
77
|
+
const NON_PUBLIC = new net.BlockList()
|
|
78
|
+
for (const [addr, bits] of [
|
|
79
|
+
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16],
|
|
80
|
+
['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15],
|
|
81
|
+
['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
|
|
82
|
+
]) NON_PUBLIC.addSubnet(addr, bits, 'ipv4')
|
|
83
|
+
for (const [addr, bits] of [
|
|
84
|
+
['::', 96], ['64:ff9b::', 96], ['64:ff9b:1::', 48], ['100::', 64],
|
|
85
|
+
['2001::', 32], ['2001:db8::', 32], ['2002::', 16], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8],
|
|
86
|
+
]) NON_PUBLIC.addSubnet(addr, bits, 'ipv6')
|
|
87
|
+
|
|
88
|
+
/** True when an IP literal is loopback, private, link-local, CGNAT, multicast, reserved or otherwise non-public. */
|
|
52
89
|
export function isPrivateAddress(ip) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
}
|
|
90
|
+
const bare = String(ip).replace(/^\[|\]$/g, '').split('%')[0]
|
|
91
|
+
if (net.isIPv4(bare)) return NON_PUBLIC.check(bare, 'ipv4')
|
|
92
|
+
if (net.isIPv6(bare)) return NON_PUBLIC.check(bare, 'ipv6')
|
|
71
93
|
return true // not an IP — treat as unsafe
|
|
72
94
|
}
|
|
73
95
|
|
|
@@ -87,8 +109,60 @@ export async function assertPublicUrl(rawUrl) {
|
|
|
87
109
|
if (isPrivateAddress(host)) throw new Error(`${host} is not a public address`)
|
|
88
110
|
return
|
|
89
111
|
}
|
|
90
|
-
const addrs = await dns.lookup(host, { all: true })
|
|
112
|
+
const addrs = await dns.promises.lookup(host, { all: true })
|
|
91
113
|
if (!addrs.length || addrs.some(a => isPrivateAddress(a.address))) {
|
|
92
114
|
throw new Error(`${host} resolves to a non-public address`)
|
|
93
115
|
}
|
|
94
116
|
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A dns.lookup replacement that refuses non-public results. Passed to http(s).request
|
|
120
|
+
* as `lookup`, it runs on the very resolution the socket then connects to — so a
|
|
121
|
+
* hostname that answers "public" to a pre-flight check and "127.0.0.1" a moment later
|
|
122
|
+
* (DNS rebinding) cannot slip through the gap that a separate assertPublicUrl() leaves.
|
|
123
|
+
*
|
|
124
|
+
* @param {Function} [resolve] dns.lookup-compatible resolver (injectable for tests)
|
|
125
|
+
*/
|
|
126
|
+
export function makePublicLookup(resolve = dns.lookup) {
|
|
127
|
+
return (hostname, options, callback) => {
|
|
128
|
+
if (typeof options === 'function') { callback = options; options = {} }
|
|
129
|
+
resolve(hostname, { ...options, all: true }, (err, addrs) => {
|
|
130
|
+
if (err) return callback(err)
|
|
131
|
+
if (!addrs?.length || addrs.some(a => isPrivateAddress(a.address))) {
|
|
132
|
+
return callback(new Error(`${hostname} resolves to a non-public address`))
|
|
133
|
+
}
|
|
134
|
+
if (options.all) return callback(null, addrs)
|
|
135
|
+
callback(null, addrs[0].address, addrs[0].family)
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Make one HTTP(S) request to a public address and resolve with { status, headers }.
|
|
142
|
+
* Redirects are NOT followed (callers walk them, re-checking each hop). The body is
|
|
143
|
+
* discarded. Private/loopback/metadata targets are refused both up front (IP literals)
|
|
144
|
+
* and at connect time (hostnames).
|
|
145
|
+
*
|
|
146
|
+
* @param {string|URL} rawUrl
|
|
147
|
+
* @param {{ method?: string, headers?: object, timeout?: number, lookup?: Function }} [opts]
|
|
148
|
+
*/
|
|
149
|
+
export function requestPublic(rawUrl, { method = 'HEAD', headers = {}, timeout = 5000, lookup = makePublicLookup() } = {}) {
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
let u
|
|
152
|
+
try { u = rawUrl instanceof URL ? rawUrl : new URL(rawUrl) } catch (e) { return reject(e) }
|
|
153
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
154
|
+
return reject(new Error(`only http(s) URLs are allowed (got ${u.protocol})`))
|
|
155
|
+
}
|
|
156
|
+
const host = u.hostname.replace(/^\[|\]$/g, '')
|
|
157
|
+
if (net.isIP(host) && isPrivateAddress(host)) return reject(new Error(`${host} is not a public address`))
|
|
158
|
+
|
|
159
|
+
const lib = u.protocol === 'https:' ? https : http
|
|
160
|
+
const req = lib.request(u, { method, headers, lookup, timeout }, (res) => {
|
|
161
|
+
resolve({ status: res.statusCode, headers: res.headers })
|
|
162
|
+
res.destroy()
|
|
163
|
+
})
|
|
164
|
+
req.on('timeout', () => req.destroy(Object.assign(new Error('timed out'), { name: 'TimeoutError' })))
|
|
165
|
+
req.on('error', reject)
|
|
166
|
+
req.end()
|
|
167
|
+
})
|
|
168
|
+
}
|
package/src/mcp/server.js
CHANGED
|
@@ -41,7 +41,7 @@ import https from 'https'
|
|
|
41
41
|
import { spawn, spawnSync } from 'child_process'
|
|
42
42
|
|
|
43
43
|
import { loadPages } from '../cli/discover.js'
|
|
44
|
-
import { resolveInRoot, isLoopbackUrl, assertPublicUrl } from './guards.js'
|
|
44
|
+
import { resolveInRoot, isInsideDir, isLoopbackUrl, assertPublicUrl, requestPublic } from './guards.js'
|
|
45
45
|
import { validateContent, validateFile, formatValidationResult } from '../cli/validate.js'
|
|
46
46
|
import { readJournal, formatJournal, resolveEntries, formatResolveResult } from '../cli/diagnose.js'
|
|
47
47
|
import { writeStamp, formatStampResult } from '../cli/stamp.js'
|
|
@@ -864,7 +864,7 @@ Rules for the spec you write:
|
|
|
864
864
|
const segments = name.replace(/\.js$/, '').split('/')
|
|
865
865
|
const fullPath = path.join(PAGES_DIR, ...segments) + '.js'
|
|
866
866
|
|
|
867
|
-
if (!
|
|
867
|
+
if (!isInsideDir(PAGES_DIR, fullPath)) {
|
|
868
868
|
return text('Error: page name must not escape src/pages/')
|
|
869
869
|
}
|
|
870
870
|
|
|
@@ -911,7 +911,7 @@ Rules for the component you write:
|
|
|
911
911
|
const safeName = name.replace(/\.js$/, '')
|
|
912
912
|
const fullPath = path.join(COMPONENTS_DIR, `${safeName}.js`)
|
|
913
913
|
|
|
914
|
-
if (!
|
|
914
|
+
if (!isInsideDir(COMPONENTS_DIR, fullPath)) {
|
|
915
915
|
return text('Error: component name must not escape src/components/')
|
|
916
916
|
}
|
|
917
917
|
|
|
@@ -3415,19 +3415,22 @@ Call it with the external URLs you plan to use, before your first Lighthouse run
|
|
|
3415
3415
|
let blocked = null
|
|
3416
3416
|
try {
|
|
3417
3417
|
for (let hop = 0; hop < MAX_HOPS; hop++) {
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3418
|
+
// Every hop: refuse internal/metadata addresses. requestPublic() re-checks the
|
|
3419
|
+
// address the socket actually connects to, so DNS rebinding cannot slip a
|
|
3420
|
+
// private IP in between this check and the request.
|
|
3421
|
+
await assertPublicUrl(url)
|
|
3422
|
+
const res = await requestPublic(url, { method: 'HEAD' })
|
|
3423
|
+
if (res.status >= 300 && res.status < 400 && res.headers.location) {
|
|
3424
|
+
url = new URL(res.headers.location, url)
|
|
3422
3425
|
await assertPublicUrl(url)
|
|
3423
3426
|
chain.push(url.origin)
|
|
3424
3427
|
continue
|
|
3425
3428
|
}
|
|
3426
3429
|
if (res.status === 405) {
|
|
3427
3430
|
// Host rejects HEAD — confirm with a ranged GET so we still see redirects
|
|
3428
|
-
const get = await
|
|
3429
|
-
if (get.status >= 300 && get.status < 400 && get.headers.
|
|
3430
|
-
url = new URL(get.headers.
|
|
3431
|
+
const get = await requestPublic(url, { method: 'GET', headers: { Range: 'bytes=0-0' } })
|
|
3432
|
+
if (get.status >= 300 && get.status < 400 && get.headers.location) {
|
|
3433
|
+
url = new URL(get.headers.location, url)
|
|
3431
3434
|
await assertPublicUrl(url)
|
|
3432
3435
|
chain.push(url.origin)
|
|
3433
3436
|
continue
|