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
+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 };
}