import { describe, expect, test, afterEach } from 'bun:test'; import { isPluginPublicPath, pluginForProtocolHeaders, pluginForRootPath, resolvePluginPublicPaths, resolvePluginRootPaths, setPluginProtocolHeaders, setPluginPublicPaths, setPluginRootClaims, } 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, splash: null, api: null, schema: null, sidecar: null, web: null, }) as DiscoveredPlugin; afterEach(() => { setPluginPublicPaths([]); setPluginRootClaims([]); setPluginProtocolHeaders([]); }); 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); }); }); describe('top-level claims', () => { const withRoots = (appName: string, roots: string[]) => { const p = plugin(appName); (p.manifest as { unauthenticatedRootPaths?: string[] }).unauthenticatedRootPaths = roots; return p; }; test('a single lowercase segment is claimed', () => { const { segments, refused } = resolvePluginRootPaths(withRoots('vault', ['/identity', '/icons']), new Set()); expect(segments).toEqual(['identity', 'icons']); expect(refused).toEqual([]); }); const thirdParty = (appName: string, roots: string[]) => { const p = plugin(appName, undefined, 'someone-else'); (p.manifest as { unauthenticatedRootPaths?: string[] }).unauthenticatedRootPaths = roots; return p; }; // `/api` is refused to EVERYONE, first-party included. It is where all of Officer lives, and // `protocolHeaders` is the only sanctioned way to touch it. test('refuses /api even to a first-party plugin', () => { const { segments, refused } = resolvePluginRootPaths(withRoots('ours', ['/api']), new Set()); expect(segments).toEqual([]); expect(refused).toHaveLength(1); }); // The rest of the reserved list records what the PLATFORM serves today, which is a different claim from // "dangerous". A first-party plugin may take one back — caldav owning `/dav` is the whole reason — and a // third-party plugin may not. The owner drew that line on 2026-08-27: fine for a plugin we wrote, // and a stranger's plugin claiming `/dav` is a question to think about properly rather than a flag. test.each([['/auth'], ['/plugins'], ['/dav'], ['/assets']])( 'refuses the reserved segment %s to a THIRD-PARTY plugin', (root) => { const { segments, refused } = resolvePluginRootPaths(thirdParty('stranger', [root]), new Set()); expect(segments).toEqual([]); expect(refused).toHaveLength(1); }, ); test('lets a FIRST-PARTY plugin claim a reserved segment — caldav taking /dav', () => { const { segments, refused } = resolvePluginRootPaths(withRoots('caldav', ['/dav']), new Set()); expect(segments).toEqual(['dav']); expect(refused).toEqual([]); }); // RFC 6764's discovery paths: a client is given a bare domain and probes these before it has any // credential. Two segments, which the single-segment rule refuses — allowed by name, and by name only. test('lets a first-party plugin claim an allow-listed .well-known name', () => { const { segments, refused } = resolvePluginRootPaths( withRoots('caldav', ['/.well-known/caldav', '/.well-known/carddav']), new Set(), ); expect(segments).toEqual(['.well-known/caldav', '.well-known/carddav']); expect(refused).toEqual([]); }); // The one that matters most in that namespace: acme-challenge is how this machine gets its TLS // certificates, and a plugin owning it could take the domain. Not an allow-list entry, so refused — // to us as much as to anyone. test('refuses a .well-known name that is not allow-listed, first-party or not', () => { expect(resolvePluginRootPaths(withRoots('ours', ['/.well-known/acme-challenge']), new Set()).segments).toEqual([]); expect(resolvePluginRootPaths(thirdParty('stranger', ['/.well-known/caldav']), new Set()).segments).toEqual([]); }); test.each([['/a/b'], ['identity'], ['/Identity'], ['/']])('refuses the malformed segment %s', (root) => { expect(resolvePluginRootPaths(withRoots('evil', [root]), new Set()).segments).toEqual([]); }); test('two plugins cannot both claim one segment — first come, loser named', () => { const taken = new Set(); expect(resolvePluginRootPaths(withRoots('vault', ['/identity']), taken).segments).toEqual(['identity']); const second = resolvePluginRootPaths(withRoots('other', ['/identity']), taken); expect(second.segments).toEqual([]); expect(second.refused[0]).toContain('already claimed'); }); test('matching a claimed root is by segment, not string prefix', () => { setPluginRootClaims([{ segment: 'identity', appName: 'vault' }]); expect(pluginForRootPath('/identity')).toBe('vault'); expect(pluginForRootPath('/identity/connect/token')).toBe('vault'); expect(pluginForRootPath('/identityx')).toBeNull(); expect(pluginForRootPath('/other')).toBeNull(); }); test('a protocol header hands /api over, and its absence does not', () => { setPluginProtocolHeaders([{ header: 'bitwarden-client-name', appName: 'vault' }]); expect(pluginForProtocolHeaders(new Headers({ 'bitwarden-client-name': 'browser' }))).toBe('vault'); expect(pluginForProtocolHeaders(new Headers({ authorization: 'Bearer x' }))).toBeNull(); }); test('nothing is claimed by default', () => { setPluginRootClaims([]); setPluginProtocolHeaders([]); expect(pluginForRootPath('/identity')).toBeNull(); expect(pluginForProtocolHeaders(new Headers({ 'bitwarden-client-name': 'browser' }))).toBeNull(); }); });