受信任的代理与客户端 IP

几乎每个部署都会在网关前面挡上点什么——CDN、反向代理、隧道、负载均衡器。一旦如此,网关看到的 socket 对端就是代理而不是调用方,而调用方的地址只残留在一个请求头里,任何人也都能手工把它设成别的值。

apps/backend/serving/utils/request_ip.py 是决定该相信哪个地址的唯一去处。下游的一切——请求日志、按 IP 的限流、认证失败黑名单、粘性路由亲和性,以及写进 api_logs 的行——读的都是它给出的答案。本页说明这个模块做什么、如何配置它,以及最终会存下什么。

两个信任开关

除非你明确表态,转发头里的任何内容都不被信任。有两个互相独立的环境变量控制这件事,它们断言的是两个不同的事实。

变量

设为 1 意味着断言

出厂默认

TRUST_PROXY_HEADERS

网关前面某个受信任的代理会覆写 X-Forwarded-For / X-Real-IP

.env.example 里是 0deploy/docker/docker-compose.yml 把 backend 服务默认设为 1

TRUST_CLOUDFLARE_HEADERS

紧邻网关的那一跳代理是 Cloudflare,因此 CF-Connecting-IP 由边缘写入,可以作准。

两处都是 0——需要显式开启

只有当 TRUST_PROXY_HEADERS 同样为 1 时才会读取 TRUST_CLOUDFLARE_HEADERS;单独设置它不起任何作用。

把它们分开,是因为它们是两件不同的事实。一个并非 Cloudflare 的代理,完全可能把 X-Forwarded-For 重写得很好,却原封不动地转发客户端自带的 CF-Connecting-IP——凭着泛泛的代理信任就去相信 Cloudflare 的那个头,等于把已经在 X-Forwarded-For 这条路上拒掉的伪造机会重新交回给调用方。如果这个服务前面挡着的不是 Cloudflare,就让 TRUST_CLOUDFLARE_HEADERS=0 保持不变

警告

开启任一开关之前,先把源站限制好。只要一个请求不经过你所信任的那个代理就能抵达源站,上面这些头就统统由攻击者控制。就 Cloudflare 而言,「Full (strict)」TLS 并不能阻止这件事——它验证的是源站对 Cloudflare 的身份,而不是 Cloudflare 对源站的身份。在源站强制启用 Authenticated Origin Pulls 或边缘 IP 白名单之前,任何人只要知道源站地址,就能直接向它发送伪造的 CF-Connecting-IP。同样的告诫也适用于 TRUST_PROXY_HEADERSX-Forwarded-For

TRUST_PROXY_HEADERS=0 时,解析会直接短路到 socket 对端,任何请求头都不影响结果——对于直接暴露在外的网关,这才是正确行为。原始请求头的值仍会被记在返回的 ClientIpInfo 上并写进日志,因此配置错了是看得见的,而不是悄无声息。

解析顺序

get_client_ip_info() 返回一个冻结的 ClientIpInfo,其中有解析出的 client_ip、解析时所依据的 peer_ip,以及标明是哪一级胜出的 source 标签。第一个命中的即为结果:

  1. CF-Connecting-IP——仅当 TRUST_CLOUDFLARE_HEADERS=1 时。source 为 cf-connecting-ip;若构成了互相印证的 Pseudo IPv4 组合(见下文),则为 cf-connecting-ipv6。注意这一级原样返回请求头的值:X-Forwarded-For 那几级所做的可路由性过滤在这里不做,因为这里假定该值是边缘写入的。

  2. 第一个可路由X-Forwarded-For,从左往右扫描。source 为 x-forwarded-for

  3. X-Real-IP,前提是它可路由。source 为 x-real-ip

  4. 套接字对端request.client.host;当 Starlette 报告没有 client 时,是字面量 "unknown")。来源标为 socket。它既是直连的情形,也是没有任何可用转发跳时的兜底。和第 1 级一样,它经过可路由性过滤——所以在 TRUST_PROXY_HEADERS 关闭、nginx 后面跑 Docker 的常见部署里,这一级报出来的是网桥地址。那是一类另外的、早已存在的污染,过滤器有意不去管它;只有第 2、3 级会被过滤。

CF-Connecting-IP 排在 X-Forwarded-For 之前是有意为之:Cloudflare 每个请求都会覆写自己的那个头,但对客户端自带的 X-Forwarded-For 只是追加,所以从那里读最左边一项,等于让任何调用方都能指定网关用来记日志和限流的地址。

什么算可路由

