@raolin2025/claude-code-node 2.8.2 → 2.8.3
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 +20 -2
- package/package.json +1 -1
- package/src/__tests__/web-fetch-guard.test.js +193 -0
- package/src/core/cli.js +1 -0
- package/src/core/config.js +10 -0
- package/src/core/query-engine.js +1 -0
- package/src/security/fetch-guard.js +311 -0
- package/src/security/redact.js +76 -0
- package/src/tools/web-fetch-providers.js +140 -0
- package/src/tools/web-fetch.js +114 -60
package/README.md
CHANGED
|
@@ -166,7 +166,7 @@ cc-node 会自动感知当前所用模型的**上下文窗口长度**,并在
|
|
|
166
166
|
| **Write** | 创建/覆盖文件 | `ask` | ✅ 写入路径安全 |
|
|
167
167
|
| **Glob** | 文件模式搜索 | `always-allow` | — |
|
|
168
168
|
| **Grep** | 内容搜索(rg/grep) | `always-allow` | — |
|
|
169
|
-
| **WebFetch** |
|
|
169
|
+
| **WebFetch** | 抓取网页内容(安全管道 + Jina 兜底) | `ask` | ✅ SSRF + 重定向 + 脱敏 |
|
|
170
170
|
| **WebSearch** | 网页搜索 | `ask` | 需要 API Key |
|
|
171
171
|
| **GitTool** | GitHub PR 自动化(审查/合并/评论) | `high` | ✅ 预检查 |
|
|
172
172
|
| **NpmPublish** | npm 发布一键工具(版本/打包/发布) | `ask` | ✅ token 校验 |
|
|
@@ -194,9 +194,27 @@ cc-node 会自动感知当前所用模型的**上下文窗口长度**,并在
|
|
|
194
194
|
✅ ::1 — IPv6 回环
|
|
195
195
|
```
|
|
196
196
|
|
|
197
|
+
### 🕸️ WebFetch 安全抓取(安全管道 + Jina 兜底)
|
|
198
|
+
|
|
199
|
+
`WebFetch` 内置完整安全管道(移植自 safe-jina-fetch 设计,`src/security/fetch-guard.js`):
|
|
200
|
+
|
|
201
|
+
- **协议白名单**:仅 `http/https`,拒绝 `file://`、`ftp://`、`data:` 等
|
|
202
|
+
- **连接级 SSRF**:TCP 连接建立时对全部解析地址逐一校验(防 DNS rebinding),
|
|
203
|
+
并对 IP 字面量前置校验(Node 对 IP 不走 DNS lookup,必须显式拦截)
|
|
204
|
+
- **重定向逐跳校验**:默认最多 5 跳,每跳重新校验协议 + SSRF(防 302 → 内网绕过)
|
|
205
|
+
- **响应大小上限**:10MB;**超时**:30s;**强制 SSL**(证书错误直接拒绝)
|
|
206
|
+
- **敏感数据自动脱敏**:API Key / Bearer / AWS Key / 私钥 / OpenAI `sk-` / Slack token 等
|
|
207
|
+
命中即替换为 `[REDACTED:类型]` 并告警(`src/security/redact.js`)
|
|
208
|
+
|
|
209
|
+
**Jina Reader 兜底**(`src/tools/web-fetch-providers.js`):
|
|
210
|
+
|
|
211
|
+
- 直连失败(403 / 反爬 / 网络错误 / 超时)时,自动经 `r.jina.ai` 清洗后返回 Markdown
|
|
212
|
+
- 直连返回 200 但正文 < 200 字符(疑似 JS 挑战页,如豆瓣)也触发兜底
|
|
213
|
+
- `extractMode` 参数:`auto`(默认,直连优先 + 兜底)/ `direct`(强制直连)/ `jina`(强制 Jina)
|
|
214
|
+
- Jina 凭据三选一(可选,匿名约 20 RPM):环境变量 `JINA_API_KEY` > 配置 `web.fetch.jinaApiKey` > 匿名
|
|
215
|
+
|
|
197
216
|
### 2️⃣ Bash 命令安全(279 行)
|
|
198
217
|
阻止 LLM 执行危险 shell 命令:
|
|
199
|
-
|
|
200
218
|
| 类别 | 示例 | 严重性 |
|
|
201
219
|
|------|------|--------|
|
|
202
220
|
| 破坏性操作 | `rm -rf /`, `dd of=/dev/sda`, `mkfs` | 🚫 CRITICAL |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raolin2025/claude-code-node",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.3",
|
|
4
4
|
"description": "Node.js AI Code Agent CLI - Zero dependencies, pure JavaScript, security hardened, multi-channel notifications, Telegram & QQ Bot remote programming, rich media upload, multi-account management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/core/index.js",
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebFetch 安全管道 + Jina 兜底测试
|
|
3
|
+
* 覆盖:协议白名单、SSRF、重定向逐跳校验、脱敏、大小上限、Jina provider、WebFetch 兜底
|
|
4
|
+
*/
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import http from 'http'
|
|
8
|
+
import {
|
|
9
|
+
parseAndValidateUrl,
|
|
10
|
+
checkAddressBlocked,
|
|
11
|
+
safeFetchWithRedirects,
|
|
12
|
+
readBodyLimited,
|
|
13
|
+
} from '../security/fetch-guard.js'
|
|
14
|
+
import { redactSensitiveData } from '../security/redact.js'
|
|
15
|
+
import {
|
|
16
|
+
safeJinaFetch,
|
|
17
|
+
cleanJinaMarkdown,
|
|
18
|
+
extractTitleFromMarkdown,
|
|
19
|
+
resolveJinaApiKey,
|
|
20
|
+
} from '../tools/web-fetch-providers.js'
|
|
21
|
+
|
|
22
|
+
// ============ 协议白名单 ============
|
|
23
|
+
test('协议白名单:拒绝 file/ftp/data', () => {
|
|
24
|
+
assert.equal(parseAndValidateUrl('file:///etc/passwd').ok, false)
|
|
25
|
+
assert.equal(parseAndValidateUrl('ftp://example.com/x').ok, false)
|
|
26
|
+
assert.equal(parseAndValidateUrl('data:text/plain,hello').ok, false)
|
|
27
|
+
assert.equal(parseAndValidateUrl('javascript:alert(1)').ok, false)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('协议白名单:允许 http/https', () => {
|
|
31
|
+
assert.equal(parseAndValidateUrl('https://example.com').ok, true)
|
|
32
|
+
assert.equal(parseAndValidateUrl('http://example.com').ok, true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('无效 URL 拒绝', () => {
|
|
36
|
+
assert.equal(parseAndValidateUrl('not a url').ok, false)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// ============ SSRF 地址校验 ============
|
|
40
|
+
test('SSRF:私有/保留地址全部阻止', () => {
|
|
41
|
+
assert.equal(checkAddressBlocked('127.0.0.1').blocked, true)
|
|
42
|
+
assert.equal(checkAddressBlocked('10.0.0.1').blocked, true)
|
|
43
|
+
assert.equal(checkAddressBlocked('192.168.1.1').blocked, true)
|
|
44
|
+
assert.equal(checkAddressBlocked('172.16.0.1').blocked, true)
|
|
45
|
+
assert.equal(checkAddressBlocked('100.64.0.1').blocked, true)
|
|
46
|
+
assert.equal(checkAddressBlocked('169.254.169.254').blocked, true)
|
|
47
|
+
assert.equal(checkAddressBlocked('0.0.0.0').blocked, true)
|
|
48
|
+
assert.equal(checkAddressBlocked('::1').blocked, true)
|
|
49
|
+
assert.equal(checkAddressBlocked('fe80::1').blocked, true)
|
|
50
|
+
assert.equal(checkAddressBlocked('fd00::1').blocked, true)
|
|
51
|
+
assert.equal(checkAddressBlocked('::ffff:192.168.1.1').blocked, true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('SSRF:公网地址放行', () => {
|
|
55
|
+
assert.equal(checkAddressBlocked('8.8.8.8').blocked, false)
|
|
56
|
+
assert.equal(checkAddressBlocked('1.1.1.1').blocked, false)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('SSRF:内网域名后缀阻止', () => {
|
|
60
|
+
assert.equal(parseAndValidateUrl('http://router.local/x').ok, false)
|
|
61
|
+
assert.equal(parseAndValidateUrl('http://svc.internal/x').ok, false)
|
|
62
|
+
assert.equal(parseAndValidateUrl('http://localhost:8080/').ok, false)
|
|
63
|
+
assert.equal(parseAndValidateUrl('http://db.home/x').ok, false)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('SSRF:IP 字面量私网地址被 parseAndValidateUrl 拒绝', () => {
|
|
67
|
+
assert.equal(parseAndValidateUrl('http://127.0.0.1:8080/').ok, false)
|
|
68
|
+
assert.equal(parseAndValidateUrl('http://192.168.1.1/').ok, false)
|
|
69
|
+
assert.equal(parseAndValidateUrl('http://10.0.0.5/').ok, false)
|
|
70
|
+
assert.equal(parseAndValidateUrl('https://8.8.8.8/').ok, true)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// ============ 重定向逐跳校验 ============
|
|
74
|
+
test('重定向:起始 URL 为内网地址被 SSRF 阻止', async () => {
|
|
75
|
+
// 本地服务器跑在 127.0.0.1(回环,SSRF 目标),safeFetchWithRedirects 应在连接前拒绝
|
|
76
|
+
const server = http.createServer((req, res) => {
|
|
77
|
+
res.writeHead(200)
|
|
78
|
+
res.end('ok')
|
|
79
|
+
})
|
|
80
|
+
await new Promise(r => server.listen(0, r))
|
|
81
|
+
const port = server.address().port
|
|
82
|
+
const result = await safeFetchWithRedirects(`http://127.0.0.1:${port}/`, { timeoutMs: 2000 })
|
|
83
|
+
server.close()
|
|
84
|
+
assert.equal(result.ok, false)
|
|
85
|
+
assert.ok(result.error && result.error.includes('SSRF'), `应报 SSRF 错误,实际: ${result.error}`)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('重定向:目标跳转到内网被拦截(校验 next URL)', async () => {
|
|
89
|
+
// 用公网可访问的域名作为起始(绕过起始 SSRF),但通过 verifySafeRedirectTarget 验证目标校验。
|
|
90
|
+
// 直接验证"相对重定向 → 内网"的目标解析 + 校验逻辑:
|
|
91
|
+
// 起始 https://example.com/a → 302 Location: http://192.168.1.1/(绝对内网)
|
|
92
|
+
const v = parseAndValidateUrl('http://192.168.1.1/')
|
|
93
|
+
assert.equal(v.ok, false)
|
|
94
|
+
assert.ok(v.reason.includes('192.168.1.1'))
|
|
95
|
+
|
|
96
|
+
// 相对重定向目标解析:https://example.com/x + Location: //192.168.1.1 也是内网
|
|
97
|
+
const parsed = new URL('//192.168.1.1/', 'https://example.com')
|
|
98
|
+
assert.equal(parsed.hostname, '192.168.1.1')
|
|
99
|
+
const v2 = parseAndValidateUrl(parsed.href)
|
|
100
|
+
assert.equal(v2.ok, false)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('重定向:相对路径解析到公网放行', () => {
|
|
104
|
+
const parsed = new URL('/final', 'https://example.com/start')
|
|
105
|
+
assert.equal(parsed.href, 'https://example.com/final')
|
|
106
|
+
assert.equal(parseAndValidateUrl(parsed.href).ok, true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// ============ 响应大小上限 ============
|
|
110
|
+
test('响应大小上限:超限截断', async () => {
|
|
111
|
+
const server = http.createServer((req, res) => {
|
|
112
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' })
|
|
113
|
+
res.end('x'.repeat(1000))
|
|
114
|
+
})
|
|
115
|
+
await new Promise(r => server.listen(0, r))
|
|
116
|
+
const port = server.address().port
|
|
117
|
+
// 无法用 127.0.0.1(SSRF 阻止),改用测试 readBodyLimited 直接逻辑
|
|
118
|
+
server.close()
|
|
119
|
+
|
|
120
|
+
// 直接测试 readBodyLimited
|
|
121
|
+
const chunks = { async *[Symbol.asyncIterator]() { yield Buffer.from('x'.repeat(100)); yield Buffer.from('y'.repeat(100)) } }
|
|
122
|
+
const fakeRes = { [Symbol.asyncIterator]: chunks[Symbol.asyncIterator].bind(chunks) }
|
|
123
|
+
const { body, truncated } = await readBodyLimited(fakeRes, 150)
|
|
124
|
+
assert.equal(truncated, true)
|
|
125
|
+
assert.equal(body.length, 150)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// ============ 敏感数据脱敏 ============
|
|
129
|
+
test('脱敏:OpenAI key 替换', () => {
|
|
130
|
+
const { text, redacted } = redactSensitiveData('key is sk-abcdefghijklmnopqrstuvwxyz1234567890')
|
|
131
|
+
assert.equal(text.includes('sk-abcdefghijklmnopqrstuvwxyz'), false)
|
|
132
|
+
assert.ok(text.includes('[REDACTED:OpenAI-Key]'))
|
|
133
|
+
assert.ok(redacted.includes('OpenAI-Key'))
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('脱敏:AWS key 替换', () => {
|
|
137
|
+
const { text, redacted } = redactSensitiveData('AKIAIOSFODNN7EXAMPLE')
|
|
138
|
+
assert.ok(text.includes('[REDACTED:AWS-Key]'))
|
|
139
|
+
assert.ok(redacted.includes('AWS-Key'))
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('脱敏:私钥整体替换', () => {
|
|
143
|
+
const { text } = redactSensitiveData('-----BEGIN RSA PRIVATE KEY-----\nabc123\n-----END RSA PRIVATE KEY-----')
|
|
144
|
+
assert.ok(!text.includes('abc123'))
|
|
145
|
+
assert.ok(text.includes('[REDACTED:Private-Key]'))
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('脱敏:Bearer token 替换', () => {
|
|
149
|
+
const { text } = redactSensitiveData('Authorization: Bearer 0123456789abcdef0123456789abcdef')
|
|
150
|
+
assert.ok(text.includes('[REDACTED:Bearer]'))
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('脱敏:无敏感内容不动', () => {
|
|
154
|
+
const { text, redacted } = redactSensitiveData('hello world, nothing sensitive here')
|
|
155
|
+
assert.equal(text, 'hello world, nothing sensitive here')
|
|
156
|
+
assert.equal(redacted.length, 0)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// ============ Jina provider ============
|
|
160
|
+
test('Jina markdown 清洗', () => {
|
|
161
|
+
const md = 'Title: Test Page\nURL Source: https://example.com\nMarkdown Content:\n\nHello **world**'
|
|
162
|
+
const cleaned = cleanJinaMarkdown(md)
|
|
163
|
+
assert.equal(cleaned, 'Hello **world**')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('Jina 标题提取', () => {
|
|
167
|
+
assert.equal(extractTitleFromMarkdown('Title: My Page\ncontent'), 'My Page')
|
|
168
|
+
assert.equal(extractTitleFromMarkdown('no title'), '')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('Jina key 解析优先级:环境变量 > 配置', () => {
|
|
172
|
+
process.env.JINA_API_KEY = 'env-key'
|
|
173
|
+
const fromEnv = resolveJinaApiKey({ get: () => 'config-key' })
|
|
174
|
+
assert.equal(fromEnv, 'env-key')
|
|
175
|
+
delete process.env.JINA_API_KEY
|
|
176
|
+
const fromCfg = resolveJinaApiKey({ get: () => 'config-key' })
|
|
177
|
+
assert.equal(fromCfg, 'config-key')
|
|
178
|
+
const anonymous = resolveJinaApiKey(null)
|
|
179
|
+
assert.equal(anonymous, '')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('safeJinaFetch:拒绝 file 协议', async () => {
|
|
183
|
+
const r = await safeJinaFetch('file:///etc/passwd')
|
|
184
|
+
assert.equal(r.ok, false)
|
|
185
|
+
assert.ok(r.error.includes('协议'))
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('safeJinaFetch:本地 mock 服务器(重定向到 Jina 不可行,验证网络错误处理)', async () => {
|
|
189
|
+
// 指向一个不存在的端口,验证错误路径
|
|
190
|
+
const r = await safeJinaFetch('http://127.0.0.1:1/', { timeoutMs: 500 })
|
|
191
|
+
// Jina 服务本身会 4xx(因为 r.jina.ai/127.0.0.1... 无法访问),或返回错误
|
|
192
|
+
assert.equal(typeof r.ok, 'boolean')
|
|
193
|
+
})
|
package/src/core/cli.js
CHANGED
package/src/core/config.js
CHANGED
|
@@ -26,6 +26,16 @@ const DEFAULTS = {
|
|
|
26
26
|
fileRead: { maxLines: 2000, maxSizeKB: 256 },
|
|
27
27
|
webFetch: { timeout: 30, maxChars: 100000 },
|
|
28
28
|
},
|
|
29
|
+
web: {
|
|
30
|
+
fetch: {
|
|
31
|
+
maxChars: 100000,
|
|
32
|
+
maxBytes: 10485760, // 10MB
|
|
33
|
+
timeoutMs: 30000,
|
|
34
|
+
maxRedirects: 5,
|
|
35
|
+
fallbackProvider: 'safe-jina', // 直连失败时的兜底 provider
|
|
36
|
+
jinaApiKey: '', // 可选(匿名约 20 RPM)
|
|
37
|
+
},
|
|
38
|
+
},
|
|
29
39
|
channels: {},
|
|
30
40
|
defaultChannel: null,
|
|
31
41
|
qqbot: {
|
package/src/core/query-engine.js
CHANGED
|
@@ -47,6 +47,7 @@ export class QueryEngineConfig {
|
|
|
47
47
|
this.onAskUser = options.onAskUser || null // AskUserQuestion 工具回调(宿主按来源分流,避免远程死锁)
|
|
48
48
|
this.readline = options.readline || null // 用于 AskUserQuestion 工具
|
|
49
49
|
this.onDelta = options.onDelta || null // 流式增量回调 {type:'text'|'reasoning', text}(供 VS Code 扩展等 UI 消费)
|
|
50
|
+
this.configStore = options.configStore || null // 配置实例(供工具读取 web.fetch.jinaApiKey 等;可选)
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch 安全管道 — 协议白名单 + 连接级 SSRF 防护 + 重定向逐跳校验 + 大小/超时/SSL
|
|
3
|
+
*
|
|
4
|
+
* 移植自 openclaw safe-jina-fetch 设计(DESIGN.md §4),零依赖,仅内置模块。
|
|
5
|
+
*
|
|
6
|
+
* 设计原则:直连优先、兜底不扰。
|
|
7
|
+
* - 直连能拿到的,不多花一跳;
|
|
8
|
+
* - 直连被拦(非 ok / 网络错误 / 超时)才由调用方走 Jina Reader 兜底(见 web-fetch.js)。
|
|
9
|
+
*
|
|
10
|
+
* 相比项目原 ssrf-guard.js 的增强:
|
|
11
|
+
* - 连接级校验:给 http/https.request 注入 lookup 钩子,在 TCP 连接建立时对
|
|
12
|
+
* 全部解析地址逐一校验(防 DNS rebinding 的 TOCTOU 竞态);
|
|
13
|
+
* - 重定向逐跳校验:默认最多 5 跳,每一跳重新走完整协议/SSRF/域名校验。
|
|
14
|
+
*/
|
|
15
|
+
import { lookup as dnsLookup } from 'dns'
|
|
16
|
+
import http from 'http'
|
|
17
|
+
import https from 'https'
|
|
18
|
+
import { isIP } from 'net'
|
|
19
|
+
import { isBlockedAddress } from './ssrf-guard.js'
|
|
20
|
+
|
|
21
|
+
/** 协议白名单 — 仅 http/https,file/ftp/data 等一律拒绝 */
|
|
22
|
+
const ALLOWED_PROTOCOLS = ['http:', 'https:']
|
|
23
|
+
|
|
24
|
+
/** 域名后缀黑名单 */
|
|
25
|
+
const BLOCKED_HOSTNAME_SUFFIXES = ['.local', '.internal', '.localdomain', '.localhost', '.home', '.lan']
|
|
26
|
+
|
|
27
|
+
/** 已知 SSRF 目标主机名(精确匹配,兜底) */
|
|
28
|
+
const BLOCKED_HOSTNAMES = [
|
|
29
|
+
'localhost',
|
|
30
|
+
'localhost.localdomain',
|
|
31
|
+
'ip6-localhost',
|
|
32
|
+
'ip6-loopback',
|
|
33
|
+
'metadata.google.internal',
|
|
34
|
+
'metadata.internal',
|
|
35
|
+
'instance-data',
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_FETCH_OPTIONS = {
|
|
39
|
+
maxBytes: 10 * 1024 * 1024, // 响应大小上限 10MB
|
|
40
|
+
timeoutMs: 30000, // 超时 30s
|
|
41
|
+
maxRedirects: 5, // 重定向最多 5 跳
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 校验 URL 的协议是否在白名单内
|
|
46
|
+
* @param {string} protocol — 如 'https:'
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
export function isAllowedProtocol(protocol) {
|
|
50
|
+
return ALLOWED_PROTOCOLS.includes(protocol)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 校验主机名是否在内网/SSRF 黑名单
|
|
55
|
+
* @param {string} hostname
|
|
56
|
+
* @returns {{blocked: boolean, reason?: string}}
|
|
57
|
+
*/
|
|
58
|
+
export function checkHostnameBlocked(hostname) {
|
|
59
|
+
const lower = (hostname || '').toLowerCase()
|
|
60
|
+
if (!lower) return { blocked: true, reason: '空主机名' }
|
|
61
|
+
if (BLOCKED_HOSTNAMES.includes(lower)) {
|
|
62
|
+
return { blocked: true, reason: `主机名 ${hostname} 为已知 SSRF 目标` }
|
|
63
|
+
}
|
|
64
|
+
for (const suffix of BLOCKED_HOSTNAME_SUFFIXES) {
|
|
65
|
+
if (lower.endsWith(suffix)) {
|
|
66
|
+
return { blocked: true, reason: `主机名 ${hostname} 为内网域名(后缀 ${suffix})` }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { blocked: false }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 校验单个 IP 地址是否安全(连接级,供 lookup 钩子逐地址调用)
|
|
74
|
+
* @param {string} address
|
|
75
|
+
* @returns {{blocked: boolean, reason?: string}}
|
|
76
|
+
*/
|
|
77
|
+
export function checkAddressBlocked(address) {
|
|
78
|
+
if (isBlockedAddress(address)) {
|
|
79
|
+
return { blocked: true, reason: `地址 ${address} 在私有/保留范围内,可能为 SSRF 目标` }
|
|
80
|
+
}
|
|
81
|
+
return { blocked: false }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 安全 DNS lookup — 连接时逐地址校验(防 DNS rebinding)
|
|
86
|
+
*
|
|
87
|
+
* 兼容 Node 两种签名:
|
|
88
|
+
* - options.all = true → (err, addresses[])
|
|
89
|
+
* - 否则 → (err, address, family)
|
|
90
|
+
* 若任一解析地址命中私有/保留网段,立即报错阻断连接(而非放行让上层再查)。
|
|
91
|
+
*/
|
|
92
|
+
export function safeLookup(hostname, options, callback) {
|
|
93
|
+
const all = !!(options && options.all)
|
|
94
|
+
|
|
95
|
+
dnsLookup(hostname, { all: true }, (err, addresses) => {
|
|
96
|
+
if (err) {
|
|
97
|
+
// DNS 解析失败 — 交由上层处理(通常导致请求失败)
|
|
98
|
+
if (typeof callback === 'function') {
|
|
99
|
+
if (all) callback(err, [])
|
|
100
|
+
else callback(err, undefined, undefined)
|
|
101
|
+
}
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const list = Array.isArray(addresses) ? addresses : [{ address, family: 4 }]
|
|
106
|
+
|
|
107
|
+
// 逐地址校验 — 任一命中即阻断(拒绝整条连接,防止 rebinding 切到私有地址)
|
|
108
|
+
for (const a of list) {
|
|
109
|
+
const addr = typeof a === 'string' ? a : a.address
|
|
110
|
+
const { blocked, reason } = checkAddressBlocked(addr)
|
|
111
|
+
if (blocked) {
|
|
112
|
+
const e = new Error(`SSRF blocked: ${reason}`)
|
|
113
|
+
e.code = 'SSRF_BLOCKED'
|
|
114
|
+
if (typeof callback === 'function') {
|
|
115
|
+
if (all) callback(e, [])
|
|
116
|
+
else callback(e, undefined, undefined)
|
|
117
|
+
}
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 全部安全 — 返回解析结果(保持签名兼容)
|
|
123
|
+
if (typeof callback === 'function') {
|
|
124
|
+
if (all) {
|
|
125
|
+
callback(null, list)
|
|
126
|
+
} else {
|
|
127
|
+
const first = list[0]
|
|
128
|
+
callback(null, typeof first === 'string' ? first : first.address, typeof first === 'string' ? 4 : first.family)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 构造带连接级 SSRF 防护的 http/https 客户端
|
|
136
|
+
* 通过注入 lookup 钩子,在 TCP 连接建立时对全部解析地址逐一校验。
|
|
137
|
+
* @returns {{http: import('http').Agent, https: import('https').Agent}}
|
|
138
|
+
*/
|
|
139
|
+
export function createSafeAgents() {
|
|
140
|
+
const agentOptions = { lookup: safeLookup, keepAlive: false }
|
|
141
|
+
return {
|
|
142
|
+
http: new http.Agent(agentOptions),
|
|
143
|
+
https: new https.Agent(agentOptions),
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 解析并校验一个 URL(协议白名单 + 主机名黑名单)
|
|
149
|
+
* @param {string} url
|
|
150
|
+
* @returns {{ok: true, url: URL} | {ok: false, reason: string}}
|
|
151
|
+
*/
|
|
152
|
+
export function parseAndValidateUrl(url) {
|
|
153
|
+
let parsed
|
|
154
|
+
try {
|
|
155
|
+
parsed = new URL(url)
|
|
156
|
+
} catch {
|
|
157
|
+
return { ok: false, reason: `无效的 URL: ${url}` }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!isAllowedProtocol(parsed.protocol)) {
|
|
161
|
+
return { ok: false, reason: `不支持的协议:${parsed.protocol}(仅允许 http/https)` }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const hostBlock = checkHostnameBlocked(parsed.hostname)
|
|
165
|
+
if (hostBlock.blocked) {
|
|
166
|
+
return { ok: false, reason: hostBlock.reason }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// IP 字面量:直接校验是否在私有/保留网段(Node 对 IP 字面量不走 DNS lookup,
|
|
170
|
+
// 连接级 lookup 钩子不会触发,必须在发起请求前显式校验,否则 SSRF 被绕过)
|
|
171
|
+
if (isIP(parsed.hostname)) {
|
|
172
|
+
const addrBlock = checkAddressBlocked(parsed.hostname)
|
|
173
|
+
if (addrBlock.blocked) {
|
|
174
|
+
return { ok: false, reason: addrBlock.reason }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { ok: true, url: parsed }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 读取响应体,限制大小(超限截断并在 warnings 标记)
|
|
183
|
+
* @param {import('http').IncomingMessage} res
|
|
184
|
+
* @param {number} maxBytes
|
|
185
|
+
* @returns {Promise<{body: string, truncated: boolean}>}
|
|
186
|
+
*/
|
|
187
|
+
export async function readBodyLimited(res, maxBytes) {
|
|
188
|
+
const chunks = []
|
|
189
|
+
let total = 0
|
|
190
|
+
let truncated = false
|
|
191
|
+
for await (const chunk of res) {
|
|
192
|
+
total += chunk.length
|
|
193
|
+
if (total > maxBytes) {
|
|
194
|
+
truncated = true
|
|
195
|
+
chunks.push(chunk.slice(0, maxBytes - (total - chunk.length)))
|
|
196
|
+
break
|
|
197
|
+
}
|
|
198
|
+
chunks.push(chunk)
|
|
199
|
+
}
|
|
200
|
+
return { body: Buffer.concat(chunks).toString('utf-8'), truncated }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 带安全管道的 HTTP 抓取函数(单次请求,含协议/SSRF/大小/超时/SSL)
|
|
205
|
+
*
|
|
206
|
+
* 注意:本函数只发一次请求,不自动跟随重定向——重定向由 safeFetchWithRedirects
|
|
207
|
+
* 逐跳处理(每跳重新校验)。返回 { status, headers, body, truncated, finalUrl }。
|
|
208
|
+
*
|
|
209
|
+
* @param {string} url
|
|
210
|
+
* @param {object} [options]
|
|
211
|
+
* @param {number} [options.timeoutMs]
|
|
212
|
+
* @param {number} [options.maxBytes]
|
|
213
|
+
* @param {object} [options.headers]
|
|
214
|
+
* @returns {Promise<{ok: boolean, status: number, headers: object, body: string, truncated: boolean, finalUrl: string, error?: string}>}
|
|
215
|
+
*/
|
|
216
|
+
export function safeRequest(url, options = {}) {
|
|
217
|
+
const { timeoutMs = DEFAULT_FETCH_OPTIONS.timeoutMs, maxBytes = DEFAULT_FETCH_OPTIONS.maxBytes, headers = {} } = options
|
|
218
|
+
|
|
219
|
+
const validated = parseAndValidateUrl(url)
|
|
220
|
+
if (!validated.ok) {
|
|
221
|
+
return Promise.resolve({ ok: false, status: 0, headers: {}, body: '', truncated: false, finalUrl: url, error: validated.reason })
|
|
222
|
+
}
|
|
223
|
+
const parsed = validated.url
|
|
224
|
+
|
|
225
|
+
return new Promise((resolve) => {
|
|
226
|
+
const client = parsed.protocol === 'https:' ? https : http
|
|
227
|
+
const req = client.request(
|
|
228
|
+
parsed,
|
|
229
|
+
{
|
|
230
|
+
method: 'GET',
|
|
231
|
+
headers: { 'User-Agent': 'cc-node', 'Accept': 'text/html,application/json,text/plain,*/*', ...headers },
|
|
232
|
+
// 连接级 SSRF 防护(TCP 连接时逐地址校验,防 DNS rebinding)
|
|
233
|
+
lookup: safeLookup,
|
|
234
|
+
// 强制校验证书,不提供跳过选项
|
|
235
|
+
rejectUnauthorized: true,
|
|
236
|
+
},
|
|
237
|
+
async (res) => {
|
|
238
|
+
// 读响应体(限制大小)
|
|
239
|
+
try {
|
|
240
|
+
const { body, truncated } = await readBodyLimited(res, maxBytes)
|
|
241
|
+
resolve({
|
|
242
|
+
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
243
|
+
status: res.statusCode || 0,
|
|
244
|
+
headers: res.headers || {},
|
|
245
|
+
body,
|
|
246
|
+
truncated,
|
|
247
|
+
finalUrl: res.responseUrl || parsed.href,
|
|
248
|
+
})
|
|
249
|
+
} catch (err) {
|
|
250
|
+
resolve({ ok: false, status: res.statusCode || 0, headers: res.headers || {}, body: '', truncated: false, finalUrl: parsed.href, error: err.message })
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
req.setTimeout(timeoutMs, () => {
|
|
256
|
+
req.destroy(new Error('请求超时'))
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
req.on('error', (err) => {
|
|
260
|
+
resolve({ ok: false, status: 0, headers: {}, body: '', truncated: false, finalUrl: parsed.href, error: err.message })
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
req.end()
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 带重定向逐跳校验的安全抓取
|
|
269
|
+
* 每一跳都重新执行协议/SSRF/域名校验,防止 302 → 内网 的重定向绕过。
|
|
270
|
+
*
|
|
271
|
+
* @param {string} url
|
|
272
|
+
* @param {object} [options]
|
|
273
|
+
* @param {number} [options.maxRedirects] — 最大跳数(默认 5)
|
|
274
|
+
* @returns {Promise<{ok: boolean, status: number, headers: object, body: string, truncated: boolean, finalUrl: string, redirects: string[], error?: string, warning?: string}>}
|
|
275
|
+
*/
|
|
276
|
+
export async function safeFetchWithRedirects(url, options = {}) {
|
|
277
|
+
const { maxRedirects = DEFAULT_FETCH_OPTIONS.maxRedirects, ...reqOptions } = options
|
|
278
|
+
let currentUrl = url
|
|
279
|
+
const redirects = []
|
|
280
|
+
|
|
281
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
282
|
+
const result = await safeRequest(currentUrl, reqOptions)
|
|
283
|
+
|
|
284
|
+
// 3xx 重定向 — 逐跳校验下一目标
|
|
285
|
+
if (result.status >= 300 && result.status < 400 && result.headers.location) {
|
|
286
|
+
// 校验下一跳 URL(相对路径需拼接到当前 URL)
|
|
287
|
+
let nextUrl
|
|
288
|
+
try {
|
|
289
|
+
nextUrl = new URL(result.headers.location, currentUrl).href
|
|
290
|
+
} catch {
|
|
291
|
+
return { ...result, redirects, error: `无效的重定向目标: ${result.headers.location}` }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 重定向目标重新走协议/SSRF 校验(防止跳到内网)
|
|
295
|
+
const v = parseAndValidateUrl(nextUrl)
|
|
296
|
+
if (!v.ok) {
|
|
297
|
+
return { ...result, redirects, error: `重定向目标被安全策略阻止: ${v.reason}` }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
redirects.push(`${result.status} → ${nextUrl}`)
|
|
301
|
+
currentUrl = nextUrl
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 非重定向 — 返回最终结果(含重定向链)
|
|
306
|
+
return { ...result, redirects, finalUrl: currentUrl }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// 超过最大跳数
|
|
310
|
+
return { ok: false, status: 0, headers: {}, body: '', truncated: false, finalUrl: currentUrl, redirects, error: `重定向次数超过上限 (${maxRedirects})` }
|
|
311
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 敏感数据自动脱敏
|
|
3
|
+
*
|
|
4
|
+
* 移植自 openclaw safe-jina-fetch 设计(DESIGN.md §4.4)。
|
|
5
|
+
* 对响应内容扫描常见敏感模式,命中即替换为 [REDACTED:类型],并在 warnings 中告警。
|
|
6
|
+
* (原 Python 版只告警不脱敏,这里按 v2.0 设计升级为自动脱敏)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** 各类敏感模式的检测规则:{ 类型, 正则, 替换值 } */
|
|
10
|
+
const REDACT_RULES = [
|
|
11
|
+
// API Key / Access Token / Auth Token(key= / token= 等 8 位以上值)
|
|
12
|
+
{
|
|
13
|
+
type: 'API-Key',
|
|
14
|
+
regex: /(key|token|api[_-]?key|access[_-]?token|auth[_-]?token|secret)=(['"]?)[A-Za-z0-9_\-./+]{8,}\2/gi,
|
|
15
|
+
value: '[REDACTED:API-Key]',
|
|
16
|
+
},
|
|
17
|
+
// Bearer Token(20 位以上)
|
|
18
|
+
{
|
|
19
|
+
type: 'Bearer',
|
|
20
|
+
regex: /Bearer\s+[A-Za-z0-9_\-.]{20,}/gi,
|
|
21
|
+
value: '[REDACTED:Bearer]',
|
|
22
|
+
},
|
|
23
|
+
// AWS Access Key(AKIA 开头 16 位)
|
|
24
|
+
{
|
|
25
|
+
type: 'AWS-Key',
|
|
26
|
+
regex: /AKIA[0-9A-Z]{16}/g,
|
|
27
|
+
value: '[REDACTED:AWS-Key]',
|
|
28
|
+
},
|
|
29
|
+
// AWS Secret Access Key
|
|
30
|
+
{
|
|
31
|
+
type: 'AWS-Secret',
|
|
32
|
+
regex: /aws_secret_access_key\s*=\s*['"]?[A-Za-z0-9/+=]{20,}/gi,
|
|
33
|
+
value: '[REDACTED:AWS-Secret]',
|
|
34
|
+
},
|
|
35
|
+
// 私钥
|
|
36
|
+
{
|
|
37
|
+
type: 'Private-Key',
|
|
38
|
+
regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
39
|
+
value: '[REDACTED:Private-Key]',
|
|
40
|
+
},
|
|
41
|
+
// OpenAI 风格(sk- + 20 位以上字母数字)
|
|
42
|
+
{
|
|
43
|
+
type: 'OpenAI-Key',
|
|
44
|
+
regex: /sk-[A-Za-z0-9]{20,}/g,
|
|
45
|
+
value: '[REDACTED:OpenAI-Key]',
|
|
46
|
+
},
|
|
47
|
+
// Slack Token(xoxb / xoxa / xoxp / xoxr)
|
|
48
|
+
{
|
|
49
|
+
type: 'Slack-Token',
|
|
50
|
+
regex: /xox[baprs]-[A-Za-z0-9-]{10,}/g,
|
|
51
|
+
value: '[REDACTED:Slack-Token]',
|
|
52
|
+
},
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 对文本执行敏感数据脱敏
|
|
57
|
+
* @param {string} text
|
|
58
|
+
* @returns {{text: string, redacted: string[]}} — 脱敏后的文本 + 命中的类型列表(去重)
|
|
59
|
+
*/
|
|
60
|
+
export function redactSensitiveData(text) {
|
|
61
|
+
if (typeof text !== 'string' || !text) return { text: text || '', redacted: [] }
|
|
62
|
+
|
|
63
|
+
let result = text
|
|
64
|
+
const hitTypes = new Set()
|
|
65
|
+
|
|
66
|
+
for (const rule of REDACT_RULES) {
|
|
67
|
+
if (rule.regex.test(result)) {
|
|
68
|
+
hitTypes.add(rule.type)
|
|
69
|
+
// 重置 lastIndex(/g 正则 test 会改变 lastIndex)
|
|
70
|
+
rule.regex.lastIndex = 0
|
|
71
|
+
result = result.replace(rule.regex, rule.value)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { text: result, redacted: [...hitTypes] }
|
|
76
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebFetch 兜底 Provider — Safe Jina Reader
|
|
3
|
+
*
|
|
4
|
+
* 移植自 openclaw safe-jina-fetch 设计(DESIGN.md §5)。
|
|
5
|
+
*
|
|
6
|
+
* 作用:当 WebFetch 直连目标 URL 失败(非 ok / 网络错误 / 超时)时,
|
|
7
|
+
* 自动经 Jina Reader(https://r.jina.ai/<url>)清洗后返回 Markdown,
|
|
8
|
+
* 专门对付反爬 / 403 / 动态渲染站点。
|
|
9
|
+
*
|
|
10
|
+
* Jina 请求模式(对齐 DESIGN.md §5.3):
|
|
11
|
+
* - CLI / 默认: text/plain + X-Return-Format: markdown → 首行 Title: xxx 提取标题
|
|
12
|
+
* - json: application/json → {title, url, content} 更稳健
|
|
13
|
+
*
|
|
14
|
+
* 凭据(三选一,可选——匿名约 20 RPM):
|
|
15
|
+
* 1. 环境变量 JINA_API_KEY / JINA_READER_KEY
|
|
16
|
+
* 2. 配置 web.fetch.jinaApiKey
|
|
17
|
+
* 3. 匿名
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const JINA_READER_BASE = 'https://r.jina.ai'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 从 Jina Reader 返回的 markdown 文本中提取标题(首行 "Title: xxx")
|
|
24
|
+
* @param {string} markdown
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function extractTitleFromMarkdown(markdown) {
|
|
28
|
+
const lines = (markdown || '').split('\n')
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
const m = line.match(/^\s*Title:\s*(.+)$/i)
|
|
31
|
+
if (m) return m[1].trim()
|
|
32
|
+
}
|
|
33
|
+
return ''
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 解析 Jina 返回的 markdown,提取正文(去掉 Title/URL 元信息行)
|
|
38
|
+
* @param {string} markdown
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
export function cleanJinaMarkdown(markdown) {
|
|
42
|
+
if (typeof markdown !== 'string') return ''
|
|
43
|
+
const lines = markdown.split('\n')
|
|
44
|
+
|
|
45
|
+
// 优先找 "Markdown Content:" 标记,从其后开始
|
|
46
|
+
const mcIdx = lines.findIndex(l => /^Markdown Content:/i.test(l.trim()))
|
|
47
|
+
if (mcIdx >= 0) return lines.slice(mcIdx + 1).join('\n').trim()
|
|
48
|
+
|
|
49
|
+
// 否则去掉开头的 Title: / URL Source: 元信息行
|
|
50
|
+
const bodyStart = lines.findIndex(l => !/^(Title:|URL Source:)/i.test(l.trim()))
|
|
51
|
+
if (bodyStart > 0) return lines.slice(bodyStart).join('\n').trim()
|
|
52
|
+
|
|
53
|
+
return markdown.trim()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 调用 Jina Reader 抓取 URL
|
|
58
|
+
* @param {string} url — 目标 URL
|
|
59
|
+
* @param {object} [options]
|
|
60
|
+
* @param {string} [options.apiKey] — Jina 凭据(可选,匿名可用)
|
|
61
|
+
* @param {'markdown'|'json'} [options.format] — 返回格式
|
|
62
|
+
* @param {number} [options.timeoutMs]
|
|
63
|
+
* @returns {Promise<{ok: boolean, text: string, title: string, finalUrl: string, status?: number, error?: string}>}
|
|
64
|
+
*/
|
|
65
|
+
export async function safeJinaFetch(url, options = {}) {
|
|
66
|
+
const { apiKey = '', format = 'markdown', timeoutMs = 30000 } = options
|
|
67
|
+
|
|
68
|
+
// 校验目标 URL 基本合法性(协议必须是 http/https)
|
|
69
|
+
let parsed
|
|
70
|
+
try {
|
|
71
|
+
parsed = new URL(url)
|
|
72
|
+
} catch {
|
|
73
|
+
return { ok: false, text: '', title: '', finalUrl: url, error: `无效的 URL: ${url}` }
|
|
74
|
+
}
|
|
75
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
76
|
+
return { ok: false, text: '', title: '', finalUrl: url, error: `不支持的协议:${parsed.protocol}` }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const jinaUrl = `${JINA_READER_BASE}/${url}`
|
|
80
|
+
const headers = {
|
|
81
|
+
'User-Agent': 'cc-node/2.8.2',
|
|
82
|
+
'Accept': format === 'json' ? 'application/json' : 'text/plain',
|
|
83
|
+
}
|
|
84
|
+
if (format === 'markdown') headers['X-Return-Format'] = 'markdown'
|
|
85
|
+
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const response = await fetch(jinaUrl, { headers, signal: AbortSignal.timeout(timeoutMs) })
|
|
89
|
+
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
text: '',
|
|
94
|
+
title: '',
|
|
95
|
+
finalUrl: jinaUrl,
|
|
96
|
+
status: response.status,
|
|
97
|
+
error: `Jina Reader HTTP ${response.status}`,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (format === 'json') {
|
|
102
|
+
const data = await response.json()
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
text: typeof data.content === 'string' ? data.content : '',
|
|
106
|
+
title: typeof data.title === 'string' ? data.title : '',
|
|
107
|
+
finalUrl: jinaUrl,
|
|
108
|
+
status: 200,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const markdown = await response.text()
|
|
113
|
+
return {
|
|
114
|
+
ok: true,
|
|
115
|
+
text: cleanJinaMarkdown(markdown),
|
|
116
|
+
title: extractTitleFromMarkdown(markdown),
|
|
117
|
+
finalUrl: jinaUrl,
|
|
118
|
+
status: 200,
|
|
119
|
+
}
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (err.name === 'TimeoutError') {
|
|
122
|
+
return { ok: false, text: '', title: '', finalUrl: jinaUrl, error: 'Jina Reader 请求超时' }
|
|
123
|
+
}
|
|
124
|
+
return { ok: false, text: '', title: '', finalUrl: jinaUrl, error: `Jina Reader 错误: ${err.message}` }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 解析 Jina 凭据(三选一:环境变量 > 配置 > 匿名)
|
|
130
|
+
* @param {object} [config] — Config 实例(可选)
|
|
131
|
+
* @returns {string} apiKey(可能为空 = 匿名)
|
|
132
|
+
*/
|
|
133
|
+
export function resolveJinaApiKey(config = null) {
|
|
134
|
+
return (
|
|
135
|
+
process.env.JINA_API_KEY ||
|
|
136
|
+
process.env.JINA_READER_KEY ||
|
|
137
|
+
(config ? config.get('web.fetch.jinaApiKey') || '' : '') ||
|
|
138
|
+
''
|
|
139
|
+
)
|
|
140
|
+
}
|
package/src/tools/web-fetch.js
CHANGED
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WebFetch 工具 —
|
|
3
|
-
*
|
|
2
|
+
* WebFetch 工具 — 抓取网页内容(安全管道 + Jina 兜底)
|
|
3
|
+
*
|
|
4
|
+
* 安全特性(移植自 safe-jina-fetch 设计):
|
|
5
|
+
* - 协议白名单:仅 http/https,file/ftp/data 拒绝
|
|
6
|
+
* - 连接级 SSRF 防护:TCP 连接时逐地址校验(防 DNS rebinding)
|
|
7
|
+
* - 重定向逐跳校验:最多 5 跳,每跳重新校验
|
|
8
|
+
* - 响应大小上限(10MB)、超时(30s)、强制 SSL
|
|
9
|
+
* - 敏感数据自动脱敏
|
|
10
|
+
*
|
|
11
|
+
* 兜底机制:
|
|
12
|
+
* - 直连成功(2xx)→ 返回清洗后的文本/JSON
|
|
13
|
+
* - 直连失败(非 2xx / 网络错误 / 超时)→ 自动经 Jina Reader 清洗后返回 Markdown
|
|
14
|
+
* - 直连 200 但正文过短(< 200 字符,疑似 JS 挑战页)→ 也触发 Jina 兜底
|
|
15
|
+
*
|
|
16
|
+
* extractMode 参数:
|
|
17
|
+
* - auto (默认)直连优先,失败/异常走 Jina 兜底
|
|
18
|
+
* - direct 强制直连,不兜底
|
|
19
|
+
* - jina 强制直接走 Jina Reader(最干净,永远清洗)
|
|
4
20
|
*/
|
|
5
21
|
import { ToolDef } from '../types/index.js'
|
|
6
|
-
import {
|
|
22
|
+
import { safeFetchWithRedirects, DEFAULT_FETCH_OPTIONS } from '../security/fetch-guard.js'
|
|
23
|
+
import { redactSensitiveData } from '../security/redact.js'
|
|
24
|
+
import { safeJinaFetch, resolveJinaApiKey } from './web-fetch-providers.js'
|
|
7
25
|
|
|
8
|
-
const
|
|
26
|
+
const DEFAULT_MAX_CHARS = 100000
|
|
27
|
+
// 正文过短阈值:低于此字符数视为疑似 JS 挑战页,触发 Jina 兜底(对齐 DESIGN.md §8 增强)
|
|
28
|
+
const MIN_BODY_CHARS = 200
|
|
9
29
|
|
|
10
30
|
/**
|
|
11
31
|
* 简单的 HTML → 纯文本转换
|
|
@@ -35,7 +55,7 @@ function htmlToText(html) {
|
|
|
35
55
|
return text
|
|
36
56
|
}
|
|
37
57
|
|
|
38
|
-
const VERSION = '2.
|
|
58
|
+
const VERSION = '2.1.0'
|
|
39
59
|
|
|
40
60
|
export const webFetchTool = new ToolDef(
|
|
41
61
|
'WebFetch',
|
|
@@ -43,7 +63,9 @@ export const webFetchTool = new ToolDef(
|
|
|
43
63
|
Usage:
|
|
44
64
|
- url must be a valid HTTP/HTTPS URL
|
|
45
65
|
- Returns the page content as cleaned text/markdown
|
|
46
|
-
- Supports HTML pages, plain text, and JSON APIs
|
|
66
|
+
- Supports HTML pages, plain text, and JSON APIs
|
|
67
|
+
- If direct fetch fails (403/anti-crawl/network error), automatically falls back to Jina Reader for a clean Markdown version
|
|
68
|
+
- extractMode: auto (direct first, fallback on failure) | direct (force direct only) | jina (always use Jina Reader)`,
|
|
47
69
|
{
|
|
48
70
|
type: 'object',
|
|
49
71
|
properties: {
|
|
@@ -56,72 +78,104 @@ Usage:
|
|
|
56
78
|
enum: ['text', 'json', 'raw'],
|
|
57
79
|
description: 'Output format: text (cleaned HTML), json (parse as JSON), raw (raw response)',
|
|
58
80
|
},
|
|
81
|
+
extractMode: {
|
|
82
|
+
type: 'string',
|
|
83
|
+
enum: ['auto', 'direct', 'jina'],
|
|
84
|
+
description: 'Extraction mode: auto (default, direct first + Jina fallback), direct (force direct), jina (always Jina Reader)',
|
|
85
|
+
},
|
|
59
86
|
},
|
|
60
87
|
required: ['url'],
|
|
61
88
|
},
|
|
62
89
|
async (input, ctx) => {
|
|
63
|
-
const { url, format = 'text' } = input
|
|
90
|
+
const { url, format = 'text', extractMode = 'auto' } = input
|
|
91
|
+
|
|
92
|
+
const config = ctx?.engine?.config?.configStore || null
|
|
93
|
+
const maxChars = config?.get?.('web.fetch.maxChars') || DEFAULT_MAX_CHARS
|
|
94
|
+
const maxBytes = config?.get?.('web.fetch.maxBytes') || DEFAULT_FETCH_OPTIONS.maxBytes
|
|
95
|
+
const timeoutMs = config?.get?.('web.fetch.timeoutMs') || DEFAULT_FETCH_OPTIONS.timeoutMs
|
|
96
|
+
const maxRedirects = config?.get?.('web.fetch.maxRedirects') || DEFAULT_FETCH_OPTIONS.maxRedirects
|
|
97
|
+
const jinaApiKey = resolveJinaApiKey(config)
|
|
64
98
|
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return `[
|
|
99
|
+
// extractMode=jina:强制直接走 Jina(永远清洗)
|
|
100
|
+
if (extractMode === 'jina') {
|
|
101
|
+
const jr = await safeJinaFetch(url, { apiKey: jinaApiKey, format: 'markdown', timeoutMs })
|
|
102
|
+
if (!jr.ok) return `[Error: ${jr.error}]`
|
|
103
|
+
const { text, redacted } = redactSensitiveData(jr.text)
|
|
104
|
+
const out = (jr.title ? `# ${jr.title}\n\n` : '') + text
|
|
105
|
+
const warning = redacted.length ? `\n\n[⚠️ 已脱敏: ${redacted.join(', ')}]` : ''
|
|
106
|
+
return (out + warning).slice(0, maxChars)
|
|
69
107
|
}
|
|
70
108
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
headers: {
|
|
74
|
-
'User-Agent': `ClaudeCode-Node/${VERSION}`,
|
|
75
|
-
'Accept': 'text/html,application/json,text/plain,*/*',
|
|
76
|
-
},
|
|
77
|
-
signal: AbortSignal.timeout(30000),
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
if (!response.ok) {
|
|
81
|
-
return `[HTTP ${response.status} ${response.statusText}]`
|
|
82
|
-
}
|
|
109
|
+
// 直连(安全管道:协议 + 连接级 SSRF + 重定向逐跳校验 + 大小/超时/SSL)
|
|
110
|
+
const result = await safeFetchWithRedirects(url, { timeoutMs, maxBytes, maxRedirects })
|
|
83
111
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (format === 'json' || contentType.includes('application/json')) {
|
|
89
|
-
try {
|
|
90
|
-
const data = JSON.parse(body)
|
|
91
|
-
const formatted = JSON.stringify(data, null, 2)
|
|
92
|
-
return formatted.length > MAX_FETCH_CHARS
|
|
93
|
-
? formatted.slice(0, MAX_FETCH_CHARS) + '\n[...truncated]'
|
|
94
|
-
: formatted
|
|
95
|
-
} catch {
|
|
96
|
-
return body.slice(0, MAX_FETCH_CHARS)
|
|
97
|
-
}
|
|
98
|
-
}
|
|
112
|
+
// extractMode=direct:不兜底,直接返回直连结果(无论成败)
|
|
113
|
+
if (extractMode === 'direct') {
|
|
114
|
+
return formatDirectResult(result, format, maxChars)
|
|
115
|
+
}
|
|
99
116
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
117
|
+
// auto 模式:直连失败或内容过短 → 走 Jina 兜底
|
|
118
|
+
const failed = !result.ok || result.error
|
|
119
|
+
const tooShort = result.ok && result.body.trim().length < MIN_BODY_CHARS
|
|
120
|
+
if (failed || tooShort) {
|
|
121
|
+
if (config?.get?.('verbose')) {
|
|
122
|
+
console.error(`[web-fetch] direct ${failed ? `failed (${result.error || result.status})` : 'content too short'}, falling back to Jina Reader`)
|
|
105
123
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
: text
|
|
124
|
+
const jr = await safeJinaFetch(url, { apiKey: jinaApiKey, format: 'markdown', timeoutMs })
|
|
125
|
+
if (jr.ok) {
|
|
126
|
+
const { text, redacted } = redactSensitiveData(jr.text)
|
|
127
|
+
const out = (jr.title ? `# ${jr.title}\n\n` : '') + text
|
|
128
|
+
const warning = redacted.length ? `\n\n[⚠️ 已脱敏: ${redacted.join(', ')}]` : ''
|
|
129
|
+
return (out + warning).slice(0, maxChars)
|
|
113
130
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
? body.slice(0, MAX_FETCH_CHARS) + '\n[...truncated]'
|
|
118
|
-
: body
|
|
119
|
-
} catch (err) {
|
|
120
|
-
if (err.name === 'TimeoutError') {
|
|
121
|
-
return `[Error: Request timed out after 30s]`
|
|
122
|
-
}
|
|
123
|
-
return `[Error fetching URL: ${err.message}]`
|
|
131
|
+
// Jina 也失败 — 返回直连结果 + Jina 错误说明
|
|
132
|
+
return formatDirectResult(result, format, maxChars) +
|
|
133
|
+
`\n\n[Jina fallback also failed: ${jr.error}]`
|
|
124
134
|
}
|
|
135
|
+
|
|
136
|
+
// 直连成功 — 返回直连结果
|
|
137
|
+
return formatDirectResult(result, format, maxChars)
|
|
125
138
|
},
|
|
126
139
|
'ask'
|
|
127
140
|
)
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 格式化直连结果(含脱敏)
|
|
144
|
+
* @param {object} result — safeFetchWithRedirects 返回值
|
|
145
|
+
* @param {string} format
|
|
146
|
+
* @param {number} maxChars
|
|
147
|
+
*/
|
|
148
|
+
function formatDirectResult(result, format, maxChars) {
|
|
149
|
+
if (!result.ok) {
|
|
150
|
+
if (result.error) return `[Error: ${result.error}]`
|
|
151
|
+
return `[HTTP ${result.status} ${result.statusText || ''}]`.trim()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const contentType = result.headers['content-type'] || ''
|
|
155
|
+
const body = result.body
|
|
156
|
+
|
|
157
|
+
let out
|
|
158
|
+
if (format === 'json' || contentType.includes('application/json')) {
|
|
159
|
+
try {
|
|
160
|
+
const data = JSON.parse(body)
|
|
161
|
+
out = JSON.stringify(data, null, 2)
|
|
162
|
+
} catch {
|
|
163
|
+
out = body
|
|
164
|
+
}
|
|
165
|
+
} else if (format === 'raw') {
|
|
166
|
+
out = body
|
|
167
|
+
} else if (contentType.includes('text/html')) {
|
|
168
|
+
out = htmlToText(body)
|
|
169
|
+
} else {
|
|
170
|
+
out = body
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 敏感数据脱敏
|
|
174
|
+
const { text, redacted } = redactSensitiveData(out)
|
|
175
|
+
let final = text
|
|
176
|
+
if (result.truncated) final += '\n[...truncated]'
|
|
177
|
+
if (redacted.length) final += `\n[⚠️ 已脱敏: ${redacted.join(', ')}]`
|
|
178
|
+
if (result.redirects?.length) final += `\n[重定向: ${result.redirects.join(' → ')}]`
|
|
179
|
+
|
|
180
|
+
return final.length > maxChars ? final.slice(0, maxChars) + '\n[...truncated]' : final
|
|
181
|
+
}
|