mirror of
https://github.com/alibaba/higress.git
synced 2026-05-21 03:07:27 +08:00
feat: add nginx rewrite compatible wasm plugin (#3823)
Signed-off-by: johnlanni <zty98751@alibaba-inc.com>
This commit is contained in:
255
plugins/wasm-go/extensions/nginx-rewrite-compatible/README.md
Normal file
255
plugins/wasm-go/extensions/nginx-rewrite-compatible/README.md
Normal 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
|
||||
```
|
||||
255
plugins/wasm-go/extensions/nginx-rewrite-compatible/README_EN.md
Normal file
255
plugins/wasm-go/extensions/nginx-rewrite-compatible/README_EN.md
Normal 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
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
1.0.0
|
||||
24
plugins/wasm-go/extensions/nginx-rewrite-compatible/go.mod
Normal file
24
plugins/wasm-go/extensions/nginx-rewrite-compatible/go.mod
Normal 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
|
||||
)
|
||||
30
plugins/wasm-go/extensions/nginx-rewrite-compatible/go.sum
Normal file
30
plugins/wasm-go/extensions/nginx-rewrite-compatible/go.sum
Normal 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=
|
||||
49
plugins/wasm-go/extensions/nginx-rewrite-compatible/main.go
Normal file
49
plugins/wasm-go/extensions/nginx-rewrite-compatible/main.go
Normal 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
|
||||
}
|
||||
332
plugins/wasm-go/extensions/nginx-rewrite-compatible/main_test.go
Normal file
332
plugins/wasm-go/extensions/nginx-rewrite-compatible/main_test.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "; ")
|
||||
}
|
||||
Reference in New Issue
Block a user