From 4b279eac70c4cd7dcf22d327961838b73758594f Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 17 Sep 2026 12:26:14 +0200 Subject: [PATCH] feat(quick-260917-gyd): Ruecksprung nach Anmeldung auf urspruenglich angeforderte Seite - safe-next.ts: buildNextParam()/sanitizeNextPath() als reine, getestete Funktionen (Open-Redirect-Schutz) - middleware.ts: haengt next-Parameter an beide Login-Umleitungen (fehlendes Cookie, ungueltige Signatur) - login/page.tsx: springt nach erfolgreicher Anmeldung auf den bereinigten next-Wert Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/app/(auth)/login/page.tsx | 8 ++- apps/web/src/lib/safe-next.test.ts | 73 +++++++++++++++++++++++++ apps/web/src/lib/safe-next.ts | Bin 0 -> 2458 bytes apps/web/src/middleware.ts | 20 ++++++- 4 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/lib/safe-next.test.ts create mode 100644 apps/web/src/lib/safe-next.ts 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 0000000000000000000000000000000000000000..4daa68164a5b235203c5ed73f82c94e7ad2c20f8 GIT binary patch literal 2458 zcmaJ@?QYvf5bSTgM7L=GQGqDhY0hItlOwCA?%Y_!z}0ch0d~)=gKlgv(@i`cRf<2Bh>)dc{QWQ zrDo$PDyv+Zq*gg&&qZJ26s!z9aAr;PUclJ`*fzVb!XOG!H~DClL) zCLVF7EsuSCFfJN@NztK{9IvhSHprofQn7PhGO5P7aRDUGl^Vbt9t`A`OXc-B)BXfd zC~NGv7!bnoOvT%o#eTGEu33Vlh4;1FNRls~|4rMbrEk@_a$~D&zY$iEg|%mE=nV0- zMOc*%f>18mjah*7j-j2PL(l;zCh@e z^h1JW>BaV&)4k!F!_)ok z!)K?v!@Z}6&uEM8ul@Y%W+zGLk%Ht(<%&MN^Rn4xy0Cs%Qr#b8LO74(GDh*b?`eW(C}me98^4;BAQJ8pVbaj}3F!5do?16fDH3%K-a0B2n??M6Wz~#@Cy+ln zcpUGkbDcwIXtgpE3lA8HrA(i&sZgGDtCB^=(QexkN9SzI%ChMVpaDi^BsNW1rY%b7 zMgj@bE7a{YV8{uQaa^8(-Y89i|I$9B);e)87^J-*w+zNB03g?AK}_suDiSoXi81F@ zS>orlp(A}rI(#99MOrhK!sx8gW!731)khto8lE+11kC8IHaR>GJPH=OS`nv)kMxnG zF8Y8?dO~vkDgte7ZBaMr(#Matt%rhcd>lBhtaq<9CP#Nsbhn4|y^w@pe&4{Tl6Diz zpE`K7x~hdh2Q3+%)2q?$K|md7bx~VFPmZ+v1{s%NJL-xey5U0`=3S7})^eM#4nnCB zw&3t0eY@!)rFTCp8}{kp-49{;Q%W1FHg0x+aG~aX+wjCw%aTY~W+H$Rh76b&?yiaa zN;|qR5DP>FvEoRe`?5i>@l?ELjOYD`tP?+F?9NWgah}AZ;Yg2 z7|FNKV~Up7hQIw*AO!g8UPx|S399kc4CY59nKr#my2O;j;9RB5l$`)3Ld{u-l-s~m z*q$D@*8)Ygt$a0w730;Fb^Ut6k))`cPh}Lep=1`Xrgr?NvRdKl=!e1f$X0VFOrNot zs8X(L{2j=Ar=TDCw;|XN?LAsMX&DLr6M~M`Pp&!gtCi#9?l-zE#=beT2k3>55V!Y@ zqf2A@0ygxprV#JyQu`=5didi>(x>$vpbE2+M!eMS45MQ;B7&Xd!|mhY8weJDMP6~@ Ef4eCrdH?_b literal 0 HcmV?d00001 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; }