From 58bf44db88402a99ca5be72a0981b7d29fd1a6a9 Mon Sep 17 00:00:00 2001 From: brobert Date: Mon, 13 Oct 2025 00:20:31 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20a=C3=B1adir=20gate=20de=20JS=20en=20/lo?= =?UTF-8?q?gin=20para=20evitar=20canje=20prematuro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- apps/web/src/routes/login/+server.ts | 56 +++++++++++++++++++++------- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/apps/web/src/routes/login/+server.ts b/apps/web/src/routes/login/+server.ts index 785108f..9b4f397 100644 --- a/apps/web/src/routes/login/+server.ts +++ b/apps/web/src/routes/login/+server.ts @@ -17,8 +17,8 @@ function escapeHtml(s: string): string { .replaceAll("'", '''); } -// GET: página intermedia para evitar que los “link preview bots” canjeen el token. -// Muestra un formulario que hace POST automático para canjear el token. +// GET: página intermedia que requiere una interacción mínima y establece una cookie "login_intent" vía JS. +// Evita que bots de previsualización canjeen el token antes de que el usuario haga clic. export const GET: RequestHandler = async (event) => { const token = event.url.searchParams.get('token')?.trim(); if (!token) { @@ -26,26 +26,45 @@ export const GET: RequestHandler = async (event) => { return new Response('Falta el token', { status: 400 }); } + // Nonce para "gate de JS" + const nonce = randomTokenBase64Url(18); + const html = ` - Accediendo… + Acceder - + - -
- - -
+
+

Acceso seguro

+

Para continuar, pulsa “Continuar”. Si no funciona, asegúrate de abrir este enlace en tu navegador.

+
+ + + +
+ +
`; @@ -68,6 +87,14 @@ export const POST: RequestHandler = async (event) => { return new Response('Falta el token', { status: 400 }); } + // Validación del "gate de JS": cookie + nonce deben coincidir + const nonce = String(form.get('nonce') || '').trim(); + const loginIntent = event.cookies.get('login_intent') || ''; + if (!nonce || !loginIntent || nonce !== loginIntent) { + console.warn('[web/login] Falta o no cuadra login_intent; posible previsualizador sin JS.'); + return new Response('Solicitud inválida', { status: 400 }); + } + const tokenHash = await sha256Hex(token); const db = await getDb(); @@ -134,6 +161,9 @@ export const POST: RequestHandler = async (event) => { maxAge: Math.floor(sessionIdleTtlMs / 1000) }); + // Eliminar cookie de intento (ya no es necesaria) + event.cookies.delete('login_intent', { path: '/' }); + // Redirigir a /app throw redirect(303, '/app'); };