From 4cbae83babe677cf611a27418c31b49fe2ee4bd4 Mon Sep 17 00:00:00 2001 From: Beorn Date: Thu, 16 Jul 2026 12:26:57 +0200 Subject: [PATCH 01/13] [IMP] auth/invite: parameterize send_invite_email subject+ttl --- tests/unit/test_password_reset.py | 68 +++++++++++++++++++++++++++++++ ui/auth/invite.py | 26 +++++++++--- 2 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 tests/unit/test_password_reset.py diff --git a/tests/unit/test_password_reset.py b/tests/unit/test_password_reset.py new file mode 100644 index 0000000..3362dd0 --- /dev/null +++ b/tests/unit/test_password_reset.py @@ -0,0 +1,68 @@ +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.mark.asyncio +async def test_send_invite_email_custom_subject_and_ttl(): + from ui.auth import invite + + user = {"email": "x@example.com", "display_name": "X", "username": "x"} + captured = {} + + async def fake_call_tool(tool_name, tool_args): + captured["subject"] = tool_args["subject"] + captured["body"] = tool_args["body"] + return "ok" + + with ( + patch("ui.auth.invite.get_client_manager") as mock_cm, + patch("ui.auth.invite.SystemConfig") as mock_sc, + patch("ui.auth.invite.get_agent_email_config") as mock_cfg, + ): + mock_cm.return_value = AsyncMock(call_tool=fake_call_tool) + mock_sc.get_param_sync.return_value = "peggy" + mock_cfg.return_value = { + "account": "hello@dubhe.it", + "sender_name": "P", + "signature": "", + } + + sent = await invite.send_invite_email( + user, "https://x/setup?token=t", subject="Reset your password", ttl_hours=1 + ) + + assert sent is True + assert captured["subject"] == "Reset your password" + assert "1 hour" in captured["body"] + + +@pytest.mark.asyncio +async def test_send_invite_email_defaults_unchanged(): + from ui.auth import invite + + user = {"email": "x@example.com", "display_name": "X", "username": "x"} + captured = {} + + async def fake_call_tool(tool_name, tool_args): + captured["subject"] = tool_args["subject"] + captured["body"] = tool_args["body"] + return "ok" + + with ( + patch("ui.auth.invite.get_client_manager") as mock_cm, + patch("ui.auth.invite.SystemConfig") as mock_sc, + patch("ui.auth.invite.get_agent_email_config") as mock_cfg, + ): + mock_cm.return_value = AsyncMock(call_tool=fake_call_tool) + mock_sc.get_param_sync.return_value = "peggy" + mock_cfg.return_value = { + "account": "hello@dubhe.it", + "sender_name": "P", + "signature": "", + } + + await invite.send_invite_email(user, "https://x/setup?token=t") + + assert captured["subject"] == "GridBear — Set up your password" + assert "48 hours" in captured["body"] diff --git a/ui/auth/invite.py b/ui/auth/invite.py index 1a4dbdf..3698d1a 100644 --- a/ui/auth/invite.py +++ b/ui/auth/invite.py @@ -11,7 +11,9 @@ import bcrypt from core.config_models import PasswordToken +from core.mcp_gateway.server import get_client_manager from core.models.user import User +from core.system_config import SystemConfig logger = logging.getLogger(__name__) @@ -123,12 +125,23 @@ def get_agent_email_config(agent_id: str) -> dict | None: return None -async def send_invite_email(user: dict, token_url: str) -> bool: +async def send_invite_email( + user: dict, + token_url: str, + subject: str | None = None, + ttl_hours: int | None = None, +) -> bool: """Try to send an invite email via the system agent's Gmail MCP tool. Reads the system_agent setting, looks up its email config (account, sender_name, signature), and calls the correct namespaced Gmail tool. + Args: + user: User dict with email, display_name, username. + token_url: Setup/reset link to embed in the email body. + subject: Email subject override (defaults to the invite subject). + ttl_hours: Expiry TTL shown in the body (defaults to INVITE_TTL_HOURS). + Returns True if email was sent, False if delivery failed or unavailable. """ email = user.get("email") @@ -139,10 +152,13 @@ async def send_invite_email(user: dict, token_url: str) -> bool: ) return False + if subject is None: + subject = "GridBear — Set up your password" + if ttl_hours is None: + ttl_hours = INVITE_TTL_HOURS + try: from core.mcp_gateway.client_manager import _sanitize_name - from core.mcp_gateway.server import get_client_manager - from core.system_config import SystemConfig client_manager = get_client_manager() if not client_manager: @@ -172,13 +188,13 @@ async def send_invite_email(user: dict, token_url: str) -> bool: tool_name = f"{sanitized}__send_email" display_name = user.get("display_name") or user.get("username", "") - subject = "GridBear — Set up your password" + hours_label = "1 hour" if ttl_hours == 1 else f"{ttl_hours} hours" body = ( f"Hi {display_name},\n\n" f"You've been invited to set up your GridBear portal password.\n\n" f"Click the link below to create your password:\n" f"{token_url}\n\n" - f"This link expires in {INVITE_TTL_HOURS} hours.\n\n" + f"This link expires in {hours_label}.\n\n" f"If you didn't expect this email, you can safely ignore it." ) if signature: From 4721a11d3a0016e7685187fefc9e556d18f31c01 Mon Sep 17 00:00:00 2001 From: Beorn Date: Thu, 16 Jul 2026 12:33:26 +0200 Subject: [PATCH 02/13] [ADD] auth/password_reset: reset-token issuance logic --- tests/unit/test_password_reset.py | 103 ++++++++++++++++++++++++++++++ ui/auth/password_reset.py | 78 ++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 ui/auth/password_reset.py diff --git a/tests/unit/test_password_reset.py b/tests/unit/test_password_reset.py index 3362dd0..f856d49 100644 --- a/tests/unit/test_password_reset.py +++ b/tests/unit/test_password_reset.py @@ -66,3 +66,106 @@ async def fake_call_tool(tool_name, tool_args): assert captured["subject"] == "GridBear — Set up your password" assert "48 hours" in captured["body"] + + +def _user(**over): + base = { + "id": 42, + "username": "dcorio", + "email": "davide.corio@dubhe.it", + "display_name": "Davide", + "is_active": True, + "password_hash": "$2b$xx", + } + base.update(over) + return base + + +@pytest.mark.asyncio +async def test_reset_unknown_email_does_nothing(): + with ( + patch("ui.auth.password_reset.User") as mock_user, + patch("ui.auth.password_reset.generate_token") as mock_gen, + patch( + "ui.auth.password_reset.send_invite_email", new_callable=AsyncMock + ) as mock_send, + ): + mock_user.raw_search_sync.return_value = [] + from ui.auth.password_reset import request_password_reset + + result = await request_password_reset("nobody@example.com", "https://x") + assert result is None + mock_gen.assert_not_called() + mock_send.assert_not_called() + + +@pytest.mark.asyncio +async def test_reset_eligible_user_issues_token_and_sends(): + with ( + patch("ui.auth.password_reset.User") as mock_user, + patch("ui.auth.password_reset.generate_token") as mock_gen, + patch( + "ui.auth.password_reset.send_invite_email", new_callable=AsyncMock + ) as mock_send, + ): + mock_user.raw_search_sync.return_value = [_user()] + mock_gen.return_value = "raw-token-xyz" + from ui.auth.password_reset import request_password_reset + + await request_password_reset("Davide.Corio@Dubhe.it", "https://x") + mock_gen.assert_called_once_with(42, purpose="reset") + args, kwargs = mock_send.call_args + assert "raw-token-xyz" in args[1] + assert kwargs["ttl_hours"] == 1 + + +@pytest.mark.asyncio +async def test_reset_bot_only_user_skipped(): + with ( + patch("ui.auth.password_reset.User") as mock_user, + patch("ui.auth.password_reset.generate_token") as mock_gen, + patch( + "ui.auth.password_reset.send_invite_email", new_callable=AsyncMock + ) as mock_send, + ): + mock_user.raw_search_sync.return_value = [_user(password_hash=None)] + from ui.auth.password_reset import request_password_reset + + await request_password_reset("davide.corio@dubhe.it", "https://x") + mock_gen.assert_not_called() + mock_send.assert_not_called() + + +@pytest.mark.asyncio +async def test_reset_inactive_user_skipped(): + with ( + patch("ui.auth.password_reset.User") as mock_user, + patch("ui.auth.password_reset.generate_token") as mock_gen, + patch( + "ui.auth.password_reset.send_invite_email", new_callable=AsyncMock + ) as mock_send, + ): + mock_user.raw_search_sync.return_value = [_user(is_active=False)] + from ui.auth.password_reset import request_password_reset + + await request_password_reset("davide.corio@dubhe.it", "https://x") + mock_gen.assert_not_called() + mock_send.assert_not_called() + + +@pytest.mark.asyncio +async def test_reset_send_failure_swallowed(): + with ( + patch("ui.auth.password_reset.User") as mock_user, + patch("ui.auth.password_reset.generate_token") as mock_gen, + patch( + "ui.auth.password_reset.send_invite_email", new_callable=AsyncMock + ) as mock_send, + ): + mock_user.raw_search_sync.return_value = [_user()] + mock_gen.return_value = "t" + mock_send.side_effect = RuntimeError("mcp down") + from ui.auth.password_reset import request_password_reset + + result = await request_password_reset("davide.corio@dubhe.it", "https://x") + assert result is None diff --git a/ui/auth/password_reset.py b/ui/auth/password_reset.py new file mode 100644 index 0000000..7f9c508 --- /dev/null +++ b/ui/auth/password_reset.py @@ -0,0 +1,78 @@ +"""Self-service password reset: lookup, token issuance, email delivery. + +request_password_reset() always returns None and never reveals whether the +address matched an eligible account — non-disclosure is a property of the +signature. +""" + +import logging + +from starlette.concurrency import run_in_threadpool + +from core.models.user import User +from ui.auth.invite import RESET_TTL_HOURS, generate_token, send_invite_email + +logger = logging.getLogger(__name__) + +RESET_SUBJECT = "GridBear — Reset your password" + + +def _lookup_and_issue_token( + email: str, base_url: str +) -> tuple[dict | None, str | None]: + """Sync half: exact case-insensitive lookup, eligibility, token. + + Returns (user_dict, token_url) or (None, None). Runs bcrypt (in + generate_token) and sync DB calls, so it MUST run off the event loop. + """ + rows = User.raw_search_sync( + "SELECT * FROM {table} WHERE lower(email) = %s", + (email.strip().lower(),), + ) + if not rows: + logger.warning("reset_requested: no match for submitted address") + return None, None + + user = rows[0] + if not user.get("is_active") or not user.get("password_hash"): + logger.warning( + "reset_requested: user_id=%s ineligible (active=%s has_pw=%s)", + user.get("id"), + user.get("is_active"), + bool(user.get("password_hash")), + ) + return None, None + + raw_token = generate_token(user["id"], purpose="reset") + token_url = f"{base_url.rstrip('/')}/auth/setup-password?token={raw_token}" + logger.warning("reset_requested: user_id=%s token issued", user["id"]) + return user, token_url + + +async def request_password_reset(email: str, base_url: str) -> None: + """Issue and email a password-reset link if the address is eligible. + + Always returns None regardless of outcome. + """ + try: + user, token_url = await run_in_threadpool( + _lookup_and_issue_token, email, base_url + ) + except Exception as err: + logger.warning("reset_requested: unexpected error: %s", err) + return None + + if user is None: + return None + + try: + sent = await send_invite_email( + user, token_url, subject=RESET_SUBJECT, ttl_hours=RESET_TTL_HOURS + ) + logger.warning( + "reset_email_%s: user_id=%s", "sent" if sent else "failed", user["id"] + ) + except Exception as err: + logger.warning("reset_email_failed: user_id=%s err=%s", user["id"], err) + + return None From 2c7473ddb3933b829709fd79cf18adddf454ea43 Mon Sep 17 00:00:00 2001 From: Beorn Date: Thu, 16 Jul 2026 12:40:00 +0200 Subject: [PATCH 03/13] [ADD] rate_limit: per-key limiter + password_reset category --- tests/unit/test_forgot_password_routes.py | 15 ++++++++++++++ ui/rate_limit.py | 24 +++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 tests/unit/test_forgot_password_routes.py diff --git a/tests/unit/test_forgot_password_routes.py b/tests/unit/test_forgot_password_routes.py new file mode 100644 index 0000000..8686814 --- /dev/null +++ b/tests/unit/test_forgot_password_routes.py @@ -0,0 +1,15 @@ +def test_password_reset_category_exists(): + from ui.rate_limit import RATE_LIMITS + + cfg = RATE_LIMITS["password_reset"] + assert cfg.requests == 3 + assert cfg.window == 300 + + +def test_check_rate_limit_key_trips_after_threshold(): + from ui.rate_limit import RateLimiter + + limiter = RateLimiter() + key = "acct:someone@example.com" + allowed = [limiter.is_allowed_key(key, "password_reset")[0] for _ in range(4)] + assert allowed == [True, True, True, False] diff --git a/ui/rate_limit.py b/ui/rate_limit.py index f8157bc..1a9f0b0 100644 --- a/ui/rate_limit.py +++ b/ui/rate_limit.py @@ -25,6 +25,7 @@ class RateLimitConfig: "login": RateLimitConfig(requests=5, window=60), # 5 attempts per minute "api": RateLimitConfig(requests=100, window=60), # 100 requests per minute "default": RateLimitConfig(requests=60, window=60), # 60 requests per minute + "password_reset": RateLimitConfig(requests=3, window=300), # 3 per 5 min } @@ -85,6 +86,14 @@ def is_allowed(self, ip: str, category: str = "default") -> tuple[bool, int]: self._requests[key].append(now) return True, 0 + def is_allowed_key(self, key: str, category: str = "default") -> tuple[bool, int]: + """Check if a request is allowed for an arbitrary key (e.g. an account). + + Alias for `is_allowed` kept separate for call-site clarity (accounts + vs. IPs); the sliding-window logic is identical. + """ + return self.is_allowed(key, category) + def get_remaining(self, ip: str, category: str = "default") -> int: """Get remaining requests in current window.""" config = RATE_LIMITS.get(category, RATE_LIMITS["default"]) @@ -136,6 +145,21 @@ async def check_rate_limit(request: Request, category: str = "default"): ) +async def check_rate_limit_key(key: str, category: str = "default"): + """Check rate limit for an arbitrary key (e.g. an account). + + Raises HTTPException 429 if rate limited. + """ + allowed, retry_after = rate_limiter.is_allowed_key(key, category) + + if not allowed: + raise HTTPException( + status_code=429, + detail=f"Too many requests. Please try again in {retry_after} seconds.", + headers={"Retry-After": str(retry_after)}, + ) + + def rate_limit(category: str = "default"): """Decorator to apply rate limiting to a route. From 0d0a2f84489c525508d1ab243eb6f14aa1119384 Mon Sep 17 00:00:00 2001 From: Beorn Date: Fri, 17 Jul 2026 10:55:22 +0200 Subject: [PATCH 04/13] [ADD] auth: /auth/forgot-password route + template --- tests/unit/test_forgot_password_routes.py | 41 +++++++++++++++++++ ui/routes/auth.py | 35 +++++++++++++++- ui/templates/auth/forgot_password.html | 50 +++++++++++++++++++++++ 3 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 ui/templates/auth/forgot_password.html diff --git a/tests/unit/test_forgot_password_routes.py b/tests/unit/test_forgot_password_routes.py index 8686814..0477cf1 100644 --- a/tests/unit/test_forgot_password_routes.py +++ b/tests/unit/test_forgot_password_routes.py @@ -13,3 +13,44 @@ def test_check_rate_limit_key_trips_after_threshold(): key = "acct:someone@example.com" allowed = [limiter.is_allowed_key(key, "password_reset")[0] for _ in range(4)] assert allowed == [True, True, True, False] + + +def _client(): + # Route-logic test only: no CSRFMiddleware here. The POST is sessionless, + # which the CSRF middleware skips by design (ui/csrf.py). In production the + # GET creates a session + token and the template's csrf_field submits it, + # same as /auth/setup-password (also not CSRF-exempt). + from fastapi import FastAPI + from fastapi.testclient import TestClient + from starlette.middleware.sessions import SessionMiddleware + + from ui.routes.auth import router + + app = FastAPI() + app.add_middleware(SessionMiddleware, secret_key="test-key") + app.include_router(router, prefix="/auth") + return TestClient(app) + + +def test_get_forgot_password_renders_form(): + client = _client() + resp = client.get("/auth/forgot-password") + assert resp.status_code == 200 + assert 'name="email"' in resp.text + assert "csrf" in resp.text.lower() + + +def test_post_known_and_unknown_email_identical_response(): + from unittest.mock import patch + + client = _client() + with patch("ui.routes.auth.request_password_reset") as mock_req: + mock_req.return_value = None + r_known = client.post( + "/auth/forgot-password", data={"email": "davide.corio@dubhe.it"} + ) + r_unknown = client.post( + "/auth/forgot-password", data={"email": "nobody@example.com"} + ) + assert r_known.status_code == r_unknown.status_code == 200 + assert r_known.text == r_unknown.text diff --git a/ui/routes/auth.py b/ui/routes/auth.py index 61e72ab..4ccc852 100644 --- a/ui/routes/auth.py +++ b/ui/routes/auth.py @@ -17,15 +17,17 @@ import bcrypt from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi.responses import HTMLResponse, RedirectResponse, Response +from starlette.background import BackgroundTask from core.api_schemas import ApiResponse, api_ok from ui.auth.database import auth_db +from ui.auth.password_reset import request_password_reset from ui.auth.recovery import recovery_manager from ui.auth.session import get_current_user, get_session_token, session_manager from ui.auth.totp import totp_manager from ui.auth.webauthn import webauthn_manager from ui.config_manager import ConfigManager -from ui.rate_limit import check_rate_limit +from ui.rate_limit import check_rate_limit, check_rate_limit_key router = APIRouter() @@ -1199,6 +1201,37 @@ async def revoke_all_sessions(request: Request, user: dict = Depends(require_use return RedirectResponse(url="/auth/security", status_code=303) +# --- Password reset (forgot password) --- + + +@router.get("/forgot-password", response_class=HTMLResponse) +async def forgot_password_page(request: Request): + """Display the password-reset request form.""" + return templates.TemplateResponse( + "auth/forgot_password.html", + {"request": request, "submitted": False}, + ) + + +@router.post("/forgot-password", response_class=HTMLResponse) +async def forgot_password(request: Request, email: str = Form(...)): + """Accept a reset request; always respond generically. + + Rate-limited per IP and per account. All lookup/token/email work runs in a + BackgroundTask AFTER the response is sent, so response time does not depend + on whether the address matched an account. + """ + await check_rate_limit(request, "password_reset") + await check_rate_limit_key(f"acct:{email.strip().lower()}", "password_reset") + + base_url = str(request.base_url) + return templates.TemplateResponse( + "auth/forgot_password.html", + {"request": request, "submitted": True}, + background=BackgroundTask(request_password_reset, email, base_url), + ) + + # --- Password setup (invite / reset token) --- diff --git a/ui/templates/auth/forgot_password.html b/ui/templates/auth/forgot_password.html new file mode 100644 index 0000000..793c983 --- /dev/null +++ b/ui/templates/auth/forgot_password.html @@ -0,0 +1,50 @@ +{% extends "auth/auth_base.html" %} + +{% block title %}Reset Password - GridBear{% endblock %} + +{% block header %} +
+ GridBear +
+

