Restore the reset-password rate limit and fix copyTrip reservation links

Two correctness/security gaps the NestJS migration introduced:

- POST /api/auth/reset-password lost its per-IP rate limiter. Restore it
  (5 attempts / 15 min on a dedicated bucket, same as the old resetLimiter)
  so reset tokens can't be brute-forced unthrottled. Covered by AUTH-019.
- copyTripById did not copy reservations.end_day_id (a day reference — now
  remapped through dayMap like day_id) or needs_review, so a duplicated trip
  lost multi-day transport end-day links and reset the review flag.
This commit is contained in:
Maurice
2026-05-31 13:38:02 +02:00
parent bfe52579df
commit 4c9631998f
3 changed files with 22 additions and 4 deletions
@@ -121,6 +121,9 @@ export class AuthPublicController {
@Post('reset-password')
@HttpCode(200)
resetPassword(@Body() body: unknown, @Req() req: Request) {
// Per-IP brute-force guard, parity with the legacy resetLimiter (5 / 15 min on
// a dedicated bucket) — without it reset tokens could be guessed unthrottled.
this.limit('reset', req, 5);
const ip = getClientIp(req);
const result = this.auth.resetPassword(body);
if (result.error) {
+7 -4
View File
@@ -636,19 +636,22 @@ export function copyTripById(sourceTripId: string | number, newOwnerId: number,
const oldReservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(sourceTripId) as any[];
const insertReservation = db.prepare(`
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, accommodation_id, title, reservation_time, reservation_end_time,
location, confirmation_number, notes, status, type, metadata, day_plan_position)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, accommodation_id, title, reservation_time, reservation_end_time,
location, confirmation_number, notes, status, type, metadata, day_plan_position, needs_review)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const r of oldReservations) {
insertReservation.run(newTripId,
r.day_id ? (dayMap.get(r.day_id) ?? null) : null,
// end_day_id is a day reference too (multi-day transport) — remap it like
// day_id, otherwise the duplicated trip loses the reservation's end-day link.
r.end_day_id ? (dayMap.get(r.end_day_id) ?? null) : null,
r.place_id ? (placeMap.get(r.place_id) ?? null) : null,
r.assignment_id ? (assignmentMap.get(r.assignment_id) ?? null) : null,
r.accommodation_id ? (accomMap.get(r.accommodation_id) ?? null) : null,
r.title, r.reservation_time, r.reservation_end_time,
r.location, r.confirmation_number, r.notes, r.status, r.type,
r.metadata, r.day_plan_position);
r.metadata, r.day_plan_position, r.needs_review ?? 0);
}
const oldBudget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(sourceTripId) as any[];
+12
View File
@@ -835,6 +835,18 @@ describe('Rate limiting', () => {
}
expect(lastStatus).toBe(429);
});
it('AUTH-019 — reset-password endpoint rate-limits after 5 attempts (parity with the legacy resetLimiter)', async () => {
let lastStatus = 0;
for (let i = 0; i <= 5; i++) {
const res = await request(app)
.post('/api/auth/reset-password')
.send({ token: 'badtoken', new_password: 'NewPassw0rd!' });
lastStatus = res.status;
if (lastStatus === 429) break;
}
expect(lastStatus).toBe(429);
});
});
// ─────────────────────────────────────────────────────────────────────────────