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') @Post('reset-password')
@HttpCode(200) @HttpCode(200)
resetPassword(@Body() body: unknown, @Req() req: Request) { 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 ip = getClientIp(req);
const result = this.auth.resetPassword(body); const result = this.auth.resetPassword(body);
if (result.error) { 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 oldReservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(sourceTripId) as any[];
const insertReservation = db.prepare(` const insertReservation = db.prepare(`
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, accommodation_id, title, reservation_time, reservation_end_time, 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) location, confirmation_number, notes, status, type, metadata, day_plan_position, needs_review)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
for (const r of oldReservations) { for (const r of oldReservations) {
insertReservation.run(newTripId, insertReservation.run(newTripId,
r.day_id ? (dayMap.get(r.day_id) ?? null) : null, 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.place_id ? (placeMap.get(r.place_id) ?? null) : null,
r.assignment_id ? (assignmentMap.get(r.assignment_id) ?? null) : null, r.assignment_id ? (assignmentMap.get(r.assignment_id) ?? null) : null,
r.accommodation_id ? (accomMap.get(r.accommodation_id) ?? null) : null, r.accommodation_id ? (accomMap.get(r.accommodation_id) ?? null) : null,
r.title, r.reservation_time, r.reservation_end_time, r.title, r.reservation_time, r.reservation_end_time,
r.location, r.confirmation_number, r.notes, r.status, r.type, 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[]; 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); 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);
});
}); });
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────