迁移旧系统,最怕的是“新代码跑在老漏洞上”
我接手过一个跑了八年的内部管理系统,代码里密密麻麻全是这样的查询:
$sql = "SELECT * FROM users WHERE username = '" . $_POST['username'] . "' AND password = '" . md5($_POST['password']) . "'";
看到这种写法,任何一个经历过 SQL 注入攻击的开发者都会倒吸一口凉气。这种硬编码 SQL、直接拼接用户输入的写法,在遗留系统里简直是家常便饭。更麻烦的是,当你决定把它迁移到 Laravel 时,“能跑就别动” 的心态会让安全风险悄悄跟着代码一起搬家。
Laravel 给我们提供了查询构造器、Eloquent ORM 和内置的参数绑定机制,这些工具天然就能防御注入攻击。但问题在于:你不可能把几万行旧 SQL 一夜之间全换成 Eloquent。迁移是一个渐进的过程,而在这个过程中,如何确保每一个替换步骤都不引入新的漏洞,才是真正的考验。
第一步:像做体检一样,先审计旧代码里的 SQL
别急着写新代码。先把整个代码库翻一遍,找到所有直接操作数据库的地方。我通常会用 grep 或者 IDE 的全局搜索,搜 mysql_query、mysqli_query、$db->query 这类关键词,然后把结果按危险程度分类:
- 红色级别:直接拼接用户输入(GET、POST、COOKIE、文件上传参数)的查询,这是最危险的。
- 橙色级别:拼接了来自数据库或文件的部分数据,但源头不可控。
- 绿色级别:纯静态 SQL,没有变量参与,这种相对安全但也不好维护。
举个例子,有一次我在一个遗留项目中发现了这样的代码:
$id = $_GET['id'];
$result = mysqli_query($conn, "SELECT * FROM orders WHERE order_id = $id");
攻击者只要在 URL 里传 ?id=1 OR 1=1,就能拿到整个订单表。这种查询就是迁移时第一个要干掉的对象。
第二步:用查询构造器做“外科手术式”替换
对于简单的 CRUD 查询,Laravel 的 DB facade 加上参数绑定是最直接的替换方案。还是上面那个例子,迁移后应该是:
$orders = DB::table('orders')->where('order_id', $request->input('id'))->get();
关键点在于:你不需要写任何 whereRaw 或者手动转义。Laravel 的查询构造器底层用的是 PDO 的 prepared statements,参数值会被数据库驱动单独处理,永远不会拼接到 SQL 字符串里。换句话说,即使 $request->input('id') 传进来的是 1 OR 1=1,它也会被当作一个普通的字符串参数传给数据库,根本不会改变查询逻辑。
我一般会先挑那些最危险、最频繁被调用的查询来替换。比如用户登录、商品搜索、订单查询这些直接暴露给用户的接口,优先处理。替换完一个,就写一个简单的测试来验证:
$this->assertNotEmpty(DB::table('users')->where('email', 'test@example.com')->first());
这种做法虽然慢,但能确保每一步替换都不出岔子。
第三步:复杂 JOIN 和关联查询,交给 Eloquent ORM
遗留系统里最让人头疼的是那种七八个表 JOIN 在一起的查询,还夹杂着子查询和聚合函数。比如:
SELECT u.*, o.total, COUNT(p.id) as product_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
LEFT JOIN products p ON o.id = p.order_id
WHERE u.status = 1
GROUP BY u.id
这种查询如果直接用 DB::raw 复制过来,本质上还是在写原生 SQL,安全风险没减少多少。正确的做法是定义模型关联:
class User extends Model {
public function orders() {
return $this->hasMany(Order::class);
}
}
class Order extends Model {
public function products() {
return $this->hasMany(Product::class);
}
}
然后用 Eloquent 的 withCount 和 with 来安全地获取数据:
$users = User::withCount('orders', 'orders.products')
->where('status', 1)
->get();
这里所有的 where 条件都是参数绑定的,你不需要担心用户输入会污染 SQL。而且 Eloquent 的关联查询默认启用了懒加载和预加载控制,避免了 N+1 查询性能问题。如果遗留数据库的字段命名风格不一致(比如有些用下划线,有些用驼峰),可以在模型里定义 $table 和访问器来适配,不用改数据库本身。
第四步:参数绑定不是万能药,输入验证才是第一道门
很多人以为用了参数绑定就万事大吉了。确实,Laravel 的参数绑定机制(底层是 PDO prepared statements)能防御绝大多数的 SQL 注入。但有一个容易被忽视的漏洞:order by 和 limit 这样的子句,是不能用参数绑定的。
假设你写了这样的代码:
DB::table('products')
->orderBy($request->input('sort'), 'asc')
->get();
这里的 $request->input('sort') 直接传给了 orderBy,如果攻击者传入 id; DROP TABLE products; --,虽然参数绑定保护了值部分,但 orderBy 的列名是直接拼接到 SQL 里的。Laravel 并不会自动帮你过滤列名。
正确的做法是:永远对这类参数做白名单验证。
$allowedSorts = ['id', 'name', 'price', 'created_at'];
$sort = in_array($request->input('sort'), $allowedSorts) ? $request->input('sort') : 'id';
DB::table('products')->orderBy($sort, 'asc')->get();
再比如,如果你用 whereRaw 或者 DB::raw 来处理复杂表达式,一定要把用户输入的部分用参数绑定传进去,而不是直接拼接。Laravel 的 whereRaw 支持绑定数组:
DB::table('products')
->whereRaw('price > ? AND category = ?', [$minPrice, $category])
->get();
这样即使 $minPrice 包含恶意内容,它也只是被当作一个字符串值传递给数据库,不会影响 SQL 结构。
第五步:存储过程和原始表达式,能封装就别裸写
有些遗留系统重度依赖 MySQL 存储过程。迁移时最忌讳的做法是把 CALL sp_get_report(1, '2026-01-01') 这样的调用直接复制到 Laravel 里用 DB::statement 执行。因为 DB::statement 本身是安全的,但它不会帮你校验参数类型。
我建议的做法是:把存储过程调用封装成一个模型方法。比如:
class Report extends Model {
public static function getMonthlyReport($userId, $startDate) {
return DB::select('CALL sp_get_report(?, ?)', [
(int)$userId,
$startDate
]);
}
}
这里我强制把 $userId 转成了整数,$startDate 也应该在调用前用 Carbon 或日期验证规则校验过。这样即使未来有人直接调用了这个方法,也不会因为传入非法参数而导致注入。
对于不可避免要使用 DB::raw 的场景(比如复杂的 CASE WHEN 或者数据库函数调用),记住一个原则:raw 部分只写固定结构的 SQL 模板,所有变量都通过参数绑定传入。
第六步:测试和监控,让安全问题无处可藏
迁移完成后,别急着上线。写几个针对性的测试用例,专门验证注入防护是否到位。
我通常会写这样的测试:
public function test_sql_injection_prevention() {
$maliciousInput = "1' OR '1'='1";
$result = DB::table('users')
->where('username', $maliciousInput)
->first();
// 应该返回 null,而不是返回所有用户
$this->assertNull($result);
}
另外,强烈建议在开发环境和预发布环境装上 Laravel Telescope 或者 Laravel Debugbar。这两个工具能实时显示每次请求执行的 SQL 语句及其绑定参数。如果你看到某个查询的绑定参数是空的,或者 SQL 里直接出现了变量值,那说明代码里可能漏掉了参数绑定,需要立即修复。
上线后的第一个月,我会定期检查数据库的慢查询日志和错误日志。如果发现某个查询的 WHERE 条件出现了意外的值(比如字符串被截断或报语法错误),很可能是某个地方没做好参数化,需要回溯代码。
安全迁移的核心就三句话
第一,永远不要信任用户输入——哪怕它来自你信任的 API 或者内部系统。第二,充分利用 Laravel 提供的安全工具——查询构造器、Eloquent ORM、验证规则,这些不是摆设,是帮你省事的。第三,保持框架和依赖的更新——Laravel 13.x(截至 2026 年 5 月的最新稳定版)在底层安全机制上做了很多改进,包括对 PDO 属性设置的优化和默认的严格模式,这些都能让你少操很多心。
迁移旧系统就像给老房子重新布线,表面上看是换新线,实际上是把过去埋下的隐患一个个挖出来处理掉。每替换一个旧查询,就相当于拆掉一个定时炸弹。这个过程可能很慢,但值得。
评论