基于 OpenResty 与 Lua 构建动态 Laravel 应用防火墙的架构权衡与实现


在 Laravel 框架内通过中间件(Middleware)实现应用层防火墙(WAF)是一种常见且直接的方案。然而,当系统面临高并发流量或需要抵御复杂的、资源消耗型的攻击(如复杂的正则表达式注入)时,纯粹依赖 PHP 中间件的 WAF 会迅速成为性能瓶颈。每个进入 Laravel 应用的请求,无论最终是否合法,都必须启动完整的 PHP-FPM 工作进程,加载框架,执行中间件。这个过程的开销,在规模化场景下是无法忽视的。

我们需要一个能够在请求到达 PHP 之前,就以极高性能完成过滤和拦截的架构。这自然引向了将安全层前置到边缘代理的思路。

架构决策:两种防火墙方案的权衡

方案A:纯 Laravel 中间件防火墙

这是最容易想到的实现。通过创建一个全局中间件,我们可以在请求生命周期的早期检查请求参数、Headers、URI 等。

  • 优势:

    1. 开发简便: 无需引入新技术栈,完全在 Laravel 生态内完成。
    2. 上下文感知: 能够轻易访问框架的全部上下文,如用户认证信息、Session、缓存等,可以制定与业务逻辑紧密耦合的复杂规则。
    3. 部署统一: 安全逻辑与业务代码一同发布,管理简单。
  • 劣势:

    1. 性能瓶颈: 所有请求,包括恶意请求,都会消耗 PHP-FPM 进程资源。对于 CC 攻击或扫描类流量,这会迅速耗尽后端服务能力。
    2. 拦截时机晚: 中间件通常在框架大部分核心服务被加载后才执行,无法在更早的连接阶段进行拦截,资源浪费严重。
    3. 技术栈限制: 受限于 PHP 的同步阻塞模型和字符串处理性能,难以实现非常高效的复杂模式匹配。

一个典型的中间件实现可能如下所示:

// app/Http/Middleware/SimpleWafMiddleware.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use App\Models\WafRule; // 假设规则存储在数据库中

class SimpleWafMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        // 从缓存或数据库中加载规则
        $rules = WafRule::where('is_enabled', true)->get();

        $requestUri = $request->getUri();
        $requestBody = $request->getContent();
        $clientIp = $request->ip();

        foreach ($rules as $rule) {
            $isMatched = false;
            // 简单的规则匹配逻辑
            switch ($rule->target) {
                case 'uri':
                    if (preg_match($rule->pattern, $requestUri)) {
                        $isMatched = true;
                    }
                    break;
                case 'body':
                    if (preg_match($rule->pattern, $requestBody)) {
                        $isMatched = true;
                    }
                    break;
                case 'ip':
                    if ($rule->pattern === $clientIp) {
                        $isMatched = true;
                    }
                    break;
            }

            if ($isMatched) {
                // 记录日志
                \Log::warning('WAF Blocked', [
                    'rule_id' => $rule->id,
                    'ip' => $clientIp,
                    'uri' => $requestUri
                ]);

                // 执行动作
                if ($rule->action === 'deny') {
                    // 返回一个通用的拒绝响应,避免信息泄露
                    return response()->json(['error' => 'Forbidden'], 403);
                }
            }
        }

        return $next($request);
    }
}

这个实现在小流量下工作良好,但在真实项目中,每次请求都去查询数据库或缓存,并用 PHP 的 preg_match 循环匹配大量规则,其性能损耗是显而易见的。

方案B:边缘 Lua 防火墙 + Laravel 管理API

这个架构将系统分为两个平面:数据平面(Data Plane)和控制平面(Control Plane)。

  • 数据平面: 使用 OpenResty(一个基于 Nginx 并集成了 LuaJIT 的高性能 Web 平台)作为 Laravel 应用的前置代理。所有的 WAF 逻辑都在 OpenResty 的 Lua 脚本中执行。它在 Nginx 处理请求的特定阶段(如 access 阶段)运行,性能极高,且完全非阻塞。
  • 控制平面: 仍然使用 Laravel。但它的职责不再是直接过滤请求,而是提供一套 API,用于管理 WAF 规则(增删改查),并将这些规则动态发布到一个高速的共享存储(如 Redis)中。数据平面的 Lua 脚本则从 Redis 中拉取最新的规则。
