可信代理与客户端 IP

几乎每个部署都会在网关前面再加一层:CDN、反向代理、隧道或负载均衡器。这样一来,网关看到的套接字对端就是这一层代理,而不是调用方;调用方的地址只能靠请求头带过来,而这个请求头任何人都可以手工伪造。

所以,网关只采信你授权过的对端转发来的地址。先看网关前面放的是什么,在下表中找到对应的那一行:

网关前面是什么

需要设置

什么都没有:客户端直连

什么都不用设。转发头默认会被忽略。

地址已知的反向代理或负载均衡器

TRUST_PROXY_HEADERS=1 和 TRUSTED_PROXIES=<that proxy's address>/32

Cloudflare,直接连到网关

TRUST_PROXY_HEADERS=1、TRUST_CLOUDFLARE_HEADERS=1 和 TRUSTED_CLOUDFLARE_NETWORKS=<Cloudflare's published ranges>

先经过 Cloudflare,再经过你自己的代理,然后到网关

同上,但改为 TRUSTED_CLOUDFLARE_NETWORKS=<your proxy's address>/32;前提是这个代理只接受来自 Cloudflare 的连接,并且会正确处理这个头

私有网络(如 10.x)上的客户端直连

TRUSTED_DIRECT_CLIENT_NETWORKS=<those client ranges>

本页其余部分会解释每一项设置、地址是怎么解析出来的,以及哪些内容会写进日志、哪些会存下来。代码里做这个判断的只有 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

有权提供 Cloudflare 头的对端

CF-Connecting-IP 需要单独授权。普通的可信反向代理,并不能让客户端自带的 CF-Connecting-IP 变得可信。只有确认过这个头只可能由 Cloudflare 写入的运维人员,才应该填写这一项:

# Example: Cloudflare → application directly
# These are Cloudflare's origin-facing IP ranges (the socket peer),
# NOT the visitor addresses in CF-Connecting-IP.
# See: https://developers.cloudflare.com/fundamentals/concepts/cloudflare-ip-addresses/
TRUSTED_CLOUDFLARE_NETWORKS=173.245.48.0/20  # example Cloudflare origin range

TRUSTED_CLOUDFLARE_NETWORKS 里列的是有权按 Cloudflare 的约定提供这些头的直接套接字对端,不一定要包含 Cloudflare 的公网网段。如果拓扑是 Cloudflare → nginx → HybridInference,HybridInference 看到的套接字对端就是 nginx。这种情况下只列 nginx 的确切地址,而且前提是 nginx 只接受来自 Cloudflare 的访问,并且会正确清理或覆盖这个头。Cloudflare 自己也建议只允许 Cloudflare 的地址访问源站,以防有人绕过 Cloudflare 直连源站伪造请求头。

把两者分开,是为了让一台配置错误的普通代理,不会意外地让攻击者伪造的 Cloudflare 头生效。

信任开关

有三个环境变量控制如何处理这些请求头:

  • 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 字段则说明用的是哪一种授权。

解析过程严格遵守信任边界,依次是:

  1. 对端未经授权——完全忽略转发头。套接字对端可路由就用它,否则返回 "unknown"。

  2. CF-Connecting-IP——只有对端在 trusted_cloudflare_networks 里并且 TRUST_CLOUDFLARE_HEADERS=1 时才使用。哪些网络有权提供 Cloudflare 头,必须由运维人员显式配置;普通的反向代理并不能让这个头变得可信。如果 Pseudo IPv4 的两个头能互相印证,就从 CF-Connecting-IPv6 取真实的 IPv6 地址。

  3. X-Forwarded-For——只有对端在 trusted_proxies 里并且 TRUST_PROXY_HEADERS=1 时才使用。从右到左逐个检查,跳过可信代理的地址,遇到第一个不可信的地址就停下:

    • 如果可路由 → 它就是客户端;

    • 如果不可路由或格式错误 → 返回 "unknown"。绝不再往左读——再往左就越过了信任边界,读到的是攻击者能控制的值。

  4. X-Real-IP——只有 TRUST_X_REAL_IP=1、对端可信、地址可路由,并且请求里完全没有 XFF 时才使用。只要请求带了 XFF,即使这条链格式错误、有歧义、过长,或者只有可信代理的地址,解析也会就此终止,绝不会退而改用这第二种声明方式。

  5. 套接字对端——用于直连的情况,或者在没有任何可用的转发地址时兜底。如果对端本身不可路由,结果就是 "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 相关字段

/v1/chat/completions

apps/backend/serving/servers/routers/completions.py

ip, user_agent, referer

/v1/messages, /anthropic/v1/messages

apps/backend/serving/servers/routers/anthropic_messages.py

ip, peer_ip, ip_source, x_forwarded_for, x_real_ip, user_agent, referer

被拒绝的请求(在开启拒绝日志时)

apps/backend/serving/observability/rejection_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:它会告诉你网关实际采信的是哪一级。