Audits every plugin HTTP route in a profile for a browser-trust fence: plugin routes win the web server's longest-prefix match ahead of the /api gateway, so they never see its trust check and must pin the Host to loopback themselves. Grades PASS/WARN/FAIL per route and fails a fence that compares Origin to Host without pinning it (bypassable by DNS rebinding). Ships as a CLI for CI and a route_fence_scan tool.
Install
# from GitHub (first run asks for allowBuilds approval — follow the hint, retry)
dsh plugin --profile web add github:Vladimir-Kryshchenko/dsh-route-fence-linter
Any plugin you install runs third-party code with your own permissions — it can read your files, use your credentials, and reach the network, and tool approvals don’t sandbox it. GitHub-sourced plugins also run build scripts at install time — pnpm blocks those until you allow them, so an install can stop with ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED or ERR_PNPM_IGNORED_BUILDS; dsh prints the exact key to add under allowBuilds in your profile’s pnpm-workspace.yaml, and the install works on the next run. Allowing a build is a trust decision: only install sources you trust, and pin a commit (github:owner/repo#sha).
README
Every plugin route registered on webServer is dispatched by longest-prefix
match, ahead of the host's /api gateway — so it never passes the gateway's
unified trust check. Each plugin author must ship their own browser-trust
fence, and most do not. This linter finds the ones that did not.
Usage
dsh-route-fence scan # $DSH_HOME/profiles/web
dsh-route-fence scan /path/to/profile
Exit codes: 0 clean · 1 at least one FAIL · 2 usage/IO error.
Verdicts
| Verdict | Meaning |
|---|---|
PASS |
The handler gates on a fence that pins the Host before Origin handling. |
WARN |
Either: inside the fence, Origin / sec-fetch-site is read before the Host is pinned; or the registered routes are defined outside the scanned package, so their fence could not be read. Confirm by hand. |
FAIL |
One of: no Host check anywhere; a fence that compares Origin to Host but never pins the Host to loopback (bypassable by DNS rebinding — the attacker controls both headers and they match); or a fence that exists but this handler never calls. |
Bundles whose sources cannot be read are reported as SKIP and counted in the
summary — a skipped bundle is not a clean bundle.
registerFallback is checked too, and reads worse than any route when
unfenced: the fallback seat answers every request no named route matched,
so one unfenced fallback is a wider surface than a whole prefix tree.
Scan scope
The scan covers what a package publishes (its package.json files field);
a package that declares no files is scanned whole. Anything excluded is named
in the output as a note line, never dropped silently.
Scoping by directory name was tried and reverted: skipping anything called
tests/ or examples/ would have let a plugin hide an unfenced route in a
directory with that name and be reported clean. Publication scope cannot be
gamed that way — the loader can only import what was installed.
The rebinding-bypassable shape is graded FAIL, not WARN: it was found live
in a real, widely-installed plugin and confirmed exploitable against a running
profile — a request with Host: evil.example and Origin: http://evil.example
passed the check and executed a state-changing method.
The fence this checks for
The shape the host itself uses (and dsh-better-sidebar/src/trust-fence.ts
copies): pin the Host header to loopback or a configured trusted authority
first, refuse cross-site fetch markers, and only then compare Origin.
Comparing Origin before pinning the Host is not a fence.
Routes defined in another file
A route registered as routes.map(r => webServer.register(r)), where routes
came from buildWebRoutes() in src/web/routes.ts, has no fence anywhere near
its registration — the fence is one module away. Grading such a route by the
text around the registration produced a false FAIL on real plugins, so the
linter follows the value instead.
When the registered argument is not an inline object — a bare identifier, a
factory call build(...), a spread, or the element parameter of a .map /
.forEach / .flatMap over one — the linter resolves that name to where the
handlers are actually written and grades them there. Each handler in the traced
definition is graded on its own; one unfenced handler fails the whole
registration, so a factory that fences four routes and forgets the fifth is a
FAIL, not a PASS.
The walk is fail-closed at every step. It reports PASS only when it found real
handler bodies that gate on the fence. Routes coming from a bare-specifier
import are WARN ("outside the scanned package — check by hand"), never PASS;
so is a relative import that resolves to nothing scanned. Anything else it
cannot establish — a handler: that is a bare function reference, a factory it
cannot find, a chain longer than three module hops — falls back to the previous
FAIL. Import cycles terminate on a visited-set of (file, name) pairs.
Specifier resolution covers what dsh plugins actually write: relative paths
with .ts / .mts / .tsx / .js / .mjs / .cjs or no extension, the
.js-spelled-for-a-.ts-file convention, and directory imports resolved
through index.<ext>. It is not a Node resolver: package.json exports,
imports (#alias), tsconfig paths, and workspace links are not followed —
they resolve to nothing scanned, which is WARN.
Limits
Heuristic over source text, not dataflow analysis. A fence is identified as the smallest function body that both references the Host and constrains it; a handler counts as gated when it calls that fence, receives it as a value, or performs the check inline — cross-file tracing holds a traced handler to exactly that same standard.
Still out of reach: a fence installed by wrapping (register(withFence(route))
where the wrapper is what checks), routes assembled at runtime from
configuration, handlers reached only through a bare-identifier reference, and
route sources behind a non-relative specifier. These read WARN or FAIL, never
PASS. The linter also checks that a fence is present in a handler, not that
it is textually the first statement.
Report false verdicts; they are bugs — a false PASS most of all.
Links
More in this category
toby-bridges/api-relay-audit★ 884
Runs local security audits of AI API relays and LLM proxies from DeepSeek Harness, producing Markdown reports for prompt injection, model substitution signals, tool-call rewriting, error leakage, stream integrity, and profile-gated Web3 risks.
SeaOf0/dsh-redteam-model★ 716
Authorized-security DSH collection: nine work modes (redteam coordinator, pentest, code audit, binary analysis, attack-defense, AV evasion, incident response, cloud security, CTF solving) and fifteen runtime plugins, managed from a settings page with one-click deploy, install, update and uninstall.
howmp/dsh-pentest★ 609
Authorized pentest mode for DeepSeek Harness — exploration chain, assets and findings with a Web view.
PerryLink/dsh-auto-review★ 233
Second-model auto-review on the approval answerer chain: a read-only reviewer subagent returns structured allow/deny verdicts with reasons, fail-closed by default.
ADWMC/helm-d#helmd★ 225
Single-bundle reverse-engineering and pentest security plugin: first-turn tool narrowing, domain routing, and 33 tools covering APK, web, native binary, protocol, malware and LLM samples — with unpacking, license-bypass and anti-analysis case playbooks, an H-CoT evaluation engine (semantic routing, /hcot command), a web workbench with a ledger-driven tool shelf, an on-disk case workflow (auto-persisted evidence chain, E-numbered validated findings, post-compaction resume), GitHub-based external tool discovery, and 361 on-demand reference docs.
NanmiCoder/dsh-auto-mode★ 164
Adds an Auto permission preset between Workspace Write and Full access: routine work stays in the official workspace-write sandbox while the current session model reviews escalation and destructive calls, granting one exact wider access once, asking when the intent is ambiguous, and denying critical paths.
Community comments
Comments are public GitHub Discussions. Loading them connects to GitHub and Giscus; a GitHub account is required to post.