可信代理与客户端 IP
几乎每个部署都会在网关前面再加一层:CDN、反向代理、隧道或负载均衡器。这样一来,网关看到的套接字对端就是这一层代理,而不是调用方;调用方的地址只能靠请求头带过来,而这个请求头任何人都可以手工伪造。
所以,网关只采信你授权过的对端转发来的地址。先看网关前面放的是什么,在下表中找到对应的那一行:
网关前面是什么 |
需要设置 |
|---|---|
什么都没有:客户端直连 |
什么都不用设。转发头默认会被忽略。 |
地址已知的反向代理或负载均衡器 |
|
Cloudflare,直接连到网关 |
|
先经过 Cloudflare,再经过你自己的代理,然后到网关 |
同上,但改为 |
私有网络(如 |
|
本页其余部分会解释每一项设置、地址是怎么解析出来的,以及哪些内容会写进日志、哪些会存下来。代码里做这个判断的只有 apps/backend/serving/utils/request_ip.py 这一处。
底层服务器
要让这个模块成为唯一做判断的地方,运行它的服务器就不能抢先做同样的判断。uvicorn 自带一套代理头处理逻辑,而且默认开启:除非明确关掉,只要 TCP 对端在 --forwarded-allow-ips 之内(默认 127.0.0.1,也可以通过环境变量 FORWARDED_ALLOW_IPS 设置),它就会根据 X-Forwarded-For / X-Forwarded-Proto 改写 request.client(也就是应用看到的套接字对端)和 URL 的 scheme。这一步发生在任何应用代码运行之前,比本页介绍的所有逻辑都早。如果不关掉它,request_ip.py 拿到的「套接字对端」就已经是伪造过的,而 TRUST_PROXY_HEADERS=0 承诺的恰恰是任何请求头都影响不了结果。要是再设成 --forwarded-allow-ips "*",每个请求的套接字对端都会变成 X-Forwarded-For 最左边的那一项,也就是调用方随便写的值。
因此,本仓库的每一份启动配置(deploy/docker/Dockerfile.backend 和两个 systemd 单元)都显式传了 --no-proxy-headers,只要有一处漏掉,就会有测试失败。如果你用自己的进程管理器运行网关,也要在那里加上 --no-proxy-headers:光是不写 --proxy-headers 还不够,因为它默认就是开启的。
服务器从不解析转发头,这带来两个后果:
在负责 TLS 终结的代理后面,
request.url.scheme始终是http。请设置BASE_URL(见.env.example),这样注册验证邮件、密码重置邮件里的绝对 URL 才不会沿用请求本身的 scheme。下文的
peer_ip又变回了真正的 TCP 对端。本页后面把它当作无法伪造的基准,靠的就是这一点。
信任配置
请求只要没有经过可信代理就到达了源站,其中的转发头(X-Forwarded-For、CF-Connecting-IP 等)就完全由攻击者控制。因此,任何请求头要影响结果,都必须先经过显式授权。
可信代理
trusted_proxies 是一个逗号分隔的 CIDR 列表。只有落在这些网段里的对端,才能通过 X-Forwarded-For / X-Real-IP 告诉网关请求原本来自哪里:
# Example: a single nginx reverse proxy at a known internal address
TRUSTED_PROXIES=172.19.0.2/32
信任的范围越窄越好。只信任那几个具体的代理 IP,也就是接收公网连接、再转发给网关的那几台代理。不要信任大片的内部子网,否则子网里的任何主机都能在任意请求里随意指定客户端身份。
CIDR 无效时,网关会在启动阶段直接报错退出。
私有网络里的直连客户端
如果套接字对端是 RFC1918、CGNAT 或 ULA 这类私有地址,默认不会把它解析成客户端地址,因为这类地址可能是容器桥接网络或共享的内部代理,而不是某一个具体的客户端。如果你的部署确实有客户端经由这些网络直连,就用 TRUSTED_DIRECT_CLIENT_NETWORKS 只授权这些客户端所在的 CIDR。这项设置不会授权转发头,也不能包含共享代理所在的网络。
TRUSTED_DIRECT_CLIENT_NETWORKS=10.42.0.0/16,100.64.0.0/10,fd00:42::/64
信任开关
有三个环境变量控制如何处理这些请求头:
TRUST_PROXY_HEADERS=1:处理X-Forwarded-For和X-Real-IP头,但只有直接对端在trusted_proxies里时才生效。TRUST_CLOUDFLARE_HEADERS=1:处理CF-Connecting-IP,但只有直接对端在trusted_cloudflare_networks里时才生效。TRUST_X_REAL_IP=1:单独开启X-Real-IP这种声明方式。默认忽略这个头;只要请求里有 XFF,就根本不会看它。
三个开关默认都是 0(关闭)。如果对应的网络列表为空,光打开开关不会有任何效果——这是默认的 fail-closed 行为:配置不全,就什么都不信任。TRUST_CLOUDFLARE_HEADERS=1 还要求同时打开总开关 TRUST_PROXY_HEADERS=1,并且 Cloudflare 授权网络列表不能为空;不满足这些条件的组合,启动时会直接报配置错误。TRUST_X_REAL_IP=1 同样要求 TRUST_PROXY_HEADERS=1,用的也是同一份可信代理网络列表,但只在请求里完全没有 XFF 时才会看它。
为什么需要这些限制
没有这些关卡,客户端就能发送 X-Forwarded-For: <anything>,而网关会按客户端自己挑的地址记日志、做限流。明确列出哪些对端可以转发地址,就堵上了这个漏洞;没列出的一律忽略。
解析顺序
get_client_ip_info() 返回一个不可变的 ClientIpInfo,包含解析出的 client_ip、解析时依据的 peer_ip,以及一个 source 标签,标明最终是哪一级胜出。trusted_proxy_headers 字段表示这次请求里是否有转发身份头真正得到了授权;更细的 trusted_forwarded_headers 和 trusted_cloudflare_headers 字段则说明用的是哪一种授权。
解析过程严格遵守信任边界,依次是:
对端未经授权——完全忽略转发头。套接字对端可路由就用它,否则返回
"unknown"。CF-Connecting-IP——只有对端在trusted_cloudflare_networks里并且TRUST_CLOUDFLARE_HEADERS=1时才使用。哪些网络有权提供 Cloudflare 头,必须由运维人员显式配置;普通的反向代理并不能让这个头变得可信。如果 Pseudo IPv4 的两个头能互相印证,就从CF-Connecting-IPv6取真实的 IPv6 地址。X-Forwarded-For——只有对端在trusted_proxies里并且TRUST_PROXY_HEADERS=1时才使用。从右到左逐个检查,跳过可信代理的地址,遇到第一个不可信的地址就停下:如果可路由 → 它就是客户端;
如果不可路由或格式错误 → 返回
"unknown"。绝不再往左读——再往左就越过了信任边界,读到的是攻击者能控制的值。
X-Real-IP——只有TRUST_X_REAL_IP=1、对端可信、地址可路由,并且请求里完全没有 XFF 时才使用。只要请求带了 XFF,即使这条链格式错误、有歧义、过长,或者只有可信代理的地址,解析也会就此终止,绝不会退而改用这第二种声明方式。套接字对端——用于直连的情况,或者在没有任何可用的转发地址时兜底。如果对端本身不可路由,结果就是
"unknown"。
请求日志会把 ip_source 和套接字对端、原始转发字段记在一起。要监测格式错误的请求头或防护降级是否突然增多,以结构化事件 client_ip_resolution_unresolved 为准;它带的结构化元数据 event="client_ip_resolution" 用来标识这次解析事件,但不会因此把未解析出的对端当成客户端身份。
示例:多跳链
XFF: "1.2.3.4, fdbd:dc02::153, 10.0.0.1, 172.16.0.5"
trusted_proxies: 172.16.0.5 (peer), 10.0.0.1
从右到左遍历:
地址 |
可信? |
可路由? |
处理 |
|---|---|---|---|
172.16.0.5 |
是(对端) |
否 |
跳过(可信) |
10.0.0.1 |
是 |
否 |
跳过(可信) |
fdbd:dc02::153 |
否 |
否(ULA) |
停止追溯 → unknown |
不会再往左读到 1.2.3.4——那就越过信任边界了。
示例:攻击者在前面添加伪造地址
XFF: "8.8.8.8, 1.2.3.4, 8.8.4.4, 172.16.0.5"
trusted_proxies: 172.16.0.5 (peer)
从右到左遍历:
地址 |
可信? |
可路由? |
处理 |
|---|---|---|---|
172.16.0.5 |
是 |
否 |
跳过(可信) |
8.8.4.4 |
否 |
是 |
当作客户端返回 |
攻击者塞在前面的 8.8.8.8 和 1.2.3.4 根本不会被读到。换成以前那种信任最左边地址的模型,返回的就是 8.8.8.8。
重复字段一律按 fail-closed 处理,宁可拒绝也不猜。请求里如果有多行 X-Forwarded-For,会先按它们在报文里的顺序拼接起来再解析,空条目也会保留。X-Real-IP 和 CF-Connecting-* 这类只该出现一次的字段,一旦重复就直接拒绝;解析器不会按框架给出的顺序去挑第一个或最后一个。从可信一侧最多检查 32 跳。第一个不可信边界左边的条目一律忽略;如果要检查超过 32 跳才能走完来源链,同样按 fail-closed 处理,判为无法解析。
什么算可路由
解析器会拒绝所有格式不合法的值,以及回环、链路本地、组播和未指定地址,还有下面这些网段:
10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 (RFC 1918)
100.64.0.0/10 (CGNAT, RFC 6598)
192.0.2.0/24 198.51.100.0/24 203.0.113.0/24 (TEST-NET)
198.18.0.0/15 (benchmarking) 240.0.0.0/4 (reserved Class E)
fc00::/7 (IPv6 unique local)
2001:db8::/32 (IPv6 documentation)
64:ff9b:1::/48 100::/64 100:0:0:1::/64 (IPv6 special-use)
2001:2::/48 3fff::/20 5f00::/16 (IPv6 special-use)
fec0::/10 (deprecated IPv6 site-local)
IPv4-mapped 形式的 IPv6 字面量,按其中内嵌的 IPv4 地址来判断。如果这样的对端映射的是私有地址,只有内嵌地址落在 TRUSTED_DIRECT_CLIENT_NETWORKS 里时才会接受。
这份清单是根据 IANA 整理、明确写死的快照,没有直接交给 ipaddress.is_private / is_global 判断,因为不同的 CPython 版本会对特殊用途网段重新归类。IANA 的特殊用途地址注册表有变化时,要同步更新这份清单。
返回「unknown」的情况
网关确定不了可信且可路由的客户端地址时,会返回 "unknown"(同时 ClientIpInfo.resolved=False),而不是拿一个内部地址冒充客户端地址。这样做是对的:如果套接字对端是 Docker 网桥,又没有可信的转发来源,"unknown" 比 172.19.0.1 更有用。
下游凡是以 client_ip 为键的逻辑(限流、认证失败黑名单、路由亲和性),都必须检查 ClientIpInfo.resolved,并显式处理 "unknown"——这是正常结果,不是错误。新的调用方一律不把未解析出的来源传给 normalize_ip_bucket(),否则互不相关的调用方会被归到同一个键上。已弃用的辅助函数 get_client_ip_bucket() 保留了旧行为,新写的防护或亲和性代码不要再用它。
Cloudflare Pseudo IPv4
在第 2 级里,CF-Connecting-IPv6 优先于 CF-Connecting-IP(它本身不单独算一级),但前提是两者能互相印证。
只有把 Pseudo IPv4 设为「Overwrite headers」时,Cloudflare 才会带上 CF-Connecting-IPv6。这种模式下,CF-Connecting-IP 里放的是根据访问者地址生成的一个合成 Class E 地址(240.0.0.0/4),而不是访问者的真实地址。如果优先用这个合成地址,IPv6 客户端就会走 IPv4 的分桶逻辑,每个轮换出来的隐私地址都各占一个限流桶,下文按 /64 归组的做法也就失效了。
互相印证之所以重要,是因为 Pseudo IPv4 关闭时这个头是缺失而不是被清空,于是任何调用方都能自己塞一个进来。因此网关只在三个条件同时成立时才采信它:IPv6 那个头能解析成 IPv6,并且 CF-Connecting-IP 能解析成 IPv4,并且这个 IPv4 落在 240.0.0.0/4 之内。第二个值由 Cloudflare 掌控,而真实的客户端地址绝不会取自保留的 Class E 段,所以这个组合无法从外部伪造。其余情况下仍以 CF-Connecting-IP 为准。两个原始请求头都会挂在 ClientIpInfo 上并写进日志,这样事后无论是合成地址还是伪造尝试,都查得到。
分桶:为什么 IPv6 按 /64 归组
一个 IPv6 客户端通常会被分到整段前缀(最少 /64,常常是 /56 或 /48),而 RFC 4941 的隐私地址就在这段前缀内不断轮换。因此完整的 IPv6 地址是个很差的身份键:一个客户端能拿出来的不同地址实际上是无限多的。
凡是需要用一个地址来代表某个调用方的地方,用的都是 normalize_ip_bucket() 给出的归组键:
IPv6 → 它所在的
/64网段(IPV6_BUCKET_PREFIXLEN = 64)。IPv4 → 地址本身。
IPv4-mapped 字面量(
::ffff:192.0.2.1,双栈监听器对 IPv4 对端报告的就是这种形式)→ 内嵌的那个 IPv4 地址。如果按前缀归组,所有 IPv4 客户端都会挤进同一个::/64。任何无法解析的值(包括
"unknown"这个回退值和带 scope 的字面量)→ 原样返回。
日志和分析数据里保留的是完整地址,只有分桶时才归并。注册限流、登录限流、重复认证失败黑名单以及路由亲和性,都是这样给调用方归组的。
derive_affinity_key() 是用于粘性路由的变体。它按精确程度从高到低,依次尝试几种调用方身份:先是请求出示的 API key 的哈希,其次是推理 grant 的 id(grant:<id>),对完全没带凭据的流量则用 ip:<bucket>。它放在这些 IP 辅助函数旁边,而不是挂在某个 router 上,这样每个把请求分发给池化 adapter 的接口面,推导调用方身份的方式都一样。
纯 IPv4 源站上出现 IPv6 客户端是正常的
日志里出现 IPv6 地址,并不意味着源站支持了 IPv6。发布了 AAAA 记录的 CDN 会用 IPv6 接下客户端,再另开一条 IPv4 连接回源,把原始地址放在转发头里带过来。客户端用哪种地址族,和源站用哪种无关。所以对纯 IPv4 源站来说,真正值得意外的是 IPv6 的 peer_ip,而不是 IPv6 的 remote_ip。
会写进日志的内容
apps/backend/serving/servers/middleware/request_log.py 为每个请求输出一行结构化的 http_request,其中既带解析出的地址,也带它的来历:remote_ip、peer_ip、ip_source、x_forwarded_for、x_real_ip、cf_connecting_ip、cf_connecting_ipv6,以及 user_agent、host、origin、referer、request_id 和 session_id。
原始请求头和判定结果记在一起,地址判错了才查得出原因:你能看到是哪一级命中的,其他几级又各自给出了什么。
在 DEBUG 级别下,中间件还会额外输出一行 http_request_headers,带上全部请求头,每个截断到 256 个字符,并把 authorization 和 x-api-key 替换成 ***。
会存进数据库的内容
这个模块的输出并不止步于日志文件,它还会写进数据库。
api_logs.metadata(JSONB 列;见 apps/backend/serving/storage/log_schema.py 以及 apps/backend/serving/storage/postgres_log.py 里的插入语句)为每个请求存下这些字段:
接口面 |
处理器 |
存下的 IP 相关字段 |
|---|---|---|
|
|
|
|
|
|
被拒绝的请求(在开启拒绝日志时) |
|
|
Anthropic 这个接口面存下的是完整来历,而不只是判定结果,所以对某个地址有疑问时,可以根据这一行重新推导。两个 CF-Connecting-* 头会写进日志,但在任何接口面上都不会存进数据库。
login_events(apps/backend/serving/storage/postgres_operational.py)为每次登录尝试存下 ip 和 user_agent,以及这次尝试的结果。
保留期由你自己定
本仓库不会定时清理这两张表。现有的删除操作都要由运维人员手动发起:
DELETE /admin/login-events?older_than_days=N——按时间清理login_events。DELETE /admin/login-events?user_id=...——清理某一个用户的登录事件。POST /admin/users/{user_id}/hard-delete——删除这个用户,连同他的api_logs行。只有开启了永久删除,这个接口才能用;见删除账号相关的密钥。POST /admin/recent-requests/clear-errors——删除最近的错误记录。
如果你的部署要受某种数据保护法规约束,或者你只是不想无限期地保存客户端地址,那就必须自己决定并实现一套保留策略。本项目不附带这样的策略,也不替你选一个默认值。
有两个开关能从源头上减少需要保留的数据:
前面没有代理时,把
trusted_proxies留空,这样记下来的就只有套接字对端。prompt 和响应的内容另由
DB_STORE_FULL_CONTENT控制。它默认是false,这时 prompt 和响应根本不会存储;见请求日志与隐私。
验证你的配置
tests/unit/utils/test_request_ip.py 覆盖解析表、Pseudo IPv4 互相印证、分桶规则以及对抗性用例(伪造头、多跳链、格式错误输入、全私有链);tests/unit/config/test_trusted_proxies.py 覆盖 CIDR 校验;tests/unit/middleware/test_request_log.py 覆盖日志字段。运行方式:
uv run pytest tests/unit/utils/test_request_ip.py tests/unit/config/test_trusted_proxies.py tests/unit/middleware/test_request_log.py
要检查正在运行的网关,可以故意发一个转发头明显离谱的请求,再看对应那行日志里的 ip_source:它会告诉你网关实际采信的是哪一级。