From a65915ea1142f6d305538ec22d0972940e62c4b0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 02:48:40 +0200 Subject: [PATCH 1/6] =?UTF-8?q?fix(auth):=20SB3=20=E2=80=94=20keep=20reset?= =?UTF-8?q?/verify=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( From 95d154957071027c65b86ce7597bdf70e22fd3ea Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 03:00:16 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat(auth):=20SA4=20=E2=80=94=20server-side?= =?UTF-8?q?=20session=20revocation=20via=20per-user=20session=20epoch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed client-side session cookies had no server-side kill switch: logout + password reset couldn't evict a stolen/copied cookie (valid until expiry). Add User.session_epoch (migration 0053), record it in the cookie at every login, and reject in current_user any cookie whose recorded epoch is behind the account's current one. - Bump the epoch on: password reset (kills ALL sessions — a reset is a compromise response), password change + a new 'Log out other sessions' action (both re-stamp the CURRENT cookie so the caller stays signed in, evicting only the others). - Per-account epoch map in the session so one account's revocation doesn't evict the other signed-in accounts in the same browser wallet. - Missing epoch (pre-SA4 cookie) is treated as 0, so the first bump revokes grandfathered sessions too. - New POST /auth/logout-others + a Settings → Account 'Active sessions' button (trilingual). --- .../versions/0053_user_session_epoch.py | 27 ++++++++++ backend/app/auth.py | 52 ++++++++++++++++++- backend/app/models.py | 5 ++ frontend/src/components/SettingsPanel.tsx | 43 +++++++++++++++ frontend/src/i18n/locales/de/settings.json | 9 ++++ frontend/src/i18n/locales/en/settings.json | 9 ++++ frontend/src/i18n/locales/hu/settings.json | 9 ++++ frontend/src/lib/api.ts | 2 + 8 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/0053_user_session_epoch.py diff --git a/backend/alembic/versions/0053_user_session_epoch.py b/backend/alembic/versions/0053_user_session_epoch.py new file mode 100644 index 0000000..6f5c838 --- /dev/null +++ b/backend/alembic/versions/0053_user_session_epoch.py @@ -0,0 +1,27 @@ +"""User.session_epoch for server-side session revocation (SA4) + +A monotonic per-user counter. A signed session cookie records the epoch it was minted under; +current_user rejects any cookie whose epoch is stale. Bumped on password reset, password change, +or "log out everywhere" — so a stolen/copied client-side cookie dies on those events instead of +staying valid until it expires. Existing rows default to 0 (matching a fresh cookie's absent/0 epoch). +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0053_user_session_epoch" +down_revision: Union[str, None] = "0052_plex_show_meta" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column("session_epoch", sa.Integer(), nullable=False, server_default="0"), + ) + + +def downgrade() -> None: + op.drop_column("users", "session_epoch") diff --git a/backend/app/auth.py b/backend/app/auth.py index 700ff02..59d616a 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -341,6 +341,7 @@ async def callback( accounts.append(user.id) request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] request.session["user_id"] = user.id + _remember_epoch(request, user) log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role) return RedirectResponse(url="/") @@ -493,6 +494,7 @@ def demo_login(payload: dict, request: Request, db: Session = Depends(get_db)) - # can't switch to / act as a previously signed-in account via the multi-account header. request.session.clear() request.session["user_id"] = demo.id + _remember_epoch(request, demo) log.info("Demo login (key=%s, demo_id=%s)", email, demo.id) return {"authenticated": True} return {"authenticated": False} @@ -505,6 +507,7 @@ def _establish_session(request: Request, user: User) -> None: accounts.append(user.id) request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] request.session["user_id"] = user.id + _remember_epoch(request, user) def _app_base() -> str: @@ -697,6 +700,7 @@ def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict if user is None: raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.") user.password_hash = hash_password(new_password) + bump_session_epoch(user) # SA4: a reset (often a compromise response) kills all existing sessions. # Burn any other outstanding reset tokens for this user. for row in db.execute( select(AuthToken).where( @@ -739,6 +743,21 @@ def resolved_user_id(request: Request) -> tuple[int | None, bool]: return default_id, True +def _remember_epoch(request: Request, user: User) -> None: + """Record this account's current `session_epoch` in the signed cookie (SA4), so `current_user` + can later reject the cookie if the account's epoch was bumped (password reset/change, or + "log out everywhere"). Per-account so one account's revocation doesn't evict the others.""" + epochs = dict(request.session.get("epochs") or {}) + epochs[str(user.id)] = user.session_epoch + request.session["epochs"] = epochs + + +def bump_session_epoch(user: User) -> None: + """Invalidate every EXISTING signed cookie for this account by advancing its epoch (SA4). Cookies + minted before the bump carry a now-stale epoch and are rejected by `current_user`.""" + user.session_epoch = (user.session_epoch or 0) + 1 + + def current_user(request: Request, db: Session = Depends(get_db)) -> User: user_id, is_default = resolved_user_id(request) if not user_id: @@ -751,6 +770,14 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User: if is_default: request.session.clear() raise HTTPException(status_code=401, detail="Not authenticated") + # SA4: reject a cookie whose recorded epoch is behind the account's current one (revoked by a + # password reset/change or "log out everywhere"). A cookie with no recorded epoch (pre-SA4, or an + # account signed in before this shipped) is treated as epoch 0 — so the FIRST bump revokes it too. + epochs = request.session.get("epochs") or {} + if int(epochs.get(str(user_id), 0)) != (user.session_epoch or 0): + if is_default: + request.session.clear() + raise HTTPException(status_code=401, detail="Not authenticated") # Always keep the session default in the switchable list — covers sessions created before # multi-session existed (their account_ids was never seeded), so the switcher isn't empty. default_id = request.session.get("user_id") @@ -773,6 +800,22 @@ def optional_current_user( return None +@router.post("/logout-others") +def logout_others( + request: Request, user: User = Depends(current_user), db: Session = Depends(get_db) +) -> dict: + """SA4: sign every OTHER session for this account out — bump the epoch so all existing cookies + are rejected, then re-stamp THIS cookie so the caller stays signed in. For "I left myself logged + in somewhere" / a suspected session compromise. The demo account (shared) is excluded.""" + if user.is_demo: + raise HTTPException(status_code=403, detail="Not available in the demo account.") + bump_session_epoch(user) + _remember_epoch(request, user) + db.commit() + log.info("Logout-others: uid=%s (epoch=%s)", user.id, user.session_epoch) + return {"ok": True} + + def require_human(user: User = Depends(current_user)) -> User: """Reject the shared demo account from actions that need a real Google/YouTube identity or spend the shared quota. Most YouTube paths are already closed to it implicitly (it has @@ -828,7 +871,10 @@ async def link_google( @router.post("/set-password") def set_password( - payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db) + payload: dict, + request: Request, + user: User = Depends(current_user), + db: Session = Depends(get_db), ) -> dict: """Set or change the signed-in account's password. Setting a first password (Google-only account) needs no current password — the session already proves identity. Changing an @@ -845,6 +891,10 @@ def set_password( if not verify_password(payload.get("current_password") or "", user.password_hash): raise HTTPException(status_code=403, detail="Current password is incorrect.") user.password_hash = hash_password(new_password) + # SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so + # the person making the change stays signed in here. + bump_session_epoch(user) + _remember_epoch(request, user) db.commit() log.info("Password set/changed: uid=%s", user.id) return {"ok": True} diff --git a/backend/app/models.py b/backend/app/models.py index d65ee87..9b31e67 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -63,6 +63,11 @@ class User(Base, TimestampMixin): is_suspended: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false" ) + # Server-side session revocation (SA4): a monotonic counter bumped on password reset, password + # change, or "log out everywhere". A signed session cookie records the epoch it was minted under; + # current_user rejects any cookie whose epoch is stale, so a stolen/copied cookie dies on those + # events (client-side signed cookies otherwise stay valid until they expire). + session_epoch: Mapped[int] = mapped_column(Integer, default=0, server_default="0") display_name: Mapped[str | None] = mapped_column(String(255)) avatar_url: Mapped[str | None] = mapped_column(String(1024)) role: Mapped[str] = mapped_column(String(16), default="user", server_default="user") diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 91c7e1e..0399ea3 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -308,11 +308,36 @@ function AccessRow({ function SignInMethods({ me }: { me: Me }) { const { t } = useTranslation(); const qc = useQueryClient(); + const confirm = useConfirm(); const [open, setOpen] = useState(false); const [current, setCurrent] = useState(""); const [next, setNext] = useState(""); const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); + const [sessBusy, setSessBusy] = useState(false); + + // SA4: sign every OTHER session for this account out (this one stays). For a shared/left-behind + // device or a suspected compromise. + const logoutOthers = async () => { + if ( + !(await confirm({ + title: t("settings.account.sessions.confirmTitle"), + message: t("settings.account.sessions.confirmBody"), + confirmLabel: t("settings.account.sessions.action"), + danger: true, + })) + ) + return; + setSessBusy(true); + try { + await api.logoutOthers(); + notify({ level: "success", message: t("settings.account.sessions.done") }); + } catch { + notify({ level: "error", message: t("settings.account.sessions.failed") }); + } finally { + setSessBusy(false); + } + }; const submit = async (e: React.FormEvent) => { e.preventDefault(); @@ -422,6 +447,24 @@ function SignInMethods({ me }: { me: Me }) { )} + +
+
+
+
{t("settings.account.sessions.title")}
+

+ {t("settings.account.sessions.hint")} +

+
+ +
+
); } diff --git a/frontend/src/i18n/locales/de/settings.json b/frontend/src/i18n/locales/de/settings.json index c441032..3425b9f 100644 --- a/frontend/src/i18n/locales/de/settings.json +++ b/frontend/src/i18n/locales/de/settings.json @@ -97,6 +97,15 @@ "saving": "Speichern…", "saved": "Passwort für {{email}} aktualisiert.", "failed": "Das Passwort konnte nicht aktualisiert werden." + }, + "sessions": { + "title": "Aktive Sitzungen", + "hint": "Diese Kontoanmeldung überall sonst abmelden — praktisch, wenn du auf einem geteilten oder verlorenen Gerät angemeldet geblieben bist. Hier bleibst du angemeldet.", + "action": "Andere Sitzungen abmelden", + "confirmTitle": "Andere Sitzungen abmelden?", + "confirmBody": "Damit wirst du in allen anderen Browsern und auf allen anderen Geräten abgemeldet. Hier bleibst du angemeldet.", + "done": "Von allen anderen Sitzungen abgemeldet.", + "failed": "Andere Sitzungen konnten nicht abgemeldet werden. Bitte versuche es erneut." } }, "demo": { diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index cd9e2d2..c6c4ac0 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -97,6 +97,15 @@ "saving": "Saving…", "saved": "Password updated for {{email}}.", "failed": "Couldn't update the password." + }, + "sessions": { + "title": "Active sessions", + "hint": "Sign this account out everywhere else — handy if you left it signed in on a shared or lost device. You stay signed in here.", + "action": "Log out other sessions", + "confirmTitle": "Log out other sessions?", + "confirmBody": "This signs you out of every other browser and device. You'll stay signed in here.", + "done": "Signed out of all other sessions.", + "failed": "Couldn't sign out other sessions. Please try again." } }, "demo": { diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index c8dea12..1048ae0 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -97,6 +97,15 @@ "saving": "Mentés…", "saved": "Jelszó frissítve ehhez: {{email}}.", "failed": "Nem sikerült frissíteni a jelszót." + }, + "sessions": { + "title": "Aktív munkamenetek", + "hint": "Jelentkeztesd ki ezt a fiókot minden más helyen — hasznos, ha megosztott vagy elveszett eszközön maradt bejelentkezve. Itt bejelentkezve maradsz.", + "action": "Kijelentkezés máshonnan", + "confirmTitle": "Kijelentkezés a többi munkamenetből?", + "confirmBody": "Ez kijelentkeztet minden más böngészőből és eszközről. Itt bejelentkezve maradsz.", + "done": "Kijelentkeztetve minden más munkamenetből.", + "failed": "Nem sikerült kijelentkeztetni a többi munkamenetet. Próbáld újra." } }, "demo": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8ebeb24..53e0de2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1394,6 +1394,8 @@ export const api = { // 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 }), + // SA4: sign every OTHER session for this account out (keeps the current one). + logoutOthers: (): Promise<{ ok: boolean }> => req("/auth/logout-others", { method: "POST" }), // Set or change the signed-in account's password (errors shown inline via quiet). setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> => req( From 07dba4da9e1298114b3110f0bc725f43c19c8893 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 03:07:13 +0200 Subject: [PATCH 3/6] fix(auth): address SA4 review findings (WS epoch check, commit ordering, verify guard) Adversarial re-review of the session-epoch work surfaced: - WS auth (messages_ws) skipped the epoch check, so a revoked-but-unexpired cookie could still open the live push channel after a reset/logout-others. Now mirrors current_user: loads the user once, rejects a stale-epoch cookie before connecting. - set_password + logout_others re-stamped the cookie BEFORE db.commit(); a failed commit would strand the current session at a newer epoch than the DB and wrongly 401 it. Commit first, then re-stamp. - Welcome verify effect could double-POST the single-use token (StrictMode/remount) and flip the banner to a false 'invalid'. Fire-once useRef guard. Left as-is (low value, documented): the Plex image proxy authenticates without a DB load / epoch check (poster/art fetches only); adding one would cost a DB hit per image. --- backend/app/auth.py | 9 ++++++--- backend/app/routes/messages.py | 8 +++++++- frontend/src/components/Welcome.tsx | 10 +++++++--- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/backend/app/auth.py b/backend/app/auth.py index 59d616a..edbb458 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -809,9 +809,11 @@ def logout_others( in somewhere" / a suspected session compromise. The demo account (shared) is excluded.""" if user.is_demo: raise HTTPException(status_code=403, detail="Not available in the demo account.") + # Commit the bump BEFORE re-stamping this cookie, so a failed commit can't strand the current + # session at a newer epoch than the DB (see set_password). bump_session_epoch(user) - _remember_epoch(request, user) db.commit() + _remember_epoch(request, user) log.info("Logout-others: uid=%s (epoch=%s)", user.id, user.session_epoch) return {"ok": True} @@ -892,10 +894,11 @@ def set_password( raise HTTPException(status_code=403, detail="Current password is incorrect.") user.password_hash = hash_password(new_password) # SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so - # the person making the change stays signed in here. + # the person making the change stays signed in here. Commit BEFORE re-stamping so a failed commit + # can't leave the cookie at a newer epoch than the DB (which would wrongly 401 THIS session). bump_session_epoch(user) - _remember_epoch(request, user) db.commit() + _remember_epoch(request, user) log.info("Password set/changed: uid=%s", user.id) return {"ok": True} diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py index 53a68e8..420bd58 100644 --- a/backend/app/routes/messages.py +++ b/backend/app/routes/messages.py @@ -381,7 +381,13 @@ async def messages_ws(ws: WebSocket) -> None: return db = SessionLocal() try: - ok = is_messageable_user(db.get(User, uid)) + user = db.get(User, uid) + # SA4: honour server-side session revocation on the live channel too — reject a cookie whose + # recorded epoch is behind the account's current one (mirrors current_user). Without this a + # copied cookie could keep receiving pushes after a password reset / "log out everywhere". + epochs = sess.get("epochs") or {} + epoch_ok = user is not None and int(epochs.get(str(uid), 0)) == (user.session_epoch or 0) + ok = epoch_ok and is_messageable_user(user) finally: db.close() if not ok: diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx index a071097..446630a 100644 --- a/frontend/src/components/Welcome.tsx +++ b/frontend/src/components/Welcome.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ArrowRight, @@ -282,9 +282,13 @@ function AuthCard() { } }, []); - // Confirm a fragment verification token by POSTing it (new SB3 flow). + // Confirm a fragment verification token by POSTing it (new SB3 flow). Fire ONCE: the token is + // single-use, so a double POST (StrictMode remount / any re-render) would 400 on the second call + // and wrongly flip the banner to "invalid". + const verifyFired = useRef(false); useEffect(() => { - if (!verifyToken) return; + if (!verifyToken || verifyFired.current) return; + verifyFired.current = true; api .verifyEmail(verifyToken) .then(() => setVerify("ok")) From 7297dbd2a8e39cd5a09611b41037f94d78bcb77f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 03:42:41 +0200 Subject: [PATCH 4/6] =?UTF-8?q?feat(auth):=20SA3=20=E2=80=94=20trusted-pro?= =?UTF-8?q?xy=20X-Forwarded-For=20for=20rate=20limiting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _client_ip trusted the first X-Forwarded-For hop unconditionally, so anyone able to reach the app port could forge XFF and dodge the login/register/reset/demo rate limits. Now trust XFF ONLY when the request's socket peer is a configured reverse proxy (settings.trusted_proxy_ips, e.g. the VPS Caddy's WireGuard peer IP), and take the RIGHTMOST entry — the client our proxy actually saw and appended, immune to a client pre-seeding a fake XFF. A request from any other peer (hitting the port directly) is keyed on its real socket IP, so XFF can't be forged to bypass the limits. New TRUSTED_PROXY_IPS env (empty default = no proxy, use the direct peer). Documented in .env.example, docs/self-hosting.md, README. Unit-verified against spoof-through-proxy and direct-bypass cases. --- .env.example | 10 ++++++++++ README.md | 3 ++- backend/app/auth.py | 19 +++++++++++++------ backend/app/config.py | 12 ++++++++++++ docs/self-hosting.md | 15 +++++++++++++++ 5 files changed, 52 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index cb17209..3e4dae1 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,16 @@ ADMIN_EMAILS= # Optional: origin of a separately-served frontend dev server (enables CORS). Leave empty in production. FRONTEND_ORIGIN= +# Reverse-proxy trust for rate limiting. If you run the app behind a reverse proxy (Caddy/Nginx/ +# Traefik…) that terminates TLS, set this to the IP the proxy CONNECTS FROM as the app sees it — the +# proxy's address on the app's network (e.g. the Docker/host/VPN IP), NOT the public client IP. Then +# the login/registration/password-reset rate limiters key on the real client (read from the proxy's +# X-Forwarded-For) instead of the proxy's own IP. Requests arriving from any OTHER peer (e.g. someone +# hitting the container port directly) are not trusted and are rate-limited by their real socket IP, +# so X-Forwarded-For can't be forged to dodge the limits. Comma-separated. Leave EMPTY if the app is +# directly exposed with no proxy (then the direct socket IP is used). Example: TRUSTED_PROXY_IPS=10.10.0.1 +TRUSTED_PROXY_IPS= + # Optional YouTube Data API key (Google Cloud Console -> Credentials -> Create API key). # When set, all public reads (channels/videos/playlist backfill + enrichment) use the key # instead of a user's OAuth token, so 24/7 backfill never depends on a refresh token that diff --git a/README.md b/README.md index cd72694..22ef321 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,8 @@ Port `8080` over plain HTTP is fine for a LAN or a quick trial. For public acces proxy (Caddy, Nginx, Traefik…) in front to terminate TLS, and set the **public URL** (the installer prompt, or `OAUTH_REDIRECT_URL` in `.env`) to your `https://…` address — this also marks the session cookie secure. Add that same `…/auth/callback` URL to your Google OAuth client's authorized redirect -URIs. +URIs. Behind a proxy, also set `TRUSTED_PROXY_IPS` so the rate limiters see the real client IP and +can't be bypassed via a forged `X-Forwarded-For` — see [docs/self-hosting.md](docs/self-hosting.md#https--public-access). ## Updating & backups diff --git a/backend/app/auth.py b/backend/app/auth.py index edbb458..7a2f254 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -173,12 +173,19 @@ def get_or_create_demo_user(db: Session) -> User: def _client_ip(request: Request) -> str: - """Best-effort client IP for rate limiting. Behind our reverse proxy (Caddy/NPM) the - real client is the first X-Forwarded-For hop; fall back to the socket peer otherwise.""" - xff = request.headers.get("x-forwarded-for") - if xff: - return xff.split(",")[0].strip() - return request.client.host if request.client else "unknown" + """Best-effort client IP for rate limiting. X-Forwarded-For is client-controlled, so we trust it + ONLY when the request actually arrived from a configured reverse proxy (settings.trusted_proxy_ips + — the address the proxy connects FROM, e.g. the VPS Caddy's WireGuard peer IP). Our proxy APPENDS + the client it saw to XFF, so the RIGHTMOST entry is the real client and can't be forged by a + client pre-seeding the header. Any other peer (someone hitting the app port directly, bypassing the + proxy) is untrusted — we use its real socket IP, so it can't spoof its rate-limit identity.""" + peer = request.client.host if request.client else "unknown" + if peer in settings.trusted_proxy_ip_set: + xff = request.headers.get("x-forwarded-for") + parts = [p.strip() for p in (xff or "").split(",") if p.strip()] + if parts: + return parts[-1] + return peer def upsert_pending_invite(db: Session, email: str) -> Invite | None: diff --git a/backend/app/config.py b/backend/app/config.py index 3f75965..a4d6c10 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -52,6 +52,14 @@ class Settings(BaseSettings): # Origin of a separately served frontend dev server (enables CORS). Empty in production. frontend_origin: str = "" + # Reverse-proxy IPs whose X-Forwarded-For we trust for the real client IP (rate limiting). This + # is the address the proxy CONNECTS FROM as seen by the app — e.g. the WireGuard peer IP of the + # VPS Caddy front door. A request from any other peer (someone hitting the app port directly) + # is NOT trusted and its raw socket IP is used, so it can't spoof its rate-limit identity via + # XFF. Empty = trust no proxy (use the direct peer) — correct for a directly-exposed / local-dev + # deploy. Comma-separated. See auth._client_ip + the deploy docs. + trusted_proxy_ips: str = "" + # --- Outbound email (onboarding: access-request + approval notices) --- # Gmail SMTP + App Password by default. All optional: if unset, email is skipped # (fail-soft) and onboarding still works via in-app notifications. @@ -226,6 +234,10 @@ class Settings(BaseSettings): def admin_email_set(self) -> set[str]: return {e.strip().lower() for e in self.admin_emails.split(",") if e.strip()} + @property + def trusted_proxy_ip_set(self) -> frozenset[str]: + return frozenset(p.strip() for p in self.trusted_proxy_ips.split(",") if p.strip()) + @property def app_base(self) -> str: """Public origin of the deployed app, derived from the OAuth redirect URL diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 94d68b6..8997a3a 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -79,6 +79,21 @@ public access, put a reverse proxy (Caddy, Nginx, Traefik…) in front to termin secure). If you've already run the installer, edit `OAUTH_REDIRECT_URL` in `.env` to the https callback URL and `docker compose -f docker-compose.selfhost.yml up -d`. +**Behind a proxy, set `TRUSTED_PROXY_IPS`.** The login / registration / password-reset rate limiters +identify callers by IP. Behind a proxy every request arrives from the proxy, so without this the +limits would apply to everyone together. Set `TRUSTED_PROXY_IPS` in `.env` to the address your proxy +connects to the app *from* (as the app sees it — e.g. its Docker/host/LAN IP, **not** the public +client address), and the app will read the real client IP from the proxy's `X-Forwarded-For` header: + +```bash +TRUSTED_PROXY_IPS=172.18.0.1 # comma-separate multiple proxies +``` + +Only that peer is trusted, so a request sent straight to port `8080` (bypassing the proxy) can't forge +`X-Forwarded-For` to dodge the limits — it's rate-limited by its real address. Leave it empty if the +app is exposed directly with no proxy. Make sure your proxy actually sets `X-Forwarded-For` (Caddy's +`reverse_proxy` and Nginx's `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for` both do). + ## Download Center (media storage) The stack includes a **Download Center**: an admin-enabled feature that saves videos to the server From 776fb38b9b749e26cf4d686b2df0ac417e40e73b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 03:49:08 +0200 Subject: [PATCH 5/6] =?UTF-8?q?harden(auth):=20SA3=20review=20follow-ups?= =?UTF-8?q?=20=E2=80=94=20IPv6-mapped=20match=20+=20no-proxy-headers=20gua?= =?UTF-8?q?rdrail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-review hardening (both fail-safe, not bugs): - _client_ip normalizes IPs via ipaddress and unwraps IPv4-mapped IPv6, so a plain TRUSTED_PROXY_IPS=10.10.0.1 also matches a peer surfaced as ::ffff:10.10.0.1 (a Docker/IPv6 self-host footgun that would otherwise silently collapse everyone into one rate-limit bucket). - entrypoint.sh + _client_ip docstring: explicit warning never to add uvicorn --proxy-headers, which would rewrite request.client from the forgeable XFF and defeat the trust check. --- backend/app/auth.py | 20 ++++++++++++++++++-- backend/entrypoint.sh | 5 +++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/backend/app/auth.py b/backend/app/auth.py index 7a2f254..5874b65 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1,3 +1,4 @@ +import ipaddress import logging import secrets from datetime import datetime, timedelta, timezone @@ -172,15 +173,30 @@ def get_or_create_demo_user(db: Session) -> User: return user +def _norm_ip(s: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None: + """Parse an IP for comparison, unwrapping IPv4-mapped IPv6 (`::ffff:10.0.0.1` → `10.0.0.1`) so a + plain-IPv4 allowlist still matches a peer that surfaces in mapped form. None for a non-IP string.""" + try: + ip = ipaddress.ip_address(s.strip()) + except ValueError: + return None + return ip.ipv4_mapped if getattr(ip, "ipv4_mapped", None) else ip + + def _client_ip(request: Request) -> str: """Best-effort client IP for rate limiting. X-Forwarded-For is client-controlled, so we trust it ONLY when the request actually arrived from a configured reverse proxy (settings.trusted_proxy_ips — the address the proxy connects FROM, e.g. the VPS Caddy's WireGuard peer IP). Our proxy APPENDS the client it saw to XFF, so the RIGHTMOST entry is the real client and can't be forged by a client pre-seeding the header. Any other peer (someone hitting the app port directly, bypassing the - proxy) is untrusted — we use its real socket IP, so it can't spoof its rate-limit identity.""" + proxy) is untrusted — we use its real socket IP, so it can't spoof its rate-limit identity. + + SAFETY: this relies on `request.client` being the TRUE socket peer. uvicorn is started WITHOUT + `--proxy-headers` (see entrypoint.sh) precisely so it never rewrites `request.client` from XFF — + do NOT add that flag, or the trust check below would compare against a forgeable value.""" peer = request.client.host if request.client else "unknown" - if peer in settings.trusted_proxy_ip_set: + trusted = {_norm_ip(p) for p in settings.trusted_proxy_ip_set} - {None} + if _norm_ip(peer) in trusted: xff = request.headers.get("x-forwarded-for") parts = [p.strip() for p in (xff or "").split(",") if p.strip()] if parts: diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 04ac8af..f7fc271 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -10,4 +10,9 @@ echo "Starting Siftlode API..." # timeout, so Caddy would reuse a connection uvicorn had just closed -> broken pipe / reset # on the next request -> 502. Non-idempotent POSTs (the player's progress/state writes, # fired every 5s) can't be auto-retried by the proxy, so those 502s reached the user. +# +# SECURITY: do NOT add --proxy-headers / --forwarded-allow-ips here. auth._client_ip trusts +# X-Forwarded-For only when the TRUE socket peer is a configured proxy (TRUSTED_PROXY_IPS); those +# flags would make uvicorn overwrite request.client from the forgeable XFF, defeating that check and +# reopening the rate-limit bypass (SA3). exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-config log_config.json --timeout-keep-alive 75 From 56dfa9b4271e68f4d38eaa1ec16477d81d897539 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 03:49:55 +0200 Subject: [PATCH 6/6] release: v0.39.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auth security round: SB3 (email tokens out of URL query → fragment), SA4 (server-side session revocation + 'Log out other sessions'), SA3 (trusted-proxy X-Forwarded-For so rate limits can't be bypassed via a forged header). --- VERSION | 2 +- frontend/src/lib/releaseNotes.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index ca75280..4ef2eb0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.38.0 +0.39.0 diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 6d0fbe8..c6cf63c 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,14 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.39.0", + date: "2026-07-12", + summary: "Sign-in security hardening.", + features: [ + "New “Log out other sessions” button in Settings → Account, to sign out everywhere else.", + ], + }, { version: "0.38.0", date: "2026-07-12",