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