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:
@@ -2,8 +2,11 @@ import { upsertPushDevice, getPushDevices, deletePushDevice } from 'officerdb';
|
||||
|
||||
// Device registration, reached through the platform's authenticated proxy.
|
||||
//
|
||||
// The owner comes from X-Officer-User, injected by createSidecarProxy and trusted because this server
|
||||
// The caller comes from X-Officer-User, injected by createSidecarProxy and trusted because this server
|
||||
// binds loopback only. Nothing else can reach it.
|
||||
//
|
||||
// Every route here is scoped to that id, including the DELETE — which takes a token from the URL, and a
|
||||
// token is the address of a device rather than a secret.
|
||||
|
||||
const PLATFORMS = new Set(['ios', 'android']);
|
||||
const ENVIRONMENTS = new Set(['production', 'sandbox']);
|
||||
@@ -72,7 +75,7 @@ export async function handleDeviceRoute(req: Request, url: URL): Promise<Respons
|
||||
|
||||
const match = url.pathname.match(/^\/_officer\/devices\/(.+)$/);
|
||||
if (match && req.method === 'DELETE') {
|
||||
await deletePushDevice(decodeURIComponent(match[1]!));
|
||||
await deletePushDevice(decodeURIComponent(match[1]!), userId);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user