基于openresty开发waf防火墙

发布时间 2023-03-30 11:24:57作者: zh7314

2023年3月30日11:12:01

安装openresty

你可以在你的 RHEL 系统中添加 openresty 仓库,这样就可以便于未来安装或更新我们的软件包(通过 yum check-update 命令)。添加仓库,运行下面的命令(对于 RHEL 8 或以上版本,应将下面的 yum 都替换成 dnf):

add the yum repo:

wget https://openresty.org/package/rhel/openresty.repo
sudo mv openresty.repo /etc/yum.repos.d/

update the yum index:

sudo yum check-update

添加了包仓库之后就可以像下面这样安装软件包,比如 openresty:

sudo yum install -y openresty

如果你想安装命令行工具 resty,那么可以像下面这样安装 openresty-resty 包:

sudo yum install -y openresty-resty

命令行工具 opm 在 openresty-opm 包里,而 restydoc 工具在 openresty-doc 包里头。

安装opm

yum install openresty-opm

安装waf

opm get codiy1992/lua-resty-waf

如果你不需要自己开发,将就的使用就直接使用

文档:https://github.com/codiy1992/lua-resty-waf

项目说明

0. 安装使用

  • 本项目基于 OpenResty,所以需要先安装好 OpenResty, Linux各发行版安装详见OpenResty® Linux 包
  • 通过 OpenResty 的包管理器 opm 安装本项目 opm get codiy1992/lua-resty-waf
  • 如下配置nginx, 即可正常工作
http {
    # 在 http 区块添加如下设定
    lua_code_cache on;
    lua_need_request_body on;
    lua_shared_dict waf 32k;
    lua_shared_dict list 10m;
    lua_shared_dict limiter 10m;
    lua_shared_dict counter 10m;
    lua_shared_dict sampler 10m;
    init_worker_by_lua_block {
        if ngx.worker.id() == 0 then
            ngx.timer.at(0, require("resty.waf").init)
        end
    }
    access_by_lua_block {
        local waf = require("resty.waf")
        waf.run({
            "manager",
            "filter",
            "limiter",
            "counter",
            "sampler",
        })
    }
}

1. 几个共享内存

当可用内存不足时, 将自动覆盖最久未被使用的未过期key

  • lua_shared_dict waf 32k; 存放 waf 配置等信息
  • lua_shared_dict list 10m; 存放ip/device/uid名单, 用于提供matcher之外的匹配功能
  • lua_shared_dict limiter 10m; 存放请求频率限制信息
  • lua_shared_dict counter 10m; 存放请求次数统计信息
  • lua_shared_dict sampler 10m; 存放采样器的采样信息

2. 执行流程

  • init_worker_by_lua 阶段, 读入默认配置, 并从 redis 获取最新配置信息, 合并两者放入共享内存
  • access_by_lua 阶段, 从共享内存读取配置, 顺序执行对应模块

3. 配置的结构

配置由三大部分组成如下

  • matchers 一些匹配规则, 可在各模块间共用, 用于匹配特定请求
  • responses 自定义响应格式, 可在各模块间共用, 用于waf模块内的http响应
  • modules 模块配置, 包含 manager, filter, limiter, counter, sampler 五大模块

3.1 Matcher

在模块内根据HTTP请求的 ip, uri, args, header, body, user_agent, referer 等信息匹配请求, 匹配命中的请求将在模块内进行下一步操作比如,限制访问直接返回或者记录请求频次等

matcher里的操作符(operator)

  • * 默认返回 true, 即默认匹配
  • = 判断两个值否相等, 字符串将忽略大小写
  • == 判断两个值是否相等, 大小写敏感
  • != 判断两个值是否不相等
  • 判断字符串是否包含于另一字符串中, 或匹配正则
  • !≈ 判断字符串是否不包含在另一字符串中, 或不匹配正则
  • # 判断某个值是否出现在table
  • Exist 判断某值是否不为nil
  • !Exist or ! 判断某值是否为nil

以下为内置的默认配置, 可以根据需求使用redis或者/waf/config接口进行配置:

