318 lines
13 KiB
Python
318 lines
13 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.
|
|
|
|
Behind a reverse proxy (US08-01) the same stack holds with two substitutions: the
|
|
allowed host set comes from configuration instead of being the loopback names, and
|
|
the host/scheme the policy judges is the *external* one, which is only read from
|
|
``X-Forwarded-*`` when the request actually arrived from a configured proxy. The
|
|
loopback check was standing in for authentication, so naming a non-loopback host
|
|
also makes an access secret mandatory — ``trust_refusal`` refuses to start without
|
|
one, and the secret is what the bootstrap endpoint trades for the session cookie.
|
|
|
|
``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
|
|
import time
|
|
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"
|
|
ACCESS_SECRET_HEADER = "x-access-secret"
|
|
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 external_view(
|
|
*,
|
|
client: str | None,
|
|
headers: Mapping[str, str],
|
|
scheme: str,
|
|
trusted_proxies: frozenset[str],
|
|
) -> tuple[str, str]:
|
|
"""The ``(scheme, host)`` the caller used, as opposed to the one this hop saw.
|
|
|
|
Forwarded headers are a client-supplied claim. Believing them from anyone lets a
|
|
request declare its own origin — and origin is half of this module's evidence —
|
|
so they count only when the connection came from a configured proxy.
|
|
"""
|
|
host = headers.get("host", "")
|
|
if client is None or client not in trusted_proxies:
|
|
return scheme, host
|
|
# A chain appends: the first entry is what the original client asked for.
|
|
forwarded_proto = headers.get("x-forwarded-proto", "").split(",")[0].strip().lower()
|
|
forwarded_host = headers.get("x-forwarded-host", "").split(",")[0].strip()
|
|
return forwarded_proto or scheme, forwarded_host or host
|
|
|
|
|
|
def evaluate(
|
|
*,
|
|
method: str,
|
|
path: str,
|
|
headers: Mapping[str, str],
|
|
session: Session,
|
|
allowed_hosts: frozenset[str] = LOOPBACK_HOSTS,
|
|
scheme: str = "http",
|
|
max_request_bytes: int,
|
|
) -> Refusal | None:
|
|
"""Why this request must be refused, or ``None`` when it may proceed.
|
|
|
|
``headers["host"]`` and ``scheme`` are the external ones (see ``external_view``);
|
|
the allowed origins are the allowed hosts under that scheme and port, so there is
|
|
no second list that can drift away from the first.
|
|
"""
|
|
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 an allowed 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 != scheme
|
|
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
|
|
|
|
|
|
def exposed_hosts(config) -> list[str]:
|
|
"""Configured names by which this application is reachable from another machine."""
|
|
names = {str(config.host).lower()}
|
|
names.update(split_host(name)[0].lower() for name in config.allowed_hosts)
|
|
return sorted(names - LOOPBACK_HOSTS)
|
|
|
|
|
|
def trust_refusal(config) -> str | None:
|
|
"""Why this configuration must not serve at all, or ``None``.
|
|
|
|
Reaching the app used to prove ownership of it. The moment a configuration makes
|
|
it reachable from elsewhere that stops being true, so serving without a secret
|
|
would publish the library — refuse at startup rather than at the first request,
|
|
when the operator is no longer watching (US08-01).
|
|
"""
|
|
exposed = exposed_hosts(config)
|
|
if exposed and config.access_secret is None:
|
|
return (
|
|
f"refusing to serve: {', '.join(exposed)} is reachable from outside this "
|
|
"machine, so PHOTO_PIPELINE_ACCESS_SECRET must be set"
|
|
)
|
|
return None
|
|
|
|
|
|
class FailureLimiter:
|
|
"""Bounded failed access-secret attempts, so the secret cannot be guessed online.
|
|
|
|
ponytail: one counter for the whole process rather than per client address —
|
|
behind a proxy every attempt arrives from the same address anyway. Per-caller
|
|
buckets if the app is ever exposed without one.
|
|
"""
|
|
|
|
def __init__(self, limit: int = 5, window: float = 60.0) -> None:
|
|
self.limit = limit
|
|
self.window = window
|
|
self._failures: list[float] = []
|
|
|
|
def blocked(self) -> bool:
|
|
now = time.monotonic()
|
|
self._failures = [at for at in self._failures if now - at < self.window]
|
|
return len(self._failures) >= self.limit
|
|
|
|
def record_failure(self) -> None:
|
|
self._failures.append(time.monotonic())
|
|
|
|
|
|
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()}
|
|
| {split_host(name)[0].lower() for name in config.allowed_hosts}
|
|
)
|
|
self.trusted_proxies = frozenset(config.trusted_proxies)
|
|
|
|
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)
|
|
client = scope.get("client")
|
|
scheme, lookup["host"] = external_view(
|
|
client=client[0] if client else None,
|
|
headers=headers,
|
|
scheme=scope.get("scheme", "http"),
|
|
trusted_proxies=self.trusted_proxies,
|
|
)
|
|
# What the session cookie's Secure flag is decided from, one hop later.
|
|
scope.setdefault("state", {})["external_scheme"] = scheme
|
|
refusal = evaluate(
|
|
method=scope.get("method", "GET"),
|
|
path=scope.get("path", "/"),
|
|
headers=lookup,
|
|
session=self.session,
|
|
allowed_hosts=self.allowed_hosts,
|
|
scheme=scheme,
|
|
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 ""
|