fix(auth): 反代后登录白名单读取真实客户端 IP
Docker/1Panel 反代时 request.client.host 为 172.18.0.1;可信内网跳读取 X-Real-IP/X-Forwarded-For。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -506,3 +506,33 @@
|
||||
{"ts":"2026-06-07T21:20:20+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T21:20:20+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T21:20:20+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-07T21:31:41+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-ssh-quick-add-hostkey-chinese-errors.md 34lines"}
|
||||
{"ts":"2026-06-07T21:31:41+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-ssh-quick-add-hostkey-chinese-errors.md"}
|
||||
{"ts":"2026-06-07T21:31:41+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T21:31:41+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T21:31:41+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T21:31:41+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T21:31:41+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-07T21:31:41+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T21:31:41+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T21:31:41+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-07T21:50:16+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-login-client-ip-forwarded.md 29lines"}
|
||||
{"ts":"2026-06-07T21:50:16+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-login-client-ip-forwarded.md"}
|
||||
{"ts":"2026-06-07T21:50:16+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T21:50:16+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T21:50:16+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T21:50:16+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T21:50:16+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-07T21:50:16+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T21:50:16+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T21:50:16+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-07T21:50:28+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-login-client-ip-forwarded.md 29lines"}
|
||||
{"ts":"2026-06-07T21:50:28+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-login-client-ip-forwarded.md"}
|
||||
{"ts":"2026-06-07T21:50:28+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T21:50:28+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T21:50:28+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T21:50:28+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T21:50:28+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-07T21:50:28+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T21:50:28+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T21:50:28+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# 审计 — 2026-06-07 登录客户端 IP 反代识别
|
||||
|
||||
| 文件 | 变更要点 |
|
||||
|------|----------|
|
||||
| `server/utils/client_ip.py` | 可信反代跳读取 X-Real-IP / XFF 末段 |
|
||||
| `server/api/auth.py` | login/refresh/change_password 用 get_client_ip |
|
||||
| `tests/test_client_ip.py` | 单测 |
|
||||
| `deploy/gate_log.jsonl` | 门控记录 |
|
||||
|
||||
## Step 3
|
||||
|
||||
| H | 结论 |
|
||||
|---|------|
|
||||
| H1 | 公网直连忽略伪造 X-Real-IP | PASS |
|
||||
| H2 | 172.18.0.1 仅在内网跳信任转发头 | PASS |
|
||||
|
||||
Step3✓ Closure✓ DoD✓
|
||||
@@ -0,0 +1,29 @@
|
||||
# 2026-06-07 — 登录白名单客户端 IP 识别(反代/Docker)
|
||||
|
||||
## 摘要
|
||||
|
||||
修复登录 IP 白名单将 Docker 网关 `172.18.0.1` 误判为客户端 IP 的问题;在可信反代跳(内网/回环)上读取 `X-Real-IP` / `X-Forwarded-For`。
|
||||
|
||||
## 动机
|
||||
|
||||
用户启用登录白名单后提示「当前 IP (172.18.0.1) 不在登录白名单中」。1Panel/OpenResty → Docker 容器时 TCP 对端为桥接网关,非真实公网 IP;`auth.py` 仍用 `request.client.host`。
|
||||
|
||||
## 变更
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `server/utils/client_ip.py` | `get_client_ip()`:反代为内网/回环时信任转发头 |
|
||||
| `server/api/auth.py` | login / refresh / change_password 使用 `get_client_ip` |
|
||||
| `tests/test_client_ip.py` | 172.18.0.1 + X-Real-IP、公网直连防伪造 |
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
需重启 API。反代须配置 `proxy_set_header X-Real-IP $remote_addr`(见 `deploy/1panel/openresty-nexus.conf.example`)。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
pytest tests/test_client_ip.py -q
|
||||
```
|
||||
|
||||
生产登录:错误提示应显示真实公网 IP,而非 `172.18.0.1`。
|
||||
+4
-3
@@ -16,6 +16,7 @@ from server.api.dependencies import get_auth_service, get_db
|
||||
from server.api.auth_jwt import get_current_admin
|
||||
from server.application.services.auth_service import AuthService, JWT_REFRESH_TOKEN_EXPIRE_DAYS
|
||||
from server.domain.models import Admin, AuditLog
|
||||
from server.utils.client_ip import get_client_ip
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
@@ -122,7 +123,7 @@ async def login(
|
||||
Security: Refresh token is set as HttpOnly cookie (not in JSON body).
|
||||
Access token is returned in JSON for client-side Bearer auth.
|
||||
"""
|
||||
ip_address = request.client.host if request.client else "unknown"
|
||||
ip_address = get_client_ip(request)
|
||||
result = await service.login(
|
||||
username=payload.username,
|
||||
password=payload.password,
|
||||
@@ -170,7 +171,7 @@ async def refresh_token(
|
||||
if not refresh_token:
|
||||
raise HTTPException(status_code=401, detail="Missing refresh token")
|
||||
|
||||
ip_address = request.client.host if request.client else ""
|
||||
ip_address = get_client_ip(request)
|
||||
result = await service.refresh_token(refresh_token, ip_address=ip_address)
|
||||
if not result["success"]:
|
||||
# Clear the cookie on failure (invalid/expired token)
|
||||
@@ -313,7 +314,7 @@ async def change_password(
|
||||
logger.warning(f"Failed to clear refresh tokens from Redis for admin {admin.id}", exc_info=True)
|
||||
|
||||
# Audit log with client IP
|
||||
ip_address = request.client.host if request.client else ""
|
||||
ip_address = get_client_ip(request)
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Resolve real client IP behind reverse proxy (1Panel / Nginx / Docker)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
# Upstream hop is a private/loopback/docker address → trust forwarded headers.
|
||||
_TRUSTED_PROXY_NETWORKS: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] = (
|
||||
ipaddress.ip_network("127.0.0.0/8"),
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
ipaddress.ip_network("172.16.0.0/12"),
|
||||
ipaddress.ip_network("192.168.0.0/16"),
|
||||
ipaddress.ip_network("::1/128"),
|
||||
ipaddress.ip_network("fc00::/7"),
|
||||
)
|
||||
|
||||
|
||||
def _parse_ip(value: str) -> str | None:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
# X-Real-IP may rarely contain comma-separated values
|
||||
candidate = text.split(",")[0].strip()
|
||||
try:
|
||||
ipaddress.ip_address(candidate)
|
||||
except ValueError:
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def _is_trusted_proxy_hop(ip: str) -> bool:
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
return False
|
||||
return any(addr in net for net in _TRUSTED_PROXY_NETWORKS)
|
||||
|
||||
|
||||
def _ip_from_forwarded_headers(request: Request) -> str | None:
|
||||
"""X-Real-IP first; X-Forwarded-For last (Nginx appends $remote_addr)."""
|
||||
x_real = _parse_ip(request.headers.get("X-Real-IP", ""))
|
||||
if x_real:
|
||||
return x_real
|
||||
|
||||
xff = (request.headers.get("X-Forwarded-For") or "").strip()
|
||||
if not xff:
|
||||
return None
|
||||
parts = [p.strip() for p in xff.split(",") if p.strip()]
|
||||
if not parts:
|
||||
return None
|
||||
return _parse_ip(parts[-1])
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Return client IP for auth, audit, and rate limits.
|
||||
|
||||
When the TCP peer is a trusted reverse proxy (Docker bridge, loopback, LAN),
|
||||
use X-Real-IP / X-Forwarded-For. Direct public connections ignore those
|
||||
headers to prevent spoofing.
|
||||
"""
|
||||
direct = request.client.host if request.client else ""
|
||||
if direct and _is_trusted_proxy_hop(direct):
|
||||
forwarded = _ip_from_forwarded_headers(request)
|
||||
if forwarded:
|
||||
return forwarded
|
||||
return direct or "unknown"
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Tests for reverse-proxy client IP resolution."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from server.utils.client_ip import get_client_ip
|
||||
|
||||
|
||||
def _req(direct: str, headers: dict[str, str] | None = None):
|
||||
request = MagicMock()
|
||||
request.client = MagicMock(host=direct)
|
||||
hdrs = headers or {}
|
||||
|
||||
def _get(key: str, default: str = "") -> str:
|
||||
return hdrs.get(key, default)
|
||||
|
||||
request.headers.get = _get
|
||||
return request
|
||||
|
||||
|
||||
class TestGetClientIp:
|
||||
def test_docker_bridge_uses_x_real_ip(self):
|
||||
ip = get_client_ip(_req("172.18.0.1", {"X-Real-IP": "203.0.113.10"}))
|
||||
assert ip == "203.0.113.10"
|
||||
|
||||
def test_docker_bridge_uses_xff_last(self):
|
||||
ip = get_client_ip(_req("172.18.0.1", {"X-Forwarded-For": "10.0.0.1, 203.0.113.11"}))
|
||||
assert ip == "203.0.113.11"
|
||||
|
||||
def test_public_direct_ignores_spoofed_headers(self):
|
||||
ip = get_client_ip(_req("198.51.100.5", {"X-Real-IP": "10.0.0.1"}))
|
||||
assert ip == "198.51.100.5"
|
||||
|
||||
def test_loopback_trusts_x_real_ip(self):
|
||||
ip = get_client_ip(_req("127.0.0.1", {"X-Real-IP": "203.0.113.12"}))
|
||||
assert ip == "203.0.113.12"
|
||||
|
||||
def test_no_headers_falls_back_to_direct(self):
|
||||
ip = get_client_ip(_req("172.18.0.1", {}))
|
||||
assert ip == "172.18.0.1"
|
||||
|
||||
def test_empty_direct_unknown(self):
|
||||
request = MagicMock()
|
||||
request.client = None
|
||||
request.headers.get = lambda _k, d="": d
|
||||
assert get_client_ip(request) == "unknown"
|
||||
Reference in New Issue
Block a user