From 4c9631998ff53a99016743f44d4799b46ca74124 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 31 May 2026 13:38:02 +0200 Subject: [PATCH] Restore the reset-password rate limit and fix copyTrip reservation links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- server/src/nest/auth/auth-public.controller.ts | 3 +++ server/src/services/tripService.ts | 11 +++++++---- server/tests/integration/auth.test.ts | 12 ++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/server/src/nest/auth/auth-public.controller.ts b/server/src/nest/auth/auth-public.controller.ts index 67d8799a..9ee059e6 100644 --- a/server/src/nest/auth/auth-public.controller.ts +++ b/server/src/nest/auth/auth-public.controller.ts @@ -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) { diff --git a/server/src/services/tripService.ts b/server/src/services/tripService.ts index aa9a5760..6138e45b 100644 --- a/server/src/services/tripService.ts +++ b/server/src/services/tripService.ts @@ -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[]; diff --git a/server/tests/integration/auth.test.ts b/server/tests/integration/auth.test.ts index 6387103e..9a4f4451 100644 --- a/server/tests/integration/auth.test.ts +++ b/server/tests/integration/auth.test.ts @@ -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); + }); }); // ─────────────────────────────────────────────────────────────────────────────