plugins: declare unauthenticated paths, validated; mounting designed and measured
half of the primitive kiosk needs. the manifest field and its validator are in;
the mounting is documented and not wired, so nothing serves anything publicly
today.
paths are relative to the plugin's own mount and refused rather than corrected:
no leading-slash-missing, no .., no //, no query or fragment, wildcard only as a
trailing /*, and never the bare / or /*. the plugin is trusted with what those
paths DO, never with which paths.
the mounting was measured rather than reasoned about, and two obvious designs
are wrong:
mounting the public router at the plugin prefix gives correct fall-through but
lets the router serve any route it defines - the manifest becomes documentation
rather than enforcement.
adding a use('*') guard blocks that leak and BREAKS the plugin's authenticated
api: a use('*') middleware always matches, so the parent never continues.
measured - an authenticated route 404'd with a valid token.
what works is registering only the declared patterns, delegating into a
pre-mounted app. undeclared paths never register a handler, so they fall through
naturally. enforcement by registration rather than by checking, which is why a
plugin cannot widen it.
docs/plugin-unauthenticated-paths.md has the probe results and the six
remaining steps.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<PluginManife
|
||||
for (const field of ['version', 'platform', 'label', 'summary', 'color'] as const) {
|
||||
if (typeof manifest[field] !== 'string' || !manifest[field]) problems.push(`${field} is required`);
|
||||
}
|
||||
if (manifest.unauthenticatedPaths !== undefined) {
|
||||
if (!Array.isArray(manifest.unauthenticatedPaths)) {
|
||||
problems.push('unauthenticatedPaths must be an array of paths');
|
||||
} else {
|
||||
for (const [i, path] of manifest.unauthenticatedPaths.entries()) {
|
||||
const problem = publicPathProblem(path);
|
||||
if (problem) problems.push(`unauthenticatedPaths[${i}] ${problem}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(manifest.permissions)) {
|
||||
problems.push('permissions must be an array (use [] when the plugin gates nothing)');
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user