在 Laravel 框架内通过中间件(Middleware)实现应用层防火墙(WAF)是一种常见且直接的方案。然而,当系统面临高并发流量或需要抵御复杂的、资源消耗型的攻击(如复杂的正则表达式注入)时,纯粹依赖 PHP 中间件的 WAF 会迅速成为性能瓶颈。每个进入 Laravel 应用的请求,无论最终是否合法,都必须启动完整的 PHP-FPM 工作进程,加载框架,执行中间件。这个过程的开销,在规模化场景下是无法忽视的。
我们需要一个能够在请求到达 PHP 之前,就以极高性能完成过滤和拦截的架构。这自然引向了将安全层前置到边缘代理的思路。
架构决策:两种防火墙方案的权衡
方案A:纯 Laravel 中间件防火墙
这是最容易想到的实现。通过创建一个全局中间件,我们可以在请求生命周期的早期检查请求参数、Headers、URI 等。
优势:
- 开发简便: 无需引入新技术栈,完全在 Laravel 生态内完成。
- 上下文感知: 能够轻易访问框架的全部上下文,如用户认证信息、Session、缓存等,可以制定与业务逻辑紧密耦合的复杂规则。
- 部署统一: 安全逻辑与业务代码一同发布,管理简单。
劣势:
- 性能瓶颈: 所有请求,包括恶意请求,都会消耗 PHP-FPM 进程资源。对于 CC 攻击或扫描类流量,这会迅速耗尽后端服务能力。
- 拦截时机晚: 中间件通常在框架大部分核心服务被加载后才执行,无法在更早的连接阶段进行拦截,资源浪费严重。
- 技术栈限制: 受限于 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;
优势:
- 极致性能: LuaJIT 的性能接近原生 C。在 Nginx 的事件驱动模型中执行 WAF 逻辑,可以轻松处理数万甚至更高的 QPS,而对后端 Laravel 应用几乎没有额外压力。
- 提前拦截: 在
access阶段进行拦截,此时 HTTP body 可能尚未完全接收。恶意请求在消耗最少服务器资源的情况下被阻断。 - 职责分离: 安全逻辑与业务逻辑解耦。WAF 的更新不依赖于业务应用的发版,可以实现规则的秒级动态下发,响应速度更快。
- 技术栈优势: Lua 拥有出色的字符串处理能力和
lua-resty-*生态,非常适合网络编程和规则匹配任务。
劣势:
- 架构复杂性: 引入了 OpenResty 和 Redis,增加了运维和监控的复杂性。
- 上下文缺失: 边缘 WAF 默认无法获取 Laravel 应用内部的业务上下文(如当前登录用户 ID)。如果需要,必须通过额外机制(如请求头、JWT 解析)传递。
- 数据同步: 需要一个可靠的机制来同步 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_agent、cookie等。也可以实现更复杂的动作,如基于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 本身不会成为新的故障点。