{
    "any": {}, // 匹配任意请求, 可以有其他名字, 如 `"*": {}`
    "attack_sql": {// 从args中匹配sql注入字符, 默认配置仅提供简单示例, 可以自行增加/修改配置
        "Args": {
            "name": ".*",
            "operator": "≈",
            "value": "select.*from"
        }
    },
    "attack_file_ext": {// 匹配URI中以特定字符结尾的请求
        "URI": {
            "value": "\\.(htaccess|bash_history|ssh|sql)$",
            "operator": "≈"
        }
    },
    "attack_agent": { // 匹配特定UserAgent请求
        "UserAgent": {
            "value": "(nmap|w3af|netsparker|nikto|fimap|wget)",
            "operator": "≈"
        }
    },
    "post": {
        "Method": {
            "value": "(put|post)",
            "operator": "≈"
        }
    },
    "trusted_referer": {
        "Method": {
            "value": {},
            "operator": "#"
        }
    },
    "wan": { // 匹配来自公网的请求
        "IP": {
            "value": "(10.|192.168|172.1[6-9].|172.2[0-9].|172.3[01].).*",
            "operator": "!≈"
        }
    },
    "app_id": { // 匹配头信息X-App-ID的值出现在value中的请求
        "Header": {
            "name": "x-app-id",
            "operator": "#",
            "value": [
                0
            ]
        }
    },
    "app_version": { // 匹配头信息X-App-Version的值出现在value中的请求
        "Header": {
            "name": "x-app-version",
            "operator": "#",
            "value": [
                "0.0.0"
            ]
        }
    },
    "uid": { // 匹配 Authorization Bearer Token 的 sub 字段
        "UID": {
            "value": [
                0
            ],
            "operator": "#"
        }
    }
}

3.2 Response

用于waf模块拒绝请求时候响应给客户端

默认配置如下, 可自行增加或修改配置

{
    "403": { // 对于各模块规则中的`code`, 不需要与HTTP的`status code`对应
        "status": 403, // HTTP的`status code`
        "body": "{\"code\":\"403\", \"message\":\"403 Forbidden\"}",
        "mime_type": "application/json"
    }
}

3.3 Manager 模块

用于 waf 的管理, 提供一系列以 /waf 开头的路由, 需要通过 Basic Authorizaton 认证
默认账号密码 waf:TTpsXHtI5mwq 或者指定头信息 Authorization: Basic d2FmOlRUcHNYSHRJNW13cQ==

可使用项目根目录下的postman.json导入postman进行使用

路由 METHOD 用途
/waf/status GET 获取状态信息
/waf/config GET 获取当前配置
/waf/config POST 临时变更配置
/waf/config/reload POST 重载配置, 将使/waf/config提交的临时配置失效
/waf/list GET 查看当前list中的名单及其ttl
/waf/list POST 临时增加/修改名单, 在nginx重启或执行/waf/list/reload失效
/waf/list/reload POST 重载名单配置, 将覆盖/waf/list提交的临时配置
/waf/module/limiter GET 查询请求频次限制器情况
/waf/module/counter GET 查询请求计数器统计情况
/waf/module/sampler GET 查询采集器里的采样数据

3.4 Filter 模块

用于过滤请求,流程如下

  • matcher匹配上的请求, 执行放行accept或者拒绝block操作
  • 执行accept将请求交给下一模块处理
  • 执行block将根据过滤规则rule中指定的code 匹配相应response作为返回

模块默认配置如下:

{
    "enable": true, // 可配置关闭此模块, 默认开启
    "rules": [
        {
            "action": "block", // accept or block
            "matcher": "any", // 详见 matcher 说明
            "code": 403, // 执行block时用于匹配对应response
            "enable": true, // 规则开关
            "by": "ip:in_list" // Optional, 使用在nginx共享内存维护的名单(`list`)来扩展matcher功能
        },
        {
            "action": "block",
            "matcher": "any",
            "code": 403,
            "enable": true,
            "by": "device:in_list"
        },
        {
            "action": "block",
            "matcher": "any",
            "code": 403,
            "enable": true,
            "by": "uid:in_list"
        },
        {
            "enable": true,
            "action": "block",
            "matcher": "attack_sql",
            "code": 403
        },
        {
            "enable": true,
            "action": "block",
            "matcher": "attack_file_ext",
            "code": 403
        },
        {
            "enable": true,
            "action": "block",
            "matcher": "attack_agent",
            "code": 403
        },
        {
            "enable": false,
            "action": "block",
            "matcher": "app_id",
            "code": 403
        },
        {
            "enable": false,
            "action": "block",
            "matcher": "app_version",
            "code": 403
        }
    ]
}

