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:
@@ -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() });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user