@raolin2025/claude-code-node 1.2.0 → 2.0.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,15 +1,17 @@
1
1
  /**
2
2
  * 权限系统增强版 — 规则持久化 + 审计日志
3
- * 对应原版: src/hooks/toolPermission/ + src/utils/permissions/
3
+ * 对应原版:src/hooks/toolPermission/ + src/utils/permissions/
4
4
  */
5
- import { readFile, writeFile, mkdir } from 'fs/promises'
6
- import { resolve, join } from 'path'
5
+ import { readFile, writeFile, mkdir, stat, rename } from 'fs/promises'
6
+ import { join } from 'path'
7
7
  import { checkBashSafety } from './bash-guard.js'
8
8
  import { checkPathSafety, checkWritePathSafety } from './path-guard.js'
9
9
  import { checkUrlSafety } from './ssrf-guard.js'
10
10
 
11
11
  const PERMISSIONS_FILE = '.claude-code/permissions.json'
12
12
  const AUDIT_LOG_FILE = '.claude-code/audit.log'
13
+ const AUDIT_LOG_MAX_SIZE = 10 * 1024 * 1024 // 10MB 审计日志上限
14
+ const AUDIT_LOG_MAX_BACKUPS = 3 // 最多保留 3 个轮转备份
13
15
 
14
16
  /**
15
17
  * 权限决策类型
@@ -25,10 +27,10 @@ export const PermissionDecision = {
25
27
  */
