From bf08cf1248a7c4754c60bf56b56d7030ac45dc97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=9A=82=E8=9A=81=E4=BA=91?= Date: Mon, 14 Sep 2026 11:54:46 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zh/volume1/env-vars.md | 14 ++- .../zh/volume4/core/cors.md | 33 +------ .../zh/volume4/tools/ip-ua.md | 92 ++++++++----------- .../zh/volume7/testing.md | 46 ++++------ .../zh/volume1/env-vars.md | 2 - .../zh/volume4/core/cors.md | 33 +------ .../zh/volume4/tools/ip-ua.md | 92 ++++++++----------- .../zh/volume7/testing.md | 46 ++++------ .../zh/volume1/env-vars.md | 14 ++- .../zh/volume4/core/cors.md | 33 +------ .../zh/volume4/tools/ip-ua.md | 92 ++++++++----------- .../zh/volume7/testing.md | 46 ++++------ 12 files changed, 186 insertions(+), 357 deletions(-) diff --git a/DjangoAdmin_FastAPI_AntdVue/zh/volume1/env-vars.md b/DjangoAdmin_FastAPI_AntdVue/zh/volume1/env-vars.md index abc99cb..e345f42 100644 --- a/DjangoAdmin_FastAPI_AntdVue/zh/volume1/env-vars.md +++ b/DjangoAdmin_FastAPI_AntdVue/zh/volume1/env-vars.md @@ -12,7 +12,7 @@ | 变量名 | 默认值 | 说明 | |:----|:----|:----| -| FASTAPI_NAME | FastAPI+AntdVue旗舰版 | 应用名称,用于界面显示和系统标识 | +| FASTAPI_NAME | FastAPI+EleVue旗舰版 | 应用名称,用于界面显示和系统标识 | | FASTAPI_VERSION | v3.0.0 | 应用版本号 | | FASTAPI_HOST | 0.0.0.0 | 应用监听地址。`127.0.0.1` 仅本地访问;容器/生产建议 `0.0.0.0` | | FASTAPI_PORT | 8031 | 应用监听端口 | @@ -20,7 +20,7 @@ | FASTAPI_ENV | production | 运行环境:`development` / `production` / `testing` | | FASTAPI_DEBUG | False | 是否开启调试模式。`True` 时输出详细错误堆栈和 SQL 日志,生产环境务必设为 `False` | | FASTAPI_DEMO | False | 是否开启演示模式。`True` 时所有写操作(add/update/delete/status/batchDelete)返回"演示环境,暂无操作权限" | -| FASTAPI_FILE_URL | http://file.fastapi.antdvue | 文件访问域名,用于拼接上传文件的访问 URL。`BaseService._enrich_file_urls()` 会自动为文件字段补此前缀 | +| FASTAPI_FILE_URL | http://file.fastapi.elevue | 文件访问域名,用于拼接上传文件的访问 URL。`BaseService._enrich_file_urls()` 会自动为文件字段补此前缀 | | UPLOAD_ALLOWED_EXTS | .jpg,.jpeg,.png,.gif,...,.zip | 允许上传的文件扩展名,逗号分隔(带点号)。`upload_size_limit_middleware` 按 `Content-Length` 提前拦截超大请求 | | UPLOAD_MAX_SIZE_MB | 10 | 上传文件大小上限(MB) | @@ -35,7 +35,7 @@ | DB_DRIVER | mysql | 数据库驱动。可选:`mysql` / `postgresql` / `mssql` / `oracle` / `sqlite` | | DB_HOST | 127.0.0.1 | 数据库地址(`sqlite` 时忽略) | | DB_PORT | 3306 | 数据库端口(`sqlite` 时忽略) | -| DB_DATABASE | djangoadmin.fastapi.antdvue | 数据库名称。`sqlite` 时作为数据库文件完整路径 | +| DB_DATABASE | djangoadmin.fastapi.elevue | 数据库名称。`sqlite` 时作为数据库文件完整路径 | | DB_USERNAME | root | 数据库账号(`sqlite` 时忽略) | | DB_PASSWORD | CHANGE_ME_DB_PASSWORD | 数据库密码,**必填**(`sqlite` 除外) | | DB_PREFIX | fastapi_ | 数据表前缀,所有业务表名自动添加此前缀。模型中通过 `DB_PREFIX + "position"` 拼接表名 | @@ -100,8 +100,6 @@ python -c "import secrets; print(secrets.token_urlsafe(48))" | 变量名 | 默认值 | 说明 | |:----|:----|:----| | CORS_ALLOWED_ORIGINS | http://localhost:8001 | CORS 允许的源,多个用逗号分隔 | -| TRUSTED_PROXY_COUNT | 0 | 可信代理层数。`0` = 不信任 X-Forwarded-For;部署在 Nginx 等反代后设为 `>=1` | -| TRUSTED_PROXY_IPS | (空) | 可信代理 IP 白名单,逗号分隔 | :::tip 温馨提示 本地开发时,`CORS_ALLOWED_ORIGINS` 需包含前端开发服务器地址(默认 `http://localhost:8001`)。如果有多个前端地址,用逗号分隔: @@ -163,14 +161,14 @@ CORS_ALLOWED_ORIGINS=http://localhost:8001,http://192.168.1.100:8001 # ============================================================ # 基础服务配置 # ============================================================ -FASTAPI_NAME=FastAPI+AntdVue旗舰版 # 应用名称 +FASTAPI_NAME=FastAPI+EleVue旗舰版 # 应用名称 FASTAPI_VERSION=v3.0.0 # 应用版本号 FASTAPI_HOST=127.0.0.1 # 服务监听地址(127.0.0.1 仅本地访问,0.0.0.0 允许外部访问) FASTAPI_PORT=8031 # 服务监听端口 FASTAPI_ENV=development # 运行环境(development / testing / production) FASTAPI_DEBUG=True # 调试模式开关(True 开启,生产环境请设为 False) FASTAPI_DEMO=False # 演示模式开关(True 开启后将限制部分写操作) -FASTAPI_FILE_URL=http://file.fastapi.antdvue # 文件服务地址(用于访问上传的文件) +FASTAPI_FILE_URL=http://file.fastapi.elevue # 文件服务地址(用于访问上传的文件) # ============================================================ # 数据库配置 @@ -178,7 +176,7 @@ FASTAPI_FILE_URL=http://file.fastapi.antdvue # 文件服务地址(用于访问 DB_DRIVER=mysql # 数据库类型(mysql / postgresql / mssql / sqlite / oracle) DB_HOST=127.0.0.1 # 数据库主机地址 DB_PORT=3306 # 数据库端口(MySQL 默认 3306) -DB_DATABASE=djangoadmin.fastapi.antdvue # 数据库名称 +DB_DATABASE=djangoadmin.fastapi.elevue # 数据库名称 DB_USERNAME=root # 数据库用户名 DB_PASSWORD=your_password # 数据库密码(请修改为实际密码) DB_PREFIX=fastapi_ # 数据表前缀(用于区分不同应用的同库表) diff --git a/DjangoAdmin_FastAPI_AntdVue/zh/volume4/core/cors.md b/DjangoAdmin_FastAPI_AntdVue/zh/volume4/core/cors.md index 487894b..58b0f89 100644 --- a/DjangoAdmin_FastAPI_AntdVue/zh/volume4/core/cors.md +++ b/DjangoAdmin_FastAPI_AntdVue/zh/volume4/core/cors.md @@ -7,8 +7,6 @@ CORS(Cross-Origin Resource Sharing)跨域资源共享是前后端分离架 | 环境变量 | 默认值 | 说明 | |---------|--------|------| | `CORS_ALLOWED_ORIGINS` | 空(允许所有源) | 允许的跨域源(逗号分隔) | -| `TRUSTED_PROXY_COUNT` | `0` | 可信代理层数 | -| `TRUSTED_PROXY_IPS` | 空 | 可信代理 IP 白名单(逗号分隔) | ## 配置示例 @@ -20,10 +18,6 @@ CORS_ALLOWED_ORIGINS= # 允许指定源(生产环境) CORS_ALLOWED_ORIGINS=https://admin.example.com,https://www.example.com - -# 信任一层反向代理(Nginx) -TRUSTED_PROXY_COUNT=1 -TRUSTED_PROXY_IPS=127.0.0.1 ``` :::warning 生产环境 @@ -73,28 +67,6 @@ register_cors(app) CORS → 限流 → 上传体积限制 → 操作日志 → DB会话 → Redis → 业务逻辑 ``` -## 代理头处理 - -当应用部署在 Nginx 等反向代理后,需要配置代理信任: - -```bash -# 信任一层代理 -TRUSTED_PROXY_COUNT=1 -TRUSTED_PROXY_IPS=127.0.0.1 -``` - -`TRUSTED_PROXY_COUNT` 说明: - -| 值 | 说明 | -|----|------| -| `0` | 不信任 X-Forwarded-For,使用 request.client.host | -| `1` | 信任一层反向代理 | -| `2` | 信任两层代理 | - -:::tip 安全提示 -仅当应用部署在反向代理后才设置 `TRUSTED_PROXY_COUNT>=1`。如果应用被直接暴露(如 Docker bridge 映射端口),设置代理信任会导致客户端伪造 XFF 绕过限流。 -::: - ## 总结 CORS 跨域配置具备以下特点: @@ -102,7 +74,6 @@ CORS 跨域配置具备以下特点: ``` 1. 环境变量配置:通过 CORS_ALLOWED_ORIGINS 灵活配置允许的源 2. 开发/生产分离:开发环境允许所有源,生产环境指定域名 -3. 代理头支持:TRUSTED_PROXY_COUNT 配置代理信任层数 -4. 安全默认:未配置时允许所有源但不携带凭证 -5. 最外层注册:确保所有响应都带 CORS 头 +3. 安全默认:未配置时允许所有源但不携带凭证 +4. 最外层注册:确保所有响应都带 CORS 头 ``` diff --git a/DjangoAdmin_FastAPI_AntdVue/zh/volume4/tools/ip-ua.md b/DjangoAdmin_FastAPI_AntdVue/zh/volume4/tools/ip-ua.md index fe1c287..784173a 100644 --- a/DjangoAdmin_FastAPI_AntdVue/zh/volume4/tools/ip-ua.md +++ b/DjangoAdmin_FastAPI_AntdVue/zh/volume4/tools/ip-ua.md @@ -12,9 +12,9 @@ ▼ ┌──────────────────────────────────────────────────────┐ │ get_client_ip(request) │ -│ ├─ 直连对端命中可信代理白名单? │ -│ │ ├─ 是 → 从 X-Forwarded-For 取真实客户端 IP │ -│ │ └─ 否 → request.client.host(fail-closed) │ +│ ├─ X-Forwarded-For 存在? │ +│ │ ├─ 是 → 取最左侧 IP(真实客户端 IP) │ +│ │ └─ 否 → request.client.host │ │ └─ client 为 None → 回退 127.0.0.1 │ └──────────┬───────────────────────────────────────────┘ │ @@ -53,11 +53,8 @@ def get_client_ip(request) -> str: """ 获取客户端真实IP地址 - 仅当直连对端命中 TRUSTED_PROXY_IPS 白名单且 TRUSTED_PROXY_COUNT>0 时才信任 - X-Forwarded-For 头: - - TRUSTED_PROXY_COUNT==0 或 TRUSTED_PROXY_IPS 未配置 / 对端不命中:不信任代理头, - 直接使用 request.client.host(fail-closed,防止被直接暴露时伪造 XFF) - - 命中白名单:从 X-Forwarded-For 右侧取倒数第 N 个 IP 作为真实客户端IP + 优先从 X-Forwarded-For 头取最左侧IP(即真实客户端IP), + 无代理头时回退到 request.client.host。 """ ``` @@ -65,60 +62,41 @@ def get_client_ip(request) -> str: ### 代理链解析原理 -当应用部署在 Nginx/HAProxy 等反向代理之后,`request.client.host` 拿到的是代理 IP 而非真实客户端 IP。此时需要从 `X-Forwarded-For`(XFF)头提取真实 IP。 +当应用部署在 Nginx/CDN 等反向代理之后,`request.client.host` 拿到的是代理 IP 而非真实客户端 IP。此时从 `X-Forwarded-For`(XFF)头取最左侧 IP 即为真实客户端 IP。 ``` 客户端 (1.2.3.4) │ ▼ -外层代理 (10.0.0.1) ← 追加 XFF: 1.2.3.4 +CDN (10.0.0.1) ← 追加 XFF: 1.2.3.4 │ ▼ -内层代理 (10.0.0.2) ← 追加 XFF: 1.2.3.4, 10.0.0.1 +Nginx ← 透传 XFF: 1.2.3.4(不追加) │ ▼ FastAPI 应用 - request.client.host = 10.0.0.2 X-Forwarded-For = "1.2.3.4, 10.0.0.1" + get_client_ip → 取最左侧 → "1.2.3.4" ``` -**取值逻辑**:XFF 最右侧由受信代理追加,客户端可控的伪造条目只可能出现在左侧。跳过最右侧 `TRUSTED_PROXY_COUNT` 个代理条目,取真实客户端 IP。 +**取值逻辑**:XFF 最左侧由 CDN/反代追加的真实客户端 IP,右侧为代理节点 IP。直接取最左侧即可。 -### 安全设计:fail-closed +:::warning Nginx 配置注意 +Nginx 代理配置应使用 `$http_x_forwarded_for`(透传上游值)而非 `$proxy_add_x_forwarded_for`(会追加本机 IP),避免 XFF 条目数不可控: + +```nginx +proxy_set_header X-Forwarded-For $http_x_forwarded_for; +``` +::: + +### 降级策略 | 场景 | 行为 | |------|------| -| `TRUSTED_PROXY_COUNT=0` | 不信任任何代理头,直接返回 `request.client.host` | -| `TRUSTED_PROXY_IPS` 未配置或为空 | 同上,fail-closed | -| 直连对端不在白名单中 | 同上(防止应用被直接暴露时客户端伪造 XFF) | -| 对端命中白名单 + XFF 条目少于代理层数 | 回退到 XFF 最右侧条目(避免拿到代理 IP) | +| X-Forwarded-For 存在 | 取最左侧 IP | +| X-Forwarded-For 为空 | 返回 `request.client.host` | | `request.client` 为 None | 回退到 `127.0.0.1` | -### 代理配置 - -在 `.env` 中配置可信代理: - -```bash -# 信任的代理层数(0 = 不信任代理头) -TRUSTED_PROXY_COUNT=1 - -# 可信代理IP白名单(逗号分隔) -TRUSTED_PROXY_IPS=127.0.0.1 -``` - -**常见部署配置**: - -| 部署方式 | TRUSTED_PROXY_COUNT | TRUSTED_PROXY_IPS | -|:---------|:----|:----| -| 直连(无代理) | `0` | 留空 | -| 单层 Nginx | `1` | `127.0.0.1`(本机)或 Nginx 内网 IP | -| Nginx + CDN | `2` | Nginx 内网 IP | -| Docker bridge 映射端口 | `0` | 留空(容器外为客户端直连) | - -:::warning 安全提示 -如果应用被直接暴露在公网(如 Docker bridge 映射端口、未走 Nginx),务必保持 `TRUSTED_PROXY_COUNT=0` 或 `TRUSTED_PROXY_IPS` 留空。否则客户端可随意伪造 XFF 首部,绕过基于 IP 的登录锁定和请求限流。 -::: - ## 二、IP 地理位置查询 位于 `src/utils/ip2region.py`,基于 [ip2region](https://github.com/lionsoul2014/ip2region) 离线数据库,无需网络请求。 @@ -406,17 +384,21 @@ if count and int(count) >= CAPTCHA_RATE_LIMIT: ## 五、配置参考 -### 完整 .env 配置 +### Nginx 代理配置 + +```nginx +location /api/ { + proxy_pass http://127.0.0.1:8031/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + # 透传上游X-Forwarded-For(不追加本机IP);无上游头时回退到直连IP + proxy_set_header X-Forwarded-For $http_x_forwarded_for; +} +``` + +### 请求限流配置(.env) ```bash -# ============================================================ -# 可信代理配置 -# ============================================================ -# 信任的代理层数(0 = 不信任代理头,直接使用 request.client.host) -TRUSTED_PROXY_COUNT=1 -# 可信代理IP白名单(逗号分隔,仅白名单内的直连对端才信任 XFF) -TRUSTED_PROXY_IPS=127.0.0.1 - # ============================================================ # 请求限流配置 # ============================================================ @@ -436,7 +418,7 @@ RATE_LIMIT_KEY_PREFIX=rl: **原因**:本地直连无代理,`request.client.host` 就是 `127.0.0.1`。 -**说明**:这是正常行为。生产环境配置 `TRUSTED_PROXY_COUNT` 和 `TRUSTED_PROXY_IPS` 后即可获取真实客户端 IP。 +**说明**:这是正常行为。生产环境部署在 Nginx/CDN 后会从 `X-Forwarded-For` 获取真实客户端 IP。 ### 地理位置显示为"未知" @@ -460,7 +442,7 @@ RATE_LIMIT_KEY_PREFIX=rl: 1. 检查 `.env` 中 `RATE_LIMIT_ENABLED=True` 2. 检查 Redis 连接是否正常 -3. 检查 `TRUSTED_PROXY_COUNT` 配置——如果限流基于 `request.client.host`(代理 IP),所有请求会共享同一 IP,导致限流提前触发或形同虚设 +3. 检查 Nginx 是否正确透传 `X-Forwarded-For`——如果限流基于代理 IP 而非真实客户端 IP,所有请求会共享同一 IP,导致限流提前触发或形同虚设 ### 如何更新 ip2region 数据库 @@ -470,7 +452,7 @@ RATE_LIMIT_KEY_PREFIX=rl: | 模块 | 文件 | 核心能力 | 降级策略 | |:-----|:-----|:---------|:---------| -| IP 获取 | `src/utils/ip.py` | 从 Request 提取真实客户端 IP,支持可信代理白名单 | client 为 None 时回退 `127.0.0.1` | +| IP 获取 | `src/utils/ip.py` | 从 Request 提取真实客户端 IP(优先 X-Forwarded-For 最左侧) | client 为 None 时回退 `127.0.0.1` | | 地理位置 | `src/utils/ip2region.py` | 基于 ip2region.xdb 离线查询,零网络依赖 | 异常/畸形/内网统一降级返回 `"未知"` 或 `"内网IP"` | | UA 解析 | `src/utils/ua.py` | 正则匹配提取操作系统和浏览器 | UA 为空时返回 `('', '')` | diff --git a/DjangoAdmin_FastAPI_AntdVue/zh/volume7/testing.md b/DjangoAdmin_FastAPI_AntdVue/zh/volume7/testing.md index 0743553..cccfd81 100644 --- a/DjangoAdmin_FastAPI_AntdVue/zh/volume7/testing.md +++ b/DjangoAdmin_FastAPI_AntdVue/zh/volume7/testing.md @@ -39,7 +39,7 @@ tests/ │ │ ├── test_base_service_page.py │ │ └── ... │ ├── utils/ # 工具函数测试 -│ │ ├── test_ip_trusted_proxy.py +│ │ ├── test_ip.py │ │ ├── test_ip2region.py │ │ └── ... │ └── scripts/ # 脚本测试 @@ -256,12 +256,11 @@ def test_is_private_ip_private_ranges(): assert _is_private_ip('8.8.8.8') is False ``` -### 代理白名单测试 +### IP 获取测试 ```python -# tests/unit/utils/test_ip_trusted_proxy.py +# tests/unit/utils/test_ip.py import types -from utils import ip as ip_mod from utils.ip import get_client_ip @@ -273,32 +272,25 @@ def _make_request(client_host, xff=None): return req -def test_proxy_count_zero_ignores_xff(monkeypatch): - """TRUSTED_PROXY_COUNT=0:不信任代理头""" - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 0) - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'127.0.0.1'})) - assert get_client_ip(_make_request('203.0.113.9', xff='1.2.3.4')) == '203.0.113.9' - - -def test_peer_not_in_whitelist_rejects_xff(monkeypatch): - """直连对端不在白名单 → XFF 被忽略""" - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1) - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'})) - assert get_client_ip(_make_request('203.0.113.9', xff='1.2.3.4')) == '203.0.113.9' - - -def test_peer_in_whitelist_trusts_xff(monkeypatch): - """单层代理:对端在白名单 → 信任 XFF""" - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1) - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'})) +def test_xff_single_ip(): + """XFF 只有一个 IP → 直接返回""" assert get_client_ip(_make_request('10.0.0.1', xff='1.2.3.4')) == '1.2.3.4' -def test_peer_in_whitelist_spoofed_left_entry_ignored(monkeypatch): - """客户端伪造条目只会在 XFF 左侧""" - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1) - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'})) - assert get_client_ip(_make_request('10.0.0.1', xff='6.6.6.6, 1.2.3.4')) == '1.2.3.4' +def test_xff_multiple_ips_returns_leftmost(): + """XFF 多个 IP → 取最左侧(真实客户端 IP)""" + assert get_client_ip(_make_request('10.0.0.1', xff='1.2.3.4, 10.0.0.1')) == '1.2.3.4' + + +def test_no_xff_falls_back_to_client_host(): + """无 XFF 头 → 回退到 request.client.host""" + assert get_client_ip(_make_request('203.0.113.9')) == '203.0.113.9' + + +def test_no_client_falls_back_to_localhost(): + """client 为 None → 回退到 127.0.0.1""" + req = types.SimpleNamespace(client=None, headers={}) + assert get_client_ip(req) == '127.0.0.1' ``` ### service 层公共 fixture diff --git a/DjangoAdmin_FastAPI_EleVue/zh/volume1/env-vars.md b/DjangoAdmin_FastAPI_EleVue/zh/volume1/env-vars.md index ed6bd68..e345f42 100644 --- a/DjangoAdmin_FastAPI_EleVue/zh/volume1/env-vars.md +++ b/DjangoAdmin_FastAPI_EleVue/zh/volume1/env-vars.md @@ -100,8 +100,6 @@ python -c "import secrets; print(secrets.token_urlsafe(48))" | 变量名 | 默认值 | 说明 | |:----|:----|:----| | CORS_ALLOWED_ORIGINS | http://localhost:8001 | CORS 允许的源,多个用逗号分隔 | -| TRUSTED_PROXY_COUNT | 0 | 可信代理层数。`0` = 不信任 X-Forwarded-For;部署在 Nginx 等反代后设为 `>=1` | -| TRUSTED_PROXY_IPS | (空) | 可信代理 IP 白名单,逗号分隔 | :::tip 温馨提示 本地开发时,`CORS_ALLOWED_ORIGINS` 需包含前端开发服务器地址(默认 `http://localhost:8001`)。如果有多个前端地址,用逗号分隔: diff --git a/DjangoAdmin_FastAPI_EleVue/zh/volume4/core/cors.md b/DjangoAdmin_FastAPI_EleVue/zh/volume4/core/cors.md index 487894b..58b0f89 100644 --- a/DjangoAdmin_FastAPI_EleVue/zh/volume4/core/cors.md +++ b/DjangoAdmin_FastAPI_EleVue/zh/volume4/core/cors.md @@ -7,8 +7,6 @@ CORS(Cross-Origin Resource Sharing)跨域资源共享是前后端分离架 | 环境变量 | 默认值 | 说明 | |---------|--------|------| | `CORS_ALLOWED_ORIGINS` | 空(允许所有源) | 允许的跨域源(逗号分隔) | -| `TRUSTED_PROXY_COUNT` | `0` | 可信代理层数 | -| `TRUSTED_PROXY_IPS` | 空 | 可信代理 IP 白名单(逗号分隔) | ## 配置示例 @@ -20,10 +18,6 @@ CORS_ALLOWED_ORIGINS= # 允许指定源(生产环境) CORS_ALLOWED_ORIGINS=https://admin.example.com,https://www.example.com - -# 信任一层反向代理(Nginx) -TRUSTED_PROXY_COUNT=1 -TRUSTED_PROXY_IPS=127.0.0.1 ``` :::warning 生产环境 @@ -73,28 +67,6 @@ register_cors(app) CORS → 限流 → 上传体积限制 → 操作日志 → DB会话 → Redis → 业务逻辑 ``` -## 代理头处理 - -当应用部署在 Nginx 等反向代理后,需要配置代理信任: - -```bash -# 信任一层代理 -TRUSTED_PROXY_COUNT=1 -TRUSTED_PROXY_IPS=127.0.0.1 -``` - -`TRUSTED_PROXY_COUNT` 说明: - -| 值 | 说明 | -|----|------| -| `0` | 不信任 X-Forwarded-For,使用 request.client.host | -| `1` | 信任一层反向代理 | -| `2` | 信任两层代理 | - -:::tip 安全提示 -仅当应用部署在反向代理后才设置 `TRUSTED_PROXY_COUNT>=1`。如果应用被直接暴露(如 Docker bridge 映射端口),设置代理信任会导致客户端伪造 XFF 绕过限流。 -::: - ## 总结 CORS 跨域配置具备以下特点: @@ -102,7 +74,6 @@ CORS 跨域配置具备以下特点: ``` 1. 环境变量配置:通过 CORS_ALLOWED_ORIGINS 灵活配置允许的源 2. 开发/生产分离:开发环境允许所有源,生产环境指定域名 -3. 代理头支持:TRUSTED_PROXY_COUNT 配置代理信任层数 -4. 安全默认:未配置时允许所有源但不携带凭证 -5. 最外层注册:确保所有响应都带 CORS 头 +3. 安全默认:未配置时允许所有源但不携带凭证 +4. 最外层注册:确保所有响应都带 CORS 头 ``` diff --git a/DjangoAdmin_FastAPI_EleVue/zh/volume4/tools/ip-ua.md b/DjangoAdmin_FastAPI_EleVue/zh/volume4/tools/ip-ua.md index fe1c287..784173a 100644 --- a/DjangoAdmin_FastAPI_EleVue/zh/volume4/tools/ip-ua.md +++ b/DjangoAdmin_FastAPI_EleVue/zh/volume4/tools/ip-ua.md @@ -12,9 +12,9 @@ ▼ ┌──────────────────────────────────────────────────────┐ │ get_client_ip(request) │ -│ ├─ 直连对端命中可信代理白名单? │ -│ │ ├─ 是 → 从 X-Forwarded-For 取真实客户端 IP │ -│ │ └─ 否 → request.client.host(fail-closed) │ +│ ├─ X-Forwarded-For 存在? │ +│ │ ├─ 是 → 取最左侧 IP(真实客户端 IP) │ +│ │ └─ 否 → request.client.host │ │ └─ client 为 None → 回退 127.0.0.1 │ └──────────┬───────────────────────────────────────────┘ │ @@ -53,11 +53,8 @@ def get_client_ip(request) -> str: """ 获取客户端真实IP地址 - 仅当直连对端命中 TRUSTED_PROXY_IPS 白名单且 TRUSTED_PROXY_COUNT>0 时才信任 - X-Forwarded-For 头: - - TRUSTED_PROXY_COUNT==0 或 TRUSTED_PROXY_IPS 未配置 / 对端不命中:不信任代理头, - 直接使用 request.client.host(fail-closed,防止被直接暴露时伪造 XFF) - - 命中白名单:从 X-Forwarded-For 右侧取倒数第 N 个 IP 作为真实客户端IP + 优先从 X-Forwarded-For 头取最左侧IP(即真实客户端IP), + 无代理头时回退到 request.client.host。 """ ``` @@ -65,60 +62,41 @@ def get_client_ip(request) -> str: ### 代理链解析原理 -当应用部署在 Nginx/HAProxy 等反向代理之后,`request.client.host` 拿到的是代理 IP 而非真实客户端 IP。此时需要从 `X-Forwarded-For`(XFF)头提取真实 IP。 +当应用部署在 Nginx/CDN 等反向代理之后,`request.client.host` 拿到的是代理 IP 而非真实客户端 IP。此时从 `X-Forwarded-For`(XFF)头取最左侧 IP 即为真实客户端 IP。 ``` 客户端 (1.2.3.4) │ ▼ -外层代理 (10.0.0.1) ← 追加 XFF: 1.2.3.4 +CDN (10.0.0.1) ← 追加 XFF: 1.2.3.4 │ ▼ -内层代理 (10.0.0.2) ← 追加 XFF: 1.2.3.4, 10.0.0.1 +Nginx ← 透传 XFF: 1.2.3.4(不追加) │ ▼ FastAPI 应用 - request.client.host = 10.0.0.2 X-Forwarded-For = "1.2.3.4, 10.0.0.1" + get_client_ip → 取最左侧 → "1.2.3.4" ``` -**取值逻辑**:XFF 最右侧由受信代理追加,客户端可控的伪造条目只可能出现在左侧。跳过最右侧 `TRUSTED_PROXY_COUNT` 个代理条目,取真实客户端 IP。 +**取值逻辑**:XFF 最左侧由 CDN/反代追加的真实客户端 IP,右侧为代理节点 IP。直接取最左侧即可。 -### 安全设计:fail-closed +:::warning Nginx 配置注意 +Nginx 代理配置应使用 `$http_x_forwarded_for`(透传上游值)而非 `$proxy_add_x_forwarded_for`(会追加本机 IP),避免 XFF 条目数不可控: + +```nginx +proxy_set_header X-Forwarded-For $http_x_forwarded_for; +``` +::: + +### 降级策略 | 场景 | 行为 | |------|------| -| `TRUSTED_PROXY_COUNT=0` | 不信任任何代理头,直接返回 `request.client.host` | -| `TRUSTED_PROXY_IPS` 未配置或为空 | 同上,fail-closed | -| 直连对端不在白名单中 | 同上(防止应用被直接暴露时客户端伪造 XFF) | -| 对端命中白名单 + XFF 条目少于代理层数 | 回退到 XFF 最右侧条目(避免拿到代理 IP) | +| X-Forwarded-For 存在 | 取最左侧 IP | +| X-Forwarded-For 为空 | 返回 `request.client.host` | | `request.client` 为 None | 回退到 `127.0.0.1` | -### 代理配置 - -在 `.env` 中配置可信代理: - -```bash -# 信任的代理层数(0 = 不信任代理头) -TRUSTED_PROXY_COUNT=1 - -# 可信代理IP白名单(逗号分隔) -TRUSTED_PROXY_IPS=127.0.0.1 -``` - -**常见部署配置**: - -| 部署方式 | TRUSTED_PROXY_COUNT | TRUSTED_PROXY_IPS | -|:---------|:----|:----| -| 直连(无代理) | `0` | 留空 | -| 单层 Nginx | `1` | `127.0.0.1`(本机)或 Nginx 内网 IP | -| Nginx + CDN | `2` | Nginx 内网 IP | -| Docker bridge 映射端口 | `0` | 留空(容器外为客户端直连) | - -:::warning 安全提示 -如果应用被直接暴露在公网(如 Docker bridge 映射端口、未走 Nginx),务必保持 `TRUSTED_PROXY_COUNT=0` 或 `TRUSTED_PROXY_IPS` 留空。否则客户端可随意伪造 XFF 首部,绕过基于 IP 的登录锁定和请求限流。 -::: - ## 二、IP 地理位置查询 位于 `src/utils/ip2region.py`,基于 [ip2region](https://github.com/lionsoul2014/ip2region) 离线数据库,无需网络请求。 @@ -406,17 +384,21 @@ if count and int(count) >= CAPTCHA_RATE_LIMIT: ## 五、配置参考 -### 完整 .env 配置 +### Nginx 代理配置 + +```nginx +location /api/ { + proxy_pass http://127.0.0.1:8031/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + # 透传上游X-Forwarded-For(不追加本机IP);无上游头时回退到直连IP + proxy_set_header X-Forwarded-For $http_x_forwarded_for; +} +``` + +### 请求限流配置(.env) ```bash -# ============================================================ -# 可信代理配置 -# ============================================================ -# 信任的代理层数(0 = 不信任代理头,直接使用 request.client.host) -TRUSTED_PROXY_COUNT=1 -# 可信代理IP白名单(逗号分隔,仅白名单内的直连对端才信任 XFF) -TRUSTED_PROXY_IPS=127.0.0.1 - # ============================================================ # 请求限流配置 # ============================================================ @@ -436,7 +418,7 @@ RATE_LIMIT_KEY_PREFIX=rl: **原因**:本地直连无代理,`request.client.host` 就是 `127.0.0.1`。 -**说明**:这是正常行为。生产环境配置 `TRUSTED_PROXY_COUNT` 和 `TRUSTED_PROXY_IPS` 后即可获取真实客户端 IP。 +**说明**:这是正常行为。生产环境部署在 Nginx/CDN 后会从 `X-Forwarded-For` 获取真实客户端 IP。 ### 地理位置显示为"未知" @@ -460,7 +442,7 @@ RATE_LIMIT_KEY_PREFIX=rl: 1. 检查 `.env` 中 `RATE_LIMIT_ENABLED=True` 2. 检查 Redis 连接是否正常 -3. 检查 `TRUSTED_PROXY_COUNT` 配置——如果限流基于 `request.client.host`(代理 IP),所有请求会共享同一 IP,导致限流提前触发或形同虚设 +3. 检查 Nginx 是否正确透传 `X-Forwarded-For`——如果限流基于代理 IP 而非真实客户端 IP,所有请求会共享同一 IP,导致限流提前触发或形同虚设 ### 如何更新 ip2region 数据库 @@ -470,7 +452,7 @@ RATE_LIMIT_KEY_PREFIX=rl: | 模块 | 文件 | 核心能力 | 降级策略 | |:-----|:-----|:---------|:---------| -| IP 获取 | `src/utils/ip.py` | 从 Request 提取真实客户端 IP,支持可信代理白名单 | client 为 None 时回退 `127.0.0.1` | +| IP 获取 | `src/utils/ip.py` | 从 Request 提取真实客户端 IP(优先 X-Forwarded-For 最左侧) | client 为 None 时回退 `127.0.0.1` | | 地理位置 | `src/utils/ip2region.py` | 基于 ip2region.xdb 离线查询,零网络依赖 | 异常/畸形/内网统一降级返回 `"未知"` 或 `"内网IP"` | | UA 解析 | `src/utils/ua.py` | 正则匹配提取操作系统和浏览器 | UA 为空时返回 `('', '')` | diff --git a/DjangoAdmin_FastAPI_EleVue/zh/volume7/testing.md b/DjangoAdmin_FastAPI_EleVue/zh/volume7/testing.md index 0743553..cccfd81 100644 --- a/DjangoAdmin_FastAPI_EleVue/zh/volume7/testing.md +++ b/DjangoAdmin_FastAPI_EleVue/zh/volume7/testing.md @@ -39,7 +39,7 @@ tests/ │ │ ├── test_base_service_page.py │ │ └── ... │ ├── utils/ # 工具函数测试 -│ │ ├── test_ip_trusted_proxy.py +│ │ ├── test_ip.py │ │ ├── test_ip2region.py │ │ └── ... │ └── scripts/ # 脚本测试 @@ -256,12 +256,11 @@ def test_is_private_ip_private_ranges(): assert _is_private_ip('8.8.8.8') is False ``` -### 代理白名单测试 +### IP 获取测试 ```python -# tests/unit/utils/test_ip_trusted_proxy.py +# tests/unit/utils/test_ip.py import types -from utils import ip as ip_mod from utils.ip import get_client_ip @@ -273,32 +272,25 @@ def _make_request(client_host, xff=None): return req -def test_proxy_count_zero_ignores_xff(monkeypatch): - """TRUSTED_PROXY_COUNT=0:不信任代理头""" - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 0) - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'127.0.0.1'})) - assert get_client_ip(_make_request('203.0.113.9', xff='1.2.3.4')) == '203.0.113.9' - - -def test_peer_not_in_whitelist_rejects_xff(monkeypatch): - """直连对端不在白名单 → XFF 被忽略""" - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1) - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'})) - assert get_client_ip(_make_request('203.0.113.9', xff='1.2.3.4')) == '203.0.113.9' - - -def test_peer_in_whitelist_trusts_xff(monkeypatch): - """单层代理:对端在白名单 → 信任 XFF""" - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1) - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'})) +def test_xff_single_ip(): + """XFF 只有一个 IP → 直接返回""" assert get_client_ip(_make_request('10.0.0.1', xff='1.2.3.4')) == '1.2.3.4' -def test_peer_in_whitelist_spoofed_left_entry_ignored(monkeypatch): - """客户端伪造条目只会在 XFF 左侧""" - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1) - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'})) - assert get_client_ip(_make_request('10.0.0.1', xff='6.6.6.6, 1.2.3.4')) == '1.2.3.4' +def test_xff_multiple_ips_returns_leftmost(): + """XFF 多个 IP → 取最左侧(真实客户端 IP)""" + assert get_client_ip(_make_request('10.0.0.1', xff='1.2.3.4, 10.0.0.1')) == '1.2.3.4' + + +def test_no_xff_falls_back_to_client_host(): + """无 XFF 头 → 回退到 request.client.host""" + assert get_client_ip(_make_request('203.0.113.9')) == '203.0.113.9' + + +def test_no_client_falls_back_to_localhost(): + """client 为 None → 回退到 127.0.0.1""" + req = types.SimpleNamespace(client=None, headers={}) + assert get_client_ip(req) == '127.0.0.1' ``` ### service 层公共 fixture diff --git a/DjangoAdmin_FastAPI_NaiveVue/zh/volume1/env-vars.md b/DjangoAdmin_FastAPI_NaiveVue/zh/volume1/env-vars.md index 19080c2..e345f42 100644 --- a/DjangoAdmin_FastAPI_NaiveVue/zh/volume1/env-vars.md +++ b/DjangoAdmin_FastAPI_NaiveVue/zh/volume1/env-vars.md @@ -12,7 +12,7 @@ | 变量名 | 默认值 | 说明 | |:----|:----|:----| -| FASTAPI_NAME | FastAPI+NaiveUI旗舰版 | 应用名称,用于界面显示和系统标识 | +| FASTAPI_NAME | FastAPI+EleVue旗舰版 | 应用名称,用于界面显示和系统标识 | | FASTAPI_VERSION | v3.0.0 | 应用版本号 | | FASTAPI_HOST | 0.0.0.0 | 应用监听地址。`127.0.0.1` 仅本地访问;容器/生产建议 `0.0.0.0` | | FASTAPI_PORT | 8031 | 应用监听端口 | @@ -20,7 +20,7 @@ | FASTAPI_ENV | production | 运行环境:`development` / `production` / `testing` | | FASTAPI_DEBUG | False | 是否开启调试模式。`True` 时输出详细错误堆栈和 SQL 日志,生产环境务必设为 `False` | | FASTAPI_DEMO | False | 是否开启演示模式。`True` 时所有写操作(add/update/delete/status/batchDelete)返回"演示环境,暂无操作权限" | -| FASTAPI_FILE_URL | http://file.fastapi.naivevue | 文件访问域名,用于拼接上传文件的访问 URL。`BaseService._enrich_file_urls()` 会自动为文件字段补此前缀 | +| FASTAPI_FILE_URL | http://file.fastapi.elevue | 文件访问域名,用于拼接上传文件的访问 URL。`BaseService._enrich_file_urls()` 会自动为文件字段补此前缀 | | UPLOAD_ALLOWED_EXTS | .jpg,.jpeg,.png,.gif,...,.zip | 允许上传的文件扩展名,逗号分隔(带点号)。`upload_size_limit_middleware` 按 `Content-Length` 提前拦截超大请求 | | UPLOAD_MAX_SIZE_MB | 10 | 上传文件大小上限(MB) | @@ -35,7 +35,7 @@ | DB_DRIVER | mysql | 数据库驱动。可选:`mysql` / `postgresql` / `mssql` / `oracle` / `sqlite` | | DB_HOST | 127.0.0.1 | 数据库地址(`sqlite` 时忽略) | | DB_PORT | 3306 | 数据库端口(`sqlite` 时忽略) | -| DB_DATABASE | djangoadmin.fastapi.naivevue | 数据库名称。`sqlite` 时作为数据库文件完整路径 | +| DB_DATABASE | djangoadmin.fastapi.elevue | 数据库名称。`sqlite` 时作为数据库文件完整路径 | | DB_USERNAME | root | 数据库账号(`sqlite` 时忽略) | | DB_PASSWORD | CHANGE_ME_DB_PASSWORD | 数据库密码,**必填**(`sqlite` 除外) | | DB_PREFIX | fastapi_ | 数据表前缀,所有业务表名自动添加此前缀。模型中通过 `DB_PREFIX + "position"` 拼接表名 | @@ -100,8 +100,6 @@ python -c "import secrets; print(secrets.token_urlsafe(48))" | 变量名 | 默认值 | 说明 | |:----|:----|:----| | CORS_ALLOWED_ORIGINS | http://localhost:8001 | CORS 允许的源,多个用逗号分隔 | -| TRUSTED_PROXY_COUNT | 0 | 可信代理层数。`0` = 不信任 X-Forwarded-For;部署在 Nginx 等反代后设为 `>=1` | -| TRUSTED_PROXY_IPS | (空) | 可信代理 IP 白名单,逗号分隔 | :::tip 温馨提示 本地开发时,`CORS_ALLOWED_ORIGINS` 需包含前端开发服务器地址(默认 `http://localhost:8001`)。如果有多个前端地址,用逗号分隔: @@ -163,14 +161,14 @@ CORS_ALLOWED_ORIGINS=http://localhost:8001,http://192.168.1.100:8001 # ============================================================ # 基础服务配置 # ============================================================ -FASTAPI_NAME=FastAPI+NaiveUI旗舰版 # 应用名称 +FASTAPI_NAME=FastAPI+EleVue旗舰版 # 应用名称 FASTAPI_VERSION=v3.0.0 # 应用版本号 FASTAPI_HOST=127.0.0.1 # 服务监听地址(127.0.0.1 仅本地访问,0.0.0.0 允许外部访问) FASTAPI_PORT=8031 # 服务监听端口 FASTAPI_ENV=development # 运行环境(development / testing / production) FASTAPI_DEBUG=True # 调试模式开关(True 开启,生产环境请设为 False) FASTAPI_DEMO=False # 演示模式开关(True 开启后将限制部分写操作) -FASTAPI_FILE_URL=http://file.fastapi.naivevue # 文件服务地址(用于访问上传的文件) +FASTAPI_FILE_URL=http://file.fastapi.elevue # 文件服务地址(用于访问上传的文件) # ============================================================ # 数据库配置 @@ -178,7 +176,7 @@ FASTAPI_FILE_URL=http://file.fastapi.naivevue # 文件服务地址(用于访 DB_DRIVER=mysql # 数据库类型(mysql / postgresql / mssql / sqlite / oracle) DB_HOST=127.0.0.1 # 数据库主机地址 DB_PORT=3306 # 数据库端口(MySQL 默认 3306) -DB_DATABASE=djangoadmin.fastapi.naivevue # 数据库名称 +DB_DATABASE=djangoadmin.fastapi.elevue # 数据库名称 DB_USERNAME=root # 数据库用户名 DB_PASSWORD=your_password # 数据库密码(请修改为实际密码) DB_PREFIX=fastapi_ # 数据表前缀(用于区分不同应用的同库表) diff --git a/DjangoAdmin_FastAPI_NaiveVue/zh/volume4/core/cors.md b/DjangoAdmin_FastAPI_NaiveVue/zh/volume4/core/cors.md index 487894b..58b0f89 100644 --- a/DjangoAdmin_FastAPI_NaiveVue/zh/volume4/core/cors.md +++ b/DjangoAdmin_FastAPI_NaiveVue/zh/volume4/core/cors.md @@ -7,8 +7,6 @@ CORS(Cross-Origin Resource Sharing)跨域资源共享是前后端分离架 | 环境变量 | 默认值 | 说明 | |---------|--------|------| | `CORS_ALLOWED_ORIGINS` | 空(允许所有源) | 允许的跨域源(逗号分隔) | -| `TRUSTED_PROXY_COUNT` | `0` | 可信代理层数 | -| `TRUSTED_PROXY_IPS` | 空 | 可信代理 IP 白名单(逗号分隔) | ## 配置示例 @@ -20,10 +18,6 @@ CORS_ALLOWED_ORIGINS= # 允许指定源(生产环境) CORS_ALLOWED_ORIGINS=https://admin.example.com,https://www.example.com - -# 信任一层反向代理(Nginx) -TRUSTED_PROXY_COUNT=1 -TRUSTED_PROXY_IPS=127.0.0.1 ``` :::warning 生产环境 @@ -73,28 +67,6 @@ register_cors(app) CORS → 限流 → 上传体积限制 → 操作日志 → DB会话 → Redis → 业务逻辑 ``` -## 代理头处理 - -当应用部署在 Nginx 等反向代理后,需要配置代理信任: - -```bash -# 信任一层代理 -TRUSTED_PROXY_COUNT=1 -TRUSTED_PROXY_IPS=127.0.0.1 -``` - -`TRUSTED_PROXY_COUNT` 说明: - -| 值 | 说明 | -|----|------| -| `0` | 不信任 X-Forwarded-For,使用 request.client.host | -| `1` | 信任一层反向代理 | -| `2` | 信任两层代理 | - -:::tip 安全提示 -仅当应用部署在反向代理后才设置 `TRUSTED_PROXY_COUNT>=1`。如果应用被直接暴露(如 Docker bridge 映射端口),设置代理信任会导致客户端伪造 XFF 绕过限流。 -::: - ## 总结 CORS 跨域配置具备以下特点: @@ -102,7 +74,6 @@ CORS 跨域配置具备以下特点: ``` 1. 环境变量配置:通过 CORS_ALLOWED_ORIGINS 灵活配置允许的源 2. 开发/生产分离:开发环境允许所有源,生产环境指定域名 -3. 代理头支持:TRUSTED_PROXY_COUNT 配置代理信任层数 -4. 安全默认:未配置时允许所有源但不携带凭证 -5. 最外层注册:确保所有响应都带 CORS 头 +3. 安全默认:未配置时允许所有源但不携带凭证 +4. 最外层注册:确保所有响应都带 CORS 头 ``` diff --git a/DjangoAdmin_FastAPI_NaiveVue/zh/volume4/tools/ip-ua.md b/DjangoAdmin_FastAPI_NaiveVue/zh/volume4/tools/ip-ua.md index fe1c287..784173a 100644 --- a/DjangoAdmin_FastAPI_NaiveVue/zh/volume4/tools/ip-ua.md +++ b/DjangoAdmin_FastAPI_NaiveVue/zh/volume4/tools/ip-ua.md @@ -12,9 +12,9 @@ ▼ ┌──────────────────────────────────────────────────────┐ │ get_client_ip(request) │ -│ ├─ 直连对端命中可信代理白名单? │ -│ │ ├─ 是 → 从 X-Forwarded-For 取真实客户端 IP │ -│ │ └─ 否 → request.client.host(fail-closed) │ +│ ├─ X-Forwarded-For 存在? │ +│ │ ├─ 是 → 取最左侧 IP(真实客户端 IP) │ +│ │ └─ 否 → request.client.host │ │ └─ client 为 None → 回退 127.0.0.1 │ └──────────┬───────────────────────────────────────────┘ │ @@ -53,11 +53,8 @@ def get_client_ip(request) -> str: """ 获取客户端真实IP地址 - 仅当直连对端命中 TRUSTED_PROXY_IPS 白名单且 TRUSTED_PROXY_COUNT>0 时才信任 - X-Forwarded-For 头: - - TRUSTED_PROXY_COUNT==0 或 TRUSTED_PROXY_IPS 未配置 / 对端不命中:不信任代理头, - 直接使用 request.client.host(fail-closed,防止被直接暴露时伪造 XFF) - - 命中白名单:从 X-Forwarded-For 右侧取倒数第 N 个 IP 作为真实客户端IP + 优先从 X-Forwarded-For 头取最左侧IP(即真实客户端IP), + 无代理头时回退到 request.client.host。 """ ``` @@ -65,60 +62,41 @@ def get_client_ip(request) -> str: ### 代理链解析原理 -当应用部署在 Nginx/HAProxy 等反向代理之后,`request.client.host` 拿到的是代理 IP 而非真实客户端 IP。此时需要从 `X-Forwarded-For`(XFF)头提取真实 IP。 +当应用部署在 Nginx/CDN 等反向代理之后,`request.client.host` 拿到的是代理 IP 而非真实客户端 IP。此时从 `X-Forwarded-For`(XFF)头取最左侧 IP 即为真实客户端 IP。 ``` 客户端 (1.2.3.4) │ ▼ -外层代理 (10.0.0.1) ← 追加 XFF: 1.2.3.4 +CDN (10.0.0.1) ← 追加 XFF: 1.2.3.4 │ ▼ -内层代理 (10.0.0.2) ← 追加 XFF: 1.2.3.4, 10.0.0.1 +Nginx ← 透传 XFF: 1.2.3.4(不追加) │ ▼ FastAPI 应用 - request.client.host = 10.0.0.2 X-Forwarded-For = "1.2.3.4, 10.0.0.1" + get_client_ip → 取最左侧 → "1.2.3.4" ``` -**取值逻辑**:XFF 最右侧由受信代理追加,客户端可控的伪造条目只可能出现在左侧。跳过最右侧 `TRUSTED_PROXY_COUNT` 个代理条目,取真实客户端 IP。 +**取值逻辑**:XFF 最左侧由 CDN/反代追加的真实客户端 IP,右侧为代理节点 IP。直接取最左侧即可。 -### 安全设计:fail-closed +:::warning Nginx 配置注意 +Nginx 代理配置应使用 `$http_x_forwarded_for`(透传上游值)而非 `$proxy_add_x_forwarded_for`(会追加本机 IP),避免 XFF 条目数不可控: + +```nginx +proxy_set_header X-Forwarded-For $http_x_forwarded_for; +``` +::: + +### 降级策略 | 场景 | 行为 | |------|------| -| `TRUSTED_PROXY_COUNT=0` | 不信任任何代理头,直接返回 `request.client.host` | -| `TRUSTED_PROXY_IPS` 未配置或为空 | 同上,fail-closed | -| 直连对端不在白名单中 | 同上(防止应用被直接暴露时客户端伪造 XFF) | -| 对端命中白名单 + XFF 条目少于代理层数 | 回退到 XFF 最右侧条目(避免拿到代理 IP) | +| X-Forwarded-For 存在 | 取最左侧 IP | +| X-Forwarded-For 为空 | 返回 `request.client.host` | | `request.client` 为 None | 回退到 `127.0.0.1` | -### 代理配置 - -在 `.env` 中配置可信代理: - -```bash -# 信任的代理层数(0 = 不信任代理头) -TRUSTED_PROXY_COUNT=1 - -# 可信代理IP白名单(逗号分隔) -TRUSTED_PROXY_IPS=127.0.0.1 -``` - -**常见部署配置**: - -| 部署方式 | TRUSTED_PROXY_COUNT | TRUSTED_PROXY_IPS | -|:---------|:----|:----| -| 直连(无代理) | `0` | 留空 | -| 单层 Nginx | `1` | `127.0.0.1`(本机)或 Nginx 内网 IP | -| Nginx + CDN | `2` | Nginx 内网 IP | -| Docker bridge 映射端口 | `0` | 留空(容器外为客户端直连) | - -:::warning 安全提示 -如果应用被直接暴露在公网(如 Docker bridge 映射端口、未走 Nginx),务必保持 `TRUSTED_PROXY_COUNT=0` 或 `TRUSTED_PROXY_IPS` 留空。否则客户端可随意伪造 XFF 首部,绕过基于 IP 的登录锁定和请求限流。 -::: - ## 二、IP 地理位置查询 位于 `src/utils/ip2region.py`,基于 [ip2region](https://github.com/lionsoul2014/ip2region) 离线数据库,无需网络请求。 @@ -406,17 +384,21 @@ if count and int(count) >= CAPTCHA_RATE_LIMIT: ## 五、配置参考 -### 完整 .env 配置 +### Nginx 代理配置 + +```nginx +location /api/ { + proxy_pass http://127.0.0.1:8031/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + # 透传上游X-Forwarded-For(不追加本机IP);无上游头时回退到直连IP + proxy_set_header X-Forwarded-For $http_x_forwarded_for; +} +``` + +### 请求限流配置(.env) ```bash -# ============================================================ -# 可信代理配置 -# ============================================================ -# 信任的代理层数(0 = 不信任代理头,直接使用 request.client.host) -TRUSTED_PROXY_COUNT=1 -# 可信代理IP白名单(逗号分隔,仅白名单内的直连对端才信任 XFF) -TRUSTED_PROXY_IPS=127.0.0.1 - # ============================================================ # 请求限流配置 # ============================================================ @@ -436,7 +418,7 @@ RATE_LIMIT_KEY_PREFIX=rl: **原因**:本地直连无代理,`request.client.host` 就是 `127.0.0.1`。 -**说明**:这是正常行为。生产环境配置 `TRUSTED_PROXY_COUNT` 和 `TRUSTED_PROXY_IPS` 后即可获取真实客户端 IP。 +**说明**:这是正常行为。生产环境部署在 Nginx/CDN 后会从 `X-Forwarded-For` 获取真实客户端 IP。 ### 地理位置显示为"未知" @@ -460,7 +442,7 @@ RATE_LIMIT_KEY_PREFIX=rl: 1. 检查 `.env` 中 `RATE_LIMIT_ENABLED=True` 2. 检查 Redis 连接是否正常 -3. 检查 `TRUSTED_PROXY_COUNT` 配置——如果限流基于 `request.client.host`(代理 IP),所有请求会共享同一 IP,导致限流提前触发或形同虚设 +3. 检查 Nginx 是否正确透传 `X-Forwarded-For`——如果限流基于代理 IP 而非真实客户端 IP,所有请求会共享同一 IP,导致限流提前触发或形同虚设 ### 如何更新 ip2region 数据库 @@ -470,7 +452,7 @@ RATE_LIMIT_KEY_PREFIX=rl: | 模块 | 文件 | 核心能力 | 降级策略 | |:-----|:-----|:---------|:---------| -| IP 获取 | `src/utils/ip.py` | 从 Request 提取真实客户端 IP,支持可信代理白名单 | client 为 None 时回退 `127.0.0.1` | +| IP 获取 | `src/utils/ip.py` | 从 Request 提取真实客户端 IP(优先 X-Forwarded-For 最左侧) | client 为 None 时回退 `127.0.0.1` | | 地理位置 | `src/utils/ip2region.py` | 基于 ip2region.xdb 离线查询,零网络依赖 | 异常/畸形/内网统一降级返回 `"未知"` 或 `"内网IP"` | | UA 解析 | `src/utils/ua.py` | 正则匹配提取操作系统和浏览器 | UA 为空时返回 `('', '')` | diff --git a/DjangoAdmin_FastAPI_NaiveVue/zh/volume7/testing.md b/DjangoAdmin_FastAPI_NaiveVue/zh/volume7/testing.md index 0743553..cccfd81 100644 --- a/DjangoAdmin_FastAPI_NaiveVue/zh/volume7/testing.md +++ b/DjangoAdmin_FastAPI_NaiveVue/zh/volume7/testing.md @@ -39,7 +39,7 @@ tests/ │ │ ├── test_base_service_page.py │ │ └── ... │ ├── utils/ # 工具函数测试 -│ │ ├── test_ip_trusted_proxy.py +│ │ ├── test_ip.py │ │ ├── test_ip2region.py │ │ └── ... │ └── scripts/ # 脚本测试 @@ -256,12 +256,11 @@ def test_is_private_ip_private_ranges(): assert _is_private_ip('8.8.8.8') is False ``` -### 代理白名单测试 +### IP 获取测试 ```python -# tests/unit/utils/test_ip_trusted_proxy.py +# tests/unit/utils/test_ip.py import types -from utils import ip as ip_mod from utils.ip import get_client_ip @@ -273,32 +272,25 @@ def _make_request(client_host, xff=None): return req -def test_proxy_count_zero_ignores_xff(monkeypatch): - """TRUSTED_PROXY_COUNT=0:不信任代理头""" - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 0) - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'127.0.0.1'})) - assert get_client_ip(_make_request('203.0.113.9', xff='1.2.3.4')) == '203.0.113.9' - - -def test_peer_not_in_whitelist_rejects_xff(monkeypatch): - """直连对端不在白名单 → XFF 被忽略""" - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1) - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'})) - assert get_client_ip(_make_request('203.0.113.9', xff='1.2.3.4')) == '203.0.113.9' - - -def test_peer_in_whitelist_trusts_xff(monkeypatch): - """单层代理:对端在白名单 → 信任 XFF""" - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1) - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'})) +def test_xff_single_ip(): + """XFF 只有一个 IP → 直接返回""" assert get_client_ip(_make_request('10.0.0.1', xff='1.2.3.4')) == '1.2.3.4' -def test_peer_in_whitelist_spoofed_left_entry_ignored(monkeypatch): - """客户端伪造条目只会在 XFF 左侧""" - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1) - monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'})) - assert get_client_ip(_make_request('10.0.0.1', xff='6.6.6.6, 1.2.3.4')) == '1.2.3.4' +def test_xff_multiple_ips_returns_leftmost(): + """XFF 多个 IP → 取最左侧(真实客户端 IP)""" + assert get_client_ip(_make_request('10.0.0.1', xff='1.2.3.4, 10.0.0.1')) == '1.2.3.4' + + +def test_no_xff_falls_back_to_client_host(): + """无 XFF 头 → 回退到 request.client.host""" + assert get_client_ip(_make_request('203.0.113.9')) == '203.0.113.9' + + +def test_no_client_falls_back_to_localhost(): + """client 为 None → 回退到 127.0.0.1""" + req = types.SimpleNamespace(client=None, headers={}) + assert get_client_ip(req) == '127.0.0.1' ``` ### service 层公共 fixture