Extract real client IP from proxy headers instead of Docker bridge IP

Nginx already forwards X-Forwarded-For and X-Real-IP, but the backend
read request.client.host directly — always returning 172.18.0.x. Added
get_client_ip() helper to audit service; updated all 13 call sites.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-01 19:20:07 +08:00
parent f8c2df9328
commit 21aa670a39
4 changed files with 29 additions and 15 deletions

View File

@ -48,7 +48,7 @@ from app.schemas.admin import (
UserListItem, UserListItem,
UserListResponse, UserListResponse,
) )
from app.services.audit import log_audit_event from app.services.audit import get_client_ip, log_audit_event
from app.services.auth import hash_password from app.services.auth import hash_password
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -228,7 +228,7 @@ async def create_user(
actor_id=actor.id, actor_id=actor.id,
target_id=new_user.id, target_id=new_user.id,
detail={"username": new_user.username, "role": new_user.role}, detail={"username": new_user.username, "role": new_user.role},
ip=request.client.host if request.client else None, ip=get_client_ip(request),
) )
try: try:
@ -290,7 +290,7 @@ async def update_user_role(
actor_id=actor.id, actor_id=actor.id,
target_id=user_id, target_id=user_id,
detail={"old_role": old_role, "new_role": data.role, "sessions_revoked": revoked}, detail={"old_role": old_role, "new_role": data.role, "sessions_revoked": revoked},
ip=request.client.host if request.client else None, ip=get_client_ip(request),
) )
await db.commit() await db.commit()
@ -332,7 +332,7 @@ async def reset_user_password(
actor_id=actor.id, actor_id=actor.id,
target_id=user_id, target_id=user_id,
detail={"sessions_revoked": revoked}, detail={"sessions_revoked": revoked},
ip=request.client.host if request.client else None, ip=get_client_ip(request),
) )
await db.commit() await db.commit()
@ -385,7 +385,7 @@ async def disable_user_mfa(
actor_id=actor.id, actor_id=actor.id,
target_id=user_id, target_id=user_id,
detail={"sessions_revoked": revoked}, detail={"sessions_revoked": revoked},
ip=request.client.host if request.client else None, ip=get_client_ip(request),
) )
await db.commit() await db.commit()
@ -420,7 +420,7 @@ async def toggle_mfa_enforce(
actor_id=actor.id, actor_id=actor.id,
target_id=user_id, target_id=user_id,
detail={"enforce": data.enforce}, detail={"enforce": data.enforce},
ip=request.client.host if request.client else None, ip=get_client_ip(request),
) )
await db.commit() await db.commit()
@ -462,7 +462,7 @@ async def toggle_user_active(
actor_id=actor.id, actor_id=actor.id,
target_id=user_id, target_id=user_id,
detail={"sessions_revoked": revoked}, detail={"sessions_revoked": revoked},
ip=request.client.host if request.client else None, ip=get_client_ip(request),
) )
await db.commit() await db.commit()
@ -494,7 +494,7 @@ async def revoke_user_sessions(
actor_id=actor.id, actor_id=actor.id,
target_id=user_id, target_id=user_id,
detail={"sessions_revoked": revoked}, detail={"sessions_revoked": revoked},
ip=request.client.host if request.client else None, ip=get_client_ip(request),
) )
await db.commit() await db.commit()
@ -545,7 +545,7 @@ async def delete_user(
actor_id=actor.id, actor_id=actor.id,
target_id=user_id, target_id=user_id,
detail={"user_id": user_id, "username": deleted_username}, detail={"user_id": user_id, "username": deleted_username},
ip=request.client.host if request.client else None, ip=get_client_ip(request),
) )
# Flush audit + session revocation within the same transaction # Flush audit + session revocation within the same transaction
await db.flush() await db.flush()
@ -652,7 +652,7 @@ async def update_system_config(
action="admin.config_updated", action="admin.config_updated",
actor_id=actor.id, actor_id=actor.id,
detail=changes, detail=changes,
ip=request.client.host if request.client else None, ip=get_client_ip(request),
) )
await db.commit() await db.commit()

View File

@ -43,7 +43,7 @@ from app.services.auth import (
create_mfa_token, create_mfa_token,
create_mfa_enforce_token, create_mfa_enforce_token,
) )
from app.services.audit import log_audit_event from app.services.audit import get_client_ip, log_audit_event
from app.config import settings as app_settings from app.config import settings as app_settings
router = APIRouter() router = APIRouter()
@ -275,7 +275,7 @@ async def setup(
await _create_user_defaults(db, new_user.id) await _create_user_defaults(db, new_user.id)
ip = request.client.host if request.client else "unknown" ip = get_client_ip(request)
user_agent = request.headers.get("user-agent") user_agent = request.headers.get("user-agent")
_, token = await _create_db_session(db, new_user, ip, user_agent) _, token = await _create_db_session(db, new_user, ip, user_agent)
_set_session_cookie(response, token) _set_session_cookie(response, token)
@ -307,7 +307,7 @@ async def login(
HTTP 403 account disabled (is_active=False) HTTP 403 account disabled (is_active=False)
HTTP 423 account locked HTTP 423 account locked
""" """
client_ip = request.client.host if request.client else "unknown" client_ip = get_client_ip(request)
result = await db.execute(select(User).where(User.username == data.username)) result = await db.execute(select(User).where(User.username == data.username))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@ -438,7 +438,7 @@ async def register(
await _create_user_defaults(db, new_user.id) await _create_user_defaults(db, new_user.id)
ip = request.client.host if request.client else "unknown" ip = get_client_ip(request)
user_agent = request.headers.get("user-agent") user_agent = request.headers.get("user-agent")
await log_audit_event( await log_audit_event(

View File

@ -35,6 +35,7 @@ from app.models.session import UserSession
from app.models.totp_usage import TOTPUsage from app.models.totp_usage import TOTPUsage
from app.models.backup_code import BackupCode from app.models.backup_code import BackupCode
from app.routers.auth import get_current_user, _set_session_cookie from app.routers.auth import get_current_user, _set_session_cookie
from app.services.audit import get_client_ip
from app.services.auth import ( from app.services.auth import (
verify_password_with_upgrade, verify_password_with_upgrade,
hash_password, hash_password,
@ -164,7 +165,7 @@ async def _create_full_session(
"""Create a UserSession row and return the signed cookie token.""" """Create a UserSession row and return the signed cookie token."""
session_id = uuid.uuid4().hex session_id = uuid.uuid4().hex
expires_at = datetime.now() + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS) expires_at = datetime.now() + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
ip = request.client.host if request.client else None ip = get_client_ip(request)
user_agent = request.headers.get("user-agent") user_agent = request.headers.get("user-agent")
db_session = UserSession( db_session = UserSession(

View File

@ -1,8 +1,21 @@
import json import json
from fastapi import Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
def get_client_ip(request: Request) -> str:
"""Extract the real client IP from proxy headers, falling back to direct connection."""
forwarded_for = request.headers.get("x-forwarded-for")
if forwarded_for:
# X-Forwarded-For can be a comma-separated list; first entry is the original client
return forwarded_for.split(",")[0].strip()
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip.strip()
return request.client.host if request.client else "unknown"
async def log_audit_event( async def log_audit_event(
db: AsyncSession, db: AsyncSession,
action: str, action: str,