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,5 +1,5 @@
|
|||||||
import * as sidecar from '@@/sidecar-registry';
|
import * as sidecar from '@@/sidecar-registry';
|
||||||
import { getNotifyServerUrl } from '../notify/router';
|
import { getNotifyServerUrl, notifyProducerHeaders } from '../notify/router';
|
||||||
import { shouldNotify, type NotifyKind } from './notify-decision';
|
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.
|
// 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;
|
if (!(await shouldNotify(userId, sessionKey, kind))) return;
|
||||||
|
|
||||||
const base = getNotifyServerUrl();
|
const base = getNotifyServerUrl();
|
||||||
|
const producer = notifyProducerHeaders();
|
||||||
// The sidecar has not reported in — either it is starting, or push is not running on this install.
|
// 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.
|
// 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
|
// `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
|
// 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.
|
// this payload is read by Apple and Google. See docs/push-notifications.md.
|
||||||
await fetch(`${base}/_officer/notify`, {
|
await fetch(`${base}/_officer/notify`, {
|
||||||
method: 'POST',
|
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 }),
|
body: JSON.stringify({ code: CODE_FOR[kind], userId, id: sessionKey }),
|
||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
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
|
// /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.
|
// 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' });
|
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;
|
export const notifyRouter = proxy.router;
|
||||||
|
|
||||||
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||||
export const getNotifyServerUrl = proxy.getHttpUrl;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
revokeSender,
|
revokeSender,
|
||||||
upsertSendoffDevice,
|
upsertSendoffDevice,
|
||||||
} from 'officerdb';
|
} from 'officerdb';
|
||||||
import { getNotifyServerUrl } from '../notify/router';
|
import { getNotifyServerUrl, notifyProducerHeaders } from '../notify/router';
|
||||||
import { displayNameFor, folderNameFor, safeFilename, safeSegment, sendoffDirFor } from './paths';
|
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> {
|
async function pushLanded(recipientUserId: number, folder: string, count: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const base = getNotifyServerUrl();
|
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`, {
|
await fetch(`${base}/_officer/notify`, {
|
||||||
method: 'POST',
|
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 }),
|
body: JSON.stringify({ code: 'officer/core:sendoff.landed', userId: recipientUserId, id: folder, n: count }),
|
||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// opposite of the line above. Invisible in testing because the owner bypasses every check.
|
||||||
personal: ['/_officer/devices', '/_officer/subscriptions'],
|
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,
|
// `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
|
// minting a pre-auth key bound to the caller. Deleted because it had no caller anywhere: the standalone
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ export type SidecarProxy = {
|
|||||||
router: Hono<{ Variables: HonoVariables }>;
|
router: Hono<{ Variables: HonoVariables }>;
|
||||||
/** `http://127.0.0.1:<port>`, or null before the sidecar has reported in. */
|
/** `http://127.0.0.1:<port>`, or null before the sidecar has reported in. */
|
||||||
getHttpUrl: () => string | null;
|
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. */
|
/** The same server as a `ws://` base, for sidecars whose sockets the platform relays. */
|
||||||
getWsUrl: () => string | null;
|
getWsUrl: () => string | null;
|
||||||
};
|
};
|
||||||
@@ -89,15 +97,19 @@ export function createSidecarProxy({
|
|||||||
permissionKey,
|
permissionKey,
|
||||||
}: SidecarProxyParams): SidecarProxy {
|
}: SidecarProxyParams): SidecarProxy {
|
||||||
let serverPort: number | null = null;
|
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
|
// Tells bodyParser to keep its hands off this prefix's request bodies. Load-bearing for uploads, not an
|
||||||
// optimisation: see proxied-prefixes.ts.
|
// optimisation: see proxied-prefixes.ts.
|
||||||
registerProxiedPrefix(prefix);
|
registerProxiedPrefix(prefix);
|
||||||
|
|
||||||
sidecar.on(`${name}:server`, (msg) => {
|
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;
|
if (typeof port !== 'number') return;
|
||||||
serverPort = port;
|
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}`);
|
console.log(`[${name}] sidecar registered on port ${port}`);
|
||||||
onRegister?.(port);
|
onRegister?.(port);
|
||||||
});
|
});
|
||||||
@@ -150,6 +162,10 @@ export function createSidecarProxy({
|
|||||||
// permissions page instead of compiled into an `if`.
|
// permissions page instead of compiled into an `if`.
|
||||||
const userId = ctx.get('user').id;
|
const userId = ctx.get('user').id;
|
||||||
headers['X-Officer-User'] = String(userId);
|
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) {
|
if (permissionKey) {
|
||||||
// Cached by role in `authorize.ts`, and the gate has already computed this for the same request, so
|
// 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 new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
|
||||||
});
|
});
|
||||||
|
|
||||||
return { router, getHttpUrl, getWsUrl };
|
return { router, getHttpUrl, getWsUrl, getProducerSecret: () => producerSecret };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||||
import { createSidecarConnector } from '../connect';
|
import { createSidecarConnector } from '../connect';
|
||||||
import { dispatch, configuredChannels } from './dispatch';
|
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.
|
// 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');
|
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({
|
const server = Bun.serve({
|
||||||
port,
|
port,
|
||||||
hostname: '127.0.0.1',
|
hostname: '127.0.0.1',
|
||||||
async fetch(req) {
|
async fetch(req) {
|
||||||
const url = new URL(req.url);
|
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') {
|
if (url.pathname === '/_health') {
|
||||||
return Response.json({ ok: true, channels: configuredChannels() });
|
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
|
// 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
|
// 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.
|
// 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 });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,19 @@ export type SidecarCommand =
|
|||||||
* `<name>:server`, where the name is the sidecar's own — `music:server`, `pty:server`, and whatever a
|
* `<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.
|
* 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 =
|
export type SidecarEvent =
|
||||||
| { type: 'pong'; id: string }
|
| { type: 'pong'; id: string }
|
||||||
|
|||||||
Reference in New Issue
Block a user