Skip to content

修复: SSRF 校验漏判 CGNAT 网段与 DNS 解析结果,普通成员可打到云元数据服务 - #1

Open
tianjimeteor wants to merge 2 commits into
AIGeniusInstitute:mainfrom
tianjimeteor:fix/ssrf-cgnat-and-dns-resolution
Open

修复: SSRF 校验漏判 CGNAT 网段与 DNS 解析结果,普通成员可打到云元数据服务#1
tianjimeteor wants to merge 2 commits into
AIGeniusInstitute:mainfrom
tianjimeteor:fix/ssrf-cgnat-and-dns-resolution

Conversation

@tianjimeteor

@tianjimeteor tianjimeteor commented Aug 18, 2026

Copy link
Copy Markdown

问题描述

云服务器都有一个内部元数据地址,读它就能拿到这台机器的云账号临时凭证。src/url-safety.ts 的黑名单负责拦这类地址,但漏了 100.64.0.0/10(RFC 6598 CGNAT)

阿里云 ECS 的元数据服务就在 100.100.100.200,内网 DNS 在 100.100.2.136/138。这一段不在任何「RFC 1918 私有地址」速查表里,看着像公网地址,所以之前被当公网放行了——部署在阿里云上的实例,此前对元数据 SSRF 完全不设防。顺带漏掉的还有 192.0.0.0/24198.18.0.0/15224.0.0.0/4240.0.0.0/4

同时,isPrivateHostname() 只判字面量 IP,判定链走到域名就 return false。攻击者把自己的域名 A 记录指向 169.254.169.254,照样过。

谁能触发

调用点 接口 权限
routes/skills.ts POST /api/skills/install authMiddleware任意登录用户
routes/workspace-config.ts POST /api/groups/:jid/workspace-config/skills/install + 工作区 owner(自建即满足)
routes/groups.ts POST /api/groupsinit_git_url admin

URL 过了校验就交给 npx skills add / git clone,由服务端以宿主机身份真实发出。前两个接口零权限可达,意味着一个 member_basic 成员就能读到宿主机的云凭证——对一个主打多租户隔离的产品,这是隔离模型被绕过。

复现(阿里云部署 + 普通成员账号):

curl -s -X POST 'https://<host>/api/skills/install' \
  -H 'Content-Type: application/json' -b 'dt_session=<普通成员 cookie>' \
  -d '{"package":"https://100.100.100.200/latest/meta-data/ram/security-credentials/"}'
# 修复前不会返回 "Refused skill URL",请求通过校验

上游 happyclaw 的情况(重要)

本仓库是 happyclaw 的 fork。查证后有两个结论直接影响了本 PR 的方案选择:

1. CGNAT 缺口上游同样存在。 对上游代码实测:

=== happyclaw validateSafeHttpsUrl 结果 ===
  放行 ← 可达      100.100.100.200    阿里云 ECS 元数据
  放行 ← 可达      100.100.2.136      阿里云内网 DNS
  拦截            169.254.169.254    AWS/GCP 元数据(对照)

上游 routes/skills.ts 的 skill 安装路径也只有字面量校验、没接 DNS 检查——三个 sink 保护了两个,漏了这一个。建议同步向上游反馈。

2. DNS 校验上游早就有,而且比我最初写的更完整。 上游有 assertResolvesToPublicAddress() / resolvePublicAddresses(),后者返回已校验的确切 IP 列表,配合 src/safe-git-proxy.ts 在连接期把 socket 钉到该 IP,真正收口了 DNS rebinding 的 TOCTOU 窗口。

因此本 PR 废弃了初版自写的 validateSafeHttpsUrlWithDns(),改为原样引入上游 API。

修复方案

src/url-safety.ts

补齐缺失网段100.64.0.0/10192.0.0.0/24198.18.0.0/15224.0.0.0/4 + 240.0.0.0/4(合并为 a >= 224,含 255.255.255.255)。沿用现有 if 链而非改成 CIDR 表驱动——这函数已被用例锁死,换实现会让 diff 从「加 4 行」变成「重写」,不划算。

