diff --git a/src/databases/officer_db/src/queries/notify.ts b/src/databases/officer_db/src/queries/notify.ts index b6201393..895666d6 100644 --- a/src/databases/officer_db/src/queries/notify.ts +++ b/src/databases/officer_db/src/queries/notify.ts @@ -37,9 +37,21 @@ export async function getPushDevices(userId: number, appSlug?: string): Promise< return db.select().from(pushDevices).where(where); } -/** Sign-out, or a hard rejection from Apple/Google (410 Unregistered, UNREGISTERED). */ -export async function deletePushDevice(token: string): Promise { - await db.delete(pushDevices).where(eq(pushDevices.token, token)); +/** + * Sign-out, or a hard rejection from Apple/Google (410 Unregistered, UNREGISTERED). + * + * `userId` is optional because the two callers are genuinely different. The APNs/FCM paths delete a + * token the provider has just declared dead, which is true for whoever owns it — they pass nothing. The + * `DELETE /_officer/devices/:token` route is reached by a signed-in account naming a token in the URL, + * so it MUST pass its own id: a token is the address of a device, not a secret, and without the + * predicate any account could deregister another's device by guessing or replaying one. + */ +export async function deletePushDevice(token: string, userId?: number): Promise { + const where = + userId === undefined + ? eq(pushDevices.token, token) + : and(eq(pushDevices.token, token), eq(pushDevices.userId, userId)); + await db.delete(pushDevices).where(where); } /** diff --git a/src/servers/api/auth/reset-password.ts b/src/servers/api/auth/reset-password.ts index d0a819c3..05fdb412 100644 --- a/src/servers/api/auth/reset-password.ts +++ b/src/servers/api/auth/reset-password.ts @@ -6,11 +6,18 @@ import argon2 from 'argon2'; import * as errors from '@@/custom-errors'; import { validatePassword } from './validate-password'; +// The token must be a reset-password token and nothing else. `verifyToken` — which only renders the form +// — has always checked `purpose`; this handler, which actually rewrites the password, did not, so any +// valid signed JWT was accepted here. A 30-day session token is a valid signed JWT, which made this a +// route from "holds a session token" to "owns the account": it rewrites the password (locking the real +// owner out, since passwordChangedAt invalidates their other tokens) and forces `status: 'Active'`, +// re-enabling an account somebody had deliberately suspended. The two handlers now agree. export const resetPasswordHandler: Handler = async function (ctx) { const { password, verificationCode } = ctx.get('body'); validatePassword(password); - const userInfo = (await verify(verificationCode)) as User; - if (!userInfo) throw errors.UNAUTHORIZED(); + const userInfo = (await verify(verificationCode)) as User & { purpose?: string }; + if (!userInfo?.id) throw errors.UNAUTHORIZED(); + if (userInfo.purpose !== 'reset-password') throw errors.UNAUTHORIZED(); const passwordHash = await argon2.hash(password); await updateUser(userInfo.id, { password: passwordHash, status: 'Active', passwordChangedAt: new Date() }); diff --git a/src/servers/sidecar/notify/devices.ts b/src/servers/sidecar/notify/devices.ts index 2b8efa06..b67521e8 100644 --- a/src/servers/sidecar/notify/devices.ts +++ b/src/servers/sidecar/notify/devices.ts @@ -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 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 }); }