DSH Plugins Marketplace

DSH Plugins

Plugins

/

dsh-fakeip-http

d

dsh-fakeip-http

Manifest valid

Accept fake-IP DNS answers for DeepSeek Harness web_fetch while keeping the SSRF guard intact.

hasBundlePatch

Fake-IP Web Fetch

CI npm License: MIT

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.

Fake-IP fallback decision path

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:

  1. Run the original validator first. A public destination, or a host with no fake-IP answer, follows the original path unchanged.
  2. Only when the original validator throws WEB_BLOCKED_URL does the wrapper resolve the same hostname locally.
  3. 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.
  4. Return those synthetic addresses. The provider pins the connection to them exactly as it pins real public addresses; TLS SNI and the Host header stay the original hostname, so the TUN can map the connection back.
  5. 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

CaseBehavior
Public real addressOriginal validator passes; unchanged.
Loopback / RFC 1918 / CGNAT / link-local / multicast / documentation / reservedRefused; 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 namesNever eligible for fake-IP.
Answer set mixes fake-IP and real addressesRefused as ambiguous / DNS pollution.
Empty answer set or DNS failureOriginal error.
Configured CIDR overlaps loopback, private, link-local, multicast, documentation, or other special-use spaceRefused 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) proxyStill resolved by the proxy (original logic); unaffected.
Every redirect hopRe-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.2 through 0.2.x.
  • web.fetchProvider must remain http (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 (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:

ForwarderTypical setting
Clash / Mihomodns.fake-ip-range (often 198.18.0.1/16)
sing-boxfake-IP DNS server inet4_range / inet6_range
Surgefake-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 http fetch provider. If web.fetchProvider is 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 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).
  • 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

SymptomCheck
web_fetch still reports a non-public addressConfirm 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 failThe 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/ worksThat would be a regression. The guard must reject it; report it privately (see SECURITY.md).
Connection times out after the guard passesThe TUN is not running or does not route the synthetic block. Check the forwarder.
Log says no safe fake-IP rangesEvery configured range overlapped special-use space. Use a non-private synthetic block.
Log says built-in http fetch provider not foundThe 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

ComponentSupported
DeepSeek Harness>=0.2.1-rc.2 <0.3.0 (engines.dsh)
Fetch providerBuilt-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…