3.5 Limiter 模块

用于请求频率限制,对于匹配matcher的请求, 可基于ip,uri,uid,device及其组合建立频率控制规则

模块默认配置如下:

{
    "enable": true, // 可配置关闭此模块, 默认开启
    "rules": [
        { // 每个IP对所有URI,每分钟至多通过60个请求, 超过则拒绝
            "time": 60, // 时间: 单位秒
            "code": 403, // 拒绝时用于匹配对应response的响应码
            "enable": false, // 默认关闭
            "count": 60, // 允许请求数
            "matcher": "any",
            "by": "ip"
        },
        { // 每个IP对单一URI,每分钟至多通过10个请求, 超过则拒绝
            "time": 60,
            "code": 403,
            "enable": false, // 默认关闭
            "count": 10,
            "matcher": "any",
            "by": "ip,uri"
        }
    ]
}

可用接口/waf/module/limiter 查询此模块信息

curl --location --request GET 'http://127.0.0.1/waf/module/limiter' \
--header 'Content-Type: application/json' \
--header 'Authorization: Basic d2FmOlRUcHNYSHRJNW13cQ==' \
--data-raw '{
    "count": 1, // 请求数量 >= 1
    "scale": 1024, // 数据规模设置为0可取全部统计数据,默认1024
    "q": "", // 查询匹配, 可以是字符串或者正则表达式
    "key": "" // 指定要查看的维度(ip, uri, uid, device)
}'

3.6 Counter 模块

统计请求次数,根据 ip, uri, uid device及其任意组合如ip,uri, uri,ip,来统计请求次数

模块默认配置如下:

{
    "enable": true, // 可配置关闭此模块, 默认开启
    "rules": [
        { // 对于任意请求, 按IP统计请求次数, 默认关闭
            "enable": false,
            "matcher": "any",
            "time": 60,
            "by": "ip"
        },
        {// 对于任意请求, 按IP+URI统计请求次数, 默认关闭
            "enable": false,
            "matcher": "any",
            "time": 60,
            "by": "ip,uri"
        }
    ]
}

可用接口/waf/module/limiter 观察统计信息

curl --location --request GET 'http://127.0.0.1/waf/module/counter' \
--header 'Content-Type: application/json' \
--header 'Authorization: Basic d2FmOlRUcHNYSHRJNW13cQ==' \
--data-raw '{
    "count": 1, // 请求数量 >= 1
    "scale": 1024, // 数据规模设置为0可取全部统计数据,默认1024
    "q": "", // 查询匹配, 可以是字符串或者正则表达式
    "key": "" // 指定要查看的维度(ip, uri, uid, device)
}'

3.7 Sampler 模块

采样器, 模块支持两个内置的额外 matcher: filtered, limited 即匹配被过滤或限制的请求, 也可根据其他 matcher 自定义规则.

模块默认配置如下:

{
    "rules": [
        {
            "rate": 25, // 采样率,当达集数据集到size时,依据rate以firt-in-first-out规则替换原有数据
            "size": 10,
            "matcher": "filtered",
            "enable": false
        },
        {
            "rate": 25, // 采样率,当达集数据集到size时,依据rate以firt-in-first-out规则替换原有数据
            "size": 10,
            "matcher": "limited",
            "enable": false
        }
    ],
    "enable": true
}

使用接口 /waf/module/sampler 获取采样数据

curl --location --request GET '127.0.0.1:8080/waf/module/sampler' \
--header 'Authorization: Basic d2FmOlRUcHNYSHRJNW13cQ==' \
--data-raw '{
    "q": "", // 查询字符串
    "all": false, // 是否输出所有采样数据(单一采样规则下的), 默认true
    "pop": false // 取出采样时候是否清空采样队列, 默认true
}'

3.8 完整的默认配置

