@raolin2025/claude-code-node 1.1.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.
- package/README.md +52 -0
- package/package.json +13 -4
- package/src/channel/index.js +81 -146
- package/src/channel/notify-daemon.js +592 -0
- package/src/core/cli.js +195 -114
- package/src/core/compact.js +171 -0
- package/src/core/config.js +1 -2
- package/src/core/cost-tracker.js +171 -0
- package/src/core/paths.js +12 -0
- package/src/core/query-engine.js +192 -89
- package/src/core/session.js +24 -9
- package/src/mcp/client.js +99 -1
- package/src/mcp/registry.js +1 -2
- package/src/security/bash-guard.js +174 -141
- package/src/security/enhanced-permission.js +72 -34
- package/src/security/path-guard.js +32 -29
- package/src/security/ssrf-guard.js +153 -50
- package/src/tools/glob.js +1 -1
- package/src/types/index.js +2 -1
- package/src/utils/file-ops.js +2 -3
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 权限系统增强版 — 规则持久化 + 审计日志
|
|
3
|
-
*
|
|
3
|
+
* 对应原版:src/hooks/toolPermission/ + src/utils/permissions/
|
|
4
4
|
*/
|
|
5
|
-
import { readFile, writeFile, mkdir } from 'fs/promises'
|
|
6
|
-
import {
|
|
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
|
|
30
|
-
this.decision = decision
|
|
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 = []
|
|
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,
|
|
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,
|
|
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:
|
|
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':
|
|
232
|
+
case 'Bash':
|
|
233
|
+
return input.command || ''
|
|
235
234
|
case 'Read':
|
|
236
235
|
case 'Edit':
|
|
237
|
-
case 'Write':
|
|
236
|
+
case 'Write':
|
|
237
|
+
return input.file_path || ''
|
|
238
238
|
case 'Glob':
|
|
239
|
-
case 'Grep':
|
|
239
|
+
case 'Grep':
|
|
240
|
+
return input.path || input.pattern || ''
|
|
240
241
|
case 'WebFetch':
|
|
241
|
-
case 'WebSearch':
|
|
242
|
-
|
|
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
|
-
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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 = {
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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 &&
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|