Files
platform/src/servers/plugins/public-paths.test.ts
T
pastilhasandClaude Opus 5 e0df28ff5d a first-party plugin may claim a root path the platform reserved
caldav is not only /api/caldav. four of its surfaces are top-level and above the
account gate on purpose, because DAVx5, iOS and Thunderbird send HTTP Basic on
every request and have nowhere to put a JWT: /dav, the ios profile under it, and
the two .well-known discovery paths a client probes before it has any credential.

the mechanism for that already existed — unauthenticatedRootPaths, exclusive
claims, first come wins. what stopped caldav using it was RESERVED_ROOT, and
what that list was recording is worth being exact about: `dav` and `.well-known`
are in it because the PLATFORM serves them today, not because they are dangerous
in themselves. once caldav is a plugin, the thing the reservation protects IS
the plugin, and keeping it would mean the platform permanently owning routes for
a feature it no longer contains.

so a reserved name is claimable by a first-party plugin and refused to everyone
else. the owner drew that line today: fine for a plugin we wrote, and letting a
stranger's plugin claim /dav is a question that needs thinking about properly
rather than falling out of a flag. /api stays refused to us too — everything of
officer's lives under it and protocolHeaders is the only sanctioned way in.

.well-known needed two segments, which the single-segment rule refuses. allowed
by NAME rather than by pattern, and the reason is in the same namespace:
acme-challenge is how this machine gets its TLS certificates, so a plugin owning
it could take the domain. caldav and carddav are on the list; nothing else is,
for us as much as for anyone.

the OPTIONS carve-out moved with it. hono's cors() answers every OPTIONS as a
preflight and never calls the route beneath — which to a DAV client is not a
preflight but the question "what can you do", answered by a header iOS refuses
the account without. that was a literal list of four DAV paths; it asks the claim
registry now, so it follows whoever owns the route rather than naming it here.

verified: server boots (totality passes), /.well-known/caldav still 301s and
/dav/ still asks for basic auth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 04:22:27 +00:00

216 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>();
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();
});
});