@invisibleloop/pulse 0.20.47 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.20.47",
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",
@@ -97,7 +97,7 @@
97
97
  "access": "public"
98
98
  },
99
99
  "devDependencies": {
100
- "@types/node": "^25.5.0",
101
- "typescript": "^5.9.3"
100
+ "@types/node": "^26.6.5",
101
+ "typescript": "^7.0.2"
102
102
  }
103
103
  }
@@ -1 +1 @@
1
- 0.20.47
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/promises'
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 (!fs.existsSync(probe)) {
34
+ while (!lexists(probe)) {
29
35
  const parent = path.dirname(probe)
30
36
  if (parent === probe) break
31
37
  probe = parent
32
38
  }
33
- const real = path.join(fs.realpathSync(probe), path.relative(probe, abs))
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
- return host === 'localhost' || host.endsWith('.localhost') || host === '::1' || /^127\./.test(host)
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
- /** True when an IP literal is loopback, private, link-local, CGNAT, multicast or otherwise non-public. */
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
- 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
- }
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 (!fullPath.startsWith(PAGES_DIR)) {
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 (!fullPath.startsWith(COMPONENTS_DIR)) {
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
- await assertPublicUrl(url) // every hop: block internal/metadata addresses
3419
- const res = await fetch(url, { method: 'HEAD', redirect: 'manual', signal: AbortSignal.timeout(5000) })
3420
- if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
3421
- url = new URL(res.headers.get('location'), url)
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 fetch(url, { method: 'GET', redirect: 'manual', headers: { Range: 'bytes=0-0' }, signal: AbortSignal.timeout(5000) })
3429
- if (get.status >= 300 && get.status < 400 && get.headers.get('location')) {
3430
- url = new URL(get.headers.get('location'), url)
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