feat: add nginx rewrite compatible wasm plugin (#3823)

Signed-off-by: johnlanni <zty98751@alibaba-inc.com>
This commit is contained in:
澄潭
2026-05-14 11:24:58 +08:00
committed by GitHub
parent c7eed0c0c1
commit b032f344e6
9 changed files with 1300 additions and 0 deletions

View File

@@ -0,0 +1,255 @@
---
title: Nginx Rewrite 兼容迁移
keywords: [higress, nginx, rewrite, set, migration]
description: nginx rewrite + set 安全迁移插件说明
---
## 功能说明
`nginx-rewrite-compatible` 插件提供与 `nginx rewrite + set` 指令组合等价的常见能力,包括路径重写、查询串追加或替换、正则捕获组变量保存,以及通过请求头将变量传递给上游服务。
这个插件面向从 Nginx 迁移到 Higress 的场景,作为安全替代方案,避免继续依赖受 `CVE-2026-42945` 影响的重写链路。
## 安全背景
`CVE-2026-42945` 是一个与 Nginx `rewrite``set` 指令组合相关的长期堆溢出问题,漏洞存在约 18 年。其触发条件集中在以下模式:
1. `rewrite` 使用带 `?` 的替换目标,在一次 rewrite pass 中同时修改 URI 和 query string。
2. `set` 在后续步骤中继续引用前一次正则匹配得到的捕获组,如 `$1``$2`
3. 两次 pass 之间rewrite 状态和捕获组状态没有保持一致,导致后续 `set` 读取了不匹配的捕获组元数据,最终触发越界访问和堆溢出。
Higress 的 WASM 插件没有这个问题,原因是:
1. 每个请求都在独立的 WASM 上下文中处理。
2. 本插件在一次请求回调内完成“匹配、重写、变量保存、向上游透传”全过程,不依赖 Nginx rewrite module 的两阶段状态机。
3. 捕获组结果只存在当前请求的内存和请求属性中,不会跨 pass 泄漏,也不会复用失配状态。
## 运行属性
插件执行阶段:`默认阶段`
插件执行优先级:`100`
## 配置字段
| 字段名 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| `rules` | array of object | 是 | - | 重写规则列表,按顺序执行 |
### `rules` 配置说明
| 字段名 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| `regex` | string | 是 | - | 匹配请求 path 的正则表达式,不包含 query string |
| `replacement` | string | 是 | - | 新的路径模板,支持 `$1``$2` 等捕获组引用 |
| `query_append` | string | 否 | - | 追加到原 query string 的片段,支持 `$1``$2` |
| `query_template` | string | 否 | - | 完全替换原 query string 的模板,支持 `$1``$2` |
| `set_vars` | array of object | 否 | - | 将捕获组写入请求级变量,或按变量名前缀改写 query/header/cookie |
| `pass_to_upstream` | bool | 否 | `false` | 是否把当前规则的变量同时写入请求头传给上游 |
| `mode` | string | 否 | `last` | 规则流转模式,支持 `break``last` |
说明:
1. `query_append``query_template` 不能同时配置。
2. `mode: break` 表示命中当前规则后停止继续匹配后续规则。
3. `mode: last` 表示命中当前规则后,使用重写后的 path 继续匹配后续规则。
4. `set_vars` 中,`arg_` 前缀会修改请求 query parameter`http_` 前缀会修改请求 header`cookie_` 前缀会修改请求 cookie其他变量名会通过 `proxywasm.SetProperty([]string{"nginx_rewrite_compatible","vars",name})` 保存。
5. `http_` 前缀对应的 header 名称会去掉前缀,并把下划线转换成横杠。
6.`pass_to_upstream: true` 时,变量还会额外写入请求头 `x-higress-rewrite-var-<name>`
### `set_vars` 配置说明
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `name` | string | 是 | 变量名。`arg_`/`http_`/`cookie_` 前缀分别表示写 query parameter、header、cookie其他名称表示自定义属性 |
| `capture_group` | int | 是 | 捕获组编号,`0` 表示整个匹配,`1` 表示第一个分组 |
## Nginx 配置对照表
### 1. 简单路径重写
Nginx:
```nginx
rewrite ^/old/(.*)$ /new/$1;
```
插件配置:
```yaml
rules:
- regex: ^/old/(.*)$
replacement: /new/$1
```
### 2. 正则捕获组替换
Nginx:
```nginx
rewrite ^/product/([0-9]+)$ /detail/$1;
```
插件配置:
```yaml
rules:
- regex: ^/product/([0-9]+)$
replacement: /detail/$1
```
### 3. Query String 操作
追加 query:
```nginx
rewrite ^/api/(.*)$ /internal?migrated=true;
```
```yaml
rules:
- regex: ^/api/(.*)$
replacement: /internal
query_append: migrated=true
```
替换 query:
```nginx
rewrite ^/x/(.*)/(.*)$ /y?a=$1&b=$2;
```
```yaml
rules:
- regex: ^/x/(.*)/(.*)$
replacement: /y
query_template: a=$1&b=$2
```
### 4. 特殊变量前缀
```yaml
rules:
- regex: "^/api/(.*)/(.*)$"
replacement: "/internal"
set_vars:
- name: original_path
capture_group: 1
- name: http_x_original
capture_group: 1
- name: arg_source
capture_group: 2
- name: cookie_track_id
capture_group: 1
```
语义:
1. `original_path` 保存为请求属性,可供后续插件通过 `GetProperty(["nginx_rewrite_compatible","vars","original_path"])` 读取。
2. `http_x_original` 设置请求头 `x-original`
3. `arg_source` 设置 query parameter `source`
4. `cookie_track_id` 设置 cookie `track_id`
### 5. 变量保存与传递
Nginx:
```nginx
rewrite ^/api/(.*)$ /internal?migrated=true;
set $original_endpoint $1;
```
插件配置:
```yaml
rules:
- regex: ^/api/(.*)$
replacement: /internal
query_append: migrated=true
set_vars:
- name: original_endpoint
capture_group: 1
pass_to_upstream: true
```
### 6. 多规则组合
Nginx:
```nginx
rewrite ^/stage/(.*)$ /mid/$1;
rewrite ^/mid/(.*)$ /final/$1;
```
插件配置:
```yaml
rules:
- regex: ^/stage/(.*)$
replacement: /mid/$1
mode: last
- regex: ^/mid/(.*)$
replacement: /final/$1
```
### 7. break / last 控制
Nginx `break`:
```nginx
rewrite ^/stage/(.*)$ /mid/$1 break;
```
```yaml
rules:
- regex: ^/stage/(.*)$
replacement: /mid/$1
mode: break
```
Nginx `last`:
```nginx
rewrite ^/stage/(.*)$ /mid/$1 last;
rewrite ^/mid/(.*)$ /final/$1;
```
```yaml
rules:
- regex: ^/stage/(.*)$
replacement: /mid/$1
mode: last
- regex: ^/mid/(.*)$
replacement: /final/$1
```
## 使用示例
```yaml
rules:
- regex: ^/api/(.*)$
replacement: /internal
query_append: migrated=true
set_vars:
- name: original_endpoint
capture_group: 1
- name: http_x_original_endpoint
capture_group: 1
- name: arg_source
capture_group: 1
- name: cookie_track_id
capture_group: 1
pass_to_upstream: true
mode: break
- regex: ^/old/(.*)$
replacement: /new/$1
- regex: ^/x/(.*)/(.*)$
replacement: /y
query_template: a=$1&b=$2
set_vars:
- name: first
capture_group: 1
- name: second
capture_group: 2
```

View File

@@ -0,0 +1,255 @@
---
title: Nginx Rewrite Compatibility Migration
keywords: [higress, nginx, rewrite, set, migration]
description: Secure migration plugin for nginx rewrite + set
---
## Features
The `nginx-rewrite-compatible` plugin provides the common behavior of `nginx rewrite + set`, including path rewrites, query append or replacement, capture-group variable storage, and optional variable propagation to upstream services through request headers.
It is designed as a secure migration alternative when moving from Nginx to Higress, so users do not need to keep relying on the rewrite path affected by `CVE-2026-42945`.
## Security Background
`CVE-2026-42945` is a long-standing heap overflow issue related to the interaction between Nginx `rewrite` and `set`. The vulnerable pattern is:
1. A `rewrite` rule uses a replacement containing `?`, so URI and query string are updated during one rewrite pass.
2. A later `set` still references capture groups such as `$1` or `$2`.
3. The state kept across rewrite passes becomes inconsistent, so `set` reads capture-group metadata from a mismatched state and eventually triggers out-of-bounds access and heap corruption.
The Higress WASM approach does not have this problem because:
1. Each request is handled in an isolated WASM request context.
2. This plugin performs match, rewrite, variable extraction, and upstream propagation in one request callback instead of relying on Nginx's multi-pass rewrite state machine.
3. Capture-group data lives only inside the current request and request properties, so there is no cross-pass state leakage.
## Runtime Properties
Plugin execution phase: `UNSPECIFIED`
Plugin execution priority: `100`
## Configuration Fields
| Field Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `rules` | array of object | Yes | - | Ordered rewrite rules |
### `rules`
| Field Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `regex` | string | Yes | - | Regular expression that matches the request path without the query string |
| `replacement` | string | Yes | - | New path template. Supports capture references such as `$1` and `$2` |
| `query_append` | string | No | - | Query fragment appended to the existing query string. Supports `$1`, `$2` |
| `query_template` | string | No | - | Query template that replaces the existing query string. Supports `$1`, `$2` |
| `set_vars` | array of object | No | - | Stores capture groups as request-scoped variables, or rewrites query/header/cookie based on variable prefixes |
| `pass_to_upstream` | bool | No | `false` | Whether variables from the current rule should also be written into upstream request headers |
| `mode` | string | No | `last` | Rule flow mode. Supported values: `break`, `last` |
Notes:
1. `query_append` and `query_template` are mutually exclusive.
2. `mode: break` stops evaluation after the current matching rule.
3. `mode: last` continues evaluating the following rules with the rewritten path.
4. In `set_vars`, the `arg_` prefix rewrites a request query parameter, `http_` rewrites a request header, `cookie_` rewrites a request cookie, and any other name is stored with `proxywasm.SetProperty([]string{"nginx_rewrite_compatible","vars",name})`.
5. For `http_`, the header name is derived by removing the prefix and converting underscores to hyphens.
6. When `pass_to_upstream: true`, variables are also written to `x-higress-rewrite-var-<name>`.
### `set_vars`
| Field Name | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | Yes | Variable name. `arg_`/`http_`/`cookie_` mean query parameter, header, and cookie updates respectively; other names become custom properties |
| `capture_group` | int | Yes | Capture-group index. `0` means the whole match and `1` means the first group |
## Nginx Mapping Table
### 1. Simple Path Rewrite
Nginx:
```nginx
rewrite ^/old/(.*)$ /new/$1;
```
Plugin:
```yaml
rules:
- regex: ^/old/(.*)$
replacement: /new/$1
```
### 2. Capture-Group Replacement
Nginx:
```nginx
rewrite ^/product/([0-9]+)$ /detail/$1;
```
Plugin:
```yaml
rules:
- regex: ^/product/([0-9]+)$
replacement: /detail/$1
```
### 3. Query String Operations
Append query:
```nginx
rewrite ^/api/(.*)$ /internal?migrated=true;
```
```yaml
rules:
- regex: ^/api/(.*)$
replacement: /internal
query_append: migrated=true
```
Replace query:
```nginx
rewrite ^/x/(.*)/(.*)$ /y?a=$1&b=$2;
```
```yaml
rules:
- regex: ^/x/(.*)/(.*)$
replacement: /y
query_template: a=$1&b=$2
```
### 4. Special Variable Prefixes
```yaml
rules:
- regex: "^/api/(.*)/(.*)$"
replacement: "/internal"
set_vars:
- name: original_path
capture_group: 1
- name: http_x_original
capture_group: 1
- name: arg_source
capture_group: 2
- name: cookie_track_id
capture_group: 1
```
Semantics:
1. `original_path` is stored as a request property and can be read later with `GetProperty(["nginx_rewrite_compatible","vars","original_path"])`.
2. `http_x_original` sets the request header `x-original`.
3. `arg_source` sets the query parameter `source`.
4. `cookie_track_id` sets the cookie `track_id`.
### 5. Variable Preservation and Propagation
Nginx:
```nginx
rewrite ^/api/(.*)$ /internal?migrated=true;
set $original_endpoint $1;
```
Plugin:
```yaml
rules:
- regex: ^/api/(.*)$
replacement: /internal
query_append: migrated=true
set_vars:
- name: original_endpoint
capture_group: 1
pass_to_upstream: true
```
### 6. Multiple Rules
Nginx:
```nginx
rewrite ^/stage/(.*)$ /mid/$1;
rewrite ^/mid/(.*)$ /final/$1;
```
Plugin:
```yaml
rules:
- regex: ^/stage/(.*)$
replacement: /mid/$1
mode: last
- regex: ^/mid/(.*)$
replacement: /final/$1
```
### 7. `break` / `last`
Nginx `break`:
```nginx
rewrite ^/stage/(.*)$ /mid/$1 break;
```
```yaml
rules:
- regex: ^/stage/(.*)$
replacement: /mid/$1
mode: break
```
Nginx `last`:
```nginx
rewrite ^/stage/(.*)$ /mid/$1 last;
rewrite ^/mid/(.*)$ /final/$1;
```
```yaml
rules:
- regex: ^/stage/(.*)$
replacement: /mid/$1
mode: last
- regex: ^/mid/(.*)$
replacement: /final/$1
```
## Example
```yaml
rules:
- regex: ^/api/(.*)$
replacement: /internal
query_append: migrated=true
set_vars:
- name: original_endpoint
capture_group: 1
- name: http_x_original_endpoint
capture_group: 1
- name: arg_source
capture_group: 1
- name: cookie_track_id
capture_group: 1
pass_to_upstream: true
mode: break
- regex: ^/old/(.*)$
replacement: /new/$1
- regex: ^/x/(.*)/(.*)$
replacement: /y
query_template: a=$1&b=$2
set_vars:
- name: first
capture_group: 1
- name: second
capture_group: 2
```

View File

@@ -0,0 +1 @@
1.0.0

View File

@@ -0,0 +1,24 @@
module github.com/alibaba/higress/plugins/wasm-go/extensions/nginx-rewrite-compatible
go 1.24.1
toolchain go1.24.4
require (
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250822030947-8345453fddd0
github.com/higress-group/wasm-go v1.0.2
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/tetratelabs/wazero v1.7.2 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/resp v0.1.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -0,0 +1,30 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250822030947-8345453fddd0 h1:YGdj8KBzVjabU3STUfwMZghB+VlX6YLfJtLbrsWaOD0=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250822030947-8345453fddd0/go.mod h1:tRI2LfMudSkKHhyv1uex3BWzcice2s/l8Ah8axporfA=
github.com/higress-group/wasm-go v1.0.2 h1:8fQqR+wHts8tP+v7GYxmsCNyW5nAjn9wPYV0/+Seqzg=
github.com/higress-group/wasm-go v1.0.2/go.mod h1:882/J8ccU4i+LeyFKmeicbHWAYLj8y7YZr60zk0OOCI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tetratelabs/wazero v1.7.2 h1:1+z5nXJNwMLPAWaTePFi49SSTL0IMx/i3Fg8Yc25GDc=
github.com/tetratelabs/wazero v1.7.2/go.mod h1:ytl6Zuh20R/eROuyDaGPkp82O9C/DJfXAwJfQ3X6/7Y=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/resp v0.1.1 h1:Ly20wkhqKTmDUPlyM1S7pWo5kk0tDu8OoC/vFArXmwE=
github.com/tidwall/resp v0.1.1/go.mod h1:3/FrruOBAxPTPtundW0VXgmsQ4ZBA0Aw714lVYgwFa0=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,49 @@
package main
import (
"fmt"
"github.com/alibaba/higress/plugins/wasm-go/extensions/nginx-rewrite-compatible/pkg"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"github.com/higress-group/wasm-go/pkg/log"
"github.com/higress-group/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
)
func main() {}
func init() {
wrapper.SetCtx(
"nginx-rewrite-compatible",
wrapper.ParseConfigBy(parseConfig),
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
)
}
// @Name nginx-rewrite-compatible
// @Category custom
// @Phase UNSPECIFIED_PHASE
// @Priority 100
// @Title zh-CN Nginx Rewrite 兼容迁移
// @Title en-US Nginx Rewrite Compatibility Migration
// @Description zh-CN 提供与 nginx rewrite + set 指令组合等价的路径重写、查询串重写,以及通过 arg_/http_/cookie_ 前缀修改请求参数、请求头和 Cookie 的能力,用于安全迁移到 Higress。
// @Description en-US Provides path rewrite, query rewrite, and nginx-style arg_/http_/cookie_ variable handling for request parameters, headers, and cookies, enabling safe migration to Higress.
// @Version 1.0.0
func parseConfig(json gjson.Result, config *pkg.PluginConfig, logger log.Log) error {
if err := config.FromJson(json); err != nil {
return fmt.Errorf("failed to parse plugin config: %w", err)
}
return nil
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config pkg.PluginConfig, logger log.Log) types.Action {
changed, err := config.Apply(ctx, logger)
if err != nil {
logger.Errorf("failed to apply rewrite rules: %v", err)
return types.ActionContinue
}
if changed {
logger.Debugf("rewrite rules applied successfully")
}
return types.ActionContinue
}

View File

@@ -0,0 +1,332 @@
package main
import (
"encoding/json"
"testing"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"github.com/higress-group/wasm-go/pkg/test"
"github.com/stretchr/testify/require"
)
func TestNginxRewriteCompatible(t *testing.T) {
test.RunTest(t, func(t *testing.T) {
t.Run("basic rewrite", func(t *testing.T) {
host, status := test.NewTestHost(mustConfig(t, map[string]any{
"rules": []map[string]any{
{
"regex": "^/old/(.*)$",
"replacement": "/new/$1",
},
},
}))
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
action := host.CallOnHttpRequestHeaders(baseHeaders("/old/demo"))
require.Equal(t, types.ActionContinue, action)
requirePath(t, host.GetRequestHeaders(), "/new/demo")
})
t.Run("rewrite and query append", func(t *testing.T) {
host, status := test.NewTestHost(mustConfig(t, map[string]any{
"rules": []map[string]any{
{
"regex": "^/api/(.*)$",
"replacement": "/internal",
"query_append": "migrated=true",
},
},
}))
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
action := host.CallOnHttpRequestHeaders(baseHeaders("/api/orders?id=1"))
require.Equal(t, types.ActionContinue, action)
requirePath(t, host.GetRequestHeaders(), "/internal?id=1&migrated=true")
})
t.Run("rewrite and set vars", func(t *testing.T) {
host, status := test.NewTestHost(mustConfig(t, map[string]any{
"rules": []map[string]any{
{
"regex": "^/user/(.*)$",
"replacement": "/profile",
"set_vars": []map[string]any{
{"name": "user_id", "capture_group": 1},
},
},
},
}))
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
action := host.CallOnHttpRequestHeaders(baseHeaders("/user/alice"))
require.Equal(t, types.ActionContinue, action)
requirePath(t, host.GetRequestHeaders(), "/profile")
value, err := host.GetProperty([]string{"nginx_rewrite_compatible", "vars", "user_id"})
require.NoError(t, err)
require.Equal(t, "alice", string(value))
})
t.Run("special set var prefixes", func(t *testing.T) {
host, status := test.NewTestHost(mustConfig(t, map[string]any{
"rules": []map[string]any{
{
"regex": "^/api/(.*)/(.*)$",
"replacement": "/internal",
"set_vars": []map[string]any{
{"name": "original_path", "capture_group": 1},
{"name": "http_x_original", "capture_group": 1},
{"name": "arg_source", "capture_group": 2},
{"name": "cookie_track_id", "capture_group": 1},
},
},
},
}))
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
action := host.CallOnHttpRequestHeaders(baseHeadersWithCookie("/api/orders/mobile?legacy=yes", "session=abc"))
require.Equal(t, types.ActionContinue, action)
requirePath(t, host.GetRequestHeaders(), "/internal?legacy=yes&source=mobile")
requireHeader(t, host.GetRequestHeaders(), "x-original", "orders")
requireHeader(t, host.GetRequestHeaders(), "cookie", "session=abc; track_id=orders")
value, err := host.GetProperty([]string{"nginx_rewrite_compatible", "vars", "original_path"})
require.NoError(t, err)
require.Equal(t, "orders", string(value))
_, err = host.GetProperty([]string{"nginx_rewrite_compatible", "vars", "http_x_original"})
require.Error(t, err)
_, err = host.GetProperty([]string{"nginx_rewrite_compatible", "vars", "arg_source"})
require.Error(t, err)
_, err = host.GetProperty([]string{"nginx_rewrite_compatible", "vars", "cookie_track_id"})
require.Error(t, err)
})
t.Run("multiple capture groups", func(t *testing.T) {
host, status := test.NewTestHost(mustConfig(t, map[string]any{
"rules": []map[string]any{
{
"regex": "^/a/(.*)/b/(.*)$",
"replacement": "/c",
"query_template": "x=$1&y=$2",
"set_vars": []map[string]any{
{"name": "first", "capture_group": 1},
{"name": "second", "capture_group": 2},
},
},
},
}))
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
action := host.CallOnHttpRequestHeaders(baseHeaders("/a/hello/b/world"))
require.Equal(t, types.ActionContinue, action)
requirePath(t, host.GetRequestHeaders(), "/c?x=hello&y=world")
first, err := host.GetProperty([]string{"nginx_rewrite_compatible", "vars", "first"})
require.NoError(t, err)
require.Equal(t, "hello", string(first))
second, err := host.GetProperty([]string{"nginx_rewrite_compatible", "vars", "second"})
require.NoError(t, err)
require.Equal(t, "world", string(second))
})
t.Run("break vs last", func(t *testing.T) {
host, status := test.NewTestHost(mustConfig(t, map[string]any{
"rules": []map[string]any{
{
"regex": "^/stage/(.*)$",
"replacement": "/mid/$1",
"mode": "break",
},
{
"regex": "^/mid/(.*)$",
"replacement": "/final/$1",
},
},
}))
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
action := host.CallOnHttpRequestHeaders(baseHeaders("/stage/item"))
require.Equal(t, types.ActionContinue, action)
requirePath(t, host.GetRequestHeaders(), "/mid/item")
})
t.Run("last continues matching", func(t *testing.T) {
host, status := test.NewTestHost(mustConfig(t, map[string]any{
"rules": []map[string]any{
{
"regex": "^/stage/(.*)$",
"replacement": "/mid/$1",
"mode": "last",
},
{
"regex": "^/mid/(.*)$",
"replacement": "/final/$1",
},
},
}))
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
action := host.CallOnHttpRequestHeaders(baseHeaders("/stage/item"))
require.Equal(t, types.ActionContinue, action)
requirePath(t, host.GetRequestHeaders(), "/final/item")
})
t.Run("no match keeps original request", func(t *testing.T) {
host, status := test.NewTestHost(mustConfig(t, map[string]any{
"rules": []map[string]any{
{
"regex": "^/old/(.*)$",
"replacement": "/new/$1",
},
},
}))
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
action := host.CallOnHttpRequestHeaders(baseHeaders("/keep"))
require.Equal(t, types.ActionContinue, action)
requirePath(t, host.GetRequestHeaders(), "/keep")
})
t.Run("special characters are preserved", func(t *testing.T) {
host, status := test.NewTestHost(mustConfig(t, map[string]any{
"rules": []map[string]any{
{
"regex": "^/raw/(.*)$",
"replacement": "/target",
"query_template": "value=$1&flag=a+b&expr=%25done",
},
},
}))
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
action := host.CallOnHttpRequestHeaders(baseHeaders("/raw/a+b%26=%25"))
require.Equal(t, types.ActionContinue, action)
requirePath(t, host.GetRequestHeaders(), "/target?value=a+b%26=%25&flag=a+b&expr=%25done")
})
t.Run("empty capture group", func(t *testing.T) {
host, status := test.NewTestHost(mustConfig(t, map[string]any{
"rules": []map[string]any{
{
"regex": "^/empty/(.*)$",
"replacement": "/done",
"set_vars": []map[string]any{
{"name": "tail", "capture_group": 1},
},
},
},
}))
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
action := host.CallOnHttpRequestHeaders(baseHeaders("/empty/"))
require.Equal(t, types.ActionContinue, action)
requirePath(t, host.GetRequestHeaders(), "/done")
_, err := host.GetProperty([]string{"nginx_rewrite_compatible", "vars", "tail"})
require.Error(t, err)
})
t.Run("query template replaces existing query", func(t *testing.T) {
host, status := test.NewTestHost(mustConfig(t, map[string]any{
"rules": []map[string]any{
{
"regex": "^/query/(.*)$",
"replacement": "/target",
"query_template": "id=$1",
},
},
}))
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
action := host.CallOnHttpRequestHeaders(baseHeaders("/query/abc?legacy=yes"))
require.Equal(t, types.ActionContinue, action)
requirePath(t, host.GetRequestHeaders(), "/target?id=abc")
})
t.Run("query append preserves existing query", func(t *testing.T) {
host, status := test.NewTestHost(mustConfig(t, map[string]any{
"rules": []map[string]any{
{
"regex": "^/query/(.*)$",
"replacement": "/target",
"query_append": "id=$1",
},
},
}))
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
action := host.CallOnHttpRequestHeaders(baseHeaders("/query/abc?legacy=yes"))
require.Equal(t, types.ActionContinue, action)
requirePath(t, host.GetRequestHeaders(), "/target?legacy=yes&id=abc")
})
t.Run("pass to upstream adds headers", func(t *testing.T) {
host, status := test.NewTestHost(mustConfig(t, map[string]any{
"rules": []map[string]any{
{
"regex": "^/api/(.*)$",
"replacement": "/internal",
"pass_to_upstream": true,
"set_vars": []map[string]any{
{"name": "original_endpoint", "capture_group": 1},
},
},
},
}))
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
action := host.CallOnHttpRequestHeaders(baseHeaders("/api/orders"))
require.Equal(t, types.ActionContinue, action)
requirePath(t, host.GetRequestHeaders(), "/internal")
requireHeader(t, host.GetRequestHeaders(), "x-higress-rewrite-var-original-endpoint", "orders")
})
})
}
func mustConfig(t *testing.T, cfg map[string]any) []byte {
t.Helper()
data, err := json.Marshal(cfg)
require.NoError(t, err)
return data
}
func baseHeaders(path string) [][2]string {
return [][2]string{
{":authority", "example.com"},
{":path", path},
{":method", "GET"},
}
}
func baseHeadersWithCookie(path string, cookie string) [][2]string {
headers := baseHeaders(path)
return append(headers, [2]string{"cookie", cookie})
}
func requirePath(t *testing.T, headers [][2]string, expected string) {
t.Helper()
requireHeader(t, headers, ":path", expected)
}
func requireHeader(t *testing.T, headers [][2]string, name string, expected string) {
t.Helper()
for _, header := range headers {
if header[0] == name {
require.Equal(t, expected, header[1])
return
}
}
t.Fatalf("header %s not found", name)
}

View File

@@ -0,0 +1,109 @@
package pkg
import (
"errors"
"fmt"
"regexp"
"strings"
"github.com/tidwall/gjson"
)
const (
ModeBreak = "break"
ModeLast = "last"
)
type PluginConfig struct {
Rules []Rule `json:"rules"`
}
type Rule struct {
Regex string `json:"regex"`
Replacement string `json:"replacement"`
QueryAppend string `json:"query_append,omitempty"`
QueryTemplate string `json:"query_template,omitempty"`
SetVars []SetVar `json:"set_vars,omitempty"`
PassToUpstream bool `json:"pass_to_upstream,omitempty"`
Mode string `json:"mode,omitempty"`
compiled *regexp.Regexp
}
type SetVar struct {
Name string `json:"name"`
CaptureGroup int `json:"capture_group"`
}
func (c *PluginConfig) FromJson(json gjson.Result) error {
rules := json.Get("rules")
if !rules.Exists() || !rules.IsArray() || len(rules.Array()) == 0 {
return errors.New("rules must be a non-empty array")
}
c.Rules = make([]Rule, 0, len(rules.Array()))
for i, item := range rules.Array() {
rule, err := parseRule(item)
if err != nil {
return fmt.Errorf("invalid rule %d: %w", i, err)
}
c.Rules = append(c.Rules, rule)
}
return nil
}
func parseRule(item gjson.Result) (Rule, error) {
rule := Rule{
Regex: item.Get("regex").String(),
Replacement: item.Get("replacement").String(),
QueryAppend: item.Get("query_append").String(),
QueryTemplate: item.Get("query_template").String(),
PassToUpstream: item.Get("pass_to_upstream").Bool(),
Mode: strings.ToLower(item.Get("mode").String()),
}
if rule.Regex == "" {
return Rule{}, errors.New("regex is required")
}
if rule.Replacement == "" {
return Rule{}, errors.New("replacement is required")
}
if rule.QueryAppend != "" && rule.QueryTemplate != "" {
return Rule{}, errors.New("query_append and query_template cannot be used together")
}
if rule.Mode == "" {
rule.Mode = ModeLast
}
if rule.Mode != ModeBreak && rule.Mode != ModeLast {
return Rule{}, fmt.Errorf("unsupported mode %q", rule.Mode)
}
compiled, err := regexp.Compile(rule.Regex)
if err != nil {
return Rule{}, fmt.Errorf("failed to compile regex: %w", err)
}
rule.compiled = compiled
setVars := item.Get("set_vars")
if setVars.Exists() {
if !setVars.IsArray() {
return Rule{}, errors.New("set_vars must be an array")
}
rule.SetVars = make([]SetVar, 0, len(setVars.Array()))
for i, setVarItem := range setVars.Array() {
setVar := SetVar{
Name: setVarItem.Get("name").String(),
CaptureGroup: int(setVarItem.Get("capture_group").Int()),
}
if setVar.Name == "" {
return Rule{}, fmt.Errorf("set_vars[%d].name is required", i)
}
if setVar.CaptureGroup < 0 || setVar.CaptureGroup > compiled.NumSubexp() {
return Rule{}, fmt.Errorf("set_vars[%d].capture_group=%d out of range", i, setVar.CaptureGroup)
}
rule.SetVars = append(rule.SetVars, setVar)
}
}
return rule, nil
}

View File

@@ -0,0 +1,245 @@
package pkg
import (
"fmt"
"strings"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/wasm-go/pkg/log"
"github.com/higress-group/wasm-go/pkg/wrapper"
)
const (
propertyNamespace = "nginx_rewrite_compatible"
headerPrefix = "x-higress-rewrite-var-"
argVarPrefix = "arg_"
httpVarPrefix = "http_"
cookieVarPrefix = "cookie_"
)
func (c PluginConfig) Apply(ctx wrapper.HttpContext, logger log.Log) (bool, error) {
originalPath, err := proxywasm.GetHttpRequestHeader(":path")
if err != nil || originalPath == "" {
originalPath = ctx.Path()
}
if originalPath == "" {
return false, fmt.Errorf("request path is empty")
}
currentPath, currentQuery := splitPathAndQuery(originalPath)
vars := map[string]string{}
passHeaders := map[string]bool{}
requestHeaders := map[string]string{}
requestCookies := map[string]string{}
changed := false
for i, rule := range c.Rules {
matches := rule.compiled.FindStringSubmatchIndex(currentPath)
if matches == nil {
continue
}
if !changed {
ctx.DisableReroute()
}
changed = true
newPath := rule.compiled.ReplaceAllString(currentPath, rule.Replacement)
if newPath == "" {
return false, fmt.Errorf("rule %d produced an empty path", i)
}
switch {
case rule.QueryTemplate != "":
currentQuery = expandTemplate(rule, currentPath, matches, rule.QueryTemplate)
case rule.QueryAppend != "":
currentQuery = appendQuery(currentQuery, expandTemplate(rule, currentPath, matches, rule.QueryAppend))
}
for _, setVar := range rule.SetVars {
value := captureGroupValue(currentPath, matches, setVar.CaptureGroup)
vars[setVar.Name] = value
passHeaders[setVar.Name] = rule.PassToUpstream
}
logger.Debugf("rule %d matched path %q and rewrote it to %q", i, currentPath, newPath)
currentPath = newPath
if rule.Mode == ModeBreak {
break
}
}
if !changed {
return false, nil
}
for name, value := range vars {
switch {
case strings.HasPrefix(name, argVarPrefix):
currentQuery = setQueryParam(currentQuery, strings.TrimPrefix(name, argVarPrefix), value)
case strings.HasPrefix(name, httpVarPrefix):
requestHeaders[buildRequestHeaderName(strings.TrimPrefix(name, httpVarPrefix))] = value
case strings.HasPrefix(name, cookieVarPrefix):
requestCookies[strings.TrimPrefix(name, cookieVarPrefix)] = value
case value != "":
if err := proxywasm.SetProperty([]string{propertyNamespace, "vars", name}, []byte(value)); err != nil {
return false, fmt.Errorf("failed to set property for var %q: %w", name, err)
}
}
headerName := buildUpstreamHeaderName(name)
if passHeaders[name] {
if err := proxywasm.ReplaceHttpRequestHeader(headerName, value); err != nil {
return false, fmt.Errorf("failed to set upstream header for var %q: %w", name, err)
}
continue
}
if err := proxywasm.RemoveHttpRequestHeader(headerName); err != nil {
logger.Warnf("failed to remove upstream header %q: %v", headerName, err)
}
}
for name, value := range requestHeaders {
if err := proxywasm.ReplaceHttpRequestHeader(name, value); err != nil {
return false, fmt.Errorf("failed to set request header %q: %w", name, err)
}
}
if len(requestCookies) > 0 {
currentCookie, err := proxywasm.GetHttpRequestHeader("cookie")
if err != nil {
currentCookie = ""
}
updatedCookie := currentCookie
for name, value := range requestCookies {
updatedCookie = setCookie(updatedCookie, name, value)
}
if err := proxywasm.ReplaceHttpRequestHeader("cookie", updatedCookie); err != nil {
return false, fmt.Errorf("failed to set cookie header: %w", err)
}
}
finalPath := buildPath(currentPath, currentQuery)
if finalPath != originalPath {
if err := proxywasm.ReplaceHttpRequestHeader(":path", finalPath); err != nil {
return false, fmt.Errorf("failed to replace :path header: %w", err)
}
}
return true, nil
}
func splitPathAndQuery(path string) (string, string) {
pathOnly, query, found := strings.Cut(path, "?")
if !found {
return path, ""
}
return pathOnly, query
}
func buildPath(path string, query string) string {
if query == "" {
return path
}
return path + "?" + query
}
func appendQuery(existing string, suffix string) string {
if suffix == "" {
return existing
}
if existing == "" {
return suffix
}
return existing + "&" + suffix
}
func setQueryParam(existing string, key string, value string) string {
if key == "" {
return existing
}
parts := []string{}
replaced := false
if existing != "" {
for _, part := range strings.Split(existing, "&") {
if part == "" {
continue
}
name, _, _ := strings.Cut(part, "=")
if name != key {
parts = append(parts, part)
continue
}
if !replaced {
parts = append(parts, key+"="+value)
replaced = true
}
}
}
if !replaced {
parts = append(parts, key+"="+value)
}
return strings.Join(parts, "&")
}
func expandTemplate(rule Rule, currentPath string, matches []int, template string) string {
return string(rule.compiled.ExpandString(nil, template, currentPath, matches))
}
func captureGroupValue(currentPath string, matches []int, group int) string {
index := group * 2
if index+1 >= len(matches) {
return ""
}
start, end := matches[index], matches[index+1]
if start < 0 || end < 0 {
return ""
}
return currentPath[start:end]
}
func buildUpstreamHeaderName(name string) string {
sanitized := strings.ToLower(strings.TrimSpace(name))
sanitized = strings.ReplaceAll(sanitized, "_", "-")
sanitized = strings.ReplaceAll(sanitized, " ", "-")
return headerPrefix + sanitized
}
func buildRequestHeaderName(name string) string {
sanitized := strings.ToLower(strings.TrimSpace(name))
sanitized = strings.ReplaceAll(sanitized, "_", "-")
sanitized = strings.ReplaceAll(sanitized, " ", "-")
return sanitized
}
func setCookie(existing string, key string, value string) string {
if key == "" {
return existing
}
parts := []string{}
replaced := false
if existing != "" {
for _, part := range strings.Split(existing, ";") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
name, _, _ := strings.Cut(part, "=")
if name != key {
parts = append(parts, part)
continue
}
if !replaced {
parts = append(parts, key+"="+value)
replaced = true
}
}
}
if !replaced {
parts = append(parts, key+"="+value)
}
return strings.Join(parts, "; ")
}