orgstate / infra /api /security.py
Legal-i's picture
Initial OrgState deploy via Stage 150 free-tier stack
d2d1903 verified
"""
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