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
This commit is contained in:
@@ -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<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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user