From ce698d25739a0d9386a4c82593734f96c4997ebd Mon Sep 17 00:00:00 2001 From: Redacted User Date: Fri, 10 Apr 2026 00:11:10 +0200 Subject: [PATCH] security: harden API ingress and CI scanning --- .env.example | 26 ++ .github/dependabot.yml | 21 ++ .github/workflows/publish-npm.yml | 11 +- .github/workflows/security-scans.yml | 57 +++ apps/api/src/alicebot_api/config.py | 191 ++++++++++ apps/api/src/alicebot_api/main.py | 349 +++++++++++++++++- pyproject.toml | 1 + tests/integration/conftest.py | 10 + .../integration/test_http_security_posture.py | 164 ++++++++ ...hase10_identity_workspace_bootstrap_api.py | 94 +++++ .../test_phase10_telegram_transport_api.py | 78 ++++ tests/unit/test_config.py | 208 +++++++++++ tests/unit/test_main.py | 93 +++++ 13 files changed, 1289 insertions(+), 14 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/security-scans.yml create mode 100644 tests/integration/test_http_security_posture.py diff --git a/.env.example b/.env.example index 877e4f1..ceb8519 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,29 @@ PUBLIC_SAMPLE_DATA_PATH=fixtures/public_sample_data/continuity_v1.json # Per-user response generation throttle (POST /v0/responses). RESPONSE_RATE_LIMIT_WINDOW_SECONDS=60 RESPONSE_RATE_LIMIT_MAX_REQUESTS=20 +# Hosted auth and webhook ingress throttles. +MAGIC_LINK_START_RATE_LIMIT_WINDOW_SECONDS=300 +MAGIC_LINK_START_RATE_LIMIT_MAX_REQUESTS=5 +MAGIC_LINK_VERIFY_RATE_LIMIT_WINDOW_SECONDS=300 +MAGIC_LINK_VERIFY_RATE_LIMIT_MAX_REQUESTS=10 +TELEGRAM_WEBHOOK_RATE_LIMIT_WINDOW_SECONDS=60 +TELEGRAM_WEBHOOK_RATE_LIMIT_MAX_REQUESTS=120 +# Telegram transport defaults. +TELEGRAM_LINK_TTL_SECONDS=600 +TELEGRAM_BOT_USERNAME=alicebot +TELEGRAM_WEBHOOK_SECRET= +TELEGRAM_BOT_TOKEN= +# Browser security posture. +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +CORS_ALLOWED_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS +CORS_ALLOWED_HEADERS=Authorization,Content-Type,X-AliceBot-User-Id,X-Telegram-Bot-Api-Secret-Token +CORS_ALLOW_CREDENTIALS=false +CORS_PREFLIGHT_MAX_AGE_SECONDS=600 +SECURITY_HEADERS_ENABLED=true +SECURITY_HEADERS_HSTS_MAX_AGE_SECONDS=31536000 +SECURITY_HEADERS_HSTS_INCLUDE_SUBDOMAINS=true +# Proxy and ingress trust boundaries. +TRUST_PROXY_HEADERS=false +TRUSTED_PROXY_IPS= +# Entrypoint abuse-control backend. +ENTRYPOINT_RATE_LIMIT_BACKEND=redis diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d26de4e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + + - package-ecosystem: npm + directory: /packages/alice-core + schedule: + interval: weekly + + - package-ecosystem: npm + directory: /packages/alice-cli + schedule: + interval: weekly diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index b3d5fc1..83d2b97 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -7,11 +7,17 @@ on: permissions: contents: read - id-token: write + +concurrency: + group: publish-npm-${{ github.ref }} + cancel-in-progress: false jobs: publish-core: runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - name: Checkout uses: actions/checkout@v4 @@ -59,6 +65,9 @@ jobs: publish-cli: runs-on: ubuntu-latest needs: publish-core + permissions: + contents: read + id-token: write steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/security-scans.yml b/.github/workflows/security-scans.yml new file mode 100644 index 0000000..78e8cfa --- /dev/null +++ b/.github/workflows/security-scans.yml @@ -0,0 +1,57 @@ +name: Security Scans + +on: + pull_request: + push: + branches: + - main + schedule: + - cron: "23 3 * * 1" + +permissions: + contents: read + +jobs: + secrets: + name: Secrets Scan (Gitleaks) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + codeql: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: + - python + - javascript + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Analyze + uses: github/codeql-action/analyze@v3 diff --git a/apps/api/src/alicebot_api/config.py b/apps/api/src/alicebot_api/config.py index aaa5aa9..a6bf947 100644 --- a/apps/api/src/alicebot_api/config.py +++ b/apps/api/src/alicebot_api/config.py @@ -52,6 +52,35 @@ DEFAULT_HOSTED_ABUSE_BLOCK_THRESHOLD = 5 DEFAULT_HOSTED_RATE_LIMITS_ENABLED_BY_DEFAULT = True DEFAULT_HOSTED_ABUSE_CONTROLS_ENABLED_BY_DEFAULT = True +DEFAULT_MAGIC_LINK_START_RATE_LIMIT_WINDOW_SECONDS = 300 +DEFAULT_MAGIC_LINK_START_RATE_LIMIT_MAX_REQUESTS = 5 +DEFAULT_MAGIC_LINK_VERIFY_RATE_LIMIT_WINDOW_SECONDS = 300 +DEFAULT_MAGIC_LINK_VERIFY_RATE_LIMIT_MAX_REQUESTS = 10 +DEFAULT_TELEGRAM_WEBHOOK_RATE_LIMIT_WINDOW_SECONDS = 60 +DEFAULT_TELEGRAM_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120 +DEFAULT_CORS_ALLOWED_ORIGINS: tuple[str, ...] = () +DEFAULT_CORS_ALLOWED_METHODS: tuple[str, ...] = ( + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", +) +DEFAULT_CORS_ALLOWED_HEADERS: tuple[str, ...] = ( + "Authorization", + "Content-Type", + "X-AliceBot-User-Id", + "X-Telegram-Bot-Api-Secret-Token", +) +DEFAULT_CORS_ALLOW_CREDENTIALS = False +DEFAULT_CORS_PREFLIGHT_MAX_AGE_SECONDS = 600 +DEFAULT_SECURITY_HEADERS_ENABLED = True +DEFAULT_SECURITY_HEADERS_HSTS_MAX_AGE_SECONDS = 31_536_000 +DEFAULT_SECURITY_HEADERS_HSTS_INCLUDE_SUBDOMAINS = True +DEFAULT_TRUST_PROXY_HEADERS = False +DEFAULT_TRUSTED_PROXY_IPS: tuple[str, ...] = () +DEFAULT_ENTRYPOINT_RATE_LIMIT_BACKEND = "redis" Environment = Mapping[str, str] @@ -71,6 +100,31 @@ def _get_env_int(env: Environment, key: str, default: int) -> int: raise ValueError(f"{key} must be an integer") from exc +def _get_env_csv(env: Environment, key: str, default: tuple[str, ...]) -> tuple[str, ...]: + raw_value = env.get(key) + if raw_value is None: + return default + + return tuple(item.strip() for item in raw_value.split(",") if item.strip() != "") + + +def _normalize_csv_tokens( + values: tuple[str, ...], + *, + uppercase: bool = False, +) -> tuple[str, ...]: + normalized: list[str] = [] + for value in values: + token = value.strip() + if token == "": + continue + if uppercase: + token = token.upper() + if token not in normalized: + normalized.append(token) + return tuple(normalized) + + @dataclass(frozen=True) class Settings: app_env: str = DEFAULT_APP_ENV @@ -110,6 +164,31 @@ class Settings: hosted_abuse_block_threshold: int = DEFAULT_HOSTED_ABUSE_BLOCK_THRESHOLD hosted_rate_limits_enabled_by_default: bool = DEFAULT_HOSTED_RATE_LIMITS_ENABLED_BY_DEFAULT hosted_abuse_controls_enabled_by_default: bool = DEFAULT_HOSTED_ABUSE_CONTROLS_ENABLED_BY_DEFAULT + magic_link_start_rate_limit_window_seconds: int = ( + DEFAULT_MAGIC_LINK_START_RATE_LIMIT_WINDOW_SECONDS + ) + magic_link_start_rate_limit_max_requests: int = DEFAULT_MAGIC_LINK_START_RATE_LIMIT_MAX_REQUESTS + magic_link_verify_rate_limit_window_seconds: int = ( + DEFAULT_MAGIC_LINK_VERIFY_RATE_LIMIT_WINDOW_SECONDS + ) + magic_link_verify_rate_limit_max_requests: int = DEFAULT_MAGIC_LINK_VERIFY_RATE_LIMIT_MAX_REQUESTS + telegram_webhook_rate_limit_window_seconds: int = ( + DEFAULT_TELEGRAM_WEBHOOK_RATE_LIMIT_WINDOW_SECONDS + ) + telegram_webhook_rate_limit_max_requests: int = DEFAULT_TELEGRAM_WEBHOOK_RATE_LIMIT_MAX_REQUESTS + cors_allowed_origins: tuple[str, ...] = DEFAULT_CORS_ALLOWED_ORIGINS + cors_allowed_methods: tuple[str, ...] = DEFAULT_CORS_ALLOWED_METHODS + cors_allowed_headers: tuple[str, ...] = DEFAULT_CORS_ALLOWED_HEADERS + cors_allow_credentials: bool = DEFAULT_CORS_ALLOW_CREDENTIALS + cors_preflight_max_age_seconds: int = DEFAULT_CORS_PREFLIGHT_MAX_AGE_SECONDS + security_headers_enabled: bool = DEFAULT_SECURITY_HEADERS_ENABLED + security_headers_hsts_max_age_seconds: int = DEFAULT_SECURITY_HEADERS_HSTS_MAX_AGE_SECONDS + security_headers_hsts_include_subdomains: bool = ( + DEFAULT_SECURITY_HEADERS_HSTS_INCLUDE_SUBDOMAINS + ) + trust_proxy_headers: bool = DEFAULT_TRUST_PROXY_HEADERS + trusted_proxy_ips: tuple[str, ...] = DEFAULT_TRUSTED_PROXY_IPS + entrypoint_rate_limit_backend: str = DEFAULT_ENTRYPOINT_RATE_LIMIT_BACKEND @classmethod def from_env(cls, env: Environment | None = None) -> "Settings": @@ -250,6 +329,88 @@ def from_env(cls, env: Environment | None = None) -> "Settings": "true" if cls.hosted_abuse_controls_enabled_by_default else "false", ).strip().lower() in {"1", "true", "yes", "on"}, + magic_link_start_rate_limit_window_seconds=_get_env_int( + current_env, + "MAGIC_LINK_START_RATE_LIMIT_WINDOW_SECONDS", + cls.magic_link_start_rate_limit_window_seconds, + ), + magic_link_start_rate_limit_max_requests=_get_env_int( + current_env, + "MAGIC_LINK_START_RATE_LIMIT_MAX_REQUESTS", + cls.magic_link_start_rate_limit_max_requests, + ), + magic_link_verify_rate_limit_window_seconds=_get_env_int( + current_env, + "MAGIC_LINK_VERIFY_RATE_LIMIT_WINDOW_SECONDS", + cls.magic_link_verify_rate_limit_window_seconds, + ), + magic_link_verify_rate_limit_max_requests=_get_env_int( + current_env, + "MAGIC_LINK_VERIFY_RATE_LIMIT_MAX_REQUESTS", + cls.magic_link_verify_rate_limit_max_requests, + ), + telegram_webhook_rate_limit_window_seconds=_get_env_int( + current_env, + "TELEGRAM_WEBHOOK_RATE_LIMIT_WINDOW_SECONDS", + cls.telegram_webhook_rate_limit_window_seconds, + ), + telegram_webhook_rate_limit_max_requests=_get_env_int( + current_env, + "TELEGRAM_WEBHOOK_RATE_LIMIT_MAX_REQUESTS", + cls.telegram_webhook_rate_limit_max_requests, + ), + cors_allowed_origins=_normalize_csv_tokens( + _get_env_csv(current_env, "CORS_ALLOWED_ORIGINS", cls.cors_allowed_origins), + ), + cors_allowed_methods=_normalize_csv_tokens( + _get_env_csv(current_env, "CORS_ALLOWED_METHODS", cls.cors_allowed_methods), + uppercase=True, + ), + cors_allowed_headers=_normalize_csv_tokens( + _get_env_csv(current_env, "CORS_ALLOWED_HEADERS", cls.cors_allowed_headers), + ), + cors_allow_credentials=_get_env_value( + current_env, + "CORS_ALLOW_CREDENTIALS", + "true" if cls.cors_allow_credentials else "false", + ).strip().lower() + in {"1", "true", "yes", "on"}, + cors_preflight_max_age_seconds=_get_env_int( + current_env, + "CORS_PREFLIGHT_MAX_AGE_SECONDS", + cls.cors_preflight_max_age_seconds, + ), + security_headers_enabled=_get_env_value( + current_env, + "SECURITY_HEADERS_ENABLED", + "true" if cls.security_headers_enabled else "false", + ).strip().lower() + in {"1", "true", "yes", "on"}, + security_headers_hsts_max_age_seconds=_get_env_int( + current_env, + "SECURITY_HEADERS_HSTS_MAX_AGE_SECONDS", + cls.security_headers_hsts_max_age_seconds, + ), + security_headers_hsts_include_subdomains=_get_env_value( + current_env, + "SECURITY_HEADERS_HSTS_INCLUDE_SUBDOMAINS", + "true" if cls.security_headers_hsts_include_subdomains else "false", + ).strip().lower() + in {"1", "true", "yes", "on"}, + trust_proxy_headers=_get_env_value( + current_env, + "TRUST_PROXY_HEADERS", + "true" if cls.trust_proxy_headers else "false", + ).strip().lower() + in {"1", "true", "yes", "on"}, + trusted_proxy_ips=_normalize_csv_tokens( + _get_env_csv(current_env, "TRUSTED_PROXY_IPS", cls.trusted_proxy_ips), + ), + entrypoint_rate_limit_backend=_get_env_value( + current_env, + "ENTRYPOINT_RATE_LIMIT_BACKEND", + cls.entrypoint_rate_limit_backend, + ).strip().lower(), ) return _validate_settings(settings) @@ -287,8 +448,34 @@ def _validate_settings(settings: Settings) -> Settings: raise ValueError("HOSTED_ABUSE_WINDOW_SECONDS must be a positive integer") if settings.hosted_abuse_block_threshold <= 0: raise ValueError("HOSTED_ABUSE_BLOCK_THRESHOLD must be a positive integer") + if settings.magic_link_start_rate_limit_window_seconds <= 0: + raise ValueError("MAGIC_LINK_START_RATE_LIMIT_WINDOW_SECONDS must be a positive integer") + if settings.magic_link_start_rate_limit_max_requests <= 0: + raise ValueError("MAGIC_LINK_START_RATE_LIMIT_MAX_REQUESTS must be a positive integer") + if settings.magic_link_verify_rate_limit_window_seconds <= 0: + raise ValueError("MAGIC_LINK_VERIFY_RATE_LIMIT_WINDOW_SECONDS must be a positive integer") + if settings.magic_link_verify_rate_limit_max_requests <= 0: + raise ValueError("MAGIC_LINK_VERIFY_RATE_LIMIT_MAX_REQUESTS must be a positive integer") + if settings.telegram_webhook_rate_limit_window_seconds <= 0: + raise ValueError("TELEGRAM_WEBHOOK_RATE_LIMIT_WINDOW_SECONDS must be a positive integer") + if settings.telegram_webhook_rate_limit_max_requests <= 0: + raise ValueError("TELEGRAM_WEBHOOK_RATE_LIMIT_MAX_REQUESTS must be a positive integer") + if settings.cors_preflight_max_age_seconds <= 0: + raise ValueError("CORS_PREFLIGHT_MAX_AGE_SECONDS must be a positive integer") + if len(settings.cors_allowed_methods) == 0: + raise ValueError("CORS_ALLOWED_METHODS must include at least one method") + if settings.security_headers_hsts_max_age_seconds <= 0: + raise ValueError("SECURITY_HEADERS_HSTS_MAX_AGE_SECONDS must be a positive integer") + if settings.entrypoint_rate_limit_backend not in {"redis", "memory"}: + raise ValueError("ENTRYPOINT_RATE_LIMIT_BACKEND must be either 'redis' or 'memory'") + if settings.trust_proxy_headers and len(settings.trusted_proxy_ips) == 0: + raise ValueError("TRUSTED_PROXY_IPS must include at least one IP when TRUST_PROXY_HEADERS is enabled") if settings.app_env not in {"development", "test"}: + if "*" in settings.cors_allowed_origins: + raise ValueError( + "CORS_ALLOWED_ORIGINS cannot include wildcard outside development/test environments" + ) if settings.auth_user_id == "": raise ValueError( "ALICEBOT_AUTH_USER_ID must be configured outside development/test environments" @@ -303,6 +490,10 @@ def _validate_settings(settings: Settings) -> Settings: raise ValueError("S3_ACCESS_KEY must be overridden outside development/test environments") if settings.s3_secret_key == DEFAULT_S3_SECRET_KEY: raise ValueError("S3_SECRET_KEY must be overridden outside development/test environments") + if settings.telegram_webhook_secret == "": + raise ValueError( + "TELEGRAM_WEBHOOK_SECRET must be configured outside development/test environments" + ) return settings diff --git a/apps/api/src/alicebot_api/main.py b/apps/api/src/alicebot_api/main.py index 0c79cad..460cc80 100644 --- a/apps/api/src/alicebot_api/main.py +++ b/apps/api/src/alicebot_api/main.py @@ -2,18 +2,28 @@ from collections import defaultdict, deque from datetime import datetime +import hmac +import hashlib import json import threading import time from typing import Annotated, Awaitable, Callable, Literal, TypedDict from uuid import UUID -from fastapi import FastAPI, Query, Request +from fastapi import FastAPI, Query, Request, Response from fastapi.encoders import jsonable_encoder from pydantic import BaseModel, ConfigDict, Field, model_validator from fastapi.responses import JSONResponse import psycopg from psycopg.rows import dict_row from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +try: + import redis + from redis.exceptions import RedisError +except Exception: # pragma: no cover - optional dependency for local-only test environments + redis = None + + class RedisError(Exception): + """Fallback Redis error used when redis package is unavailable.""" from alicebot_api.compiler import compile_and_persist_trace, compile_resumption_brief from alicebot_api.config import Settings, get_settings @@ -615,6 +625,85 @@ def reset(self) -> None: response_rate_limiter = ResponseRateLimiter() +class EntrypointRateLimiterUnavailableError(RuntimeError): + """Raised when the configured entrypoint rate limiter backend is unavailable.""" + + +class EntrypointRateLimiter: + def __init__(self) -> None: + self._memory_fallback = ResponseRateLimiter() + self._redis_clients_by_url: dict[str, object] = {} + self._lock = threading.Lock() + + def _get_redis_client(self, redis_url: str): + with self._lock: + cached_client = self._redis_clients_by_url.get(redis_url) + if cached_client is not None: + return cached_client + + if redis is None: + raise EntrypointRateLimiterUnavailableError( + "redis backend is unavailable; install redis client dependency" + ) + + redis_client = redis.Redis.from_url( + redis_url, + decode_responses=True, + socket_connect_timeout=1, + socket_timeout=1, + ) + self._redis_clients_by_url[redis_url] = redis_client + return redis_client + + def allow( + self, + *, + settings: Settings, + key: str, + max_requests: int, + window_seconds: int, + ) -> tuple[bool, int]: + if settings.entrypoint_rate_limit_backend == "memory": + return self._memory_fallback.allow( + key=key, + max_requests=max_requests, + window_seconds=window_seconds, + ) + + try: + redis_client = self._get_redis_client(settings.redis_url) + redis_key = f"entrypoint_rate:{key}" + count = int(redis_client.incr(redis_key)) + ttl = int(redis_client.ttl(redis_key)) + + if count == 1 or ttl <= 0: + redis_client.expire(redis_key, window_seconds) + ttl = window_seconds + + if count > max_requests: + return False, max(1, ttl if ttl > 0 else window_seconds) + return True, 0 + except (RedisError, EntrypointRateLimiterUnavailableError) as exc: + # Local and test workflows can continue deterministically with in-memory fallback. + if settings.app_env in {"development", "test"}: + return self._memory_fallback.allow( + key=key, + max_requests=max_requests, + window_seconds=window_seconds, + ) + raise EntrypointRateLimiterUnavailableError( + "redis-backed entrypoint rate limiter is unavailable" + ) from exc + + def reset(self) -> None: + self._memory_fallback.reset() + with self._lock: + self._redis_clients_by_url.clear() + + +entrypoint_rate_limiter = EntrypointRateLimiter() + + def _resolve_authenticated_user_id(settings: Settings, request: Request) -> UUID | None: if settings.auth_user_id != "": return UUID(settings.auth_user_id) @@ -1331,11 +1420,191 @@ def _enforce_response_rate_limit(settings: Settings, user_id: UUID) -> JSONRespo ) +def _request_client_identifier(request: Request, settings: Settings) -> str: + peer_host = "" + if request.client is not None: + peer_host = (request.client.host or "").strip() + + if ( + settings.trust_proxy_headers + and peer_host != "" + and peer_host in settings.trusted_proxy_ips + ): + forwarded_for = request.headers.get("x-forwarded-for", "").strip() + if forwarded_for != "": + first_hop = forwarded_for.split(",", maxsplit=1)[0].strip() + if first_hop != "": + return first_hop + + if peer_host == "": + return "unknown" + return peer_host + + +def _entrypoint_rate_limit_error( + *, + detail_code: str, + message: str, + max_requests: int, + window_seconds: int, + retry_after_seconds: int, +) -> JSONResponse: + return JSONResponse( + status_code=429, + headers={"Retry-After": str(retry_after_seconds)}, + content={ + "detail": { + "code": detail_code, + "message": message, + "retry_after_seconds": retry_after_seconds, + "window_seconds": window_seconds, + "max_requests": max_requests, + } + }, + ) + + +def _enforce_entrypoint_rate_limit( + *, + settings: Settings, + key: str, + max_requests: int, + window_seconds: int, + detail_code: str, + message: str, +) -> JSONResponse | None: + try: + allowed, retry_after_seconds = entrypoint_rate_limiter.allow( + settings=settings, + key=key, + max_requests=max_requests, + window_seconds=window_seconds, + ) + except EntrypointRateLimiterUnavailableError: + return JSONResponse( + status_code=503, + content={ + "detail": { + "code": "entrypoint_rate_limiter_unavailable", + "message": "entrypoint rate limiter backend is unavailable", + } + }, + ) + if allowed: + return None + return _entrypoint_rate_limit_error( + detail_code=detail_code, + message=message, + max_requests=max_requests, + window_seconds=window_seconds, + retry_after_seconds=retry_after_seconds, + ) + + +def _append_vary_header(response: Response, value: str) -> None: + existing = response.headers.get("Vary", "") + values = [item.strip() for item in existing.split(",") if item.strip() != ""] + if value not in values: + values.append(value) + response.headers["Vary"] = ", ".join(values) + + +def _cors_origin_allowed(origin: str, allowed_origins: tuple[str, ...]) -> bool: + if len(allowed_origins) == 0: + return False + if "*" in allowed_origins: + return True + return origin in allowed_origins + + +def _resolve_cors_allow_origin_value(settings: Settings, origin: str) -> str: + if "*" in settings.cors_allowed_origins and not settings.cors_allow_credentials: + return "*" + return origin + + +def _apply_cors_headers( + *, + response: Response, + settings: Settings, + origin: str, + preflight: bool, +) -> None: + allow_origin = _resolve_cors_allow_origin_value(settings, origin) + response.headers["Access-Control-Allow-Origin"] = allow_origin + if allow_origin != "*": + _append_vary_header(response, "Origin") + if settings.cors_allow_credentials: + response.headers["Access-Control-Allow-Credentials"] = "true" + + if not preflight: + return + + response.headers["Access-Control-Allow-Methods"] = ", ".join(settings.cors_allowed_methods) + response.headers["Access-Control-Allow-Headers"] = ", ".join(settings.cors_allowed_headers) + response.headers["Access-Control-Max-Age"] = str(settings.cors_preflight_max_age_seconds) + _append_vary_header(response, "Access-Control-Request-Method") + _append_vary_header(response, "Access-Control-Request-Headers") + + +def _apply_security_headers(*, response: Response, settings: Settings, request: Request) -> None: + if not settings.security_headers_enabled: + return + + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("Referrer-Policy", "no-referrer") + response.headers.setdefault( + "Permissions-Policy", + ( + "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), " + "microphone=(), payment=(), usb=()" + ), + ) + + if request.url.scheme != "https" or settings.app_env in {"development", "test"}: + return + + hsts_value = f"max-age={settings.security_headers_hsts_max_age_seconds}" + if settings.security_headers_hsts_include_subdomains: + hsts_value += "; includeSubDomains" + response.headers.setdefault("Strict-Transport-Security", hsts_value) + + +@app.middleware("http") +async def apply_http_security_posture( + request: Request, + call_next: Callable[[Request], Awaitable[Response]], +) -> Response: + settings = get_settings() + origin = request.headers.get("origin", "").strip() + is_preflight = ( + request.method.upper() == "OPTIONS" + and request.headers.get("access-control-request-method", "").strip() != "" + ) + + if is_preflight: + if origin == "" or not _cors_origin_allowed(origin, settings.cors_allowed_origins): + response = JSONResponse(status_code=403, content={"detail": "CORS origin is not allowed"}) + _apply_security_headers(response=response, settings=settings, request=request) + return response + response = Response(status_code=204) + _apply_cors_headers(response=response, settings=settings, origin=origin, preflight=True) + _apply_security_headers(response=response, settings=settings, request=request) + return response + + response = await call_next(request) + if origin != "" and _cors_origin_allowed(origin, settings.cors_allowed_origins): + _apply_cors_headers(response=response, settings=settings, origin=origin, preflight=False) + _apply_security_headers(response=response, settings=settings, request=request) + return response + + @app.middleware("http") async def enforce_authenticated_user_identity( request: Request, - call_next: Callable[[Request], Awaitable[JSONResponse]], -): + call_next: Callable[[Request], Awaitable[Response]], +) -> Response: if not request.url.path.startswith("/v0/"): return await call_next(request) @@ -4879,8 +5148,22 @@ def get_entity(entity_id: UUID, user_id: UUID) -> JSONResponse: @app.post("/v1/auth/magic-link/start") -def start_v1_magic_link(request: MagicLinkStartRequest) -> JSONResponse: - settings = get_settings() +def start_v1_magic_link(http_request: Request, request: MagicLinkStartRequest) -> JSONResponse: + settings = get_settings() + email_fingerprint = hashlib.sha256(request.email.strip().lower().encode("utf-8")).hexdigest()[:20] + rate_limit_error = _enforce_entrypoint_rate_limit( + settings=settings, + key=( + "auth_magic_link_start:" + f"{_request_client_identifier(http_request, settings)}:{email_fingerprint}" + ), + max_requests=settings.magic_link_start_rate_limit_max_requests, + window_seconds=settings.magic_link_start_rate_limit_window_seconds, + detail_code="magic_link_start_rate_limit_exceeded", + message="magic-link start rate limit exceeded", + ) + if rate_limit_error is not None: + return rate_limit_error try: with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: @@ -4893,19 +5176,42 @@ def start_v1_magic_link(request: MagicLinkStartRequest) -> JSONResponse: except ValueError as exc: return JSONResponse(status_code=400, content={"detail": str(exc)}) + challenge_payload = serialize_magic_link_challenge(challenge) + delivery_payload = { + "kind": "simulated_magic_link", + "posture": "builder_visible_only", + } + if settings.app_env not in {"development", "test"}: + challenge_payload.pop("challenge_token", None) + delivery_payload = { + "kind": "magic_link", + "posture": "out_of_band_delivery_required", + } + payload = { - "challenge": serialize_magic_link_challenge(challenge), - "delivery": { - "kind": "simulated_magic_link", - "posture": "builder_visible_only", - }, + "challenge": challenge_payload, + "delivery": delivery_payload, } return JSONResponse(status_code=200, content=jsonable_encoder(payload)) @app.post("/v1/auth/magic-link/verify") -def verify_v1_magic_link(request: MagicLinkVerifyRequest) -> JSONResponse: - settings = get_settings() +def verify_v1_magic_link(http_request: Request, request: MagicLinkVerifyRequest) -> JSONResponse: + settings = get_settings() + challenge_fingerprint = hashlib.sha256(request.challenge_token.strip().encode("utf-8")).hexdigest()[:20] + rate_limit_error = _enforce_entrypoint_rate_limit( + settings=settings, + key=( + "auth_magic_link_verify:" + f"{_request_client_identifier(http_request, settings)}:{challenge_fingerprint}" + ), + max_requests=settings.magic_link_verify_rate_limit_max_requests, + window_seconds=settings.magic_link_verify_rate_limit_window_seconds, + detail_code="magic_link_verify_rate_limit_exceeded", + message="magic-link verify rate limit exceeded", + ) + if rate_limit_error is not None: + return rate_limit_error try: with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: @@ -5803,9 +6109,26 @@ def get_v1_telegram_status( @app.post("/v1/channels/telegram/webhook") async def ingest_v1_telegram_webhook(request: Request) -> JSONResponse: settings = get_settings() + if settings.app_env not in {"development", "test"} and settings.telegram_webhook_secret == "": + return JSONResponse( + status_code=503, + content={"detail": "telegram webhook ingress is not configured"}, + ) + + rate_limit_error = _enforce_entrypoint_rate_limit( + settings=settings, + key=f"telegram_webhook:{_request_client_identifier(request, settings)}", + max_requests=settings.telegram_webhook_rate_limit_max_requests, + window_seconds=settings.telegram_webhook_rate_limit_window_seconds, + detail_code="telegram_webhook_rate_limit_exceeded", + message="telegram webhook rate limit exceeded", + ) + if rate_limit_error is not None: + return rate_limit_error + if settings.telegram_webhook_secret != "": header_secret = request.headers.get("x-telegram-bot-api-secret-token", "").strip() - if header_secret != settings.telegram_webhook_secret: + if not hmac.compare_digest(header_secret, settings.telegram_webhook_secret): return JSONResponse(status_code=401, content={"detail": "telegram webhook secret is invalid"}) try: diff --git a/pyproject.toml b/pyproject.toml index 0b7a6bf..aa83986 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "alembic>=1.14,<2.0", "fastapi>=0.115,<1.0", "psycopg[binary]>=3.2,<4.0", + "redis>=5.0,<6.0", "sqlalchemy>=2.0,<3.0", "uvicorn>=0.34,<1.0", ] diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index f413549..3cf7ef9 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -10,6 +10,7 @@ from psycopg import sql import pytest +import apps.api.src.alicebot_api.main as main_module from alicebot_api.migrations import make_alembic_config @@ -53,3 +54,12 @@ def migrated_database_urls(database_urls: dict[str, str]) -> Iterator[dict[str, config = make_alembic_config(database_urls["admin"]) command.upgrade(config, "head") yield database_urls + + +@pytest.fixture(autouse=True) +def reset_response_rate_limiter_between_tests() -> Iterator[None]: + main_module.response_rate_limiter.reset() + main_module.entrypoint_rate_limiter.reset() + yield + main_module.response_rate_limiter.reset() + main_module.entrypoint_rate_limiter.reset() diff --git a/tests/integration/test_http_security_posture.py b/tests/integration/test_http_security_posture.py new file mode 100644 index 0000000..c4474ee --- /dev/null +++ b/tests/integration/test_http_security_posture.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import json + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings + + +def invoke_request( + method: str, + path: str, + *, + scheme: str = "http", + headers: dict[str, str] | None = None, +) -> tuple[int, dict[str, str], bytes]: + messages: list[dict[str, object]] = [] + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + request_received = True + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + request_headers = [(b"content-type", b"application/json")] + for key, value in (headers or {}).items(): + request_headers.append((key.lower().encode("utf-8"), value.encode("utf-8"))) + + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": scheme, + "path": path, + "raw_path": path.encode("utf-8"), + "query_string": b"", + "headers": request_headers, + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + response_headers = { + key.decode("utf-8").lower(): value.decode("utf-8") + for key, value in start_message["headers"] + } + return start_message["status"], response_headers, body + + +def test_security_headers_are_applied_to_api_responses(monkeypatch) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + app_env="test", + database_url="postgresql://db", + redis_url="redis://localhost:6379/0", + s3_endpoint_url="http://localhost:9000", + ), + ) + monkeypatch.setattr(main_module, "ping_database", lambda *_args, **_kwargs: True) + + status_code, headers, body = invoke_request("GET", "/healthz") + + assert status_code == 200 + assert json.loads(body)["status"] == "ok" + assert headers["x-content-type-options"] == "nosniff" + assert headers["x-frame-options"] == "DENY" + assert headers["referrer-policy"] == "no-referrer" + assert "permissions-policy" in headers + assert "strict-transport-security" not in headers + + +def test_security_headers_include_hsts_for_https_outside_dev(monkeypatch) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + app_env="staging", + database_url="postgresql://db", + security_headers_hsts_max_age_seconds=86_400, + security_headers_hsts_include_subdomains=True, + ), + ) + monkeypatch.setattr(main_module, "ping_database", lambda *_args, **_kwargs: True) + + status_code, headers, _body = invoke_request("GET", "/healthz", scheme="https") + + assert status_code == 200 + assert headers["strict-transport-security"] == "max-age=86400; includeSubDomains" + + +def test_cors_preflight_allows_configured_origin(monkeypatch) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + app_env="test", + database_url="postgresql://db", + cors_allowed_origins=("https://app.example.com",), + cors_allowed_methods=("GET", "POST", "OPTIONS"), + cors_allowed_headers=("Authorization", "Content-Type"), + cors_allow_credentials=True, + cors_preflight_max_age_seconds=900, + ), + ) + + status_code, headers, body = invoke_request( + "OPTIONS", + "/healthz", + headers={ + "origin": "https://app.example.com", + "access-control-request-method": "GET", + "access-control-request-headers": "authorization,content-type", + }, + ) + + assert status_code == 204 + assert body == b"" + assert headers["access-control-allow-origin"] == "https://app.example.com" + assert headers["access-control-allow-methods"] == "GET, POST, OPTIONS" + assert headers["access-control-allow-headers"] == "Authorization, Content-Type" + assert headers["access-control-allow-credentials"] == "true" + assert headers["access-control-max-age"] == "900" + + +def test_cors_preflight_rejects_disallowed_origin(monkeypatch) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + app_env="test", + database_url="postgresql://db", + cors_allowed_origins=("https://app.example.com",), + ), + ) + + status_code, headers, body = invoke_request( + "OPTIONS", + "/healthz", + headers={ + "origin": "https://evil.example.com", + "access-control-request-method": "GET", + }, + ) + + assert status_code == 403 + assert json.loads(body) == {"detail": "CORS origin is not allowed"} + assert headers["x-content-type-options"] == "nosniff" diff --git a/tests/integration/test_phase10_identity_workspace_bootstrap_api.py b/tests/integration/test_phase10_identity_workspace_bootstrap_api.py index 92a3f8b..bfe1d3c 100644 --- a/tests/integration/test_phase10_identity_workspace_bootstrap_api.py +++ b/tests/integration/test_phase10_identity_workspace_bootstrap_api.py @@ -422,3 +422,97 @@ def test_phase10_device_link_invalid_and_expired_paths(migrated_database_urls, m ) assert expired_confirm_status == 401 assert expired_confirm_payload == {"detail": "device-link token has expired"} + + +def test_phase10_magic_link_start_hides_challenge_token_outside_dev( + migrated_database_urls, + monkeypatch, +) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + app_env="staging", + database_url=migrated_database_urls["app"], + magic_link_ttl_seconds=600, + auth_session_ttl_seconds=3600, + device_link_ttl_seconds=600, + entrypoint_rate_limit_backend="memory", + ), + ) + + start_status, start_payload = invoke_request( + "POST", + "/v1/auth/magic-link/start", + payload={"email": "staging-builder@example.com"}, + ) + + assert start_status == 200 + assert "challenge_token" not in start_payload["challenge"] + assert start_payload["delivery"] == { + "kind": "magic_link", + "posture": "out_of_band_delivery_required", + } + + +def test_phase10_magic_link_start_and_verify_rate_limits( + migrated_database_urls, + monkeypatch, +) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + magic_link_ttl_seconds=600, + auth_session_ttl_seconds=3600, + device_link_ttl_seconds=600, + magic_link_start_rate_limit_max_requests=1, + magic_link_start_rate_limit_window_seconds=60, + magic_link_verify_rate_limit_max_requests=1, + magic_link_verify_rate_limit_window_seconds=60, + ), + ) + + start_status, start_payload = invoke_request( + "POST", + "/v1/auth/magic-link/start", + payload={"email": "rate-limit@example.com"}, + ) + assert start_status == 200 + + start_limited_status, start_limited_payload = invoke_request( + "POST", + "/v1/auth/magic-link/start", + payload={"email": "rate-limit@example.com"}, + ) + assert start_limited_status == 429 + assert start_limited_payload["detail"]["code"] == "magic_link_start_rate_limit_exceeded" + assert start_limited_payload["detail"]["max_requests"] == 1 + assert start_limited_payload["detail"]["window_seconds"] == 60 + + challenge_token = start_payload["challenge"]["challenge_token"] + verify_status, _verify_payload = invoke_request( + "POST", + "/v1/auth/magic-link/verify", + payload={ + "challenge_token": challenge_token, + "device_label": "Rate Limited Device", + "device_key": "rate-limited-device", + }, + ) + assert verify_status == 200 + + verify_limited_status, verify_limited_payload = invoke_request( + "POST", + "/v1/auth/magic-link/verify", + payload={ + "challenge_token": challenge_token, + "device_label": "Rate Limited Device", + "device_key": "rate-limited-device", + }, + ) + assert verify_limited_status == 429 + assert verify_limited_payload["detail"]["code"] == "magic_link_verify_rate_limit_exceeded" + assert verify_limited_payload["detail"]["max_requests"] == 1 + assert verify_limited_payload["detail"]["window_seconds"] == 60 diff --git a/tests/integration/test_phase10_telegram_transport_api.py b/tests/integration/test_phase10_telegram_transport_api.py index 2dfd7dd..d4a5a60 100644 --- a/tests/integration/test_phase10_telegram_transport_api.py +++ b/tests/integration/test_phase10_telegram_transport_api.py @@ -586,3 +586,81 @@ def test_phase10_telegram_rejects_cross_workspace_identity_conflict( ) assert second_status_code == 200 assert second_status_payload["linked"] is False + + +def test_phase10_telegram_webhook_requires_secret_outside_dev( + migrated_database_urls, + monkeypatch, +) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + app_env="staging", + database_url=migrated_database_urls["app"], + telegram_webhook_secret="", + telegram_bot_token="", + telegram_bot_username="alicebot", + ), + ) + + webhook_status, webhook_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={"update_id": 1, "message": {"message_id": 1}}, + ) + assert webhook_status == 503 + assert webhook_payload == {"detail": "telegram webhook ingress is not configured"} + + +def test_phase10_telegram_webhook_rate_limit_enforced( + migrated_database_urls, + monkeypatch, +) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + telegram_webhook_secret="", + telegram_bot_token="", + telegram_bot_username="alicebot", + telegram_webhook_rate_limit_max_requests=1, + telegram_webhook_rate_limit_window_seconds=60, + ), + ) + + first_webhook_status, _first_webhook_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={ + "update_id": 7101, + "message": { + "message_id": 4001, + "date": 1710007000, + "chat": {"id": 12345, "type": "private"}, + "from": {"id": 12345, "username": "ratelimited"}, + "text": "hello", + }, + }, + ) + assert first_webhook_status == 200 + + second_webhook_status, second_webhook_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={ + "update_id": 7102, + "message": { + "message_id": 4002, + "date": 1710007005, + "chat": {"id": 12345, "type": "private"}, + "from": {"id": 12345, "username": "ratelimited"}, + "text": "hello again", + }, + }, + ) + assert second_webhook_status == 429 + assert second_webhook_payload["detail"]["code"] == "telegram_webhook_rate_limit_exceeded" + assert second_webhook_payload["detail"]["max_requests"] == 1 + assert second_webhook_payload["detail"]["window_seconds"] == 60 diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 82a7b61..34b1697 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -41,6 +41,23 @@ def test_settings_defaults(monkeypatch): "HOSTED_ABUSE_BLOCK_THRESHOLD", "HOSTED_RATE_LIMITS_ENABLED_BY_DEFAULT", "HOSTED_ABUSE_CONTROLS_ENABLED_BY_DEFAULT", + "MAGIC_LINK_START_RATE_LIMIT_WINDOW_SECONDS", + "MAGIC_LINK_START_RATE_LIMIT_MAX_REQUESTS", + "MAGIC_LINK_VERIFY_RATE_LIMIT_WINDOW_SECONDS", + "MAGIC_LINK_VERIFY_RATE_LIMIT_MAX_REQUESTS", + "TELEGRAM_WEBHOOK_RATE_LIMIT_WINDOW_SECONDS", + "TELEGRAM_WEBHOOK_RATE_LIMIT_MAX_REQUESTS", + "CORS_ALLOWED_ORIGINS", + "CORS_ALLOWED_METHODS", + "CORS_ALLOWED_HEADERS", + "CORS_ALLOW_CREDENTIALS", + "CORS_PREFLIGHT_MAX_AGE_SECONDS", + "SECURITY_HEADERS_ENABLED", + "SECURITY_HEADERS_HSTS_MAX_AGE_SECONDS", + "SECURITY_HEADERS_HSTS_INCLUDE_SUBDOMAINS", + "TRUST_PROXY_HEADERS", + "TRUSTED_PROXY_IPS", + "ENTRYPOINT_RATE_LIMIT_BACKEND", ): monkeypatch.delenv(key, raising=False) @@ -73,6 +90,28 @@ def test_settings_defaults(monkeypatch): assert settings.hosted_abuse_block_threshold == 5 assert settings.hosted_rate_limits_enabled_by_default is True assert settings.hosted_abuse_controls_enabled_by_default is True + assert settings.magic_link_start_rate_limit_window_seconds == 300 + assert settings.magic_link_start_rate_limit_max_requests == 5 + assert settings.magic_link_verify_rate_limit_window_seconds == 300 + assert settings.magic_link_verify_rate_limit_max_requests == 10 + assert settings.telegram_webhook_rate_limit_window_seconds == 60 + assert settings.telegram_webhook_rate_limit_max_requests == 120 + assert settings.cors_allowed_origins == () + assert settings.cors_allowed_methods == ("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + assert settings.cors_allowed_headers == ( + "Authorization", + "Content-Type", + "X-AliceBot-User-Id", + "X-Telegram-Bot-Api-Secret-Token", + ) + assert settings.cors_allow_credentials is False + assert settings.cors_preflight_max_age_seconds == 600 + assert settings.security_headers_enabled is True + assert settings.security_headers_hsts_max_age_seconds == 31_536_000 + assert settings.security_headers_hsts_include_subdomains is True + assert settings.trust_proxy_headers is False + assert settings.trusted_proxy_ips == () + assert settings.entrypoint_rate_limit_backend == "redis" def test_settings_honor_environment_overrides(monkeypatch): @@ -101,6 +140,26 @@ def test_settings_honor_environment_overrides(monkeypatch): monkeypatch.setenv("HOSTED_ABUSE_BLOCK_THRESHOLD", "6") monkeypatch.setenv("HOSTED_RATE_LIMITS_ENABLED_BY_DEFAULT", "false") monkeypatch.setenv("HOSTED_ABUSE_CONTROLS_ENABLED_BY_DEFAULT", "false") + monkeypatch.setenv("MAGIC_LINK_START_RATE_LIMIT_WINDOW_SECONDS", "360") + monkeypatch.setenv("MAGIC_LINK_START_RATE_LIMIT_MAX_REQUESTS", "8") + monkeypatch.setenv("MAGIC_LINK_VERIFY_RATE_LIMIT_WINDOW_SECONDS", "420") + monkeypatch.setenv("MAGIC_LINK_VERIFY_RATE_LIMIT_MAX_REQUESTS", "12") + monkeypatch.setenv("TELEGRAM_WEBHOOK_RATE_LIMIT_WINDOW_SECONDS", "90") + monkeypatch.setenv("TELEGRAM_WEBHOOK_RATE_LIMIT_MAX_REQUESTS", "180") + monkeypatch.setenv( + "CORS_ALLOWED_ORIGINS", + "https://app.example.com, https://staging.example.com", + ) + monkeypatch.setenv("CORS_ALLOWED_METHODS", "GET,POST,OPTIONS") + monkeypatch.setenv("CORS_ALLOWED_HEADERS", "Authorization,Content-Type") + monkeypatch.setenv("CORS_ALLOW_CREDENTIALS", "true") + monkeypatch.setenv("CORS_PREFLIGHT_MAX_AGE_SECONDS", "900") + monkeypatch.setenv("SECURITY_HEADERS_ENABLED", "false") + monkeypatch.setenv("SECURITY_HEADERS_HSTS_MAX_AGE_SECONDS", "86400") + monkeypatch.setenv("SECURITY_HEADERS_HSTS_INCLUDE_SUBDOMAINS", "false") + monkeypatch.setenv("TRUST_PROXY_HEADERS", "true") + monkeypatch.setenv("TRUSTED_PROXY_IPS", "127.0.0.1,10.0.0.2") + monkeypatch.setenv("ENTRYPOINT_RATE_LIMIT_BACKEND", "memory") settings = Settings.from_env() @@ -129,6 +188,23 @@ def test_settings_honor_environment_overrides(monkeypatch): assert settings.hosted_abuse_block_threshold == 6 assert settings.hosted_rate_limits_enabled_by_default is False assert settings.hosted_abuse_controls_enabled_by_default is False + assert settings.magic_link_start_rate_limit_window_seconds == 360 + assert settings.magic_link_start_rate_limit_max_requests == 8 + assert settings.magic_link_verify_rate_limit_window_seconds == 420 + assert settings.magic_link_verify_rate_limit_max_requests == 12 + assert settings.telegram_webhook_rate_limit_window_seconds == 90 + assert settings.telegram_webhook_rate_limit_max_requests == 180 + assert settings.cors_allowed_origins == ("https://app.example.com", "https://staging.example.com") + assert settings.cors_allowed_methods == ("GET", "POST", "OPTIONS") + assert settings.cors_allowed_headers == ("Authorization", "Content-Type") + assert settings.cors_allow_credentials is True + assert settings.cors_preflight_max_age_seconds == 900 + assert settings.security_headers_enabled is False + assert settings.security_headers_hsts_max_age_seconds == 86400 + assert settings.security_headers_hsts_include_subdomains is False + assert settings.trust_proxy_headers is True + assert settings.trusted_proxy_ips == ("127.0.0.1", "10.0.0.2") + assert settings.entrypoint_rate_limit_backend == "memory" def test_settings_can_be_loaded_from_an_explicit_environment_mapping() -> None: @@ -157,6 +233,23 @@ def test_settings_can_be_loaded_from_an_explicit_environment_mapping() -> None: "HOSTED_ABUSE_BLOCK_THRESHOLD": "4", "HOSTED_RATE_LIMITS_ENABLED_BY_DEFAULT": "true", "HOSTED_ABUSE_CONTROLS_ENABLED_BY_DEFAULT": "true", + "MAGIC_LINK_START_RATE_LIMIT_WINDOW_SECONDS": "360", + "MAGIC_LINK_START_RATE_LIMIT_MAX_REQUESTS": "8", + "MAGIC_LINK_VERIFY_RATE_LIMIT_WINDOW_SECONDS": "420", + "MAGIC_LINK_VERIFY_RATE_LIMIT_MAX_REQUESTS": "12", + "TELEGRAM_WEBHOOK_RATE_LIMIT_WINDOW_SECONDS": "90", + "TELEGRAM_WEBHOOK_RATE_LIMIT_MAX_REQUESTS": "180", + "CORS_ALLOWED_ORIGINS": "https://app.example.com,https://staging.example.com", + "CORS_ALLOWED_METHODS": "GET,POST,OPTIONS", + "CORS_ALLOWED_HEADERS": "Authorization,Content-Type", + "CORS_ALLOW_CREDENTIALS": "true", + "CORS_PREFLIGHT_MAX_AGE_SECONDS": "900", + "SECURITY_HEADERS_ENABLED": "false", + "SECURITY_HEADERS_HSTS_MAX_AGE_SECONDS": "86400", + "SECURITY_HEADERS_HSTS_INCLUDE_SUBDOMAINS": "false", + "TRUST_PROXY_HEADERS": "true", + "TRUSTED_PROXY_IPS": "127.0.0.1,10.0.0.2", + "ENTRYPOINT_RATE_LIMIT_BACKEND": "memory", } ) @@ -183,6 +276,23 @@ def test_settings_can_be_loaded_from_an_explicit_environment_mapping() -> None: assert settings.hosted_abuse_block_threshold == 4 assert settings.hosted_rate_limits_enabled_by_default is True assert settings.hosted_abuse_controls_enabled_by_default is True + assert settings.magic_link_start_rate_limit_window_seconds == 360 + assert settings.magic_link_start_rate_limit_max_requests == 8 + assert settings.magic_link_verify_rate_limit_window_seconds == 420 + assert settings.magic_link_verify_rate_limit_max_requests == 12 + assert settings.telegram_webhook_rate_limit_window_seconds == 90 + assert settings.telegram_webhook_rate_limit_max_requests == 180 + assert settings.cors_allowed_origins == ("https://app.example.com", "https://staging.example.com") + assert settings.cors_allowed_methods == ("GET", "POST", "OPTIONS") + assert settings.cors_allowed_headers == ("Authorization", "Content-Type") + assert settings.cors_allow_credentials is True + assert settings.cors_preflight_max_age_seconds == 900 + assert settings.security_headers_enabled is False + assert settings.security_headers_hsts_max_age_seconds == 86400 + assert settings.security_headers_hsts_include_subdomains is False + assert settings.trust_proxy_headers is True + assert settings.trusted_proxy_ips == ("127.0.0.1", "10.0.0.2") + assert settings.entrypoint_rate_limit_backend == "memory" def test_settings_raise_clear_error_for_invalid_integer_values() -> None: @@ -259,6 +369,72 @@ def test_settings_reject_non_positive_rate_limit_values() -> None: ): Settings.from_env({"HOSTED_ABUSE_BLOCK_THRESHOLD": "0"}) + with pytest.raises( + ValueError, + match="MAGIC_LINK_START_RATE_LIMIT_WINDOW_SECONDS must be a positive integer", + ): + Settings.from_env({"MAGIC_LINK_START_RATE_LIMIT_WINDOW_SECONDS": "0"}) + + with pytest.raises( + ValueError, + match="MAGIC_LINK_START_RATE_LIMIT_MAX_REQUESTS must be a positive integer", + ): + Settings.from_env({"MAGIC_LINK_START_RATE_LIMIT_MAX_REQUESTS": "0"}) + + with pytest.raises( + ValueError, + match="MAGIC_LINK_VERIFY_RATE_LIMIT_WINDOW_SECONDS must be a positive integer", + ): + Settings.from_env({"MAGIC_LINK_VERIFY_RATE_LIMIT_WINDOW_SECONDS": "0"}) + + with pytest.raises( + ValueError, + match="MAGIC_LINK_VERIFY_RATE_LIMIT_MAX_REQUESTS must be a positive integer", + ): + Settings.from_env({"MAGIC_LINK_VERIFY_RATE_LIMIT_MAX_REQUESTS": "0"}) + + with pytest.raises( + ValueError, + match="TELEGRAM_WEBHOOK_RATE_LIMIT_WINDOW_SECONDS must be a positive integer", + ): + Settings.from_env({"TELEGRAM_WEBHOOK_RATE_LIMIT_WINDOW_SECONDS": "0"}) + + with pytest.raises( + ValueError, + match="TELEGRAM_WEBHOOK_RATE_LIMIT_MAX_REQUESTS must be a positive integer", + ): + Settings.from_env({"TELEGRAM_WEBHOOK_RATE_LIMIT_MAX_REQUESTS": "0"}) + + with pytest.raises( + ValueError, + match="CORS_PREFLIGHT_MAX_AGE_SECONDS must be a positive integer", + ): + Settings.from_env({"CORS_PREFLIGHT_MAX_AGE_SECONDS": "0"}) + + with pytest.raises( + ValueError, + match="CORS_ALLOWED_METHODS must include at least one method", + ): + Settings.from_env({"CORS_ALLOWED_METHODS": " "}) + + with pytest.raises( + ValueError, + match="SECURITY_HEADERS_HSTS_MAX_AGE_SECONDS must be a positive integer", + ): + Settings.from_env({"SECURITY_HEADERS_HSTS_MAX_AGE_SECONDS": "0"}) + + with pytest.raises( + ValueError, + match="ENTRYPOINT_RATE_LIMIT_BACKEND must be either 'redis' or 'memory'", + ): + Settings.from_env({"ENTRYPOINT_RATE_LIMIT_BACKEND": "invalid"}) + + with pytest.raises( + ValueError, + match="TRUSTED_PROXY_IPS must include at least one IP when TRUST_PROXY_HEADERS is enabled", + ): + Settings.from_env({"TRUST_PROXY_HEADERS": "true"}) + def test_settings_require_hardened_non_dev_configuration() -> None: with pytest.raises( @@ -274,3 +450,35 @@ def test_settings_require_hardened_non_dev_configuration() -> None: "ALICEBOT_AUTH_USER_ID": "00000000-0000-0000-0000-000000000001", } ) + + with pytest.raises( + ValueError, + match="TELEGRAM_WEBHOOK_SECRET must be configured outside development/test environments", + ): + Settings.from_env( + { + "APP_ENV": "staging", + "ALICEBOT_AUTH_USER_ID": "00000000-0000-0000-0000-000000000001", + "DATABASE_URL": "postgresql://secure-app:secret@localhost:5432/alicebot_secure", + "DATABASE_ADMIN_URL": "postgresql://secure-admin:secret@localhost:5432/alicebot_secure", + "S3_ACCESS_KEY": "secure-access", + "S3_SECRET_KEY": "secure-secret", + } + ) + + with pytest.raises( + ValueError, + match="CORS_ALLOWED_ORIGINS cannot include wildcard outside development/test environments", + ): + Settings.from_env( + { + "APP_ENV": "staging", + "ALICEBOT_AUTH_USER_ID": "00000000-0000-0000-0000-000000000001", + "DATABASE_URL": "postgresql://secure-app:secret@localhost:5432/alicebot_secure", + "DATABASE_ADMIN_URL": "postgresql://secure-admin:secret@localhost:5432/alicebot_secure", + "S3_ACCESS_KEY": "secure-access", + "S3_SECRET_KEY": "secure-secret", + "TELEGRAM_WEBHOOK_SECRET": "secure-webhook-secret", + "CORS_ALLOWED_ORIGINS": "*", + } + ) diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 161d46a..137d0c3 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -317,6 +317,99 @@ def test_rewrite_user_id_json_body_rejects_mismatch() -> None: asyncio.run(main_module._rewrite_user_id_json_body(request, uuid4())) +def test_request_client_identifier_ignores_forwarded_header_when_proxy_not_trusted() -> None: + request = _build_request( + method="POST", + path="/v1/auth/magic-link/start", + headers={"x-forwarded-for": "203.0.113.9, 127.0.0.1"}, + ) + + client_identifier = main_module._request_client_identifier( + request, + Settings(database_url="postgresql://app"), + ) + + assert client_identifier == "127.0.0.1" + + +def test_request_client_identifier_uses_forwarded_header_for_trusted_proxy() -> None: + request = _build_request( + method="POST", + path="/v1/auth/magic-link/start", + headers={"x-forwarded-for": "203.0.113.9, 127.0.0.1"}, + ) + + client_identifier = main_module._request_client_identifier( + request, + Settings( + database_url="postgresql://app", + trust_proxy_headers=True, + trusted_proxy_ips=("127.0.0.1",), + ), + ) + + assert client_identifier == "203.0.113.9" + + +def test_entrypoint_rate_limit_memory_backend_enforces_limits() -> None: + settings = Settings( + database_url="postgresql://app", + entrypoint_rate_limit_backend="memory", + ) + + main_module.entrypoint_rate_limiter.reset() + first_result = main_module._enforce_entrypoint_rate_limit( + settings=settings, + key="entrypoint-test-memory-backend", + max_requests=1, + window_seconds=60, + detail_code="entrypoint_test_limited", + message="entrypoint test limit exceeded", + ) + second_result = main_module._enforce_entrypoint_rate_limit( + settings=settings, + key="entrypoint-test-memory-backend", + max_requests=1, + window_seconds=60, + detail_code="entrypoint_test_limited", + message="entrypoint test limit exceeded", + ) + main_module.entrypoint_rate_limiter.reset() + + assert first_result is None + assert second_result is not None + assert second_result.status_code == 429 + assert json.loads(second_result.body)["detail"]["code"] == "entrypoint_test_limited" + + +def test_entrypoint_rate_limit_returns_503_when_redis_backend_is_unavailable(monkeypatch) -> None: + settings = Settings( + app_env="staging", + database_url="postgresql://app", + entrypoint_rate_limit_backend="redis", + ) + main_module.entrypoint_rate_limiter.reset() + monkeypatch.setattr(main_module, "redis", None) + + limited = main_module._enforce_entrypoint_rate_limit( + settings=settings, + key="entrypoint-test-redis-unavailable", + max_requests=1, + window_seconds=60, + detail_code="entrypoint_test_limited", + message="entrypoint test limit exceeded", + ) + + assert limited is not None + assert limited.status_code == 503 + assert json.loads(limited.body) == { + "detail": { + "code": "entrypoint_rate_limiter_unavailable", + "message": "entrypoint rate limiter backend is unavailable", + } + } + + def test_compile_context_returns_trace_and_context_pack(monkeypatch) -> None: user_id = uuid4() thread_id = uuid4()