From a65915ea1142f6d305538ec22d0972940e62c4b0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 02:48:40 +0200 Subject: [PATCH] =?UTF-8?q?fix(auth):=20SB3=20=E2=80=94=20keep=20reset/ver?= =?UTF-8?q?ify=20tokens=20out=20of=20URL=20query=20strings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Secret email tokens now ride the URL fragment (#reset=/#verify=), never the query (?reset=/?token=): a fragment isn't sent to the server, so the token can't leak into proxy/access logs or a Referer header. - Reset: link → /#reset=; the SPA reads the token from location.hash and POSTs it (unchanged /password-reset/confirm). - Verify: link → /#verify=; new POST /auth/verify (token in body). The legacy GET /auth/verify?token= is kept so pre-deploy emails in flight still work until they expire. The SPA reads the fragment token, POSTs it, shows ok/invalid. - Welcome: read secret tokens from the fragment, status flags from the query; strip both after capture so nothing lingers in history. --- backend/app/auth.py | 23 ++++++++++++++++++++--- frontend/src/components/Welcome.tsx | 28 ++++++++++++++++++++++------ frontend/src/lib/api.ts | 3 +++ 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/backend/app/auth.py b/backend/app/auth.py index 97bb25d..700ff02 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -587,8 +587,10 @@ def register( upsert_pending_invite(db, email) # admin-approval gate if email_ok: raw = _issue_token(db, user, "verify", VERIFY_TTL) + # Fragment (#), not a query token: keeps the token out of proxy/access logs + Referer. + # The SPA reads the fragment and POSTs it to /auth/verify (SB3). background.add_task( - email_mod.send_verify_email, email, f"{_app_base()}/auth/verify?token={raw}" + email_mod.send_verify_email, email, f"{_app_base()}/#verify={raw}" ) if settings.admin_email_set: background.add_task( @@ -598,9 +600,22 @@ def register( return {"status": "ok"} +@router.post("/verify") +def verify_email_confirm(payload: dict, db: Session = Depends(get_db)) -> dict: + """Confirm an email-verification token the SPA read from the URL fragment (SB3). Uniform 400 + on any invalid/expired/used token.""" + user = _consume_token(db, payload.get("token"), "verify") + if user is None: + raise HTTPException(status_code=400, detail="This verification link is invalid or has expired.") + user.email_verified = True + db.commit() + return {"ok": True} + + @router.get("/verify") def verify_email(token: str, db: Session = Depends(get_db)): - """Confirm an email-verification link, then bounce to a friendly status on the app.""" + """Legacy query-token verification link (pre-SB3 emails still in flight). New emails use the + fragment + POST flow above; this bounces to a friendly status for older links until they expire.""" user = _consume_token(db, token, "verify") if user is None: return RedirectResponse(url="/?verify=invalid") @@ -662,7 +677,9 @@ def password_reset_request( user = db.execute(select(User).where(User.email == email)).scalar_one_or_none() if user is not None and user.password_hash and not user.is_demo: raw = _issue_token(db, user, "reset", RESET_TTL) - background.add_task(email_mod.send_password_reset, email, f"{_app_base()}/?reset={raw}") + # Token in the URL FRAGMENT (#), not the query (?): a fragment is never sent to the + # server, so it can't leak into proxy/access logs or a Referer header (SB3). + background.add_task(email_mod.send_password_reset, email, f"{_app_base()}/#reset={raw}") return {"status": "ok"} diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx index 56b3ce1..a071097 100644 --- a/frontend/src/components/Welcome.tsx +++ b/frontend/src/components/Welcome.tsx @@ -259,22 +259,38 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose function AuthCard() { const { t } = useTranslation(); - // Snapshot the redirect params ONCE so we can clean them from the address bar below without - // dismissing the banners/reset-mode they drive (those read this stable snapshot, not the URL). + // Snapshot the pre-login params ONCE (query + fragment) so we can clean the address bar below + // without dismissing the banners/reset-mode they drive. Secret tokens (reset, verify) ride the + // URL FRAGMENT so they never reach the server / proxy logs / Referer (SB3); plain status flags + // stay in the query string. const [params] = useState(() => new URLSearchParams(window.location.search)); - const resetToken = params.get("reset"); + const [hashParams] = useState(() => new URLSearchParams(window.location.hash.replace(/^#/, ""))); + const resetToken = hashParams.get("reset"); + const verifyToken = hashParams.get("verify"); // raw token → POST to confirm (new flow) const bounced = params.get("access"); // Google denied → "requested" | "denied" - const verify = params.get("verify"); // "ok" | "invalid" const login = params.get("login"); // Google login blocked → "suspended" const deleted = params.get("deleted"); // self-service account deletion just completed + // Verification outcome: from POSTing the fragment token (new flow), or the legacy + // ?verify=ok|invalid redirect (pre-SB3 emails still in flight). + const [verify, setVerify] = useState(() => params.get("verify")); - // Strip the query string so the address bar stays clean (http://host/) after we've captured it. + // Strip BOTH the query string and the fragment so the address bar stays clean and the token + // doesn't linger in history. useEffect(() => { - if (window.location.search) { + if (window.location.search || window.location.hash) { window.history.replaceState(null, "", window.location.pathname); } }, []); + // Confirm a fragment verification token by POSTing it (new SB3 flow). + useEffect(() => { + if (!verifyToken) return; + api + .verifyEmail(verifyToken) + .then(() => setVerify("ok")) + .catch(() => setVerify("invalid")); + }, [verifyToken]); + const [mode, setMode] = useState(resetToken ? "reset" : "signin"); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8b4c534..8ebeb24 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1391,6 +1391,9 @@ export const api = { req("/auth/password-reset/request", { method: "POST", body: JSON.stringify({ email }) }, { quiet: true }), confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> => req("/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) }, { quiet: true }), + // Confirm an email-verification token the SPA read from the URL fragment (SB3). + verifyEmail: (token: string): Promise<{ ok: boolean }> => + req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }), // Set or change the signed-in account's password (errors shown inline via quiet). setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> => req(