Reset Your Password

+

Enter your email and we'll send you a reset link.

+{% endblock %} + +{% block content %} +{% if submitted %} +
+ + If that address is registered, a reset link is on its way. The link expires in 1 hour. +
+ + + Back to Login + +{% else %} +
+ {% include "partials/csrf_field.html" %} +
+ +
+ + + + +
+
+ + Back to login +
+{% endif %} +{% endblock %} From fb3b92cee0decd4a07b2bf949187adf1ecc05f4b Mon Sep 17 00:00:00 2001 From: Beorn Date: Fri, 17 Jul 2026 11:10:20 +0200 Subject: [PATCH 05/13] [ADD] auth/database: 013 unique index on lower(email) --- core/models/user.py | 3 + tests/integration/test_users_email_unique.py | 72 ++++++++++++++++++++ ui/auth/database.py | 41 +++++++---- 3 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 tests/integration/test_users_email_unique.py diff --git a/core/models/user.py b/core/models/user.py index f58f166..6e47a42 100644 --- a/core/models/user.py +++ b/core/models/user.py @@ -17,6 +17,9 @@ class User(Model): username = fields.Text(unique=True) email = fields.Text() + # Uniqueness enforced by partial index idx_users_email_lower + # (migration 013 in ui/auth/database.py), not the ORM + password_hash = fields.Text() # nullable = bot-only user display_name = fields.Text() avatar_url = fields.Text() diff --git a/tests/integration/test_users_email_unique.py b/tests/integration/test_users_email_unique.py new file mode 100644 index 0000000..6851a59 --- /dev/null +++ b/tests/integration/test_users_email_unique.py @@ -0,0 +1,72 @@ +import os + +import pytest + +DATABASE_URL = os.environ.get("TEST_DATABASE_URL", "") +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif(not DATABASE_URL, reason="TEST_DATABASE_URL not set"), +] + + +@pytest.fixture(scope="module") +def pg_db(): + from psycopg.rows import dict_row + from psycopg_pool import ConnectionPool + + from core.database import DatabaseManager + from core.models.company import Company + from core.models.user import User + from core.orm.migrate import migrate_all + from core.orm.model import set_database as orm_set_database + + dm = DatabaseManager(DATABASE_URL) + dm._sync_pool = ConnectionPool( + DATABASE_URL, + min_size=1, + max_size=3, + open=False, + kwargs={"row_factory": dict_row}, + ) + dm._sync_pool.open() + + orm_set_database(dm) + + with dm.acquire_sync() as conn: + conn.execute("CREATE SCHEMA IF NOT EXISTS admin") + conn.execute("CREATE SCHEMA IF NOT EXISTS app") + conn.execute( + "CREATE TABLE IF NOT EXISTS public._migrations (" + "id SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL, " + "applied_at TIMESTAMPTZ DEFAULT NOW())" + ) + conn.commit() + + # Ensure app.users exists (ORM-managed) before the migration under test + # creates a unique index against it. Company must be migrated first — + # User.company_id is a FK to app.companies. + migrate_all([Company, User], dm) + + yield dm + dm._sync_pool.close() + + +def test_migration_creates_partial_unique_index_and_is_idempotent(pg_db): + from unittest.mock import patch + + # init_auth_db() does `from core.registry import get_database` INSIDE the + # function body, so patching core.registry.get_database is sufficient — + # the local import resolves the attribute on core.registry at call time. + # ui.auth.database has no module-level get_database name to patch. + with patch("core.registry.get_database", return_value=pg_db): + from ui.auth.database import init_auth_db + + init_auth_db() + init_auth_db() # second call must not raise + + with pg_db.acquire_sync() as conn: + row = conn.execute( + "SELECT 1 FROM pg_indexes WHERE schemaname='app' " + "AND indexname='idx_users_email_lower'" + ).fetchone() + assert row is not None diff --git a/ui/auth/database.py b/ui/auth/database.py index 20fc8d7..c426541 100644 --- a/ui/auth/database.py +++ b/ui/auth/database.py @@ -80,26 +80,39 @@ def _init_pg(db) -> None: - """Run the PostgreSQL migration if not already applied.""" + """Run PostgreSQL migrations if not already applied.""" with db.acquire_sync() as conn: - # Check if migration already applied + # Migration 001: admin auth schema row = conn.execute( "SELECT 1 FROM public._migrations WHERE name = %s", ("001_admin_auth",), ).fetchone() - if row: - return - - # Execute embedded DDL - conn.execute(PG_SCHEMA) + if not row: + conn.execute(PG_SCHEMA) + conn.execute( + "INSERT INTO public._migrations (name) VALUES (%s)", + ("001_admin_auth",), + ) + conn.commit() + logger.info("Applied migration: 001_admin_auth") - # Register migration - conn.execute( - "INSERT INTO public._migrations (name) VALUES (%s)", - ("001_admin_auth",), - ) - conn.commit() - logger.info("Applied migration: 001_admin_auth") + # Migration 013: partial unique index on lower(email) + row = conn.execute( + "SELECT 1 FROM public._migrations WHERE name = %s", + ("013_users_email_unique",), + ).fetchone() + if not row: + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_lower " + "ON app.users (lower(email)) " + "WHERE email IS NOT NULL AND email <> ''" + ) + conn.execute( + "INSERT INTO public._migrations (name) VALUES (%s)", + ("013_users_email_unique",), + ) + conn.commit() + logger.info("Applied migration: 013_users_email_unique") def init_auth_db() -> None: From 289b76e76588afbecc497aae7b3b6e86da9c508b Mon Sep 17 00:00:00 2001 From: Beorn Date: Fri, 17 Jul 2026 14:27:16 +0200 Subject: [PATCH 06/13] [FIX] users: graceful error on duplicate email Partial unique index on lower(email) can raise UniqueViolation from create/update portal user; redirect to /users?error=email_exists instead of surfacing a 500. --- tests/integration/test_users_email_unique.py | 23 +++++ tests/unit/test_users_routes.py | 92 ++++++++++++++++++++ ui/routes/users.py | 37 +++++--- ui/templates/users.html | 17 ++++ 4 files changed, 155 insertions(+), 14 deletions(-) create mode 100644 tests/unit/test_users_routes.py diff --git a/tests/integration/test_users_email_unique.py b/tests/integration/test_users_email_unique.py index 6851a59..e41af30 100644 --- a/tests/integration/test_users_email_unique.py +++ b/tests/integration/test_users_email_unique.py @@ -70,3 +70,26 @@ def test_migration_creates_partial_unique_index_and_is_idempotent(pg_db): "AND indexname='idx_users_email_lower'" ).fetchone() assert row is not None + + +def test_create_user_duplicate_email_rejected(pg_db): + """Two users sharing an email (case-insensitive) must be rejected at DB level.""" + import psycopg + + with pg_db.acquire_sync() as conn: + conn.execute( + "INSERT INTO app.users (username, email, is_active) " + "VALUES ('dup_a', 'dup@example.com', true) " + "ON CONFLICT (username) DO NOTHING" + ) + conn.commit() + with pg_db.acquire_sync() as conn: + with pytest.raises(psycopg.errors.UniqueViolation): + conn.execute( + "INSERT INTO app.users (username, email, is_active) " + "VALUES ('dup_b', 'DUP@example.com', true)" + ) + conn.commit() + with pg_db.acquire_sync() as conn: + conn.execute("DELETE FROM app.users WHERE username IN ('dup_a','dup_b')") + conn.commit() diff --git a/tests/unit/test_users_routes.py b/tests/unit/test_users_routes.py new file mode 100644 index 0000000..e97d7ed --- /dev/null +++ b/tests/unit/test_users_routes.py @@ -0,0 +1,92 @@ +from unittest.mock import MagicMock, patch + +import pytest + + +def _unique_violation(constraint_name): + """Build a UniqueViolation whose .diag.constraint_name is set. + + The real psycopg .diag is a read-only C attribute populated only when the + error comes from the server, so we subclass and override it for unit tests. + Subclassing keeps the stub isolated (no global mutation of the psycopg + class) while remaining a genuine UniqueViolation for the route's except. + """ + import psycopg + + class _StubUniqueViolation(psycopg.errors.UniqueViolation): + diag = MagicMock(constraint_name=constraint_name) + + return _StubUniqueViolation("dup") + + +@pytest.mark.asyncio +async def test_create_portal_user_duplicate_email_redirects(): + from ui.routes import users + + mock_db = MagicMock() + mock_db.get_user_by_username.return_value = None + mock_db.create_user.side_effect = _unique_violation("idx_users_email_lower") + + with ( + patch("ui.routes.users.auth_db", new=mock_db), + patch("ui.routes.auth.hash_password", return_value="h"), + ): + resp = await users.create_portal_user( + request=None, + username="newuser", + password="a-strong-password-123", + display_name="New", + email="dup@example.com", + is_superadmin="", + _=True, + ) + assert resp.status_code == 303 + assert "error=email_exists" in resp.headers["location"] + + +@pytest.mark.asyncio +async def test_create_portal_user_username_race_redirects(): + from ui.routes import users + + mock_db = MagicMock() + mock_db.get_user_by_username.return_value = None + mock_db.create_user.side_effect = _unique_violation("users_username_key") + + with ( + patch("ui.routes.users.auth_db", new=mock_db), + patch("ui.routes.auth.hash_password", return_value="h"), + ): + resp = await users.create_portal_user( + request=None, + username="raced", + password="a-strong-password-123", + display_name="R", + email="r@example.com", + is_superadmin="", + _=True, + ) + assert resp.status_code == 303 + assert "error=username_exists" in resp.headers["location"] + + +@pytest.mark.asyncio +async def test_update_portal_user_duplicate_email_redirects(): + import psycopg + + from ui.routes import users + + mock_db = MagicMock() + mock_db.update_user.side_effect = psycopg.errors.UniqueViolation("dup email") + + with patch("ui.routes.users.auth_db", new=mock_db): + resp = await users.update_portal_user( + request=None, + user_id=1, + display_name="Existing", + email="dup@example.com", + is_superadmin="", + is_active="1", + _=True, + ) + assert resp.status_code == 303 + assert "error=email_exists" in resp.headers["location"] diff --git a/ui/routes/users.py b/ui/routes/users.py index 365d8af..ab65737 100644 --- a/ui/routes/users.py +++ b/ui/routes/users.py @@ -1,5 +1,6 @@ from pathlib import Path +import psycopg from fastapi import APIRouter, Depends, Form, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse @@ -156,13 +157,18 @@ async def create_portal_user( if existing: return RedirectResponse(url="/users?error=username_exists", status_code=303) - auth_db.create_user( - username=username, - password_hash=hash_password(password), - display_name=display_name.strip() or None, - is_superadmin=is_superadmin == "1", - email=email.strip() or None, - ) + try: + auth_db.create_user( + username=username, + password_hash=hash_password(password), + display_name=display_name.strip() or None, + is_superadmin=is_superadmin == "1", + email=email.strip() or None, + ) + except psycopg.errors.UniqueViolation as exc: + if getattr(exc.diag, "constraint_name", None) == "idx_users_email_lower": + return RedirectResponse(url="/users?error=email_exists", status_code=303) + return RedirectResponse(url="/users?error=username_exists", status_code=303) return RedirectResponse(url="/users", status_code=303) @@ -178,13 +184,16 @@ async def update_portal_user( _: bool = Depends(require_login), ): """Update a user.""" - auth_db.update_user( - user_id, - display_name=display_name.strip() or None, - is_superadmin=is_superadmin == "1", - is_active=is_active == "1", - email=email.strip() or None, - ) + try: + auth_db.update_user( + user_id, + display_name=display_name.strip() or None, + is_superadmin=is_superadmin == "1", + is_active=is_active == "1", + email=email.strip() or None, + ) + except psycopg.errors.UniqueViolation: + return RedirectResponse(url="/users?error=email_exists", status_code=303) return RedirectResponse(url="/users", status_code=303) diff --git a/ui/templates/users.html b/ui/templates/users.html index 071b12b..74d3f16 100644 --- a/ui/templates/users.html +++ b/ui/templates/users.html @@ -6,6 +6,23 @@ {% block breadcrumb_active %}Users{% endblock %} {% block page_description %}

