| """ |
| 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: 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 |
| 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).""" |
| |
| |
| |
| |
| |
| 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 () |
| |
| |
| |
| 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 |
|
|