DNS 校验对齐上游 API

export interface ResolvedPublicAddress { address: string; family: 4 | 6 }

export async function resolvePublicAddresses(
  hostname: string,
  label = 'Hostname',
  lookupFn: DnsLookupFn = defaultLookup,   // ← 相对上游唯一增量:可选注入,供单测
): Promise<ResolvedPublicAddress[]>

export async function assertResolvesToPublicAddress(
  hostname, label = 'Hostname', lookupFn?,
): Promise<void>

选择照搬而不是自己写,三个理由:

  1. 函数名 / 签名 / 抛异常语义与上游完全一致,后续同步 happyclaw 时这个文件不会冲突。自造的版本反而会制造一处永久分歧。
  2. resolvePublicAddresses() 返回确切 IP 列表,是连接期钉 socket 的前置原语。将来移植上游 safe-git-proxy.ts 可以直接用;「返回字符串」的版本给不了这个能力。
  3. 抛异常而非返回字符串:调用方漏判时直接中断后续网络请求,而不是静默放行。

唯一增量是可选的第三个参数 lookupFn,可选、不改变上游任何调用点签名,用于让单测不依赖真实网络。

三个调用点

改用上游的两段式写法,先字面量后 DNS,与上游 skill-import-service.ts / routes/groups.ts 的调用顺序一致——字面内网 IP 不必等一次网络往返就能拒掉:

const reason = validateSafeHttpsUrl(pkg);
if (reason) return ...;
try {
  await assertResolvesToPublicAddress(new URL(pkg).hostname, 'Skill URL hostname');
} catch (err) { return ...; }

tests/url-safety.test.ts

62 → 90 条。新增网段全部成对写边界值(命中 + 紧邻不命中),否则 a >= 100 这种写错的实现照样能过测试;另加 12 条 DNS 用例——上游这两个函数目前零测试覆盖,这部分属净增。

补充一个观察:这个文件之前补过 R3 轮加固,但补的都是「字面量 IP 的表示形式」这一个维度,越补越密,而「hostname 是域名」这个维度一条都没有。缺的不是用例,是维度。

边界

  1. fail-closed:DNS 解析失败或无记录一律拒绝。副作用是「只有 HTTP(S)_PROXY 出网、本机无 resolver」的部署会被拒。
  2. 本 PR 仍不能根治 DNS rebinding:真正发请求的 npx / git 会各自重新解析(TOCTOU)。彻底收口需要移植上游 safe-git-proxy.ts 那种连接期钉 IP 的做法——本 PR 引入的 resolvePublicAddresses() 已经是它需要的前置原语,建议后续单独立 issue。
  3. 未纳入本 PR:上游三个入口都会拒绝 URL 内嵌凭证(parsedUrl.username || parsedUrl.password),DeepThink 都没有。那属于凭证泄露而非 SSRF,建议单独同步。

测试

  • tsc --noEmit 全量通过
  • tests/url-safety.test.ts 90 条通过
  • 全量 vitest:本次改动零新增失败。用 origin/main 建 worktree 跑基线对照:基线在 Windows 上 16 个测试文件失败,改动后 14 个,新引入失败为 0。这些失败集中在符号链接、node 路径解析、POSIX 路径假设等平台相关用例,与本次改动无关(另有 2 个文件两次运行结果不一致,疑似 flaky,同样与本改动无因果关系)。Linux CI 上的基线结果请以 CI 为准。
  • 本机实跑服务验证:npm run dev 起后端,/api/health 返回 {"status":"healthy","database":true,"queue":true},前端 5173 正常,autonomy 七个模块 / supervisor / scheduler 均正常启动
  • 修复前后对照 PoC:旧实现对 100.100.100.200100.100.2.136198.18.0.1192.0.0.8255.255.255.255 全部放行,新实现全部拦截

