安全资讯

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

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


原文链接: AI 网关的密钥边界:Kong Higress APISIX 横评