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) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { login } from '@/lib/auth-actions';
|
import { login } from '@/lib/auth-actions';
|
||||||
|
import { sanitizeNextPath } from '@/lib/safe-next';
|
||||||
import { BRAND_YELLOW } from '@/components/brand/brand';
|
import { BRAND_YELLOW } from '@/components/brand/brand';
|
||||||
import { TesseraLogo } from '@/components/brand/tessera-logo';
|
import { TesseraLogo } from '@/components/brand/tessera-logo';
|
||||||
import { DesktopDownloadLinks } from '@/components/desktop/desktop-download-links';
|
import { DesktopDownloadLinks } from '@/components/desktop/desktop-download-links';
|
||||||
@@ -30,7 +31,12 @@ export default function LoginPage() {
|
|||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await login(formData);
|
const result = await login(formData);
|
||||||
if (result.success) {
|
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 {
|
} else {
|
||||||
setError(result.error ?? 'invalidCredentials');
|
setError(result.error ?? 'invalidCredentials');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Binary file not shown.
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { jwtVerify } from 'jose';
|
import { jwtVerify } from 'jose';
|
||||||
|
import { buildNextParam } from '@/lib/safe-next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Next.js middleware for frontend route protection (Pattern 4).
|
* Next.js middleware for frontend route protection (Pattern 4).
|
||||||
@@ -11,6 +12,21 @@ import { jwtVerify } from 'jose';
|
|||||||
|
|
||||||
const publicRoutes = ['/login', '/reset-password'];
|
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() {
|
function getSecret() {
|
||||||
const secret = process.env.JWT_SECRET || process.env.SESSION_SECRET;
|
const secret = process.env.JWT_SECRET || process.env.SESSION_SECRET;
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
@@ -43,7 +59,7 @@ export async function middleware(req: NextRequest) {
|
|||||||
const session = req.cookies.get('session')?.value;
|
const session = req.cookies.get('session')?.value;
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.redirect(new URL('/login', req.nextUrl));
|
return redirectToLogin(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -62,7 +78,7 @@ export async function middleware(req: NextRequest) {
|
|||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
} catch {
|
} catch {
|
||||||
// JWT verification failed -- clear stale cookie and redirect to login
|
// 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');
|
response.cookies.delete('session');
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user