_is_reportable_ip() 会拒绝任何无法解析的值,以及回环、链路本地、组播和未指定地址,还有这些网段:

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)
fc00::/7          (IPv6 unique local)

IPv4-mapped 的 IPv6 字面量按其内嵌的 IPv4 地址判定,因此映射过来的私有对端同样会被拒绝。

跳过这些跳正是从左往右扫描的意义所在:某个中间设备若把自己的内网地址插在最左边一跳,不这样跳过的话,那个内网地址就会被当作客户端上报——并写进日志。

这份清单是显式写死的,而不是交给 ipaddress.is_private / is_global,因为在 CPython 3.12.4 与 3.13 之间,它们把文档用途(192.0.2.0/24198.51.100.0/24203.0.113.0/24)和基准测试用途(198.18.0.0/15)的网段重新分了类;硬编码稳定的 RFC 网段,可以让 IP 解析不依赖解释器版本。

这么做的后果值得明说,因为它正是显式清单换来的东西:在这个过滤器下,文档用途或基准测试用途的地址是可路由的,会被当作客户端地址报出来。这也是为什么下面拿 ::ffff:192.0.2.1 当例子是成立的,尽管 is_private 会把它判掉。

已知限制

最左边一项被伪造成公网地址的 X-Forwarded-For 仍会被照单全收。要正确地剥掉它,需要配置一组受信任代理的 CIDR,网关才能把自己的代理和客户端自带的跳区分开;而改取最右边的公网跳,又会把所有藏在同一个共享公网中间设备后面的客户端全部归错。模块自己的 docstring 里写明了这一点,这项加固尚未实现。如果你需要它,要么在你的代理上终结 X-Forwarded-For(覆写而不是追加),要么只信任一个你确知代理会覆写的边缘请求头。

Cloudflare Pseudo IPv4

在第 1 级内部,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 关闭时这个头是缺失而不是被清空,于是任何调用方都能自己塞一个进来。因此 _pseudo_ipv4_origin() 只在三个条件同时成立时才采信它: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 的字面量)→ 原样返回。

日志和分析保留完整地址,折叠的只是分桶。在当前这个版本里,调用 normalize_ip_bucket() 的有:注册限流、登录限流、重复认证失败黑名单,以及路由亲和性。

derive_affinity_key() 是用于粘性路由的变体。它按「指向单个调用方的精确程度」依次尝试各种调用方身份:先是所提供 API key 的哈希,然后是 inference 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_ippeer_ipip_sourcex_forwarded_forx_real_ipcf_connecting_ipcf_connecting_ipv6,以及 user_agenthostoriginrefererrequest_idsession_id

把原始请求头和判定结果放在一起,才让一个错误的地址是可以诊断的:你能看到是哪一级命中的,以及其他几级各自说了什么。

DEBUG 级别下,中间件还会额外输出一行 http_request_headers,带上全部请求头,每个截断到 256 个字符,并把 authorizationx-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_eventsapps/backend/serving/storage/postgres_operational.py)为每次登录尝试存下 ipuser_agent,以及这次尝试的结果。

保留期由你自己定

本仓库里没有任何东西会按定时器让这两张表过期。现有的删除操作全部由运维方主动发起:

  • DELETE /admin/login-events?older_than_days=N——按时间清理 login_events

  • DELETE /admin/login-events?user_id=...——清理某一个用户的登录事件。

  • hard_delete_user_data(user_id)——抹掉该用户的 api_logs 行。

  • delete_recent_error_requests(hours=N)——删掉近期的错误行。

如果你的部署要受某种数据保护法规约束,或者你只是不想无限期地保存客户端地址,那就必须自己决定并实现一套保留策略。本项目不附带这样的策略,也不替你选一个默认值。

有两个开关能从源头上减少需要保留的数据:

  • 在前面没有代理的情况下把两个信任开关都留在 0,那么被记录下来的就只有 socket 对端。

  • prompt 和响应的内容由 DB_STORE_FULL_CONTENT 单独控制(默认 false,即对内容做哈希而不是原样存下;见 apps/backend/serving/config/settings.py)。

验证你的配置

tests/unit/utils/test_request_ip.py 覆盖解析顺序表、Pseudo IPv4 的互相印证以及分桶规则;tests/unit/middleware/test_request_log.py 覆盖日志字段。运行方式:

uv run pytest tests/unit/utils/test_request_ip.py tests/unit/middleware/test_request_log.py

要检查一个正在运行的网关,就发一个带着刻意离谱的转发头的请求,然后看产生的那行日志里的 ip_source——它会告诉你网关实际相信的是哪一级。