219 lines
8.7 KiB
Python
219 lines
8.7 KiB
Python
"""Local-web attack surface: session, CSRF, Origin/Host checks, default headers.
|
|
|
|
The app binds to 127.0.0.1, so the attacker is not a remote client but another page
|
|
in the user's browser (concept §15, "Local web attack"): any site can issue requests
|
|
to ``http://127.0.0.1:8000`` and can embed ``<img src=...>`` against media endpoints.
|
|
The defenses stack, because each one alone has a hole:
|
|
|
|
* **Host** must be a loopback name — a DNS rebinding host that resolves to 127.0.0.1
|
|
passes the browser's origin rules but not this check.
|
|
* **Origin**, when the browser sends one, must be this exact origin (scheme, host,
|
|
port). There is no CORS middleware at all, so a foreign page can never *read* a
|
|
response even if it manages to send a request.
|
|
* **Sec-Fetch-Site** rejects cross-site loads that carry no Origin, which is what an
|
|
``<img>`` or ``<script>`` against a media endpoint looks like.
|
|
* A **session cookie** (``SameSite=Strict``, ``HttpOnly``) is required by every
|
|
``/api/v1`` route except liveness/readiness and the bootstrap itself. Strict means
|
|
the browser never attaches it to a request another site initiated.
|
|
* A **CSRF token** must be echoed in a header on every mutation. It is handed out
|
|
only in the bootstrap response body, which a foreign page cannot read (no CORS) —
|
|
so possessing it proves the caller is same-origin.
|
|
|
|
``evaluate`` is a pure function over the request metadata: the whole policy is one
|
|
table that a unit test can enumerate, and the middleware only applies its verdict.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import secrets
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from urllib.parse import urlsplit
|
|
|
|
from starlette.datastructures import Headers, MutableHeaders
|
|
from starlette.responses import JSONResponse
|
|
|
|
SESSION_COOKIE = "pp_session"
|
|
CSRF_HEADER = "x-csrf-token"
|
|
API_PREFIX = "/api/v1"
|
|
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
# Reachable without a session: liveness/readiness (an orchestrator has no cookie)
|
|
# and the bootstrap that issues the session in the first place.
|
|
PUBLIC_PATHS = frozenset(
|
|
{f"{API_PREFIX}/health/live", f"{API_PREFIX}/health/ready", f"{API_PREFIX}/session"}
|
|
)
|
|
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", "[::1]"})
|
|
# Mutating endpoints that must stay reachable while mutation itself is gated: the
|
|
# backup a careful operator takes first, and its retention (US07-07).
|
|
MUTATION_EXEMPT_PATHS = frozenset({f"{API_PREFIX}/backups", f"{API_PREFIX}/backups/prune"})
|
|
|
|
# Applied to every response. No inline script/style is used by the frontend, so the
|
|
# policy can stay strict; `frame-ancestors 'none'` and CORP keep other pages from
|
|
# embedding the app or its thumbnails.
|
|
DEFAULT_HEADERS = {
|
|
"x-content-type-options": "nosniff",
|
|
"x-frame-options": "DENY",
|
|
"referrer-policy": "no-referrer",
|
|
"cross-origin-resource-policy": "same-origin",
|
|
"cross-origin-opener-policy": "same-origin",
|
|
"content-security-policy": (
|
|
"default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; "
|
|
"connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
|
|
),
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Session:
|
|
"""One process, one session. A local app has exactly one user; a session store
|
|
would be bookkeeping without a second subject to distinguish.
|
|
|
|
ponytail: per-session rows if the app ever serves more than one operator.
|
|
"""
|
|
|
|
id: str
|
|
csrf_token: str
|
|
|
|
@classmethod
|
|
def create(cls) -> Session:
|
|
return cls(secrets.token_urlsafe(32), secrets.token_urlsafe(32))
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Refusal:
|
|
status: int
|
|
code: str
|
|
message: str
|
|
|
|
|
|
def split_host(value: str) -> tuple[str, str]:
|
|
"""``"127.0.0.1:8000"`` -> ``("127.0.0.1", "8000")``; bracketed IPv6 aware."""
|
|
value = value.strip()
|
|
if value.startswith("["):
|
|
host, _, port = value.partition("]")
|
|
return host + "]", port.lstrip(":")
|
|
host, _, port = value.partition(":")
|
|
return host, port
|
|
|
|
|
|
def evaluate(
|
|
*,
|
|
method: str,
|
|
path: str,
|
|
headers: Mapping[str, str],
|
|
session: Session,
|
|
allowed_hosts: frozenset[str] = LOOPBACK_HOSTS,
|
|
max_request_bytes: int,
|
|
) -> Refusal | None:
|
|
"""Why this request must be refused, or ``None`` when it may proceed."""
|
|
host_header = headers.get("host", "")
|
|
host, port = split_host(host_header)
|
|
if host.lower() not in allowed_hosts:
|
|
return Refusal(403, "host_not_allowed", "request host is not a local address")
|
|
|
|
origin = headers.get("origin")
|
|
if origin is not None and origin != "":
|
|
parts = urlsplit(origin)
|
|
origin_host, origin_port = split_host(parts.netloc)
|
|
if (
|
|
parts.scheme not in ("http", "https")
|
|
or origin_host.lower() not in allowed_hosts
|
|
or origin_port != port
|
|
):
|
|
return Refusal(403, "origin_not_allowed", "request origin is not this application")
|
|
|
|
# Absent means a non-browser client; "none" is a user-initiated navigation.
|
|
fetch_site = headers.get("sec-fetch-site")
|
|
if fetch_site is not None and fetch_site not in ("same-origin", "none"):
|
|
return Refusal(403, "cross_site_blocked", "cross-site requests are not accepted")
|
|
|
|
length = headers.get("content-length")
|
|
if length and length.isdigit() and int(length) > max_request_bytes:
|
|
return Refusal(413, "payload_too_large", "request body exceeds the configured limit")
|
|
|
|
protected = path.startswith(API_PREFIX) and path not in PUBLIC_PATHS
|
|
if not protected:
|
|
return None
|
|
|
|
if headers.get("cookie-session") != session.id:
|
|
return Refusal(401, "unauthenticated", "a valid application session is required")
|
|
if method.upper() not in SAFE_METHODS and headers.get(CSRF_HEADER) != session.csrf_token:
|
|
return Refusal(403, "csrf_failed", "missing or invalid CSRF token")
|
|
return None
|
|
|
|
|
|
class SecurityMiddleware:
|
|
"""Pure-ASGI so the SSE stream keeps streaming (BaseHTTPMiddleware buffers)."""
|
|
|
|
def __init__(self, app, *, session: Session, config) -> None:
|
|
self.app = app
|
|
self.session = session
|
|
self.config = config
|
|
self.max_request_bytes = config.max_request_bytes
|
|
self.allowed_hosts = frozenset(LOOPBACK_HOSTS | {str(config.host).lower()})
|
|
|
|
async def __call__(self, scope, receive, send) -> None:
|
|
if scope["type"] != "http":
|
|
await self.app(scope, receive, send)
|
|
return
|
|
headers = Headers(scope=scope)
|
|
# The cookie is read here and handed to the pure policy as one value, so the
|
|
# policy never has to parse a Cookie header.
|
|
lookup = dict(headers)
|
|
lookup["cookie-session"] = _cookie(headers.get("cookie", ""), SESSION_COOKIE)
|
|
refusal = evaluate(
|
|
method=scope.get("method", "GET"),
|
|
path=scope.get("path", "/"),
|
|
headers=lookup,
|
|
session=self.session,
|
|
allowed_hosts=self.allowed_hosts,
|
|
max_request_bytes=self.max_request_bytes,
|
|
)
|
|
if refusal is None:
|
|
refusal = self._mutation_refusal(scope)
|
|
if refusal is not None:
|
|
response = JSONResponse(
|
|
status_code=refusal.status,
|
|
content={"error": {"code": refusal.code, "message": refusal.message}},
|
|
headers=DEFAULT_HEADERS,
|
|
)
|
|
await response(scope, receive, send)
|
|
return
|
|
|
|
async def send_with_headers(message):
|
|
if message["type"] == "http.response.start":
|
|
out = MutableHeaders(scope=message)
|
|
for name, value in DEFAULT_HEADERS.items():
|
|
out.setdefault(name, value)
|
|
await send(message)
|
|
|
|
await self.app(scope, receive, send_with_headers)
|
|
|
|
def _mutation_refusal(self, scope) -> Refusal | None:
|
|
"""Refuse every mutating request while the library's dry run is unapproved.
|
|
|
|
One choke point for the whole API: every mutation the browser can start is a
|
|
non-safe method under ``/api/v1``. Reading stays open — an operator has to be
|
|
able to look at what the application found in order to approve it (US07-07).
|
|
"""
|
|
method = scope.get("method", "GET").upper()
|
|
path = scope.get("path", "/")
|
|
if method in SAFE_METHODS or not path.startswith(API_PREFIX):
|
|
return None
|
|
if path in MUTATION_EXEMPT_PATHS:
|
|
return None
|
|
from photo_pipeline.services.release import mutation_blockers
|
|
|
|
blockers = mutation_blockers(self.config)
|
|
if not blockers:
|
|
return None
|
|
return Refusal(403, blockers[0]["code"], blockers[0]["message"])
|
|
|
|
|
|
def _cookie(header: str, name: str) -> str:
|
|
for part in header.split(";"):
|
|
key, _, value = part.strip().partition("=")
|
|
if key == name:
|
|
return value
|
|
return ""
|