diff --git a/docs/plugin-unauthenticated-paths.md b/docs/plugin-unauthenticated-paths.md new file mode 100644 index 00000000..beda0710 --- /dev/null +++ b/docs/plugin-unauthenticated-paths.md @@ -0,0 +1,80 @@ +# Letting a plugin serve a path without a session + +Design proven by measurement on 2026-08-18, half implemented. The manifest field and its validator are in; +the mounting is not. Everything below was tested against Hono rather than reasoned about — the probes are +reproduced because two of the three obvious designs are wrong in ways that only show up when run. + +## Why + +Some integrations receive requests that structurally cannot carry a session. Kiosk is the first: iOS +`installd` fetches an install manifest and a binary **itself**, and an enrolling device posts its identifier +from the OS. Neither is our code, so neither can set a header, and the platform's `?token=` escape hatch +does not help. + +Today a plugin cannot serve such a path at all: `UNPROTECTED_API_PREFIXES` is a hardcoded list and plugin +routers mount only on `protectedRouter`, behind `userMiddleware`. + +The rejected alternative was putting an `ofk_` API key in the URL. That is authority over the whole platform +— files, terminal, vault — in a string that lands in a manifest, a server log and a phone's install history. + +## What is built + +`PluginManifest.unauthenticatedPaths?: string[]`, and `publicPathProblem()` in `plugins/manifest.ts`, wired +into `validateManifest`. Paths are relative to the plugin's own mount and refused — never corrected — if +they start without `/`, contain `..`, `//`, a query or fragment, use a wildcard anywhere but as a trailing +`/*`, or are the bare `/` or `/*`. + +The plugin is trusted with **what those paths do**, never with **which paths**. Authorising them is its own +job; Kiosk mints a per-user key it can rotate, scoped to one plugin. + +## What is not built: the mounting + +Three designs, and the measurements that killed two of them. + +**1. Mount the public router at the plugin prefix, before `/api`.** Fall-through works — an undeclared path +reaches the gate and 401s correctly. **But the plugin's public router can define any route it likes**, and +whatever it defines is served publicly. The manifest becomes documentation rather than enforcement. + +**2. Same, plus a `use('*')` guard that 404s undeclared paths.** Blocks the leak — and **breaks the plugin's +authenticated API**. A `use('*')` middleware always matches, so it handles every request under the prefix +and the parent never continues. Measured: `/api/kiosk/secret` returned 404 even _with_ a valid token, where +it should have reached the protected router. + +**3. Register ONLY the declared patterns, delegating into a pre-mounted app. ← correct** + +```ts +const mounted = new Hono(); +mounted.route(`/api${plugin.prefix}`, plugin.publicRouter); // so sub-routes see paths they recognise +for (const pattern of declaredPatterns) { + app.all(pattern, (ctx) => mounted.fetch(ctx.req.raw)); // BEFORE app.route('/api', protectedRouter) +} +``` + +Undeclared paths never register a public handler, so they never match and fall through naturally. Measured, +all four cases correct: + +| request | token | result | +| --------------------------------------------------- | ----- | ------------------------------------------------- | +| `/api/kiosk/artefact/a` (declared) | none | **200** from the public router | +| `/api/kiosk/secret` (plugin defined it; undeclared) | none | **401** — falls to the gate, does not leak | +| `/api/kiosk/secret` | valid | **200** from the protected router | +| `/api/kiosk/apps` | valid | **200** — ordinary authenticated route unaffected | + +Enforcement is by **registration**, not by checking, which is why a plugin cannot widen it. + +## Remaining work + +1. `api/public.ts` in discovery (`discover.ts`), loaded only when `unauthenticatedPaths` is declared — a + separate file from `api/router.ts` so the two surfaces cannot be confused by whoever reads either. +2. `MountedPlugin` gains `publicRouter` and the absolute declared patterns; `mount.ts` builds them from + `mountPrefix` + each declared path, so the plugin never supplies an absolute path. +3. `buildHonoApp`: design 3 above, registered before `app.route('/api', protectedRouter)`. +4. **Boot-time re-check.** `validateManifest` runs at install; a file edited afterwards must not widen the + surface. Re-validate where `assertPermissionTotality` already refuses to boot. +5. Surface it in the install dialog the way `osDependencies` is — the owner should be told, in those words, + that a plugin wants to serve something without a login. +6. Tests that bite: an undeclared route the plugin defines must not be reachable, and the plugin's + authenticated routes must keep working. Both are regressions that would otherwise be silent. + +Nothing here is wired, so no plugin serves anything publicly today and the platform behaves exactly as +before. diff --git a/src/servers/plugins/manifest.ts b/src/servers/plugins/manifest.ts index bef3a751..f4c5e1a6 100644 --- a/src/servers/plugins/manifest.ts +++ b/src/servers/plugins/manifest.ts @@ -128,6 +128,44 @@ export type PluginManifest = { permissions: PluginPermission[]; + /** + * Paths under this plugin's own mount that are served ABOVE the account gate. + * + * ── Why this exists ── + * + * Some integrations receive requests that structurally cannot carry a session. Kiosk is the first: + * iOS `installd` fetches an install manifest and a binary ITSELF — not the app, not the browser — and a + * device enrolling posts its identifier from the OS, not from a page. None of those has anywhere to put + * a bearer token, and the platform's own `?token=` escape hatch does not help because none of them is + * our code. + * + * The alternative was smuggling a full-power credential through a URL, which is strictly worse: an + * `ofk_` key is authority over the whole platform — files, terminal, vault — and it would end up in a + * manifest, a server log and a phone's install history. + * + * So this is the honest model. These paths really are public, and saying so lets the platform enforce + * exactly which ones rather than having a plugin invent its own bypass. + * + * ── What a plugin is trusted with, and what it is not ── + * + * NOT trusted: which paths. `validateManifest` refuses anything that could reach beyond the plugin's + * own mount, and `assertPluginPublicPaths` re-checks at boot, so a plugin cannot widen its surface by + * editing a file after review. + * + * Trusted: what those paths do. Authorising them is the plugin's job — Kiosk uses a per-user key it + * mints and can rotate, which is a credential scoped to one plugin and one purpose. + * + * ── Rules, each refused rather than corrected ── + * + * - must start with `/`, and is relative to the plugin's mount (`/artefact/…`, never `/api/kiosk/…`) + * - no `..`, no `//`, no query or fragment + * - a wildcard only as a trailing `/*`, never bare `*` and never mid-path + * - a bare `/` or `/*` is refused: that is the whole plugin, which is not a declaration + * + * Absent means the plugin serves nothing without a session, which is what every plugin today wants. + */ + unauthenticatedPaths?: string[]; + /** * Host binaries this plugin needs. Absent means it is self-sufficient, which offscale and example are. * @@ -194,6 +232,28 @@ export type DiscoveredPlugin = { */ export const FIRST_PARTY_PUBLISHER = 'officerdev'; +/** + * Check one declared public path, returning a reason it is refused or null. + * + * Pure and exported so it can be tested directly. Every rule here exists to make one sentence true: **a + * declared path can only ever reach code inside this plugin's own mount.** A plugin that could escape it + * would be declaring the platform's routes public, which is a different and much worse thing than + * declaring its own. + */ +export function publicPathProblem(path: unknown): string | null { + if (typeof path !== 'string' || !path) return 'must be a non-empty string'; + if (!path.startsWith('/')) return `"${path}" must start with "/" and be relative to the plugin's mount`; + if (path.includes('..')) return `"${path}" may not contain ".."`; + if (path.includes('//')) return `"${path}" may not contain "//"`; + if (path.includes('?') || path.includes('#')) return `"${path}" may not contain a query or fragment`; + // A wildcard is allowed only as the final segment. `/a/*/b` and `/a*` are both refused: the first is a + // pattern this does not need, and the second is the kind of thing that looks narrow and is not. + const star = path.indexOf('*'); + if (star !== -1 && !path.endsWith('/*')) return `"${path}" may only use a wildcard as a trailing "/*"`; + if (path === '/' || path === '/*') return 'the whole plugin cannot be public — declare the paths you mean'; + return null; +} + export function mountPrefix(plugin: { appName: string; manifest: { publisher: string } }): string { const { appName } = plugin; return plugin.manifest.publisher === FIRST_PARTY_PUBLISHER @@ -285,6 +345,16 @@ export function manifestProblems(appName: string, manifest: Partial