diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index 4ab93ae..2e49a96 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { login } from '@/lib/auth-actions'; +import { sanitizeNextPath } from '@/lib/safe-next'; import { BRAND_YELLOW } from '@/components/brand/brand'; import { TesseraLogo } from '@/components/brand/tessera-logo'; import { DesktopDownloadLinks } from '@/components/desktop/desktop-download-links'; @@ -30,7 +31,12 @@ export default function LoginPage() { startTransition(async () => { const result = await login(formData); if (result.success) { - window.location.href = '/'; + // `next` erst beim Absenden aus window.location.search lesen statt + // per useSearchParams(): der Hook verlangt in Next 15 eine + // Suspense-Grenze, sonst bricht `next build` fuer die statisch + // vorgerenderte Anmeldeseite ab (quick-260917-gyd). + const next = new URLSearchParams(window.location.search).get('next'); + window.location.href = sanitizeNextPath(next); } else { setError(result.error ?? 'invalidCredentials'); } diff --git a/apps/web/src/lib/safe-next.test.ts b/apps/web/src/lib/safe-next.test.ts new file mode 100644 index 0000000..f288d79 --- /dev/null +++ b/apps/web/src/lib/safe-next.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { buildNextParam, sanitizeNextPath } from './safe-next'; + +/** + * safe-next.test — Ruecksprung nach Anmeldung (quick-260917-gyd). + * + * Reine Funktionen ohne DOM-/Node-Abhaengigkeit: `buildNextParam` baut den + * `next`-Wert aus Pfad + Query, `sanitizeNextPath` prueft ihn beim Absenden + * der Anmeldeseite gegen Open-Redirect-Versuche. + */ + +describe('buildNextParam', () => { + it('Test 1: Pfad ohne Query bleibt unveraendert', () => { + expect(buildNextParam('/settings/general/desktop', '')).toBe( + '/settings/general/desktop', + ); + }); + + it('Test 2: _rsc wird entfernt, andere Parameter bleiben', () => { + expect(buildNextParam('/modules/tender-radar', '?tab=alerts&_rsc=1abc')).toBe( + '/modules/tender-radar?tab=alerts', + ); + }); + + it('Test 3: leere Query nach Entfernen von _rsc bleibt ohne "?"', () => { + expect(buildNextParam('/modules/tender-radar', '?_rsc=1abc')).toBe( + '/modules/tender-radar', + ); + }); + + it('Test 4: Startseite und Anmeldeseite liefern null (kein Ruecksprung auf sich selbst)', () => { + expect(buildNextParam('/', '')).toBeNull(); + expect(buildNextParam('/login', '?next=%2Fx')).toBeNull(); + expect(buildNextParam('/login/', '')).toBeNull(); + }); +}); + +describe('sanitizeNextPath', () => { + it('Test 5: gueltige relative Pfade bleiben unveraendert', () => { + expect(sanitizeNextPath('/settings/general/desktop')).toBe( + '/settings/general/desktop', + ); + expect(sanitizeNextPath('/modules/x?tab=1')).toBe('/modules/x?tab=1'); + }); + + it('Test 6: fehlende oder falsch typisierte Werte fallen auf "/" zurueck', () => { + expect(sanitizeNextPath(null)).toBe('/'); + expect(sanitizeNextPath(undefined)).toBe('/'); + expect(sanitizeNextPath('')).toBe('/'); + expect(sanitizeNextPath(42)).toBe('/'); + }); + + it('Test 7: protokoll-relative und Schema-Adressen fallen auf "/" zurueck', () => { + expect(sanitizeNextPath('//evil.example')).toBe('/'); + expect(sanitizeNextPath('/\\evil.example')).toBe('/'); + expect(sanitizeNextPath('https://evil.example/x')).toBe('/'); + expect(sanitizeNextPath('javascript:alert(1)')).toBe('/'); + }); + + it('Test 8: fehlender Slash, Steuerzeichen und Ueberlaenge fallen auf "/" zurueck', () => { + expect(sanitizeNextPath('settings')).toBe('/'); + expect(sanitizeNextPath('/foo\nbar')).toBe('/'); + expect(sanitizeNextPath('/a b')).toBe('/'); + expect(sanitizeNextPath('/x'.padEnd(3000, 'y'))).toBe('/'); + }); + + it('Test 9: die Anmeldeseite selbst ist kein gueltiges Ziel, "/loginhistory" schon', () => { + expect(sanitizeNextPath('/login')).toBe('/'); + expect(sanitizeNextPath('/login?next=/x')).toBe('/'); + expect(sanitizeNextPath('/login/')).toBe('/'); + expect(sanitizeNextPath('/loginhistory')).toBe('/loginhistory'); + }); +}); diff --git a/apps/web/src/lib/safe-next.ts b/apps/web/src/lib/safe-next.ts new file mode 100644 index 0000000..4daa681 Binary files /dev/null and b/apps/web/src/lib/safe-next.ts differ diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index aea60c7..5a55670 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { jwtVerify } from 'jose'; +import { buildNextParam } from '@/lib/safe-next'; /** * Next.js middleware for frontend route protection (Pattern 4). @@ -11,6 +12,21 @@ import { jwtVerify } from 'jose'; const publicRoutes = ['/login', '/reset-password']; +/** + * Umleitung zur Anmeldeseite mit `next`-Parameter (quick-260917-gyd): Pfad + * + Query der urspruenglich angeforderten Seite wandern mit, damit die + * Anmeldeseite nach erfolgreichem Login dorthin zurueckspringen kann + * (Ausloeser: Link "Update herunterladen" der Desktop-App). + */ +function redirectToLogin(req: NextRequest): NextResponse { + const url = new URL('/login', req.nextUrl); + const next = buildNextParam(req.nextUrl.pathname, req.nextUrl.search); + if (next !== null) { + url.searchParams.set('next', next); + } + return NextResponse.redirect(url); +} + function getSecret() { const secret = process.env.JWT_SECRET || process.env.SESSION_SECRET; if (!secret) { @@ -43,7 +59,7 @@ export async function middleware(req: NextRequest) { const session = req.cookies.get('session')?.value; if (!session) { - return NextResponse.redirect(new URL('/login', req.nextUrl)); + return redirectToLogin(req); } try { @@ -62,7 +78,7 @@ export async function middleware(req: NextRequest) { return NextResponse.next(); } catch { // JWT verification failed -- clear stale cookie and redirect to login - const response = NextResponse.redirect(new URL('/login', req.nextUrl)); + const response = redirectToLogin(req); response.cookies.delete('session'); return response; }