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:
@@ -2,6 +2,7 @@ import { listPluginInstalls, type PluginInstall } from 'officerdb';
|
||||
import type { MountedPlugin } from '../hono';
|
||||
import { rebuildHonoApp } from '../hono';
|
||||
import { setPluginPermissions, type Permission } from '../permissions/registry';
|
||||
import { resolvePluginPublicPaths, setPluginPublicPaths, type PublicPath } from './public-paths';
|
||||
import { discoverPlugins } from './discover';
|
||||
import { mountPrefix, type DiscoveredPlugin } from './manifest';
|
||||
import { generatePluginsModule, rebuildFrontend } from './generate';
|
||||
@@ -144,6 +145,22 @@ export async function refreshPluginMounts(options: { skipFrontend?: boolean } =
|
||||
console.error(`[plugins] permission keys refused (already taken by the platform): ${rejected.join(', ')}`);
|
||||
}
|
||||
|
||||
// Unauthenticated paths, recomputed on the same cycle and for the same reason as permissions above:
|
||||
// install, uninstall, enable and disable are one recomputation rather than a diff. Logged at INFO
|
||||
// whenever there are any — a route that answers without a session should never be something you have to
|
||||
// read a manifest to discover.
|
||||
const publicPaths: PublicPath[] = [];
|
||||
for (const { plugin } of enabled) {
|
||||
const { paths, refused } = resolvePluginPublicPaths(plugin);
|
||||
for (const reason of refused) console.error(`[plugins] unauthenticated path refused — ${reason}`);
|
||||
for (const pattern of paths) publicPaths.push({ pattern, appName: plugin.appName });
|
||||
}
|
||||
setPluginPublicPaths(publicPaths);
|
||||
if (publicPaths.length) {
|
||||
const shown = publicPaths.map((p) => `${p.pattern} (${p.appName})`).join(', ');
|
||||
console.log(`[plugins] served WITHOUT a session: ${shown}`);
|
||||
}
|
||||
|
||||
const mounted = await mountablePlugins(snapshot);
|
||||
rebuildHonoApp(mounted);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user