From 59a3c0897afa606ac377bc050966d60ef3963883 Mon Sep 17 00:00:00 2001 From: "garzasecure@pm.me" Date: Sun, 19 Apr 2026 03:20:15 +0000 Subject: [PATCH 1/2] Harden login: validate next param, use timing-safe HMAC verify --- app/login/page.tsx | 19 +++++++++++++++---- lib/session.ts | 19 +++++++++++++++++-- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/app/login/page.tsx b/app/login/page.tsx index 5416abd..45d1716 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -6,6 +6,16 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com export const dynamic = "force-dynamic"; +// Only allow redirecting back to a same-origin relative path. Anything else +// (absolute URLs, protocol-relative `//evil.com`, backslashes, etc.) falls +// back to `/` to prevent open-redirect abuse. +function safeNext(next: string | undefined | null): string { + if (!next) return "/"; + if (!next.startsWith("/")) return "/"; + if (next.startsWith("//") || next.startsWith("/\\")) return "/"; + return next; +} + async function doLogin(formData: FormData) { "use server"; const password = String(formData.get("password") ?? ""); @@ -13,8 +23,8 @@ async function doLogin(formData: FormData) { if (!ok) { redirect("/login?error=1"); } - const next = String(formData.get("next") ?? "/"); - redirect(next || "/"); + const next = safeNext(String(formData.get("next") ?? "/")); + redirect(next); } export default async function LoginPage({ @@ -23,8 +33,9 @@ export default async function LoginPage({ searchParams: Promise<{ error?: string; next?: string }>; }) { const params = await searchParams; + const next = safeNext(params.next); if (await isAuthed()) { - redirect(params.next || "/"); + redirect(next); } return (
@@ -35,7 +46,7 @@ export default async function LoginPage({
- + ) { return env.APP_SESSION_SECRET || env.APP_PASSWORD || "dev-insecure-secret"; } @@ -37,8 +48,12 @@ export async function verify(signed: string | undefined, secret: string) { const idx = signed.lastIndexOf("."); if (idx < 0) return false; const value = signed.slice(0, idx); - const expected = await sign(value, secret); - return expected === signed; + const sigHex = signed.slice(idx + 1); + const sig = fromHex(sigHex); + if (!sig) return false; + const key = await hmacKey(secret); + // crypto.subtle.verify performs a timing-safe comparison internally. + return crypto.subtle.verify("HMAC", key, sig, enc.encode(value)); } export const SESSION_COOKIE = "kilo_control_session"; From aa8bd44e52679283421abfaa9e104f3a2fa7baf7 Mon Sep 17 00:00:00 2001 From: "garzasecure@pm.me" Date: Sun, 19 Apr 2026 04:17:26 +0000 Subject: [PATCH 2/2] Strict hex validation in fromHex (regex, reject empty) --- lib/session.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/session.ts b/lib/session.ts index 020dea8..14b16b8 100644 --- a/lib/session.ts +++ b/lib/session.ts @@ -23,12 +23,11 @@ function toHex(buf: ArrayBuffer) { } function fromHex(hex: string) { - if (hex.length % 2 !== 0) return null; + if (hex.length === 0 || hex.length % 2 !== 0) return null; + if (!/^[0-9a-fA-F]+$/.test(hex)) return null; const out = new Uint8Array(hex.length / 2); for (let i = 0; i < out.length; i++) { - const byte = parseInt(hex.slice(i * 2, i * 2 + 2), 16); - if (Number.isNaN(byte)) return null; - out[i] = byte; + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); } return out; }