dsh-fakeip-http
Manifest validAccept fake-IP DNS answers for DeepSeek Harness web_fetch while keeping the SSRF guard intact.
Fake-IP Web Fetch
English | 简体中文
Make web_fetch work behind a fake-IP DNS + TUN setup without weakening
Harness's SSRF guard. Loopback, private, link-local, multicast, documentation,
reserved, and IP-literal destinations stay blocked.
Unofficial community plugin. Not affiliated with or endorsed by DeepSeek.
Preview
In a fake-IP environment, web_fetch enters the fallback only after the
built-in guard rejects the name.
web_fetch
│
▼
built-in SSRF guard ─── public ──────────────▶ unchanged
│
│ WEB_BLOCKED_URL
▼
fake-IP policy
│
├─ [ok] ordinary hostname, not an IP or local name
├─ [ok] every A/AAAA answer is synthetic
└─ [ok] configured range avoids local/private space
│
▼
pin synthetic ──▶ TUN forwarder ──▶ target
before x resolves to a non-public IP address
DNS: 198.18.0.100, 2001:2::56
after ok HTTP 200 · text/html · example.com
The plugin does not add an allowlist entry: the public guard still owns the first decision.
The problem
@deepseek-ai/dsh-web-fetch-http resolves a destination before sending the
request and requires the complete DNS answer set to be public unicast. One
non-public answer is enough to fail:
URL hostname "example.com" resolves to a non-public IP address
A fake-IP forwarder (Clash/Mihomo, sing-box, Surge, ...) answers almost every name with an address from a reserved block, then maps the connection back to the real destination inside its TUN. A host can look like this:
example.com -> 198.18.0.100, 2001:2::56
www.google.com -> 198.18.0.9, 2001:2::9
Those addresses are in the benchmarking ranges (RFC 2544 for IPv4, RFC 5180
for IPv6), so the built-in guard rejects the URL before a connection exists.
curl and a plain Node fetch succeed because they do not inspect the resolved
addresses.
The fix
The plugin does not replace the provider and does not widen
web.fetchProvider=http. It finds the built-in HttpFetchProvider instance
and wraps the instance's resolveAddresses function:
- Run the original validator first. A public destination, or a host with no fake-IP answer, follows the original path unchanged.
- Only when the original validator throws
WEB_BLOCKED_URLdoes the wrapper resolve the same hostname locally. - Accept the answer only when the complete answer set — every A and AAAA record — is inside an explicitly configured synthetic CIDR, and the hostname is eligible for fake-IP forwarding.
- Return those synthetic addresses. The provider pins the connection to them
exactly as it pins real public addresses; TLS SNI and the
Hostheader stay the original hostname, so the TUN can map the connection back. - On any policy failure, rethrow the original error with a short diagnostic suffix.
This is not "add 198.18.0.0/15 to an allowlist". It is "accept this one
resolution only because the built-in guard already rejected it, the whole
answer set is synthetic, the name is an ordinary DNS name, and the synthetic
block cannot point at local or special-use space".
Safety invariants
| Case | Behavior |
|---|---|
| Public real address | Original validator passes; unchanged. |
| Loopback / RFC 1918 / CGNAT / link-local / multicast / documentation / reserved | Refused; the fallback only accepts configured synthetic blocks. |
IP literal (http://198.18.0.1/, http://[2001:2::1]/) | Always refused; never enters the fallback. |
localhost, *.localhost, *.local, *.lan, *.internal, *.home, *.corp, *.private, *.home.arpa, *.arpa, *.test, *.example, *.invalid, single-label names | Never eligible for fake-IP. |
| Answer set mixes fake-IP and real addresses | Refused as ambiguous / DNS pollution. |
| Empty answer set or DNS failure | Original error. |
| Configured CIDR overlaps loopback, private, link-local, multicast, documentation, or other special-use space | Refused at load time, logged, and excluded from the policy. |
IPv4-mapped IPv6 answer (::ffff:198.18.0.1) | Matched against the IPv4 synthetic blocks. |
| Explicit HTTP(S) proxy | Still resolved by the proxy (original logic); unaffected. |
| Every redirect hop | Re-resolved, re-validated, re-pinned (original logic). |
The special-use rejection closes an important hole: without it, a user could
configure cidrs: [127.0.0.0/8] or fc00::/7 and turn the fallback into a
local-network gateway. A synthetic block must not overlap any range in
SPECIAL_USE_CIDRS; invalid or unsafe entries are logged and the remaining safe
ranges stay active.
Why it is disabled by default
enabled defaults to false in code. The shipped cordis.patch.yml sets it
to true explicitly. Installing the package without the patch therefore does
not silently relax the guard; removing the config from the patch restores the
strict behavior.
Install
Requirements:
- DeepSeek Harness Desktop or Web,
0.2.1-rc.2through0.2.x. web.fetchProvidermust remainhttp(the default).- A fake-IP forwarder reachable through the local resolver, with its synthetic range known.
From npm
dsh plugin --profile web add dsh-fakeip-http
Published as dsh-fakeip-http
by the jayve-dev npm account. The package is unscoped, so the install path is
just the package name.
From GitHub
dsh plugin --profile web add github:Jayve/dsh-fakeip-http
From a checkout
git clone https://github.com/Jayve/dsh-fakeip-http.git
dsh plugin --profile web add ./dsh-fakeip-http
The desktop app usually uses the desktop profile:
dsh plugin --profile desktop add dsh-fakeip-http
The package is plain JavaScript with no dependencies and no build step, so git
and npm installs need no pnpm build approval and no prepare script.
Restart the Harness process after installation, then verify the layer:
dsh --profile web --dump-config
Look for # == dsh-fakeip-http and an enabled: true fake-IP row.
Verify
In a new session:
web_fetch https://example.com
Expected: HTTP 200 and the page body. Then check that the guard is still strict:
web_fetch http://127.0.0.1:1203/ # still fails (loopback)
web_fetch http://198.18.0.1/ # still fails (IP literal, no fallback)
web_fetch http://10.0.0.1/ # still fails (private)
Uninstall
dsh plugin --profile web remove dsh-fakeip-http
Removing the bundle removes its patch layer; web.fetchProvider is untouched.
Migrating from a local build
A local development row such as @local/dsh-fakeip-http uses the same row id
(fakeip-http). Remove that bundle first, then add this one, so the composed
config does not contain two rows with the same id.
Configuration
- id: fakeip-http
name: dsh-fakeip-http
config:
enabled: true
cidrs:
- 198.18.0.0/15
- 2001:2::/48
enabled(defaultfalse): accept synthetic answers. The shipped patch sets it totrue.cidrs(default198.18.0.0/15,2001:2::/48): synthetic blocks the policy may accept. IPv4 and IPv6 both work. A block that overlaps special-use space is refused and logged; if every configured block is refused, the defaults apply.
Find your forwarder's range before changing this:
| Forwarder | Typical setting |
|---|---|
| Clash / Mihomo | dns.fake-ip-range (often 198.18.0.1/16) |
| sing-box | fake-IP DNS server inet4_range / inet6_range |
| Surge | fake-IP range in DNS settings |
Use the range your forwarder actually emits. If it emits an IPv6 range as well,
include both. A range such as 28.0.0.0/8 is accepted if your forwarder uses it;
a private or ULA range is not, because that would expose the local network.
How it fits together
web_fetch
-> HttpFetchProvider.resolveAddresses
-> built-in public-IP guard
-> public: unchanged
-> WEB_BLOCKED_URL:
-> fake-IP policy
-> hostname eligible?
-> every answer inside a safe configured CIDR?
-> yes: pin synthetic addresses
-> no: rethrow the original error
-> TLS with the original SNI / Host
-> fake-IP forwarder maps the connection to the real destination
The wrapper lives on the provider instance as an own property
(resolveAddresses). It is deliberately small because it depends on an
internal provider detail; see Limitations.
The policy module has no third-party imports and can be tested on its own. The full threat model is in docs/security-model.md.
Why not just allow the range globally?
Because 198.18.0.0/15 is not the only thing a fake-IP resolver can return. If
the guard accepted every address in that block unconditionally, then:
- a DNS answer pointing at a local service would be reachable through
web_fetch; - an IP literal such as
http://198.18.0.1/would bypass the guard entirely; - a mixed answer (one fake, one real) would be ambiguous and unsafe to pin.
The plugin keeps every original refusal and only adds a narrow, configurable synthetic path for ordinary hostnames.
Limitations
- It wraps only the built-in
httpfetch provider. Ifweb.fetchProvideris changed, the plugin has no effect. - It only affects
web_fetch.web_search, model requests, MCP servers, and other HTTP clients do their own resolution and do not need this plugin. - If the forwarder maps a public name to a private address, that is the forwarder's policy. The plugin stops the application from directly reaching private space; it does not audit the forwarder's rules.
- It depends on
resolveAddressesbeing an own property of the provider instance. A Harness release that changes that internal structure requires a plugin update. The better long-term fix is an upstreamfakeIpconfig indsh-web-fetch-http(see below). - A synthetic range that is not routed by the TUN will fail at connect time. That is a forwarder configuration problem, not a policy failure.
- Split DNS that returns both fake and real answers for one name is refused by design; fix the split-horizon rule instead of forcing the plugin to guess.
Troubleshooting
| Symptom | Check |
|---|---|
web_fetch still reports a non-public address | Confirm the plugin is active in dsh --profile web --dump-config; confirm web.fetchProvider is http; confirm the DNS answer is inside the configured cidrs, not a different block. |
| Only IPv6 sites fail | The forwarder returns an IPv6 fake-IP range that is not in cidrs, or returns mixed A/AAAA families. Add the IPv6 range. |
web_fetch http://127.0.0.1:1203/ works | That would be a regression. The guard must reject it; report it privately (see SECURITY.md). |
| Connection times out after the guard passes | The TUN is not running or does not route the synthetic block. Check the forwarder. |
Log says no safe fake-IP ranges | Every configured range overlapped special-use space. Use a non-private synthetic block. |
Log says built-in http fetch provider not found | The row activated before the provider and the 10-second retry expired. Restart with the standard web-fetch-http bundle; if it persists, open an issue with the composition. |
Security
The policy is intentionally conservative. Read docs/security-model.md for the attacker model, the list of refused ranges, and the residual risks. Report a bypass privately via GitHub Security Advisories; see SECURITY.md.
Compatibility
| Component | Supported |
|---|---|
| DeepSeek Harness | >=0.2.1-rc.2 <0.3.0 (engines.dsh) |
| Fetch provider | Built-in http provider of @deepseek-ai/dsh-web-fetch-http |
| Node (development only) | ^22.19.0 or >=24.0.0 |
The plugin is unit-tested on Node 24 with synthetic DNS answers. The provider
integration follows the built-in provider's resolveAddresses contract; it has
not been exercised against every forwarder.
Development
No dependencies and no build step:
npm test # node --test: policy + provider wrapper + manifest
npm run pack:check
Layout:
index.js Provider wrapper and lifecycle
lib/policy.js Hostname, CIDR, and special-use policy
test/policy.test.mjs Resolution and range-safety tests
test/plugin.test.mjs Wrapper install/uninstall and apply lifecycle
test/manifest.test.mjs Packaging contract
docs/ Diagram and threat model
To iterate in a real profile, from the repository root:
dsh plugin --profile web add .
dsh web --profile web
Host edits require a process restart. There is no client half, so no page reload is needed after a restart.
Upstream proposal
The durable fix belongs in @deepseek-ai/dsh-web-fetch-http: make the guard
aware of configured synthetic ranges and keep these invariants in the same
place that performs the pin.
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({}),
})
Such a config must also reject ranges that overlap special-use space, refuse IP literals and local names, and require the complete answer set to be synthetic. Without those rules it is a security regression, not a feature.
License
MIT © 2026 Jayve
Comments
Loading…