dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
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.
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.
@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 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:
WEB_BLOCKED_URL does the wrapper
resolve the same hostname locally.Host header stay
the original hostname, so the TUN can map the connection back.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".
| 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.
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.
Requirements:
0.2.1-rc.2 through 0.2.x.web.fetchProvider must remain http (the default).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.
dsh plugin --profile web add github:Jayve/dsh-fakeip-http
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.
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)
dsh plugin --profile web remove dsh-fakeip-http
Removing the bundle removes its patch layer; web.fetchProvider is untouched.
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.
- id: fakeip-http
name: dsh-fakeip-http
config:
enabled: true
cidrs:
- 198.18.0.0/15
- 2001:2::/48
enabled (default false): accept synthetic answers. The shipped patch sets
it to true.cidrs (default 198.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.
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.
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:
web_fetch;http://198.18.0.1/ would bypass the guard entirely;The plugin keeps every original refusal and only adds a narrow, configurable synthetic path for ordinary hostnames.
http fetch provider. If web.fetchProvider is
changed, the plugin has no effect.web_fetch. web_search, model requests, MCP servers, and
other HTTP clients do their own resolution and do not need this plugin.resolveAddresses being 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 upstream fakeIp config in
dsh-web-fetch-http (see below).| 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. |
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.
| 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.
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.
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.
MIT © 2026 Jayve