Files
FabledSteward/steward/core/proxy.py
T
bvandeusenandClaude Opus 5.5 7a3f7869fe
CI / lint (push) Failing after 2s
CI / integration (push) Failing after 47s
CI / unit (push) Successful in 1m5s
CI / publish (push) Skipped
feat(auth): sessions end when they should; logins are throttled; Secure behind HTTPS (#5035)
#5035 "Sessions: lifetime applied, role and is_active re-read per request,
logout is a POST, failed logins throttled".

- steward/core/proxy.py: trusted_value / is_https / client_ip read
  X-Forwarded-Proto and -For only as far as the trusted proxy hops
  (Settings → General, default 1, 0 = trust none).
- The session cookie is Secure, and HSTS is sent, only on requests that
  arrived over HTTPS by that rule; plain-HTTP and LAN use are unchanged
  and nothing redirects.
- A login lasts Settings → General's session lifetime from when it began
  (session.permanent + a login_at stamp checked every request — the
  cookie's own age slides on every response). A deleted or deactivated
  user is logged out on their next request; a role change applies at once.
- Logout is a POST (a form in the sidebar); GET /logout is gone.
- Failed logins pause an address after login.max_failures within
  login.lockout_minutes (Settings → General; defaults 5 in 15 min),
  keyed by address so nobody can lock a user out by name. Failures are
  audited as auth.login_failed with the address.
- to_access_cfg projects the new settings into app.config at startup and
  on save, beside the existing projections.
- Integration tests seed real users and a login_at, since every request
  now re-reads the session's user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:02:27 -04:00

51 lines
1.8 KiB
Python

"""What the request looked like before the reverse proxy.
Steward serves plain HTTP; TLS ends at the operator's proxy (Traefik, often
with Cloudflare in front). The proxy reports the original scheme and client
in X-Forwarded-Proto / X-Forwarded-For, but anyone can send those headers,
so they are believed only as far as the configured number of trusted proxy
hops (Settings → General, default 1). 0 means no proxy is trusted.
Each proxy appends to the header, so the value the nearest trusted proxy
wrote is the hops-th from the right. HTTPS-only behaviour (the session
cookie's Secure flag, HSTS) turns on per request from is_https(); nothing
here ever redirects to https.
"""
from __future__ import annotations
from quart import current_app, request
def trusted_value(header_values: list[str], hops: int) -> str | None:
"""The value written by the outermost trusted proxy, or None when there
are fewer values than trusted hops (or hops is 0)."""
if hops <= 0:
return None
values = [v.strip() for line in header_values for v in line.split(",") if v.strip()]
if len(values) < hops:
return None
return values[-hops]
def _hops() -> int:
try:
return max(0, int(current_app.config.get("TRUSTED_PROXY_HOPS", 1)))
except (TypeError, ValueError):
return 1
def is_https() -> bool:
"""True when this request reached Steward over HTTPS, directly or as
reported by a trusted proxy."""
if request.scheme == "https":
return True
proto = trusted_value(request.headers.getlist("X-Forwarded-Proto"), _hops())
return (proto or "").lower() == "https"
def client_ip() -> str:
"""The client's address as the outermost trusted proxy saw it, else the
socket peer."""
forwarded = trusted_value(request.headers.getlist("X-Forwarded-For"), _hops())
return forwarded or (request.remote_addr or "")