@raolin2025/claude-code-node 1.2.0 → 2.1.0

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.
@@ -1,99 +1,92 @@
1
1
  /**
2
2
  * SSRF 防护 — 阻止对内网/云元数据端点的请求
3
- * 对应原版: src/utils/hooks/ssrfGuard.ts
3
+ * 对应原版:src/utils/hooks/ssrfGuard.ts
4
+ *
5
+ * v1.2 修复:
6
+ * - H4: 完整检测 fe80::/10 范围(fe80:: - febf::),而非仅 fe8 前缀
7
+ * - H3: 导出 dnsLookup 函数供 query-engine 使用自定义 DNS resolver
4
8
  */
5
9
  import { lookup as dnsLookup } from 'dns'
6
10
  import { isIP } from 'net'
7
11
 
8
12
  /**
9
13
  * 检查 IPv4 地址是否在应被阻止的范围内
10
- *
11
- * 阻止列表:
12
- * - 0.0.0.0/8 — "this" 网络
13
- * - 10.0.0.0/8 — 私有网络
14
- * - 100.64.0.0/10 — CGNAT 共享地址(阿里云元数据 100.100.100.200)
15
- * - 169.254.0.0/16 — 链路本地(AWS/GCP 云元数据)
16
- * - 172.16.0.0/12 — 私有网络
17
- * - 192.168.0.0/16 — 私有网络
18
- * - 192.0.2.0/24 — TEST-NET-1 (RFC 5737)
19
- * - 198.51.100.0/24 — TEST-NET-2
20
- * - 203.0.113.0/24 — TEST-NET-3
21
- *
22
- * 允许:
23
- * - 127.0.0.0/8 — 回环(本地开发 hook 服务器)
24
14
  */
25
15
  function isBlockedV4(address) {
26
16
  const parts = address.split('.').map(Number)
27
17
  if (parts.length !== 4 || parts.some(n => Number.isNaN(n))) return false
18
+ const [a, b, c, d] = parts
28
19
 
29
- const [a, b] = parts
30
-
31
- // 回环 — 允许
32
- if (a === 127) return false
33
-
34
- // 0.0.0.0/8
20
+ // 0.0.0.0/8 "this" 网络
35
21
  if (a === 0) return true
36
-
37
- // 10.0.0.0/8
22
+ // 127.0.0.0/8 — 回环/localhost(SSRF 核心目标,必须阻止)
23
+ if (a === 127) return true
24
+ // 10.0.0.0/8 — 私有网络
38
25
  if (a === 10) return true
39
-
40
26
  // 100.64.0.0/10 — CGNAT (RFC 6598)
41
27
  if (a === 100 && b >= 64 && b <= 127) return true
42
-
28
+ // 100.100.100.200 — 阿里云元数据(精确匹配)
29
+ if (a === 100 && b === 100 && c === 100 && d === 200) return true
43
30
  // 169.254.0.0/16 — 链路本地,云元数据
44
31
  if (a === 169 && b === 254) return true
45
-
46
32
  // 172.16.0.0/12
47
33
  if (a === 172 && b >= 16 && b <= 31) return true
48
-
49
34
  // 192.168.0.0/16
50
35
  if (a === 192 && b === 168) return true
51
-
52
36
  // 192.0.2.0/24 — TEST-NET-1
53
- if (a === 192 && b === 0 && parts[2] === 2) return true
54
-
37
+ if (a === 192 && b === 0 && c === 2) return true
55
38
  // 198.51.100.0/24 — TEST-NET-2
56
- if (a === 198 && b === 51 && parts[2] === 100) return true
57
-
39
+ if (a === 198 && b === 51 && c === 100) return true
58
40
  // 203.0.113.0/24 — TEST-NET-3
59
- if (a === 203 && b === 0 && parts[2] === 113) return true
41
+ if (a === 203 && b === 0 && c === 113) return true
60
42
 
61
43
  return false
62
44
  }
63
45
 
64
46
  /**
65
47
  * 检查 IPv6 地址是否在应被阻止的范围内
66
- *
67
- * 阻止:
68
- * - :: — 未指定地址
69
- * - fc00::/7 — 唯一本地地址 (ULA)
70
- * - fe80::/10 — 链路本地
71
- * - ::ffff:<被阻止的v4> — IPv4 映射地址
72
- *
73
- * 允许:
74
- * - ::1 — 回环
48
+ *
49
+ * 修复 H4: 完整检测 fe80::/10 范围(fe80:: - febf::)
75
50
  */