graph TD
    subgraph "控制平面 (Control Plane)"
        A[Admin/DevOps] -- "管理规则" --> B(Laravel API);
        B -- "发布规则到" --> C{Redis};
    end

    subgraph "数据平面 (Data Plane)"
        D[Client Request] --> E[OpenResty / Nginx];
        E -- "1. access_by_lua" --> F(Lua WAF Engine);
        F -- "2. 定期从Redis拉取规则" --> C;
        F -- "3. 匹配请求" --> G{Is Request Malicious?};
        G -- "Yes" --> H[Block & Log Request];
        G -- "No" --> I[Proxy Pass to Laravel];
        I --> J(Laravel Application);
    end

    H -.-> D;
    J -.-> D;
  • 优势:

    1. 极致性能: LuaJIT 的性能接近原生 C。在 Nginx 的事件驱动模型中执行 WAF 逻辑,可以轻松处理数万甚至更高的 QPS,而对后端 Laravel 应用几乎没有额外压力。
    2. 提前拦截:access 阶段进行拦截,此时 HTTP body 可能尚未完全接收。恶意请求在消耗最少服务器资源的情况下被阻断。
    3. 职责分离: 安全逻辑与业务逻辑解耦。WAF 的更新不依赖于业务应用的发版,可以实现规则的秒级动态下发,响应速度更快。
    4. 技术栈优势: Lua 拥有出色的字符串处理能力和 lua-resty-* 生态,非常适合网络编程和规则匹配任务。
  • 劣势:

    1. 架构复杂性: 引入了 OpenResty 和 Redis,增加了运维和监控的复杂性。
    2. 上下文缺失: 边缘 WAF 默认无法获取 Laravel 应用内部的业务上下文(如当前登录用户 ID)。如果需要,必须通过额外机制(如请求头、JWT 解析)传递。
    3. 数据同步: 需要一个可靠的机制来同步 Laravel 和 OpenResty 之间的规则。

最终选择: 对于任何有一定规模和性能要求的生产系统,方案 B 都是更专业、更可靠的选择。它将正确的工作交给了正确的工具。下面我们将深入探讨方案 B 的核心实现。

核心实现:构建动态 WAF 系统

1. Laravel 控制平面:规则管理与发布

首先,我们需要在 Laravel 中定义规则模型、API 和发布机制。

规则模型 WafRule

php artisan make:model WafRule -m

修改生成的 migration 文件:

// database/migrations/xxxx_xx_xx_xxxxxx_create_waf_rules_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('waf_rules', function (Blueprint $table) {
            $table->id();
            $table->string('name')->comment('规则名称');
            $table->text('description')->nullable()->comment('规则描述');
            $table->enum('target', ['uri', 'args', 'header', 'body', 'ip'])->comment('匹配目标');
            $table->string('pattern')->comment('PCRE 正则表达式');
            $table->enum('action', ['deny', 'log'])->default('deny')->comment('执行动作');
            $table->integer('priority')->default(100)->comment('优先级,越小越高');
            $table->boolean('is_enabled')->default(true)->comment('是否启用');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('waf_rules');
    }
};

API 控制器 WafRuleController

提供标准的 RESTful 接口来管理这些规则。每次规则发生变更(创建、更新、删除)时,我们需要触发一个事件或任务,将最新的全量规则发布到 Redis。

// app/Http/Controllers/Api/WafRuleController.php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\WafRule;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Validator;

class WafRuleController extends Controller
{
    const WAF_RULES_REDIS_KEY = 'laravel_waf:rules';

    public function index()
    {
        return WafRule::orderBy('priority', 'asc')->get();
    }

    public function store(Request $request)
    {
        $validator = Validator::make($request->all(), [
            'name' => 'required|string|max:255',
            'target' => 'required|in:uri,args,header,body,ip',
            'pattern' => 'required|string',
            'action' => 'required|in:deny,log',
            'priority' => 'integer',
            'is_enabled' => 'boolean',
        ]);

        if ($validator->fails()) {
            return response()->json($validator->errors(), 422);
        }

        $rule = WafRule::create($validator->validated());
        $this->publishRulesToRedis(); // 变更后发布

        return response()->json($rule, 201);
    }
    
    // ... update, destroy 方法类似,每次操作后都调用 publishRulesToRedis()

    public function update(Request $request, WafRule $rule)
    {
        // ... validation and update logic ...
        $rule->update($validatedData);
        $this->publishRulesToRedis();
        return response()->json($rule);
    }

    public function destroy(WafRule $rule)
    {
        $rule->delete();
        $this->publishRulesToRedis();
        return response()->noContent();
    }

    /**
     * 将所有启用的规则发布到 Redis。
     * 这是一个关键函数,它将规则集格式化为 Lua 易于解析的 JSON 格式。
     */
    public function publishRulesToRedis()
    {
        $rules = WafRule::where('is_enabled', true)
            ->orderBy('priority', 'asc')
            ->get(['id', 'target', 'pattern', 'action', 'priority'])
            ->toArray();

        // 使用 try-catch 保证 Redis 连接的健壮性
        try {
            Redis::set(self::WAF_RULES_REDIS_KEY, json_encode($rules));
            // 可以在这里增加一个发布版本号或时间戳,方便 Lua 端判断是否需要更新
            Redis::set(self::WAF_RULES_REDIS_KEY . ':version', time());

        } catch (\Exception $e) {
            // 记录严重的错误,这可能意味着 WAF 失效
            \Log::critical('Failed to publish WAF rules to Redis.', ['error' => $e->getMessage()]);
            // 这里应该有告警机制
        }
    }
}

