f540a473ef
Two P3 multi-user hardening fixes from the 2026-06-13 full-eval. Login timing oracle: both login paths (the UI server action and POST /api/auth) returned immediately on an unknown email but ran bcrypt.compare when the email matched a user, so response latency revealed which emails have accounts. New verifyPasswordOrDummy() in lib/auth runs bcrypt against a fixed dummy hash when there is no user, so every attempt spends exactly one bcrypt; the two error branches in each route collapse into one. exerciseId ownership: exercises are per-user, but the workout create / PATCH (set-replace) / add-sets and CSV import-save routes wrote SetLogs from a client-supplied exerciseId with no ownership check — letting a user attach another user's exercise to their own workout, which leaks that exercise's name/notes on fetch and wires up a cross-user onDelete: Cascade link. All four now reject unowned ids with 400 via the shared lib/exerciseOwnership helper; the pre-existing inline checks in both programs routes are refactored onto the same helper. App-code only — no schema, no API contract change, no data migration.
59 lines
2.1 KiB
TypeScript
59 lines
2.1 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
hashPassword,
|
|
verifyPassword,
|
|
verifyPasswordOrDummy,
|
|
} from '@/lib/auth';
|
|
|
|
// Pure-function bits of lib/auth.ts (no Prisma, no cookies).
|
|
|
|
describe('hashPassword / verifyPassword', () => {
|
|
it('verifies a correct password', async () => {
|
|
const hash = await hashPassword('correct horse battery staple');
|
|
expect(await verifyPassword('correct horse battery staple', hash)).toBe(true);
|
|
});
|
|
|
|
it('rejects an incorrect password', async () => {
|
|
const hash = await hashPassword('correct horse battery staple');
|
|
expect(await verifyPassword('wrong horse', hash)).toBe(false);
|
|
});
|
|
|
|
it('produces different hashes for the same password (salt-randomness)', async () => {
|
|
const a = await hashPassword('same input');
|
|
const b = await hashPassword('same input');
|
|
expect(a).not.toBe(b);
|
|
expect(await verifyPassword('same input', a)).toBe(true);
|
|
expect(await verifyPassword('same input', b)).toBe(true);
|
|
});
|
|
|
|
it('produces a bcrypt-format hash starting with $2', async () => {
|
|
const hash = await hashPassword('whatever');
|
|
expect(hash.startsWith('$2')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('verifyPasswordOrDummy', () => {
|
|
it('verifies a correct password against a real hash', async () => {
|
|
const hash = await hashPassword('hunter2');
|
|
expect(await verifyPasswordOrDummy('hunter2', hash)).toBe(true);
|
|
});
|
|
|
|
it('rejects a wrong password against a real hash', async () => {
|
|
const hash = await hashPassword('hunter2');
|
|
expect(await verifyPasswordOrDummy('wrong', hash)).toBe(false);
|
|
});
|
|
|
|
it('returns false without throwing when there is no user (null hash)', async () => {
|
|
expect(await verifyPasswordOrDummy('anything', null)).toBe(false);
|
|
});
|
|
|
|
it('still spends bcrypt time on the null-hash path (timing-oracle guard)', async () => {
|
|
// A real cost-10 bcrypt.compare is tens of ms; a path that skipped
|
|
// bcrypt would return in well under 1ms. 5ms is a safe lower bound,
|
|
// so this fails if someone removes the dummy compare.
|
|
const start = Date.now();
|
|
await verifyPasswordOrDummy('anything', null);
|
|
expect(Date.now() - start).toBeGreaterThan(5);
|
|
});
|
|
});
|