fix(knowledge): 支持白名单内网 URL 抓取#736
Conversation
调整 URL 抓取的 IP 安全策略,允许白名单内的普通私网地址用于内网知识库接入,同时继续拦截回环和链路本地地址,并补充回归测试与文档说明。
将 URL 列表项改为响应式对象,确保解析成功后提交按钮状态能随 URL 状态变化重新计算。
There was a problem hiding this comment.
Code Review
This pull request modifies the URL fetcher to allow access to private network IP addresses while continuing to block loopback and link-local addresses, updating the documentation and adding corresponding unit tests. It also makes fetched URL items reactive in the frontend. The review highlights two critical security vulnerabilities in the backend: a potential Fail-Open issue when parsing IPv6 addresses with a Zone Index, and a DNS Rebinding vulnerability due to double resolution of hostnames during validation and request execution.
| for item in ip_list: | ||
| ip_addr = item[4][0] | ||
| ip_obj = ipaddress.ip_address(ip_addr) | ||
| if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local: | ||
| if ip_obj.is_loopback or ip_obj.is_link_local: | ||
| return True |
There was a problem hiding this comment.
内典包否元由十分危险的 Fail-Open 咈朠处理 IPv6 Zone Index 占用的申范避颃险
- IPv6 Zone Index 申范避:如枒
socket.getaddrinfo蔷回了带有 Zone Index 的 IPv6 地址(例如fe80::1%lo0或fe80::1%eth0),ipaddress.ip_address()会因为无法解析%之后的字符而抛出ValueError。这会导致代码进入except分支 。 - Fail-Open 颃险:就在下方的
except Exception分支中(第 34 行),代码目前蔷回了False(即认为该 IP 不是禁用 IP)。这意味着一旦抛出上述ValueError,该 IP 将直接被放行,从而成功申范避对链路本地(Link-Local)IPv6 地址的拦截 。
改进建议:
- 在解析前剥离 IPv6 的 Zone Index。
- 将第 34 行的
return False修改为return True(Fail-Closed 原则),确保在解析或发生异常时默认拦截,防止安全策略被申范避。
| for item in ip_list: | |
| ip_addr = item[4][0] | |
| ip_obj = ipaddress.ip_address(ip_addr) | |
| if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local: | |
| if ip_obj.is_loopback or ip_obj.is_link_local: | |
| return True | |
| for item in ip_list: | |
| ip_addr = item[4][0] | |
| # Strip IPv6 zone index if present (e.g., fe80::1%lo0) | |
| if "%" in ip_addr: | |
| ip_addr = ip_addr.split("%")[0] | |
| ip_obj = ipaddress.ip_address(ip_addr) | |
| if ip_obj.is_loopback or ip_obj.is_link_local: | |
| return True |
| if await is_forbidden_ip(hostname): | ||
| raise ValueError("Access to loopback or link-local IP addresses is forbidden") |
There was a problem hiding this comment.
内典包否元由十分危险的 DNS Rebinding(DNS 重绑定)导致 SSRF 申范避的颃险
当前代码在发起 HTTP 请求前,先通过 is_forbidden_ip(hostname) 解析并检查了 IP 地址。然而,在随后的 client.stream("GET", current_url, ...) 中,httpx 会对 current_url 中的域名进行第二次 DNS 解析。
攻击者可以配置一个惡意的 DNS 服务器,在第一次解析(安全检查)时蔷回一个合法的公共 IP,但在期短的 TTL 之后,在第二次解析(实际请求)时蔷回 127.0.0.1 或其他内网敏感 IP。这可以完全申范避 is_forbidden_ip 的检查,导致 SSRF(服务端请求伪造) 。
由于在重定向逻辑中(第 120-121 行)也存在相同的检查并请求逻辑,同样受此问题影响。
缓解方案建议:
- 使用自定义的 Transport/Resolver:在
httpx.AsyncClient中配置一个自定义的 DNS 解析器,确保域名只被解析一次,并将解析后的 IP 锁定(Pin)用于后续的连接。 - 直接请求 IP 并设置 Host 头:在解析并验证 IP 安全后,直接将请求 URL 中的域名替换为 IP 地址进行请求,同时在 HTTP 请求头中手动设置
Host: <original_hostname>。如枒是 HTTPS 请求,还需要注意配置 SSL 验证(如server_hostname)以防止证书校验失败。
剥离 IPv6 zone index 后再判断地址类型,并在解析异常时默认拦截,避免链路本地地址绕过安全校验。
|
补充说明一下 Gemini 提到的 DNS Rebinding 问题:这个风险判断是成立的,但不是本 PR 新引入的问题。 本 PR 保留了原有的“请求前解析并检查目标 IP,随后由 httpx 发起请求”的结构,只调整了白名单内普通私网 IP 的允许策略,并补充了本次改动范围内的安全加固:
DNS Rebinding 的完整缓解需要 DNS pinning、自定义 httpx transport,或使用解析后的 IP 发起请求并正确处理 Host、HTTPS SNI 和证书校验,改动范围明显大于本次内网白名单 URL 支持。建议后续单独开 SSRF hardening PR 处理,避免在当前小修中引入大范围网络栈变更。 |
Summary
YUXI_URL_WHITELIST的普通内网 IP URL 抓取,同时继续拦截 loopback/link-local 地址。Test plan
python3 -m py_compile backend/package/yuxi/knowledge/utils/url_fetcher.py backend/test/unit/knowledge/test_url_fetcher.pygit diff --checkcd backend && uv run pytest test/unit/knowledge/test_url_fetcher.py,本地收集阶段使用 Python 3.10,因项目依赖 Python 3.12 的datetime.UTC阻塞,非本次断言失败。