diff --git a/src/lib/safe-next.ts b/src/lib/safe-next.ts new file mode 100644 index 0000000..b9b9a6c --- /dev/null +++ b/src/lib/safe-next.ts @@ -0,0 +1,7 @@ +// Path interno sicuro per il redirect post-login: deve iniziare con "/", non essere +// protocol-relative ("//"), non puntare alle API. Tutto il resto → home. +export function safeNext(next: string | null): string { + if (!next || !next.startsWith('/') || next.startsWith('//')) return '/'; + if (next.startsWith('/api/')) return '/'; + return next; +} diff --git a/src/pages/login.astro b/src/pages/login.astro new file mode 100644 index 0000000..2009e39 --- /dev/null +++ b/src/pages/login.astro @@ -0,0 +1,53 @@ +--- +import Base from '../layouts/Base.astro'; +import { getDb } from '../lib/db'; +import { login, getSessionUser, SESSION_COOKIE } from '../lib/auth'; +import { rateLimit } from '../lib/rate-limit'; +import { safeNext } from '../lib/safe-next'; +export const prerender = false; + +const dest = safeNext(Astro.url.searchParams.get('next')); + +// Già loggato → via dal login. +const existing = Astro.cookies.get(SESSION_COOKIE)?.value; +if (existing && getSessionUser(getDb(), existing)) return Astro.redirect(dest); + +let error = ''; +if (Astro.request.method === 'POST') { + if (!rateLimit(`login:${Astro.clientAddress}`, 5, 15 * 60 * 1000)) { + error = 'Troppi tentativi: riprova tra 15 minuti.'; + } else { + const form = await Astro.request.formData(); + const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? '')); + if (token) { + Astro.cookies.set(SESSION_COOKIE, token, { + httpOnly: true, sameSite: 'lax', path: '/', + secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600, + }); + return Astro.redirect(safeNext(String(form.get('next') ?? '') || dest)); + } + error = 'Credenziali non valide.'; + } +} +--- + +
+
+

Accedi

+ {error && } + +
+
+ + + diff --git a/tests/safe-next.test.ts b/tests/safe-next.test.ts new file mode 100644 index 0000000..9435af9 --- /dev/null +++ b/tests/safe-next.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; +import { safeNext } from '../src/lib/safe-next'; + +describe('safeNext', () => { + it('accetta path interni', () => { + expect(safeNext('/about')).toBe('/about'); + expect(safeNext('/blog/x?y=1')).toBe('/blog/x?y=1'); + }); + it('rifiuta esterni, protocol-relative, api e vuoti', () => { + expect(safeNext(null)).toBe('/'); + expect(safeNext('')).toBe('/'); + expect(safeNext('//evil.com')).toBe('/'); + expect(safeNext('https://evil.com')).toBe('/'); + expect(safeNext('/api/admin/posts')).toBe('/'); + expect(safeNext('javascript:alert(1)')).toBe('/'); + expect(safeNext('not-a-path')).toBe('/'); + }); +});