Manage user accounts, platform identities and channel authorization

{% endblock %} +{% block content_alerts %} +{% set error = request.query_params.get('error') %} +{% if error %} +
+ + + {% if error == 'email_exists' %}A user with that email already exists. + {% elif error == 'username_exists' %}A user with that username already exists. + {% elif error == 'invalid_input' %}Invalid input: check username and password. + {% elif error == 'password_short' %}Password is too short. + {% elif error == 'self_delete' %}You cannot delete your own account. + {% else %}An error occurred.{% endif %} + +
+{% endif %} +{% endblock %} + {% block content %}
From 5849075341dc36049523151503c25df32557018f Mon Sep 17 00:00:00 2001 From: Beorn Date: Fri, 17 Jul 2026 15:18:35 +0200 Subject: [PATCH 07/13] [FIX] auth: setup_password form honors min length 12 --- tests/unit/test_forgot_password_routes.py | 10 +++++++++ ui/routes/auth.py | 25 ++++++++++++++++++++--- ui/templates/auth/setup_password.html | 6 +++--- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/tests/unit/test_forgot_password_routes.py b/tests/unit/test_forgot_password_routes.py index 0477cf1..667c539 100644 --- a/tests/unit/test_forgot_password_routes.py +++ b/tests/unit/test_forgot_password_routes.py @@ -54,3 +54,13 @@ def test_post_known_and_unknown_email_identical_response(): ) assert r_known.status_code == r_unknown.status_code == 200 assert r_known.text == r_unknown.text + + +def test_setup_password_page_reflects_min_length(): + client = _client() + resp = client.get( + "/auth/setup-password" + ) # no token → renders form with an error banner + assert resp.status_code == 200 + assert 'minlength="12"' in resp.text + assert 'minlength="8"' not in resp.text diff --git a/ui/routes/auth.py b/ui/routes/auth.py index 4ccc852..a65c75d 100644 --- a/ui/routes/auth.py +++ b/ui/routes/auth.py @@ -1244,14 +1244,24 @@ async def setup_password_page(request: Request): if not raw_token: return templates.TemplateResponse( "auth/setup_password.html", - {"request": request, "error": "Missing token.", "token": ""}, + { + "request": request, + "error": "Missing token.", + "token": "", + "min_password_length": MIN_PASSWORD_LENGTH, + }, ) token_data = validate_token(raw_token) if not token_data: return templates.TemplateResponse( "auth/setup_password.html", - {"request": request, "error": "Invalid or expired link.", "token": ""}, + { + "request": request, + "error": "Invalid or expired link.", + "token": "", + "min_password_length": MIN_PASSWORD_LENGTH, + }, ) return templates.TemplateResponse( @@ -1263,6 +1273,7 @@ async def setup_password_page(request: Request): "display_name": token_data.get("display_name"), "error": None, "success": None, + "min_password_length": MIN_PASSWORD_LENGTH, }, ) @@ -1281,7 +1292,12 @@ async def setup_password( if not token_data: return templates.TemplateResponse( "auth/setup_password.html", - {"request": request, "error": "Invalid or expired link.", "token": ""}, + { + "request": request, + "error": "Invalid or expired link.", + "token": "", + "min_password_length": MIN_PASSWORD_LENGTH, + }, ) if password != password_confirm: @@ -1294,6 +1310,7 @@ async def setup_password( "username": token_data["unified_id"], "display_name": token_data.get("display_name"), "success": None, + "min_password_length": MIN_PASSWORD_LENGTH, }, ) @@ -1307,6 +1324,7 @@ async def setup_password( "username": token_data["unified_id"], "display_name": token_data.get("display_name"), "success": None, + "min_password_length": MIN_PASSWORD_LENGTH, }, ) @@ -1320,5 +1338,6 @@ async def setup_password( "success": "Password set successfully! You can now log in.", "error": None, "token": "", + "min_password_length": MIN_PASSWORD_LENGTH, }, ) diff --git a/ui/templates/auth/setup_password.html b/ui/templates/auth/setup_password.html index 2c8f373..6bd86c0 100644 --- a/ui/templates/auth/setup_password.html +++ b/ui/templates/auth/setup_password.html @@ -42,11 +42,11 @@

