dsh-trusted-proxy-auth
Manifest validDeepSeek Harness plugin that trusts authentication already performed by a reverse proxy (Traefik + Keycloak OIDC) while keeping native DSH browser authentication as a fallback.
dsh-trusted-proxy-auth
A small, host-only DeepSeek Harness
(DSH) plugin that adds a second, independent authentication path for
deployments fronted by a trusted reverse proxy — for example
Traefik → Keycloak OIDC → DSH.
When the reverse proxy has already authenticated the caller, it injects a private shared secret in a request header. DSH then treats that request as authenticated. When the header is absent or wrong, DSH's native browser launch-token/cookie authentication runs exactly as it always did, so direct loopback/SSH access keeps working.
The plugin does not replace DSH's transport, patch any file inside the DSH installation, or implement OIDC/JWT logic. It is an authentication-boundary adapter and nothing more.
External browser
│
▼
Traefik ───────────► Keycloak OIDC
│ (identity, login, roles, sessions)
│
│ injects: X-DSH-Proxy-Auth: <private secret>
▼
DSH ─────────────► trusted-proxy-auth
│ 401 native browser auth + valid secret ──► permit
│ 403 Host/Origin fence ──► still 403
▼
native DSH transport, unchanged
SSH tunnel / direct access
│ (no X-DSH-Proxy-Auth header)
▼
DSH ─────────────► native ?token=… / cookie flow
Status
- Tested against DSH 0.1.5-rc.2 on Node v24.21.0 (Node 20 and 22 are covered by CI).
- 86 unit tests and 37 live-DSH integration checks pass. See
docs/VALIDATION.md. - A startup invariant re-proves DSH's
403/401semantics on every boot, so a semantics change cannot silently turn the plugin into a bypass. - Injects a server-verified
__DSH_TRANSPORT__.ownsHost = truebootstrap into the index HTML for proxy-authenticated requests, enabling full host settings persistence (Models catalog, Plugins configuration) across remote origins. - Normalises proxy-authenticated requests to loopback for
webServer.matchandwebServer.handleroutes, enabling third-party plugins with strict loopback checks (e.g.dshmarket) to work seamlessly while strictly preventing cross-origin CSRF attacks. - No runtime dependencies — only
node:crypto. - No build step; plain ESM.
Install
Generate a secret (96 hex characters is plenty):
openssl rand -hex 48
Add it to the environment the DSH service already reads (for example
/etc/dsh/env, root:dsh, mode 0640):
SECRET="$(openssl rand -hex 48)"
printf 'DSH_PROXY_AUTH_SECRET=%s\n' "$SECRET" | sudo tee -a /etc/dsh/env >/dev/null
sudo chown root:dsh /etc/dsh/env
sudo chmod 0640 /etc/dsh/env
Install the plugin bundle into the Web profile and restart:
# from a Git checkout
dsh plugin --profile web add /path/to/dsh-trusted-proxy-auth
# or directly from this repository, pinned to the released tag
dsh plugin --profile web add git+https://github.com/lukepoo101/dsh-trusted-proxy-auth.git#v0.2.3
# an exact commit is equally acceptable for an authentication-boundary plugin
dsh plugin --profile web add git+https://github.com/lukepoo101/dsh-trusted-proxy-auth.git#<40-char-commit>
sudo systemctl restart deepseek-harness
Pin the tag or commit rather than following main: this plugin sits on the
authentication boundary, so the installed revision should be the one you
reviewed. Bump it deliberately when you upgrade.
Verify the composed profile contains the plugin in addition to the Connection transport:
dsh web --dump-config | grep -E -A1 "connection|trusted-proxy-auth"
- id: connection
name: '@deepseek-ai/dsh-client-connection'
...
- id: trusted-proxy-auth
name: dsh-trusted-proxy-auth
The plugin must also be told which public authority it serves. Add the final public hostname to the DSH launch flags:
dsh web --trusted-host dsh.example.com
Configuration
| Setting | Where | Notes |
|---|---|---|
DSH_PROXY_AUTH_SECRET | environment | Required. At least 32 bytes of printable ASCII other than comma. Never put it in YAML. |
X-DSH-Proxy-Auth | request header | Injected by the reverse proxy only. Never sent to the browser. |
There are no plugin configuration keys. A missing, empty, too-short, or
malformed secret makes plugin activation fail loudly, which makes the profile
fail to boot — the server never starts half-authenticated. openssl rand -hex 48
(hex) and openssl rand -base64 48 both satisfy the accepted character set;
excluding comma and whitespace is what makes the duplicate/joined-header
guarantee unambiguous.
Behaviour
requestRejection() (used by /api and the WebSocket mux):
| Upstream result | Secret | Result |
|---|---|---|
403 (Host/Origin/DNS-rebinding fence) | valid | 403 |
403 | missing/wrong | 403 |
401 (native browser auth) | valid | permitted |
401 | missing/wrong | 401 |
undefined (native auth already passed) | any | permitted |
authorizeIndex() (the frontend / route):
| Secret | Host fence | Result |
|---|---|---|
| missing/wrong | — | delegated to the original native flow |
| valid | 403 | 403 forbidden, response ended |
| valid | 401 or undefined | index served with ownsHost: true bootstrap injection |
The plugin is never allowed to turn an upstream 403 into success, so
DNS-rebinding and cross-site protections keep working even with a correct
secret.
Settings persistence & ownsHost bootstrap
DSH's client-side settings service resolves persistence strategy via:
persistence = ctx.remote.$host.isLoopback ? 'host' : 'memory'
When accessed over a reverse proxy (e.g. https://dsh.example.com),
window.location.hostname is non-loopback, causing DSH to switch to in-memory
persistence mode, leaving Models and Plugins empty with "settings are unavailable
in this browser".
To resolve this safely without trusting arbitrary client hostnames,
dsh-trusted-proxy-auth hooks into DSH's supported web server extension seams
(webServer.tapIndex and the webserver/index-inject event) to inject:
<script>globalThis.__DSH_TRANSPORT__=Object.assign(globalThis.__DSH_TRANSPORT__||{},{ownsHost:true});</script>
into <head> when rendering the web shell. DSH's connection package evaluates transport?.ownsHost === true,
setting isLoopback = true and activating 'host' persistence for the proxy-authenticated
operator session. Node's ServerResponse pipeline remains completely unmodified, ensuring standard
HTTP chunked streaming and headers without truncation.
Third-party plugin route compatibility & loopback normalisation
Several DSH plugins (most notably dshmarket@1.65.1, which powers the built-in Plugin Marketplace)
protect their HTTP mutation routes (/dsh-market/discovery-compatibility, /dsh-market/update,
/dsh-market/install) with a strict loopback check:
const sameOrigin = (req) => isLoopback(req.headers.host) && (!req.headers.origin || req.headers.origin === `http://${req.headers.host}`)
When DSH is fronted by a reverse proxy, incoming requests carry the public domain (e.g. Host: dsh.example.com).
Blindly rewriting Origin at the reverse proxy (e.g. via Nginx or Envoy) would strip CSRF defense across the board
and risk exposing state-changing endpoints to malicious websites.
To solve this safely and universally:
dsh-trusted-proxy-authintercepts route dispatch viactx.webServer.match(withwebServer.handlefallback).- For requests carrying a valid
X-DSH-Proxy-Authheader, it calls DSH's undecoratedconnection.requestRejection(req)against the real inbound headers. - If the request fails the Host/Origin fence (e.g. an attacker on
https://evil.exampletriggering a cross-origin CSRF request, or an untrusted Host header), it is immediately rejected with HTTP 403 Forbidden and response ended without invoking downstream handlers or normalising headers. - For verified proxy requests, the plugin:
- Preserves the original public authority in
X-Forwarded-HostandX-Forwarded-Proto. - Normalises
req.headers.hostto127.0.0.1:<port>. - Normalises
req.headers.origin(if present) tohttp://127.0.0.1:<port>.
- Preserves the original public authority in
- Downstream plugin route handlers run their loopback check against the normalized headers, succeeding with
200 OK.
Reverse-proxy contract
Traefik (or any equivalent proxy) must:
- Require successful Keycloak OIDC authentication.
- Remove or overwrite any inbound
X-DSH-Proxy-Authvalue. - Add the private
X-DSH-Proxy-Authvalue only on the authenticated DSH route. - Forward that header on normal HTTP requests and WebSocket upgrades.
- Preserve the original public
Host. - Preserve browser
Originbehaviour.
Setting the header on the router satisfies (2) and (3) at once, because Traefik overwrites the incoming value:
# Traefik dynamic configuration (illustrative)
http:
middlewares:
dsh-proxy-auth:
headers:
customRequestHeaders:
X-DSH-Proxy-Auth: '<private secret>'
routers:
dsh:
rule: 'Host(`dsh.example.com`)'
entryPoints: [websecure]
middlewares: [dsh-proxy-auth]
service: dsh
services:
dsh:
loadBalancer:
servers:
- url: 'http://10.0.20.103:3081'
The DSH service must include the final public authority in --trusted-host:
--trusted-host dsh.example.com
The proxy→DSH hop carries the secret in clear text. The example above
forwards to http://10.0.20.103:3081; anything that can passively observe that
path can recover X-DSH-Proxy-Auth and replay it. Firewalling blocks unwanted
connections but does not prevent sniffing, so do one of the following:
- keep the hop on an isolated trusted network segment or encrypted overlay (WireGuard, VXLAN+IPsec) and document that as an explicit deployment assumption; or
- terminate TLS — ideally mTLS — between the proxy and DSH.
Network restriction is still required. The shared secret is defence-in-depth, not a replacement for a firewall: anyone who can reach the DSH port directly and knows the secret bypasses native authentication. Firewall the VM/LAN port so only the Kubernetes/Traefik network can reach it.
Operations
Rotate the secret. Generate a new value, update /etc/dsh/env and the
Traefik middleware, then restart DSH. Requests in flight during the change fall
back to native authentication.
Emergency/admin access. The native authenticated URL printed by dsh web
(http://127.0.0.1:3080/?token=…) is untouched. Over an SSH tunnel you can
always log in with DSH's own browser session, independent of Traefik.
Upgrade DSH. The plugin validates the runtime interface on every start:
sudo npm install -g @deepseek-ai/dsh@latest
sudo systemctl restart deepseek-harness
systemctl status deepseek-harness
journalctl -u deepseek-harness -n 100
If a future DSH renames or removes requestRejection / authorizeIndex, or
changes their 403/401 semantics, the plugin refuses to activate and the
profile fails to boot, rather than silently dropping authentication.
Uninstall.
dsh plugin --profile web remove dsh-trusted-proxy-auth
sudo systemctl restart deepseek-harness
Security properties
- The entire assertion is "the caller knows a private secret only the proxy
and DSH share". No
X-Forwarded-User, email, username, role, or JWT claim is ever treated as authentication. - Comparison uses
crypto.timingSafeEqual()over UTF-8 bytes. - A malformed, duplicated, array-valued, combined, or non-string header fails authentication.
- The secret, the header value, cookies, and launch tokens are never logged.
- The secret must be printable ASCII without comma or whitespace, so a duplicate or joined HTTP header value can never equal it.
- Every activation probes the original
requestRejectionand refuses to start unless403still means the Host/Origin trust fence and401still means "native browser session missing". A DSH upgrade that changes those semantics fails the profile instead of quietly becoming a bypass. - The plugin decorates only the single live
ctx.connectioninstance; it never touchesHostConnectionService.prototypeand never writes inside the DSH installation. - Activation is fail-closed and transactional: a bad configuration stops the
profile from starting, any partial decoration (including a write that failed
verification, or a disposer that failed to register) is rolled back, and
unexpected runtime exceptions preserve native authentication or fail the
request — never a silent
true.
See SECURITY.md for the full threat model.
Testing
npm test # 50 unit tests, node:test, no dependencies
npm run test:integration # 35 live checks against a real DSH server
npm run test:integration creates a throwaway DSH_HOME, derives a private
profile from the shipped web template, installs this checkout, boots the real
Harness Web server on a free loopback port, and exercises the HTTP index route,
/api, the /api/remote.mux WebSocket upgrade, native token/cookie login,
fail-closed startup, and log hygiene. It never touches an existing Harness home
or a running server. Use KEEP_TMP=1 to keep the temporary home for
inspection.
CI has two tracks: the unit suite on Node 20/22/24 and the real-DSH integration
suite run on every push and pull request against the pinned 0.1.5-rc.2, while
a scheduled job runs the same integration suite against
@deepseek-ai/dsh@latest as an early-warning signal for upstream drift.
Runtime compatibility
DSH 0.1.5-rc.2 hands plugins a Cordis traceable proxy for ctx.connection,
not the raw service instance. Reading a method through that proxy returns a
fresh shadow-method proxy on every access, so reference-equality checks are not
stable. The plugin therefore captures and restores methods through
Object.getOwnPropertyDescriptor, which reaches the real instance:
ctx.connectionis a proxy;Object.isExtensible(ctx.connection)istrue.requestRejection,authorizeIndex, andauthenticatedUrlare inherited, writable, non-enumerable prototype methods (own=false).- Assignment through the proxy creates an own property on the raw instance;
deletethrough the proxy removes it and exposes the prototype method again.
Cleanup is compare-and-swap and unwraps any wrapper it finds, so repeated or
overlapping activation cannot stack wrappers and the instance returns exactly to
its original shape (own property deleted when the method was inherited). The
wrapper marker is a registered (Symbol.for) symbol, so two generations of the
module loaded at once still recognise each other. See docs/VALIDATION.md for
the raw probe output.
Shape is not the whole contract, so activation also asserts DSH's semantics
against the original method: an unauthenticated loopback request must be 401,
an untrusted Host must be 403, and cross-origin/cross-site requests must be
403. If a future DSH ever authenticated before applying the trust fence — so
an untrusted Host returned 401 — promoting that 401 would no longer be safe,
and the plugin refuses to start.
Non-goals
This plugin deliberately contains none of the following:
- Keycloak libraries, JWT validation, OAuth/OIDC discovery
- user management, login pages, sessions, TOTP, roles, permission mapping
- browser-side code
- inspection of the Keycloak access token, or passing it into DSH
- exposure of the shared secret to the browser
Traefik and Keycloak own those concerns. This plugin is an authentication-boundary adapter only.
License
Comments
Loading…
Similar plugins
by luodeb
Authentication reverse-proxy gateway plugin for DeepSeek Harness Web
★ 3
↓ 70/wk
BSD-3-Clause
TypeScript
Aug 17, 2026
dsh plugin --profile web add dsh-web-auth-gatewayby ZSeven-W
DeepSeek Harness (DSH) plugin: a read-only ledger for the plugins you already have installed — a capability inventory with file:line evidence, declared-vs-detected reconciliation, cross-profile versio
★ 24
↓ 58/wk
MIT
JavaScript
Sep 24, 2026
dsh plugin --profile web add @zseven-w/dsh-harborby maxwell-feng
DeepSeek Harness plugin: back the native web_search / web_fetch tools with your self-hosted SearXNG instance — keyless, private, no third-party search vendor.
★ 7
↓ 158/wk
MIT
TypeScript
Sep 13, 2026
dsh plugin --profile web add dsh-searxng-webby BotonJ
Authenticated remote gateway for DeepSeek Harness: QR/HMAC pairing, cookie sessions, mDNS, fork_session tool. Zero-dep plugin.
★ 5
MIT
JavaScript
Aug 19, 2026
dsh plugin --profile web add dsh-remote-linkby houlain
DeepSeek Harness plugin: workspace file browser + code viewer + session change diff + per-hunk partial revert (Trae-style). Windows verified only; Linux/macOS untested — use with caution.
★ 1
MIT
TypeScript
Aug 16, 2026
dsh plugin --profile web add dsh-workspace-studioby taichuy
DeepSeek Harness auth插件
★ 25
↓ 229/wk
Apache-2.0
TypeScript
Oct 2, 2026
dsh plugin --profile web add deepseek-harness-auth