把Envoy WASM 过滤器用在网关侧做统一签名校验,是近几年 API 网关加固里性价比最高的做法:业务代码一行不改,签名算法、时间窗、防重放规则全部收敛到边缘层,出问题只改网关配置。本文以 proxy-wasm-go-sdk + TinyGo 写一个 HMAC-SHA256 请求签名校验插件,跑通编译、加载、验证、排错全链路。
常见的错误做法是在 Nginx 上用 Lua 拼签名,或者在每个微服务里复制一份校验逻辑。前者受限于 OpenResty 的模块生态,后者一旦某个服务漏改就等于没有防护。Envoy 的 WebAssembly 过滤器把这段逻辑放进一个受控沙箱,配置下发即可生效,还能通过 /stats 直接看到拦截计数。
适用场景
以下三类环境最适合把签名校验下沉到 Envoy WASM 过滤器:
- 多租户 API 网关:对外暴露上千个接口,需要在入口统一校验调用方身份,拒绝未签名的扫描流量。签名通过后再交给后端的 JWT 鉴权,两层互不干扰。
- 开放平台/合作方对接:合作伙伴用 AppKey + AppSecret 签名调用,密钥轮换需要在网关侧灰度,不可能让几十个合作方同时改代码。
- 内部服务网格边界:Istio 的 ingressgateway 本质是 Envoy,同一份 wasm 插件既能挂在边缘网关,也能挂在 sidecar 上,规则完全一致。
不适合的场景也要说清楚:如果只是校验一个固定 API Key,用 Envoy 原生的 envoy.filters.http.header_to_metadata 加 RBAC 就够了,没必要引入 wasm 的运维成本。需要解析请求体做深度校验时,优先考虑 ext_authz 外部服务,wasm 里处理大 body 会撞上内存上限。
前置条件
- Envoy 1.28 及以上,官方镜像自带 V8 wasm 运行时,编入标志为
envoy.wasm.runtime.v8。 - TinyGo 0.30 及以上,或 Go 1.21 配合
GOOS=wasip1 GOARCH=wasm编译。TinyGo 产物体积通常在 1MB 以内,是首选。 proxy-wasm-go-sdk最新稳定版本,ABI 版本需与 Envoy 匹配,不匹配会直接在加载阶段失败。- 一个可用的
openssl,用于本地生成测试签名。 - 网关到后端服务的正常转发链路,签名校验只做前置拦截,不承担鉴权职责。
原理说明
HTTP 过滤器链中的执行位置
Envoy 处理一个请求时按 http_filters 列表顺序调用过滤器。envoy.filters.http.wasm 是一个标准 HTTP 过滤器,它在 onRequestHeaders 阶段拿到完整的请求头,此时请求体还没到达。这个特性决定了签名校验最好只覆盖「方法 + 路径 + 时间戳 + 随机串 + 关键头」,不要去算 body 摘要。
过滤器返回 Continue 时请求继续向后传递,返回 Pause 则挂起等待异步回调,而直接调用 SendHttpResponse 就会在当前层终结请求并返回响应。签名失败时应当立刻终结,不要放行到后端再拒绝,否则后端日志里会堆满无效请求。
proxy-wasm ABI 与沙箱边界
WASM 模块跑在 Envoy 进程内的 V8 沙箱里,不能直接读文件、不能发起任意网络请求,只能通过 ABI 提供的 hostcall 访问请求头、日志、共享数据。这个限制既是安全边界也是能力边界:模块被攻破也拿不到宿主机权限,但反过来,你也无法在插件里查询 Redis 做 nonce 去重。
需要 nonce 去重的场景,正确做法是网关侧只校验时间窗,把 nonce 落库交给后端的 ext_authz 服务。本文的插件用 300 秒时间窗做基础防重放,超出窗口的请求直接拒绝,这也是绝大多数开放平台的通行做法。
签名算法约定
签名串按固定顺序拼接,避免参数顺序歧义:
string_to_sign = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE
用 AppSecret 作为 HMAC 密钥做 SHA256,结果转小写十六进制,放进 X-Signature 头。服务端用同一套规则重算并做常量时间比较,不能直接用 == 比较字符串,否则会泄露时序信息。
操作步骤
步骤一:初始化插件工程
mkdir -p envoy-sign-check && cd envoy-sign-check
go mod init example.com/signcheck
go get github.com/tetratelabs/proxy-wasm-go-sdk@latest
步骤二:编写插件主逻辑
核心只有三个回调:插件启动时读取配置、请求头到达时校验签名、出错时返回 401。注意 OnPluginStart 里读取的 GetPluginConfiguration 返回的是 Envoy 配置里 configuration 字段的原始字节,本文用 JSON 承载密钥与时间窗。
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strconv"
"strings"
"time"
"github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm"
"github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types"
)
type vmContext struct{ types.DefaultVMContext }
func main() { proxywasm.SetVMContext(&vmContext{}) }
func (*vmContext) NewPluginContext(uint32) types.PluginContext { return &pluginCtx{} }
type pluginCtx struct {
types.DefaultPluginContext
secret []byte
window int64
}
type conf struct {
Secret string `json:"secret"`
Window int64 `json:"window"`
}
func (p *pluginCtx) OnPluginStart(pluginConfigurationSize int) types.OnPluginStartStatus {
raw, err := proxywasm.GetPluginConfiguration()
if err != nil {
proxywasm.LogCriticalf("read config failed: %v", err)
return types.OnPluginStartStatusFailed
}
var c conf
if err := json.Unmarshal(raw, &c); err != nil || c.Secret == "" {
proxywasm.LogCritical("invalid plugin configuration")
return types.OnPluginStartStatusFailed
}
p.secret = []byte(c.Secret)
p.window = c.Window
if p.window <= 0 {
p.window = 300
}
return types.OnPluginStartStatusOK
}
func (p *pluginCtx) NewHttpContext(uint32) types.HttpContext {
return &httpCtx{secret: p.secret, window: p.window}
}
type httpCtx struct {
types.DefaultHttpContext
secret []byte
window int64
}
func header(name string) string {
v, err := proxywasm.GetHttpRequestHeader(name)
if err != nil {
return ""
}
return strings.TrimSpace(v)
}
func (h *httpCtx) OnHttpRequestHeaders(int, bool) types.Action {
tsStr := header("x-timestamp")
nonce := header("x-nonce")
sig := strings.ToLower(header("x-signature"))
if tsStr == "" || nonce == "" || sig == "" {
return h.deny("missing signature headers", 401)
}
ts, err := strconv.ParseInt(tsStr, 10, 64)
if err != nil {
return h.deny("bad timestamp", 401)
}
now := time.Now().Unix()
diff := now - ts
if diff < 0 {
diff = -diff
}
if diff > h.window {
return h.deny("timestamp out of window", 401)
}
method, _ := proxywasm.GetHttpRequestHeader(":method")
path, _ := proxywasm.GetHttpRequestHeader(":path")
if i := strings.IndexByte(path, '?'); i >= 0 {
path = path[:i]
}
sts := method + "\n" + path + "\n" + tsStr + "\n" + nonce
mac := hmac.New(sha256.New, h.secret)
mac.Write([]byte(sts))
want := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(want), []byte(sig)) {
return h.deny("signature mismatch", 401)
}
return types.ActionContinue
}
func (h *httpCtx) deny(reason string, code uint32) types.Action {
proxywasm.LogWarnf("sign-check reject: %s", reason)
_ = proxywasm.SendHttpResponse(code,
[][2]string{{"content-type", "application/json"}},
[]byte(`{"code":"SIGN_INVALID","msg":"`+reason+`"}`), -1)
return types.ActionPause
}
步骤三:编译为 wasm
必须指定 -scheduler=none,否则 TinyGo 会引入 goroutine 调度器,Envoy 的 wasm 运行时无法满足其阻塞语义,加载阶段就会报错。
docker run --rm -v "$PWD":/src -w /src tinygo/tinygo:0.34.0 \
tinygo build -o sign_check.wasm -target=wasi -scheduler=none .
ls -lh sign_check.wasm
步骤四:挂载到 Envoy 过滤器链
关键点有三处:runtime 必须是 envoy.wasm.runtime.v8;root_id 要与插件注册的 root context 对应;configuration 用 google.protobuf.StringValue 承载 JSON 字符串。wasm 过滤器要放在 envoy.filters.http.router 之前,否则永远不执行。
static_resources:
listeners:
- name: api_gateway
address:
socket_address: { address: 0.0.0.0, port_value: 10000 }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: backend
domains: ["*"]
routes:
- match: { prefix: "/" }
route: { cluster: app_backend }
http_filters:
- name: envoy.filters.http.wasm
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
config:
name: sign-check
root_id: sign-check
vm_config:
runtime: envoy.wasm.runtime.v8
code:
local:
filename: /etc/envoy/sign_check.wasm
allow_precompiled: true
configuration:
"@type": type.googleapis.com/google.protobuf.StringValue
value: |
{"secret":"replace-with-32-byte-random","window":300}
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: app_backend
connect_timeout: 1s
type: STRICT_DNS
load_assignment:
cluster_name: app_backend
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address: { address: 127.0.0.1, port_value: 8080 }
admin:
address:
socket_address: { address: 127.0.0.1, port_value: 9901 }
启动时把 wasm 文件挂进容器,配置改动后 Envoy 热重启即可,不需要重建镜像:
docker run -d --name envoy-gw --network host \
-v "$PWD/envoy.yaml":/etc/envoy/envoy.yaml:ro \
-v "$PWD/sign_check.wasm":/etc/envoy/sign_check.wasm:ro \
envoyproxy/envoy:v1.31-latest \
-c /etc/envoy/envoy.yaml --log-level info
配置验证
验证一:无签名请求被拦截
不带任何签名头直接请求,应当立刻返回 401,且后端服务收不到这条请求。这是判断过滤器是否真正生效的最快方法,比看日志可靠。
curl -i -s http://127.0.0.1:10000/api/orders
# 预期 HTTP/1.1 401 Unauthorized
# {"code":"SIGN_INVALID","msg":"missing signature headers"}
验证二:正确签名请求放行
用 openssl 按同一套规则算签名,注意 printf 里的换行要真实存在,用 echo 带 -n 容易踩坑。
SECRET="replace-with-32-byte-random"
TS=$(date +%s)
NONCE=$(openssl rand -hex 8)
STS=$(printf 'GET\n/api/orders\n%s\n%s' "$TS" "$NONCE")
SIG=$(printf '%s' "$STS" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
curl -i -s http://127.0.0.1:10000/api/orders \
-H "X-Timestamp: $TS" \
-H "X-Nonce: $NONCE" \
-H "X-Signature: $SIG"
# 预期 HTTP/1.1 200 OK,响应来自后端 8080
验证三:时间窗与篡改校验
把时间戳改早 400 秒,或者改动路径但沿用原签名,两种都必须 401。这一步验证的是防重放与完整性,缺一不可。
OLD=$(( $(date +%s) - 400 ))
curl -i -s http://127.0.0.1:10000/api/orders \
-H "X-Timestamp: $OLD" -H "X-Nonce: $NONCE" -H "X-Signature: $SIG"
# 预期 401 {"msg":"timestamp out of window"}
验证四:查看运行时统计
通过 admin 端口确认模块已加载、插件未崩溃。如果 wasm 相关指标一条都没有,说明过滤器根本没进入过滤器链。
curl -s http://127.0.0.1:9901/stats | grep -i wasm
curl -s http://127.0.0.1:9901/config_dump | grep -A3 sign-check
常见问题
Q1:启动报 Failed to load WASM module,提示 unknown import
九成是 ABI 版本不匹配。proxy-wasm-go-sdk 的版本必须与 Envoy 的 proxy-wasm ABI 对得上,Envoy 1.31 建议用 sdk 的 v0.6.x 分支。另外确认编译目标是 wasi 而不是 wasm32-unknown-unknown,后者没有 wasi_snapshot_preview1 导出,加载时会缺符号。
排查顺序:先看 docker logs envoy-gw | grep -i wasm 的错误行,再用 wasm-objdump -x sign_check.wasm | head -40 确认导入表里只有 proxy_ 前缀的 hostcall,没有意外的文件系统调用。
Q2:签名算法完全一致,但线上一直 401
优先怀疑路径被改写。:path 里可能带查询串,插件里已经截掉了 ? 之后的部分,如果客户端签名时把 query 也算进去,两边就永远对不上。其次是大小写:Envoy 会把请求头名统一转小写,读取时必须用小写头名;签名结果建议统一转小写十六进制再比较。
还有一种隐蔽情况:网关前面还有一层 Nginx 或 CDN,它们可能改写 URI 或追加参数。用 curl -v 对比源站直连与经网关的 :path 即可定位。
Q3:能不能在 wasm 里直接读请求体做校验
可以但不建议。onRequestHeaders 阶段 body 尚未到达,必须实现 OnHttpRequestBody 并处理分块与 end_of_stream,同时要设置 vm_config.runtime 的内存上限,否则大文件上传会直接把实例打爆。生产环境里更稳的方案是让网关只校验头部签名,body 摘要交给后端服务在读取时计算。
总结
把签名校验做成 Envoy WASM 过滤器,本质是把「谁能进来」这个判断从业务代码里抽出来,收敛到唯一入口。三层收益很明确:
- 一致性:几十个服务共用一份插件,不存在漏改;规则变更只发一次配置。
- 可观测:拦截发生在边缘,后端日志干净,攻击流量不会污染业务指标。
- 可回滚:插件出错时把 wasm 过滤器从
http_filters摘掉即可,不涉及服务发布。
落地时注意三条红线:密钥不要写死在镜像里,用 Envoy 的 configuration 加外部配置下发;时间窗不要超过 5 分钟,否则重放空间过大;hmac.Equal 这类常量时间比较必须保留,别为了省事改成字符串直接比较。做到这三点,这套方案可以直接扛住开放平台的真实调用量。