From e7dd715d8ad21cf90e1c0538b517b21bea47313f Mon Sep 17 00:00:00 2001 From: wahh3b-lgtm Date: Mon, 15 Jun 2026 23:18:40 +0330 Subject: [PATCH 1/2] security: timing-safe HMAC and CORS wildcard fix - Use hmac.compare_digest with bitwise OR for constant-time JWT signature verification - Reject wildcard CORS origins when allow_credentials=True - Fix Python 2 exception syntax (except TypeError, ValueError -> except (TypeError, ValueError)) --- app/middlewares/__init__.py | 18 ++++++++++++++++-- app/utils/jwt.py | 5 +++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/app/middlewares/__init__.py b/app/middlewares/__init__.py index 0d581a4d2..905ad6373 100644 --- a/app/middlewares/__init__.py +++ b/app/middlewares/__init__.py @@ -9,10 +9,24 @@ def setup_middleware(app: FastAPI): + # Security: reject wildcard origin with credentials enabled + allowed_origins = cors_settings.allowed_origins + if "*" in allowed_origins: + import warnings + + warnings.warn( + "CORS allow_origins contains '*' with allow_credentials=True is insecure. " + "Set ALLOWED_ORIGINS to explicit origins in production.", + stacklevel=2, + ) + allow_credentials = False + else: + allow_credentials = True + app.add_middleware( CORSMiddleware, - allow_origins=cors_settings.allowed_origins, - allow_credentials=True, + allow_origins=allowed_origins, + allow_credentials=allow_credentials, allow_methods=["*"], allow_headers=["*"], ) diff --git a/app/utils/jwt.py b/app/utils/jwt.py index 2efa14451..924dd9779 100644 --- a/app/utils/jwt.py +++ b/app/utils/jwt.py @@ -1,3 +1,4 @@ +import hmac import time import jwt from base64 import b64decode, b64encode @@ -38,7 +39,7 @@ async def get_admin_payload(token: str) -> dict | None: if admin_id is not None: try: admin_id = int(admin_id) - except TypeError, ValueError: + except (TypeError, ValueError): return if not username or access not in ("admin", "sudo"): return @@ -97,7 +98,7 @@ async def get_subscription_payload(token: str) -> dict | None: sha256((u_token + await get_secret_key()).encode("utf-8")).digest(), altchars=b"-_" ).decode("utf-8")[:10] u_token_hex_resign = sha256((u_token + await get_secret_key()).encode("utf-8")).hexdigest()[:10] - if u_signature in (u_token_resign, u_token_hex_resign): + if hmac.compare_digest(u_signature, u_token_resign) | hmac.compare_digest(u_signature, u_token_hex_resign): parts = u_token_dec_str.split(",") if len(parts) == 3 and parts[0] in ("v2", "v3"): _, u_user_id_str, u_created_at_str = parts From c8a87c9bd23bc735fa2a15992ce9d20c5d5ec459 Mon Sep 17 00:00:00 2001 From: T3ST3ST3R0N Date: Sun, 11 Oct 2026 03:24:17 +0330 Subject: [PATCH 2/2] fix(security): drop CORS credentials for a wildcard origin and compare legacy signatures in constant time ALLOWED_ORIGINS defaults to "*". With allow_credentials on, Starlette then echoes every cross-origin request's Origin back together with Access-Control-Allow-Credentials, so any site could make credentialed requests and read the responses. Credentials are now only allowed when the origins are listed explicitly. The panel authenticates with bearer tokens, so the dashboard is unaffected; there is no startup warning, since the wildcard is the default. The legacy subscription token branch still matched its signature with `in`. It now uses hmac.compare_digest on bytes for both encodings (str input must be ASCII, and the signature comes from the URL). --- app/middlewares/__init__.py | 15 ++-------- app/utils/jwt.py | 6 +++- tests/test_security_hardening.py | 51 ++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 14 deletions(-) create mode 100644 tests/test_security_hardening.py diff --git a/app/middlewares/__init__.py b/app/middlewares/__init__.py index 905ad6373..58a373012 100644 --- a/app/middlewares/__init__.py +++ b/app/middlewares/__init__.py @@ -9,20 +9,9 @@ def setup_middleware(app: FastAPI): - # Security: reject wildcard origin with credentials enabled allowed_origins = cors_settings.allowed_origins - if "*" in allowed_origins: - import warnings - - warnings.warn( - "CORS allow_origins contains '*' with allow_credentials=True is insecure. " - "Set ALLOWED_ORIGINS to explicit origins in production.", - stacklevel=2, - ) - allow_credentials = False - else: - allow_credentials = True - + # With a wildcard plus credentials, Starlette echoes every Origin back with Allow-Credentials. + allow_credentials = "*" not in allowed_origins app.add_middleware( CORSMiddleware, allow_origins=allowed_origins, diff --git a/app/utils/jwt.py b/app/utils/jwt.py index cbbfe6275..dfb8b70f6 100644 --- a/app/utils/jwt.py +++ b/app/utils/jwt.py @@ -188,7 +188,11 @@ async def get_subscription_payload(token: str) -> dict | None: :10 ] u_token_hex_resign = sha256((u_token + secret).encode("utf-8")).hexdigest()[:10] - if u_signature in (u_token_resign, u_token_hex_resign): + # Constant-time compare against both encodings; bytes, since str input must be ASCII. + signature = u_signature.encode("utf-8") + if hmac.compare_digest(signature, u_token_resign.encode("utf-8")) | hmac.compare_digest( + signature, u_token_hex_resign.encode("utf-8") + ): return _parse_subscription_data(u_token_dec_str) return except jwt.exceptions.PyJWTError: diff --git a/tests/test_security_hardening.py b/tests/test_security_hardening.py new file mode 100644 index 000000000..d06c95cf1 --- /dev/null +++ b/tests/test_security_hardening.py @@ -0,0 +1,51 @@ +import asyncio +import time +from base64 import b64encode +from hashlib import sha256 +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app import middlewares +from app.utils import jwt as jwt_utils + + +def _legacy_v2_token(user_id: int, secret: str) -> str: + data_b64 = b64encode(f"v2,{user_id},{int(time.time())}".encode(), altchars=b"-_").decode().rstrip("=") + return data_b64 + b64encode(sha256((data_b64 + secret).encode()).digest(), altchars=b"-_").decode()[:10] + + +@pytest.fixture +def secret(monkeypatch): + async def fake_get_secret_key(): + return "test-secret" + + monkeypatch.setattr(jwt_utils, "get_secret_key", fake_get_secret_key) + return "test-secret" + + +def test_legacy_subscription_token_rejects_bad_signatures_without_errors(secret): + token = _legacy_v2_token(789, secret) + + assert asyncio.run(jwt_utils.get_subscription_payload(token))["user_id"] == 789 + for signature in ("A" * 10, "é" * 10): + assert asyncio.run(jwt_utils.get_subscription_payload(token[:-10] + signature)) is None + + +@pytest.mark.parametrize( + ("origins", "credentials"), + [("*", None), ("https://panel.example.com", "true")], +) +def test_cors_credentials_only_for_explicit_origins(monkeypatch, origins, credentials): + monkeypatch.setattr(middlewares, "cors_settings", SimpleNamespace(allowed_origins=[origins])) + app = FastAPI() + middlewares.setup_middleware(app) + app.get("/ping")(lambda: {"ok": True}) + + response = TestClient(app).get("/ping", headers={"Origin": "https://panel.example.com", "Cookie": "session=1"}) + + assert response.headers.get("access-control-allow-credentials") == credentials + expected_origin = "*" if origins == "*" else "https://panel.example.com" + assert response.headers.get("access-control-allow-origin") == expected_origin