mirror of
https://github.com/alibaba/higress.git
synced 2026-02-06 23:21:08 +08:00
feat: add replay protection plugin (#1672)
Co-authored-by: hanxiantao <601803023@qq.com>
This commit is contained in:
110
plugins/wasm-go/extensions/replay-protection/README.md
Normal file
110
plugins/wasm-go/extensions/replay-protection/README.md
Normal file
@@ -0,0 +1,110 @@
|
||||
---
|
||||
title: 防重放攻击
|
||||
keywords: [higress,replay-protection]
|
||||
description: 防重放攻击插件配置参考
|
||||
---
|
||||
|
||||
## 功能说明
|
||||
|
||||
防重放插件通过验证请求中的一次性随机数来防止请求重放攻击。每个请求都需要携带一个唯一的 nonce 值,服务器会记录并校验这个值的唯一性,从而防止请求被恶意重放
|
||||
|
||||
具体包含一下功能:
|
||||
|
||||
- **强制或可选的 nonce 校验**:可根据配置决定是否强制要求请求携带 nonce 值。
|
||||
- **基于 Redis 的 nonce 唯一性验证**:通过 Redis 存储和校验 nonce 值,确保其唯一性。
|
||||
- **可配置的 nonce 有效期**:支持设置 nonce 的有效期,过期后自动失效。
|
||||
- **nonce 格式和长度校验**:支持对 nonce 值的格式(Base64)和长度进行验证。
|
||||
- **自定义错误响应**:支持配置拒绝请求时的状态码和错误信息。
|
||||
- **可自定义 nonce 请求头**:可以自定义携带 nonce 的请求头名称。
|
||||
|
||||
## 运行属性
|
||||
|
||||
插件执行阶段:`认证阶段`
|
||||
插件执行优先级:`800`
|
||||
|
||||
## 配置字段
|
||||
|
||||
| 名称 | 数据类型 | 必填 | 默认值 | 描述 |
|
||||
|----------------------|--------|------|-----------------|---------------------------------|
|
||||
| `force_nonce` | bool | 否 | true | 是否强制要求请求携带 nonce 值 |
|
||||
| `nonce_header` | string | 否 | `X-Higress-Nonce` | 指定携带 nonce 值的请求头名称 |
|
||||
| `nonce_ttl` | int | 否 | 900 | nonce 的有效期,单位秒 |
|
||||
| `nonce_min_length` | int | 否 | 8 | nonce 值的最小长度 |
|
||||
| `nonce_max_length` | int | 否 | 128 | nonce 值的最大长度 |
|
||||
| `reject_code` | int | 否 | 429 | 拒绝请求时返回的状态码 |
|
||||
| `reject_msg` | string | 否 | `Replay Attack Detected` | 拒绝请求时返回的错误信息 |
|
||||
| `validate_base64` | bool | 否 | false | 是否校验 nonce 的 base64 编码格式 |
|
||||
| `redis` | Object | 是 | - | redis 相关配置 |
|
||||
|
||||
`redis` 中每一项的配置字段说明
|
||||
|
||||
| 名称 | 数据类型 | 必填 | 默认值 | 描述|
|
||||
| -------------- | -------- | ---- |---------------------| --------------------------------------- |
|
||||
| `service_name` | string | 是 | - | redis 服务名称,带服务类型的完整 FQDN 名称,例如 my-redis.dns、redis.my-ns.svc.cluster.local |
|
||||
| `service_port` | int | 否 | 6379 | redis 服务端口|
|
||||
| `username` | string | 否 | - | redis 用户名|
|
||||
| `password` | string | 否 | - | redis 密码|
|
||||
| `timeout` | int | 否 | 1000 | redis 连接超时时间,单位毫秒 |
|
||||
| `database` | int | 否 | 0 | 使用的数据库id,例如配置为1,对应`SELECT 1`|
|
||||
| `key_prefix` | string | 否 | `replay-protection` | redis 键前缀,用于区分不同的 nonce 键 |
|
||||
|
||||
## 配置示例
|
||||
|
||||
以下是一个防重放攻击插件的完整配置示例:
|
||||
|
||||
```yaml
|
||||
force_nonce: true
|
||||
nonce_header: "X-Higress-Nonce" # 指定 nonce 请求头名称
|
||||
nonce_ttl: 900 # nonce 有效期,设置为 900 秒
|
||||
nonce_min_length: 8 # nonce 的最小长度
|
||||
nonce_max_length: 128 # nonce 的最大长度
|
||||
validate_base64: true # 是否开启 base64 格式校验
|
||||
reject_code: 429 # 当拒绝请求时返回的 HTTP 状态码
|
||||
reject_msg: "Replay Attack Detected" # 拒绝请求时返回的错误信息内容
|
||||
redis:
|
||||
service_name: redis.static # Redis 服务的名称
|
||||
service_port: 80 # Redis 服务所使用的端口
|
||||
timeout: 1000 # Redis 操作的超时时间(单位:毫秒)
|
||||
key_prefix: "replay-protection" # Redis 中键的前缀
|
||||
```
|
||||
|
||||
## 使用说明
|
||||
|
||||
### 请求头要求
|
||||
|
||||
| 请求头名称 | 是否必须 | 说明 |
|
||||
|-----------------|----------------|------------------------------------------|
|
||||
| `X-Higress-Nonce` | 根据 `force_nonce` 配置决定 | 请求中携带的随机生成的 nonce 值,需符合 Base64 格式。 |
|
||||
|
||||
> **注意**:可以通过 `nonce_header` 配置自定义请求头名称,默认值为 `X-Higress-Nonce`。
|
||||
|
||||
### 使用示例
|
||||
|
||||
```bash
|
||||
# Generate nonce
|
||||
nonce=$(openssl rand -base64 32)
|
||||
|
||||
# Send request
|
||||
curl -X POST 'https://api.example.com/path' \
|
||||
-H "X-Higress-Nonce: $nonce" \
|
||||
-d '{"key": "value"}'
|
||||
```
|
||||
|
||||
## 返回结果
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 429,
|
||||
"message": "Replay Attack Detected"
|
||||
}
|
||||
```
|
||||
|
||||
## 错误响应示例
|
||||
|
||||
| 错误场景 | 状态码 | 错误信息 |
|
||||
|------------------------|-------|--------------------|
|
||||
| 缺少 nonce 请求头 | 400 | `Missing Required Header` |
|
||||
| nonce 长度不符合要求 | 400 | `Invalid Nonce` |
|
||||
| nonce 格式不符合 Base64 | 400 | `Invalid Nonce` |
|
||||
| nonce 已被使用(重放攻击) | 429 | `Replay Attack Detected` |
|
||||
|
||||
109
plugins/wasm-go/extensions/replay-protection/README_EN.md
Normal file
109
plugins/wasm-go/extensions/replay-protection/README_EN.md
Normal file
@@ -0,0 +1,109 @@
|
||||
---
|
||||
title: Replay Attack Prevention
|
||||
keywords: [higress, replay-protection]
|
||||
description: Configuration reference for the replay attack prevention plugin
|
||||
---
|
||||
|
||||
## Functional Description
|
||||
|
||||
The replay prevention plugin prevents request replay attacks by verifying the one-time random number in the request. Each request needs to carry a unique nonce value. The server will record and verify the uniqueness of this value, thus preventing requests from being maliciously replayed.
|
||||
|
||||
Specifically, it includes the following functions:
|
||||
|
||||
- **Mandatory or Optional Nonce Verification**: It can be configured to determine whether requests are required to carry a nonce value.
|
||||
- **Nonce Uniqueness Verification Based on Redis**: The nonce value is stored and verified in Redis to ensure its uniqueness.
|
||||
- **Configurable Nonce Validity Period**: It supports setting the validity period of the nonce, which will automatically expire after the period.
|
||||
- **Nonce Format and Length Verification**: It supports verifying the format (Base64) and length of the nonce value.
|
||||
- **Custom Error Response**: It supports configuring the status code and error message when a request is rejected.
|
||||
- **Customizable Nonce Request Header**: The name of the request header carrying the nonce can be customized.
|
||||
|
||||
## Runtime Attributes
|
||||
|
||||
Plugin execution stage: `Authentication Stage`
|
||||
Plugin execution priority: `800`
|
||||
|
||||
## Configuration Fields
|
||||
|
||||
| Name | Data Type | Required | Default Value | Description |
|
||||
|----------------------|--------|------|-----------------|---------------------------------|
|
||||
| `force_nonce` | bool | No | true | Whether requests are required to carry a nonce value. |
|
||||
| `nonce_header` | string | No | `X-Higress-Nonce` | Specifies the name of the request header carrying the nonce value. |
|
||||
| `nonce_ttl` | int | No | 900 | The validity period of the nonce, in seconds. |
|
||||
| `nonce_min_length` | int | No | 8 | The minimum length of the nonce value. |
|
||||
| `nonce_max_length` | int | No | 128 | The maximum length of the nonce value. |
|
||||
| `reject_code` | int | No | 429 | The status code returned when a request is rejected. |
|
||||
| `reject_msg` | string | No | `Replay Attack Detected` | The error message returned when a request is rejected. |
|
||||
| `validate_base64` | bool | No | false | Whether to verify the Base64 encoding format of the nonce. |
|
||||
| `redis` | Object | Yes | - | Redis-related configuration |
|
||||
|
||||
Description of each configuration field in `redis`
|
||||
|
||||
| Name | Data Type | Required | Default Value | Description|
|
||||
| -------------- | -------- | ---- |---------------------| --------------------------------------- |
|
||||
| `service_name` | string | Yes | - | The name of the Redis service, the complete FQDN name with the service type, such as my-redis.dns, redis.my-ns.svc.cluster.local. |
|
||||
| `service_port` | int | No | 6379 | The port of the Redis service. |
|
||||
| `username` | string | No | - | The username of Redis. |
|
||||
| `password` | string | No | - | The password of Redis. |
|
||||
| `timeout` | int | No | 1000 | The connection timeout time of Redis, in milliseconds. |
|
||||
| `database` | int | No | 0 | The ID of the database to be used. For example, if it is configured as 1, it corresponds to `SELECT 1`. |
|
||||
| `key_prefix` | string | No | `replay-protection` | The key prefix of Redis, used to distinguish different nonce keys. |
|
||||
|
||||
## Configuration Example
|
||||
|
||||
The following is a complete configuration example of the replay attack prevention plugin:
|
||||
|
||||
```yaml
|
||||
force_nonce: true
|
||||
nonce_header: "X-Higress-Nonce" # Specifies the name of the nonce request header
|
||||
nonce_ttl: 900 # The validity period of the nonce, set to 900 seconds
|
||||
nonce_min_length: 8 # The minimum length of the nonce
|
||||
nonce_max_length: 128 # The maximum length of the nonce
|
||||
validate_base64: true # Whether to enable Base64 format verification
|
||||
reject_code: 429 # The HTTP status code returned when a request is rejected
|
||||
reject_msg: "Replay Attack Detected" # The error message content returned when a request is rejected
|
||||
redis:
|
||||
service_name: redis.static # The name of the Redis service
|
||||
service_port: 80 # The port used by the Redis service
|
||||
timeout: 1000 # The timeout time of Redis operations (unit: milliseconds)
|
||||
key_prefix: "replay-protection" # The key prefix in Redis
|
||||
```
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
### Request Header Requirements
|
||||
|
||||
| Request Header Name | Required | Description |
|
||||
|-----------------|----------------|------------------------------------------|
|
||||
| `X-Higress-Nonce` | Determined by the `force_nonce` configuration | The randomly generated nonce value carried in the request, which needs to conform to the Base64 format. |
|
||||
|
||||
> **Note**: The name of the request header can be customized through the `nonce_header` configuration. The default value is `X-Higress-Nonce`.
|
||||
|
||||
### Usage Example
|
||||
|
||||
```bash
|
||||
# Generate nonce
|
||||
nonce=$(openssl rand -base64 32)
|
||||
|
||||
# Send request
|
||||
curl -X POST 'https://api.example.com/path' \
|
||||
-H "X-Higress-Nonce: $nonce" \
|
||||
-d '{"key": "value"}'
|
||||
```
|
||||
|
||||
## Return Results
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 429,
|
||||
"message": "Replay Attack Detected"
|
||||
}
|
||||
```
|
||||
|
||||
## Error Response Examples
|
||||
|
||||
| Error Scenario | Status Code | Error Message |
|
||||
|------------------------|-------|--------------------|
|
||||
| Missing nonce request header | 400 | `Missing Required Header` |
|
||||
| Nonce length does not meet the requirements | 400 | `Invalid Nonce` |
|
||||
| Nonce format does not conform to Base64 | 400 | `Invalid Nonce` |
|
||||
| Nonce has been used (replay attack) | 429 | `Replay Attack Detected` |
|
||||
1
plugins/wasm-go/extensions/replay-protection/VERSION
Normal file
1
plugins/wasm-go/extensions/replay-protection/VERSION
Normal file
@@ -0,0 +1 @@
|
||||
1.0.0-alpha
|
||||
107
plugins/wasm-go/extensions/replay-protection/config/config.go
Normal file
107
plugins/wasm-go/extensions/replay-protection/config/config.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
type ReplayProtectionConfig struct {
|
||||
ForceNonce bool // Whether to enforce nonce verification
|
||||
NonceTTL int // Expiration time of the nonce (in seconds)
|
||||
Redis RedisConfig
|
||||
NonceMinLen int // Minimum length of the nonce
|
||||
NonceMaxLen int // Maximum length of the nonce
|
||||
NonceHeader string // Name of the nonce header
|
||||
ValidateBase64 bool // Whether to validate base64 encoding format
|
||||
RejectCode uint32 // Response code
|
||||
RejectMsg string // Response body
|
||||
}
|
||||
|
||||
type RedisConfig struct {
|
||||
Client wrapper.RedisClient
|
||||
KeyPrefix string
|
||||
}
|
||||
|
||||
func ParseConfig(json gjson.Result, config *ReplayProtectionConfig, log wrapper.Log) error {
|
||||
// Parse Redis configuration
|
||||
redisConfig := json.Get("redis")
|
||||
if !redisConfig.Exists() {
|
||||
return fmt.Errorf("missing redis config")
|
||||
}
|
||||
|
||||
serviceName := redisConfig.Get("service_name").String()
|
||||
if serviceName == "" {
|
||||
return fmt.Errorf("redis service name is required")
|
||||
}
|
||||
|
||||
servicePort := redisConfig.Get("service_port").Int()
|
||||
if servicePort == 0 {
|
||||
if strings.HasSuffix(serviceName, ".static") {
|
||||
servicePort = 80 // default logic port for static service
|
||||
} else {
|
||||
servicePort = 6379
|
||||
}
|
||||
}
|
||||
|
||||
username := redisConfig.Get("username").String()
|
||||
password := redisConfig.Get("password").String()
|
||||
timeout := redisConfig.Get("timeout").Int()
|
||||
if timeout == 0 {
|
||||
timeout = 1000
|
||||
}
|
||||
|
||||
// Initialize Redis client
|
||||
config.Redis.Client = wrapper.NewRedisClusterClient(wrapper.FQDNCluster{
|
||||
FQDN: serviceName,
|
||||
Port: servicePort,
|
||||
})
|
||||
database := int(redisConfig.Get("database").Int())
|
||||
if err := config.Redis.Client.Init(username, password, timeout, wrapper.WithDataBase(database)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
keyPrefix := redisConfig.Get("key_prefix").String()
|
||||
if keyPrefix == "" {
|
||||
keyPrefix = "replay-protection"
|
||||
}
|
||||
config.Redis.KeyPrefix = keyPrefix
|
||||
|
||||
config.NonceHeader = json.Get("nonce_header").String()
|
||||
if config.NonceHeader == "" {
|
||||
config.NonceHeader = "X-Higress-Nonce"
|
||||
}
|
||||
|
||||
config.ValidateBase64 = json.Get("validate_base64").Bool()
|
||||
|
||||
config.RejectCode = uint32(json.Get("reject_code").Int())
|
||||
if config.RejectCode == 0 {
|
||||
config.RejectCode = 429
|
||||
}
|
||||
|
||||
config.RejectMsg = json.Get("reject_msg").String()
|
||||
if config.RejectMsg == "" {
|
||||
config.RejectMsg = "Replay Attack Detected"
|
||||
}
|
||||
|
||||
config.ForceNonce = json.Get("force_nonce").Bool()
|
||||
|
||||
config.NonceTTL = int(json.Get("nonce_ttl").Int())
|
||||
if config.NonceTTL == 0 {
|
||||
config.NonceTTL = 900
|
||||
}
|
||||
|
||||
config.NonceMinLen = int(json.Get("nonce_min_length").Int())
|
||||
if config.NonceMinLen == 0 {
|
||||
config.NonceMinLen = 8
|
||||
}
|
||||
|
||||
config.NonceMaxLen = int(json.Get("nonce_max_length").Int())
|
||||
if config.NonceMaxLen == 0 {
|
||||
config.NonceMaxLen = 128
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
22
plugins/wasm-go/extensions/replay-protection/go.mod
Normal file
22
plugins/wasm-go/extensions/replay-protection/go.mod
Normal file
@@ -0,0 +1,22 @@
|
||||
module replay-protection
|
||||
|
||||
go 1.19
|
||||
|
||||
replace github.com/alibaba/higress/plugins/wasm-go => ../..
|
||||
|
||||
require (
|
||||
github.com/alibaba/higress/plugins/wasm-go v1.4.2
|
||||
github.com/higress-group/proxy-wasm-go-sdk v1.0.0
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/tidwall/resp v0.1.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 // indirect
|
||||
github.com/magefile/mage v1.14.0 // indirect
|
||||
github.com/tetratelabs/wazero v1.7.1 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/wasilibs/go-re2 v1.5.3 // indirect
|
||||
)
|
||||
24
plugins/wasm-go/extensions/replay-protection/go.sum
Normal file
24
plugins/wasm-go/extensions/replay-protection/go.sum
Normal file
@@ -0,0 +1,24 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 h1:IHDghbGQ2DTIXHBHxWfqCYQW1fKjyJ/I7W1pMyUDeEA=
|
||||
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520/go.mod h1:Nz8ORLaFiLWotg6GeKlJMhv8cci8mM43uEnLA5t8iew=
|
||||
github.com/higress-group/proxy-wasm-go-sdk v1.0.0 h1:BZRNf4R7jr9hwRivg/E29nkVaKEak5MWjBDhWjuHijU=
|
||||
github.com/higress-group/proxy-wasm-go-sdk v1.0.0/go.mod h1:iiSyFbo+rAtbtGt/bsefv8GU57h9CCLYGJA74/tF5/0=
|
||||
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
|
||||
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/tetratelabs/wazero v1.7.1 h1:QtSfd6KLc41DIMpDYlJdoMc6k7QTN246DM2+n2Y/Dx8=
|
||||
github.com/tetratelabs/wazero v1.7.1/go.mod h1:ytl6Zuh20R/eROuyDaGPkp82O9C/DJfXAwJfQ3X6/7Y=
|
||||
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 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/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/wasilibs/go-re2 v1.5.3 h1:wiuTcgDZdLhu8NG8oqF5sF5Q3yIU14lPAvXqeYzDK3g=
|
||||
github.com/wasilibs/go-re2 v1.5.3/go.mod h1:PzpVPsBdFC7vM8QJbbEnOeTmwA0DGE783d/Gex8eCV8=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
77
plugins/wasm-go/extensions/replay-protection/main.go
Normal file
77
plugins/wasm-go/extensions/replay-protection/main.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
|
||||
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
|
||||
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
|
||||
"github.com/tidwall/resp"
|
||||
"replay-protection/config"
|
||||
"replay-protection/util"
|
||||
)
|
||||
|
||||
func main() {
|
||||
wrapper.SetCtx(
|
||||
"replay-protection",
|
||||
wrapper.ParseConfigBy(config.ParseConfig),
|
||||
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
|
||||
)
|
||||
}
|
||||
|
||||
func onHttpRequestHeaders(ctx wrapper.HttpContext, cfg config.ReplayProtectionConfig, log wrapper.Log) types.Action {
|
||||
nonce, _ := proxywasm.GetHttpRequestHeader(cfg.NonceHeader)
|
||||
if cfg.ForceNonce && nonce == "" {
|
||||
// In force mode, reject the request if a required header is missing.
|
||||
// Do not return the specific header name in the response.
|
||||
log.Warnf("missing nonce header")
|
||||
proxywasm.SendHttpResponse(400, nil, []byte("Missing Required Header"), -1)
|
||||
return types.ActionPause
|
||||
}
|
||||
|
||||
// If there is no nonce, pass through directly (when not in force mode)
|
||||
if nonce == "" {
|
||||
return types.ActionContinue
|
||||
}
|
||||
|
||||
if err := validateNonce(nonce, &cfg); err != nil {
|
||||
log.Warnf("invalid nonce: %v", err)
|
||||
proxywasm.SendHttpResponse(400, nil, []byte("Invalid Nonce"), -1)
|
||||
return types.ActionPause
|
||||
}
|
||||
|
||||
redisKey := fmt.Sprintf("%s:%s", cfg.Redis.KeyPrefix, nonce)
|
||||
|
||||
// Check if the nonce already exists
|
||||
err := cfg.Redis.Client.SetNX(redisKey, "1", cfg.NonceTTL, func(response resp.Value) {
|
||||
if response.Error() != nil {
|
||||
log.Errorf("redis call error: %v", response.Error())
|
||||
proxywasm.ResumeHttpRequest()
|
||||
} else if response.String() != "OK" {
|
||||
log.Warnf("duplicate nonce detected: %s", nonce)
|
||||
proxywasm.SendHttpResponse(cfg.RejectCode, nil, []byte(cfg.RejectMsg), -1)
|
||||
} else {
|
||||
proxywasm.ResumeHttpRequest()
|
||||
}
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("redis call failed: %v", err)
|
||||
return types.ActionContinue
|
||||
}
|
||||
return types.ActionPause
|
||||
}
|
||||
|
||||
func validateNonce(nonce string, cfg *config.ReplayProtectionConfig) error {
|
||||
nonceLength := len(nonce)
|
||||
if nonceLength < cfg.NonceMinLen || nonceLength > cfg.NonceMaxLen {
|
||||
return fmt.Errorf("invalid nonce length: must be between %d and %d",
|
||||
cfg.NonceMinLen, cfg.NonceMaxLen)
|
||||
}
|
||||
|
||||
if cfg.ValidateBase64 && !util.IsValidBase64(nonce) {
|
||||
return fmt.Errorf("invalid nonce format: must be base64 encoded")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package util
|
||||
|
||||
import re "github.com/wasilibs/go-re2"
|
||||
|
||||
// IsValidBase64 checks if a string is a valid base64 encoded string
|
||||
func IsValidBase64(s string) bool {
|
||||
return re.MustCompile(`^[a-zA-Z0-9+/=-]+$`).MatchString(s)
|
||||
}
|
||||
@@ -44,6 +44,7 @@ type RedisClient interface {
|
||||
Get(key string, callback RedisResponseCallback) error
|
||||
Set(key string, value interface{}, callback RedisResponseCallback) error
|
||||
SetEx(key string, value interface{}, ttl int, callback RedisResponseCallback) error
|
||||
SetNX(key string, value interface{}, ttl int, callback RedisResponseCallback) error
|
||||
MGet(keys []string, callback RedisResponseCallback) error
|
||||
MSet(kvMap map[string]interface{}, callback RedisResponseCallback) error
|
||||
Incr(key string, callback RedisResponseCallback) error
|
||||
@@ -308,6 +309,22 @@ func (c *RedisClusterClient[C]) SetEx(key string, value interface{}, ttl int, ca
|
||||
return RedisCall(c.cluster, respString(args), callback)
|
||||
}
|
||||
|
||||
func (c *RedisClusterClient[C]) SetNX(key string, value interface{}, ttl int, callback RedisResponseCallback) error {
|
||||
if err := c.checkReadyFunc(); err != nil {
|
||||
return err
|
||||
}
|
||||
args := make([]interface{}, 0)
|
||||
args = append(args, "set")
|
||||
args = append(args, key)
|
||||
args = append(args, value)
|
||||
args = append(args, "nx")
|
||||
if ttl > 0 {
|
||||
args = append(args, "ex")
|
||||
args = append(args, ttl)
|
||||
}
|
||||
return RedisCall(c.cluster, respString(args), callback)
|
||||
}
|
||||
|
||||
func (c *RedisClusterClient[C]) MGet(keys []string, callback RedisResponseCallback) error {
|
||||
if err := c.checkReadyFunc(); err != nil {
|
||||
return err
|
||||
|
||||
139
test/e2e/conformance/tests/go-wasm-replay-protection.go
Normal file
139
test/e2e/conformance/tests/go-wasm-replay-protection.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) 2025 Alibaba Group Holding Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package tests
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
|
||||
"github.com/alibaba/higress/test/e2e/conformance/utils/http"
|
||||
"github.com/alibaba/higress/test/e2e/conformance/utils/suite"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(WasmPluginsReplayProtection)
|
||||
}
|
||||
|
||||
func generateBase64Nonce(length int) string {
|
||||
bytes := make([]byte, length)
|
||||
rand.Read(bytes)
|
||||
return base64.StdEncoding.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
var WasmPluginsReplayProtection = suite.ConformanceTest{
|
||||
ShortName: "WasmPluginsReplayProtection",
|
||||
Description: "The replay protection wasm plugin prevents replay attacks by validating request nonce.",
|
||||
Manifests: []string{"tests/go-wasm-replay-protection.yaml"},
|
||||
Features: []suite.SupportedFeature{suite.WASMGoConformanceFeature},
|
||||
Test: func(t *testing.T, suite *suite.ConformanceTestSuite) {
|
||||
replayNonce := generateBase64Nonce(32)
|
||||
testcases := []http.Assertion{
|
||||
{
|
||||
Meta: http.AssertionMeta{
|
||||
TestCaseName: "Missing nonce header",
|
||||
CompareTarget: http.CompareTargetResponse,
|
||||
},
|
||||
Request: http.AssertionRequest{
|
||||
ActualRequest: http.Request{
|
||||
Host: "foo.com",
|
||||
Path: "/",
|
||||
Method: "GET",
|
||||
},
|
||||
},
|
||||
Response: http.AssertionResponse{
|
||||
ExpectedResponse: http.Response{
|
||||
StatusCode: 400,
|
||||
ContentType: http.ContentTypeTextPlain,
|
||||
Body: []byte(`Missing Required Header`),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Meta: http.AssertionMeta{
|
||||
TestCaseName: "Invalid nonce not base64 encoded",
|
||||
CompareTarget: http.CompareTargetResponse,
|
||||
},
|
||||
Request: http.AssertionRequest{
|
||||
ActualRequest: http.Request{
|
||||
Host: "foo.com",
|
||||
Path: "/",
|
||||
Method: "GET",
|
||||
Headers: map[string]string{
|
||||
"X-Higress-Nonce": "invalid nonce",
|
||||
},
|
||||
},
|
||||
},
|
||||
Response: http.AssertionResponse{
|
||||
ExpectedResponse: http.Response{
|
||||
StatusCode: 400,
|
||||
ContentType: http.ContentTypeTextPlain,
|
||||
Body: []byte(`Invalid Nonce`),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Meta: http.AssertionMeta{
|
||||
TestCaseName: "First request with unique nonce returns 200",
|
||||
CompareTarget: http.CompareTargetResponse,
|
||||
},
|
||||
Request: http.AssertionRequest{
|
||||
ActualRequest: http.Request{
|
||||
Host: "foo.com",
|
||||
Path: "/",
|
||||
Method: "GET",
|
||||
Headers: map[string]string{
|
||||
"X-Higress-Nonce": replayNonce,
|
||||
},
|
||||
},
|
||||
},
|
||||
Response: http.AssertionResponse{
|
||||
ExpectedResponse: http.Response{
|
||||
StatusCode: 200,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Meta: http.AssertionMeta{
|
||||
TestCaseName: "Second request with repeated nonce returns 429",
|
||||
CompareTarget: http.CompareTargetResponse,
|
||||
},
|
||||
Request: http.AssertionRequest{
|
||||
ActualRequest: http.Request{
|
||||
Host: "foo.com",
|
||||
Path: "/",
|
||||
Method: "GET",
|
||||
Headers: map[string]string{
|
||||
"X-Higress-Nonce": replayNonce,
|
||||
},
|
||||
},
|
||||
},
|
||||
Response: http.AssertionResponse{
|
||||
ExpectedResponse: http.Response{
|
||||
StatusCode: 429,
|
||||
ContentType: http.ContentTypeTextPlain,
|
||||
Body: []byte(`Replay Attack Detected`),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("WasmPlugins replay-protection", func(t *testing.T) {
|
||||
for _, testcase := range testcases {
|
||||
http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, suite.GatewayAddress, testcase)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
88
test/e2e/conformance/tests/go-wasm-replay-protection.yaml
Normal file
88
test/e2e/conformance/tests/go-wasm-replay-protection.yaml
Normal file
@@ -0,0 +1,88 @@
|
||||
# Copyright (c) 2025 Alibaba Group Holding Ltd.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Deploy Redis service
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: higress-conformance-infra
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:6.2
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: higress-conformance-infra
|
||||
spec:
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: 6379
|
||||
selector:
|
||||
app: redis
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: replay-protection
|
||||
namespace: higress-conformance-infra
|
||||
spec:
|
||||
ingressClassName: higress
|
||||
rules:
|
||||
- host: "foo.com"
|
||||
http:
|
||||
paths:
|
||||
- pathType: Prefix
|
||||
path: "/"
|
||||
backend:
|
||||
service:
|
||||
name: infra-backend-v1
|
||||
port:
|
||||
number: 8080
|
||||
---
|
||||
# Configure WasmPlugin
|
||||
apiVersion: extensions.higress.io/v1alpha1
|
||||
kind: WasmPlugin
|
||||
metadata:
|
||||
name: replay-protection
|
||||
namespace: higress-system
|
||||
spec:
|
||||
defaultConfig:
|
||||
force_nonce: true
|
||||
nonce_ttl: 86400
|
||||
nonce_header: "X-Higress-Nonce"
|
||||
nonce_min_length: 8
|
||||
nonce_max_length: 128
|
||||
validate_base64: true
|
||||
reject_code: 429
|
||||
redis:
|
||||
service_name: "redis.higress-conformance-infra.svc.cluster.local"
|
||||
service_port: 6379
|
||||
timeout: 1000
|
||||
key_prefix: "replay-protection"
|
||||
url: file:///opt/plugins/wasm-go/extensions/replay-protection/plugin.wasm
|
||||
Reference in New Issue
Block a user