Set Up Your Password

+ minlength="{{ min_password_length }}">
@@ -62,7 +62,7 @@

Set Up Your Password

placeholder="Confirm password" required autocomplete="new-password" - minlength="8"> + minlength="{{ min_password_length }}"> From 2093dd142f747db6b37f9b7a291333841102cb6b Mon Sep 17 00:00:00 2001 From: Beorn Date: Fri, 17 Jul 2026 15:33:38 +0200 Subject: [PATCH 08/13] [FIX] tests: stop webauthn stub leaking across tests --- tests/unit/test_oauth2_register_cli.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_oauth2_register_cli.py b/tests/unit/test_oauth2_register_cli.py index c8ed0d9..de1b720 100644 --- a/tests/unit/test_oauth2_register_cli.py +++ b/tests/unit/test_oauth2_register_cli.py @@ -8,14 +8,20 @@ 5. Backward compatibility: redirect_uris flow still works """ +import importlib import sys from unittest.mock import AsyncMock, MagicMock, patch import pytest -# Stub optional dependencies that may not be installed on the host +# Stub optional dependencies only when genuinely absent. Checking `not in +# sys.modules` is not enough: an installed-but-not-yet-imported package would be +# stubbed with a MagicMock that then leaks into later tests (e.g. `from +# webauthn.helpers import ...` failing with "'webauthn' is not a package"). for _mod in ("pyotp", "qrcode", "webauthn"): - if _mod not in sys.modules: + try: + importlib.import_module(_mod) + except ImportError: sys.modules[_mod] = MagicMock() from core.oauth2.server import register_client From 77d73964266d1c56beb91ad7329bb57444965910 Mon Sep 17 00:00:00 2001 From: Beorn Date: Fri, 17 Jul 2026 15:49:30 +0200 Subject: [PATCH 09/13] [FIX] auth: reset link uses GRIDBEAR_BASE_URL not request host --- tests/unit/test_forgot_password_routes.py | 39 +++++++++++++++++++++++ ui/routes/auth.py | 4 ++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_forgot_password_routes.py b/tests/unit/test_forgot_password_routes.py index 667c539..922028c 100644 --- a/tests/unit/test_forgot_password_routes.py +++ b/tests/unit/test_forgot_password_routes.py @@ -1,3 +1,17 @@ +import pytest + + +@pytest.fixture(autouse=True) +def _reset_rate_limiter(): + # rate_limiter is a module-level singleton; clear it so per-IP/per-account + # counts don't accumulate across tests in the same process. + from ui.rate_limit import rate_limiter + + rate_limiter._requests.clear() + yield + rate_limiter._requests.clear() + + def test_password_reset_category_exists(): from ui.rate_limit import RATE_LIMITS @@ -56,6 +70,31 @@ def test_post_known_and_unknown_email_identical_response(): assert r_known.text == r_unknown.text +def test_post_uses_configured_base_url(monkeypatch): + from unittest.mock import patch + + monkeypatch.setenv("GRIDBEAR_BASE_URL", "https://gridbear.example.com") + client = _client() + with patch("ui.routes.auth.request_password_reset") as mock_req: + mock_req.return_value = None + client.post("/auth/forgot-password", data={"email": "x@example.com"}) + # The BackgroundTask runs during response teardown under TestClient. + args, _ = mock_req.call_args + assert args[1] == "https://gridbear.example.com" + + +def test_post_falls_back_to_request_base_url(monkeypatch): + from unittest.mock import patch + + monkeypatch.delenv("GRIDBEAR_BASE_URL", raising=False) + client = _client() + with patch("ui.routes.auth.request_password_reset") as mock_req: + mock_req.return_value = None + client.post("/auth/forgot-password", data={"email": "x@example.com"}) + args, _ = mock_req.call_args + assert args[1].startswith("http://testserver") + + def test_setup_password_page_reflects_min_length(): client = _client() resp = client.get( diff --git a/ui/routes/auth.py b/ui/routes/auth.py index a65c75d..bc1ae11 100644 --- a/ui/routes/auth.py +++ b/ui/routes/auth.py @@ -1224,7 +1224,9 @@ async def forgot_password(request: Request, email: str = Form(...)): await check_rate_limit(request, "password_reset") await check_rate_limit_key(f"acct:{email.strip().lower()}", "password_reset") - base_url = str(request.base_url) + # Prefer the configured public URL so the emailed link points at the real + # host, not whatever host this request arrived on (proxy/curl/localhost). + base_url = os.environ.get("GRIDBEAR_BASE_URL") or str(request.base_url) return templates.TemplateResponse( "auth/forgot_password.html", {"request": request, "submitted": True}, From e55a3fb95e07d3e2eb90b2e4b6ff84961c40c379 Mon Sep 17 00:00:00 2001 From: Beorn Date: Fri, 17 Jul 2026 16:02:47 +0200 Subject: [PATCH 10/13] [FIX] auth/invite: invalidate prior tokens (IS NULL not = NULL) --- tests/integration/test_token_invalidation.py | 127 +++++++++++++++++++ ui/auth/invite.py | 2 +- 2 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 tests/integration/test_token_invalidation.py diff --git a/tests/integration/test_token_invalidation.py b/tests/integration/test_token_invalidation.py new file mode 100644 index 0000000..728a72d --- /dev/null +++ b/tests/integration/test_token_invalidation.py @@ -0,0 +1,127 @@ +import os +from unittest.mock import patch + +import pytest + +DATABASE_URL = os.environ.get("TEST_DATABASE_URL", "") +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif(not DATABASE_URL, reason="TEST_DATABASE_URL not set"), +] + + +@pytest.fixture(scope="module") +def pg_db(): + from psycopg.rows import dict_row + from psycopg_pool import ConnectionPool + + from core.config_models import PasswordToken + from core.database import DatabaseManager + from core.models.company import Company + from core.models.user import User + from core.orm.migrate import migrate_all + from core.orm.model import set_database as orm_set_database + + dm = DatabaseManager(DATABASE_URL) + dm._sync_pool = ConnectionPool( + DATABASE_URL, + min_size=1, + max_size=3, + open=False, + kwargs={"row_factory": dict_row}, + ) + dm._sync_pool.open() + + orm_set_database(dm) + + with dm.acquire_sync() as conn: + conn.execute("CREATE SCHEMA IF NOT EXISTS admin") + conn.execute("CREATE SCHEMA IF NOT EXISTS app") + conn.execute( + "CREATE TABLE IF NOT EXISTS public._migrations (" + "id SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL, " + "applied_at TIMESTAMPTZ DEFAULT NOW())" + ) + # Stale environment fixup: this test DB may contain a leftover + # app.users table predating the current User model (PK on + # `username`, no `id` column). migrate_all() only ever ADDs + # columns to existing tables, it never fixes a wrong PK, so a + # stale table would break the PasswordToken.user_id FK to + # app.users(id). Drop and let migrate_all recreate it correctly. + conn.execute("DROP TABLE IF EXISTS app.password_tokens CASCADE") + conn.execute("DROP TABLE IF EXISTS app.users CASCADE") + # Dropping app.users also destroys the idx_users_email_lower + # index applied by ui.auth.database migration 013. Clear its + # marker so init_auth_db() (used by other integration tests + # sharing this DB) re-applies it against the recreated table. + conn.execute( + "DELETE FROM public._migrations WHERE name = %s", + ("013_users_email_unique",), + ) + conn.commit() + + migrate_all([Company, User, PasswordToken], dm) + + yield dm + dm._sync_pool.close() + + +def test_generate_token_invalidates_prior_unused_token(pg_db): + """generate_token must invalidate prior unused reset tokens for the + same user+purpose. Regression for `("used_at", "=", None)` which is + SQL `used_at = NULL` (always false) instead of `used_at IS NULL`. + """ + from core.models.user import User + + with patch("core.registry.get_database", return_value=pg_db): + user = User.create_sync( + username="token_invalidation_test_user", + password_hash="x", + is_active=True, + ) + user_id = user["id"] if isinstance(user, dict) else user + + try: + from ui.auth.invite import generate_token + + t1 = generate_token(user_id, "reset") + t2 = generate_token(user_id, "reset") + + assert t1 != t2 + + with pg_db.acquire_sync() as conn: + rows = conn.execute( + "SELECT used_at FROM app.password_tokens " + "WHERE user_id = %s AND purpose = 'reset' " + "ORDER BY created_at", + (user_id,), + ).fetchall() + + assert len(rows) == 2 + assert rows[0]["used_at"] is not None, ( + "first token should have been invalidated by the second " + "generate_token() call" + ) + assert rows[1]["used_at"] is None + + unused_count_row = None + with pg_db.acquire_sync() as conn: + unused_count_row = conn.execute( + "SELECT count(*) AS c FROM app.password_tokens " + "WHERE user_id = %s AND purpose = 'reset' " + "AND used_at IS NULL", + (user_id,), + ).fetchone() + + assert unused_count_row["c"] == 1 + finally: + with pg_db.acquire_sync() as conn: + conn.execute( + "DELETE FROM app.password_tokens WHERE user_id = %s", + (user_id,), + ) + conn.execute( + "DELETE FROM app.users WHERE id = %s", + (user_id,), + ) + conn.commit() diff --git a/ui/auth/invite.py b/ui/auth/invite.py index 3698d1a..5eef266 100644 --- a/ui/auth/invite.py +++ b/ui/auth/invite.py @@ -47,7 +47,7 @@ def generate_token(user_id: int, purpose: str, ttl_hours: int | None = None) -> # Invalidate any existing unused tokens for same user+purpose existing = PasswordToken.search_sync( - [("user_id", "=", user_id), ("purpose", "=", purpose), ("used_at", "=", None)] + [("user_id", "=", user_id), ("purpose", "=", purpose), ("used_at", "is", None)] ) for old in existing: PasswordToken.write_sync(old["id"], used_at=datetime.now()) From 7f8ec1735847b3ade345059fd3ba53ccac5e4363 Mon Sep 17 00:00:00 2001 From: Beorn Date: Fri, 17 Jul 2026 17:40:09 +0200 Subject: [PATCH 11/13] [FIX] deps: pin starlette <0.51.0 to match fastapi --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c9d78db..d3bf2f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "filelock>=3.13.0", # Web / Admin UI "fastapi>=0.131.0,<0.139.1", - "starlette>=0.38.0,<2.0.0", + "starlette>=0.40.0,<0.51.0", "uvicorn>=0.27.0", "jinja2>=3.1.0", "itsdangerous>=2.1.0", From 808a2d18247a6e9b6945f5ddaca04ac9e5b992c1 Mon Sep 17 00:00:00 2001 From: Beorn Date: Fri, 17 Jul 2026 19:46:27 +0200 Subject: [PATCH 12/13] [FIX] ci: ignore starlette CVEs pending 1.x migration --- .github/workflows/ci.yml | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8bd075..0d16348 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,7 +134,30 @@ jobs: # No fix release as of 2026-04-25; only triggered by malicious package # archives during install — we pin our own deps and don't install # arbitrary user input, so the path isn't reachable in our runtime. - run: pip-audit --desc --skip-editable --ignore-vuln CVE-2026-4539 --ignore-vuln CVE-2026-3219 # pygments regex DoS + pip tar/zip confusion + # + # Starlette PYSEC-2026-161/248/249/2280/2281: all fixed in Starlette + # 1.x, but the codebase is pinned to starlette<0.51.0 because fastapi's + # TemplateResponse call sites use the pre-1.0 signature (see issue for + # the tracked 1.x migration). Reachability audited — none apply to the + # running app: + # - 161 (Host header -> request.url.path auth bypass): the only + # request.url.path security check lives in OAuth2BearerMiddleware, + # which is not mounted; no active middleware/route reads request.url + # for authorization. + # - 248 (path -> request.url.hostname): no request.url.hostname/netloc + # usage anywhere in the codebase. + # - 249 (form limits ignored for urlencoded): we never set + # max_fields/max_part_size, so no configured limit is bypassed; + # generic large-body DoS is bounded by the fronting proxy. + # - 2280 (HTTPEndpoint method confusion): no Starlette HTTPEndpoint + # subclasses — routes are FastAPI APIRouter handlers. + # - 2281 (StaticFiles SSRF on Windows): Linux-only deployment. + run: >- + pip-audit --desc --skip-editable + --ignore-vuln CVE-2026-4539 --ignore-vuln CVE-2026-3219 + --ignore-vuln PYSEC-2026-161 --ignore-vuln GHSA-86qp-5c8j-p5mr + --ignore-vuln PYSEC-2026-248 --ignore-vuln PYSEC-2026-249 + --ignore-vuln PYSEC-2026-2280 --ignore-vuln PYSEC-2026-2281 - name: Secret scan run: | From 1bbf37f3cab684e38224b77ba632c79868de0147 Mon Sep 17 00:00:00 2001 From: Beorn Date: Fri, 17 Jul 2026 19:47:56 +0200 Subject: [PATCH 13/13] [FIX] ci: reference issue #181 in starlette CVE ignore --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d16348..016eb4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,9 +137,9 @@ jobs: # # Starlette PYSEC-2026-161/248/249/2280/2281: all fixed in Starlette # 1.x, but the codebase is pinned to starlette<0.51.0 because fastapi's - # TemplateResponse call sites use the pre-1.0 signature (see issue for - # the tracked 1.x migration). Reachability audited — none apply to the - # running app: + # TemplateResponse call sites use the pre-1.0 signature (tracked for + # the 1.x migration in issue #181). Reachability audited — none apply + # to the running app: # - 161 (Host header -> request.url.path auth bypass): the only # request.url.path security check lives in OAuth2BearerMiddleware, # which is not mounted; no active middleware/route reads request.url