Files
platform/src/servers/api/notify/router.ts
T
pastilhasandClaude Opus 5 cb2dcfa4cc the notify sidecar stops trusting whoever reaches it
to send one message to four accounts an hour ago i found the sidecar's
ephemeral port and POSTed to it directly, once per user, with no
authentication of any kind. that worked, which was the problem: the same
door was open to anything else on the machine, including a commander with
a shell.

two changes.

POST /api/notify/broadcast, owner only via `ownerOnly` on the permission —
the supported way to say one thing to everyone. teams will narrow it later;
today the choice is everyone or one person.

and the sidecar now requires a secret on everything under /_officer/. it
is generated per boot, held in memory, announced to the platform over the
registration socket beside the port, and never written to disk.

X-Officer-User could not carry this on its own, and my first attempt got
that wrong: it says WHO is asking, and a caller on loopback can write it
itself. only the secret says the PLATFORM is asking. so the check covers
the whole /_officer/ surface rather than just the send route — /_officer/
devices takes the same header to decide whose phones are listed and
deleted.

what it bounds, honestly: a MEMBER's commander runs as its own Linux user
and cannot read this process, so for every account but the owner's this is
kernel-enforced. the owner's own commander runs as the owner, same as the
platform, and is not bounded by this and cannot be — the same reason
`execution` permissions are never grantable. the owner's decision, and
this is a real improvement rather than a boundary.

`bun run notify:test` posts to that port and will now be refused. it is a
developer CLI on a box where the platform is running; left to fail loudly
rather than given a way in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 16:29:48 +00:00

77 lines
3.7 KiB
TypeScript

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.
//
// No notification knowledge lives in the platform: not a device token, not an APNs key, not a payload.
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<BroadcastBody>().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<string, string> | null {
const secret = proxy.getProducerSecret();
return secret ? { 'X-Officer-Producer': secret } : null;
}