dsh-ip-based-acl
A DeepSeek Harness plugin bundle that gates the Harness web surface by peer IP address.
[!WARNING] Early project, not security-reviewed. This is 0.1.x: the tests drive a stub Connection, the gate has never run against a live Harness, and nobody has audited it. Treat it as an experiment that works in the cases it tests, not as a boundary you can rely on — an address allowlist is one layer, never a substitute for a firewall, a TLS-terminating proxy, or a VPN.
Do not expose DSH to the internet. The Harness is powerful by design: it drives an agent with access to the machine it runs on, able to read and write files, execute commands, and reach the network. An instance reachable by someone you did not intend is, for practical purposes, remote code execution on that host. This bundle decides which peer addresses reach the door; it does not make public exposure safe. Misconfiguration cuts both ways: an empty
allowlocks everyone out, and atrustedProxiesentry that covers real clients lets a forgedX-Forwarded-Forthrough. Run it on loopback or behind a private network — VPN, WireGuard, Tailscale, an SSH tunnel — and keep it off the public internet.
Status: implemented, not installed. The allowlist is compiled at activation and installed as a
gate over Connection's admission, index, and connect entries, with X-Forwarded-For resolution
through configured proxies. The bundle has not been installed into a live profile, so the gate has
not yet been exercised against a running Harness.
Install
Node 24 or newer and pnpm are enough to build and test it: Cordis resolves from the Harness that loads
the bundle at runtime, and from devDependencies for the local checks. lib/ is generated rather than
committed, so build before installing:
git clone https://github.com/arinanto/dsh-ip-based-acl.git
cd dsh-ip-based-acl
pnpm install
pnpm build
Install that directory as a bundle in a DSH profile — the Harness Plugin Manager's install action,
or the plugin_manager tool with action: install_bundle and the checkout's absolute path as
target. It contributes one Host row, ip-based-acl, and ships it without config.
Configure allow before enabling the row: the default is deny-everything, loopback included, so a
row enabled with an empty list makes the web interface unreachable until you edit the profile patch and
restart.
- id: ip-based-acl
config:
allow: ["127.0.0.1", "::1"]
trustedProxies: []
A manual install is the same package link the Plugin Manager makes:
dsh plugin --profile <name> add <path-to-this-checkout>, which forwards pnpm add inside the profile.
Policy
allow is an allowlist, and admission is default-deny.
- A client is admitted only when its address sits inside a configured address or CIDR network. A bare
address is a single-host rule (
203.0.113.9is/32,::1is/128). - A client that matches no entry is refused with
403, even when it presents a valid launch token or an existing session cookie. - An empty
allowlist refuses every client, loopback included, and logs a warning at activation. - A missing or unparseable client address is refused, never treated as local.
- A malformed pattern fails activation, so a typo cannot become a silent grant.
- IPv4 and IPv6 share one 128-bit space:
10.0.0.0/8and::ffff:10.0.0.0/104describe the same peers, and a mapped peer matches either spelling. - Loopback must be listed explicitly (
127.0.0.1and/or::1). A dual-stack listener often reports an IPv4 peer in mapped form (::ffff:127.0.0.1), which the IPv4 rule already covers.
Proxies
trustedProxies names the reverse proxies whose X-Forwarded-For header may be believed. The chain
is resolved with MDN's trusted-proxy-list method:
- The socket peer is authoritative. If it is not in
trustedProxies, it is the client and the header is ignored entirely — a direct client cannot spoof its way in. - If it is, the list is read right to left, starting with the address the nearest proxy recorded and skipping every entry that is itself a trusted proxy. The first entry that is not one is the client. It may be an untrusted intermediate proxy rather than the origin, but it is the only address fit for an access decision.
- Repeated
X-Forwarded-Forheaders form one chain. A malformed entry is treated as the client and therefore refused. - Name only proxies you control, never a client subnet: any host inside a trusted range can forge the
header. A trusted proxy that sends no header falls back to its own address, so do not list a proxy
in
allowunless requests resolving to it should be admitted. - The proxy must add
X-Forwarded-Forto the WebSocket upgrade as well as to plain HTTP requests. An upgrade that arrives without it resolves to the proxy's own address. - An empty
trustedProxiesignores the header, which is the safe default for a directly exposed server.
- id: ip-based-acl
config:
allow: ["203.0.113.7", "127.0.0.1", "::1"]
trustedProxies: ["10.0.0.1"]
Enforcement
The web carrier has no pre-dispatch hook and Connection exposes no policy extension point, so the gate wraps the three entry points that decide whether a browser may use the interface:
| Entry point | Covers |
|---|---|
connection.admit |
every credential-bearing request: /api, the Remote-stream WebSocket, and feature routes using requestRejection |
connection.authorizeIndex |
the page itself and the launch-token exchange |
connection.authorizeConnect |
the API credential exchange on /connect |
An admitted peer continues through the normal token and cookie flow unchanged. A refused peer is answered before Connection reads the credential, so no token is exchanged, no cookie is minted, and a cookie issued earlier buys nothing. Disposal restores every original method.
Scope limits, stated plainly:
- Public asset routes (plugin bundles, HMR events) stay reachable; they carry no session data, and
every data request behind them passes through
admit. - In-process peers (agent tools, workers) have no socket address and never reach the gate.
- The wrappers target the installed
@deepseek-ai/dsh-client-connectionsurface (verified against 0.2.1-rc.3). An upstream rename fails activation instead of silently dropping the gate.
A future bypass list — allowlisted peers that receive a browser session without a token — is not
implemented yet.
Layout
| Path | Purpose |
|---|---|
src/index.ts |
Host plugin entry: apply, inject, re-exported Config, public seams |
src/config.ts |
Config schema (schemastery) and the AclConfig shape |
src/policy.ts |
CIDR compilation, peer matching, and X-Forwarded-For resolution, framework-free |
src/gate.ts |
Request-time gate over Connection's admission, index, and connect entries |
test/ |
node:test suites run against src/ through Node's type stripping |
cordis.patch.yml |
Bundle patch: one ip-based-acl Host row |
locale/en.json |
Plugin Manager title and description |
icon.svg |
Original artwork exported as ./icon |
lib/ |
Build output (pnpm build); never edited by hand |
CONTRIBUTING.md |
Setup, checks, commit rules, and how to report a bypass |
DCO |
Developer Certificate of Origin 1.1 text |
.github/workflows/ |
CI: pnpm check and pnpm build on push and pull request |
Commands
pnpm install # dependencies
pnpm check # typecheck + lint + format check + tests
pnpm build # emit lib/index.js and lib/types/**.d.ts
pnpm dev # tsc --watch on the build project
Node 24 or newer is required; tests run test/**/*.test.ts through native type stripping.
lib/ is the published shape: main/types point into it, and install_bundle links the package
from a checkout, so run pnpm build before installing the bundle into a profile.
Style
The conventions come from the author's reference TypeScript project and are enforced by the compiler, ESLint, and Prettier rather than by review:
strictplusnoUnusedLocals,noUnusedParameters,noImplicitReturns,noFallthroughCasesInSwitch,noUncheckedIndexedAccess,noImplicitOverride.- Double quotes, semicolons, trailing commas everywhere, 120-column lines, 2-space indentation.
- Type-aware ESLint (
projectService) with@typescript-eslint/no-explicit-anyas a warning;eslint-config-prettierlast so formatting stays Prettier's job.
Rules added for the Harness plugin layout:
erasableSyntaxOnly— every source file must also run under Node's type stripping, which is howpnpm testexecutestest/**/*.test.tsagainstsrc/without a build step. No enums, namespaces, or parameter properties.verbatimModuleSyntax— type-only imports never leak into the emitted ESM.rewriteRelativeImportExtensionsin the build project — source imports name.tsso the test runner can resolve them, and the emit rewrites them to.jsfor the publishedlib/.
Harness contract
- The package is an ES module bundle:
dsh.bundle.patchnamescordis.patch.yml, whose single row carries the stable idip-based-acl. - The Host entry exports
apply(ctx, config),Config, andinject: ["connection"]; Cordis validates the row'sconfigthrough the schema's Standard Schema interface beforeapplyruns, and a profile without theconnectionservice never applies the gate. - Display metadata is exported without activating the plugin:
./locale/*.jsonsuppliesmeta.title/meta.description,./iconthe artwork. - Peer dependencies are the platform's:
@deepseek-ai/cordiscomes from the Harness install, while@deepseek-ai/schemasteryis a real runtime dependency of this package.
Verified
pnpm check— typecheck, lint, formatting, and 32 policy and gate cases, including the trusted proxy walk, repeated headers, spoofed headers, and malformed entries.pnpm build—lib/index.jspluslib/types/index.d.tsin the layoutpackage.jsondeclares.
Not verified: installation into a live profile and the gate against a real browser session. The unit tests exercise the wrappers with a stub Connection, not the shipped plugin.
Contributing
Every commit must carry a Developer Certificate of Origin sign-off,
so commit with git commit --signoff (-s). Git appends the trailer from your configured identity:
Signed-off-by: Your Name <you@example.com>
The trailer certifies the DCO 1.1 statement: you wrote the change, or you have the right to submit it under the project's license, AGPL-3.0-only. Do not type it by hand for work you did not write, and do not drop it when rewriting or squashing commits.
CONTRIBUTING.md has the rest: local setup, the checks CI runs, and the commit conventions. The DCO text itself is in DCO.
License
Copyright (C) 2026 Rinaldi Widianto
This project is licensed under the GNU Affero General Public License, version 3 only (AGPL-3.0-only). The full text is in LICENSE.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Because the plugin is used over a network, section 13 applies: if you run a modified version and let others interact with it remotely, you must offer them its corresponding source.
No comments yet. Be the first to write one.