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
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, test, afterEach } from 'bun:test';
import { isPluginPublicPath, resolvePluginPublicPaths, setPluginPublicPaths } from './public-paths';
import type { DiscoveredPlugin } from './manifest';
// This is an auth boundary: everything matched here skips `userMiddleware` AND the permission gate. The
// property worth pinning is not "the happy path works" but "a plugin cannot name a path outside its own
// mount", because that is the only failure that would matter.
const plugin = (appName: string, unauthenticatedPaths?: string[], publisher = 'officerdev'): DiscoveredPlugin =>
({
appName,
dir: `/tmp/${appName}`,
manifest: {
publisher,
version: '1.0.0',
platform: '>=1.0.0',
label: appName,
summary: '',
color: '#000',
permissions: [],
...(unauthenticatedPaths ? { unauthenticatedPaths } : {}),
},
icon: null,
api: null,
schema: null,
sidecar: null,
web: null,
}) as DiscoveredPlugin;
afterEach(() => setPluginPublicPaths([]));
describe('resolving a plugin’s declared paths', () => {
test('resolves against the plugin’s own mount', () => {
expect(resolvePluginPublicPaths(plugin('vault', ['/bw/*'])).paths).toEqual(['/api/vault/bw/*']);
});
test('a third-party plugin resolves under its publisher, never at the root', () => {
const { paths } = resolvePluginPublicPaths(plugin('vault', ['/bw/*'], 'alice'));
expect(paths).toEqual(['/api/p/alice/vault/bw/*']);
});
test('declaring nothing is the ordinary case and yields nothing', () => {
expect(resolvePluginPublicPaths(plugin('kiosk')).paths).toEqual([]);
});
// The rules `publicPathProblem` enforces, checked through this function because that is how they are
// actually reached. Each is refused rather than corrected.
test.each([
['..', ['/../../auth']],
['a double slash', ['//auth']],
['a mid-path wildcard', ['/a/*/b']],
['a bare wildcard suffix', ['/a*']],
['the whole plugin', ['/*']],
['the plugin root', ['/']],
['a relative path', ['bw/*']],
['a query string', ['/bw?x=1']],
])('refuses %s', (_label, declared) => {
const { paths, refused } = resolvePluginPublicPaths(plugin('vault', declared));
expect(paths).toEqual([]);
expect(refused).toHaveLength(1);
});
test('one bad entry does not take the good ones with it', () => {
const { paths, refused } = resolvePluginPublicPaths(plugin('vault', ['/bw/*', '/../nope']));
expect(paths).toEqual(['/api/vault/bw/*']);
expect(refused).toHaveLength(1);
});
});
describe('matching a request path', () => {
test('a trailing /* matches the prefix and everything under it', () => {
setPluginPublicPaths([{ pattern: '/api/vault/bw/*', appName: 'vault' }]);
expect(isPluginPublicPath('/api/vault/bw')).toBe(true);
expect(isPluginPublicPath('/api/vault/bw/identity/connect/token')).toBe(true);
});
test('it does not leak to the plugin’s own gated routes', () => {
setPluginPublicPaths([{ pattern: '/api/vault/bw/*', appName: 'vault' }]);
// The whole point: /config stays behind the session, one segment away from a public prefix.
expect(isPluginPublicPath('/api/vault/config')).toBe(false);
expect(isPluginPublicPath('/api/vault')).toBe(false);
});
test('a prefix match is on segments, not characters', () => {
setPluginPublicPaths([{ pattern: '/api/vault/bw/*', appName: 'vault' }]);
// `/bwx` shares the string prefix and must not match — the same class of bug `permissionForApiPath`
// guards against with `/user` vs `/username`.
expect(isPluginPublicPath('/api/vault/bwx')).toBe(false);
expect(isPluginPublicPath('/api/vault/bwevil/x')).toBe(false);
});
test('an exact pattern matches only itself', () => {
setPluginPublicPaths([{ pattern: '/api/kiosk/manifest', appName: 'kiosk' }]);
expect(isPluginPublicPath('/api/kiosk/manifest')).toBe(true);
expect(isPluginPublicPath('/api/kiosk/manifest/extra')).toBe(false);
});
test('nothing is public when nothing is declared — the default for every plugin today', () => {
expect(isPluginPublicPath('/api/vault/bw/identity')).toBe(false);
expect(isPluginPublicPath('/api/users')).toBe(false);
});
});