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:
@@ -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 ──
|
||||
|
||||
|
||||
Reference in New Issue
Block a user