prettier --check 对本次改动的 4 个文件在 main 上本来就不通过(已用 stash 验证基线)。没跑 --write,避免无关重排淹没安全改动;需要的话可以单开一个纯格式 commit。

完整根因分析、诊断命令、凭证轮换步骤和巡检脚本见 docs/issues/2026-08-18-ssrf-cgnat-and-dns-bypass.md(按 CLAUDE.md §10.1 规范撰写)。

TianjiMeteor added 2 commits August 18, 2026 21:32
`isPrivateHostname()` 有两个缺口,叠加后使任意登录用户(member_basic,
零 permission)可通过 POST /api/skills/install 让服务端向内网发起请求:

1. 未覆盖 RFC 6598 的 100.64.0.0/10 (CGNAT)。这一段不在任何「RFC 1918
   私有地址」速查表里,看上去就是公网地址,但阿里云 ECS 把元数据服务放在
   100.100.100.200、内网 DNS 放在 100.100.2.136/138 —— 之前完全放行。
   同时漏掉 192.0.0.0/24、198.18.0.0/15、224.0.0.0/4、240.0.0.0/4。

2. 只判字面量 IP,不解析域名。任何 A/AAAA 记录指向内网的域名都能绕过,
   包括已覆盖的 169.254.169.254。这一条让第一条的严重性成倍放大 ——
   即便补齐所有网段,攻击者用自己的域名仍可指向任意内网地址。

改动:

- 补齐上述地址段
- 新增 validateSafeHttpsUrlWithDns(),在字面量校验之后解析 hostname 并
  逐条判定所有 A/AAAA 记录。DNS 函数可注入,单测不依赖真实网络。
  fail-closed:解析失败 / 无记录一律拒绝
- 三个调用点(skills.ts / workspace-config.ts / groups.ts 的 init_git_url)
  切到异步版本
- 测试 62 → 89 条,新增维度:hostname 是域名。每个新增网段成对写边界值
  (命中 + 紧邻不命中)

已知边界(文档中明确写出,不做过度承诺):本修复不能根治 DNS rebinding,
真正发请求的 npx / git 会各自重新解析(TOCTOU)。它把攻击门槛从「填个域名」
抬到「必须精确翻转 DNS 应答」,是纵深防御的一层。彻底封堵需在出站连接层做
socket 级 IP 校验或出网代理白名单,另立 issue。

详见 docs/issues/2026-08-18-ssrf-cgnat-and-dns-bypass.md
上一版自写了 validateSafeHttpsUrlWithDns()(返回 reason 字符串)。查证后
发现上游 happyclaw(本仓库的 fork 源)早已有等价实现,且更完整:

- assertResolvesToPublicAddress() / resolvePublicAddresses()
- 后者返回已校验的确切 IP 列表,是连接期把 socket 钉到该 IP 的前置原语
- 上游据此实现了 safe-git-proxy.ts,真正收口 DNS rebinding 的 TOCTOU 窗口

自造版本给不了第 2、3 条能力,还会在这个文件上制造一处永久分歧,
让 DeepThink 后续同步上游必然冲突。因此废弃自造方案,改为原样引入
上游 API:相同函数名、相同签名、相同的抛异常语义。

相对上游唯一增量是可选的第三个参数 lookupFn(单测注入用),可选、
不改变上游任何调用点签名。上游这两个函数目前零测试覆盖,本次补 12 条。

三个调用点改用上游的两段式写法(先字面量校验、再 DNS),与上游
skill-import-service.ts / routes/groups.ts 的调用顺序一致:字面内网 IP
不必等一次网络往返就能拒掉。

CGNAT 等网段补齐部分不变——那部分上游同样缺失,已实测确认,
另行反馈上游。

测试:
- tests/url-safety.test.ts 90 条通过
- 全量 vitest:本次改动零新增失败(基线 origin/main 在 Windows 上
  16 个文件失败,改动后 14 个,均为符号链接/路径解析等平台问题)
- tsc --noEmit 全量通过
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant