close three cross-account holes found reading the multi-user path

reset-password accepted any valid signed jwt as a reset token, including a
30-day session token — its sibling verify-token.ts already gated on
purpose === 'reset-password' and this handler did not. forgot-password mints
that claim, so the gate costs the legitimate flow nothing.

notify's DELETE /_officer/devices/:token deleted by token with no user
predicate: a token is the address of a device, not a secret, so any account
holding the notify capability could deregister another's device.
deletePushDevice now takes an optional userId — the route passes it, the
APNs/FCM dead-token paths deliberately do not.

POST /_officer/notify let a request body's userId override the
proxy-injected X-Officer-User. The header now wins where present, which is
what separates a signed-in browser from a loopback producer that has no
session to speak from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 21:58:36 +00:00
co-authored by Claude Opus 5
parent 7ce9289921
commit ac64a7362b
4 changed files with 39 additions and 12 deletions
+10 -5
View File
@@ -61,12 +61,17 @@ const server = Bun.serve({
if (!body.type || !VALID_TYPES.includes(body.type)) {
return Response.json({ error: `type must be one of ${VALID_TYPES.join(', ')}` }, { status: 400 });
}
// Producers inside the tailnet POST directly and say who to notify. A browser reaching this
// through /api/notify cannot know its own id, so the proxy's injected header stands in — the
// platform already authenticated whoever sent it.
// Producers inside the tailnet POST directly over loopback and say who to notify — the queue and
// the email sidecar have no session to speak from, so the body is their only way to name a user.
//
// The header WINS where it is present, and that ordering is the whole access control here. A
// request carrying X-Officer-User arrived through createSidecarProxy, meaning a signed-in browser
// sent it; letting its body override the id the platform authenticated would let any account with
// the `notify` capability push to any other account's devices. A direct producer sets no header,
// so its body is still honoured.
const headerUser = Number(req.headers.get('X-Officer-User'));
const userId = typeof body.userId === 'number' ? body.userId : headerUser;
if (!Number.isFinite(userId) || userId <= 0) {
const userId = Number.isFinite(headerUser) && headerUser > 0 ? headerUser : body.userId;
if (typeof userId !== 'number' || !Number.isFinite(userId) || userId <= 0) {
return Response.json({ error: 'userId is required (body or X-Officer-User)' }, { status: 400 });
}