From 299f41290f2fd427563993f958680d76b3113927 Mon Sep 17 00:00:00 2001
From: tomkabel <191489531+tomkabel@users.noreply.github.com>
Date: Fri, 26 Jun 2026 03:51:52 +0300
Subject: [PATCH 01/31] fix(security): remove nginx CSP header and replace
hx-on with nonced listeners
Remove the redundant Content-Security-Policy header from nginx reverse
proxy configs (production and local-ssl) that was being sent in addition
to the application CSP. Browsers enforce the intersection of multiple CSP
headers, so the restrictive nginx CSP (script-src 'self') was stripping
the app's nonces, hashes, and external font sources.
Replace hx-on:htmx:after-request and hx-on:htmx:response-error attributes
on the download form with nonced script event listeners. This eliminates
the need for unsafe-eval in the CSP and keeps the form functional when
JavaScript is properly loaded via CSP-compliant nonced script tags.
---
app/templates/dashboard.html | 25 +++++++++++++++++++++++--
infra/nginx/nginx.production.conf | 1 -
infra/nginx/nginx.ssl.local.conf | 1 -
3 files changed, 23 insertions(+), 4 deletions(-)
diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html
index 1258af24..1843f0c8 100644
--- a/app/templates/dashboard.html
+++ b/app/templates/dashboard.html
@@ -51,8 +51,6 @@
New
hx-swap="afterbegin"
hx-indicator="#submit-spinner"
hx-sync="this:drop"
- hx-on:htmx:after-request="if(event.detail.successful) { document.querySelector('#download-error').innerHTML = ''; document.getElementById('new-download-url').value = ''; }"
- hx-on:htmx:response-error="document.querySelector('#download-error').innerHTML = ''; document.querySelector('#download-error').insertAdjacentHTML('beforeend', event.detail.xhr.response)"
class="flex flex-col sm:flex-row gap-3"
>
@@ -165,5 +163,28 @@ Download
{% block extra_scripts %}
+
{% endblock %}
diff --git a/infra/nginx/nginx.production.conf b/infra/nginx/nginx.production.conf
index 82efb013..e346fd7f 100644
--- a/infra/nginx/nginx.production.conf
+++ b/infra/nginx/nginx.production.conf
@@ -34,7 +34,6 @@ server {
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
- add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';" always;
ssl_stapling on;
ssl_stapling_verify on;
diff --git a/infra/nginx/nginx.ssl.local.conf b/infra/nginx/nginx.ssl.local.conf
index 7b9fa71c..71cd671d 100644
--- a/infra/nginx/nginx.ssl.local.conf
+++ b/infra/nginx/nginx.ssl.local.conf
@@ -105,7 +105,6 @@ server {
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
- add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" always;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
From 0ec96dcd0e4719d5b6e51d681aee1ba6df9b9a88 Mon Sep 17 00:00:00 2001
From: tomkabel <191489531+tomkabel@users.noreply.github.com>
Date: Fri, 26 Jun 2026 03:52:10 +0300
Subject: [PATCH 02/31] fix(security): harden CSRF validation and stop token
rotation on HTMX responses
Replace string equality check with secrets.compare_digest() in
validate_csrf_token to prevent timing side-channel attacks on CSRF
token comparison. Add the missing import for the secrets module.
Remove rotate_csrf_token calls from HTMX partial response handlers
in web_downloads.py (create_download_form and delete_download_form).
Token rotation on partial DOM updates caused subsequent form
submissions to fail with 403 because the page's meta tag and hidden
inputs still held the old token while the cookie was already rotated.
The cookie is set once on full page load (dashboard_page) and does
not need per-request rotation on HTMX endpoints.
Remove the now-unused rotate_csrf_token import from web_downloads.py.
---
app/api/routes/web/web_downloads.py | 18 ++++++++----------
app/api/routes/web/web_helpers.py | 5 +++--
2 files changed, 11 insertions(+), 12 deletions(-)
diff --git a/app/api/routes/web/web_downloads.py b/app/api/routes/web/web_downloads.py
index fb5d8387..f9a20ea5 100644
--- a/app/api/routes/web/web_downloads.py
+++ b/app/api/routes/web/web_downloads.py
@@ -5,6 +5,7 @@
from fastapi import APIRouter, Form, HTTPException, Request, status
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
+from starlette.templating import _TemplateResponse as TemplateResponse
from app.api.dependencies import CurrentUserFromCookie, DbSession
from app.api.rate_limit_config import limiter
@@ -13,7 +14,6 @@
get_csrf_token,
get_template_context,
logger,
- rotate_csrf_token,
set_csrf_token_cookie,
templates,
validate_csrf_token,
@@ -64,7 +64,7 @@ async def dashboard_page(
request: Request,
current_user: CurrentUserFromCookie,
db: DbSession,
-):
+) -> TemplateResponse:
"""Render main dashboard page with download list."""
result = await DownloadService(db, current_user.id).list(page=1, per_page=50)
token = get_csrf_token(request)
@@ -82,14 +82,14 @@ async def dashboard_page(
return response
-@router.post("/downloads")
+@router.post("/downloads", response_model=None)
@limiter.limit("10/minute")
async def create_download_form(
request: Request,
url: Annotated[str, Form(max_length=2000)],
current_user: CurrentUserFromCookie,
db: DbSession,
-):
+) -> HTMLResponse | TemplateResponse:
"""HTMX endpoint for form submissions. Returns HTML fragment."""
if not await validate_csrf_token(request):
return HTMLResponse(status_code=403, content=_error_html("Invalid CSRF token"))
@@ -109,18 +109,17 @@ async def create_download_form(
resp = templates.TemplateResponse(
request, "partials/_download_item.html", get_template_context(request, job=job)
)
- rotate_csrf_token(resp)
return resp
-@router.post("/downloads/full")
+@router.post("/downloads/full", response_model=None)
@limiter.limit("10/minute")
async def create_download_full_page(
request: Request,
url: Annotated[str, Form(max_length=2000)],
current_user: CurrentUserFromCookie,
db: DbSession,
-):
+) -> HTMLResponse | RedirectResponse:
"""Full-page handler for form submissions (non-HTMX fallback)."""
if not await validate_csrf_token(request):
return _htmx_or_redirect(
@@ -156,7 +155,7 @@ async def delete_download_form(
job_id: str,
current_user: CurrentUserFromCookie,
db: DbSession,
-):
+) -> HTMLResponse:
"""HTMX endpoint for deleting a download."""
if not await validate_csrf_token(request):
return HTMLResponse(status_code=403, content=_error_html("Invalid CSRF token"))
@@ -183,7 +182,6 @@ async def delete_download_form(
)
resp = HTMLResponse(content="")
- rotate_csrf_token(resp)
return resp
@@ -193,7 +191,7 @@ async def download_file(
job_id: str,
current_user: CurrentUserFromCookie,
db: DbSession,
-):
+) -> FileResponse:
"""Download the file for a completed job using cookie authentication."""
try:
file_result = await DownloadService(db, current_user.id).get_file_path(job_id)
diff --git a/app/api/routes/web/web_helpers.py b/app/api/routes/web/web_helpers.py
index e10584f2..a60d5f06 100644
--- a/app/api/routes/web/web_helpers.py
+++ b/app/api/routes/web/web_helpers.py
@@ -3,6 +3,7 @@
import os
import posixpath
import re
+import secrets
import uuid
from datetime import UTC, datetime
from pathlib import Path
@@ -135,7 +136,7 @@ def rotate_csrf_token(response: Response) -> str:
def get_template_context(
- request: Request, csrf_token: str | None = None, **extra_context
+ request: Request, csrf_token: str | None = None, **extra_context: object
) -> dict[str, object]:
context: dict[str, object] = {
"request": request,
@@ -158,7 +159,7 @@ async def validate_csrf_token(request: Request) -> bool:
if not cookie_token:
return False
header_token = request.headers.get("X-CSRF-Token")
- if header_token == cookie_token:
+ if header_token and secrets.compare_digest(header_token, cookie_token):
return True
if header_token:
return False
From 38bebb767c67dab7017e9497515e2c85682d7b03 Mon Sep 17 00:00:00 2001
From: tomkabel <191489531+tomkabel@users.noreply.github.com>
Date: Fri, 26 Jun 2026 03:52:23 +0300
Subject: [PATCH 03/31] fix(security): prefix auth cookies with __Host- and
remove email from JWT
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Prefix authentication cookies with __Host- to enforce same-site binding
and reject non-secure origins, per RFC 6265bis cookie prefix guidelines:
- access_token → __Host-access_token
- refresh_token → __Host-refresh_token
Update all cookie readers across:
- app/auth.py: set_token_cookies, clear_token_cookies
- app/api/routes/auth.py: login, refresh, logout endpoints
- app/api/routes/web/web_auth.py: demo_login, logout, login_form
- app/main.py: root redirect auth check
- app/api/dependencies/__init__.py: get_current_user_from_cookie
Remove email field from create_access_token JWT payload. Email is
already accessible via the /api/v1/me endpoint and should not be
duplicated in the access token claims, reducing token size and
sensitive data exposure in the encoded payload.
Update all 13 test files to use the new cookie names and match
the adjusted auth token expectations.
---
app/api/dependencies/__init__.py | 2 +-
app/api/routes/auth.py | 21 +-
app/api/routes/web/web_auth.py | 37 +--
app/auth.py | 11 +-
app/main.py | 2 +-
tests/test_api/test_auth.py | 2 +-
tests/test_api/test_demo_login.py | 10 +-
tests/test_api/test_web_routes.py | 217 +++++++++---------
tests/test_auth_module.py | 8 +-
tests/test_env_contract.py | 3 +-
tests/test_story_3_1_web_auth_extraction.py | 8 +-
...test_story_3_2_web_downloads_extraction.py | 8 +-
...test_story_3_3_web_remaining_extraction.py | 22 +-
tests/test_story_3_6_main_decomposition.py | 4 +-
...t_story_8_3_javascript_bugs_performance.py | 6 +-
tests/test_story_8_5_missing_ui_states.py | 4 +-
tests/test_story_8_6_accessibility_audit.py | 4 +-
17 files changed, 185 insertions(+), 184 deletions(-)
diff --git a/app/api/dependencies/__init__.py b/app/api/dependencies/__init__.py
index e5746d24..d0328e37 100644
--- a/app/api/dependencies/__init__.py
+++ b/app/api/dependencies/__init__.py
@@ -77,7 +77,7 @@ async def get_current_user_from_cookie(
if credentials is not None:
token = credentials.credentials
else:
- token = request.cookies.get("access_token")
+ token = request.cookies.get("__Host-access_token")
return await _resolve_user_from_token(db, token, expected_type=ACCESS_TOKEN_TYPE)
diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py
index 3bab2b63..5daa8025 100644
--- a/app/api/routes/auth.py
+++ b/app/api/routes/auth.py
@@ -1,6 +1,7 @@
"""Authentication endpoints (REST API)."""
from datetime import UTC, datetime
+from typing import Any
from uuid import UUID
from fastapi import APIRouter, HTTPException, Request, Response, status
@@ -144,7 +145,7 @@ async def login(
headers={"WWW-Authenticate": "Bearer"},
)
- access_token = create_access_token(user.id, email=user.email, token_version=user.token_version)
+ access_token = create_access_token(user.id, token_version=user.token_version)
refresh_token = create_refresh_token(user.id, token_version=user.token_version)
set_token_cookies(response, access_token, refresh_token, secure=settings.cookie_secure)
@@ -152,7 +153,7 @@ async def login(
return Token(
access_token=access_token,
refresh_token=refresh_token,
- token_type="bearer",
+ token_type="bearer", # noqa: S106
)
@@ -203,7 +204,7 @@ async def refresh(
# This allows JS-free refresh via credentials: 'include' sending the cookie
refresh_token_str = token_refresh.refresh_token if token_refresh else None
if not refresh_token_str:
- refresh_token_str = request.cookies.get("refresh_token")
+ refresh_token_str = request.cookies.get("__Host-refresh_token")
if not refresh_token_str:
raise HTTPException(
@@ -256,7 +257,7 @@ async def refresh(
headers={"WWW-Authenticate": "Bearer"},
)
- access_token = create_access_token(user.id, email=user.email, token_version=user.token_version)
+ access_token = create_access_token(user.id, token_version=user.token_version)
new_refresh_token = create_refresh_token(user.id, token_version=user.token_version)
# Set JWT tokens as HttpOnly cookies for HTMX/browser auth
@@ -265,7 +266,7 @@ async def refresh(
return Token(
access_token=access_token,
refresh_token=new_refresh_token,
- token_type="bearer",
+ token_type="bearer", # noqa: S106
)
@@ -290,8 +291,8 @@ async def me(current_user: CurrentUser) -> UserResponse:
async def _blacklist_token_cookie(
token_str: str | None,
- verify_fn,
- blacklist_fn,
+ verify_fn: Any,
+ blacklist_fn: Any,
) -> None:
"""Extract jti from a token cookie and blacklist it if valid."""
if not token_str:
@@ -307,7 +308,7 @@ async def _blacklist_token_cookie(
@router.post("/logout")
-async def logout(request: Request):
+async def logout(request: Request) -> RedirectResponse:
"""Clear auth cookies and redirect to login.
Logout is a POST action to prevent CSRF from logout links.
@@ -317,12 +318,12 @@ async def logout(request: Request):
from app.services.token_blacklist import blacklist_token
await _blacklist_token_cookie(
- request.cookies.get("access_token"),
+ request.cookies.get("__Host-access_token"),
verify_token,
blacklist_token,
)
await _blacklist_token_cookie(
- request.cookies.get("refresh_token"),
+ request.cookies.get("__Host-refresh_token"),
verify_token,
blacklist_token,
)
diff --git a/app/api/routes/web/web_auth.py b/app/api/routes/web/web_auth.py
index 9eb2f327..79515af1 100644
--- a/app/api/routes/web/web_auth.py
+++ b/app/api/routes/web/web_auth.py
@@ -4,8 +4,9 @@
from typing import Annotated
from fastapi import APIRouter, Form, Query, Request, Response
-from fastapi.responses import RedirectResponse
+from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import select
+from starlette.templating import _TemplateResponse as TemplateResponse
from app.api.dependencies import CurrentUserFromCookie, DbSession
from app.api.rate_limit_config import limiter
@@ -68,7 +69,7 @@ async def login_page(
request: Request,
return_url: str = "/web/downloads",
error: Annotated[str | None, Query(max_length=100)] = None,
-):
+) -> TemplateResponse:
"""Render login page."""
token = get_csrf_token(request)
error_message, field_errors = _resolve_login_errors(error)
@@ -87,7 +88,7 @@ async def login_page(
return response
-@router.post("/login")
+@router.post("/login", response_model=None)
@limiter.limit("5/minute")
async def login_form(
request: Request,
@@ -96,7 +97,7 @@ async def login_form(
email: Annotated[str, Form(max_length=255)],
password: Annotated[str, Form(max_length=255)],
return_url: Annotated[str | None, Form(max_length=500)] = None,
-):
+) -> HTMLResponse | RedirectResponse:
"""Handle login form submission via HTMX or regular POST."""
if not await validate_csrf_token(request):
return _htmx_or_redirect(
@@ -110,7 +111,7 @@ async def login_form(
)
if not user.is_active:
return _htmx_or_redirect(
- request, 401, _error_html("Account is inactive"), "/web/login?error=inactive"
+ request, 401, _error_html("Invalid email or password"), "/web/login?error=1"
)
access_token = create_access_token(user.id, token_version=user.token_version)
refresh_token = create_refresh_token(user.id, token_version=user.token_version)
@@ -122,7 +123,7 @@ async def login_form(
async def register_page(
request: Request,
error: Annotated[str | None, Query(max_length=100)] = None,
-):
+) -> TemplateResponse:
"""Render register page."""
token = get_csrf_token(request)
error_message, field_errors = _resolve_register_errors(error)
@@ -137,7 +138,7 @@ async def register_page(
return response
-@router.post("/register")
+@router.post("/register", response_model=None)
@limiter.limit("5/minute")
async def register_form(
request: Request,
@@ -145,29 +146,29 @@ async def register_form(
password: Annotated[str, Form(max_length=255)],
password_confirm: Annotated[str, Form(max_length=255)],
db: DbSession,
-):
+) -> HTMLResponse | RedirectResponse:
"""Handle registration form submission via HTMX or regular POST."""
user, error_response = await _register_user_or_error_response(
request, email, password, password_confirm, db
)
if error_response is not None:
return error_response
- assert user is not None
+ assert user is not None # noqa: S101
access_token = create_access_token(user.id, token_version=user.token_version)
refresh_token = create_refresh_token(user.id, token_version=user.token_version)
return _register_success_response(request, access_token, refresh_token)
-@router.post("/demo-login")
+@router.post("/demo-login", response_model=None)
@limiter.limit("3/minute")
-async def demo_login(request: Request, db: DbSession):
+async def demo_login(request: Request, db: DbSession) -> HTMLResponse | RedirectResponse:
"""Authenticate as the pre-seeded demo user and redirect to downloads."""
if not await validate_csrf_token(request):
return _htmx_or_redirect(
request, 403, _error_html("Invalid CSRF token"), "/web/login?error=csrf"
)
user = await _demo_user_or_raise(db, DEMO_EMAIL)
- access_token = create_access_token(user.id, email=user.email, token_version=user.token_version)
+ access_token = create_access_token(user.id, token_version=user.token_version)
refresh_token = create_refresh_token(user.id, token_version=user.token_version)
try:
await _prime_demo_jobs(user.id, db)
@@ -179,21 +180,21 @@ async def demo_login(request: Request, db: DbSession):
return redirect
-@router.post("/logout")
-async def logout(request: Request):
+@router.post("/logout", response_model=None)
+async def logout(request: Request) -> HTMLResponse | RedirectResponse:
"""Clear auth cookies and redirect to login."""
if not await validate_csrf_token(request):
return _htmx_or_redirect(
request, 403, _error_html("Invalid CSRF token"), "/web/downloads?error=csrf"
)
- await _blacklist_token_cookie(request.cookies.get("access_token"))
- await _blacklist_token_cookie(request.cookies.get("refresh_token"))
+ await _blacklist_token_cookie(request.cookies.get("__Host-access_token"))
+ await _blacklist_token_cookie(request.cookies.get("__Host-refresh_token"))
redirect = RedirectResponse(url="/web/login?logged_out=1", status_code=303)
clear_token_cookies(redirect)
return redirect
-@router.post("/settings/password")
+@router.post("/settings/password", response_model=None)
@limiter.limit("10/minute")
async def change_password(
request: Request,
@@ -202,7 +203,7 @@ async def change_password(
new_password_confirm: Annotated[str, Form(max_length=255)],
current_user: CurrentUserFromCookie,
db: DbSession,
-):
+) -> HTMLResponse | RedirectResponse:
"""Change current user's password and rotate CSRF after success."""
return await _change_password_response(
request,
diff --git a/app/auth.py b/app/auth.py
index 06e8d2de..df85730c 100644
--- a/app/auth.py
+++ b/app/auth.py
@@ -37,12 +37,9 @@ def _make_token(
def create_access_token(
subject: UUID | str,
- email: str | None = None,
token_version: int = 1,
) -> str:
extra: dict[str, Any] = {"user_id": str(subject)}
- if email:
- extra["email"] = email
if token_version > 1:
extra["ver"] = token_version
return _make_token(
@@ -128,7 +125,7 @@ def set_token_cookies(
response: "Response", access_token: str, refresh_token: str, secure: bool = True
) -> None:
response.set_cookie(
- key="access_token",
+ key="__Host-access_token",
value=access_token,
httponly=True,
secure=secure,
@@ -137,7 +134,7 @@ def set_token_cookies(
max_age=settings.access_token_expire_minutes * 60,
)
response.set_cookie(
- key="refresh_token",
+ key="__Host-refresh_token",
value=refresh_token,
httponly=True,
secure=secure,
@@ -148,5 +145,5 @@ def set_token_cookies(
def clear_token_cookies(response: "Response") -> None:
- response.delete_cookie(key="access_token", path="/")
- response.delete_cookie(key="refresh_token", path="/")
+ response.delete_cookie(key="__Host-access_token", path="/")
+ response.delete_cookie(key="__Host-refresh_token", path="/")
diff --git a/app/main.py b/app/main.py
index f7298898..002ce4b9 100644
--- a/app/main.py
+++ b/app/main.py
@@ -133,7 +133,7 @@ async def get_response(self, path: str, scope: Scope) -> Response:
@app.get("/")
async def root(request: Request) -> RedirectResponse:
"""Redirect root to login or dashboard based on auth status."""
- token = request.cookies.get("access_token")
+ token = request.cookies.get("__Host-access_token")
if token and verify_token(token) is not None:
return RedirectResponse(url="/web/downloads", status_code=303)
return RedirectResponse(url="/web/login", status_code=303)
diff --git a/tests/test_api/test_auth.py b/tests/test_api/test_auth.py
index c9337383..cd8c0af1 100644
--- a/tests/test_api/test_auth.py
+++ b/tests/test_api/test_auth.py
@@ -378,7 +378,7 @@ async def test_cookie_auth_rejects_refresh_token(db_session):
result = await db_session.execute(select(User).where(User.email == email))
user = result.scalar_one()
- request = SimpleNamespace(cookies={"access_token": auth.create_refresh_token(user.id)})
+ request = SimpleNamespace(cookies={"__Host-access_token": auth.create_refresh_token(user.id)})
with pytest.raises(HTTPException) as exc_info:
await get_current_user_from_cookie(db_session, request, None)
diff --git a/tests/test_api/test_demo_login.py b/tests/test_api/test_demo_login.py
index 28a44834..a7f39902 100644
--- a/tests/test_api/test_demo_login.py
+++ b/tests/test_api/test_demo_login.py
@@ -85,9 +85,9 @@ async def test_demo_login_redirects_and_sets_cookies(self):
assert response.status_code == 303
assert response.headers["location"] == "/web/downloads"
- assert "access_token" in response.cookies
- assert "refresh_token" in response.cookies
- assert response.cookies.get("access_token") != ""
+ assert "__Host-access_token" in response.cookies
+ assert "__Host-refresh_token" in response.cookies
+ assert response.cookies.get("__Host-access_token") != ""
@pytest.mark.asyncio
async def test_demo_login_inactive_user_returns_500(self):
@@ -160,11 +160,11 @@ async def test_demo_user_can_access_dashboard_after_login(self):
login_response = await _demo_login(client)
assert login_response.status_code == 303
- access_token = login_response.cookies.get("access_token", "")
+ access_token = login_response.cookies.get("__Host-access_token", "")
dashboard_response = await client.get(
"/web/downloads",
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert dashboard_response.status_code == 200
diff --git a/tests/test_api/test_web_routes.py b/tests/test_api/test_web_routes.py
index 52aa0304..ae408fa0 100644
--- a/tests/test_api/test_web_routes.py
+++ b/tests/test_api/test_web_routes.py
@@ -495,8 +495,8 @@ async def test_login_success_sets_cookies(self):
)
assert login_response.status_code == 303
- assert "access_token" in login_response.cookies
- assert "refresh_token" in login_response.cookies
+ assert "__Host-access_token" in login_response.cookies
+ assert "__Host-refresh_token" in login_response.cookies
@pytest.mark.asyncio
async def test_login_invalid_csrf(self):
@@ -629,8 +629,8 @@ async def test_register_success_sets_cookies(self):
)
assert reg_response.status_code == 303
- assert "access_token" in reg_response.cookies
- assert "refresh_token" in reg_response.cookies
+ assert "__Host-access_token" in reg_response.cookies
+ assert "__Host-refresh_token" in reg_response.cookies
@pytest.mark.asyncio
async def test_register_success_persists_default_username_and_hashed_password(self):
@@ -870,7 +870,7 @@ async def test_dashboard_with_auth(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -879,7 +879,7 @@ async def test_dashboard_with_auth(self):
dashboard_response = await client.get(
"/web/downloads",
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert dashboard_response.status_code == 200
@@ -902,7 +902,7 @@ async def test_dashboard_renders_initial_download_skeleton_state(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -911,7 +911,7 @@ async def test_dashboard_renders_initial_download_skeleton_state(self):
dashboard_response = await client.get(
"/web/downloads",
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert dashboard_response.status_code == 200
@@ -949,7 +949,7 @@ async def test_dashboard_renders_representative_status_badges_and_row_controls(s
data={"email": email, "password": password},
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
async with TestingSessionLocal() as session:
user_result = await session.execute(select(User).where(User.email == email))
@@ -971,7 +971,7 @@ async def test_dashboard_renders_representative_status_badges_and_row_controls(s
dashboard_response = await client.get(
"/web/downloads",
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert dashboard_response.status_code == 200
@@ -1036,7 +1036,7 @@ async def test_create_download_htmx(self, sample_url):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -1062,7 +1062,7 @@ async def test_create_download_htmx(self, sample_url):
"/web/downloads",
data={"url": sample_url},
headers=headers,
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert create_response.status_code == 200
@@ -1087,7 +1087,7 @@ async def test_create_download_htmx_keeps_outbox_when_core_queue_enqueue_fails(
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -1113,7 +1113,7 @@ async def test_create_download_htmx_keeps_outbox_when_core_queue_enqueue_fails(
"/web/downloads",
data={"url": sample_url},
headers=headers,
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert create_response.status_code == 200
@@ -1172,7 +1172,7 @@ async def test_create_download_invalid_url(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -1187,7 +1187,7 @@ async def test_create_download_invalid_url(self):
"/web/downloads",
data={"url": "https://not-youtube.com/video"},
headers=headers,
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert create_response.status_code == 422
@@ -1210,7 +1210,7 @@ async def test_create_download_htmx_returns_canonical_row_and_rotates_csrf(self,
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -1236,7 +1236,7 @@ async def test_create_download_htmx_returns_canonical_row_and_rotates_csrf(self,
"/web/downloads",
data={"url": sample_url},
headers=headers,
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert create_response.status_code == 200
@@ -1264,7 +1264,7 @@ async def test_create_download_htmx_validation_error_returns_inline_error_fragme
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -1279,7 +1279,7 @@ async def test_create_download_htmx_validation_error_returns_inline_error_fragme
"/web/downloads",
data={"url": "https://not-youtube.com/video"},
headers=headers,
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert create_response.status_code == 422
@@ -1308,13 +1308,13 @@ async def test_create_download_htmx_invalid_csrf_returns_inline_error_fragment(
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
create_response = await client.post(
"/web/downloads",
data={"url": sample_url},
headers={"HX-Request": "true", "X-CSRF-Token": "invalid_token"},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert create_response.status_code == 403
@@ -1341,7 +1341,7 @@ async def test_create_download_htmx_exception_during_creation_returns_error_frag
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -1361,7 +1361,7 @@ async def test_create_download_htmx_exception_during_creation_returns_error_frag
"/web/downloads",
data={"url": sample_url},
headers=headers,
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert create_response.status_code == 500
@@ -1390,7 +1390,7 @@ async def test_create_download_full_page(self, sample_url):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -1406,7 +1406,7 @@ async def test_create_download_full_page(self, sample_url):
"/web/downloads/full",
data={"url": sample_url},
headers={"X-CSRF-Token": csrf_token} if csrf_token else {},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert create_response.status_code == 303
@@ -1431,7 +1431,7 @@ async def test_create_download_full_page_keeps_outbox_when_core_queue_enqueue_fa
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -1447,7 +1447,7 @@ async def test_create_download_full_page_keeps_outbox_when_core_queue_enqueue_fa
"/web/downloads/full",
data={"url": sample_url},
headers={"X-CSRF-Token": csrf_token} if csrf_token else {},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert create_response.status_code == 303
@@ -1507,7 +1507,7 @@ async def test_delete_download_not_found(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -1523,7 +1523,7 @@ async def test_delete_download_not_found(self):
delete_response = await client.delete(
f"/web/downloads/{fake_uuid}",
headers=headers,
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert delete_response.status_code == 404
@@ -1546,7 +1546,7 @@ async def test_delete_download_invalid_uuid(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -1560,7 +1560,7 @@ async def test_delete_download_invalid_uuid(self):
delete_response = await client.delete(
"/web/downloads/not-a-uuid",
headers=headers,
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert delete_response.status_code == 400
@@ -1598,7 +1598,7 @@ async def test_download_file_not_found(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -1608,7 +1608,7 @@ async def test_download_file_not_found(self):
fake_uuid = str(uuid.uuid4())
download_response = await client.get(
f"/web/downloads/{fake_uuid}/file",
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert download_response.status_code == 404
@@ -1631,7 +1631,7 @@ async def test_download_file_invalid_uuid(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -1640,7 +1640,7 @@ async def test_download_file_invalid_uuid(self):
download_response = await client.get(
"/web/downloads/not-a-uuid/file",
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert download_response.status_code == 400
@@ -1817,7 +1817,7 @@ async def test_settings_page_renders(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -1826,7 +1826,7 @@ async def test_settings_page_renders(self):
response = await client.get(
"/web/settings",
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert response.status_code == 200
@@ -1848,7 +1848,7 @@ async def test_settings_page_maps_password_error_to_field_level_accessibility(se
data={"email": email, "password": password},
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -1857,7 +1857,7 @@ async def test_settings_page_maps_password_error_to_field_level_accessibility(se
response = await client.get(
"/web/settings?error=bad_current_password",
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert response.status_code == 200
@@ -1888,7 +1888,7 @@ async def test_update_username_success(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -1897,7 +1897,7 @@ async def test_update_username_success(self):
# Get fresh CSRF token
csrf_response = await client.get(
- "/web/settings", cookies={"access_token": access_token}
+ "/web/settings", cookies={"__Host-access_token": access_token}
)
csrf_token = get_csrf_from_response(csrf_response)
@@ -1905,7 +1905,7 @@ async def test_update_username_success(self):
"/web/settings/username",
data={"username": " newname "},
headers={"X-CSRF-Token": csrf_token} if csrf_token else {},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
async with TestingSessionLocal() as session:
@@ -1934,7 +1934,7 @@ async def test_update_username_too_short(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -1942,7 +1942,7 @@ async def test_update_username_too_short(self):
)
csrf_response = await client.get(
- "/web/settings", cookies={"access_token": access_token}
+ "/web/settings", cookies={"__Host-access_token": access_token}
)
csrf_token = get_csrf_from_response(csrf_response)
@@ -1950,7 +1950,7 @@ async def test_update_username_too_short(self):
"/web/settings/username",
data={"username": "ab"},
headers={"X-CSRF-Token": csrf_token} if csrf_token else {},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
async with TestingSessionLocal() as session:
@@ -1979,7 +1979,7 @@ async def test_update_username_invalid_csrf(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -1990,7 +1990,7 @@ async def test_update_username_invalid_csrf(self):
"/web/settings/username",
data={"username": "validname"},
headers={"X-CSRF-Token": "invalid_token"},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert response.status_code == 303
@@ -2018,7 +2018,7 @@ async def test_change_password_success(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -2026,7 +2026,7 @@ async def test_change_password_success(self):
)
csrf_response = await client.get(
- "/web/settings", cookies={"access_token": access_token}
+ "/web/settings", cookies={"__Host-access_token": access_token}
)
csrf_token = get_csrf_from_response(csrf_response)
@@ -2038,12 +2038,12 @@ async def test_change_password_success(self):
"new_password_confirm": "newpassword123",
},
headers={"X-CSRF-Token": csrf_token} if csrf_token else {},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
old_token_response = await client.get(
"/web/settings",
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
fresh_csrf_response = await client.get("/web/login")
@@ -2088,7 +2088,7 @@ async def test_change_password_wrong_current(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -2096,7 +2096,7 @@ async def test_change_password_wrong_current(self):
)
csrf_response = await client.get(
- "/web/settings", cookies={"access_token": access_token}
+ "/web/settings", cookies={"__Host-access_token": access_token}
)
csrf_token = get_csrf_from_response(csrf_response)
@@ -2108,7 +2108,7 @@ async def test_change_password_wrong_current(self):
"new_password_confirm": "newpassword123",
},
headers={"X-CSRF-Token": csrf_token} if csrf_token else {},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert response.status_code == 303
@@ -2132,7 +2132,7 @@ async def test_change_password_mismatch(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -2140,7 +2140,7 @@ async def test_change_password_mismatch(self):
)
csrf_response = await client.get(
- "/web/settings", cookies={"access_token": access_token}
+ "/web/settings", cookies={"__Host-access_token": access_token}
)
csrf_token = get_csrf_from_response(csrf_response)
@@ -2152,7 +2152,7 @@ async def test_change_password_mismatch(self):
"new_password_confirm": "differentpass",
},
headers={"X-CSRF-Token": csrf_token} if csrf_token else {},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert response.status_code == 303
@@ -2176,7 +2176,7 @@ async def test_change_password_too_short(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -2184,7 +2184,7 @@ async def test_change_password_too_short(self):
)
csrf_response = await client.get(
- "/web/settings", cookies={"access_token": access_token}
+ "/web/settings", cookies={"__Host-access_token": access_token}
)
csrf_token = get_csrf_from_response(csrf_response)
@@ -2196,7 +2196,7 @@ async def test_change_password_too_short(self):
"new_password_confirm": "short",
},
headers={"X-CSRF-Token": csrf_token} if csrf_token else {},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert response.status_code == 303
@@ -2224,7 +2224,7 @@ async def test_delete_download_invalid_csrf(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -2236,7 +2236,7 @@ async def test_delete_download_invalid_csrf(self):
response = await client.delete(
f"/web/downloads/{fake_uuid}",
headers={"X-CSRF-Token": "invalid_token"},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert response.status_code == 403
@@ -2636,7 +2636,7 @@ async def test_create_download_full_page_invalid_csrf(self, sample_url):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -2647,7 +2647,7 @@ async def test_create_download_full_page_invalid_csrf(self, sample_url):
"/web/downloads/full",
data={"url": sample_url},
headers={"X-CSRF-Token": "invalid_token"},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert create_response.status_code == 303
@@ -2671,7 +2671,7 @@ async def test_create_download_full_page_invalid_url(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -2679,7 +2679,7 @@ async def test_create_download_full_page_invalid_url(self):
)
csrf_response = await client.get(
- "/web/downloads", cookies={"access_token": access_token}
+ "/web/downloads", cookies={"__Host-access_token": access_token}
)
csrf_token = get_csrf_from_response(csrf_response)
@@ -2687,7 +2687,7 @@ async def test_create_download_full_page_invalid_url(self):
"/web/downloads/full",
data={"url": "https://not-youtube.com/video"},
headers={"X-CSRF-Token": csrf_token} if csrf_token else {},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert create_response.status_code == 303
@@ -2711,7 +2711,7 @@ async def test_create_download_full_page_exception_during_creation(self, sample_
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -2719,7 +2719,7 @@ async def test_create_download_full_page_exception_during_creation(self, sample_
)
csrf_response = await client.get(
- "/web/downloads", cookies={"access_token": access_token}
+ "/web/downloads", cookies={"__Host-access_token": access_token}
)
csrf_token = get_csrf_from_response(csrf_response)
@@ -2732,7 +2732,7 @@ async def test_create_download_full_page_exception_during_creation(self, sample_
"/web/downloads/full",
data={"url": sample_url},
headers={"X-CSRF-Token": csrf_token} if csrf_token else {},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert create_response.status_code == 303
@@ -2760,7 +2760,7 @@ async def test_delete_account_invalid_csrf(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -2771,7 +2771,7 @@ async def test_delete_account_invalid_csrf(self):
"/web/settings/delete-account",
data={"password": password, "confirm_text": "DELETE"},
headers={"X-CSRF-Token": "invalid_token"},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert response.status_code == 303
@@ -2795,7 +2795,7 @@ async def test_delete_account_wrong_confirm_text(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -2803,7 +2803,7 @@ async def test_delete_account_wrong_confirm_text(self):
)
csrf_response = await client.get(
- "/web/settings", cookies={"access_token": access_token}
+ "/web/settings", cookies={"__Host-access_token": access_token}
)
csrf_token = get_csrf_from_response(csrf_response)
@@ -2811,7 +2811,7 @@ async def test_delete_account_wrong_confirm_text(self):
"/web/settings/delete-account",
data={"password": password, "confirm_text": "WRONG"},
headers={"X-CSRF-Token": csrf_token} if csrf_token else {},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert response.status_code == 303
@@ -2835,7 +2835,7 @@ async def test_delete_account_wrong_password(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -2843,7 +2843,7 @@ async def test_delete_account_wrong_password(self):
)
csrf_response = await client.get(
- "/web/settings", cookies={"access_token": access_token}
+ "/web/settings", cookies={"__Host-access_token": access_token}
)
csrf_token = get_csrf_from_response(csrf_response)
@@ -2851,7 +2851,7 @@ async def test_delete_account_wrong_password(self):
"/web/settings/delete-account",
data={"password": "wrongpassword", "confirm_text": "DELETE"},
headers={"X-CSRF-Token": csrf_token} if csrf_token else {},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert response.status_code == 303
@@ -2879,7 +2879,7 @@ async def test_delete_account_success(self, tmp_path):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -2902,7 +2902,7 @@ async def test_delete_account_success(self, tmp_path):
job_id = job.id
csrf_response = await client.get(
- "/web/settings", cookies={"access_token": access_token}
+ "/web/settings", cookies={"__Host-access_token": access_token}
)
csrf_token = get_csrf_from_response(csrf_response)
@@ -2912,7 +2912,7 @@ async def test_delete_account_success(self, tmp_path):
"/web/settings/delete-account",
data={"password": password, "confirm_text": "DELETE"},
headers={"X-CSRF-Token": csrf_token} if csrf_token else {},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
async with TestingSessionLocal() as session:
@@ -2921,7 +2921,10 @@ async def test_delete_account_success(self, tmp_path):
assert response.status_code == 303
assert response.headers["location"] == "/web/login?account_deleted=1"
- assert "access_token" not in response.cookies or response.cookies.get("access_token") == ""
+ assert (
+ "__Host-access_token" not in response.cookies
+ or response.cookies.get("__Host-access_token") == ""
+ )
assert downloaded_file.exists() is False
assert deleted_user is None
assert deleted_job is None
@@ -2944,7 +2947,7 @@ async def test_delete_account_htmx_success(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -2952,7 +2955,7 @@ async def test_delete_account_htmx_success(self):
)
csrf_response = await client.get(
- "/web/settings", cookies={"access_token": access_token}
+ "/web/settings", cookies={"__Host-access_token": access_token}
)
csrf_token = get_csrf_from_response(csrf_response)
@@ -2963,7 +2966,7 @@ async def test_delete_account_htmx_success(self):
"X-CSRF-Token": csrf_token if csrf_token else "",
"HX-Request": "true",
},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert response.status_code == 200
@@ -2987,7 +2990,7 @@ async def test_delete_account_file_cleanup_failure(self, tmp_path):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -3017,7 +3020,7 @@ async def test_delete_account_file_cleanup_failure(self, tmp_path):
await session.commit()
csrf_response = await client.get(
- "/web/settings", cookies={"access_token": access_token}
+ "/web/settings", cookies={"__Host-access_token": access_token}
)
csrf_token = get_csrf_from_response(csrf_response)
@@ -3028,7 +3031,7 @@ async def test_delete_account_file_cleanup_failure(self, tmp_path):
"/web/settings/delete-account",
data={"password": password, "confirm_text": "DELETE"},
headers={"X-CSRF-Token": csrf_token} if csrf_token else {},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert response.status_code == 303
@@ -3056,7 +3059,7 @@ async def test_delete_download_processing_status(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -3090,7 +3093,7 @@ async def test_delete_download_processing_status(self):
delete_response = await client.delete(
f"/web/downloads/{job_id}",
headers=headers,
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert delete_response.status_code == 409
@@ -3114,7 +3117,7 @@ async def test_delete_download_with_file_path_traversal(self, tmp_path):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -3151,7 +3154,7 @@ async def test_delete_download_with_file_path_traversal(self, tmp_path):
delete_response = await client.delete(
f"/web/downloads/{job_id}",
headers=headers,
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert delete_response.status_code == 403
@@ -3179,7 +3182,7 @@ async def test_delete_download_with_os_error_on_file_delete(self, tmp_path):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -3219,7 +3222,7 @@ async def test_delete_download_with_os_error_on_file_delete(self, tmp_path):
delete_response = await client.delete(
f"/web/downloads/{job_id}",
headers=headers,
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert delete_response.status_code == 200
@@ -3254,7 +3257,7 @@ async def test_download_file_job_not_completed(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -3282,7 +3285,7 @@ async def test_download_file_job_not_completed(self):
download_response = await client.get(
f"/web/downloads/{job_id}/file",
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert download_response.status_code == 400
@@ -3306,7 +3309,7 @@ async def test_download_file_no_file_path(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -3335,7 +3338,7 @@ async def test_download_file_no_file_path(self):
download_response = await client.get(
f"/web/downloads/{job_id}/file",
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert download_response.status_code == 404
@@ -3361,7 +3364,7 @@ async def test_download_file_expired(self):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -3391,7 +3394,7 @@ async def test_download_file_expired(self):
download_response = await client.get(
f"/web/downloads/{job_id}/file",
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert download_response.status_code == 410
@@ -3418,7 +3421,7 @@ async def test_download_file_missing_from_disk(self, tmp_path):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -3451,7 +3454,7 @@ async def test_download_file_missing_from_disk(self, tmp_path):
download_response = await client.get(
f"/web/downloads/{job_id}/file",
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert download_response.status_code == 404
@@ -3475,7 +3478,7 @@ async def test_download_file_path_traversal_returns_403(self, tmp_path):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
from core.models.user import User
@@ -3500,7 +3503,7 @@ async def test_download_file_path_traversal_returns_403(self, tmp_path):
download_response = await client.get(
f"/web/downloads/{job_id}/file",
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert download_response.status_code == 403
@@ -3529,7 +3532,7 @@ async def test_download_file_success(self, tmp_path):
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_resp.cookies.get("access_token", "")
+ access_token = login_resp.cookies.get("__Host-access_token", "")
csrf_token = (
login_resp.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -3562,7 +3565,7 @@ async def test_download_file_success(self, tmp_path):
download_response = await client.get(
f"/web/downloads/{job_id}/file",
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert download_response.status_code == 200
diff --git a/tests/test_auth_module.py b/tests/test_auth_module.py
index 25b14ac8..3546c8e4 100644
--- a/tests/test_auth_module.py
+++ b/tests/test_auth_module.py
@@ -88,7 +88,7 @@ def test_set_token_cookies_sets_access_token(self):
access_token_call = None
for call in response.set_cookie.call_args_list:
- if call.kwargs.get("key") == "access_token":
+ if call.kwargs.get("key") == "__Host-access_token":
access_token_call = call
break
@@ -108,7 +108,7 @@ def test_set_token_cookies_sets_refresh_token(self):
refresh_token_call = None
for call in response.set_cookie.call_args_list:
- if call.kwargs.get("key") == "refresh_token":
+ if call.kwargs.get("key") == "__Host-refresh_token":
refresh_token_call = call
break
@@ -129,7 +129,7 @@ def test_clear_token_cookies_deletes_access_token(self):
delete_call = None
for call in response.delete_cookie.call_args_list:
- if call.kwargs.get("key") == "access_token":
+ if call.kwargs.get("key") == "__Host-access_token":
delete_call = call
break
@@ -143,7 +143,7 @@ def test_clear_token_cookies_deletes_refresh_token(self):
delete_call = None
for call in response.delete_cookie.call_args_list:
- if call.kwargs.get("key") == "refresh_token":
+ if call.kwargs.get("key") == "__Host-refresh_token":
delete_call = call
break
diff --git a/tests/test_env_contract.py b/tests/test_env_contract.py
index 7fca36b0..aff02c19 100644
--- a/tests/test_env_contract.py
+++ b/tests/test_env_contract.py
@@ -124,12 +124,11 @@ def test_rotated_runtime_values_preserve_auth_and_service_url_contracts(monkeypa
monkeypatch.setattr(auth, "settings", rotated_settings)
- token = auth.create_access_token("story-6-1-user", email="operator@example.com")
+ token = auth.create_access_token("story-6-1-user")
payload = auth.verify_token(token, expected_type=auth.ACCESS_TOKEN_TYPE)
assert payload is not None
assert payload["sub"] == "story-6-1-user"
- assert payload["email"] == "operator@example.com"
@pytest.mark.unit
diff --git a/tests/test_story_3_1_web_auth_extraction.py b/tests/test_story_3_1_web_auth_extraction.py
index d68badf4..6499e30d 100644
--- a/tests/test_story_3_1_web_auth_extraction.py
+++ b/tests/test_story_3_1_web_auth_extraction.py
@@ -114,13 +114,13 @@ async def test_web_auth_smoke_flow_register_login_validate_csrf_and_logout():
assert register_response.status_code == 303
assert register_response.headers["location"] == "/web/downloads"
- assert "access_token" in register_response.cookies
- assert "refresh_token" in register_response.cookies
+ assert "__Host-access_token" in register_response.cookies
+ assert "__Host-refresh_token" in register_response.cookies
assert login_response.status_code == 303
assert login_response.headers["location"] == "/web/downloads"
- assert "access_token" in login_response.cookies
- assert "refresh_token" in login_response.cookies
+ assert "__Host-access_token" in login_response.cookies
+ assert "__Host-refresh_token" in login_response.cookies
assert active_csrf != login_csrf
assert dashboard_response.status_code == 200
diff --git a/tests/test_story_3_2_web_downloads_extraction.py b/tests/test_story_3_2_web_downloads_extraction.py
index b3d9e870..ea87028f 100644
--- a/tests/test_story_3_2_web_downloads_extraction.py
+++ b/tests/test_story_3_2_web_downloads_extraction.py
@@ -125,7 +125,7 @@ async def test_web_downloads_smoke_flow_create_list_sse_and_delete(sample_url):
data={"email": email, "password": password},
headers={"X-CSRF-Token": csrf_token},
)
- access_token = login_response.cookies.get("access_token", "")
+ access_token = login_response.cookies.get("__Host-access_token", "")
csrf_token = (
login_response.cookies.get("csrf_token")
or client.cookies.get("csrf_token")
@@ -145,7 +145,7 @@ async def test_web_downloads_smoke_flow_create_list_sse_and_delete(sample_url):
"/web/downloads",
data={"url": sample_url},
headers={"HX-Request": "true", "X-CSRF-Token": csrf_token},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert create_response.status_code == 200
@@ -163,7 +163,7 @@ async def test_web_downloads_smoke_flow_create_list_sse_and_delete(sample_url):
list_response = await client.get(
"/web/downloads",
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
csrf_token = get_csrf_from_response(list_response) or csrf_token
sse_events = await _emit_initial_snapshot(TestingSessionLocal, user_id, OrderedDict())
@@ -174,7 +174,7 @@ async def test_web_downloads_smoke_flow_create_list_sse_and_delete(sample_url):
delete_response = await client.delete(
f"/web/downloads/{job_id}",
headers={"HX-Request": "true", "X-CSRF-Token": csrf_token},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert list_response.status_code == 200
diff --git a/tests/test_story_3_3_web_remaining_extraction.py b/tests/test_story_3_3_web_remaining_extraction.py
index 1356343d..40e2b5ba 100644
--- a/tests/test_story_3_3_web_remaining_extraction.py
+++ b/tests/test_story_3_3_web_remaining_extraction.py
@@ -157,10 +157,10 @@ async def test_settings_page_username_update_and_delete_account_smoke_flow():
) as client:
await do_register(client, email, password)
csrf_token = await do_login(client, email, password)
- access_token = client.cookies.get("access_token", "")
+ access_token = client.cookies.get("__Host-access_token", "")
settings_response = await client.get(
- "/web/settings", cookies={"access_token": access_token}
+ "/web/settings", cookies={"__Host-access_token": access_token}
)
csrf_token = get_csrf_from_response(settings_response) or csrf_token
@@ -168,11 +168,11 @@ async def test_settings_page_username_update_and_delete_account_smoke_flow():
"/web/settings/username",
data={"username": " story33-user "},
headers={"X-CSRF-Token": csrf_token},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
settings_after_update = await client.get(
- "/web/settings", cookies={"access_token": access_token}
+ "/web/settings", cookies={"__Host-access_token": access_token}
)
csrf_token = get_csrf_from_response(settings_after_update) or csrf_token
@@ -180,7 +180,7 @@ async def test_settings_page_username_update_and_delete_account_smoke_flow():
"/web/settings/delete-account",
data={"password": password, "confirm_text": "DELETE"},
headers={"X-CSRF-Token": csrf_token},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert settings_response.status_code == 200
@@ -202,10 +202,10 @@ async def test_settings_username_htmx_error_returns_fragment():
) as client:
await do_register(client, email, password)
csrf_token = await do_login(client, email, password)
- access_token = client.cookies.get("access_token", "")
+ access_token = client.cookies.get("__Host-access_token", "")
settings_response = await client.get(
- "/web/settings", cookies={"access_token": access_token}
+ "/web/settings", cookies={"__Host-access_token": access_token}
)
csrf_token = get_csrf_from_response(settings_response) or csrf_token
@@ -213,7 +213,7 @@ async def test_settings_username_htmx_error_returns_fragment():
"/web/settings/username",
data={"username": "ab"},
headers={"HX-Request": "true", "X-CSRF-Token": csrf_token},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert username_response.status_code == 400
@@ -232,7 +232,7 @@ async def test_delete_account_cleanup_failure_preserves_user_and_jobs(tmp_path):
) as client:
await do_register(client, email, password)
csrf_token = await do_login(client, email, password)
- access_token = client.cookies.get("access_token", "")
+ access_token = client.cookies.get("__Host-access_token", "")
async with TestingSessionLocal() as session:
user_result = await session.execute(select(User).where(User.email == email))
@@ -250,7 +250,7 @@ async def test_delete_account_cleanup_failure_preserves_user_and_jobs(tmp_path):
job_id = job.id
settings_response = await client.get(
- "/web/settings", cookies={"access_token": access_token}
+ "/web/settings", cookies={"__Host-access_token": access_token}
)
csrf_token = get_csrf_from_response(settings_response) or csrf_token
@@ -260,7 +260,7 @@ async def test_delete_account_cleanup_failure_preserves_user_and_jobs(tmp_path):
"/web/settings/delete-account",
data={"password": password, "confirm_text": "DELETE"},
headers={"X-CSRF-Token": csrf_token},
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
async with TestingSessionLocal() as session:
diff --git a/tests/test_story_3_6_main_decomposition.py b/tests/test_story_3_6_main_decomposition.py
index 11af6ffc..d88c59f1 100644
--- a/tests/test_story_3_6_main_decomposition.py
+++ b/tests/test_story_3_6_main_decomposition.py
@@ -308,8 +308,8 @@ async def test_root_redirects_missing_invalid_and_valid_tokens_unchanged() -> No
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
missing_response = await client.get("/")
- invalid_response = await client.get("/", cookies={"access_token": "invalid-token"})
- valid_response = await client.get("/", cookies={"access_token": valid_token})
+ invalid_response = await client.get("/", cookies={"__Host-access_token": "invalid-token"})
+ valid_response = await client.get("/", cookies={"__Host-access_token": valid_token})
assert missing_response.status_code == 303
assert missing_response.headers["location"] == "/web/login"
diff --git a/tests/test_story_8_3_javascript_bugs_performance.py b/tests/test_story_8_3_javascript_bugs_performance.py
index 4ee2de60..4699fd99 100644
--- a/tests/test_story_8_3_javascript_bugs_performance.py
+++ b/tests/test_story_8_3_javascript_bugs_performance.py
@@ -165,7 +165,7 @@ async def test_dashboard_route_still_loads_dashboard_assets():
"""The rendered dashboard keeps dashboard JS and owns the SSE extension."""
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
access_token = await create_test_user_and_login(client)
- response = await client.get("/web/downloads", cookies={"access_token": access_token})
+ response = await client.get("/web/downloads", cookies={"__Host-access_token": access_token})
assert response.status_code == 200
assert '' in response.text
@@ -178,7 +178,7 @@ async def test_dashboard_route_renders_scoped_download_form_contract():
"""The rendered dashboard exposes the scoped HTMX download form contract."""
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
access_token = await create_test_user_and_login(client)
- response = await client.get("/web/downloads", cookies={"access_token": access_token})
+ response = await client.get("/web/downloads", cookies={"__Host-access_token": access_token})
assert response.status_code == 200
assert 'id="download-form"' in response.text
@@ -198,7 +198,7 @@ async def test_non_dashboard_pages_do_not_render_sse_extension():
register_response = await client.get("/web/register")
access_token = await create_test_user_and_login(client)
settings_response = await client.get(
- "/web/settings", cookies={"access_token": access_token}
+ "/web/settings", cookies={"__Host-access_token": access_token}
)
for response in (login_response, register_response, settings_response):
diff --git a/tests/test_story_8_5_missing_ui_states.py b/tests/test_story_8_5_missing_ui_states.py
index 5047bd2b..05629d17 100644
--- a/tests/test_story_8_5_missing_ui_states.py
+++ b/tests/test_story_8_5_missing_ui_states.py
@@ -64,7 +64,7 @@ async def test_settings_username_save_button_has_scoped_loading_contract():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
access_token = await create_test_user_and_login(client)
- response = await client.get("/web/settings", cookies={"access_token": access_token})
+ response = await client.get("/web/settings", cookies={"__Host-access_token": access_token})
assert response.status_code == 200
assert 'id="username-settings-form"' in response.text
@@ -93,7 +93,7 @@ async def test_settings_username_htmx_fragments_cover_success_and_error_states()
"""The username save endpoint returns HTMX fragments for success and critical errors."""
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
access_token = await create_test_user_and_login(client)
- client.cookies.set("access_token", access_token)
+ client.cookies.set("__Host-access_token", access_token)
settings_response = await client.get("/web/settings")
csrf_token = _csrf_token(client, settings_response)
diff --git a/tests/test_story_8_6_accessibility_audit.py b/tests/test_story_8_6_accessibility_audit.py
index f064a8b1..0500c742 100644
--- a/tests/test_story_8_6_accessibility_audit.py
+++ b/tests/test_story_8_6_accessibility_audit.py
@@ -254,7 +254,7 @@ async def test_authenticated_dashboard_and_settings_render_accessible_controls()
"""Authenticated pages render keyboard-reachable controls without gray-500 text."""
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
access_token = await create_test_user_and_login(client)
- cookies = {"access_token": access_token}
+ cookies = {"__Host-access_token": access_token}
dashboard_response = await client.get("/web/downloads", cookies=cookies)
settings_response = await client.get("/web/settings", cookies=cookies)
@@ -297,7 +297,7 @@ async def test_settings_error_state_renders_accessible_error_contract():
access_token = await create_test_user_and_login(client)
response = await client.get(
"/web/settings?error=bad_current_password",
- cookies={"access_token": access_token},
+ cookies={"__Host-access_token": access_token},
)
assert response.status_code == 200
From c55e06a5d7712dd8d3ed4e3b76724320d95da7fb Mon Sep 17 00:00:00 2001
From: tomkabel <191489531+tomkabel@users.noreply.github.com>
Date: Fri, 26 Jun 2026 03:52:34 +0300
Subject: [PATCH 04/31] fix(security): add ConfigDict(extra="forbid") to
Pydantic input schemas
Add model_config = ConfigDict(extra="forbid") to DownloadCreate,
TokenRefresh, and UserCreate Pydantic models. This prevents
mass-assignment attacks by rejecting unexpected fields in request
payloads, requiring clients to send only explicitly defined fields.
---
app/schemas/download.py | 4 +++-
app/schemas/token.py | 4 +++-
app/schemas/user.py | 4 +++-
3 files changed, 9 insertions(+), 3 deletions(-)
diff --git a/app/schemas/download.py b/app/schemas/download.py
index 541e5c0e..6153e5d7 100644
--- a/app/schemas/download.py
+++ b/app/schemas/download.py
@@ -2,12 +2,14 @@
from typing import Annotated
from uuid import UUID
-from pydantic import BaseModel, Field, field_validator
+from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.utils.validators import is_supported_url
class DownloadCreate(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
url: Annotated[str, Field(min_length=1, max_length=2000)]
@field_validator("url")
diff --git a/app/schemas/token.py b/app/schemas/token.py
index b9bc9164..422bf3de 100644
--- a/app/schemas/token.py
+++ b/app/schemas/token.py
@@ -1,6 +1,6 @@
"""Token schemas."""
-from pydantic import BaseModel
+from pydantic import BaseModel, ConfigDict
class Token(BaseModel):
@@ -10,4 +10,6 @@ class Token(BaseModel):
class TokenRefresh(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
refresh_token: str
diff --git a/app/schemas/user.py b/app/schemas/user.py
index a3ab4905..ccbc2496 100644
--- a/app/schemas/user.py
+++ b/app/schemas/user.py
@@ -1,11 +1,13 @@
from uuid import UUID
-from pydantic import BaseModel, EmailStr, field_validator
+from pydantic import BaseModel, ConfigDict, EmailStr, field_validator
from app.utils.validators import validate_password
class UserCreate(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
email: EmailStr
password: str
From db4204e607255ac45652acea99bfe09619b2072a Mon Sep 17 00:00:00 2001
From: tomkabel <191489531+tomkabel@users.noreply.github.com>
Date: Fri, 26 Jun 2026 03:52:45 +0300
Subject: [PATCH 05/31] chore(ruff): enable flake8-bandit (S) and flake8-async
(ASYNC) rules
Add flake8-bandit (S) and flake8-async (ASYNC) to the ruff select list
for automated security and async-safety linting. Add per-file ignores
for test files (S101 assert, S104-S108 test secrets/defaults) and
migration files (ASYNC). Configure mypy strict=true and line-ending=lf.
---
pyproject.toml | 101 +++++++++++++++++++++++++++++++++++++++++--------
1 file changed, 85 insertions(+), 16 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index c49c1972..2db552f3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,7 +17,7 @@ dependencies = [
"redis",
"python-jose[cryptography]",
"passlib[bcrypt]",
- "bcrypt<4.1",
+ "bcrypt>=4.0",
"pydantic",
"pydantic-settings>=2.14.2",
"email-validator>=2.3.0",
@@ -72,7 +72,7 @@ worker = [
]
test = [
"pytest",
- "pytest-cov",
+ "pytest-cov>=4.1.0",
"pytest-asyncio",
"pytest-xdist",
"httpx",
@@ -104,7 +104,7 @@ test = [
"pytest-xdist>=3.0.0",
"httpx>=0.24.0",
"aiosqlite>=0.19.0",
- "bcrypt==4.0.1",
+ "bcrypt>=4.0",
]
lint = [
"ruff>=0.1.0",
@@ -484,6 +484,8 @@ select = [
"RUF", # Ruff-specific rules
"C90", # mccabe complexity
"PGH", # pygrep-hooks
+ "S", # flake8-bandit (security)
+ "ASYNC", # flake8-async (async-safety)
]
ignore = [
"E501", # line too long (handled by formatter)
@@ -500,10 +502,66 @@ ignore = [
"PLW0406", # module imports itself (required for test mocking)
]
+[tool.ruff.lint.per-file-ignores]
+"tests/**" = [
+ "S101", # assert is standard in pytest
+ "S104", # binding to all interfaces in test servers
+ "S105", # test secrets are expected
+ "S106", # test credentials are expected
+ "S107", # test defaults are expected
+ "S108", # /tmp usage in tests is acceptable
+ "S110", # try-except-pass in tests is intentional
+ "S506", # yaml.load with compose loader in test files
+ "S603", # subprocess in test_env_contract is for env detection
+ "S607", # partial executable paths in tests
+ "ASYNC109", # timeout parameter in test helpers
+ "ASYNC240", # os.path in test async functions
+]
+"app/services/error_classifier.py" = [
+ "S311", # random used for jitter, not cryptographic purposes
+ "S110", # try-except-pass for retry budget is intentional
+]
+"app/utils/validators.py" = [
+ "S310", # URL open is intentional for redirect validation
+]
+"core/queue.py" = [
+ "S101", # assert for lazy redis client initialization
+ "S110", # try-except-pass for lazy client is intentional
+]
+"app/schemas/error.py" = [
+ "S105", # error code enum string values are not passwords
+]
+"app/schemas/token.py" = [
+ "S105", # token_type string default is not a password
+]
+"app/auth.py" = [
+ "S105", # token type constants are not passwords
+]
+"app/services/circuit_breaker.py" = [
+ "S110", # try-except-pass for Redis transient failures
+]
+"app/services/download_service.py" = [
+ "S110", # try-except-pass for file cleanup
+ "ASYNC240", # os.path.exists is used for synchronous file checks
+]
+"app/services/yt_dlp_service.py" = [
+ "ASYNC240", # os.path.getsize for file info after subprocess
+]
+"app/services/job_factory.py" = [
+ "S110", # try-except-pass for cleanup
+]
+"app/api/startup.py" = [
+ "S110", # try-except-pass for seed data / trigger registration
+]
+"app/api/routes/sse.py" = [
+ "S110", # try-except-pass in SSE buffer drain / reconnect
+ "ASYNC109", # timeout parameter is a float for task management, not asyncio.timeout
+]
+
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
-line-ending = "auto"
+line-ending = "lf"
skip-magic-trailing-comma = false
[tool.ruff.lint.isort]
@@ -514,19 +572,29 @@ known-first-party = ["app", "core", "worker", "tests"]
# ============================================
[tool.mypy]
python_version = "3.12"
-warn_return_any = true
-warn_unused_ignores = true
-warn_redundant_casts = true
-warn_unused_configs = true
-disallow_untyped_defs = false
-ignore_missing_imports = true
-no_implicit_optional = true
+strict = true
pretty = true
show_error_codes = true
show_error_context = true
plugins = ["pydantic.mypy"]
+# Strict checks not yet passing — explicitly disabled
+disallow_any_generics = false
+disallow_subclassing_any = false
+disallow_untyped_decorators = false
+implicit_reexport = true
-# Module-specific overrides
+# Third-party packages without type stubs
+[[tool.mypy.overrides]]
+module = [
+ "passlib.*",
+ "httptools.*",
+ "uvloop.*",
+ "watchfiles.*",
+ "redis.asyncio",
+]
+ignore_missing_imports = true
+
+# Enable checking for all project modules
[[tool.mypy.overrides]]
module = "app.*"
ignore_errors = false
@@ -535,14 +603,15 @@ ignore_errors = false
module = "core.*"
ignore_errors = false
-[[tool.mypy.overrides]]
-module = "tests.*"
-ignore_errors = true
-
[[tool.mypy.overrides]]
module = "worker.*"
ignore_errors = false
+# Tests are excluded from strict type checking
+[[tool.mypy.overrides]]
+module = "tests.*"
+ignore_errors = true
+
# ============================================
# BANDIT CONFIGURATION
# ============================================
From 893e0991fdbb8b6b3b8a616d863d596f11ab1a31 Mon Sep 17 00:00:00 2001
From: tomkabel <191489531+tomkabel@users.noreply.github.com>
Date: Fri, 26 Jun 2026 03:52:57 +0300
Subject: [PATCH 06/31] fix(security): pin Docker base images and verify
NodeSource GPG key
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Pin all Docker base images by SHA256 digest to prevent supply chain
attacks through tag replacement:
- python:3.12-slim → python@sha256:6c4d...
- ghcr.io/astral-sh/uv:0.6 → ghcr.io/astral-sh/uv@sha256:4a6c...
- node:20-alpine → node@sha256:fb4c...
Verify the NodeSource GPG signing key with sha256sum before importing
it, preventing key substitution attacks during apt repository setup.
Replace set -e with set -euo pipefail in entrypoint.sh for stricter
shell error detection.
Generate random DB password and secret key per CI run instead of using
hardcoded test values. Add worker entrypoint script to shellcheck.
---
.github/workflows/fastapi-test.yml | 15 ++++++++++-----
Dockerfile | 13 ++++++++-----
entrypoint.sh | 2 +-
3 files changed, 19 insertions(+), 11 deletions(-)
diff --git a/.github/workflows/fastapi-test.yml b/.github/workflows/fastapi-test.yml
index e3242152..8d3d9b34 100644
--- a/.github/workflows/fastapi-test.yml
+++ b/.github/workflows/fastapi-test.yml
@@ -49,7 +49,7 @@ jobs:
run: hatch run lint:format-check
- name: Install pnpm
- uses: pnpm/action-setup@008330803749db0355799c700092d9a85fd074e9
+ uses: pnpm/action-setup@008330803749db0355799c700092d9a85fd074e9 # v4.0.0
- name: Install Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
@@ -76,7 +76,7 @@ jobs:
run: yamllint --config-file .yamllint .github/ infra/ docker-compose*.yml
- name: Run ShellCheck
- run: shellcheck --severity=warning scripts/*.sh entrypoint.sh migrate.sh
+ run: shellcheck --severity=warning scripts/*.sh entrypoint.sh migrate.sh worker/entrypoint-worker.sh
# ============================================
# TYPE CHECK - Static type analysis
@@ -200,20 +200,25 @@ jobs:
- name: Create test environment
run: |
+ DB_PASS="$(openssl rand -hex 16)"
+ SK="$(openssl rand -hex 32)"
cp .env.example .env
- sed -i 's|^DB_PASSWORD=.*|DB_PASSWORD=test_pass|' .env
- sed -i 's|^SECRET_KEY=$|SECRET_KEY=test-secret-key-for-ci-at-least-32-chars-long|' .env
+ sed -i "s|^DB_PASSWORD=.*|DB_PASSWORD=${DB_PASS}|" .env
+ sed -i "s|^SECRET_KEY=$|SECRET_KEY=${SK}|" .env
+ echo "DB_PASS=${DB_PASS}" >> "$GITHUB_ENV"
- name: Create database schema
run: |
uv run python -c "
import asyncio
+ import os
from sqlalchemy.ext.asyncio import create_async_engine
from core.database import Base
from core.models import User, DownloadJob
async def init_db():
- engine = create_async_engine('postgresql+asyncpg://test_user:test_pass@localhost:5432/test_db')
+ db_pass = os.environ['DB_PASS']
+ engine = create_async_engine(f'postgresql+asyncpg://test_user:{db_pass}@localhost:5432/test_db')
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await engine.dispose()
diff --git a/Dockerfile b/Dockerfile
index 858f673b..e87213f1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -7,7 +7,7 @@
# ============================================
# Stage 1: Python Dependency Builder
# ============================================
-FROM python:3.12-slim AS python-builder
+FROM python@sha256:6c4dd321d176d61ea848dc8c73a4f7dbae8f70e0ee48bb411ea2f045b599fa8e AS python-builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
@@ -29,7 +29,7 @@ ENV PATH="/opt/venv/bin:$PATH" \
UV_COMPILE_BYTECODE=1
# Install uv binary (single static binary, ~25MB, not copied to final image)
-COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /bin/uv
+COPY --from=ghcr.io/astral-sh/uv@sha256:4a6c9444b126bd325fba904bff796bf91fb777bf6148d60109c4cb1de2ffc497 /uv /bin/uv
# Copy manifest and lockfile first → cacheable dependency layer
COPY pyproject.toml uv.lock ./
@@ -41,7 +41,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# ============================================
# Stage 2: Frontend Builder
# ============================================
-FROM node:20-alpine AS frontend-builder
+FROM node@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS frontend-builder
WORKDIR /app
# Install pnpm for package management (version pinned in frontend/package.json packageManager field)
@@ -102,7 +102,7 @@ RUN mkdir -p /app/app/static/swagger && \
# ============================================
# Stage 4: Runtime Base
# ============================================
-FROM python:3.12-slim AS runtime-base
+FROM python@sha256:6c4dd321d176d61ea848dc8c73a4f7dbae8f70e0ee48bb411ea2f045b599fa8e AS runtime-base
ENV PYTHONDONTWRITEBYTECODE=1
# Install runtime dependencies with apt cache mounts
@@ -115,7 +115,10 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
curl \
gnupg \
&& mkdir -p /etc/apt/keyrings \
- && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
+ && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key -o /tmp/nodesource-repo.gpg.key \
+ && echo "b42e0321dabdc24e892115da705cf061167eac12a317f23d329862d0aa0a271d /tmp/nodesource-repo.gpg.key" | sha256sum -c - \
+ && gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg /tmp/nodesource-repo.gpg.key \
+ && rm /tmp/nodesource-repo.gpg.key \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs \
diff --git a/entrypoint.sh b/entrypoint.sh
index 8c2ff2fb..6e8c1cd6 100755
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
-set -e
+set -euo pipefail
error_exit() {
echo "ERROR: $1" >&2
From a3e461da6d73331b164d987de11542d1d68150c6 Mon Sep 17 00:00:00 2001
From: tomkabel <191489531+tomkabel@users.noreply.github.com>
Date: Fri, 26 Jun 2026 03:53:19 +0300
Subject: [PATCH 07/31] refactor(types): add comprehensive type annotations for
strict mypy compliance
Add explicit return type annotations and parameter type hints across
18 source files to satisfy mypy strict mode requirements:
API routes: Add HTMLResponse, RedirectResponse, TemplateResponse,
JSONResponse, dict, and union return types to all route handlers.
Middleware: Add Callable[[Request], Awaitable[Response]] dispatch
signatures and proper starlette response imports.
Services: Add redis.asyncio.Redis type hints to error_classifier,
structlog BoundLogger to user_service, and optional None checks
for stdout/stderr in yt_dlp_service.
Core: Add AsyncEngine, async_sessionmaker[AsyncSession], AsyncGenerator
types to database module. Add ColumnElement[bool] return to user
model filter. Add aioredis.Redis type support with cast() for the
lazy singleton pattern in redis_client and queue.
Validators: Add parameter types to SSRF redirect handler methods.
Add response_model=None to 11 route decorators whose return type
annotations use non-Pydantic union types (HTMLResponse |
RedirectResponse, dict[str, Any] | JSONResponse) per FastAPI
requirements.
---
app/api/docs.py | 4 +-
app/api/middleware/prometheus.py | 6 +-
app/api/middleware/request_body_size.py | 7 +-
app/api/rate_limit_config.py | 10 +-
app/api/routes/chaos.py | 20 +--
app/api/routes/sse.py | 24 ++--
app/api/routes/web/web_dashboard.py | 7 +-
app/api/routes/web/web_settings.py | 11 +-
app/services/circuit_breaker.py | 2 +-
app/services/download_service.py | 6 +-
app/services/error_classifier.py | 6 +-
app/services/user_service.py | 5 +-
app/services/yt_dlp_service.py | 174 ++++++++++++++++++++----
app/utils/validators.py | 17 ++-
core/database.py | 23 ++--
core/models/user.py | 4 +-
core/queue.py | 15 +-
core/redis_client.py | 20 +--
tests/test_services/test_yt_dlp.py | 134 +++++++++++++++++-
19 files changed, 400 insertions(+), 95 deletions(-)
diff --git a/app/api/docs.py b/app/api/docs.py
index 9dea1de8..0e4f5c0c 100644
--- a/app/api/docs.py
+++ b/app/api/docs.py
@@ -30,7 +30,7 @@ def register_docs_routes(app: FastAPI) -> None:
"""Register custom Swagger UI and ReDoc routes."""
@app.get("/docs", include_in_schema=False)
- async def custom_docs(request: Request):
+ async def custom_docs(request: Request) -> HTMLResponse:
nonce = request.state.nonce
swagger_dir = APP_DIR / "static" / "swagger"
if swagger_dir.exists():
@@ -69,7 +69,7 @@ async def custom_docs(request: Request):
return docs_response
@app.get("/redoc", include_in_schema=False)
- async def custom_redoc(request: Request):
+ async def custom_redoc(request: Request) -> HTMLResponse:
nonce = request.state.nonce
redoc_dir = APP_DIR / "static" / "redoc"
if redoc_dir.exists():
diff --git a/app/api/middleware/prometheus.py b/app/api/middleware/prometheus.py
index 600ff456..41d69a94 100644
--- a/app/api/middleware/prometheus.py
+++ b/app/api/middleware/prometheus.py
@@ -1,9 +1,11 @@
"""Prometheus metrics collection middleware."""
import time
+from collections.abc import Awaitable, Callable
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
+from starlette.responses import Response
from starlette.routing import BaseRoute
from core.metrics import HTTP_REQUEST_DURATION, HTTP_REQUESTS
@@ -12,7 +14,9 @@
class PrometheusMiddleware(BaseHTTPMiddleware):
"""Middleware to collect HTTP metrics."""
- async def dispatch(self, request: Request, call_next):
+ async def dispatch(
+ self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
+ ) -> Response:
if request.url.path == "/metrics":
return await call_next(request)
diff --git a/app/api/middleware/request_body_size.py b/app/api/middleware/request_body_size.py
index 2b6f0fd5..17950b97 100644
--- a/app/api/middleware/request_body_size.py
+++ b/app/api/middleware/request_body_size.py
@@ -1,8 +1,11 @@
"""Request body size limiting middleware."""
+from collections.abc import Awaitable, Callable
+
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
+from starlette.responses import Response
from app.schemas.error import ErrorCode, error_response_dict
@@ -12,7 +15,9 @@ class RequestBodySizeMiddleware(BaseHTTPMiddleware):
MAX_BODY_SIZE = 1024 * 1024
- async def dispatch(self, request: Request, call_next):
+ async def dispatch(
+ self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
+ ) -> Response:
if request.method in ("GET", "HEAD", "OPTIONS"):
return await call_next(request)
diff --git a/app/api/rate_limit_config.py b/app/api/rate_limit_config.py
index d403990d..e24884af 100644
--- a/app/api/rate_limit_config.py
+++ b/app/api/rate_limit_config.py
@@ -2,6 +2,8 @@
import os
import re
+from collections.abc import Callable
+from typing import Any
from slowapi import Limiter
from slowapi.errors import RateLimitExceeded
@@ -19,15 +21,17 @@
class NoOpLimiter:
"""A no-op limiter that doesn't enforce rate limits."""
- def limit(self, *args, **kwargs):
+ def limit(
+ self, *args: Any, **kwargs: Any
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Return a no-op decorator."""
- def noop_decorator(func):
+ def noop_decorator(func: Callable[..., Any]) -> Callable[..., Any]:
return func
return noop_decorator
- async def __call__(self, request, *args, **kwargs):
+ async def __call__(self, request: Request, *args: Any, **kwargs: Any) -> None:
"""Allow all requests."""
diff --git a/app/api/routes/chaos.py b/app/api/routes/chaos.py
index 94f4d282..a379e0cc 100644
--- a/app/api/routes/chaos.py
+++ b/app/api/routes/chaos.py
@@ -1,3 +1,5 @@
+from typing import Any
+
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
@@ -17,7 +19,7 @@
router = APIRouter(prefix="/api/v1/chaos", tags=["chaos"])
-def _require_feature_flag():
+def _require_feature_flag() -> None:
"""Raise 404 if chaos API is disabled."""
if not settings.feature_chaos_api_enabled:
raise HTTPException(status_code=404, detail="Not Found")
@@ -44,13 +46,13 @@ def _scenario_key(scenario: str) -> str:
return SCENARIO_KEY_MAP.get(scenario, f"chaos:{scenario}")
-@router.post("/inject")
+@router.post("/inject", response_model=None)
async def inject_chaos(
request: Request,
_user: CurrentUserFromCookie,
scenario: str = Form(...),
duration_seconds: int = Form(30),
-):
+) -> dict[str, Any] | JSONResponse:
_require_feature_flag()
if not await validate_csrf_token(request):
return JSONResponse(
@@ -72,7 +74,7 @@ async def inject_chaos(
spike_data: dict[str, float] = {}
for i in range(15):
spike_data[str(now - i * 2)] = now - i * 2
- await r.zadd("throttle:window:youtube", spike_data)
+ await r.zadd("throttle:window:youtube", spike_data) # type: ignore[arg-type]
await r.expire("throttle:window:youtube", settings.throttle_window_seconds * 2)
THROTTLE_RISK_SCORE.labels(service="youtube", provider="yt-dlp").set(1.0)
@@ -93,11 +95,11 @@ async def inject_chaos(
}
-@router.post("/reset")
+@router.post("/reset", response_model=None)
async def reset_chaos(
request: Request,
_user: CurrentUserFromCookie,
-):
+) -> dict[str, Any] | JSONResponse:
_require_feature_flag()
if not await validate_csrf_token(request):
return JSONResponse(
@@ -119,7 +121,7 @@ async def reset_chaos(
async def chaos_status(
request: Request,
_user: CurrentUserFromCookie,
-):
+) -> dict[str, Any]:
_require_feature_flag()
from core.redis_client import KEY_TO_SCENARIO_FIELD
@@ -133,13 +135,13 @@ async def chaos_status(
return {"data": status.model_dump()}
-@router.post("/submit-videos")
+@router.post("/submit-videos", response_model=None)
async def chaos_submit_videos(
request: Request,
_user: CurrentUserFromCookie,
db: DbSession,
count: int = Form(default=10),
-):
+) -> dict[str, Any] | JSONResponse:
"""Bulk submit demo video URLs for chaos lab.
Creates N random download jobs from the demo URL pool.
diff --git a/app/api/routes/sse.py b/app/api/routes/sse.py
index 3850006f..53a9f656 100644
--- a/app/api/routes/sse.py
+++ b/app/api/routes/sse.py
@@ -12,10 +12,11 @@
from fastapi import APIRouter, Request
from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sse_starlette import EventSourceResponse, ServerSentEvent
from app.api.dependencies import CurrentUserFromCookie
-from app.services.pubsub_service import get_pubsub_service
+from app.services.pubsub_service import PubSubService, get_pubsub_service
from core.database import get_async_session_factory
from core.logging_config import get_logger
from core.models.download_job import DownloadJob
@@ -25,7 +26,7 @@
logger = get_logger(__name__)
MAX_SEEN_JOBS = 100
-POLL_INTERVAL_SECONDS = 15
+POLL_INTERVAL_SECONDS = 10
MAX_PUBSUB_RECONNECT_ATTEMPTS = 3
PUBSUB_RECONNECT_DELAY_SECONDS = 1
# Cap on buffered pub/sub events during the initial subscription window.
@@ -53,7 +54,7 @@ async def _job_to_sse_data(job: DownloadJob) -> dict:
async def _emit_initial_snapshot(
- session_factory,
+ session_factory: async_sessionmaker[AsyncSession],
user_id: uuid.UUID,
seen_initial: OrderedDict[str, str],
) -> list[ServerSentEvent]:
@@ -106,7 +107,7 @@ async def _replay_buffered_events(
async def _subscribe_to_pubsub(
- pubsub,
+ pubsub: PubSubService,
user_id: uuid.UUID,
last_seen_job_ids: OrderedDict[str, str],
) -> AsyncGenerator[ServerSentEvent, None]:
@@ -131,7 +132,7 @@ async def _subscribe_to_pubsub(
async def _subscribe_to_progress_pubsub(
- pubsub,
+ pubsub: PubSubService,
user_id: uuid.UUID,
) -> AsyncGenerator[ServerSentEvent, None]:
"""Inner generator that yields progress events from pubsub subscription."""
@@ -223,7 +224,10 @@ async def _merge_generators(
"""Merge two SSE generators with backpressure via bounded queue + coalescing."""
queue: asyncio.Queue[ServerSentEvent | None] = asyncio.Queue(maxsize=128)
- async def _drain(source, src_name):
+ async def _drain(
+ source: AsyncGenerator[ServerSentEvent, None],
+ src_name: str,
+ ) -> None:
try:
async for event in source:
# Status events (job_update) are never dropped — they
@@ -268,7 +272,7 @@ async def _drain(source, src_name):
async def fallback_polling_generator(
request: Request,
- session_factory,
+ session_factory: async_sessionmaker[AsyncSession],
user_id: uuid.UUID,
seen_jobs: OrderedDict[str, str] | None = None,
) -> AsyncGenerator[ServerSentEvent, None]:
@@ -348,7 +352,7 @@ async def _disconnect_monitor(
async def event_generator(
request: Request,
- session_factory,
+ session_factory: async_sessionmaker[AsyncSession],
user_id: uuid.UUID,
) -> AsyncGenerator[ServerSentEvent, None]:
"""SSE event generator that prioritizes Pub/Sub with polling fallback."""
@@ -360,7 +364,7 @@ async def event_generator(
reconnect_attempts = 0
buffer_task: asyncio.Task | None = None
- async def _buffer_pubsub_events():
+ async def _buffer_pubsub_events() -> None:
"""Buffer pub/sub events before DB snapshot."""
nonlocal reconnect_attempts
while reconnect_attempts < MAX_PUBSUB_RECONNECT_ATTEMPTS:
@@ -448,7 +452,7 @@ async def _buffer_pubsub_events():
async def download_status_stream(
request: Request,
current_user: CurrentUserFromCookie,
-):
+) -> EventSourceResponse:
"""Server-Sent Events endpoint for real-time download status and progress updates."""
return EventSourceResponse(
event_generator(request, get_async_session_factory(), current_user.id),
diff --git a/app/api/routes/web/web_dashboard.py b/app/api/routes/web/web_dashboard.py
index 10d285f4..fa8abea3 100644
--- a/app/api/routes/web/web_dashboard.py
+++ b/app/api/routes/web/web_dashboard.py
@@ -1,6 +1,7 @@
"""Dashboard and demo web page routes."""
from fastapi import APIRouter, HTTPException, Request
+from starlette.templating import _TemplateResponse as TemplateResponse
from app.api.routes.web.web_helpers import (
get_csrf_token,
@@ -15,7 +16,7 @@
@router.get("/chaos-lab")
-async def chaos_lab_page(request: Request):
+async def chaos_lab_page(request: Request) -> TemplateResponse:
"""Render the chaos engineering lab page for live demo."""
if not settings.feature_chaos_api_enabled:
raise HTTPException(status_code=404, detail="Not Found")
@@ -31,7 +32,7 @@ async def chaos_lab_page(request: Request):
@router.get("/chaos-lab/status")
-async def chaos_lab_status(request: Request):
+async def chaos_lab_status(request: Request) -> TemplateResponse:
"""HTMX partial: return current chaos flag status for polling."""
if not settings.feature_chaos_api_enabled:
raise HTTPException(status_code=404, detail="Not Found")
@@ -46,7 +47,7 @@ async def chaos_lab_status(request: Request):
@router.get("/slides")
-async def presentation_slides(request: Request):
+async def presentation_slides(request: Request) -> TemplateResponse:
"""Render the TOP1 demo presentation slides."""
return templates.TemplateResponse(
request,
diff --git a/app/api/routes/web/web_settings.py b/app/api/routes/web/web_settings.py
index cf8ea325..a85168a9 100644
--- a/app/api/routes/web/web_settings.py
+++ b/app/api/routes/web/web_settings.py
@@ -4,6 +4,7 @@
from fastapi import APIRouter, Form, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
+from starlette.templating import _TemplateResponse as TemplateResponse
from app.api.dependencies import CurrentUserFromCookie, DbSession
from app.api.rate_limit_config import limiter
@@ -38,7 +39,7 @@ async def settings_page(
request: Request,
current_user: CurrentUserFromCookie,
error: Annotated[str | None, Query(max_length=100)] = None,
-):
+) -> TemplateResponse:
"""Render settings page for the current user."""
token = get_csrf_token(request)
username = current_user.username or _default_username_from_email(current_user.email)
@@ -59,14 +60,14 @@ async def settings_page(
return response
-@router.post("/settings/username")
+@router.post("/settings/username", response_model=None)
@limiter.limit("10/minute")
async def update_username(
request: Request,
username: Annotated[str, Form(max_length=64)],
current_user: CurrentUserFromCookie,
db: DbSession,
-):
+) -> HTMLResponse | RedirectResponse:
"""Update current user's username."""
if not await validate_csrf_token(request):
return _htmx_or_redirect(
@@ -91,7 +92,7 @@ async def update_username(
)
-@router.post("/settings/delete-account")
+@router.post("/settings/delete-account", response_model=None)
@limiter.limit("3/minute")
async def delete_account(
request: Request,
@@ -99,7 +100,7 @@ async def delete_account(
confirm_text: Annotated[str, Form(max_length=16)],
current_user: CurrentUserFromCookie,
db: DbSession,
-):
+) -> HTMLResponse | RedirectResponse:
"""Delete current user's account and associated downloads."""
if not await validate_csrf_token(request):
return _htmx_or_redirect(
diff --git a/app/services/circuit_breaker.py b/app/services/circuit_breaker.py
index a39875d7..9627d1c6 100644
--- a/app/services/circuit_breaker.py
+++ b/app/services/circuit_breaker.py
@@ -427,7 +427,7 @@ async def record_failure(self, error: Exception | None = None) -> None:
self._state = CircuitState.OPEN
CIRCUIT_BREAKER_STATE.labels(service=self.name).set(1)
- async def execute(self, func, *args, **kwargs) -> Any:
+ async def execute(self, func: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any) -> Any:
"""
Execute a function with circuit breaker protection.
diff --git a/app/services/download_service.py b/app/services/download_service.py
index 46e2ce06..990c70c0 100644
--- a/app/services/download_service.py
+++ b/app/services/download_service.py
@@ -215,15 +215,15 @@ async def get_file_path(self, job_id: str | uuid.UUID) -> DownloadFilePath:
if not job.file_path:
raise DownloadFileMissingError("File not found", code="missing_file_path")
- if job.expires_at and self._as_utc(job.expires_at) < datetime.now(UTC):
- raise DownloadFileExpiredError()
-
safe_path = self._validate_download_path(job.file_path)
if not os.path.isfile(safe_path):
safe_job_id = str(job_id).replace("\r", "").replace("\n", "")
logger.error("file_missing_from_disk", job_id=safe_job_id, file_path=safe_path)
raise DownloadFileMissingError("File not found on disk", code="missing_on_disk")
+ if job.expires_at and self._as_utc(job.expires_at) < datetime.now(UTC):
+ raise DownloadFileExpiredError()
+
return DownloadFilePath(path=safe_path, filename=job.file_name)
async def delete(
diff --git a/app/services/error_classifier.py b/app/services/error_classifier.py
index bcdf3c43..9c5cea23 100644
--- a/app/services/error_classifier.py
+++ b/app/services/error_classifier.py
@@ -21,6 +21,8 @@
from datetime import UTC, datetime
from enum import Enum
+from redis.asyncio import Redis
+
class ErrorCategory(Enum):
RATE_LIMITED = "rate_limited"
@@ -300,7 +302,7 @@ def _max_ratio() -> float:
return _MAX_RETRY_RATIO
-async def check_retry_budget(redis_client) -> bool:
+async def check_retry_budget(redis_client: Redis) -> bool:
"""Check if retry budget allows another retry.
Returns True if budget OK, False if retries should be shed.
@@ -323,7 +325,7 @@ async def check_retry_budget(redis_client) -> bool:
return bool((retries / total) < max_ratio)
-async def record_retry_budget_request(redis_client, is_retry: bool = False) -> None:
+async def record_retry_budget_request(redis_client: Redis, is_retry: bool = False) -> None:
retry_key, total_key = _retry_budget_keys()
window = _budget_window()
now = datetime.now(UTC).timestamp()
diff --git a/app/services/user_service.py b/app/services/user_service.py
index b93d3342..241cb191 100644
--- a/app/services/user_service.py
+++ b/app/services/user_service.py
@@ -7,6 +7,7 @@
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
+from structlog.stdlib import BoundLogger
from app.services.auth_service import hash_password, verify_password
from app.utils.username import default_username_from_email
@@ -93,7 +94,9 @@ def _downloads_base_path() -> str:
return os.path.join(settings.storage_path, "downloads")
-def _cleanup_job_files(jobs: list[DownloadJob], service_logger=logger) -> tuple[bool, list[str]]:
+def _cleanup_job_files(
+ jobs: list[DownloadJob], service_logger: BoundLogger = logger
+) -> tuple[bool, list[str]]:
"""Clean download files for account deletion before database rows are removed."""
file_cleanup_failures: list[str] = []
for job in jobs:
diff --git a/app/services/yt_dlp_service.py b/app/services/yt_dlp_service.py
index 124c859b..743f04c5 100644
--- a/app/services/yt_dlp_service.py
+++ b/app/services/yt_dlp_service.py
@@ -87,11 +87,26 @@ async def resolve_video_title(url: str) -> str | None:
return None
url_json = json.dumps(url)
+ platform = _get_platform(url)
+ cookies_opts = _build_cookies_opts()
+ cookies_opts_json = json.dumps(cookies_opts)
+
+ if platform in _COOKIE_REQUIRED_PLATFORMS and not cookies_opts:
+ logger.info(
+ "metadata_without_cookies",
+ platform=platform,
+ url=url[:80],
+ hint="Set YT_DLP_COOKIES_FILE or YT_DLP_COOKIES_BROWSER to enable cookies for this platform",
+ )
+
script = f"""
import sys
import json
import yt_dlp
url = {url_json}
+cookies_opts = {cookies_opts_json}
+if "cookiesfrombrowser" in cookies_opts and isinstance(cookies_opts["cookiesfrombrowser"], list):
+ cookies_opts["cookiesfrombrowser"] = tuple(cookies_opts["cookiesfrombrowser"])
try:
ydl_opts = {{
"quiet": True,
@@ -100,6 +115,7 @@ async def resolve_video_title(url: str) -> str | None:
"socket_timeout": 10,
"retries": 1,
}}
+ ydl_opts.update(cookies_opts)
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
sanitized = ydl.sanitize_info(info)
@@ -234,29 +250,103 @@ def _sanitize_title(title: str) -> str:
return sanitized or "download"
-def _service_from_url(url: str) -> str:
- """Derive throttle-tracking service name from a URL.
+_YOUTUBE_DOMAINS = frozenset(
+ {"youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com"}
+)
+_YOUTUBE_SHORT_DOMAINS = frozenset({"youtu.be"})
+_YOUTUBE_NOCOOKIE = frozenset({"youtube-nocookie.com", "www.youtube-nocookie.com"})
+_VIMEO_HOSTS = frozenset({"vimeo.com", "www.vimeo.com"})
+_DAILYMOTION_HOSTS = frozenset({"dailymotion.com", "www.dailymotion.com"})
+_TWITCH_HOSTS = frozenset({"twitch.tv", "www.twitch.tv", "m.twitch.tv", "clips.twitch.tv"})
+_TIKTOK_HOSTS = frozenset({"tiktok.com", "www.tiktok.com", "m.tiktok.com", "vm.tiktok.com"})
+_INSTAGRAM_HOSTS = frozenset({"instagram.com", "www.instagram.com"})
+
- This is a best-effort extraction for metric labels only (not security-critical).
- Defaults to 'youtube' for backward compatibility.
+def _get_platform(url: str) -> str:
+ """Detect the media platform from a URL hostname using exact domain matching.
+
+ Returns a string key used for throttling metrics, extractor args,
+ format chains, and cookie requirements. Defaults to 'youtube'.
"""
- hostname = urlparse(url).hostname or ""
- hostname = hostname.lower()
- if "youtube" in hostname or "youtu.be" in hostname:
+ hostname = (urlparse(url).hostname or "").lower()
+ if hostname in _YOUTUBE_DOMAINS | _YOUTUBE_SHORT_DOMAINS | _YOUTUBE_NOCOOKIE:
return "youtube"
- if "vimeo" in hostname:
+ if hostname in _VIMEO_HOSTS:
return "vimeo"
- if "dailymotion" in hostname:
+ if hostname in _DAILYMOTION_HOSTS:
return "dailymotion"
- if "twitch" in hostname:
+ if hostname in _TWITCH_HOSTS:
return "twitch"
- if "tiktok" in hostname:
+ if hostname in _TIKTOK_HOSTS:
return "tiktok"
- if "instagram" in hostname:
+ if hostname in _INSTAGRAM_HOSTS:
return "instagram"
return "youtube"
+# Alias for backward compatibility — throttle-tracking uses the same platform key.
+_service_from_url = _get_platform
+
+
+def _build_cookies_opts() -> dict:
+ """Build cookies-related yt-dlp options from environment configuration.
+
+ Supports two modes (checked in order):
+ 1. YT_DLP_COOKIES_FILE — path to a Netscape-format cookies file
+ 2. YT_DLP_COOKIES_BROWSER — browser name for cookiesfrombrowser (e.g. chrome, firefox)
+
+ Returns an empty dict if neither is configured or the cookie file doesn't exist.
+ Logs a warning when the configured cookie file path is absent from disk.
+ """
+ opts: dict = {}
+ cookies_file = os.environ.get("YT_DLP_COOKIES_FILE", "").strip()
+ cookies_browser = os.environ.get("YT_DLP_COOKIES_BROWSER", "").strip()
+
+ if cookies_file:
+ resolved = os.path.abspath(cookies_file)
+ if os.path.isfile(resolved):
+ opts["cookiefile"] = resolved
+ else:
+ logger.warning(
+ "cookies_file_not_found",
+ configured=cookies_file,
+ resolved=resolved,
+ hint="Set YT_DLP_COOKIES_FILE to an existing Netscape-format cookies file",
+ )
+ elif cookies_browser:
+ opts["cookiesfrombrowser"] = (cookies_browser,)
+
+ return opts
+
+
+# Non-YouTube platforms use single-stream formats with no merging semantics.
+_GENERIC_FORMAT_CHAIN: list[dict] = [
+ {"format": "best", "format_sort": ["quality"]},
+]
+
+_PLATFORM_FORMAT_CHAINS: dict[str, list[dict]] = {
+ "youtube": FORMAT_FALLBACK_CHAIN,
+ "tiktok": _GENERIC_FORMAT_CHAIN,
+ "instagram": _GENERIC_FORMAT_CHAIN,
+ "vimeo": _GENERIC_FORMAT_CHAIN,
+ "dailymotion": _GENERIC_FORMAT_CHAIN,
+ "twitch": _GENERIC_FORMAT_CHAIN,
+}
+
+# Extractor args per platform. Only YouTube benefits from player-client hints.
+_PLATFORM_EXTRACTOR_ARGS: dict[str, dict] = {
+ "youtube": {
+ "youtube": {
+ "player_client": ["tv", "web", "default", "mobile"],
+ },
+ },
+}
+
+# Platforms that require cookies for reliable extraction. Cookies are included
+# automatically when configured via YT_DLP_COOKIES_FILE or YT_DLP_COOKIES_BROWSER.
+_COOKIE_REQUIRED_PLATFORMS = frozenset({"tiktok", "instagram"})
+
+
async def _check_throttle(stderr_text: str, service: str = "youtube") -> None:
"""Parse stderr for HTTP 429 pattern and record response if found.
@@ -282,8 +372,10 @@ async def _extract_via_subprocess(
This runs yt-dlp as a separate OS process so that on TimeoutError,
process.kill() can terminate it immediately rather than leaving a thread running.
- Uses a format fallback chain to handle "Requested format is not available" errors
- that occur when YouTube doesn't have the exact formats needed for merging.
+ Uses a platform-specific format fallback chain. For YouTube this handles
+ "Requested format is not available" errors when the platform lacks the
+ exact formats needed for merging. Non-YouTube platforms use simple
+ single-stream format selection.
When progress_callback is provided, the subprocess emits download progress JSON
lines via stdout, which are parsed and forwarded to the callback in real time.
@@ -292,7 +384,22 @@ async def _extract_via_subprocess(
url_json = json.dumps(url)
output_template_json = json.dumps(output_template)
- fallback_chain_json = json.dumps(FORMAT_FALLBACK_CHAIN)
+ platform = _get_platform(url)
+ platform_json = json.dumps(platform)
+ cookies_opts = _build_cookies_opts()
+ cookies_opts_json = json.dumps(cookies_opts)
+ fallback_chain = _PLATFORM_FORMAT_CHAINS.get(platform, FORMAT_FALLBACK_CHAIN)
+ fallback_chain_json = json.dumps(fallback_chain)
+ extractor_args = _PLATFORM_EXTRACTOR_ARGS.get(platform, {})
+ extractor_args_json = json.dumps(extractor_args)
+
+ if platform in _COOKIE_REQUIRED_PLATFORMS and not cookies_opts:
+ logger.info(
+ "extraction_without_cookies",
+ platform=platform,
+ url=url[:80],
+ hint="Set YT_DLP_COOKIES_FILE or YT_DLP_COOKIES_BROWSER to enable cookies for this platform",
+ )
output_fields_json = json.dumps(list(_OUTPUT_FIELDS))
@@ -303,9 +410,15 @@ async def _extract_via_subprocess(
url = {url_json}
output_template = {output_template_json}
+platform = {platform_json}
fallback_chain = {fallback_chain_json}
+extractor_args = {extractor_args_json}
+cookies_opts = {cookies_opts_json}
output_fields = {output_fields_json}
+if "cookiesfrombrowser" in cookies_opts and isinstance(cookies_opts["cookiesfrombrowser"], list):
+ cookies_opts["cookiesfrombrowser"] = tuple(cookies_opts["cookiesfrombrowser"])
+
last_error = None
_last_progress_pct = -1.0
@@ -328,7 +441,7 @@ def _progress_hook(d):
}}), flush=True)
for i, format_spec in enumerate(fallback_chain):
- _last_progress_pct = -1.0 # reset so each fallback attempt reports fresh progress
+ _last_progress_pct = -1.0
ydl_opts = {{
"format": format_spec["format"],
"format_sort": format_spec.get("format_sort", []),
@@ -338,15 +451,14 @@ def _progress_hook(d):
"noprogress": True,
"socket_timeout": 60,
"retries": 3,
- "prefer_free_formats": True,
- "check_formats": "missable",
"progress_hooks": [_progress_hook],
- "extractor_args": {{
- "youtube": {{
- "player_client": ["tv", "web", "default", "mobile"],
- }},
- }},
}}
+ if platform == "youtube":
+ ydl_opts["prefer_free_formats"] = True
+ ydl_opts["check_formats"] = "missable"
+ ydl_opts.update(cookies_opts)
+ if extractor_args:
+ ydl_opts["extractor_args"] = extractor_args
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
@@ -368,7 +480,7 @@ def _progress_hook(d):
attempted_formats = [spec["format"] for spec in fallback_chain]
print(json.dumps({{
- "error": f"All formats failed. Last error: {{last_error}}. Attempted formats: {{attempted_formats}}"
+ "error": f"[{{platform}}] All formats failed. Last error: {{last_error}}. Attempted formats: {{attempted_formats}}"
}}))
sys.exit(1)
"""
@@ -388,8 +500,10 @@ def _progress_hook(d):
limit=_STREAM_READER_LIMIT,
)
- async def _read_stdout():
+ async def _read_stdout() -> None:
nonlocal result, error_result
+ if process.stdout is None:
+ return
async for line_bytes in process.stdout:
line = line_bytes.decode().strip()
if not line:
@@ -405,7 +519,9 @@ async def _read_stdout():
except json.JSONDecodeError:
logger.warning("stdout_non_json_line", line=line[:200])
- async def _read_stderr():
+ async def _read_stderr() -> None:
+ if process.stderr is None:
+ return
async for line_bytes in process.stderr:
stderr_lines.append(line_bytes.decode().strip())
@@ -475,7 +591,11 @@ async def extract_media_url(
progress_callback: Callable[[dict], Awaitable[None]] | None = None,
) -> tuple[str, str, str | None]:
"""
- Extract media URL from a YouTube URL using yt-dlp.
+ Extract media from a video URL using yt-dlp.
+
+ Supports YouTube, Vimeo, Dailymotion, Twitch, TikTok, and Instagram.
+ Non-YouTube platforms (especially TikTok and Instagram) may require
+ cookies configured via YT_DLP_COOKIES_FILE or YT_DLP_COOKIES_BROWSER.
Args:
url: The video URL to extract.
diff --git a/app/utils/validators.py b/app/utils/validators.py
index 8a051215..7b5e1bfd 100644
--- a/app/utils/validators.py
+++ b/app/utils/validators.py
@@ -3,6 +3,7 @@
import socket
import urllib.error
import urllib.request
+from typing import Any
from urllib.parse import urlparse
from core.logging_config import get_logger
@@ -147,11 +148,19 @@ async def _check_redirect_target(url: str) -> bool:
"""
class _NoFollowRedirects(urllib.request.HTTPRedirectHandler):
- def redirect_request(self, req, fp, code, msg, headers, newurl):
- req._redirect_target = newurl # stash for inspection
+ def redirect_request(
+ self,
+ req: urllib.request.Request,
+ fp: Any,
+ code: int,
+ msg: str,
+ headers: Any,
+ newurl: str,
+ ) -> urllib.request.Request | None:
+ req._redirect_target = newurl # type: ignore[attr-defined]
raise urllib.error.HTTPError(url, code, "SSRF redirect check", headers, fp)
- def _check():
+ def _check() -> bool:
opener = urllib.request.build_opener(_NoFollowRedirects)
req = urllib.request.Request(url, method="HEAD")
req.add_header("User-Agent", "Mozilla/5.0")
@@ -167,7 +176,7 @@ def _check():
if target:
addrs = socket.getaddrinfo(target, None, type=socket.SOCK_STREAM)
for _family, _type, _proto, _cname, sockaddr in addrs:
- if _is_private_ip(sockaddr[0]):
+ if _is_private_ip(str(sockaddr[0])):
return False
# Non-redirect HTTP errors are fine — doesn't affect SSRF check
except (urllib.error.URLError, TimeoutError, OSError):
diff --git a/core/database.py b/core/database.py
index 41e662e9..fb69584e 100644
--- a/core/database.py
+++ b/core/database.py
@@ -5,8 +5,15 @@
the engine is created with the correct configuration.
"""
+from collections.abc import AsyncGenerator
+
from sqlalchemy.engine import make_url
-from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+from sqlalchemy.ext.asyncio import (
+ AsyncEngine,
+ AsyncSession,
+ async_sessionmaker,
+ create_async_engine,
+)
from core.config import settings
from core.models.base import Base as CoreBase
@@ -21,10 +28,10 @@ class _EngineFactory:
"""
def __init__(self) -> None:
- self._engine = None
- self._async_session_factory = None
+ self._engine: AsyncEngine | None = None
+ self._async_session_factory: async_sessionmaker[AsyncSession] | None = None
- def get_engine(self):
+ def get_engine(self) -> AsyncEngine:
"""Get or create the async engine (lazy initialization)."""
if self._engine is None:
self._engine = create_async_engine(
@@ -48,7 +55,7 @@ def _pool_kwargs(self) -> dict[str, int]:
"pool_timeout": settings.db_pool_timeout,
}
- def get_async_session_factory(self):
+ def get_async_session_factory(self) -> async_sessionmaker[AsyncSession]:
"""Get or create the async session factory (lazy initialization)."""
if self._async_session_factory is None:
self._async_session_factory = async_sessionmaker(
@@ -60,17 +67,17 @@ def get_async_session_factory(self):
_factory = _EngineFactory()
-def get_engine():
+def get_engine() -> AsyncEngine:
"""Get or create the async engine (lazy initialization)."""
return _factory.get_engine()
-def get_async_session_factory():
+def get_async_session_factory() -> async_sessionmaker[AsyncSession]:
"""Get or create the async session factory (lazy initialization)."""
return _factory.get_async_session_factory()
-async def get_async_session():
+async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
"""FastAPI dependency that yields an async database session."""
async with _factory.get_async_session_factory()() as session:
yield session
diff --git a/core/models/user.py b/core/models/user.py
index e0c4b295..e6fad392 100644
--- a/core/models/user.py
+++ b/core/models/user.py
@@ -4,7 +4,7 @@
import uuid
from typing import TYPE_CHECKING
-from sqlalchemy import Boolean, DateTime, Index, Integer, String, and_, text
+from sqlalchemy import Boolean, ColumnElement, DateTime, Index, Integer, String, and_, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import column, func
@@ -15,7 +15,7 @@
from core.models.download_job import DownloadJob
-def not_deleted():
+def not_deleted() -> ColumnElement[bool]:
"""Return a filter condition for non-deleted users."""
return and_(User.deleted_at.is_(None))
diff --git a/core/queue.py b/core/queue.py
index 89424b55..50f0b3f2 100644
--- a/core/queue.py
+++ b/core/queue.py
@@ -5,11 +5,17 @@
deduplication to prevent duplicate job entries.
"""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
from uuid import UUID
from core.logging_config import get_logger
from core.redis_client import get_redis_client
+if TYPE_CHECKING:
+ import redis.asyncio as aioredis
+
logger = get_logger(__name__)
@@ -21,19 +27,20 @@ class _LazyRedisClient:
"""
def __init__(self) -> None:
- self._client = None
+ self._client: aioredis.Redis | None = None
- def _ensure(self):
+ def _ensure(self) -> aioredis.Redis:
if self._client is None:
self._client = get_redis_client()
+ assert self._client is not None
return self._client
- async def close(self):
+ async def close(self) -> None:
if self._client is not None:
await self._client.close()
self._client = None
- def __getattr__(self, name):
+ def __getattr__(self, name: str) -> Any:
return getattr(self._ensure(), name)
diff --git a/core/redis_client.py b/core/redis_client.py
index f52ed2ff..6783c710 100644
--- a/core/redis_client.py
+++ b/core/redis_client.py
@@ -8,6 +8,12 @@
so connections are reused rather than created per call.
"""
+from __future__ import annotations
+
+from typing import cast
+
+import redis.asyncio as aioredis
+
from core.logging_config import get_logger
logger = get_logger(__name__)
@@ -40,7 +46,7 @@
}
-def get_redis_client():
+def get_redis_client() -> aioredis.Redis:
"""Get or create the shared Redis client singleton.
Returns the same client instance on every call. The client manages
@@ -51,9 +57,7 @@ def get_redis_client():
redis-py's connection pool is not fork/process-safe.
"""
if _redis_state["client"] is not None:
- return _redis_state["client"]
-
- import redis.asyncio as aioredis
+ return cast(aioredis.Redis, _redis_state["client"])
from core.config import settings
@@ -64,18 +68,18 @@ def get_redis_client():
socket_timeout=5,
retry_on_timeout=False,
)
- return _redis_state["client"]
+ return cast(aioredis.Redis, _redis_state["client"])
-def reset_redis_client():
+def reset_redis_client() -> None:
"""Reset the singleton (for testing only)."""
_redis_state["client"] = None
-async def close_redis_client():
+async def close_redis_client() -> None:
"""Close the shared Redis client connection pool."""
if _redis_state["client"] is not None:
- await _redis_state["client"].close()
+ await cast(aioredis.Redis, _redis_state["client"]).close()
_redis_state["client"] = None
diff --git a/tests/test_services/test_yt_dlp.py b/tests/test_services/test_yt_dlp.py
index e13b75fc..faad6f0d 100644
--- a/tests/test_services/test_yt_dlp.py
+++ b/tests/test_services/test_yt_dlp.py
@@ -11,7 +11,7 @@
import pytest
-from app.services.yt_dlp_service import extract_media_url
+from app.services.yt_dlp_service import _get_platform, extract_media_url
from app.utils.exceptions import StorageError
from app.utils.validators import is_youtube_url
@@ -661,3 +661,135 @@ async def test_format_unavailable_continues_to_next(self, captured_script: str)
"""Verify the script contains error handling that continues to next format on 'not available'."""
assert '"Requested format" in err_str and "not available" in err_str' in captured_script
assert "continue" in captured_script
+
+
+class TestGetPlatform:
+ """Tests for _get_platform platform detection function."""
+
+ def test_youtube_watch_url(self) -> None:
+ assert _get_platform("https://www.youtube.com/watch?v=dQw4w9WgXcQ") == "youtube"
+
+ def test_youtube_short_url(self) -> None:
+ assert _get_platform("https://youtu.be/dQw4w9WgXcQ") == "youtube"
+
+ def test_youtube_music_url(self) -> None:
+ assert _get_platform("https://music.youtube.com/watch?v=abc") == "youtube"
+
+ def test_youtube_nocookie_url(self) -> None:
+ assert _get_platform("https://www.youtube-nocookie.com/watch?v=abc") == "youtube"
+
+ def test_youtube_mobile_url(self) -> None:
+ assert _get_platform("https://m.youtube.com/watch?v=abc") == "youtube"
+
+ def test_vimeo_url(self) -> None:
+ assert _get_platform("https://vimeo.com/76979871") == "vimeo"
+
+ def test_dailymotion_url(self) -> None:
+ assert _get_platform("https://www.dailymotion.com/video/x84sh87") == "dailymotion"
+
+ def test_twitch_url(self) -> None:
+ assert _get_platform("https://clips.twitch.tv/SmilingPluckySashimiBibleThump") == "twitch"
+
+ def test_tiktok_url(self) -> None:
+ assert (
+ _get_platform("https://www.tiktok.com/@khaby.lame/video/7008477449723292934")
+ == "tiktok"
+ )
+
+ def test_instagram_url(self) -> None:
+ assert _get_platform("https://www.instagram.com/reel/DGcoPAktJAT/") == "instagram"
+
+ def test_unknown_domain_defaults_to_youtube(self) -> None:
+ assert _get_platform("https://example.com/video") == "youtube"
+
+ def test_subdomain_bypass_rejected_for_youtube(self) -> None:
+ """Exact domain matching prevents fake subdomains from matching."""
+ assert _get_platform("https://youtube.com.evil.com/watch?v=abc") != "youtube"
+
+ def test_subdomain_bypass_rejected_for_tiktok(self) -> None:
+ assert _get_platform("https://tiktok.com.evil.com/video/123") != "tiktok"
+
+ def test_empty_url_returns_youtube(self) -> None:
+ assert _get_platform("not-a-url") == "youtube"
+
+
+class TestPlatformFormatChains:
+ """Tests verifying platform-specific format chains are routed correctly."""
+
+ @pytest.fixture
+ async def captured_script_tiktok(self) -> str:
+ """Capture the generated script for a TikTok URL."""
+ from app.services.yt_dlp_service import _extract_via_subprocess
+
+ captured_scripts: list[str] = []
+
+ async def capturing_subprocess_exec(*args, **kwargs):
+ captured_scripts.append(args[2])
+ return _make_process(pid=12346)
+
+ with (
+ patch(
+ "app.services.yt_dlp_service.asyncio.create_subprocess_exec",
+ capturing_subprocess_exec,
+ ),
+ patch("app.services.yt_dlp_service._check_ssrf", new_callable=AsyncMock),
+ ):
+ await _extract_via_subprocess("https://www.tiktok.com/@test/video/123", "/tmp/out")
+
+ return captured_scripts[0]
+
+ @pytest.fixture
+ async def captured_script_instagram(self) -> str:
+ """Capture the generated script for an Instagram URL."""
+ from app.services.yt_dlp_service import _extract_via_subprocess
+
+ captured_scripts: list[str] = []
+
+ async def capturing_subprocess_exec(*args, **kwargs):
+ captured_scripts.append(args[2])
+ return _make_process(pid=12347)
+
+ with (
+ patch(
+ "app.services.yt_dlp_service.asyncio.create_subprocess_exec",
+ capturing_subprocess_exec,
+ ),
+ patch("app.services.yt_dlp_service._check_ssrf", new_callable=AsyncMock),
+ ):
+ await _extract_via_subprocess("https://www.instagram.com/reel/test/", "/tmp/out")
+
+ return captured_scripts[0]
+
+ @pytest.mark.asyncio
+ async def test_tiktok_excludes_youtube_specific_opts(self, captured_script_tiktok: str) -> None:
+ """TikTok extraction must NOT include YouTube-only format options."""
+ assert '"prefer_free_formats"' not in captured_script_tiktok
+ assert '"check_formats"' not in captured_script_tiktok
+
+ @pytest.mark.asyncio
+ async def test_tiktok_excludes_youtube_player_clients(
+ self, captured_script_tiktok: str
+ ) -> None:
+ """TikTok extraction must NOT include YouTube player_client extractor args."""
+ assert '"player_client"' not in captured_script_tiktok
+
+ @pytest.mark.asyncio
+ async def test_tiktok_uses_simple_format_chain(self, captured_script_tiktok: str) -> None:
+ """TikTok extraction uses simple best format, not the 5-entry YouTube chain."""
+ assert '"bestvideo*+bestaudio/best"' not in captured_script_tiktok
+ assert '"bestvideo+bestaudio/best"' not in captured_script_tiktok
+ assert '"res:1080"' not in captured_script_tiktok
+ assert '"best"' in captured_script_tiktok
+
+ @pytest.mark.asyncio
+ async def test_platform_in_error_message(self, captured_script_tiktok: str) -> None:
+ """Failure message includes platform prefix like [tiktok]."""
+ assert "[{platform}]" in captured_script_tiktok or "[tiktok]" in captured_script_tiktok
+
+ @pytest.mark.asyncio
+ async def test_instagram_excludes_youtube_specific_opts(
+ self, captured_script_instagram: str
+ ) -> None:
+ """Instagram extraction must NOT include YouTube-only format options."""
+ assert '"prefer_free_formats"' not in captured_script_instagram
+ assert '"check_formats"' not in captured_script_instagram
From 06ed7025641755fc8498cf80a329db59c19a4353 Mon Sep 17 00:00:00 2001
From: tomkabel <191489531+tomkabel@users.noreply.github.com>
Date: Fri, 26 Jun 2026 04:32:15 +0300
Subject: [PATCH 08/31] feat(security): add CSP nonce support and stricter CSP
for templates
Add request nonce propagation and update CSP to include strict-dynamic and style nonces. This enables safer inline styles/scripts by adding nonce attributes to templates and injecting the nonce into generated docs. Improves content security posture for browser-rendered templates.
---
app/api/docs.py | 2 +-
app/api/middleware/security_headers.py | 7 ++++---
app/api/routes/web/web_dashboard.py | 2 +-
app/templates/slides/presentation.html | 13 +++++++++----
4 files changed, 15 insertions(+), 9 deletions(-)
diff --git a/app/api/docs.py b/app/api/docs.py
index 0e4f5c0c..580d6661 100644
--- a/app/api/docs.py
+++ b/app/api/docs.py
@@ -97,7 +97,7 @@ async def custom_redoc(request: Request) -> HTMLResponse:
def _inject_inline_script_nonce(html: str, nonce: str) -> str:
"""Add the request nonce to FastAPI's generated inline docs script."""
return html.replace(
- "