Files
InsanityLab-web/.superpowers/sdd/review-5ce10da..6b85872.diff
Adriano b913a2bf90 registri dei piani in git (AI-OS#309)
Refs gitea:Adriano/AI-OS#310

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:36:26 +02:00

109 lines
3.9 KiB
Diff

# Review package: 5ce10da..6b85872
## Commits
6b85872 feat(login): pagina pubblica /login con next sicuro
## Files changed
src/lib/safe-next.ts | 7 +++++++
src/pages/login.astro | 53 +++++++++++++++++++++++++++++++++++++++++++++++++
tests/safe-next.test.ts | 18 +++++++++++++++++
3 files changed, 78 insertions(+)
## Diff
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.';
+ }
+}
+---
+<Base title="Accedi" description="Area riservata InsanityLab">
+ <section class="section">
+ <div class="container login-wrap">
+ <h1 class="section-heading">Accedi</h1>
+ {error && <p class="login-err">{error}</p>}
+ <form method="post" class="login-form">
+ <input type="hidden" name="next" value={dest} />
+ <input class="login-field" name="username" placeholder="Utente" required autocomplete="username" />
+ <input class="login-field" type="password" name="password" placeholder="Password" required autocomplete="current-password" />
+ <button class="btn btn--dark" type="submit">Entra</button>
+ </form>
+ </div>
+ </section>
+</Base>
+
+<style>
+ .login-wrap { max-width: 380px; margin: 0 auto; }
+ .login-form { display: grid; gap: 14px; margin-top: 20px; }
+ .login-field { padding: 12px; border: 1px solid #ccc; font: inherit; }
+ .login-err { color: #a33; }
+</style>
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('/');
+ });
+});