feat(6838): read the client address and HTTPS from the trusted proxy hop
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Python tests (push) Failing after 1m20s
CI & Build / integration (push) Successful in 2m27s
CI & Build / Build & push image (push) Skipped

grimoire/proxy.py is the one reader of X-Forwarded-For and X-Forwarded-Proto.
It takes the entry TRUSTED_PROXY_HOPS in from the right (default 1; a shorter
header reads its leftmost entry; 0 trusts no proxy), so an entry a client puts
left of the proxy's is never believed. Before this, with TRUST_PROXY_HEADERS
on, the leftmost X-Forwarded-For entry keyed the login, register, forgot and
reset rate limits and the audit log, and a client could choose it.

The session cookie is Secure, and HSTS (max-age=31536000) is sent, exactly
when the request is HTTPS by the trusted proxy's word. Plain HTTP is
untouched and nothing redirects. SECURE_COOKIES is gone (a startup warning says
so when it is still set); TRUST_PROXY_HEADERS=false still maps to 0 hops, with
a warning to move to TRUSTED_PROXY_HOPS. Admin audit lines now use the same
client address instead of the socket peer.

The placeholder-key refusal keyed on SECURE_COOKIES, so it now refuses either
placeholder key outright; the instance makes its own key when none is set.

Family idea #5004 (IsHTTPS, trusted-hop X-Forwarded-Proto), canon v2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 19:32:20 -05:00
co-authored by Claude Opus 5.5
parent b87178d261
commit dbd6d5b8b5
12 changed files with 369 additions and 82 deletions
+20 -28
View File
@@ -8,6 +8,7 @@ from quart import Blueprint, g, jsonify, redirect, request, session
from grimoire.auth import login_required, get_current_user_id
from grimoire.config import Config
from grimoire.proxy import client_ip
from grimoire.rate_limit import is_rate_limited
from grimoire.services.auth import (
authenticate,
@@ -43,20 +44,11 @@ from grimoire.services.oauth import (
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
def _client_ip() -> str:
if Config.TRUST_PROXY_HEADERS:
for header in ("X-Forwarded-For", "X-Real-IP"):
val = request.headers.get(header, "").split(",")[0].strip()
if val:
return val
return request.remote_addr or ""
@auth_bp.route("/register", methods=["POST"])
async def register():
if not Config.LOCAL_AUTH_ENABLED:
return jsonify({"error": "Local authentication is disabled. Please use SSO."}), 403
if await is_rate_limited(f"register:{_client_ip()}", max_requests=5, window_seconds=300):
if await is_rate_limited(f"register:{client_ip()}", max_requests=5, window_seconds=300):
return jsonify({"error": "Too many registration attempts. Try again later."}), 429
if not await is_registration_open():
return jsonify({"error": "Registration is closed"}), 403
@@ -78,7 +70,7 @@ async def register():
session["user_id"] = user.id
session["session_version"] = user.session_version
await log_audit("register", user_id=user.id, username=user.username, ip_address=_client_ip())
await log_audit("register", user_id=user.id, username=user.username, ip_address=client_ip())
return jsonify(user.to_dict()), 201
@@ -86,7 +78,7 @@ async def register():
async def login():
if not Config.LOCAL_AUTH_ENABLED:
return jsonify({"error": "Local authentication is disabled. Please use SSO."}), 403
if await is_rate_limited(f"login:{_client_ip()}", max_requests=10, window_seconds=60):
if await is_rate_limited(f"login:{client_ip()}", max_requests=10, window_seconds=60):
return jsonify({"error": "Too many login attempts. Try again later."}), 429
data = await request.get_json()
username = (data.get("username") or "").strip()
@@ -97,22 +89,22 @@ async def login():
user = await authenticate(username, password)
if not user:
await log_audit("login_failed", username=username, ip_address=_client_ip())
await log_audit("login_failed", username=username, ip_address=client_ip())
# Try to notify the actual user about the failed attempt
target_user = await get_user_by_username(username)
if target_user:
asyncio.create_task(notify_security_event(
target_user.id, "login_failed",
{"ip_address": _client_ip(), "username": username},
{"ip_address": client_ip(), "username": username},
))
return jsonify({"error": "Invalid username or password"}), 401
session["user_id"] = user.id
session["session_version"] = user.session_version
await log_audit("login", user_id=user.id, username=user.username, ip_address=_client_ip())
await log_audit("login", user_id=user.id, username=user.username, ip_address=client_ip())
asyncio.create_task(notify_security_event(
user.id, "login",
{"ip_address": _client_ip()},
{"ip_address": client_ip()},
))
return jsonify(user.to_dict())
@@ -122,10 +114,10 @@ async def logout():
uid = session.get("user_id")
if uid:
user = await get_user_by_id(uid)
await log_audit("logout", user_id=uid, username=user.username if user else None, ip_address=_client_ip())
await log_audit("logout", user_id=uid, username=user.username if user else None, ip_address=client_ip())
asyncio.create_task(notify_security_event(
uid, "logout",
{"ip_address": _client_ip()},
{"ip_address": client_ip()},
))
session.clear()
return jsonify({"status": "ok"})
@@ -159,10 +151,10 @@ async def update_password():
# Keep the current session alive with the new version (other sessions are now invalidated)
session["session_version"] = new_version
await log_audit("password_change", user_id=uid, username=g.user.username, ip_address=_client_ip())
await log_audit("password_change", user_id=uid, username=g.user.username, ip_address=client_ip())
asyncio.create_task(notify_security_event(
uid, "password_change",
{"ip_address": _client_ip()},
{"ip_address": client_ip()},
))
return jsonify({"status": "ok"})
@@ -178,7 +170,7 @@ async def invalidate_sessions_route():
uid = get_current_user_id()
new_version = await invalidate_other_sessions(uid)
session["session_version"] = new_version
await log_audit("sessions_invalidated", user_id=uid, username=g.user.username, ip_address=_client_ip())
await log_audit("sessions_invalidated", user_id=uid, username=g.user.username, ip_address=client_ip())
return jsonify({"status": "ok"})
@@ -208,14 +200,14 @@ async def update_email():
return jsonify({"error": "Email already in use by another account"}), 409
await update_user_email(uid, new_email)
await log_audit("email_change", user_id=uid, username=user.username, ip_address=_client_ip())
await log_audit("email_change", user_id=uid, username=user.username, ip_address=client_ip())
updated = await get_user_by_id(uid)
return jsonify(updated.to_dict())
@auth_bp.route("/forgot-password", methods=["POST"])
async def forgot_password():
if await is_rate_limited(f"forgot:{_client_ip()}", max_requests=5, window_seconds=300):
if await is_rate_limited(f"forgot:{client_ip()}", max_requests=5, window_seconds=300):
return jsonify({"status": "ok"})
data = await request.get_json()
email = (data.get("email") or "").strip().lower()
@@ -234,7 +226,7 @@ async def forgot_password():
"password_reset_requested",
user_id=user.id,
username=user.username,
ip_address=_client_ip(),
ip_address=client_ip(),
)
return jsonify({"status": "ok"})
@@ -242,7 +234,7 @@ async def forgot_password():
@auth_bp.route("/reset-password", methods=["POST"])
async def reset_password():
if await is_rate_limited(f"reset:{_client_ip()}", max_requests=10, window_seconds=60):
if await is_rate_limited(f"reset:{client_ip()}", max_requests=10, window_seconds=60):
return jsonify({"error": "Too many attempts. Try again later."}), 429
data = await request.get_json()
token = (data.get("token") or "").strip()
@@ -261,7 +253,7 @@ async def reset_password():
if user:
await log_audit(
"password_reset_completed",
user_id=user.id, username=user.username, ip_address=_client_ip(),
user_id=user.id, username=user.username, ip_address=client_ip(),
)
if user.email:
asyncio.create_task(send_password_reset_success_email(user.email))
@@ -304,7 +296,7 @@ async def register_with_invite():
"register_with_invite",
user_id=user.id,
username=user.username,
ip_address=_client_ip(),
ip_address=client_ip(),
)
return jsonify(user.to_dict()), 201
@@ -371,5 +363,5 @@ async def oauth_callback():
user = await find_or_create_oauth_user(sub, email, preferred_username)
session["user_id"] = user.id
session["session_version"] = user.session_version
await log_audit("oauth_login", user_id=user.id, username=user.username, ip_address=_client_ip())
await log_audit("oauth_login", user_id=user.id, username=user.username, ip_address=client_ip())
return redirect("/")