fix(web): address auth review findings (open redirect, dead code, HMAC per-req)

Pre-merge review of the P0 branch:
- Open redirect: login redirected to an unvalidated ?next param, so a crafted
  /login?next=https://evil (or //evil) bounced off-site after login. Now only
  same-origin relative paths are honored (else fall back to "/").
- Remove the unused useRouter import/binding in the login form.
- Memoize expectedToken() keyed on (password, secret) so middleware stops
  recomputing an HMAC on every gated request.

Deferred (low-value/latent, noted): Soulseek cover.jpg isn't retrieved into
staging (UI still shows Cover Art Archive art); _retrieve's basename-keyed map
could undercount same-named files but source_refs come from a single slskd dir;
SignOut visibility rides a server-env read (cosmetic).

web 138 tests green, tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 10:25:37 +02:00
parent 8cd5392ec3
commit 446a4bc2d2
2 changed files with 13 additions and 4 deletions
+5 -3
View File
@@ -1,12 +1,14 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
export function LoginForm() { export function LoginForm() {
const router = useRouter();
const params = useSearchParams(); const params = useSearchParams();
const next = params.get("next") || "/"; // Only allow same-origin relative paths, so a crafted ?next=https://evil or ?next=//evil
// can't turn login into an open redirect.
const raw = params.get("next") || "/";
const next = raw.startsWith("/") && !raw.startsWith("//") ? raw : "/";
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
+8 -1
View File
@@ -13,11 +13,16 @@ function toB64(bytes: Uint8Array): string {
return btoa(s); return btoa(s);
} }
// Memoize the derived token so the middleware doesn't recompute an HMAC on every request
// (password/secret don't change at runtime; keyed on both so a change still recomputes).
let _cache: { password: string; secret: string; token: string } | null = null;
/** The token a valid session cookie must carry, or "" when auth is disabled (no password). */ /** The token a valid session cookie must carry, or "" when auth is disabled (no password). */
export async function expectedToken(): Promise<string> { export async function expectedToken(): Promise<string> {
const password = process.env.LYRA_PASSWORD; const password = process.env.LYRA_PASSWORD;
if (!password) return ""; if (!password) return "";
const secret = process.env.LYRA_SECRET_KEY ?? "lyra"; const secret = process.env.LYRA_SECRET_KEY ?? "lyra";
if (_cache && _cache.password === password && _cache.secret === secret) return _cache.token;
const enc = new TextEncoder(); const enc = new TextEncoder();
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
"raw", "raw",
@@ -27,7 +32,9 @@ export async function expectedToken(): Promise<string> {
["sign"], ["sign"],
); );
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(password)); const sig = await crypto.subtle.sign("HMAC", key, enc.encode(password));
return toB64(new Uint8Array(sig)); const token = toB64(new Uint8Array(sig));
_cache = { password, secret, token };
return token;
} }
/** Constant-time string compare (both operands are our own derived tokens). */ /** Constant-time string compare (both operands are our own derived tokens). */