# Fake-IP 网络抓取（Fake-IP Web Fetch）

[![CI](https://github.com/Jayve/dsh-fakeip-http/actions/workflows/ci.yml/badge.svg)](https://github.com/Jayve/dsh-fakeip-http/actions/workflows/ci.yml)
[![npm](https://img.shields.io/npm/v/dsh-fakeip-http.svg)](https://www.npmjs.com/package/dsh-fakeip-http)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

[English](README.md) | 简体中文

让 `web_fetch` 在 **Fake-IP DNS + TUN 转发** 环境中正常工作，同时保留 Harness 原有的 SSRF 防护：环回、内网、链路本地、组播、文档、保留地址和 IP 字面量仍然一律拒绝。

![Fake-IP fallback decision path](docs/preview.svg)

> 非官方社区插件，与 DeepSeek 官方无隶属关系，也未获其背书。

## 预览

在 Fake-IP 环境里，`web_fetch` 只在原校验器拒绝之后才进入兜底。

```text
  web_fetch
     │
     ▼
  内置 SSRF 校验器 ─── 公网 ──────────────▶ 原样通过
     │
     │ WEB_BLOCKED_URL
     ▼
  Fake-IP 策略
     │
     ├─ [通过] 普通域名，不是 IP 或本地域名
     ├─ [通过] 每条 A/AAAA 应答都是合成地址
     └─ [通过] 配置段不与本地/私网/特殊用途段重叠
     │
     ▼
  pin 合成地址 ──▶ TUN 转发器 ──▶ 真实目标

  之前   x  解析到非公网 IP（DNS: 198.18.0.100, 2001:2::56）
  之后   ok HTTP 200 · text/html · example.com
```

插件没有把整段地址加进白名单：第一个判断仍然由内置校验器完成。

## 问题

`@deepseek-ai/dsh-web-fetch-http` 在发请求前会解析目标域名，并要求**整个 DNS 应答集**都是公网 unicast 地址；只要有一个地址不是，就抛出：

```text
URL hostname "example.com" resolves to a non-public IP address
```

Fake-IP 转发器（Clash/Mihomo、sing-box、Surge 等）会把几乎每个域名都解析到保留段，再由 TUN 在连接时映射回真实目标。例如本机实际观测到：

```text
example.com    -> 198.18.0.100, 2001:2::56
www.google.com -> 198.18.0.9,   2001:2::9
```

这些地址都在基准测试段（IPv4 为 RFC 2544 的 `198.18.0.0/15`，IPv6 为 RFC 5180 的 `2001:2::/48`）里，于是内置校验器在真正建立连接之前就失败了。命令行 `curl` 和普通 Node `fetch` 却能成功，因为它们不检查解析结果。

## 方案

插件不替换 provider，也不放宽 `web.fetchProvider=http`。它找到内置 `HttpFetchProvider` 实例，在实例的 `resolveAddresses` 外面包一层：

1. **先跑原校验器。** 公网目标、或没有 Fake-IP 应答的域名完全走原路径，行为不变。
2. 只有原校验器抛出 `WEB_BLOCKED_URL` 时，才用同一个域名做一次本地 DNS 解析。
3. 只有**完整应答集**——每一条 A 和 AAAA 记录——都落在显式配置的合成 CIDR 内，且域名本身符合条件时，才接受这次解析。
4. 返回这些合成地址。provider 随后照常把连接 **pin** 到该地址集；TLS SNI 和 `Host` 仍是原域名，TUN 据此完成转发。
5. 任何策略检查失败，都继续抛**原错误**，并附加一行简短诊断。

因此这不是“把 `198.18.0.0/15` 加进白名单”，而是“只在原校验器已经判定目标为非公网、完整应答集都是合成地址、域名本身是普通 DNS 名、且合成段不会指向本地或特殊用途地址时，才接受这一次解析”。

### 安全不变量

| 场景 | 行为 |
| --- | --- |
| 公网真实地址 | 原校验器通过，行为不变。 |
| 环回 / RFC1918 / CGNAT / 链路本地 / 组播 / 文档 / 保留地址 | 拒绝；Fake-IP 兜底只认配置的合成段。 |
| IP 字面量（`http://198.18.0.1/`、`http://[2001:2::1]/`） | 永远拒绝，不进入兜底。 |
| `localhost`、`*.localhost`、`*.local`、`*.lan`、`*.internal`、`*.home`、`*.corp`、`*.private`、`*.home.arpa`、`*.arpa`、`*.test`、`*.example`、`*.invalid`、单标签名 | 永远不参与 Fake-IP。 |
| 应答集混有 Fake-IP 和真实地址 | 视为歧义/DNS 污染，全部拒绝。 |
| 应答集为空或 DNS 失败 | 抛原错误。 |
| 配置的 CIDR 与环回、内网、链路本地、组播、文档或其它特殊用途段重叠 | 加载时拒绝并记录日志，不进入策略。 |
| IPv4-mapped IPv6 应答（`::ffff:198.18.0.1`） | 按 IPv4 合成段匹配。 |
| 显式 HTTP(S) 代理路径 | 仍由代理解析目标（原逻辑），不受影响。 |
| 重定向的每一跳 | 重新解析、重新校验、重新 pin（原逻辑）。 |

特殊用途段检查堵住了一个关键漏洞：没有它，用户可以配置 `cidrs: [127.0.0.0/8]` 或 `fc00::/7`，把兜底逻辑变成访问内网的通道。合成段不得与 `SPECIAL_USE_CIDRS` 中任何一段重叠；非法或不安全的条目会被记录并排除，其余安全段继续生效。

### 为什么默认关闭

`enabled` 在代码里默认是 `false`；随包提供的 `cordis.patch.yml` 显式设为 `true`。这样“安装了包但没启用”不会静默放宽安全边界；从 patch 里删掉配置即可回到严格模式。

## 安装

环境要求：

- DeepSeek Harness Desktop 或 Web，版本 `0.2.1-rc.2` 至 `0.2.x`。
- `web.fetchProvider` 保持为 `http`（默认值）。
- 本地解析器能访问 Fake-IP 转发器，且知道它使用的合成段。

### 从 npm 安装

```sh
dsh plugin --profile web add dsh-fakeip-http
```

已发布为 [`dsh-fakeip-http`](https://www.npmjs.com/package/dsh-fakeip-http)，npm 维护者账号为 `jayve-dev`。包名不带 scope，安装路径就是包名本身。

### 从 GitHub 安装

```sh
dsh plugin --profile web add github:Jayve/dsh-fakeip-http
```

### 从本地 checkout 安装

```sh
git clone https://github.com/Jayve/dsh-fakeip-http.git
dsh plugin --profile web add ./dsh-fakeip-http
```

桌面版通常使用 `desktop` profile：

```sh
dsh plugin --profile desktop add dsh-fakeip-http
```

包是纯 JavaScript，没有依赖也没有构建步骤，因此从 npm 或 GitHub 安装都不需要 pnpm 构建授权，也不需要 `prepare` 脚本。

安装后重启 Harness 进程，然后确认配置层：

```sh
dsh --profile web --dump-config
```

应能看到 `# == dsh-fakeip-http`，以及 `enabled: true` 的 Fake-IP 行。

### 验证

在新会话中执行：

```text
web_fetch https://example.com
```

预期返回 HTTP 200 和页面正文。然后回归验证安全边界：

```text
web_fetch http://127.0.0.1:1203/   # 仍应失败（环回）
web_fetch http://198.18.0.1/      # 仍应失败（IP 字面量，不进入兜底）
web_fetch http://10.0.0.1/        # 仍应失败（内网）
```

### 卸载

```sh
dsh plugin --profile web remove dsh-fakeip-http
```

移除 bundle 会同时移除它的 patch 层；`web.fetchProvider` 不受影响。

### 从本地版本迁移

`@local/dsh-fakeip-http` 之类的本地开发行使用同一个行 id（`fakeip-http`）。请先移除旧 bundle，再安装本插件，避免组合配置里出现两行相同 id。

## 配置

```yaml
- id: fakeip-http
  name: dsh-fakeip-http
  config:
    enabled: true
    cidrs:
      - 198.18.0.0/15
      - 2001:2::/48
```

- `enabled`（默认 `false`）：是否接受合成应答。随包 patch 显式设为 `true`。
- `cidrs`（默认 `198.18.0.0/15`、`2001:2::/48`）：允许的合成地址段，IPv4/IPv6 均可。与特殊用途段重叠的配置会被拒绝并记录；如果所有配置都被拒绝，回退到默认值。

修改前先确认转发器实际使用的段：

| 转发器 | 常见配置项 |
| --- | --- |
| Clash / Mihomo | `dns.fake-ip-range`（常见 `198.18.0.1/16`） |
| sing-box | fake-IP DNS server 的 `inet4_range` / `inet6_range` |
| Surge | DNS 设置中的 fake-IP range |

请填写转发器实际返回的段。如果它也返回 IPv6 合成地址，就把两段都写上。`28.0.0.0/8` 这类非特殊用途段可以使用；私有段或 ULA 段不行，因为那会把本地网络暴露给应用。

## 工作方式

```text
web_fetch
   -> HttpFetchProvider.resolveAddresses
      -> 内置公网校验器
         -> 公网：原样通过
         -> WEB_BLOCKED_URL：
            -> Fake-IP 策略
               -> 域名是否符合条件？
               -> 是否每条应答都在安全的配置段内？
               -> 是：pin 合成地址
               -> 否：抛回原错误
   -> 使用原 SNI / Host 建立 TLS
   -> Fake-IP 转发器把连接映射到真实目标
```

包装挂在 provider 实例的 own property（`resolveAddresses`）上。它刻意保持很小，因为它依赖 provider 的内部细节；见[局限](#局限)。

策略模块没有第三方 import，可以独立测试。完整威胁模型见 [docs/security-model.md](docs/security-model.md)。

## 为什么不直接把整段加进白名单？

因为 `198.18.0.0/15` 不是 Fake-IP 解析器唯一可能返回的东西。如果校验器无条件接受这个段里的任何地址，那么：

- 一条指向本地服务的 DNS 应答就能通过 `web_fetch` 访问；
- `http://198.18.0.1/` 这样的 IP 字面量会完全绕过校验器；
- 混合应答（一条合成、一条真实）会产生歧义，pin 到哪个地址都不安全。

插件保留原有的全部拒绝逻辑，只为普通域名增加一条狭窄、可配置的合成路径。

## 局限

- 只包装内置的 `http` fetch provider。若把 `web.fetchProvider` 切到别的 provider，本插件不生效。
- 只处理 `web_fetch` 使用的 provider。`web_search`、模型请求、MCP 等走各自的 HTTP 客户端，本来就不做“公网地址”判定，不需要本插件。
- 若 Fake-IP 转发器把某个公网域名映射到内网地址，那属于转发器策略；本插件拦截的是“应用直接访问内网”，不能替代转发器自身的规则审计。
- 依赖 provider 的 `resolveAddresses` 是实例 own property。Harness 升级若改变该内部结构，插件需要跟着调整；更合适的长期做法是把本策略合并进 `dsh-web-fetch-http` 的 Config。
- 合成段没有被 TUN 路由时，连接会失败。这是转发器配置问题，不是策略拒绝。
- Split DNS 对同一个域名同时返回合成地址和真实地址时，插件按设计拒绝；请修正 split-horizon 规则，而不是让插件猜测。

## 故障排查

| 现象 | 检查 |
| --- | --- |
| `web_fetch` 仍报非公网地址 | 确认 `dsh --profile web --dump-config` 中插件已激活；确认 `web.fetchProvider` 是 `http`；确认 DNS 应答在配置的 `cidrs` 内，而不是别的段。 |
| 只有 IPv6 站点失败 | 转发器返回了未配置的 IPv6 合成段，或 A/AAAA 返回了混合族。补上 IPv6 段。 |
| `web_fetch http://127.0.0.1:1203/` 反而成功 | 这是安全回归。校验器必须拒绝它；请按 [SECURITY.md](SECURITY.md) 私下报告。 |
| 校验通过后连接超时 | TUN 没运行，或没有路由该合成段。检查转发器。 |
| 日志出现 `no safe fake-IP ranges` | 所有配置段都与特殊用途段重叠。换一个非私有的合成段。 |
| 日志出现 `built-in http fetch provider not found` | 本行比 provider 先激活，且 10 秒重试已用完。用标准的 `web-fetch-http` bundle 重启；若仍复现，请附上组合配置开 issue。 |

## 安全

策略刻意保守。攻击模型、拒绝的段列表和残余风险见 [docs/security-model.md](docs/security-model.md)。发现绕过请通过 GitHub Security Advisories 私下报告，见 [SECURITY.md](SECURITY.md)。

## 兼容性

| 组件 | 支持范围 |
| --- | --- |
| DeepSeek Harness | `>=0.2.1-rc.2 <0.3.0`（`engines.dsh`） |
| Fetch provider | `@deepseek-ai/dsh-web-fetch-http` 的内置 `http` provider |
| Node（仅开发） | `^22.19.0` 或 `>=24.0.0` |

插件在 Node 24 上用合成 DNS 应答做了单元测试。provider 集成遵循内置 provider 的 `resolveAddresses` 约定，但未针对每一种转发器做端到端验证。

## 开发

没有依赖，也没有构建步骤：

```sh
npm test          # node --test：策略、provider 包装、manifest
npm run pack:check
```

目录：

```text
index.js                provider 包装与生命周期
lib/policy.js           域名、CIDR 与特殊用途段策略
test/policy.test.mjs    解析与地址段安全测试
test/plugin.test.mjs    包装安装/卸载与 apply 生命周期
test/manifest.test.mjs  打包约定
docs/                   示意图与威胁模型
```

在真实 profile 中调试，从仓库根目录执行：

```sh
dsh plugin --profile web add .
dsh web --profile web
```

Host 改动需要重启进程。插件没有 Client 部分，重启后不需要刷新页面。

## 上游建议

长期看，这段策略应成为 `@deepseek-ai/dsh-web-fetch-http` 的正式配置，并在执行 pin 的同一处保留全部不变量：

```ts
Config = z.object({
  // ...
  fakeIp: z.object({
    enabled: z.boolean().default(false),
    cidrs: z.array(z.string()).default(['198.18.0.0/15', '2001:2::/48']),
  }).default({}),
})
```

正式实现同样必须拒绝与特殊用途段重叠的配置、拒绝 IP 字面量和本地域名，并要求完整应答集都是合成地址。缺少这些规则的“Fake-IP 支持”是安全回归，不是功能。

## 许可证

[MIT](LICENSE) © 2026 Jayve
