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
This commit is contained in:
+21
-14
@@ -25,7 +25,7 @@ import { dashboardsRouter } from './api/dashboards';
|
|||||||
import { router as fileBrowserRouter } from './api/file-browser/router';
|
import { router as fileBrowserRouter } from './api/file-browser/router';
|
||||||
import { devProxyRouter } from './api/dev-proxy/router';
|
import { devProxyRouter } from './api/dev-proxy/router';
|
||||||
import { pluginsRouter } from './api/plugins/router';
|
import { pluginsRouter } from './api/plugins/router';
|
||||||
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
|
import { isPluginPublicPath, pluginForProtocolHeaders, pluginForRootPath } from './plugins/public-paths';
|
||||||
import { agentHandoffRouter } from './api/agent-handoff/router';
|
import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||||
import { terminalRouter } from './api/terminal/sidecar-server';
|
import { terminalRouter } from './api/terminal/sidecar-server';
|
||||||
import { sendoffRouter } from './api/sendoff/router';
|
import { sendoffRouter } from './api/sendoff/router';
|
||||||
@@ -104,17 +104,21 @@ const corsMiddleware = cors({
|
|||||||
allowHeaders: ['Content-Type', 'Authorization'],
|
allowHeaders: ['Content-Type', 'Authorization'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// The DAV doors are the one place CORS must NOT run, because hono's cors() answers every OPTIONS itself
|
// A plugin's TOP-LEVEL paths are the one place CORS must NOT run, because hono's cors() answers every
|
||||||
// as a preflight — 204, no body, no DAV headers — and never calls the route beneath it.
|
// OPTIONS itself as a preflight — 204, no body, none of the route's own headers — and never calls the
|
||||||
|
// route beneath it.
|
||||||
//
|
//
|
||||||
// OPTIONS is not a preflight to a DAV client. It is how the client asks what the server can do, and the
|
// The case that taught this is DAV. OPTIONS is not a preflight to a DAV client: it is how the client asks
|
||||||
// answer it needs is the `DAV: 1, 2, 3, calendar-access, addressbook` header. iOS sends it during account
|
// what the server can do, and the answer it needs is the `DAV: 1, 2, 3, calendar-access, addressbook`
|
||||||
// setup and refuses the account when it is missing, reporting the failure as "Cannot connect using SSL" —
|
// header. iOS sends it during account setup and refuses the account when it is missing, reporting the
|
||||||
// a message about TLS for a problem that has nothing to do with TLS, which is how this cost an evening.
|
// failure as "Cannot connect using SSL" — a message about TLS for a problem that has nothing to do with
|
||||||
|
// TLS, which is how this cost an evening.
|
||||||
//
|
//
|
||||||
// Nothing is lost by skipping it: a CalDAV client is not a browser and has no origin to check.
|
// Nothing is lost by skipping it. A top-level path exists because something that is NOT a browser has to
|
||||||
const isDavPath = (path: string) =>
|
// reach it — a DAV client, a phone, a protocol handler — and those have no origin to check. This was
|
||||||
path === '/dav' || path.startsWith('/dav/') || path === '/.well-known/caldav' || path === '/.well-known/carddav';
|
// `isDavPath`, a literal list of four paths, until caldav became a plugin and took them with it; asking
|
||||||
|
// the claim registry means the carve-out follows whoever owns the route instead of naming it here.
|
||||||
|
const isPluginRootPath = (path: string) => pluginForRootPath(path) !== null;
|
||||||
|
|
||||||
// The mount table, as DATA rather than forty statements.
|
// The mount table, as DATA rather than forty statements.
|
||||||
//
|
//
|
||||||
@@ -195,7 +199,7 @@ export const UNPROTECTED_API_PREFIXES: string[] = [
|
|||||||
export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: HonoVariables }> {
|
export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: HonoVariables }> {
|
||||||
const app = new Hono<{ Variables: HonoVariables }>();
|
const app = new Hono<{ Variables: HonoVariables }>();
|
||||||
|
|
||||||
app.use((ctx, next) => (isDavPath(ctx.req.path) ? next() : corsMiddleware(ctx, next)));
|
app.use((ctx, next) => (isPluginRootPath(ctx.req.path) ? next() : corsMiddleware(ctx, next)));
|
||||||
|
|
||||||
/*
|
/*
|
||||||
A plugin's PUBLIC router, mounted above everything — the gate, `userMiddleware`, the whole platform API.
|
A plugin's PUBLIC router, mounted above everything — the gate, `userMiddleware`, the whole platform API.
|
||||||
@@ -318,21 +322,24 @@ export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: H
|
|||||||
|
|
||||||
app.route('/api', protectedRouter);
|
app.route('/api', protectedRouter);
|
||||||
|
|
||||||
|
// Every error is JSON, shape `{ error: string }` — success is already JSON, so a text/plain failure
|
||||||
|
// was the one response a client had to special-case. An OBJECT returnValue passes through untouched:
|
||||||
|
// callers rely on its fields, and wrapping it would break them.
|
||||||
app.onError((error, ctx) => {
|
app.onError((error, ctx) => {
|
||||||
if (error instanceof CustomError) {
|
if (error instanceof CustomError) {
|
||||||
if (error.returnValue) {
|
if (error.returnValue) {
|
||||||
if (typeof error.returnValue === 'string') {
|
if (typeof error.returnValue === 'string') {
|
||||||
return ctx.text(error.returnValue, error.statusCode);
|
return ctx.json({ error: error.returnValue }, error.statusCode);
|
||||||
} else {
|
} else {
|
||||||
return ctx.json(error.returnValue, error.statusCode);
|
return ctx.json(error.returnValue, error.statusCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ctx.text(error.message, error.statusCode);
|
return ctx.json({ error: error.message }, error.statusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('Unexpected error:', error.message);
|
console.error('Unexpected error:', error.message);
|
||||||
console.log(error.stack);
|
console.log(error.stack);
|
||||||
return ctx.text('Internal Server Error', 500);
|
return ctx.json({ error: 'Internal Server Error' }, 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -589,20 +589,62 @@ const RESERVED_ROOT = new Set([
|
|||||||
'index.html',
|
'index.html',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names under `/.well-known` a first-party plugin may claim.
|
||||||
|
*
|
||||||
|
* `.well-known` is IANA's registry namespace (RFC 8615) and the whole point of it is that a name in there
|
||||||
|
* means one specific thing — so claiming a name from it is not the platform inventing a URL, it is a
|
||||||
|
* plugin answering the question that name asks. `caldav` and `carddav` are RFC 6764's discovery paths:
|
||||||
|
* a client is given a bare domain and probes them before it has any credential.
|
||||||
|
*
|
||||||
|
* An ALLOW-LIST rather than "any name under .well-known", because the namespace also holds things that
|
||||||
|
* must never move — `acme-challenge` is how this machine gets its TLS certificates, and a plugin owning
|
||||||
|
* it could take the domain. Adding a name here is a deliberate act; there is no pattern that grants one.
|
||||||
|
*/
|
||||||
|
const WELL_KNOWN_CLAIMABLE = new Set(['caldav', 'carddav']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check one declared ROOT path, returning a reason it is refused or null.
|
* Check one declared ROOT path, returning a reason it is refused or null.
|
||||||
*
|
*
|
||||||
* Stricter than `publicPathProblem` because the surface is: this is the platform's own namespace, not the
|
* Stricter than `publicPathProblem` because the surface is: this is the platform's own namespace, not the
|
||||||
* plugin's, so a plugin gets to claim a single name and nothing structural.
|
* plugin's, so a plugin gets to claim a single name and nothing structural.
|
||||||
|
*
|
||||||
|
* ── firstParty ──
|
||||||
|
*
|
||||||
|
* A reserved name is refused for everyone EXCEPT a first-party plugin, and that exception exists because
|
||||||
|
* of what "reserved" was recording. `dav` and `.well-known` are in the list because the PLATFORM serves
|
||||||
|
* them today, not because they are dangerous in themselves — and when caldav becomes a plugin, the thing
|
||||||
|
* the reservation was protecting is the plugin. Refusing it would mean the platform permanently owning
|
||||||
|
* routes for a feature it no longer contains.
|
||||||
|
*
|
||||||
|
* A THIRD-PARTY plugin is still refused every reserved name, and that is the line the owner drew on
|
||||||
|
* 2026-08-27: this is fine for a plugin we wrote, and letting a stranger's plugin claim `/dav` — or
|
||||||
|
* anything else the platform is trusted to answer — is a question that needs thinking about properly
|
||||||
|
* rather than falling out of a flag. `api` stays refused for everyone, first-party included: it is where
|
||||||
|
* all of Officer lives and `protocolHeaders` is the only sanctioned way to touch it.
|
||||||
*/
|
*/
|
||||||
export function rootPathProblem(path: unknown): string | null {
|
export function rootPathProblem(path: unknown, firstParty = false): string | null {
|
||||||
if (typeof path !== 'string' || !path) return 'must be a non-empty string';
|
if (typeof path !== 'string' || !path) return 'must be a non-empty string';
|
||||||
if (!path.startsWith('/')) return `"${path}" must start with "/"`;
|
if (!path.startsWith('/')) return `"${path}" must start with "/"`;
|
||||||
const segment = path.slice(1);
|
const segment = path.slice(1);
|
||||||
|
|
||||||
|
// A two-segment claim under `.well-known`, which the single-segment rule below would refuse. First-party
|
||||||
|
// only and allow-listed by name — see WELL_KNOWN_CLAIMABLE.
|
||||||
|
const wellKnown = /^\.well-known\/([a-z][a-z0-9-]{0,38})$/.exec(segment);
|
||||||
|
if (wellKnown) {
|
||||||
|
if (!firstParty) return `"${path}" is reserved by the platform`;
|
||||||
|
if (!WELL_KNOWN_CLAIMABLE.has(wellKnown[1]!)) {
|
||||||
|
return `"${path}" is not a claimable .well-known name`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!/^[a-z][a-z0-9-]{0,38}$/.test(segment)) {
|
if (!/^[a-z][a-z0-9-]{0,38}$/.test(segment)) {
|
||||||
return `"${path}" must be a single lowercase segment, e.g. "/identity"`;
|
return `"${path}" must be a single lowercase segment, e.g. "/identity"`;
|
||||||
}
|
}
|
||||||
if (RESERVED_ROOT.has(segment)) return `"${path}" is reserved by the platform`;
|
// `api` is refused even to us. Everything of Officer's lives under it.
|
||||||
|
if (segment === 'api') return `"${path}" is reserved by the platform`;
|
||||||
|
if (RESERVED_ROOT.has(segment) && !firstParty) return `"${path}" is reserved by the platform`;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,7 +784,7 @@ export function manifestProblems(appName: string, manifest: Partial<PluginManife
|
|||||||
problems.push('unauthenticatedRootPaths must be an array of paths');
|
problems.push('unauthenticatedRootPaths must be an array of paths');
|
||||||
} else {
|
} else {
|
||||||
for (const [i, path] of manifest.unauthenticatedRootPaths.entries()) {
|
for (const [i, path] of manifest.unauthenticatedRootPaths.entries()) {
|
||||||
const problem = rootPathProblem(path);
|
const problem = rootPathProblem(path, manifest.publisher === FIRST_PARTY_PUBLISHER);
|
||||||
if (problem) problems.push(`unauthenticatedRootPaths[${i}] ${problem}`);
|
if (problem) problems.push(`unauthenticatedRootPaths[${i}] ${problem}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,14 +128,58 @@ describe('top-level claims', () => {
|
|||||||
expect(refused).toEqual([]);
|
expect(refused).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The whole point of the reserved list: a plugin claiming these would shadow the platform for every
|
const thirdParty = (appName: string, roots: string[]) => {
|
||||||
// request, and `/api` in particular is where all of Officer lives.
|
const p = plugin(appName, undefined, 'someone-else');
|
||||||
test.each([['/api'], ['/auth'], ['/plugins'], ['/dav']])('refuses the reserved segment %s', (root) => {
|
(p.manifest as { unauthenticatedRootPaths?: string[] }).unauthenticatedRootPaths = roots;
|
||||||
const { segments, refused } = resolvePluginRootPaths(withRoots('evil', [root]), new Set());
|
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(segments).toEqual([]);
|
||||||
expect(refused).toHaveLength(1);
|
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) => {
|
test.each([['/a/b'], ['identity'], ['/Identity'], ['/']])('refuses the malformed segment %s', (root) => {
|
||||||
expect(resolvePluginRootPaths(withRoots('evil', [root]), new Set()).segments).toEqual([]);
|
expect(resolvePluginRootPaths(withRoots('evil', [root]), new Set()).segments).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { mountPrefix, publicPathProblem, rootPathProblem, type DiscoveredPlugin } from './manifest';
|
import {
|
||||||
|
mountPrefix,
|
||||||
|
publicPathProblem,
|
||||||
|
rootPathProblem,
|
||||||
|
FIRST_PARTY_PUBLISHER,
|
||||||
|
type DiscoveredPlugin,
|
||||||
|
} from './manifest';
|
||||||
|
|
||||||
// Which plugin paths are served ABOVE the account gate.
|
// Which plugin paths are served ABOVE the account gate.
|
||||||
//
|
//
|
||||||
@@ -111,8 +117,12 @@ export function resolvePluginRootPaths(
|
|||||||
): { segments: string[]; refused: string[] } {
|
): { segments: string[]; refused: string[] } {
|
||||||
const segments: string[] = [];
|
const segments: string[] = [];
|
||||||
const refused: string[] = [];
|
const refused: string[] = [];
|
||||||
|
// Provenance decides whether a RESERVED name may be claimed. A first-party plugin can take one back
|
||||||
|
// from the platform — which is what caldav owning `/dav` is — and a third-party plugin cannot.
|
||||||
|
const firstParty = plugin.manifest.publisher === FIRST_PARTY_PUBLISHER;
|
||||||
|
|
||||||
for (const path of plugin.manifest.unauthenticatedRootPaths ?? []) {
|
for (const path of plugin.manifest.unauthenticatedRootPaths ?? []) {
|
||||||
const problem = rootPathProblem(path);
|
const problem = rootPathProblem(path, firstParty);
|
||||||
if (problem) {
|
if (problem) {
|
||||||
refused.push(`${plugin.appName}: ${problem}`);
|
refused.push(`${plugin.appName}: ${problem}`);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
Reference in New Issue
Block a user