{
    "matchers": {
        "attack_file_ext": {
            "URI": {
                "operator": "≈",
                "value": "\\.(htaccess|bash_history|ssh|sql)$"
            }
        },
        "app_version": {
            "Header": {
                "value": [
                    "0.0.0"
                ],
                "name": "x-app-version",
                "operator": "#"
            }
        },
        "app_id": {
            "Header": {
                "value": [
                    0
                ],
                "name": "x-app-id",
                "operator": "#"
            }
        },
        "trusted_referer": {
            "Method": {
                "operator": "#",
                "value": {}
            }
        },
        "uid": {
            "UID": {
                "operator": "#",
                "value": [
                    0
                ]
            }
        },
        "attack_agent": {
            "UserAgent": {
                "operator": "≈",
                "value": "(nmap|w3af|netsparker|nikto|fimap|wget)"
            }
        },
        "any": {},
        "attack_sql": {
            "Args": {
                "value": "select.*from",
                "name": ".*",
                "operator": "≈"
            }
        },
        "wan": {
            "IP": {
                "operator": "!≈",
                "value": "(10.|192.168|172.1[6-9].|172.2[0-9].|172.3[01].).*"
            }
        },
        "post": {
            "Method": {
                "operator": "≈",
                "value": "(put|post)"
            }
        }
    },
    "responses": {
        "403": {
            "body": "{\"code\":403, \"message\":\"Forbidden\"}",
            "mime_type": "application/json",
            "status": 403
        }
    },
    "modules": {
        "sampler": {
            "enable": true,
            "rules": [
                {
                    "enable": false,
                    "rate": 25,
                    "matcher": "filtered",
                    "size": 10
                },
                {
                    "enable": false,
                    "rate": 25,
                    "matcher": "limited",
                    "size": 10
                }
            ]
        },
        "manager": {
            "auth": {
                "pass": "TTpsXHtI5mwq",
                "user": "waf"
            },
            "enable": true
        },
        "filter": {
            "enable": true,
            "rules": [
                {
                    "action": "block",
                    "by": "ip:in_list",
                    "enable": true,
                    "matcher": "any",
                    "code": 403
                },
                {
                    "action": "block",
                    "by": "device:in_list",
                    "enable": true,
                    "matcher": "any",
                    "code": 403
                },
                {
                    "action": "block",
                    "by": "uid:in_list",
                    "enable": true,
                    "matcher": "any",
                    "code": 403
                },
                {
                    "enable": true,
                    "action": "block",
                    "matcher": "attack_sql",
                    "code": 403
                },
                {
                    "enable": true,
                    "action": "block",
                    "matcher": "attack_file_ext",
                    "code": 403
                },
                {
                    "enable": true,
                    "action": "block",
                    "matcher": "attack_agent",
                    "code": 403
                },
                {
                    "enable": false,
                    "action": "block",
                    "matcher": "app_id",
                    "code": 403
                },
                {
                    "enable": false,
                    "action": "block",
                    "matcher": "app_version",
                    "code": 403
                }
            ]
        },
        "limiter": {
            "enable": true,
            "rules": [
                {
                    "count": 60,
                    "by": "ip",
                    "enable": false,
                    "code": 403,
                    "time": 60,
                    "matcher": "any"
                },
                {
                    "count": 10,
                    "by": "ip,uri",
                    "enable": false,
                    "code": 403,
                    "time": 60,
                    "matcher": "any"
                }
            ]
        },
        "counter": {
            "enable": true,
            "rules": [
                {
                    "enable": false,
                    "by": "ip",
                    "time": 60,
                    "matcher": "any"
                },
                {
                    "enable": false,
                    "by": "ip,uri",
                    "time": 60,
                    "matcher": "any"
                }
            ]
        }
    }
}

4. 自定义配置(临时生效, 通过HTTP接口)

4.1 自定义配置config

自定义配置将以和默认配置合并, 在nginx重启或者通过接口/waf/config/reload重载配置后失效

配置合并的规则:

  1. 对于模块的rules配置, 只要设置了就会完全替换默认配置, 否则保留默认配置
  2. 对于matchers,responses等采用 修改原有 + 新增 的方式进行合并, 会保留已经存在的默认配置