Redis 中的数据结构
发布后,Redis 中 laravel_waf:rules 键的值会是一个 JSON 字符串,类似这样:

[
    {
        "id": 1,
        "target": "uri",
        "pattern": "(?i)select\\s+.+from",
        "action": "deny",
        "priority": 10
    },
    {
        "id": 2,
        "target": "args",
        "pattern": "<script.*>",
        "action": "deny",
        "priority": 20
    }
]

2. OpenResty 数据平面:Lua WAF 引擎

这是系统的核心。我们需要配置 Nginx 并编写 waf.lua 脚本。

Nginx 配置 (nginx.conf)

# nginx.conf

# ... 全局配置 ...
# 定义 lua package path,让 OpenResty 能找到我们的 lua 脚本和库
lua_package_path "/path/to/your/lua/scripts/?.lua;;";

# 在 http block 中定义共享内存,用于跨 worker 进程缓存数据或锁
# 例如,用于在更新规则时防止惊群效应
lua_shared_dict waf_cache 10m;

# ...

http {
    # ...
    # 在 worker 进程启动时执行的 lua 代码
    init_worker_by_lua_block {
        -- 引入我们的 waf 主模块
        local waf = require "waf"
        -- 启动一个后台定时器,定期同步规则
        waf.start_rules_updater()
    }

    server {
        listen 80;
        server_name your-laravel-app.com;

        # WAF 核心入口
        access_by_lua_block {
            local waf = require "waf"
            waf.run()
        }

        location / {
            proxy_pass http://127.0.0.1:8000; # 代理到后端的 Laravel 应用
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        # 禁止直接访问 Laravel 提供的 WAF 管理 API
        # 只允许内网或特定 IP 访问
        location /api/waf {
            allow 127.0.0.1;
            # allow your_office_ip;
            deny all;
            
            proxy_pass http://127.0.0.1:8000;
            # ... proxy headers ...
        }
    }
}

Lua WAF 引擎 (waf.lua)

这是最关键的部分,包含了规则加载、缓存和请求匹配的逻辑。

-- /path/to/your/lua/scripts/waf.lua

local redis = require "resty.redis"
local cjson = require "cjson.safe"
local ngx_timer_at = ngx.timer.at

-- 模块表
local _M = {}

-- 配置
local config = {
    redis_host = "127.0.0.1",
    redis_port = 6379,
    redis_db = 0,
    redis_timeout = 2000, -- ms
    rules_key = "laravel_waf:rules",
    rules_version_key = "laravel_waf:rules:version",
    update_interval = 5 -- seconds, 规则更新检查周期
}

-- 使用 ngx.shared.DICT 在 worker 间共享规则数据和版本号
-- 避免每个 worker 都频繁请求 Redis
local waf_cache = ngx.shared.waf_cache
local current_rules = nil -- worker 进程级别的内存缓存

local function log(level, message)
    ngx.log(level, "[WAF] ", message)
end

-- 从 Redis 加载规则并更新本地缓存
local function load_rules_from_redis()
    local red = redis:new()
    red:set_timeout(config.redis_timeout)
    
    local ok, err = red:connect(config.redis_host, config.redis_port)
    if not ok then
        log(ngx.ERR, "failed to connect to redis: ", err)
        return false
    end

    -- 使用 pipeline 减少网络往返
    red:init_pipeline()
    red:get(config.rules_version_key)
    red:get(config.rules_key)
    local results, err = red:commit_pipeline()

    if not results then
        log(ngx.ERR, "failed to get rules from redis: ", err)
        red:close()
        return false
    end

    local remote_version = results[1]
    local rules_json = results[2]

    red:close()

    if remote_version == ngx.null or rules_json == ngx.null then
        log(ngx.WARN, "rules or version not found in redis.")
        return false
    end

    local local_version = waf_cache:get("rules_version")

    -- 只有当 Redis 中的版本号与本地缓存不同时才更新
    if remote_version ~= local_version then
        local rules, err = cjson.decode(rules_json)
        if not rules then
            log(ngx.ERR, "failed to decode rules json: ", err)
            return false
        end

        -- 更新 worker 级别的内存缓存
        current_rules = rules
        -- 更新共享内存中的版本号
        waf_cache:set("rules_version", remote_version)
        log(ngx.NOTICE, "WAF rules updated to version: ", remote_version)
    end
    
    return true
end

-- 后台定时器句柄
local function rules_updater()
    -- 使用 ngx.worker.pid() 作为随机种子,错开不同 worker 的更新时间,避免惊群
    math.randomseed(ngx.worker.pid())
    
    local ok, err = ngx_timer_at(0, function(premature)
        if premature then
            return
        end
        
        -- 加一个锁,防止多个 worker 同时更新
        local lock_key = "rules_update_lock"
        local lock_acquired, err = waf_cache:add(lock_key, true, 1) -- 锁1秒
        if not lock_acquired then
            -- 其他 worker 正在更新,本次跳过
            ngx_timer_at(config.update_interval, rules_updater)
            return
        end
        
        load_rules_from_redis()
        
        -- 递归调用,形成循环
        ngx_timer_at(config.update_interval, rules_updater)
    end)

    if not ok then
        log(ngx.ERR, "failed to create rules updater timer: ", err)
    end
end

function _M.start_rules_updater()
    -- 首次加载,确保启动时有规则可用
    load_rules_from_redis()
    rules_updater()
end

-- 核心 WAF 执行函数
function _M.run()
    if not current_rules or #current_rules == 0 then
        return -- 没有规则,直接放行
    end

    -- 提前读取可能需要多次使用的变量
    ngx.req.read_body()
    local args = ngx.req.get_uri_args()
    local headers = ngx.req.get_headers()

    for _, rule in ipairs(current_rules) do
        local subject = nil
        local matched = false
        
        if rule.target == "uri" then
            subject = ngx.var.request_uri
        elseif rule.target == "ip" then
            subject = ngx.var.remote_addr
        elseif rule.target == "args" then
            for key, val in pairs(args) do
                -- 对 key 和 value 都进行匹配
                if ngx.re.match(tostring(key), rule.pattern, "ijo") or (type(val) == "string" and ngx.re.match(val, rule.pattern, "ijo")) then
                    matched = true
                    break
                end
            end
        elseif rule.target == "header" then
            for key, val in pairs(headers) do
                if ngx.re.match(tostring(key), rule.pattern, "ijo") or (type(val) == "string" and ngx.re.match(val, rule.pattern, "ijo")) then
                    matched = true
                    break
                end
            end
        elseif rule.target == "body" then
            subject = ngx.req.get_body_data()
        end

        if not matched and subject then
            local m, err = ngx.re.match(subject, rule.pattern, "ijo")
            if err then
                log(ngx.ERR, "regex error on rule ", rule.id, ": ", err)
            elseif m then
                matched = true
            end
        end

        if matched then
            log(ngx.WARN, "Request blocked by WAF rule ID: ", rule.id, 
                ", pattern: ", rule.pattern, 
                ", target: ", rule.target, 
                ", client: ", ngx.var.remote_addr, 
                ", uri: ", ngx.var.request_uri)

            if rule.action == "deny" then
                ngx.header["Content-Type"] = "application/json"
                ngx.status = 403
                ngx.say('{"error": "Request blocked for security reasons."}')
                return ngx.exit(ngx.HTTP_FORBIDDEN)
            end
            -- 如果是 "log" 动作,则记录日志后继续执行其他规则
        end
    end
end

return _M

架构的扩展性与局限性

这套架构提供了非常好的扩展基础。例如,我们可以轻易地增加新的规则目标(target),如 user_agentcookie等。也可以实现更复杂的动作,如基于IP的动态速率限制(使用 lua-resty-limit-traffic 库),或者将可疑请求标记后转发到蜜罐系统。规则的 pattern 也可以不局限于正则表达式,可以是一个IP范围,或者是一个更复杂的Lua函数名,实现动态代码规则。

然而,这套架构也存在固有的局限性。

首先,规则同步存在延迟。尽管我们设置了5秒的轮询间隔,但这仍然意味着从规则发布到全网生效存在数秒的窗口期。对于需要即时生效的紧急封禁,可以考虑引入 Redis 的 Pub/Sub 机制来主动通知 worker 进程更新,但这会增加 Lua 端的复杂性。

其次,WAF 运行在边缘,对 Laravel 应用内部的业务状态是无知的。它不知道当前请求的用户是谁,属于哪个租户,拥有什么权限。如果需要实现“只对非管理员用户开启SQL注入防护”这类与业务强相关的规则,就必须由 Laravel 应用通过特定的请求头(如 X-User-Role: admin)将上下文信息传递给 OpenResty,这又造成了一定程度的耦合。

最后,对这套分布式系统的测试和调试比单体应用要复杂得多。需要对 Lua 脚本编写单元测试(例如使用 busted 框架),并建立完善的日志监控和告警体系,以确保 WAF 本身不会成为新的故障点。


  目录