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( - "