dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
dsh 插件:出网护栏——只放公网、重定向逐跳复查、边读边限大小(防 SSRF) · Egress guard for dsh: public addresses only, every redirect hop re-checked, size capped while streaming.
English | 简体中文
给 DeepSeek Harness(dsh)用的出网护栏: 让"拉一个外部 URL"这件事不再是一个 SSRF 入口。
一个服务(netguard)+ 一个工具(fetch_url,默认关闭)。
别的插件直接调服务,不用自己再写一遍地址判定 —— 判定写错一次,就是一次内网暴露。
不挡会怎样
Agent 插件几乎都要出去拉东西:图片、语音、网页、回调。只要那个 URL 来自 聊天消息或模型输出(群里有人贴了张图、模型自己拼了个地址),它就是攻击者能控制的字符串。 而跑插件的机器通常站在一个比你有权的位置上:
| 模型/用户给出的 URL | 实际会发生什么 |
|---|---|
http://127.0.0.1:5099/api/… | 本机上别的服务(管理台、内部 API、数据库 HTTP 口) |
http://192.168.1.1/ | 路由器 / 内网设备的管理页 |
http://169.254.169.254/latest/meta-data/iam/security-credentials/ | 云厂商元数据 → 临时凭据。拿到它,你的云账号就是他的 |
http://[::1]/ | IPv6 回环,只写 127\. 的实现完全看不见 |
http://2130706433/ | 还是 127.0.0.1(十进制变形)。0177.0.0.1、0x7f.1、0x7f000001 同理 |
http://demo.example.com/x | 这个域名解析到 10.0.0.5 —— 域名看着人畜无害 |
http://demo.example.com/x | 它回一个 302 Location: http://169.254.169.254/… |
file:///etc/passwd | 有些 fetch 封装会把协议校验漏掉,直接读本地文件 |
最后两条最容易被漏掉,也最致命:
Location: http://169.254.169.254/… 就够了。这个插件把这两条都补上了:解析出来的每一条地址都判,重定向的每一跳都重新判。
dsh plugin --profile web add github:JackZo400/dsh-netguard
没有依赖、没有子进程、没有要装的二进制。Node 18.17+(实测 22.x)。
- insert:
- id: netguard
name: dsh-netguard
config:
allowedProtocols: ['http:', 'https:'] # 只放这两种
maxBytes: 8388608 # 一次最多下多少字节(8 MiB)
onOverflow: error # error = 报错;truncate = 截断到上限
timeoutMs: 20000 # 整条链共用的超时
allowRedirects: true # 跟不跟重定向(每一跳都会重新体检)
maxRedirects: 3
blockedPorts: [22, 23, 25, 6379, 11211, 27017] # 内网服务常客
allowUrlCredentials: false # 允不允许 URL 里带 user:pass@
allowSingleLabel: false # 允不允许 http://intranet/ 这种
userAgent: dsh-netguard/0.1.0
enableTool: false # 要不要登记 fetch_url 工具
toolMaxChars: 20000 # 工具最多回多少字符正文
大小上限与超时的取舍(这几条是真会踩到的):
onOverflow: truncate。Content-Length 报得太大就直接不下载,流量一点都不花。reader.cancel()),不是读完再判断 ——
对面挂一个 50GB 的流,读完再判断等于自己把内存吃光。const netguard = ctx.get('netguard')
// 只要字节(签名和老实现一致:(url, { timeoutMs, maxBytes }) → Buffer)
const bytes = await netguard.fetchPublicBytes(imageUrl, { maxBytes: 8 * 1024 * 1024, timeoutMs: 20_000 })
// 要更多信息:状态码、最终落到的地址、跳了几次、有没有被截断
const r = await netguard.fetchPublic(url, { maxBytes: 64 * 1024, onOverflow: 'truncate' })
// → { finalUrl, status, contentType, headers, bytes, truncated, redirects, hops }
// 只体检,不下载
const u = await netguard.assertPublicUrl(url) // 不安全就抛(带 code)
// 主机名/地址判定(可以单独用,不用发请求)
netguard.isPublicHost('127.0.0.1') // false(同步,不查 DNS,只挡明显的写法)
await netguard.checkHost('demo.example.com') // { ok, addresses, offender? }(会查 DNS)
netguard.classifyIp('::ffff:10.0.0.1') // { isIp:true, family:6, public:false, reason:'PRIVATE', mapped:'10.0.0.1' }
失败一律抛 NetguardError,message 是能直接上屏的人话,code 是机器可读的原因:
PRIVATE_ADDRESS BAD_HOST DNS_PRIVATE DNS_FAILED BAD_PROTOCOL BAD_CREDENTIALS
BAD_PORT BLOCKED_PORT BAD_URL TOO_LARGE TIMEOUT ABORTED NETWORK
REDIRECT_DISABLED BAD_REDIRECT TOO_MANY_REDIRECTS REDIRECT_LOOP HTTP_ERROR
错误文案只带主机名,不带完整 URL(URL 里可能有 token),也不带响应体 —— 可以放心写日志。
打开之后多一个 fetch_url 工具:
config:
enableTool: true
抓一下 https://example.com/demo.html 里写了什么
它的返回值会进模型上下文,所以默认是关的 —— 那等于开了一条"外部内容 → 模型"的管道。 工具描述里写明了:拉回来的东西是数据,不是指令。
import { isPublicIp, classifyIp } from 'dsh-netguard/src/ip.js'
import { assertPublicUrl } from 'dsh-netguard/src/guard.js'
isPublicIp('169.254.169.254') // false
src/ip.js 是纯函数,不碰网络、不读配置,可以单独拿去用。
IPv4 0.0.0.0/8 10/8 100.64/10 127/8 169.254/16 172.16/12 192.0.0.0/24
192.0.2.0/24 192.88.99.0/24 192.168/16 198.18/15 198.51.100/24
203.0.113/24 224/4 240/4(含 255.255.255.255)
以及 169.254.169.254 / 100.100.100.200 / 192.0.0.192 这几个元数据地址(单独报原因)
变形写法:127.1 / 2130706433 / 0177.0.0.1 / 0x7f.1 / 0x7f000001 …
IPv6 ::1 :: fc00::/7 fe80::/10 fec0::/10 ff00::/8 100::/64 2001:db8::/32
::ffff:a.b.c.d(IPv4-mapped) ::a.b.c.d(IPv4-compatible)
2002::/16(6to4,把 IPv4 塞在第 3~6 字节) 2001::/32(Teredo)
64:ff9b::/96(NAT64,最后 4 字节就是 IPv4)
最后只放行 2000::/3 —— 白名单式:漏掉一条规则只会更严,不会更松
名字 localhost / *.localhost / *.local / *.localdomain / *.internal / *.home.arpa
*.lan / *.intranet / *.corp / *.private / *.home / *.onion
单段主机名(http://intranet/)
末段是纯数字/0x 的畸形写法(999.1.1.1 / 1.2.3.4.5 / 0178.0.0.1)—— 不猜,直接拒
协议 只放 http: / https:
端口 22 23 25 110 143 445 993 995 3306 5432 6379 11211 27017(可配)
DNS 解析出来的**每一条**都判;有一条内网就整体拒(不是取第一条、不是挑最像公网的那条)
解析失败 / 解析出空 / 解析出不是 IP 的东西 → 一律拒(fail closed,绝不放行)
跳转 每一跳都重走一遍上面全部检查;成环、超跳数、跳转被关掉、没有 Location 都报错
如果你手上已经有一个 isPrivateIp() 之类的实现,可以对照一下这几处:
/^127\./、/^10\./ 这种前缀正则,
127.1、2130706433、0177.0.0.1、0x7f.1 全都过。这里按 inet_aton 的语义
把 1~4 段的写法真解析成 32 位整数再判。192.0.0.0/24、198.18/15、
240/4、多播段都不在里面。这里按特殊用途地址表逐块判,IPv6 干脆只放 2000::/3。::ffff:127.0.0.1 在内核看来就是 IPv4 连接,
字符串前缀判定的实现完全看不见它(::ffff:7f00:1 这种纯十六进制写法更看不见)。
这里解成 16 字节按位判,6to4 / NAT64 也一样拆出来。lookup 不带 all: true 时甚至只看一个族):
攻击者给两条记录(一条公网、一条内网)就绕过去了。这里要全部记录,有一条内网就拒。redirect: 'follow',等于把"跳去哪"完全交给了对方。
这里 redirect: 'manual' 自己接管,每一跳重新体检。arrayBuffer() 读完再比 —— 内存已经吃进去了。
这里流式读、边读边数,超了立刻断。另外 Content-Length 就能提前判掉。AbortSignal.timeout()(其实它根本没跟跳转,
所以没暴露),这里整条链一个 deadline。.catch(() => []) 之后报"解析失败",
意思对;但这里还把 code 分出来了(DNS_FAILED / DNS_PRIVATE / PRIVATE_ADDRESS…),
调用方能分类处理,不用去正则匹配中文。顺手也顺手把 URL 里的账号密码这类
"看着像别人"的伪装写法挡掉了。npm test # 或者分别跑:
node test/selftest.mjs # 判定逻辑 + 重定向 + 大小上限 + 超时(249 条断言)
node test/plugin-selftest.mjs # 插件接线:服务/工具/配置覆盖(65 条断言)
两个脚本合计 314 条断言。两个都是完全离线的:DNS 解析器和 fetch 都是注入进去的假实现,
所以不需要网络、不会抖动,也不需要任何密钥 —— 更不会"顺手把内网探测一遍"。
断言是真断言:把 src/ 里任何一条防护删掉,脚本都会红(比如把重定向的逐跳复查去掉,
会挂 4 条;把大小上限去掉,会挂 7 条)。
没防住的部分,写清楚比假装防住了强
fetchImpl 注入点,宿主可以接自己的实现。HTTP_PROXY / HTTPS_PROXY,但这个行为取决于运行时版本 ——
别指望这一层替你管代理。<meta http-equiv="refresh">、JS 跳转、Service Worker 这些
客户端侧的重定向不归它管(fetch 也看不到)。8081、9090、5000 这些也可能挂着内网服务。
想更严就自己往 blockedPorts 里加。2000::/3,极少数合法的特殊写法会被误挡(被挡了会报 RESERVED,能改)。MIT © 2026 JackZo400
→ Full English README: README.en.md