446a4bc2d2
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>
59 lines
1.7 KiB
TypeScript
59 lines
1.7 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useSearchParams } from "next/navigation";
|
|
|
|
export function LoginForm() {
|
|
const params = useSearchParams();
|
|
// 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 [error, setError] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
async function submit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
const res = await fetch("/api/auth", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ password }),
|
|
});
|
|
if (res.ok) {
|
|
// full navigation so middleware re-evaluates with the new cookie
|
|
window.location.assign(next);
|
|
return;
|
|
}
|
|
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
|
setError(data.error || "Login failed");
|
|
setBusy(false);
|
|
} catch {
|
|
setError("Login failed");
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<form className="login-form" onSubmit={submit}>
|
|
<label className="field">
|
|
<span>Password</span>
|
|
<input
|
|
type="password"
|
|
autoFocus
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder="shared password"
|
|
/>
|
|
</label>
|
|
{error ? <p className="login-error">{error}</p> : null}
|
|
<button className="btn accent" type="submit" disabled={busy || !password}>
|
|
{busy ? "Entering…" : "Enter"}
|
|
</button>
|
|
</form>
|
|
);
|
|
}
|