diff --git a/src/servers/api/chat/notify-push.ts b/src/servers/api/chat/notify-push.ts index 7045ed98..1589da88 100644 --- a/src/servers/api/chat/notify-push.ts +++ b/src/servers/api/chat/notify-push.ts @@ -1,5 +1,5 @@ import * as sidecar from '@@/sidecar-registry'; -import { getNotifyServerUrl } from '../notify/router'; +import { getNotifyServerUrl, notifyProducerHeaders } from '../notify/router'; import { shouldNotify, type NotifyKind } from './notify-decision'; // The phone half of the bell: the same signal the browser socket listens to, delivered to APNs/FCM. @@ -32,16 +32,17 @@ export function registerChatPushNotifications(): void { if (!(await shouldNotify(userId, sessionKey, kind))) return; const base = getNotifyServerUrl(); + const producer = notifyProducerHeaders(); // The sidecar has not reported in — either it is starting, or push is not running on this install. // Not an error: the browser bell still works, and this is the channel that is allowed to be absent. - if (!base) return; + if (!base || !producer) return; // `id` is the session key so the device can deep-link straight to the conversation. It is an // opaque uuid, which is the only reason it may travel: NO title, and no message content, because // this payload is read by Apple and Google. See docs/push-notifications.md. await fetch(`${base}/_officer/notify`, { method: 'POST', - headers: { 'content-type': 'application/json', 'X-Officer-User': String(userId) }, + headers: { 'content-type': 'application/json', 'X-Officer-User': String(userId), ...producer }, body: JSON.stringify({ code: CODE_FOR[kind], userId, id: sessionKey }), signal: AbortSignal.timeout(10_000), }); diff --git a/src/servers/api/notify/router.ts b/src/servers/api/notify/router.ts index 2ecac6fc..0d7ea1f2 100644 --- a/src/servers/api/notify/router.ts +++ b/src/servers/api/notify/router.ts @@ -1,4 +1,6 @@ import { createSidecarProxy } from '../../sidecar/create-proxy'; +import { getUsers } from 'officerdb'; +import * as errors from '../../custom-errors'; // /api/notify/* — auth, then forward to officer-notify. The apps register their push tokens through here // and nothing else uses it; producers inside the tailnet POST to the sidecar over loopback directly. @@ -7,7 +9,68 @@ import { createSidecarProxy } from '../../sidecar/create-proxy'; const proxy = createSidecarProxy({ name: 'notify', prefix: '/api/notify' }); +/* + POST /api/notify/broadcast — one message to every account, owner only. + + It exists because the alternative was what actually happened on 2026-08-24: to reach four accounts at + once, somebody found the sidecar's ephemeral port and POSTed to it directly, once per user, with no + authentication at all. That worked, which was the problem — the same door was open to anything else on + the machine, including an agent. + + Teams will narrow this later. Today the choice is everyone or one person, and everyone is owner-only. +*/ +proxy.router.post('/broadcast', async (ctx) => { + type BroadcastBody = { title?: string; body?: string; code?: string }; + const body: BroadcastBody = await ctx.req.json().catch(() => ({}) as BroadcastBody); + const title = body.title?.trim(); + const message = body.body?.trim(); + if (!title || !message) throw errors.BAD_REQUEST('title and body are required'); + + const base = proxy.getHttpUrl(); + const producer = notifyProducerHeaders(); + if (!base || !producer) throw errors.createError(503, 'notify sidecar not available'); + + // Sequential, and deliberately so: this is a handful of accounts on a personal server, and one slow + // APNs call must not turn into a hundred concurrent ones against the same host. + const results: { userId: number; ok: boolean }[] = []; + for (const user of await getUsers()) { + try { + const res = await fetch(`${base}/_officer/notify`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...producer }, + // `userId` in the BODY, with no `X-Officer-User`: this is the platform speaking about someone + // else, which is exactly the distinction `sidecar/notify/resolve-user.ts` draws. + body: JSON.stringify({ code: body.code ?? 'officer/core:message', userId: user.id, title, body: message }), + signal: AbortSignal.timeout(15_000), + }); + results.push({ userId: user.id, ok: res.ok }); + } catch { + // One account's failure must not stop the rest — a broadcast that gives up halfway is worse than + // one that reports which half landed. + results.push({ userId: user.id, ok: false }); + } + } + + return ctx.json({ sent: results.filter((r) => r.ok).length, total: results.length, results }); +}); + export const notifyRouter = proxy.router; /** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ export const getNotifyServerUrl = proxy.getHttpUrl; + +/** + * The header an in-process producer must send when it calls the sidecar directly. + * + * Producers inside the platform — sendoff, chat — bypass this proxy and POST over loopback, because they + * are notifying somebody other than the caller and the proxy pins the target to whoever is signed in. + * That path used to accept anyone who could reach the port, which on a machine running commanders is + * anyone with a shell. + * + * Null before the sidecar registers, in which case the producer is right to give up rather than send + * unauthenticated. + */ +export function notifyProducerHeaders(): Record | null { + const secret = proxy.getProducerSecret(); + return secret ? { 'X-Officer-Producer': secret } : null; +} diff --git a/src/servers/api/sendoff/router.ts b/src/servers/api/sendoff/router.ts index 4699ff7c..d2e9bea8 100644 --- a/src/servers/api/sendoff/router.ts +++ b/src/servers/api/sendoff/router.ts @@ -16,7 +16,7 @@ import { revokeSender, upsertSendoffDevice, } from 'officerdb'; -import { getNotifyServerUrl } from '../notify/router'; +import { getNotifyServerUrl, notifyProducerHeaders } from '../notify/router'; import { displayNameFor, folderNameFor, safeFilename, safeSegment, sendoffDirFor } from './paths'; /* @@ -78,10 +78,13 @@ function freePath(dir: string, filename: string): string { async function pushLanded(recipientUserId: number, folder: string, count: number): Promise { try { const base = getNotifyServerUrl(); - if (!base) return; + const producer = notifyProducerHeaders(); + // No secret means the sidecar has not registered with THIS process yet. Giving up is right: the + // alternative is sending unauthenticated, which the sidecar now refuses anyway. + if (!base || !producer) return; await fetch(`${base}/_officer/notify`, { method: 'POST', - headers: { 'content-type': 'application/json', 'X-Officer-User': String(recipientUserId) }, + headers: { 'content-type': 'application/json', 'X-Officer-User': String(recipientUserId), ...producer }, body: JSON.stringify({ code: 'officer/core:sendoff.landed', userId: recipientUserId, id: folder, n: count }), signal: AbortSignal.timeout(10_000), }); diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index 042fcf0c..5f45be5b 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -219,6 +219,14 @@ const CORE_REGISTRY: Permission[] = [ // nothing, so a read-level member could list their phones but not register or delete one — the exact // opposite of the line above. Invisible in testing because the owner bypasses every check. personal: ['/_officer/devices', '/_officer/subscriptions'], + /* + Broadcast is the one part of a core permission that is not the caller's own. + + Everything else here acts on the account asking — its devices, its notifications — which is what + made `core` right. Sending to EVERY account is administration, so it needs the owner even though + the permission around it is not deniable. + */ + ownerOnly: ['/broadcast'], }, // `vpn` (POST /api/vpn/enroll) was here until 2026-08-14 — the one member-grantable piece of headscale, // minting a pre-auth key bound to the caller. Deleted because it had no caller anywhere: the standalone diff --git a/src/servers/sidecar/create-proxy.ts b/src/servers/sidecar/create-proxy.ts index d91c6de4..c83b9493 100644 --- a/src/servers/sidecar/create-proxy.ts +++ b/src/servers/sidecar/create-proxy.ts @@ -76,6 +76,14 @@ export type SidecarProxy = { router: Hono<{ Variables: HonoVariables }>; /** `http://127.0.0.1:`, or null before the sidecar has reported in. */ getHttpUrl: () => string | null; + /** + * The secret this sidecar announced at registration, for a producer INSIDE the platform calling it + * directly rather than through this proxy. Null until it registers. + * + * In memory on both sides and never written down — see `sidecar/notify/index.ts` for why a file would + * not have bounded the thing this exists to bound. + */ + getProducerSecret: () => string | null; /** The same server as a `ws://` base, for sidecars whose sockets the platform relays. */ getWsUrl: () => string | null; }; @@ -89,15 +97,19 @@ export function createSidecarProxy({ permissionKey, }: SidecarProxyParams): SidecarProxy { let serverPort: number | null = null; + let producerSecret: string | null = null; // Tells bodyParser to keep its hands off this prefix's request bodies. Load-bearing for uploads, not an // optimisation: see proxied-prefixes.ts. registerProxiedPrefix(prefix); sidecar.on(`${name}:server`, (msg) => { - const port = (msg as { port?: number }).port; + const { port, secret } = msg as { port?: number; secret?: string }; if (typeof port !== 'number') return; serverPort = port; + // Optional: only a sidecar that guards a producer path announces one, and one that stops announcing + // (an older build after a rollback) must not leave a stale secret behind that no longer opens it. + producerSecret = typeof secret === 'string' && secret ? secret : null; console.log(`[${name}] sidecar registered on port ${port}`); onRegister?.(port); }); @@ -150,6 +162,10 @@ export function createSidecarProxy({ // permissions page instead of compiled into an `if`. const userId = ctx.get('user').id; headers['X-Officer-User'] = String(userId); + // Proof that this request came THROUGH the proxy, for a sidecar that guards a path with it. Without + // it, `X-Officer-User` says who is asking but nothing says the platform is the one asking — and on a + // machine where an agent has a shell, anything on loopback can write that header itself. + if (producerSecret) headers['X-Officer-Producer'] = producerSecret; if (permissionKey) { // Cached by role in `authorize.ts`, and the gate has already computed this for the same request, so @@ -224,5 +240,5 @@ export function createSidecarProxy({ return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) }); }); - return { router, getHttpUrl, getWsUrl }; + return { router, getHttpUrl, getWsUrl, getProducerSecret: () => producerSecret }; } diff --git a/src/servers/sidecar/notify/index.ts b/src/servers/sidecar/notify/index.ts index 1b327cef..b165b296 100644 --- a/src/servers/sidecar/notify/index.ts +++ b/src/servers/sidecar/notify/index.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'node:crypto'; import type { SidecarCommand, SidecarEvent } from '../protocol'; import { createSidecarConnector } from '../connect'; import { dispatch, configuredChannels } from './dispatch'; @@ -62,12 +63,49 @@ const port = getFreePort(); // thing that works until it doesn't. One file, rewritten on every boot, deleted on clean shutdown. const ADDRESS_FILE = join(DATA_PATH, 'notify-server.url'); +/* + What an in-process producer must present, when it is not speaking for a signed-in user. + + Two callers reach POST /_officer/notify: a browser through the platform's proxy, which arrives with + X-Officer-User injected and can only ever notify itself; and a producer inside the platform — sendoff, + chat — which is notifying somebody else and therefore names them in the body. The second had no + authentication at all, on the reasoning that binding loopback was enough. + + It stopped being enough when commanders arrived. A commander has a shell on this machine, so "anything + on loopback is the platform" became "anything on loopback, including an agent, can notify any user on + this server as if the platform had asked". + + Generated per boot and held in MEMORY, announced to the platform over the registration socket alongside + the port. Deliberately not a file: a member's commander runs as its own Linux user and cannot read this + process, but any file both processes can read is a file that account could read too if it were ever + granted a shell in the wrong place. The owner's own commander is not bounded by this and cannot be — + it runs as the owner, same as the platform — which is the same reason `execution` permissions are + never grantable. +*/ +const PRODUCER_SECRET = randomBytes(32).toString('base64url'); + const server = Bun.serve({ port, hostname: '127.0.0.1', async fetch(req) { const url = new URL(req.url); + /* + Everything under `/_officer/` acts on a named user, so all of it needs the secret — not just the + send route. + + `X-Officer-User` cannot carry this on its own. It says WHO, and a caller on loopback can write it + itself; only the secret says the PLATFORM is the one asking. Guarding just the send route would have + left `/_officer/devices`, where the same header decides whose phones are listed, registered and + deleted. + + `/_health` and `/mapping` stay open: one is a liveness probe and the other is the code-to-text + catalogue, which is shipped to every device and is not a secret. + */ + if (url.pathname.startsWith('/_officer/') && req.headers.get('X-Officer-Producer') !== PRODUCER_SECRET) { + return Response.json({ error: 'not the platform' }, { status: 403 }); + } + if (url.pathname === '/_health') { return Response.json({ ok: true, channels: configuredChannels() }); } @@ -147,7 +185,7 @@ const connection = createSidecarConnector({ // Bun types `Server.port` as optional because a unix-socket server has none; this one is TCP, so it // is a number. Asserted narrowly here — it used to be `as SidecarEvent` on the whole object, which // suppressed this and would have suppressed a genuinely wrong event shape too. - connection.send({ type: 'notify:server', port: server.port! }); + connection.send({ type: 'notify:server', port: server.port!, secret: PRODUCER_SECRET }); }, }); diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index 58f9c136..79d14e4d 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -128,7 +128,19 @@ export type SidecarCommand = * `:server`, where the name is the sidecar's own — `music:server`, `pty:server`, and whatever a * plugin nobody has written yet decides to call itself. Deliberately open: see the note in SidecarEvent. */ -export type SidecarPortAnnouncement = { type: `${string}:server`; port: number }; +export type SidecarPortAnnouncement = { + type: `${string}:server`; + port: number; + /** + * A per-boot secret for callers INSIDE the platform that reach this sidecar directly rather than + * through its proxy — announced here, in memory on both sides, never written to disk. + * + * Optional because only a sidecar with a producer path to guard has one. `notify` is the first: its + * send route accepts a target user in the body, which loopback alone stopped being enough to protect + * once commanders could run a shell on the same machine. + */ + secret?: string; +}; export type SidecarEvent = | { type: 'pong'; id: string }