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
+5 -3
View File
@@ -4,6 +4,7 @@ import { resolveAuthToken } from '../auth-token';
import { isSuperAdmin } from '../super-admin';
import { isApiRequestAllowed } from '../permissions/authorize';
import { isExemptApiPath } from '../permissions/totality';
import { isPluginPublicPath } from '../plugins/public-paths';
// The global authorization gate: a valid NON-owner token may reach only what its ROLE has been granted.
//
@@ -37,12 +38,13 @@ export const permissionGateMiddleware: MiddlewareHandler = async function (ctx,
const payload = token ? await resolveAuthToken(token) : null;
const isOwner = payload ? await isSuperAdmin(payload) : false;
// Exempt paths (signin, the public pages, the Bitwarden door) are skipped because they are served above
// the account gate — the same list the boot check uses, deliberately.
// Exempt paths (signin, the public pages) are skipped because they are served above the account gate —
// the same list the boot check uses, deliberately. A plugin's declared `unauthenticatedPaths` are
// skipped for the same reason, resolved against its own mount: see plugins/public-paths.ts.
//
// Non-/api paths are not ours: the DAV sync door authenticates with its own app password and carries no
// platform account, so there is nothing here to resolve.
if (payload && !isOwner && path.startsWith('/api') && !isExemptApiPath(path)) {
if (payload && !isOwner && path.startsWith('/api') && !isExemptApiPath(path) && !isPluginPublicPath(path)) {
const { allowed, reason } = await isApiRequestAllowed(payload.id, ctx.req.method, path);
if (!allowed) throw errors.FORBIDDEN(reason ?? 'Not permitted for this account');
}