fix(files): ending every session ends every API token, and the share links hold their folder

- A password reset or change, "log out everywhere" and a revoked untrusted password revoke all of the
  account's API tokens too (audited): one minted from a stolen session no longer outlives the answer.
- A bearer token or link grant with a non-ASCII character is refused (401/403), not a 500.
- Making a token's grants or a folder link holds the folder, so a racing delete cannot fail the
  foreign key; a link's view count is one atomic UPDATE.
- A folder link's card counts exactly the files its page lists (one `links.folder_files`).
- A file with no extension is served without a trailing dot.
- 0106's downgrade names a Hub folder so it never collides with one of the user's folders.
- The speed baseline reads share tokens through the app (the database keeps them hashed/encrypted).
- Tests: the token scope on the real session stack, the folder poster, the Hub refused for a link,
  0106's downgrade data step, 0107 without a key; docs/comments follow (three folder kinds, folder
  links, key change, the cleanup job's token warnings).
This commit is contained in:
2026-10-15 04:58:16 +02:00
parent 86712b6b0e
commit 827df7494c
23 changed files with 399 additions and 96 deletions
+17 -13
View File
@@ -16,6 +16,7 @@ from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app import apitokens
from app import email as email_mod
from app import logs
from app import mailtext
@@ -446,7 +447,7 @@ async def callback(
# SEC-10: drop any password nobody proved the mailbox for, now that Google attests
# ownership (bumps the epoch to revoke any session it minted). Runs before the
# activation below, which is what it reads.
revoke_untrusted_preset_password(user)
revoke_untrusted_preset_password(db, user)
# Google verified the email and is_allowed granted access, so finish activating a
# previously pending password registration (otherwise current_user would bounce it).
user.email_verified = True
@@ -486,7 +487,7 @@ async def callback(
# real owner, signed in through Google right now, sets a new password in Settings.
if canonical_email(email) in env_admin_canonicals():
if user.role != "admin" and user.password_hash is not None:
_revoke_password(user)
_revoke_password(db, user)
user.role = "admin"
elif not user.role:
user.role = "user"
@@ -1118,9 +1119,8 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
# about. The person still confirms their address the normal way.
if kind == "reset":
user.email_verified = True
bump_session_epoch(
user
) # SA4: a reset (often a compromise response) kills all existing sessions.
# SA4: a reset (often a compromise response) kills all existing sessions — and API tokens.
bump_session_epoch(db, user)
# Burn any other outstanding reset tokens for this user.
for row in db.execute(
select(AuthToken).where(
@@ -1143,13 +1143,17 @@ def _remember_epoch(request: Request, user: User) -> None:
request.session["epochs"] = epochs
def bump_session_epoch(user: User) -> None:
def bump_session_epoch(db: Session, 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`."""
minted before the bump carry a now-stale epoch and are rejected by `current_user`. The account's
API tokens go with them (`apitokens.revoke_all`): a token minted from a stolen session must not
outlive the answer to that theft. Not committed — the caller commits it with its change."""
user.session_epoch = (user.session_epoch or 0) + 1
if user.id is not None:
apitokens.revoke_all(db, user.id)
def revoke_untrusted_preset_password(user: User) -> None:
def revoke_untrusted_preset_password(db: Session, user: User) -> None:
"""SEC-10 (account pre-emption): a password set on an account whose email was NEVER verified is
untrusted — whoever registered the address (to pre-empt the real owner) could have chosen it. When
the real owner attests ownership through Google, drop that password and bump the epoch to revoke any
@@ -1183,15 +1187,15 @@ def revoke_untrusted_preset_password(user: User) -> None:
_send_reset_if_eligible keyed off exactly that, so a revoked account silently got no reset link and
was locked out for good (it could not sign in either). The stamp separates the two cases."""
if not user.email_verified or (not user.is_active and user.password_hash is not None):
_revoke_password(user)
_revoke_password(db, user)
def _revoke_password(user: User) -> None:
def _revoke_password(db: Session, user: User) -> None:
"""Drop the account's password, stamp why (so password-reset still accepts it) and end every
session it signed in."""
user.password_hash = None
user.password_revoked_at = datetime.now(timezone.utc)
bump_session_epoch(user)
bump_session_epoch(db, user)
@router.post("/logout-others")
@@ -1203,7 +1207,7 @@ def logout_others(
in somewhere" / a suspected session compromise."""
# 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)
bump_session_epoch(db, user)
db.commit()
_remember_epoch(request, user)
log.info("Logout-others: uid=%s (epoch=%s)", user.id, user.session_epoch)
@@ -1255,7 +1259,7 @@ def set_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. 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)
bump_session_epoch(db, user)
db.commit()
_remember_epoch(request, user)
log.info("Password set/changed: uid=%s", user.id)
+9 -2
View File
@@ -27,6 +27,7 @@ from app.db import get_db
from app.downloads import clip as clipmod
from app.downloads import credentials as dlcreds
from app.downloads import edit as editmod
from app.downloads import folders
from app.downloads import links as linksmod
from app.downloads import quota, service, storage
from app.downloads import urlguard
@@ -806,8 +807,14 @@ def create_link(
def _shareable_folder(db: Session, user: User, folder_id: int) -> FileFolder:
"""One of the user's own folders. A source folder lets its files go on its own clock, and the Hub
is the user's channel to a machine of theirs: neither is a shelf to hand out."""
folder = owned_or_404(db, FileFolder, folder_id, user, what="folder")
is the user's channel to a machine of theirs: neither is a shelf to hand out. Held (FOR SHARE)
for the rest of the transaction, so a delete of it at the same moment waits and takes the new
link with it, rather than failing the link's foreign key."""
owned_or_404(db, FileFolder, folder_id, user, what="folder")
try:
folder = folders.own_folder(db, user.id, folder_id)
except folders.FolderError as e:
raise refused(409, e.reason) from e
if folder.kind != FolderKind.OWN:
raise refused(409, "share_folder_kind")
return folder
+12 -29
View File
@@ -9,7 +9,7 @@ a short-lived signed grant that the media URL carries (a `<video src>` can't sen
from fastapi import APIRouter, Depends, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.orm import Session
from app.config import settings
@@ -22,7 +22,6 @@ from app.ratelimit import RateLimiter, client_key
from app.security import verify_password
from app.refusal import refused
from app.routes._common import not_found
from app.routes.files import MAX_LIST
router = APIRouter(prefix="/api/public", tags=["public"])
@@ -37,9 +36,6 @@ _unlock_limit = RateLimiter(max_events=10, window_seconds=300)
# already running when it fills are still checked — as many as the route's threadpool runs at once.
_unlock_failures = RateLimiter(max_events=50, window_seconds=300)
# The most files a folder link lists: as many as the owner's own Files list shows.
_FOLDER_MAX = MAX_LIST
def _link_or_404(db: Session, token: str) -> DownloadLink:
link = linksmod.find(db, token)
@@ -89,16 +85,6 @@ def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAs
return _ready_job(db, db.get(DownloadJob, link.job_id))
def _in_folder(link: DownloadLink, folder: FileFolder) -> tuple:
"""What a folder link shows: the done files in its folder NOW, the folder owner's. A file moved
out stops opening through the link at its next request; one moved in starts."""
return (
DownloadJob.folder_id == link.folder_id,
DownloadJob.user_id == folder.user_id,
DownloadJob.status == JobStatus.DONE,
)
def _link_folder(db: Session, link: DownloadLink) -> FileFolder:
folder = db.get(FileFolder, link.folder_id) if link.folder_id is not None else None
if folder is None:
@@ -109,7 +95,7 @@ def _link_folder(db: Session, link: DownloadLink) -> FileFolder:
def _folder_job(db: Session, link: DownloadLink, job_id: int) -> tuple[DownloadJob, MediaAsset]:
folder = _link_folder(db, link)
job = db.execute(
select(DownloadJob).where(DownloadJob.id == job_id, *_in_folder(link, folder))
select(DownloadJob).where(DownloadJob.id == job_id, *linksmod.in_folder(folder))
).scalar_one_or_none()
if job is None:
raise refused(404, "video_gone")
@@ -136,16 +122,9 @@ def _poster_url(
def _folder_meta(db: Session, link: DownloadLink, token: str, grant: str | None) -> dict:
"""A folder link's page: the folder's name and each ready file's own watch metadata, newest
first. A file whose bytes are not on disk is left out, so the list never offers a dead row."""
"""A folder link's page: the folder's name and each file's own watch metadata
(`linksmod.folder_files`: ready, on disk, newest first)."""
folder = _link_folder(db, link)
rows = db.execute(
select(DownloadJob, MediaAsset)
.join(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
.where(*_in_folder(link, folder), MediaAsset.status == "ready")
.order_by(DownloadJob.ready_at.desc().nulls_last(), DownloadJob.id.desc())
.limit(_FOLDER_MAX)
).all()
files = [
{
"id": job.id,
@@ -157,8 +136,7 @@ def _folder_meta(db: Session, link: DownloadLink, token: str, grant: str | None)
_poster_url(token, asset, grant, job.id),
),
}
for job, asset in rows
if storage.safe_abs_path(settings.files_root, asset.rel_path) is not None
for job, asset in linksmod.folder_files(db, folder)
]
return {
"needs_password": False,
@@ -187,8 +165,13 @@ def _opened(db: Session, link: DownloadLink, token: str, grant: str | None = Non
if link.folder_id is not None
else _file_meta(db, link, token, grant)
)
link.view_count += 1
db.flush()
# In SQL, not `+= 1` on the loaded row: two opens at once would both write the same count.
db.execute(
update(DownloadLink)
.where(DownloadLink.id == link.id)
.values(view_count=DownloadLink.view_count + 1)
.execution_options(synchronize_session=False)
)
return meta
+2 -2
View File
@@ -104,9 +104,9 @@ def _wanted_grants(db: Session, user: User, body: TokenIn) -> dict[int, set[Righ
for folder_id, rights in wanted.items():
try:
if Right.WRITE in rights:
folders.upload_target(db, user.id, folder_id, hold=False)
folders.upload_target(db, user.id, folder_id)
else:
folders.own_folder(db, user.id, folder_id, hold=False)
folders.own_folder(db, user.id, folder_id)
except folders.FolderError as e:
raise refused(409, e.reason) from e
return wanted