76
51
  function isBlockedV6(address) {
77
52
  const normalized = address.toLowerCase()
78
53
 
79
- // ::1 回环 — 允许
80
- if (normalized === '::1') return false
81
-
54
+ // ::1 回环 — 阻止(SSRF 核心目标)
55
+ if (normalized === '::1') return true
82
56
  // :: 未指定
83
57
  if (normalized === '::' || normalized === '0:0:0:0:0:0:0:0') return true
84
58
 
85
59
  // fc00::/7 — 唯一本地 (fc00:: - fdff::)
86
60
  if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true
87
61
 
88
- // fe80::/10 — 链路本地
89
- if (normalized.startsWith('fe8')) return true
62
+ // fe80::/10 — 链路本地 (fe80:: - febf::)
63
+ // 修复 H4: 使用数值比较而非前缀匹配
64
+ const firstTwoBytes = normalized.split(':')[0]
65
+ if (firstTwoBytes) {
66
+ const firstByte = parseInt(firstTwoBytes, 16)
67
+ if (!isNaN(firstByte) && firstByte >= 0xfe80 && firstByte <= 0xfebf) {
68
+ return true
69
+ }
70
+ }
90
71
 
91
- // ::ffff:<IPv4> — IPv4 映射地址
72
+ // ::ffff:<IPv4> — 短格式 IPv4 映射地址
92
73
  const v4Mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)
93
74
  if (v4Mapped) {
94
75
  return isBlockedV4(v4Mapped[1])
95
76
  }
96
77
 
78
+ // 0:0:0:0:0:ffff:<IPv4> — 完整格式 IPv4 映射地址
79
+ const v4MappedFull = normalized.match(/^0:0:0:0:0:ffff:(\d+\.\d+\.\d+\.\d+)$/)
80
+ if (v4MappedFull) {
81
+ return isBlockedV4(v4MappedFull[1])
82
+ }
83
+
84
+ // 64:ff9b::<IPv4> — NAT64 映射地址 (RFC 6052)
85
+ const nat64Match = normalized.match(/^64:ff9b::(\d+\.\d+\.\d+\.\d+)$/)
86
+ if (nat64Match) {
87
+ return isBlockedV4(nat64Match[1])
88
+ }
89
+
97
90
  return false
98
91
  }
99
92
 
@@ -126,6 +119,26 @@ export async function checkHostSafety(hostname) {
126
119
  }
127
120
  }
128
121
 
122
+ // 常见 SSRF 绕过主机名黑名单
123
+ const blockedHostnames = [
124
+ 'localhost',
125
+ 'localhost.localdomain',
126
+ 'ip6-localhost',
127
+ 'ip6-loopback',
128
+ 'metadata.google.internal', // GCP 元数据
129
+ 'metadata.internal', // AWS 元数据
130
+ 'instance-data', // CloudStack 元数据
131
+ ]
132
+ const lowerHost = hostname.toLowerCase()
133
+ if (blockedHostnames.includes(lowerHost)) {
134
+ return { allowed: false, addresses: [], reason: `主机名 ${hostname} 为已知 SSRF 目标` }
135
+ }
136
+
137
+ // 阻止 *.internal / *.local / *.localhost 域名模式
138
+ if (lowerHost.endsWith('.internal') || lowerHost.endsWith('.local') || lowerHost.endsWith('.localhost')) {
139
+ return { allowed: false, addresses: [], reason: `主机名 ${hostname} 为内网域名` }
140
+ }
141
+
129
142
  // DNS 解析后检查
130
143
  return new Promise((resolve) => {
131
144
  dnsLookup(hostname, (err, address) => {
@@ -162,10 +175,10 @@ export async function checkUrlSafety(url) {
162
175
 
163
176
  // 只允许 HTTP/HTTPS
164
177
  if (!['http:', 'https:'].includes(parsed.protocol)) {
165
- return { allowed: false, reason: `不支持的协议: ${parsed.protocol}` }
178
+ return { allowed: false, reason: `不支持的协议:${parsed.protocol}` }
166
179
  }
167
180
 
168
- // 检查主机名
181
+ // 检查主机名(含主机名黑名单 + DNS 解析)
169
182
  const hostResult = await checkHostSafety(parsed.hostname)
170
183
  if (!hostResult.allowed) {
171
184
  return { allowed: false, reason: hostResult.reason }
@@ -176,3 +189,93 @@ export async function checkUrlSafety(url) {
176
189
  return { allowed: false, reason: '无效的 URL' }
177
190
  }
178
191
  }
192
+
193
+ /**
194
+ * 安全 DNS 解析器 — 用于防止 DNS Rebinding 攻击
195
+ * 返回解析后的 IP 地址列表,可直接用于 fetch 的 DNS 查找
196
+ * @param {string} hostname — 主机名
197
+ * @returns {Promise<{addresses: string[], blocked: boolean, reason?: string}>}
198
+ */
199
+ export async function safeDnsLookup(hostname) {
200
+ return new Promise((resolve) => {
201
+ dnsLookup(hostname, (err, address) => {
202
+ if (err) {
203
+ resolve({ addresses: [], blocked: false, reason: undefined })
204
+ return
205
+ }
206
+
207
+ const addresses = Array.isArray(address) ? address.map(a => a.address) : [address]
208
+ const blockedAddrs = addresses.filter(a => isBlockedAddress(a))
209
+
210
+ if (blockedAddrs.length > 0) {
211
+ resolve({
212
+ addresses,
213
+ blocked: true,
214
+ reason: `主机 ${hostname} 解析到私有地址 ${blockedAddrs.join(', ')}`,
215
+ })
216
+ } else {
217
+ resolve({ addresses, blocked: false, reason: undefined })
218
+ }
219
+ })
220
+ })
221
+ }
222
+
223
+ /**
224
+ * 创建安全 fetch 函数 — 防止 DNS Rebinding
225
+ * 使用预先解析的 IP 地址,避免二次 DNS 查询
226
+ * @param {Function} nativeFetch — 原生 fetch
227
+ * @returns {Function} 安全 fetch
228
+ */
229
+ export function createSafeFetch(nativeFetch) {
230
+ return async function safeFetch(url, options = {}) {
231
+ const parsed = new URL(url)
232
+
233
+ // 只处理 HTTP/HTTPS
234
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
235
+ return nativeFetch(url, options)
236
+ }
237
+
238
+ // 检查主机名安全性
239
+ const hostResult = await checkHostSafety(parsed.hostname)
240
+ if (!hostResult.allowed) {
241
+ throw new Error(`SSRF blocked: ${hostResult.reason}`)
242
+ }
243
+
244
+ // 如果主机名是 IP 地址,直接使用
245
+ if (isIP(parsed.hostname)) {
246
+ return nativeFetch(url, options)
247
+ }
248
+
249
+ // 对于域名,使用预先解析的 IP 地址
250
+ // 通过设置 Host header 和直接连接 IP 来防止 DNS Rebinding
251
+ const lookupResult = await safeDnsLookup(parsed.hostname)
252
+ if (lookupResult.blocked) {
253
+ throw new Error(`SSRF blocked: ${lookupResult.reason}`)
254
+ }
255
+
256
+ // 使用第一个非阻止的 IP 地址
257
+ if (lookupResult.addresses.length > 0) {
258
+ // 创建一个自定义 Agent 来覆盖 DNS 解析
259
+ // 注意:这需要 Node.js 的 http/https 模块支持
260
+ // 简单方案:直接修改 URL 为主机名 + IP
261
+ const originalHost = parsed.hostname
262
+ const ip = lookupResult.addresses[0]
263
+
264
+ // 创建新 URL 使用 IP 地址
265
+ const safeUrl = url.replace(originalHost, ip)
266
+
267
+ // 设置 Host header 为原始主机名
268
+ const safeOptions = {
269
+ ...options,
270
+ headers: {
271
+ ...options.headers,
272
+ 'Host': originalHost,
273
+ },
274
+ }
275
+
276
+ return nativeFetch(safeUrl, safeOptions)
277
+ }
278
+
279
+ return nativeFetch(url, options)
280
+ }
281
+ }
package/src/tools/glob.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Glob 工具 — 文件模式搜索
3
3
  * 对应原版: src/tools/GlobTool/
4
4
  */
5
- import { readdir, stat } from 'fs/promises'
5
+ import { readdir } from 'fs/promises'
6
6
  import { resolve, join, isAbsolute } from 'path'
7
7
  import { ToolDef } from '../types/index.js'
8
8
 
@@ -35,6 +35,8 @@ function htmlToText(html) {
35
35
  return text
36
36
  }
37
37
 
38
+ const VERSION = '2.0.0'
39
+
38
40
  export const webFetchTool = new ToolDef(
39
41
  'WebFetch',
40
42
  `Fetch and extract content from a URL.
@@ -69,7 +71,7 @@ Usage:
69
71
  try {
70
72
  const response = await fetch(url, {
71
73
  headers: {
72
- 'User-Agent': 'ClaudeCode-Node/1.0',
74
+ 'User-Agent': `ClaudeCode-Node/${VERSION}`,
73
75
  'Accept': 'text/html,application/json,text/plain,*/*',
74
76
  },
75
77
  signal: AbortSignal.timeout(30000),
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'crypto'
1
2
  // ====== 消息类型 ======
2
3
 
3
4
  /** 消息角色 */
@@ -18,7 +19,7 @@ export class Message {
18
19
  this.role = role
19
20
  this.content = content
20
21
  this.timestamp = Date.now()
21
- this.id = crypto.randomUUID()
22
+ this.id = randomUUID()
22
23
  }
23
24
  }
24
25
 
@@ -2,9 +2,8 @@
2
2
  * 文件操作工具
3
3
  * 对应原版: src/utils/file.ts + src/utils/fsOperations.ts
4
4
  */
5
- import { readFile, writeFile, stat, mkdir, rm, rename, copyFile } from 'fs/promises'
6
- import { resolve, dirname, basename, isAbsolute } from 'path'
7
- import { existsSync } from 'fs'
5
+ import { readFile, writeFile, stat, mkdir, rm } from 'fs/promises'
6
+ import { resolve, dirname, isAbsolute } from 'path'
8
7
 
9
8
  /**
10
9
  * 安全读取文件(带大小限制)