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:
2026-08-24 16:29:48 +00:00
co-authored by Claude Opus 5
parent de633f8697
commit cb2dcfa4cc
7 changed files with 151 additions and 10 deletions
+4 -3
View File
@@ -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),
});
+63
View File
@@ -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;
}
+6 -3
View File
@@ -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<void> {
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),
});
+8
View File
@@ -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
+18 -2
View File
@@ -76,6 +76,14 @@ export type SidecarProxy = {
router: Hono<{ Variables: HonoVariables }>;
/** `http://127.0.0.1:<port>`, 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 };
}
+39 -1
View File
@@ -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 });
},
});
+13 -1
View File
@@ -128,7 +128,19 @@ export type SidecarCommand =
* `<name>: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 }