Saltar al contenido principal
V

dsh-route-fence-linter

vladimir-kryshchenko/dsh-route-fence-linter

Audita cada ruta HTTP de los plugins del perfil en busca de un límite de confianza del navegador. Las rutas de plugins ganan la coincidencia de prefijo más largo del servidor web frente al gateway `/api`, por lo que no pasan su comprobación de confianza y deben fijar Host a loopback. Califica cada ruta como PASS/WARN/FAIL y marca FAIL si compara Origin con Host sin fijarlo, algo evadible mediante DNS rebinding. Incluye CLI para CI y la herramienta `route_fence_scan`.

Instalar

dsh plugin --profile web add github:vladimir-kryshchenko/dsh-route-fence-linter

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

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

VerdictMeaning
PASSThe handler gates on a fence that pins the Host before Origin handling.
WARNEither: 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.
FAILOne 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.

Plugins relacionados