Files
platform/src/servers/plugins/public-paths.ts
T
pastilhasandClaude Opus 5 8367fada13 plugins: let a plugin claim top-level paths and divert /api by header
Restores the setup the vault extraction regressed: paste the bare Officer URL
into a Bitwarden client and log in. Nothing else.

The extraction moved the Bitwarden door to /api/vault/bw, which is correct and
unusable — a client derives /api, /identity, /icons and /notifications from ONE
url it is given, so a prefixed base turns setup into five fields copied by hand
into an advanced pane. That is not a feature anyone completes, and the owner
said so. My regression, my wrong call: I treated "a plugin cannot serve
top-level paths" as a fact about the platform when it was a fact about what had
been built.

Two additions, both manifest-declared, both narrow:

  unauthenticatedRootPaths  single lowercase segments (`/identity`), served
                            above the gate. Refused if reserved (`/api`,
                            `/auth`, `/plugins`, `/dav`, …), refused if another
                            plugin already claims it, refused if it is anything
                            more structural than one name.

  protocolHeaders           hands `/api/*` to the plugin when the request
                            carries one. Needed because /api is the one
                            namespace both servers claim — Vaultwarden has
                            /api/sync, Officer has /api/chat — and the path
                            cannot tell them apart, so the client says who it
                            is. ROUTING, not authentication: a forged header
                            reaches the plugin, which demands its own
                            credential, and cannot reach anything of Officer's.
                            The worst it does is cost that caller Officer's API.

The Bun route table gets an entry per claimed segment, built from the plugin
set — `refreshPluginMounts` runs at line ~204, long before serve() at ~378, so
the claims are known. This is the constraint I earlier asserted made the whole
thing impossible, without checking it. The real cost is smaller and stated in
the code: a plugin claiming a root path is not fully installed until
`pm2 restart officer`, because the table is fixed once serve() is called.

Every claim is logged at boot by plugin — a root path answering without a
session should not be something you read a manifest to discover.

13 more tests (30 in the file): reserved segments, malformed segments, two
plugins colliding, and segment-not-substring matching so `/identityx` never
matches `/identity`. Suite 1074 pass / 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:47:04 +00:00

168 lines
6.7 KiB
TypeScript

