diff --git a/plugins/wasm-go/extensions/nginx-rewrite-compatible/README.md b/plugins/wasm-go/extensions/nginx-rewrite-compatible/README.md new file mode 100644 index 00000000..10185c49 --- /dev/null +++ b/plugins/wasm-go/extensions/nginx-rewrite-compatible/README.md @@ -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-`。 + +### `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 +``` diff --git a/plugins/wasm-go/extensions/nginx-rewrite-compatible/README_EN.md b/plugins/wasm-go/extensions/nginx-rewrite-compatible/README_EN.md new file mode 100644 index 00000000..a4dcb9b3 --- /dev/null +++ b/plugins/wasm-go/extensions/nginx-rewrite-compatible/README_EN.md @@ -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-`. + +### `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 +``` diff --git a/plugins/wasm-go/extensions/nginx-rewrite-compatible/VERSION b/plugins/wasm-go/extensions/nginx-rewrite-compatible/VERSION new file mode 100644 index 00000000..3eefcb9d --- /dev/null +++ b/plugins/wasm-go/extensions/nginx-rewrite-compatible/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/plugins/wasm-go/extensions/nginx-rewrite-compatible/go.mod b/plugins/wasm-go/extensions/nginx-rewrite-compatible/go.mod new file mode 100644 index 00000000..cc77c285 --- /dev/null +++ b/plugins/wasm-go/extensions/nginx-rewrite-compatible/go.mod @@ -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 +) diff --git a/plugins/wasm-go/extensions/nginx-rewrite-compatible/go.sum b/plugins/wasm-go/extensions/nginx-rewrite-compatible/go.sum new file mode 100644 index 00000000..d2ed1130 --- /dev/null +++ b/plugins/wasm-go/extensions/nginx-rewrite-compatible/go.sum @@ -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= diff --git a/plugins/wasm-go/extensions/nginx-rewrite-compatible/main.go b/plugins/wasm-go/extensions/nginx-rewrite-compatible/main.go new file mode 100644 index 00000000..7649fd27 --- /dev/null +++ b/plugins/wasm-go/extensions/nginx-rewrite-compatible/main.go @@ -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 +} diff --git a/plugins/wasm-go/extensions/nginx-rewrite-compatible/main_test.go b/plugins/wasm-go/extensions/nginx-rewrite-compatible/main_test.go new file mode 100644 index 00000000..a9b7b0d3 --- /dev/null +++ b/plugins/wasm-go/extensions/nginx-rewrite-compatible/main_test.go @@ -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) +} diff --git a/plugins/wasm-go/extensions/nginx-rewrite-compatible/pkg/config.go b/plugins/wasm-go/extensions/nginx-rewrite-compatible/pkg/config.go new file mode 100644 index 00000000..4c6940b4 --- /dev/null +++ b/plugins/wasm-go/extensions/nginx-rewrite-compatible/pkg/config.go @@ -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 +} diff --git a/plugins/wasm-go/extensions/nginx-rewrite-compatible/pkg/rewrite.go b/plugins/wasm-go/extensions/nginx-rewrite-compatible/pkg/rewrite.go new file mode 100644 index 00000000..3d24ffee --- /dev/null +++ b/plugins/wasm-go/extensions/nginx-rewrite-compatible/pkg/rewrite.go @@ -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, "; ") +}