26
28
  export class PermissionRule {
27
29
  constructor({ tool, pattern, decision, reason, expiresAt = null }) {
28
- this.tool = tool // 工具名或 '*'(所有工具)
29
- this.pattern = pattern // 匹配模式(glob 或 regex 字符串)
30
- this.decision = decision // allow / deny / ask
31
- this.reason = reason // 规则原因
30
+ this.tool = tool // 工具名或 '*'(所有工具)
31
+ this.pattern = pattern // 匹配模式(glob 或 regex 字符串)
32
+ this.decision = decision // allow / deny / ask
33
+ this.reason = reason // 规则原因
32
34
  this.createdAt = Date.now()
33
35
  this.expiresAt = expiresAt // 过期时间(会话级规则)
34
36
  }
@@ -41,21 +43,17 @@ export class PermissionRule {
41
43
  /** 检查输入是否匹配此规则 */
42
44
  matches(input) {
43
45
  if (this.isExpired) return false
44
-
45
46
  // 简单 glob 匹配
46
47
  const pattern = this.pattern
47
48
  if (pattern === '*') return true
48
-
49
49
  // 路径模式
50
50
  if (typeof input === 'string' && input.includes('/')) {
51
51
  return this._globMatch(pattern, input)
52
52
  }
53
-
54
53
  // 命令模式
55
54
  if (typeof input === 'string') {
56
55
  return input.startsWith(pattern) || this._globMatch(pattern, input)
57
56
  }
58
-
59
57
  return false
60
58
  }
61
59
 
@@ -74,8 +72,8 @@ export class PermissionRule {
74
72
  export class EnhancedPermissionChecker {
75
73
  constructor(mode = 'ask', options = {}) {
76
74
  this.mode = mode
77
- this.rules = [] // PermissionRule 列表
78
- this.auditLog = [] // 审计日志
75
+ this.rules = [] // PermissionRule 列表
76
+ this.auditLog = [] // 审计日志
79
77
  this.cwd = options.cwd || process.cwd()
80
78
  this.projectDir = options.projectDir || process.cwd()
81
79
  this._maxAuditEntries = 1000
@@ -117,7 +115,7 @@ export class EnhancedPermissionChecker {
117
115
  * 综合权限检查
118
116
  * @param {string} toolName — 工具名
119
117
  * @param {object} input — 工具输入参数
120
- * @returns {Promise<{allowed: boolean, reason?: string, securityCheck?: object}>}
118
+ * @returns {Promise<{allowed: boolean, reason?: string, requiresConfirmation?: boolean, securityCheck?: object}>}
121
119
  */
122
120
  async check(toolName, input = {}) {
123
121
  // 1. 模式级检查
@@ -125,6 +123,7 @@ export class EnhancedPermissionChecker {
125
123
  this._log(toolName, input, false, '全局拒绝模式')
126
124
  return { allowed: false, reason: '全局拒绝模式' }
127
125
  }
126
+
128
127
  if (this.mode === 'always-allow') {
129
128
  const securityResult = await this._securityCheck(toolName, input)
130
129
  if (!securityResult.safe) {
@@ -141,7 +140,7 @@ export class EnhancedPermissionChecker {
141
140
  if (rule.tool === toolName || rule.tool === '*') {
142
141
  if (rule.matches(this._extractPattern(toolName, input))) {
143
142
  if (rule.decision === PermissionDecision.DENY) {
144
- this._log(toolName, input, false, `规则拒绝: ${rule.reason}`)
143
+ this._log(toolName, input, false, `规则拒绝:${rule.reason}`)
145
144
  return { allowed: false, reason: rule.reason }
146
145
  }
147
146
  if (rule.decision === PermissionDecision.ALLOW) {
@@ -151,7 +150,7 @@ export class EnhancedPermissionChecker {
151
150
  this._log(toolName, input, false, securityResult.reason)
152
151
  return { allowed: false, reason: securityResult.reason, securityCheck: securityResult }
153
152
  }
154
- this._log(toolName, input, true, `规则允许: ${rule.reason}`)
153
+ this._log(toolName, input, true, `规则允许:${rule.reason}`)
155
154
  return { allowed: true }
156
155
  }
157
156
  }
@@ -166,9 +165,12 @@ export class EnhancedPermissionChecker {
166
165
  }
167
166
 
168
167
  // 4. ask 模式 — 需要用户确认
168
+ // 返回 requiresConfirmation=true,让调用方处理确认逻辑
169
169
  this._log(toolName, input, true, 'ask 模式 — 等待用户确认')
170
170
  return {
171
- allowed: true, // 简化版直接允许(完整版应弹出确认对话框)
171
+ allowed: false, // ask 模式下先拒绝,等待用户确认
172
+ requiresConfirmation: true, // 标记需要用户确认
173
+ reason: 'ask 模式需要用户确认',
172
174
  securityCheck: securityResult,
173
175
  }
174
176
  }
@@ -188,7 +190,6 @@ export class EnhancedPermissionChecker {
188
190
  detail: result,
189
191
  }
190
192
  }
191
-
192
193
  case 'Read': {
193
194
  const filePath = input.file_path || ''
194
195
  if (!filePath) return { safe: true }
@@ -199,7 +200,6 @@ export class EnhancedPermissionChecker {
199
200
  detail: result,
200
201
  }
201
202
  }
202
-
203
203
  case 'Edit':
204
204
  case 'Write': {
205
205
  const filePath = input.file_path || ''
@@ -212,7 +212,6 @@ export class EnhancedPermissionChecker {
212
212
  detail: result,
213
213
  }
214
214
  }
215
-
216
215
  case 'WebFetch': {
217
216
  const url = input.url || ''
218
217
  if (!url) return { safe: true }
@@ -222,7 +221,6 @@ export class EnhancedPermissionChecker {
222
221
  reason: result.reason,
223
222
  }
224
223
  }
225
-
226
224
  default:
227
225
  return { safe: true }
228
226
  }
@@ -231,15 +229,20 @@ export class EnhancedPermissionChecker {
231
229
  /** 提取规则匹配用的模式字符串 */
232
230
  _extractPattern(toolName, input) {
233
231
  switch (toolName) {
234
- case 'Bash': return input.command || ''
232
+ case 'Bash':
233
+ return input.command || ''
235
234
  case 'Read':
236
235
  case 'Edit':
237
- case 'Write': return input.file_path || ''
236
+ case 'Write':
237
+ return input.file_path || ''
238
238
  case 'Glob':
239
- case 'Grep': return input.path || input.pattern || ''
239
+ case 'Grep':
240
+ return input.path || input.pattern || ''
240
241
  case 'WebFetch':
241
- case 'WebSearch': return input.url || input.query || ''
242
- default: return JSON.stringify(input)
242
+ case 'WebSearch':
243
+ return input.url || input.query || ''
244
+ default:
245
+ return JSON.stringify(input)
243
246
  }
244
247
  }
245
248
 
@@ -252,7 +255,6 @@ export class EnhancedPermissionChecker {
252
255
  allowed,
253
256
  reason,
254
257
  })
255
-
256
258
  // 限制审计日志大小
257
259
  if (this.auditLog.length > this._maxAuditEntries) {
258
260
  this.auditLog = this.auditLog.slice(-this._maxAuditEntries)
@@ -271,7 +273,11 @@ export class EnhancedPermissionChecker {
271
273
  decision: r.decision,
272
274
  reason: r.reason,
273
275
  }))
274
- await writeFile(join(dir, 'permissions.json'), JSON.stringify(data, null, 2), 'utf-8')
276
+ // v1.1 修复:文件权限 0600(仅所有者可读写),防止其他用户读取权限规则
277
+ await writeFile(join(dir, 'permissions.json'), JSON.stringify(data, null, 2), {
278
+ encoding: 'utf-8',
279
+ mode: 0o600,
280
+ })
275
281
  }
276
282
 
277
283
  /** 加载权限规则 */
@@ -287,19 +293,51 @@ export class EnhancedPermissionChecker {
287
293
  }
288
294
  }
289
295
 
290
- /** 保存审计日志 */
296
+ /** 保存审计日志(v1.1: 增加轮转,防止磁盘耗尽) */
291
297
  async saveAuditLog() {
292
298
  const dir = join(this.projectDir, '.claude-code')
293
299
  await mkdir(dir, { recursive: true })
294
- const lines = this.auditLog.map(e =>
295
- `${e.timestamp} | ${e.tool} | ${e.allowed ? 'ALLOW' : 'DENY'} | ${e.reason} | ${e.inputSnippet}`
296
- ).join('\n')
297
- await writeFile(join(dir, 'audit.log'), lines, 'utf-8')
300
+ const logPath = join(dir, 'audit.log')
301
+
302
+ // 检查现有日志大小,超过上限则轮转
303
+ try {
304
+ const logStat = await stat(logPath)
305
+ if (logStat.size >= AUDIT_LOG_MAX_SIZE) {
306
+ // 轮转:audit.log → audit.log.1 → audit.log.2 → audit.log.3(最老的删除)
307
+ for (let i = AUDIT_LOG_MAX_BACKUPS; i >= 1; i--) {
308
+ const src = i === 1 ? logPath : join(dir, `audit.log.${i - 1}`)
309
+ const dst = join(dir, `audit.log.${i}`)
310
+ try {
311
+ if (i === AUDIT_LOG_MAX_BACKUPS) {
312
+ // 最老的备份直接删除
313
+ const { unlink } = await import('fs/promises')
314
+ await unlink(dst).catch(() => {})
315
+ }
316
+ await rename(src, dst).catch(() => {})
317
+ } catch {
318
+ /* 忽略轮转错误 */
319
+ }
320
+ }
321
+ }
322
+ } catch {
323
+ /* 日志文件不存在,首次写入 */
324
+ }
325
+
326
+ const lines = this.auditLog
327
+ .map(e => `${e.timestamp} | ${e.tool} | ${e.allowed ? 'ALLOW' : 'DENY'} | ${e.reason} | ${e.inputSnippet}`)
328
+ .join('\n')
329
+ // v1.1 修复:文件权限 0600
330
+ await writeFile(logPath, lines, { encoding: 'utf-8', mode: 0o600 })
298
331
  }
299
332
 
300
333
  /** 获取审计摘要 */
301
334
  getAuditSummary() {
302
- const summary = { total: this.auditLog.length, allowed: 0, denied: 0, byTool: {} }
335
+ const summary = {
336
+ total: this.auditLog.length,
337
+ allowed: 0,
338
+ denied: 0,
339
+ byTool: {},
340
+ }
303
341
  for (const entry of this.auditLog) {
304
342
  if (entry.allowed) summary.allowed++
305
343
  else summary.denied++
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * 路径安全防护 — 防止路径遍历攻击和敏感文件访问
3
3
  * 对应原版: src/utils/permissions/filesystem.ts + 多处路径检查
4
+ *
5
+ * v1.1 修复:
6
+ * - /proc/self/ 加入禁止路径(容器逃逸/内存泄露)
7
+ * - 新增 URL 编码绕过检测(%2e%2e, %2f, %5c 等)
8
+ * - 双重编码绕过检测(%252e 等)
4
9
  */
5
10
  import { resolve, normalize, isAbsolute, relative, sep } from 'path'
6
11
 
@@ -16,6 +21,7 @@ const FORBIDDEN_PATHS = [
16
21
  '/etc/pam.d/',
17
22
  '/boot/',
18
23
  '/proc/sys/',
24
+ '/proc/self/', // v1.1: 阻止 /proc/self 访问(容器逃逸/内存泄露)
19
25
  '/sys/kernel/',
20
26
  ]
21
27
 
@@ -31,13 +37,21 @@ const SENSITIVE_PREFIXES = [
31
37
 
32
38
  /**
33
39
  * 规范化路径 — 解析 .., ., 符号链接等
40
+ * v1.1 修复: 增加编码绕过检测(%2e%2e, %252e 等 URL 编码变形)
34
41
  * @param {string} filePath — 输入路径
35
42
  * @param {string} cwd — 当前工作目录
36
43
  * @returns {string} 规范化后的绝对路径
37
44
  */
38
45
  export function sanitizePath(filePath, cwd = process.cwd()) {
46
+ // v1.1: 解码 URL 编码绕过(%2e = ., %2f = /, %5c = \)
47
+ let decoded = filePath
48
+ // 双重编码先解
49
+ decoded = decoded.replace(/%252e/gi, '.').replace(/%252f/gi, '/').replace(/%255c/gi, '\\')
50
+ // 单次编码
51
+ decoded = decoded.replace(/%2e/gi, '.').replace(/%2f/gi, '/').replace(/%5c/gi, '\\')
52
+
39
53
  // 如果是相对路径,基于 cwd 解析
40
- const absPath = isAbsolute(filePath) ? filePath : resolve(cwd, filePath)
54
+ const absPath = isAbsolute(decoded) ? decoded : resolve(cwd, decoded)
41
55
  // 规范化:消除 .. 和 .
42
56
  return normalize(absPath)
43
57
  }
@@ -52,7 +66,16 @@ export function sanitizePath(filePath, cwd = process.cwd()) {
52
66
  export function checkPathTraversal(filePath, cwd = process.cwd(), allowedDirs = []) {
53
67
  const resolvedPath = sanitizePath(filePath, cwd)
54
68
 
55
- // 1. 检查 .. 在原始路径中的使用
69
+ // 1. v1.1: 检查原始输入中的编码绕过尝试
70
+ if (/%2e|%2f|%5c|%252e|%252f/i.test(filePath)) {
71
+ return {
72
+ safe: false,
73
+ resolvedPath,
74
+ reason: `路径编码绕过检测: ${filePath} 包含 URL 编码字符,疑似路径遍历攻击`,
75
+ }
76
+ }
77
+
78
+ // 2. 检查 .. 在原始路径中的使用
56
79
  if (filePath.includes('..')) {
57
80
  const normalizedRelative = relative(cwd, resolvedPath)
58
81
  if (normalizedRelative.startsWith('..') || resolvedPath.startsWith('/etc/') || resolvedPath.startsWith('/root/')) {
@@ -64,13 +87,12 @@ export function checkPathTraversal(filePath, cwd = process.cwd(), allowedDirs =
64
87
  }
65
88
  }
66
89
 
67
- // 2. 检查是否在允许的目录范围内
90
+ // 3. 检查是否在允许的目录范围内
68
91
  if (allowedDirs.length > 0) {
69
92
  const isInAllowedDir = allowedDirs.some(dir => {
70
93
  const normDir = normalize(isAbsolute(dir) ? dir : resolve(cwd, dir))
71
94
  return resolvedPath.startsWith(normDir + sep) || resolvedPath === normDir
72
95
  })
73
-
74
96
  if (!isInAllowedDir) {
75
97
  return {
76
98
  safe: false,
@@ -79,7 +101,6 @@ export function checkPathTraversal(filePath, cwd = process.cwd(), allowedDirs =
79
101
  }
80
102
  }
81
103
  }
82
-
83
104
  return { safe: true, resolvedPath }
84
105
  }
85
106
 
@@ -92,41 +113,26 @@ export function checkForbiddenPath(resolvedPath) {
92
113
  // 1. 严格匹配禁止路径
93
114
  for (const forbidden of FORBIDDEN_PATHS) {
94
115
  if (resolvedPath === forbidden || resolvedPath.startsWith(forbidden + sep) || resolvedPath.startsWith(forbidden + '/')) {
95
- return {
96
- allowed: false,
97
- reason: `禁止访问敏感路径: ${resolvedPath}(匹配规则: ${forbidden})`,
98
- }
116
+ return { allowed: false, reason: `禁止访问敏感路径: ${resolvedPath}(匹配规则: ${forbidden})` }
99
117
  }
100
118
  }
101
119
 
102
120
  // 2. SSH 目录特殊处理
103
121
  if (resolvedPath.includes('/.ssh/') || resolvedPath.includes('\\.ssh\\')) {
104
- // 允许读取 known_hosts 和 config,禁止读取私钥
105
122
  const sshKeyPattern = /\/\.ssh\/id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$/i
106
123
  const sshConfigPattern = /\/\.ssh\/(config|known_hosts|authorized_keys)$/i
107
-
108
124
  if (sshKeyPattern.test(resolvedPath)) {
109
- return {
110
- allowed: false,
111
- reason: `禁止访问 SSH 密钥文件: ${resolvedPath}`,
112
- }
125
+ return { allowed: false, reason: `禁止访问 SSH 密钥文件: ${resolvedPath}` }
113
126
  }
114
-
115
127
  if (sshConfigPattern.test(resolvedPath)) {
116
- return {
117
- allowed: true,
118
- reason: `⚠️ 访问 SSH 配置文件: ${resolvedPath}`,
119
- }
128
+ return { allowed: true, reason: `⚠️ 访问 SSH 配置文件: ${resolvedPath}` }
120
129
  }
121
130
  }
122
131
 
123
132
  // 3. 敏感前缀检查 — 允许但提示
124
133
  for (const prefix of SENSITIVE_PREFIXES) {
125
134
  if (resolvedPath.startsWith(prefix)) {
126
- return {
127
- allowed: true,
128
- reason: `⚠️ 访问系统敏感目录: ${resolvedPath}`,
129
- }
135
+ return { allowed: true, reason: `⚠️ 访问系统敏感目录: ${resolvedPath}` }
130
136
  }
131
137
  }
132
138
 
@@ -143,7 +149,6 @@ export function checkPathSafety(filePath, options = {}) {
143
149
  const { cwd = process.cwd(), allowedDirs = [], checkForbidden = true } = options
144
150
  const reasons = []
145
151
 
146
- // 路径遍历检查
147
152
  const traversalResult = checkPathTraversal(filePath, cwd, allowedDirs)
148
153
  const resolvedPath = traversalResult.resolvedPath
149
154
 
@@ -151,7 +156,6 @@ export function checkPathSafety(filePath, options = {}) {
151
156
  reasons.push(traversalResult.reason)
152
157
  }
153
158
 
154
- // 禁止路径检查
155
159
  if (checkForbidden) {
156
160
  const forbiddenResult = checkForbiddenPath(resolvedPath)
157
161
  if (!forbiddenResult.allowed) {
@@ -162,7 +166,7 @@ export function checkPathSafety(filePath, options = {}) {
162
166
  }
163
167
 
164
168
  return {
165
- safe: !reasons.some(r => r.startsWith('禁止') || r.startsWith('路径遍历')),
169
+ safe: !reasons.some(r => r.startsWith('禁止') || r.startsWith('路径遍历') || r.startsWith('路径编码绕过')),
166
170
  resolvedPath,
167
171
  reasons,
168
172
  }
@@ -178,13 +182,12 @@ export function checkWritePathSafety(filePath, options = {}) {
178
182
  const result = checkPathSafety(filePath, options)
179
183
 
180
184
  // 写入额外检查:不能写到系统关键目录
181
- const systemWriteDirs = ['/etc/', '/boot/', '/usr/bin/', '/usr/lib/', '/sbin/', '/bin/']
185
+ const systemWriteDirs = ['/etc/', '/boot/', '/usr/bin/', '/usr/lib/', '/sbin/', '/bin/', '/proc/', '/sys/']
182
186
  for (const dir of systemWriteDirs) {
183
187
  if (result.resolvedPath.startsWith(dir)) {
184
188
  result.safe = false
185
189
  result.reasons.push(`禁止写入系统关键目录: ${dir}`)
186
190
  }
187
191
  }
188
-
189
192
  return result
190
193
  }
@@ -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
+ }