迁移旧系统,最怕的是“新代码跑在老漏洞上”

我接手过一个跑了八年的内部管理系统,代码里密密麻麻全是这样的查询:

$sql = "SELECT * FROM users WHERE username = '" . $_POST['username'] . "' AND password = '" . md5($_POST['password']) . "'";

看到这种写法,任何一个经历过 SQL 注入攻击的开发者都会倒吸一口凉气。这种硬编码 SQL、直接拼接用户输入的写法,在遗留系统里简直是家常便饭。更麻烦的是,当你决定把它迁移到 Laravel 时,“能跑就别动” 的心态会让安全风险悄悄跟着代码一起搬家。

Laravel 给我们提供了查询构造器、Eloquent ORM 和内置的参数绑定机制,这些工具天然就能防御注入攻击。但问题在于:你不可能把几万行旧 SQL 一夜之间全换成 Eloquent。迁移是一个渐进的过程,而在这个过程中,如何确保每一个替换步骤都不引入新的漏洞,才是真正的考验。

Laravel query builder safe replacement

第一步:像做体检一样,先审计旧代码里的 SQL

别急着写新代码。先把整个代码库翻一遍,找到所有直接操作数据库的地方。我通常会用 grep 或者 IDE 的全局搜索,搜 mysql_querymysqli_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,就能拿到整个订单表。这种查询就是迁移时第一个要干掉的对象。

Eloquent ORM complex join refactoring

第二步:用查询构造器做“外科手术式”替换

对于简单的 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 的 withCountwith 来安全地获取数据:

$users = User::withCount('orders', 'orders.products')
    ->where('status', 1)
    ->get();

这里所有的 where 条件都是参数绑定的,你不需要担心用户输入会污染 SQL。而且 Eloquent 的关联查询默认启用了懒加载和预加载控制,避免了 N+1 查询性能问题。如果遗留数据库的字段命名风格不一致(比如有些用下划线,有些用驼峰),可以在模型里定义 $table 和访问器来适配,不用改数据库本身。

第四步:参数绑定不是万能药,输入验证才是第一道门

很多人以为用了参数绑定就万事大吉了。确实,Laravel 的参数绑定机制(底层是 PDO prepared statements)能防御绝大多数的 SQL 注入。但有一个容易被忽视的漏洞:order bylimit 这样的子句,是不能用参数绑定的。

假设你写了这样的代码:

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 属性设置的优化和默认的严格模式,这些都能让你少操很多心。

迁移旧系统就像给老房子重新布线,表面上看是换新线,实际上是把过去埋下的隐患一个个挖出来处理掉。每替换一个旧查询,就相当于拆掉一个定时炸弹。这个过程可能很慢,但值得。