Bundle
dsh-route-fence-linter
Static linter: every plugin HTTP route must carry a browser-trust fence (loopback Host pin before Origin / sec-fetch-site).
- Source
- Vladimir-Kryshchenko
- License
- MIT
- Updated
- Updated 2 days ago
Readme
# dsh-route-fence-linter
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
```sh
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.
Install
dsh plugin --profile web add github:Vladimir-Kryshchenko/dsh-route-fence-linter#904339d50031181390bdb22ddd970c15876e5870
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-route-fence-linter from the hub
- This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.