plugins: implement unauthenticatedPaths — the half that was never built

`unauthenticatedPaths` has been in PluginManifest since it was designed, with
validation (`publicPathProblem`), a long docstring, and a promise that
`assertPluginPublicPaths` re-checks it at boot. Nothing read the field. No
function by that name existed. Kiosk, the plugin it was written for, declares
none — so it validated, documented itself, and did nothing, and no plugin has
ever actually been served above the gate.

Vault is the first real user: a stock Bitwarden client speaks Vaultwarden's
protocol and has nowhere to put a platform JWT, so it cannot use the
authenticated /api/vault router at all.

── What was missing, and where it had to go ──

Two places, not one, and the second is the one that matters:

  • the permission gate skips declared paths — but that gate only runs when a
    token WAS presented (`payload && !isOwner && …`), so it is what stops a
    valid non-owner reaching something ungranted.
  • `protectedRouter.use(userMiddleware)` is what stops an ANONYMOUS request.
    A path meant to answer without a session has to skip that too, so it is now
    conditional on the same predicate. Fixing only the gate would have left
    every declared path returning 401 — passing its own tests and failing in
    the one case it exists for.

── Containment ──

Paths resolve against the plugin's own mount (`/api` + mountPrefix + path) and
containment is re-checked there, so a plugin cannot name `/auth` or another
plugin's routes however it edits its manifest. Refusal is per-path: one bad
entry does not take the good ones with it, and each is logged by name.

Matching is on segments rather than characters — `/api/vault/bwx` must not
match `/api/vault/bw/*`, the same class of bug `permissionForApiPath` guards
against with `/user` vs `/username`.

Every plugin with public paths is logged at boot: a route that answers without
a session should not be something you have to read a manifest to discover.

17 tests, pinning the containment rules rather than the happy path. Suite
1061 pass / 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 14:26:35 +00:00
co-authored by Claude Opus 5
parent 5e4f3fe29f
commit dc190bb790
5 changed files with 224 additions and 4 deletions
+87
View File
@@ -0,0 +1,87 @@
import { mountPrefix, publicPathProblem, type DiscoveredPlugin } from './manifest';
// Which plugin paths are served ABOVE the account gate.
//
// The declaration side of this — `unauthenticatedPaths` in a manifest, `publicPathProblem` validating it —
// existed from the day the field was designed. Nothing read it: no plugin had needed one yet (kiosk
// declares none), so the field validated, documented itself, and did nothing. Vault is the first real
// user, and this is the half that was missing.
//
// ── What a plugin is trusted with, and what it is not ──
//
// NOT trusted: which paths. A declared path is resolved against the plugin's OWN mount and re-checked
// here at every mount, so a plugin cannot name `/auth`, `/users`, or another plugin's routes however it
// edits its manifest. `publicPathProblem` already refuses `..`, `//`, mid-path wildcards and the whole
// plugin; this adds the containment check that makes the promise structural rather than syntactic.
//
// Trusted: what those paths DO. Authorising them is the plugin's job — it asked for them to be public, so
// whatever credential they check is the plugin's to define. The platform's job is to be exact about which
// ones, and to say so out loud at boot.
/** A resolved entry: the absolute path pattern, and who asked for it. */
export type PublicPath = { pattern: string; appName: string };
let publicPaths: PublicPath[] = [];
/**
* Resolve a plugin's declared paths against its own mount.
*
* Returns the absolute patterns and the reasons any were refused. Refusal is per-path rather than
* per-plugin: one bad entry must not quietly take the good ones with it, and the caller logs what it lost.
*/
export function resolvePluginPublicPaths(plugin: DiscoveredPlugin): { paths: string[]; refused: string[] } {
const declared = plugin.manifest.unauthenticatedPaths ?? [];
const base = `/api${mountPrefix(plugin)}`;
const paths: string[] = [];
const refused: string[] = [];
for (const path of declared) {
const problem = publicPathProblem(path);
if (problem) {
refused.push(`${plugin.appName}: ${problem}`);
continue;
}
const absolute = `${base}${path}`;
// Containment, re-checked rather than assumed. `publicPathProblem` already makes escaping hard; this
// makes it impossible, and it is cheap. A path that does not sit under the plugin's own mount is the
// one failure that would matter, so it is checked where the mount is actually known.
if (absolute !== base && !absolute.startsWith(`${base}/`)) {
refused.push(`${plugin.appName}: "${path}" resolves outside the plugin's own mount`);
continue;
}
paths.push(absolute);
}
return { paths, refused };
}
/**
* Replace the whole set. Called from `refreshPluginMounts` alongside `setPluginPermissions`, and for the
* same reason: install, uninstall, enable and disable are all one recomputation rather than a diff.
*/
export function setPluginPublicPaths(entries: PublicPath[]): void {
publicPaths = entries;
}
/** Everything currently public, for logging and for the plugins API to show. */
export function listPluginPublicPaths(): PublicPath[] {
return publicPaths;
}
/**
* Is this request path served above the account gate?
*
* A trailing `/*` matches the prefix and everything under it; anything else is exact. Deliberately not a
* regex: the patterns come from a manifest, and the set of shapes `publicPathProblem` allows is small
* enough to match literally — which is one fewer place for a pattern to mean more than it looks like.
*/
export function isPluginPublicPath(path: string): boolean {
for (const { pattern } of publicPaths) {
if (pattern.endsWith('/*')) {
const prefix = pattern.slice(0, -2);
if (path === prefix || path.startsWith(`${prefix}/`)) return true;
} else if (path === pattern) {
return true;
}
}
return false;
}