import { mountPrefix, publicPathProblem, rootPathProblem, type DiscoveredPlugin } from './manifest';
// Which plugin paths are served ABOVE the account gate.
//
// The declaration side of this — `unauthenticatedPaths` in a manifest, `publicPathProblem` validating it —
// existed from the day the field was designed. Nothing read it: no plugin had needed one yet (kiosk
// declares none), so the field validated, documented itself, and did nothing. Vault is the first real
// user, and this is the half that was missing.
//
// ── What a plugin is trusted with, and what it is not ──
//
// NOT trusted: which paths. A declared path is resolved against the plugin's OWN mount and re-checked
// here at every mount, so a plugin cannot name `/auth`, `/users`, or another plugin's routes however it
// edits its manifest. `publicPathProblem` already refuses `..`, `//`, mid-path wildcards and the whole
// plugin; this adds the containment check that makes the promise structural rather than syntactic.
//
// Trusted: what those paths DO. Authorising them is the plugin's job — it asked for them to be public, so
// whatever credential they check is the plugin's to define. The platform's job is to be exact about which
// ones, and to say so out loud at boot.
/** A resolved entry: the absolute path pattern, and who asked for it. */
export type PublicPath = { pattern: string; appName: string };
let publicPaths: PublicPath[] = [];
/**
* Resolve a plugin's declared paths against its own mount.
*
* Returns the absolute patterns and the reasons any were refused. Refusal is per-path rather than
* per-plugin: one bad entry must not quietly take the good ones with it, and the caller logs what it lost.
*/
export function resolvePluginPublicPaths(plugin: DiscoveredPlugin): { paths: string[]; refused: string[] } {
const declared = plugin.manifest.unauthenticatedPaths ?? [];
const base = `/api${mountPrefix(plugin)}`;
const paths: string[] = [];
const refused: string[] = [];
for (const path of declared) {
const problem = publicPathProblem(path);
if (problem) {
refused.push(`${plugin.appName}: ${problem}`);
continue;
}
const absolute = `${base}${path}`;
// Containment, re-checked rather than assumed. `publicPathProblem` already makes escaping hard; this
// makes it impossible, and it is cheap. A path that does not sit under the plugin's own mount is the
// one failure that would matter, so it is checked where the mount is actually known.
if (absolute !== base && !absolute.startsWith(`${base}/`)) {
refused.push(`${plugin.appName}: "${path}" resolves outside the plugin's own mount`);
continue;
}
paths.push(absolute);
}
return { paths, refused };
}
/**
* Replace the whole set. Called from `refreshPluginMounts` alongside `setPluginPermissions`, and for the
* same reason: install, uninstall, enable and disable are all one recomputation rather than a diff.
*/
export function setPluginPublicPaths(entries: PublicPath[]): void {
publicPaths = entries;
}
/** Everything currently public, for logging and for the plugins API to show. */
export function listPluginPublicPaths(): PublicPath[] {
return publicPaths;
}
/**
* Is this request path served above the account gate?
*
* A trailing `/*` matches the prefix and everything under it; anything else is exact. Deliberately not a
* regex: the patterns come from a manifest, and the set of shapes `publicPathProblem` allows is small
* enough to match literally — which is one fewer place for a pattern to mean more than it looks like.
*/
export function isPluginPublicPath(path: string): boolean {
for (const { pattern } of publicPaths) {
if (pattern.endsWith('/*')) {
const prefix = pattern.slice(0, -2);
if (path === prefix || path.startsWith(`${prefix}/`)) return true;
} else if (path === pattern) {
return true;
}
}
return false;
}
// ── Top-level claims: root paths, and the header that hands `/api/*` to a plugin ──
//
// Everything above is about a plugin's own mount. This is about the platform's namespace, which a plugin
// may reach into only for a protocol it does not control and cannot move — see `unauthenticatedRootPaths`
// in manifest.ts for the full argument. Kept in the same file because it is the same question ("what does
// a plugin serve without a session") and splitting it would let the two drift.
export type RootClaim = { segment: string; appName: string };
let rootClaims: RootClaim[] = [];
let protocolHeaders: { header: string; appName: string }[] = [];
/**
* Resolve a plugin's root claims, refusing collisions against a set already taken.
*
* `taken` is threaded in rather than read from module state so the caller can resolve every plugin
* against one accumulating set — first come, first served, deterministic in install order, and the loser
* is told by name rather than silently overwritten.
*/
export function resolvePluginRootPaths(
plugin: DiscoveredPlugin,
taken: Set<string>,
): { segments: string[]; refused: string[] } {
const segments: string[] = [];
const refused: string[] = [];
for (const path of plugin.manifest.unauthenticatedRootPaths ?? []) {
const problem = rootPathProblem(path);
if (problem) {
refused.push(`${plugin.appName}: ${problem}`);
continue;
}
const segment = path.slice(1);
if (taken.has(segment)) {
refused.push(`${plugin.appName}: "/${segment}" is already claimed by another plugin`);
continue;
}
taken.add(segment);
segments.push(segment);
}
return { segments, refused };
}
export function setPluginRootClaims(claims: RootClaim[]): void {
rootClaims = claims;
}
export function listPluginRootClaims(): RootClaim[] {
return rootClaims;
}
/** Does this top-level path belong to a plugin? Segment match, so `/identityx` never counts. */
export function pluginForRootPath(path: string): string | null {
for (const { segment, appName } of rootClaims) {
if (path === `/${segment}` || path.startsWith(`/${segment}/`)) return appName;
}
return null;
}
export function setPluginProtocolHeaders(entries: { header: string; appName: string }[]): void {
protocolHeaders = entries;
}
export function listPluginProtocolHeaders(): { header: string; appName: string }[] {
return protocolHeaders;
}
/**
* Which plugin, if any, claims this `/api/*` request by header.
*
* ROUTING, not authentication: a forged header reaches the plugin's own public router, which demands
* whatever credential it demands. The worst outcome for a caller who sets it is losing access to
* Officer's API for that request.
*/
export function pluginForProtocolHeaders(headers: Headers): string | null {
for (const { header, appName } of protocolHeaders) {
if (headers.has(header)) return appName;
}
return null;
}