curl --request POST 'http://127.0.0.1/waf/config' \
--header 'Content-Type: application/json' \
--header 'Authorization: Basic d2FmOlRUcHNYSHRJNW13cQ==' \
--data-raw '{
    "modules": {
        "counter": {
            "enable": true,
            "rules": [
                {
                    "matcher": "any",
                    "by": "ip",
                    "time": 86400,
                    "enable": true
                },
                {
                    "matcher": "any",
                    "by": "ip,uri",
                    "time": 86400,
                    "enable": true
                }
            ]
        }
    }
}'

4.2 自定义配置list

自定义配置将以覆盖模式和当前list合并

curl --location --request POST 'http://127.0.0.1/waf/list' \
--header 'Content-Type: application/json' \
--header 'Authorization: Basic d2FmOlRUcHNYSHRJNW13cQ==' \
--data-raw '{
    "127.0.0.1": 6000, // 将IP:127.0.0.1放入名单, ttl为6000秒
    "30000000": 86400,
    "832489A9-2442-4E87-BD6B-24D85B05FB25": 3600 
}'

5. 自定义配置(持续生效, 通过Redis)

默认读取环境变量REDIS_HOST,REDIS_PORT,REDIS_DB 来获取redis配置, 否则从 /data/.env 读取

5.1 自定义配置config

配置合并的规则:

  1. 对于模块的rules配置, 只要设置了就会完全替换默认配置, 否则保留默认配置
  2. 对于matcher,response等采用 修改原有 + 新增 的方式进行合并, 会保留已经存在的默认配置
  • config存放在 redis 中以 waf:config: 为开头的hset
  • 目前支持几个配置项,
    • waf:config:matchers
    • waf:config:responses
    • waf:config:moduules:manager:auth
    • waf:config:moduules:filter:rules
    • waf:config:moduules:limiter:rules
    • waf:config:moduules:counter:rules
    • waf:config:moduules:sampler:rules
    • waf:config:moduules:filter(仅支持对enable进行设置)
    • waf:config:moduules:limiter(仅支持对enable进行设置)
    • waf:config:moduules:counter(仅支持对enable进行设置)
    • waf:config:moduules:sampler(仅支持对enable进行设置)
  • 如在redis中执行命令 hset waf:config:moduules:counter enable false
  • 在 redis 配置后需执行 /waf/config/reload 将配置与默认配置进行合并,方可生效

5.2 自定义配置list

  • 自定义的list放在 redis 中以 waf:list 为key的 zset
  • 如在redis中执行命令 zadd waf:list 1666267510 127.0.0.1
  • 在 redis 配置后需执行 /waf/list/reload 将配置与当前共享内存名单合并后生效

6. 应用场景示范

6.1 维护IP/uid/device名单

示例一: 限制访问(默认配置已经在filter模块中开启了对list名单的支持, 默认为黑名单)

// 限制设备号`X-Device-ID` = `f14268d542f919d5` 访问, 在到达Unix time 1666267510 之前
zadd waf:list 1666267510 f14268d542f919d5
// 限制IP `13.251.156.174` 的访问, 在到达Unix time 1666267510 之前
zadd waf:list 1666267510 13.251.156.174
// 重载配置
curl --request POST 'http://127.0.0.1/waf/list/reload' \
    --header 'Authorization: Basic d2FmOlRUcHNYSHRJNW13cQ=='

示例二: 允许访问 (修改默认配置,将list用作白名单)

在 redis 中执行

hset waf:config:moduules:filter:rules 1 '{"matcher":"any","action":"accept","enable":true,"by":"ip:in_list"}'
hset waf:config:moduules:filter:rules 0 '{"matcher":"any","action":"block","enable":true,"by":"ip:not_in_list"}'
zadd waf:list 1666267510 13.251.156.174

重载配置及名单后生效

curl --request POST 'http://127.0.0.1/waf/config/reload' \
    --header 'Authorization: Basic d2FmOlRUcHNYSHRJNW13cQ=='
curl --request POST 'http://127.0.0.1/waf/list/reload' \
    --header 'Authorization: Basic d2FmOlRUcHNYSHRJNW13cQ=='

