feat(security): R4 S3 — abuse & leak hardening

- C-3.7: fold a per-link password_version into the signed watch grant (migration
  0059 adds the column) and bump it on every password change, so rotating a share
  link's password invalidates outstanding grants at once instead of letting them
  live out the 6h TTL. make_grant/check_grant take the version; update_link bumps.
- C-3.8: the share recipient picker and share-by-email now reuse the messageable
  filter (not-demo AND active AND not-suspended) instead of only excluding demo,
  so suspended/deactivated addresses aren't leaked or reachable as targets.
- C-3.9: GET /keys/{user_id} adopts get_thread's MB2 guard — a non-messageable
  target with no shared history returns the same "User not found" as a missing id,
  so it can't be probed to enumerate users or fetch suspended users' keys.
- C-3.12: _register_account catches IntegrityError on the insert (the select-then-
  insert TOCTOU) and treats it as the already-registered no-op.
- C-3.13: trigger_job claims the running flag atomically under _activity_lock
  (compare-and-set) instead of a bare check, closing the double-start window.
- Move _messageable/is_messageable_user into a shared app/userscope.py so downloads
  doesn't import the messages route module.
This commit is contained in:
2026-07-23 03:38:57 +02:00
parent dce191f1c0
commit 733b41fd86
10 changed files with 144 additions and 38 deletions
@@ -0,0 +1,32 @@
"""add password_version to download_links (invalidate watch grants on password change)
Revision ID: 0059_dl_link_pw_version
Revises: 0058_dl_default_720p
Create Date: 2026-07-23
A password-protected share link mints a short-lived HMAC "grant" at /unlock so the media URL never
carries the password. The grant previously signed only <token>.<exp>, so rotating a link's password
did NOT invalidate outstanding grants — they lived out their 6h TTL. Fold a per-link password_version
into the signed grant and bump it on every password change, so a rotation revokes old grants at once.
Small table (one row per share link), so this is a quick add-column with a default.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0059_dl_link_pw_version"
down_revision: Union[str, None] = "0058_dl_default_720p"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"download_links",
sa.Column("password_version", sa.Integer(), nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_column("download_links", "password_version")
+9 -1
View File
@@ -9,6 +9,7 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse
from starlette.requests import HTTPConnection
from sqlalchemy import delete, func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app import email as email_mod
@@ -718,7 +719,14 @@ def _register_account(email: str, password: str) -> None:
email_verified=not email_ok,
)
db.add(user)
db.flush()
try:
db.flush()
except IntegrityError:
# A concurrent registration for the same new email won the race between the pre-check
# above and this flush (the unique-email constraint fires). Same already-registered
# no-op as the pre-check hit — roll back and stop.
db.rollback()
return
upsert_pending_invite(db, email) # admin-approval gate (commits)
if email_ok:
raw = _issue_token(db, user, "verify", VERIFY_TTL)
+9 -5
View File
@@ -33,13 +33,17 @@ def _sign(msg: str) -> str:
).hexdigest()[:32]
def make_grant(token: str, now: float | None = None) -> str:
"""A short-lived HMAC grant proving the viewer unlocked `token`. Format: `<exp>.<sig>`."""
def make_grant(token: str, password_version: int, now: float | None = None) -> str:
"""A short-lived HMAC grant proving the viewer unlocked `token`. Format: `<exp>.<sig>`. The
link's current `password_version` is folded into the signature, so rotating the password (which
bumps the version) invalidates every grant minted under the old password at once."""
exp = int((now if now is not None else datetime.now(timezone.utc).timestamp())) + _GRANT_TTL
return f"{exp}.{_sign(f'{token}.{exp}')}"
return f"{exp}.{_sign(f'{token}.{exp}.{password_version}')}"
def check_grant(token: str, grant: str | None) -> bool:
def check_grant(token: str, grant: str | None, password_version: int) -> bool:
"""Verify a grant against the link's CURRENT `password_version`; a version bump (password
rotation) fails the signature check even before the grant's TTL expires."""
if not grant or "." not in grant:
return False
exp_s, sig = grant.split(".", 1)
@@ -49,7 +53,7 @@ def check_grant(token: str, grant: str | None) -> bool:
return False
if exp < datetime.now(timezone.utc).timestamp():
return False
return hmac.compare_digest(sig, _sign(f"{token}.{exp}"))
return hmac.compare_digest(sig, _sign(f"{token}.{exp}.{password_version}"))
# --- serialization -------------------------------------------------------------------------
+4
View File
@@ -943,6 +943,10 @@ class DownloadLink(Base, TimestampMixin):
Boolean, default=False, server_default="false"
)
password_hash: Mapped[str | None] = mapped_column(Text)
# Bumped whenever the password changes; folded into the signed watch grant so rotating the
# password immediately invalidates any grants issued under the old one (instead of them living
# out the 6h grant TTL).
password_version: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
view_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+10 -3
View File
@@ -31,6 +31,7 @@ from app.models import (
User,
)
from app.security import hash_password
from app.userscope import is_messageable_user, messageable_clauses
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
admin_router = APIRouter(prefix="/api/admin/downloads", tags=["admin-downloads"])
@@ -666,7 +667,9 @@ def share_download(
recipient = db.execute(
select(User).where(func.lower(User.email) == target)
).scalar_one_or_none()
if recipient is None:
# Uniform "no user" for a missing OR non-messageable (demo/suspended/deactivated) target, so
# sharing can't be used to probe which addresses exist or to reach a suspended account.
if not is_messageable_user(recipient):
raise HTTPException(status_code=404, detail="No user with that email.")
if recipient.id == user.id:
raise HTTPException(status_code=400, detail="That's already your download.")
@@ -741,11 +744,12 @@ def remove_shared_with_me(
def share_recipients(
user: User = Depends(require_human), db: Session = Depends(get_db)
) -> list[dict]:
"""Registered users this user can share a download with (excludes self + the demo account)."""
"""Registered users this user can share a download with (excludes self + any non-messageable
account — demo, suspended, or deactivated — so the picker never leaks those addresses)."""
rows = (
db.execute(
select(User)
.where(User.id != user.id, User.is_demo.is_(False))
.where(User.id != user.id, *messageable_clauses())
.order_by(func.lower(User.email))
)
.scalars()
@@ -834,6 +838,9 @@ def update_link(
if "password" in payload:
pw = (payload.get("password") or "").strip()
link.password_hash = hash_password(pw) if pw else None
# Bump the version so any grant issued under the previous password stops working now, rather
# than living out its 6h TTL (setting/clearing/changing the password all count as a rotation).
link.password_version += 1
db.commit()
return linksmod.owner_view(link)
+17 -14
View File
@@ -26,6 +26,7 @@ from app.models import Message, MessageKey, User
from app.ratelimit import RateLimiter
from app.realtime import manager
from app.security import verify_password
from app.userscope import is_messageable_user, messageable_clauses
router = APIRouter(prefix="/api/messages", tags=["messages"])
@@ -101,18 +102,6 @@ def _serialize_msg(m: Message) -> dict:
}
def _messageable() -> list:
"""SQL clauses for "a real, active, non-suspended human" — for directory/recipient queries."""
return [
User.is_demo.is_(False),
User.is_active.is_(True),
User.is_suspended.is_(False),
]
def is_messageable_user(u: User | None) -> bool:
"""Python mirror of `_messageable()` for an already-loaded row (WS auth, send recipient)."""
return bool(u and not u.is_demo and u.is_active and not u.is_suspended)
def ensure_welcome(db: Session, user: User) -> None:
@@ -221,7 +210,21 @@ def reset_keys(
def public_key(
user_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
"""A member's public key, for deriving the pairwise conversation key."""
"""A member's public key, for deriving the pairwise conversation key. MB2 (like get_thread):
resolve the target only if they're messageable OR we already share a thread, else the same
"User not found" as a non-existent id — so this can't be probed to enumerate user_ids or to
fetch keys for suspended/deactivated/demo accounts."""
cond = and_(
Message.kind == "user",
or_(
and_(Message.sender_id == user.id, Message.recipient_id == user_id),
and_(Message.sender_id == user_id, Message.recipient_id == user.id),
),
)
p = db.get(User, user_id)
has_history = db.scalar(select(Message.id).where(cond).limit(1))
if p is None or (not is_messageable_user(p) and not has_history):
raise HTTPException(status_code=404, detail="User not found")
k = db.get(MessageKey, user_id)
if k is None:
raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.")
@@ -240,7 +243,7 @@ def list_recipients(
db.execute(
select(User)
.join(MessageKey, MessageKey.user_id == User.id)
.where(User.id != user.id, *_messageable())
.where(User.id != user.id, *messageable_clauses())
.order_by(func.lower(func.coalesce(User.display_name, User.email)))
)
.scalars()
+3 -3
View File
@@ -97,7 +97,7 @@ def watch_unlock(token: str, payload: dict, db: Session = Depends(get_db)) -> di
job, asset = _ready_target(db, link)
link.view_count += 1
db.commit()
grant = linksmod.make_grant(token)
grant = linksmod.make_grant(token, link.password_version)
return {
"needs_password": False,
**linksmod.public_meta(
@@ -111,7 +111,7 @@ def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
"""Range-aware stream of the shared file. Inline by default (plays in the page); an
`allow_download` link serves it as an attachment. Password links require a valid grant."""
link = _link_or_404(db, token)
if link.password_hash and not linksmod.check_grant(token, g):
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
raise HTTPException(status_code=403, detail="This link needs a password.")
asset = _ready_asset(db, link)
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
@@ -129,7 +129,7 @@ def watch_poster(token: str, g: str | None = None, db: Session = Depends(get_db)
"""The generated poster frame for a shared thumbnail-less video (used as the page/video poster
and the link-preview image). Password links require a valid grant, like the file."""
link = _link_or_404(db, token)
if link.password_hash and not linksmod.check_grant(token, g):
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
raise HTTPException(status_code=403, detail="This link needs a password.")
job, asset = _ready_target(db, link)
if not asset.poster_path:
+21 -2
View File
@@ -380,6 +380,18 @@ def _is_running(job_id: str) -> bool:
return bool(_activity.get(job_id, {}).get("running"))
def _claim_running(job_id: str) -> bool:
"""Atomically claim a job as running: True if it was idle (and marks it running now), False if
it was already running. Holding the lock across BOTH the check and the set closes the TOCTOU
window where two rapid manual triggers each saw 'idle' (the running flag is otherwise only set
later inside the spawned thread) and both started a run."""
with _activity_lock:
if _activity.get(job_id, {}).get("running"):
return False
_activity.setdefault(job_id, {})["running"] = True
return True
def running_job_ids() -> set[str]:
"""Ids of jobs executing right now (any trigger). Lets non-admin surfaces — e.g. the
header's sync indicator — reflect real activity instead of just pending-work counts."""
@@ -399,7 +411,9 @@ def trigger_job(job_id: str, actor_id: int | None = None) -> str:
fn = JOB_FUNCS.get(job_id)
if fn is None:
raise KeyError(job_id)
if _is_running(job_id):
# Atomic claim (not a bare _is_running check) so two rapid clicks can't both start — the loser
# gets "already_running". _job re-affirms running=True and clears it when the run ends.
if not _claim_running(job_id):
return "already_running"
def run() -> None:
@@ -407,7 +421,12 @@ def trigger_job(job_id: str, actor_id: int | None = None) -> str:
_manual_actor.set(actor_id)
fn()
threading.Thread(target=run, name=f"trigger-{job_id}", daemon=True).start()
try:
threading.Thread(target=run, name=f"trigger-{job_id}", daemon=True).start()
except BaseException:
# Never got the worker going — release the claim so the job isn't wedged "running" forever.
_record(job_id, running=False)
raise
return "started"
+22
View File
@@ -0,0 +1,22 @@
"""Shared "who is a real, active, non-suspended human" predicate.
Used wherever a user must be filtered out of a directory / recipient list or rejected as a target:
the messaging directory AND the download share-by-email / recipient-picker endpoints. Kept in one
place so the two forms (SQL clauses for a query, Python check for a loaded row) can't drift apart,
and so a route module doesn't have to import another route module just to reuse the rule.
"""
from app.models import User
def messageable_clauses() -> list:
"""SQL clauses for "a real, active, non-suspended human" — splat into a directory/recipient query."""
return [
User.is_demo.is_(False),
User.is_active.is_(True),
User.is_suspended.is_(False),
]
def is_messageable_user(u: User | None) -> bool:
"""Python mirror of `messageable_clauses()` for an already-loaded row (WS auth, send recipient)."""
return bool(u and not u.is_demo and u.is_active and not u.is_suspended)
+17 -10
View File
@@ -14,30 +14,37 @@ def test_new_token_is_unguessable_and_unique():
class TestGrant:
def test_a_fresh_grant_verifies_for_its_own_token(self):
grant = make_grant("tok")
assert check_grant("tok", grant) is True
grant = make_grant("tok", 0)
assert check_grant("tok", grant, 0) is True
def test_a_grant_does_not_verify_for_a_different_token(self):
# The signature binds the token, so a grant minted for one link can't unlock another.
assert check_grant("other", make_grant("tok")) is False
assert check_grant("other", make_grant("tok", 0), 0) is False
def test_a_tampered_signature_is_rejected(self):
exp, _sig = make_grant("tok").split(".", 1)
assert check_grant("tok", f"{exp}.deadbeef") is False
exp, _sig = make_grant("tok", 0).split(".", 1)
assert check_grant("tok", f"{exp}.deadbeef", 0) is False
def test_a_tampered_expiry_is_rejected(self):
# Extending the exp field invalidates the signature (which covers token.exp).
_exp, sig = make_grant("tok").split(".", 1)
# Extending the exp field invalidates the signature (which covers token.exp.version).
_exp, sig = make_grant("tok", 0).split(".", 1)
future = int(datetime.now(timezone.utc).timestamp()) + 10 * _GRANT_TTL
assert check_grant("tok", f"{future}.{sig}") is False
assert check_grant("tok", f"{future}.{sig}", 0) is False
def test_an_expired_grant_is_rejected(self):
past = datetime.now(timezone.utc).timestamp() - 2 * _GRANT_TTL
assert check_grant("tok", make_grant("tok", now=past)) is False
assert check_grant("tok", make_grant("tok", 0, now=past), 0) is False
def test_a_password_rotation_invalidates_an_old_grant(self):
# A grant minted at version 0 must fail once the link's password_version has been bumped —
# this is what makes changing a link password revoke outstanding grants immediately.
grant = make_grant("tok", 0)
assert check_grant("tok", grant, 0) is True
assert check_grant("tok", grant, 1) is False
def test_malformed_grants_are_rejected_not_crashed(self):
for bad in [None, "", "no-dot", "notanumber.sig"]:
assert check_grant("tok", bad) is False
assert check_grant("tok", bad, 0) is False
class TestIsExpired: