make the notify identity rule testable, and fix what the test found

Extracting resolveNotifyUser into its own module immediately caught a hole
in the fix from the previous commit: a header that was present but
unparseable fell through to the body, so a browser could send junk in the
header, name any user in the body and win.

PRESENCE of X-Officer-User is the signal, not its validity — a malformed
header means a proxied request went wrong, and falling through hands the
decision back to the caller we just declined to trust.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 10:00:02 +00:00
co-authored by Claude Opus 5
parent 33d96f19ee
commit b077671f19
4 changed files with 84 additions and 13 deletions
+8 -12
View File
@@ -2,6 +2,7 @@ import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { dispatch, configuredChannels } from './dispatch';
import { handleDeviceRoute } from './devices';
import { resolveNotifyUser } from './resolve-user';
import { closeApnsSessions } from './apns';
import type { Notification, NotifyType } from './types';
@@ -61,17 +62,10 @@ 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 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 = Number.isFinite(headerUser) && headerUser > 0 ? headerUser : body.userId;
if (typeof userId !== 'number' || !Number.isFinite(userId) || userId <= 0) {
// The header wins over the body wherever it is present — see ./resolve-user.ts for why that
// ordering is the whole access control on this route.
const userId = resolveNotifyUser({ header: req.headers.get('X-Officer-User'), bodyUserId: body.userId });
if (userId === null) {
return Response.json({ error: 'userId is required (body or X-Officer-User)' }, { status: 400 });
}
@@ -93,7 +87,9 @@ const server = Bun.serve({
},
});
console.log(`[notify] listening on http://127.0.0.1:${server.port} — channels: ${configuredChannels().join(', ') || 'none configured'}`);
console.log(
`[notify] listening on http://127.0.0.1:${server.port} — channels: ${configuredChannels().join(', ') || 'none configured'}`,
);
// ── Connect to the API server ──