""" infra.api.security — security headers + CORS config (Stage 80). Production BLOCKER 3/5. Before this stage: * Responses carried zero hardening headers — a browser embedding our API in an iframe would have no protection, MIME-sniffing could elevate text/plain to executable, referrers leaked URLs. * No CORS middleware — a customer dashboard hosted on app.customer.com calling api.orgstate.io would be blocked by the browser unless they ran the API + dashboard on the same origin (forces co-hosting; impractical for a SaaS). This module: * ``always_on_headers()`` returns the dict of headers we add to every response, including 4xx/5xx (security headers should NOT depend on the response being successful). * ``cors_origins_from_env()`` parses the allowlist. * ``hsts_config_from_env()`` returns (enabled, max_age). HSTS is OPT‑IN because turning it on without TLS bricks the deployment for a year (per max-age); operators must consciously enable after verifying their TLS terminator works. Wiring happens in app.py — this module stays middleware-free so it's importable without FastAPI. """ from __future__ import annotations import os from dataclasses import dataclass from typing import List, Tuple # --- always-on headers -------------------------------------------- # Doc on each header: # X-Content-Type-Options: nosniff # Stop browsers from MIME-sniffing a text/plain response into # application/javascript or text/html. Without this, a malicious # payload uploaded as an "image" could execute when fetched. # X-Frame-Options: DENY # Disallow embedding our pages in iframes (clickjacking # protection). The newer CSP frame-ancestors supersedes this, # but X-Frame-Options is universally supported and small. # Referrer-Policy: strict-origin-when-cross-origin # Don't leak the full URL (including query strings — could # hold ?api_key= or ?tenant_id=) to other origins. Browsers # send the origin only on cross-origin navigation, nothing # on downgrade (HTTPS→HTTP). # Permissions-Policy: interest-cohort=() # Opt out of the FLoC ad-tracking cohort assignment. Symbolic # — we're an API, not a tracker — but signals intent. _ALWAYS_ON: dict = { "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "Referrer-Policy": "strict-origin-when-cross-origin", "Permissions-Policy": "interest-cohort=()", } @dataclass(frozen=True) class SecurityConfig: hsts_enabled: bool = False hsts_max_age: int = 31_536_000 # 1 year, the HSTS standard cors_origins: Tuple[str, ...] = () @classmethod def from_env(cls) -> "SecurityConfig": """Read security config from env. Garbage values raise ValueError at construction (Stage 77 fail-fast pattern).""" # HSTS enable flag — accept truthy spellings; everything # else (including unset) = False. We default OFF because # enabling HSTS commits clients to HTTPS for max_age # seconds — a misconfigured TLS terminator bricks the # deployment for a year. Opt-in is the safe default. raw_enabled = os.environ.get("ORGSTATE_HSTS_ENABLED", "") hsts_enabled = raw_enabled.lower() in {"1", "true", "yes", "on"} raw_age = os.environ.get("ORGSTATE_HSTS_MAX_AGE") if raw_age is None: hsts_max_age = 31_536_000 else: try: hsts_max_age = int(raw_age) except ValueError as exc: raise ValueError( f"ORGSTATE_HSTS_MAX_AGE must be an integer, " f"got {raw_age!r}" ) from exc if hsts_max_age < 0: raise ValueError( f"ORGSTATE_HSTS_MAX_AGE must be >= 0, " f"got {hsts_max_age}" ) return cls( hsts_enabled=hsts_enabled, hsts_max_age=hsts_max_age, cors_origins=cors_origins_from_env(), ) def cors_origins_from_env() -> Tuple[str, ...]: """Parse ``ORGSTATE_CORS_ORIGINS`` (comma-separated). Empty/unset → no CORS middleware mounted at all (denies all cross-origin requests by default; co-hosted deployments don't need CORS).""" raw = os.environ.get("ORGSTATE_CORS_ORIGINS", "") if not raw.strip(): return () # split + strip + dedup while preserving order — operators # listing the same origin twice (e.g. via concatenation) gets # one entry, not a duplicate that breaks the allowlist seen: List[str] = [] for part in raw.split(","): cleaned = part.strip() if cleaned and cleaned not in seen: seen.append(cleaned) return tuple(seen) def always_on_headers(config: SecurityConfig) -> dict: """The dict of headers added to every response by the security middleware. Includes the constant set + HSTS if enabled.""" headers = dict(_ALWAYS_ON) if config.hsts_enabled: headers["Strict-Transport-Security"] = ( f"max-age={config.hsts_max_age}; includeSubDomains" ) return headers def is_safe_hsts_value(value: str) -> bool: """Quick sanity check used by tests: the directive starts with max-age and (when enabled) is non-zero.""" return value.startswith("max-age=") and "max-age=0" not in value