Envoy WASM过滤器实战:API签名校验配置

把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 这类常量时间比较必须保留,别为了省事改成字符串直接比较。做到这三点,这套方案可以直接扛住开放平台的真实调用量。