直接通过字符串拼接构建 SQL 的自定义查询类,即便使用了 addslashes() 或 mysqli_real_escape_string() 进行过滤,依然无法抵御 SQL 注入攻击——这种问题并非简单的加固就能解决,而是源于架构层面的根本缺陷。

自定义查询类必须强制采用预处理绑定机制
很多开发团队习惯编写一套“通用查询类”,底层却仍然依赖 mysql_query() 或 mysqli_query() 进行字符串拼接,只是在外层简单包装方法名。说实话,这无异于换汤不换药。真正安全的做法是:所有执行入口——例如 select()、update()——内部必须统一调用 prepare() 与 execute(),并且严格禁止开放任何可以直接拼写完整 SQL 语句的接口。
- 类在构造时就应该持有已配置好的
PDO或MySQLi实例,并且该实例必须禁用模拟预处理:PDO::ATTR_EMULATE_PREPARES => false - 所有来自用户输入的参数——包括
where条件、limit数值、order字段——都必须通过占位符传递,严禁直接在 SQL 字符串中插值拼接 - 如果类中提供了
raw()、expr()、setSql()这类方法,默认情况下应将其禁用,或者仅允许极少数白名单内的固定语句(例如"NOW()")
动态表名与字段名必须经过白名单硬性校验
预处理占位符 ? 和 :name 只能用于数据值,不能作用于标识符——像表名、列名、ORDER BY、GROUP BY 等元素都不适用。自定义类如果要支持动态字段,必须提前约定好允许范围,运行时只做匹配校验,不进行任何转义处理。
- 以排序字段为例:先定义
$allowedSortFields = ['id', 'created_at', 'status'],然后通过in_array($_GET['sort'], $allowedSortFields) ? $_GET['sort'] : 'id'进行选择 - 表名映射更稳妥的做法是:
$tableMap = ['user' => 'users', 'order' => 'orders']; $realTable = $tableMap[$_GET['type']] ?? 'users'; - 绝对不要使用
filter_var($input, FILTER_SANITIZE_STRING)来处理字段名——它仅仅删除 HTML 标签,对 SQL 注入防护毫无作用
IN 语句与批量参数需手动展开占位符
预处理机制并不支持直接将数组绑定到 IN (?) 这样的操作。自定义类如果封装了 whereIn() 方法,就必须动态生成对应数量的 ?,并将数组值平铺到参数列表中。
- 错误写法:
"WHERE id IN (?)", [$ids]—— 这只会将整个数组转换为字符串,最终变成IN ('Array') - 正确做法:先用
str_repeat('?,', count($ids) - 1) . '?'构造占位符字符串,再用array_values($ids)提取纯数值参数 - 注意类型要保持一致:整数数组使用
bind_param('i', ...),字符串数组使用's',混合类型则需要拆分处理
切勿轻信“输入验证可替代预处理”
有人认为“我用了 filter_var($id, FILTER_VALIDATE_INT),拼接就安全了”——这其实是一个相当普遍的误解。整数校验无法防御逻辑绕过的攻击(例如传入 -1 OR 1=1),也无法抵御字符集攻击(如宽字节截断),更无法防范业务层误用(比如将校验过的 ID 当作用户名拼接到另一个查询中)。
- 输入验证是必要补充,但绝不能替代预处理:数字要强转
(int)、邮箱用FILTER_VALIDATE_EMAIL、长度超限直接return false - 但只要代码中还存在
"SELECT * FROM {$table} WHERE id = " . $id这种模式,验证就形同虚设 - 最危险的是“半预处理”——部分参数绑定,部分参数拼接。攻击者会专门瞄准那些未绑定的部分下手
说到底,真正的难点不在于写对一个 prepare(),而在于让整个查询类的所有分支路径都遵循同一套绑定机制,并且不允许出现任何例外。一旦你为了“兼容旧逻辑”而放开一个口子——比如添加一个 unsafeRawQuery() 方法——那整套防护体系就会瞬间归零。
