AI 网关的密钥边界:Kong Higress APISIX 横评
AI 网关三产品同陷密钥危机:Kong 让请求头决定密钥去向;APISIX漏声明加密字段,etcd 落盘明文;Higress 客户端可控,配套 ai-gateway-audit.py 一键自查。
复现环境: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 i
# 漏洞 # 渗透测试 # 网络安全 # 漏洞分析 # 安全管理
已在FreeBuf发表 0 篇文章
本文为 独立观点,未经授权禁止转载。
如需授权、对文章有疑问或需删除稿件,请联系 FreeBuf 客服小蜜蜂(微信:freebee1024)
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
相关推荐
- 0 文章数
- 0 关注者

文章目录
写在前面
一、把密钥当成一个独立对象
- 按老办法审的天花板
- 第一版结论为什么站不住
- 换个切法:盯住密钥本身
- 适用边界
二、密钥存下来是明文还是密文
- APISIX:同族六个插件里唯一漏声明的那个
- 把这条做实:同一条路由,两个插件的落盘形态不一样
- Kong OSS:encrypted这个属性,全代码没人读
三、一个请求头就能改掉密钥的去向
- 配置里的字符串会被请求展开
- 主机取自 URL,密钥取自配置
- 实测:密钥落到了我指定的主机
- 控制组:不带那个头会怎样
- 为什么不能简单报成 SSRF
- 设计意图没问题,问题在取值来源
- 还剩下两类取值来源
四、被当成内部信号的那个头
- 一个被当成内部信号的头
- 那段逻辑在保护什么
- 光看这一小段判断不了
- 把这条做实:伪造的头在什么条件下能活到下游
- 从 panic 修复找到的另一条路
- 崩溃没了,换成了静默放行
五、证据到哪一步,哪些是我否证掉的
- 证据强度分三档
- 想过但没成立的方向
- 抹掉这些假设之后剩下什么
六、工具:把这套切法做成三个检查
- 这套切法可以机械化
- 它在真目标上跑出来什么
- 工具自己出过的两次误报
- 它的局限
七、修复建议
- 声明和实现对齐,靠 CI 兜住
- 上游地址别交给字符串模板
- 把那个头列进 internal_only_headers
八、这条线是怎么串起来的