6.2 配置 matcher

// 匹配头部参数 X-App-ID = 4 的请求
hset waf:config:matchers app_id '{"Header":{"operator":"#","name":"x-app-id","value":[4]}}'
// 匹配 UserAgent 包含 "postman" 的请求
hset waf:config:matchers attack_agent '{"UserAgent":{"value":"(postman)","operator":"≈"}}'
// 重载配置
curl --request POST 'http://127.0.0.1/waf/config/reload' \
--header 'Authorization: Basic d2FmOlRUcHNYSHRJNW13cQ=='

6.3 配置 response

// Redis 命令
hset waf:config:responses 503 '{"status":503,"mime_type":"application/json","body":"{\"code\":\"503\", \"message\":\"Custom Message\"}"}'
// 重载配置
curl --request POST 'http://127.0.0.1/waf/config/reload' \
    --header 'Authorization: Basic d2FmOlRUcHNYSHRJNW13cQ=='

6.4 moduules:filter:rules

// Redis 命令
hset waf:config:moduules:filter:rules 0 '{"matcher":"any","action":"block","enable":true,"by":"ip:not_in_list"}'
// 重载配置
curl --request POST 'http://127.0.0.1/waf/config/reload' \
    --header 'Authorization: Basic d2FmOlRUcHNYSHRJNW13cQ=='

6.5 moduules:limiter:rules

// Redis 命令
hset waf:config:moduules:limiter:rules 0 '{"code":403,"count":60,"time":60,"matcher":"any","by":"ip","enable":true}'
// 重载配置
curl --request POST 'http://127.0.0.1/waf/config/reload' \
    --header 'Authorization: Basic d2FmOlRUcHNYSHRJNW13cQ=='

6.6 moduules:counter:rules

// Redis 命令
hset waf:config:moduules:counter:rules 0 '{"matcher":"any","by":"ip,uri","time":60,"enable":true}'
// 重载配置
curl --request POST 'http://127.0.0.1/waf/config/reload' \
    --header 'Authorization: Basic d2FmOlRUcHNYSHRJNW13cQ=='

6.7 修改 moduules:manager

// Redis 命令
hset waf:config:moduules:manager:auth '{"user": "test", "pass": "123" }'
// 重载配置
curl --request POST 'http://127.0.0.1/waf/config/reload' \
    --header 'Authorization: Basic d2FmOlRUcHNYSHRJNW13cQ=='

7. 参考项目

8. OpenResty 一些知识

8.1 模块里的变量

  • 处于模块级别的变量在每个 worker 间是相互独立的,且在 worker 的生命周期中是只读的, 只在第一次导入模块时初始化.
  • 模块里函数的局部变量,则在调用时初始化

8.2 ngx.var.*

  • lua-nginx-module#ngxvarvariable
  • 使用代价较高
  • 续先预定义才可使用(可在server 或 location 中定义)
  • 类型只能是字符串
  • 内部重定向会破坏原始请求的 ngx.var.* 变量 (如 error_page, try_files, index 等)

8.3 ngx.ctx.*

  • lua-nginx-module#ngxctx
  • 内部重定向会破坏原始请求的 ngx.ctx.* 变量 (如 error_page, try_files, index 等)

8.4 ngx.shared.DICT.*

8.5 resty.lrucache

  • lua-resty-lrucache
  • 不同 worker 间数据相互隔离
  • 同一 worker 不同请求共享数据

https://github.com/openresty/lua-nginx-module/#data-sharing-within-an-nginx-worker

8.6 table 与 metatable

https://www.cnblogs.com/liekkas01/p/12728712.html

9 如何开发

// 环境建立
git clone https://github.com/codiy1992/lua-resty-waf.git
cd lua-resty-waf
touch .opmrc
docker-compose up -d

// 编码
...

// 打包
docker exec -it resty opm build
docker exec -it resty opm upload

10. 一些相关链接

codiy1992/lua-resty-waf本身功能很多,足够大多数场景使用
如果你需要自己开发,就git吧项目拉下来,然后根据自己的需要修改代码

比如我就不太使用使用http api接口去操作全局的配置文件,我更喜欢全部使用配置文件,我就修改了读取配置的文件的部分