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,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 });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user