Laravel 的查询构造器与 Eloquent ORM 默认使用 PDO 参数绑定,这已经能挡住绝大多数 SQL 注入。但真正容易出问题的地方不在框架本身,而在原生 SQL 写法、字段白名单缺失、以及过度信任“内部数据”这几个环节。很多人以为用了 Laravel 就天然免疫注入,实际上只要出现 DB::raw、orderBy 动态字段、whereRaw 拼接,或者把用户输入直接塞进 select 的列名里,风险立刻回来了。下面直接说哪些地方需要配置和改写,以及每一处为什么必须处理。
先确认数据库连接强制使用 PDO 预处理
Laravel 的 config/database.php 里,mysql 和 pgsql 连接默认的 options 已经包含 PDO::ATTR_EMULATE_PREPARES => false。这个配置必须保持关闭状态。如果模拟预处理被打开,部分驱动会先把参数拼进 SQL 再交给数据库,等于绕过了真正的预处理机制。检查你的配置里是否存在下面这段,并且不要随意删除:
'mysql' => [
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'strict' => true,
'engine' => null,
'options' => [
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
],
],
这里还有一点常被忽略:PDO::ATTR_ERRMODE 必须设为 ERRMODE_EXCEPTION。如果错误模式不是异常,SQL 执行失败时可能只返回 false,后续代码继续执行,导致异常行为甚至信息泄露。原生配置优化第一步就是把这两个参数锁死。
原生查询里最容易出事的 DB::raw 和 whereRaw
DB::raw 本身不是漏洞,漏洞来自把用户输入直接放进 raw 表达式。比如下面这种写法就是典型的注入点:
$sort = $request->input('sort');
$users = DB::table('users')
->select(DB::raw("id, name, $sort as sort_value"))
->get();
如果 sort 参数被构造为 “(select password from users limit 1)”,整个查询语义就变了。正确做法是永远不要让用户输入进入 raw 字符串。排序字段必须使用白名单映射:
$allowedSort = [
'created_at' => 'created_at',
'name' => 'name',
'id' => 'id',
];
$sortKey = $request->input('sort', 'id');
$sortColumn = $allowedSort[$sortKey] ?? 'id';
$users = DB::table('users')
->orderBy($sortColumn)
->get();
whereRaw 同理。如果业务确实需要复杂条件,必须拆分成参数绑定。whereRaw 支持第二个参数数组进行绑定,但绑定只能用于值,不能用于列名或表达式结构。所以凡是涉及列名、操作符、函数名的部分,一律走白名单或硬编码,用户输入只允许作为绑定值出现。
orderBy 和 groupBy 的动态字段必须做严格校验
Laravel 的 orderBy 方法第二个参数是排序方向,它不会自动过滤字段名。如果控制器里直接写:
$order = $request->input('order');
$direction = $request->input('direction');
$list = DB::table('orders')->orderBy($order, $direction)->paginate(20);
攻击者可以传入 order=length(password) 或 order=(select 1 from users limit 1) 之类的表达式。orderBy 内部虽然会对字段加反引号,但并不能阻止函数或子查询注入。解决方式依然是白名单,字段名和排序方向都做严格限制:
$allowedOrders = ['id', 'order_no', 'amount', 'created_at'];
$allowedDirections = ['asc', 'desc'];
$order = in_array($request->input('order'), $allowedOrders, true)
? $request->input('order')
: 'id';
$direction = in_array(strtolower($request->input('direction', 'desc')), $allowedDirections, true)
? strtolower($request->input('direction'))
: 'desc';
$list = DB::table('orders')->orderBy($order, $direction)->paginate(20);
groupBy 也一样。凡是需要把字段名作为接口参数的地方,都不能相信前端传过来的值。字段名白名单应该放在模型常量或配置文件中统一维护,避免散落在多个控制器里造成遗漏。
查询构造器中的值绑定要覆盖所有输入点
很多人知道 where 方法会自动绑定,但容易忽略 when、having、whereIn 等方法的组合使用。whereIn 接收数组时会自动为每个值做占位符绑定,这部分是安全的。但 having 方法如果直接拼接字符串,风险与 whereRaw 相同。例如:
$minAmount = $request->input('min_amount');
$orders = DB::table('orders')
->select('user_id', DB::raw('SUM(amount) as total_amount'))
->groupBy('user_id')
->having('total_amount', '>', $minAmount)
->get();
这种写法本身是安全的,因为 having 的值会走绑定。但如果你写成 havingRaw("total_amount > $minAmount"),注入风险就出现了。原则很明确:能用绑定方法就不要用 raw,能用 where 就不要用 whereRaw,能用 having 就不要用 havingRaw。
Eloquent 模型中的原生查询优化
Eloquent 模型内部使用查询构造器,大部分查询自动绑定。但有些开发者喜欢在模型里写 scope 或者访问器时使用 DB::raw,比如:
public function scopeActive($query)
{
return $query->where(DB::raw('status'), 1);
}
这里 DB::raw 用于字段名是多余的,直接 where('status', 1) 即可。真正需要 raw 的场景通常是复杂计算字段或数据库函数,比如 COALESCE、IFNULL、DATE_FORMAT 等。这些场景下,raw 里的函数名和表名字段名必须是硬编码,只有值部分通过绑定传入。例如:
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
$orders = Order::select(
'id',
'order_no',
DB::raw('DATE_FORMAT(created_at, "%Y-%m-%d") as order_date')
)
->whereBetween('created_at', [$startDate, $endDate])
->get();
这里 DATE_FORMAT 和 created_at 都是硬编码,用户输入只进入 whereBetween 的值绑定,安全可控。
避免使用原生 DB::statement 执行拼接 SQL
有些开发者在处理复杂报表或批量更新时会直接用 DB::statement 或 DB::unprepared 执行整段 SQL。这类方法不会自动做任何过滤,如果 SQL 字符串里有用户输入,风险极高。例如:
$table = $request->input('table');
DB::statement("TRUNCATE TABLE $table");
这种代码一旦存在,等于把数据库操作权直接交给用户。即使业务上需要动态表名,也必须做严格的白名单校验,并且优先使用 Schema 构建器或查询构造器提供的方法。对于 TRUNCATE 这种无法绑定表名的操作,表名必须来自服务端配置或常量,不能来自请求参数。
配置文件层面的安全加固
除了连接选项,还有几个配置直接影响注入防御效果。首先是 database.connections.mysql.strict 建议保持 true。严格模式会让 MySQL 拒绝无效数据和不合理查询,虽然不能直接防注入,但能减少注入成功后造成的数据污染范围。其次是数据库用户权限最小化。Laravel 应用使用的数据库账号不应该拥有 DROP、ALTER、GRANT 等权限。即使注入发生,攻击者也只能在有限的 CRUD 范围内操作,无法直接删库或提权。
另外建议在 AppServiceProvider 中监听 QueryExecuted 事件,记录所有实际执行的 SQL 和绑定参数。这不仅能帮助发现潜在注入尝试,也能在事后审计时还原攻击路径。记录时注意对敏感字段脱敏,避免日志本身成为泄露源。
处理 LIKE 查询中的特殊字符
LIKE 查询本身使用参数绑定是安全的,但用户输入中的百分号和下划线会被当作通配符,可能导致查询结果被恶意扩大。例如用户搜索 “%” 可能返回全表数据。虽然这不是传统意义上的 SQL 注入,但属于输入处理缺失导致的数据暴露。优化方式是在执行 LIKE 前对通配符进行转义:
$keyword = $request->input('keyword');
$escapedKeyword = addcslashes($keyword, '%_\\');
$users = DB::table('users')
->where('name', 'like', '%' . $escapedKeyword . '%')
->get();
注意 addcslashes 处理的是通配符,不是 SQL 注入的引号转义。参数绑定已经处理了引号问题,这里只需要控制通配符行为。如果业务允许用户使用通配符搜索,则可以跳过这一步,但必须明确知晓并接受该行为。
对 JSON 字段和数组查询的输入处理
Laravel 支持 JSON 字段查询,例如 where('options->color', 'red')。这类查询底层会转换成 JSON_EXTRACT 等函数,值部分依然走绑定。但要注意,如果 JSON 路径本身来自用户输入,比如 where('options->'.$path, $value),路径部分无法绑定,存在注入风险。JSON 路径必须硬编码或从固定集合中选取。数组查询 whereIn 的数组长度也要限制,避免用户传入超大数组导致 SQL 语句过长或数据库性能问题。
最后强调一个容易被忽略的环节:错误信息回显
生产环境必须关闭调试模式。如果 APP_DEBUG=true,SQL 报错会把完整 SQL 语句、表结构甚至部分数据回显到页面,攻击者可以利用这些信息构造更精准的注入。即使参数绑定已经生效,错误信息中的表名、字段名、数据库版本等内容也会大幅降低攻击难度。生产环境务必设置 APP_DEBUG=false,并自定义异常处理,对外只返回通用错误信息,详细日志写入服务端文件或日志系统。
Laravel 防 SQL 注入的核心不是装插件或加中间件,而是把原生 SQL 的使用面积压到最小,把用户输入严格限制在值绑定范围内,把字段名、表名、操作符全部收归服务端白名单。连接层的 PDO 配置、查询层的 raw 使用规范、应用层的输入校验和错误处理,三层同时收紧,才能把注入风险降到实际可控的最低水平。
