File size: 5,374 Bytes
d2d1903 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 | """
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
|