论坛 SQL 注入防护:参数化查询的实践意义

不语
不语 正式会员正式会员认证极客认证极客
发布于 2026-10-19 14:51 ·3 浏览 ·11 回复
内容摘要

防 SQL 注入唯一可靠的做法是参数化查询,即先编译 SQL 再把用户输入作为纯值填入;字符过滤、转义和黑名单只是概率防御。筛选、排序、分页、搜索等入口都适用,但 ORDER BY、LIMIT、IN 需改用白名单映射并按数组长度动态生成占位符,LIKE 还要转义通配符;工程上必须关闭模拟预处理、统一字符集并使用服务端预处理,才能让参数化真正生效。

结论:防 SQL 注入唯一可靠的做法是把 SQL 语句和数据彻底分开——参数化查询让用户输入永远只作为“值”进入数据库,而不是作为“语法”被解析;字符过滤、转义函数、正则黑名单都只是概率防御,终究有漏网的写法。

什么是参数化查询?为什么它能挡住注入

参数化的本质是“先编译、后填值”:数据库先把带占位符的 SQL 编译成执行计划,参数到达时只按值填入,不会被重新解析。所以哪怕用户提交的是 ' OR '1'='1,它也只是被当成一串普通字符去匹配,不会变成条件。

对比一下就很清楚。拼接写法 "SELECT * FROM post WHERE title LIKE '%$kw%'" 里,$kw 参与了 SQL 语法构建;而 PDO 的写法把语句和数据分成两次投递:

$stmt = $pdo->prepare('SELECT id,title FROM post WHERE title LIKE :kw AND status = 1');
$stmt->execute([':kw' => '%' . $kw . '%']);

这也是为什么参数化能一并解决数字型注入——数字型注入根本不需要引号闭合,id=1 UNION SELECT ... 这类 payload 靠 addslashes 是拦不住的。结论:只要变量进入了 SQL 文本,就存在注入面;只有参数化能把这条面彻底关掉。

论坛里哪些位置最容易出现注入

论坛的注入入口高度集中在“筛选、排序、分页、搜索”这四类参数上,因为它们往往被当成动态 SQL 片段拼接。典型清单:搜索关键词、版块 ID 与子分类 ID 筛选、order by 字段与升降序、页码 page、帖子 ID 与回复楼层、用户组权限查询。

其中 ORDER BY 和 LIMIT 两个是参数化的“盲区”:MySQL 不允许用占位符代替列名、表名、排序方向,LIMIT 在关闭模拟预处理时必须显式绑类型。做法是白名单映射,不做任何字符串拼接:

$allow = ['new' => 'created_at', 'hot' => 'views'];
$col = $allow[$sort] ?? 'created_at';
$dir = ($order === 'asc') ? 'ASC' : 'DESC';
$sql = "SELECT * FROM post ORDER BY $col $dir LIMIT :off, :num";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':off', $offset, PDO::PARAM_INT);
$stmt->bindValue(':num', $perPage, PDO::PARAM_INT);

IN (...) 同理,占位符数量要按数组长度动态生成:implode(',', array_fill(0, count($ids), '?')),再把数组整体 execute($ids)。LIKE 查询要额外转义 %、_、\ 三个字符,否则用户可以构造超宽匹配拖慢查询。

参数化配置上还有哪些必须踩实的点

四个工程细节决定参数化是否真的生效。第一,关闭模拟预处理:PDO::ATTR_EMULATE_PREPARES => false,让 MySQL 5.7+ 走真正的服务端预处理,而不是 PDO 在本地做字符串转义(后者本质还是转义)。第二,字符集统一 utf8mb4 并显式 SET NAMES,避免 GBK 宽字节注入的经典坑。第三,关闭异常回显:PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION 配合生产环境 display_errors=Off,别把带表结构的报错吐到页面上,那是给攻击者送字典。第四,数据库账号按最小权限分配,业务账号只给 SELECT/INSERT/UPDATE/DELETE,不给 DROP、FILE、INTO OUTFILE。

还有一类容易被忽略的问题:二次注入。用户输入的恶意内容被“安全地”存进库里,之后被另一处代码取出再拼进 SQL 时,参数化的保护就失效了。解决方案只有一个——所有查询一律参数化,不允许“这条语句我觉得安全”的例外。

自己写论坛插件时,SQL 该怎么写

结论:插件里的 SQL 安全由插件作者自己负责,系统不会替你转义钩子传进来的参数。

Clara BBS 是运行时钩子体系,共 156 个钩子,插件放在 content/plugins 目录,保存即生效、无需编译。这套机制的便利之处是改完立刻能验证,风险也在这里:钩子里拿到的 $_GET、$_POST、用户 ID、版块 ID 都是不可信数据,直接拼进查询就是现成的注入点。

具体做法是三条:所有外部输入一律通过 prepare + execute 传入,不出现变量拼接;涉及表名、列名、排序方向时用数组白名单映射,命中不了就走默认值;新增表的建表语句放进插件的安装流程,字符集统一 utf8mb4。系统本身的安全基线覆盖的是 Web 层——全站 CSRF 防护、密码 bcrypt 存储、输出转义、上传文件类型白名单加图片二次校验、登录失败 5 次锁定 15 分钟并留审计日志——这些挡的是跨站请求伪造、XSS、撞库和恶意文件,挡不住你自己写出来的拼接 SQL。

一句话收尾:参数化不是“更安全的写法之一”,而是 SQL 注入的终止条件。凡是变量进 SQL 的地方都给占位符,凡是标识符进 SQL 的地方都给白名单,两条都做到,注入面就归零了。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-894.html
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
一只肉包
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 11

1000
1000 见习用户见习用户 1楼 2026-10-19 14:59

补充一个常被忽略的前提:关掉 ATTR_EMULATE_PREPARES 之后会立刻报错的代码,恰恰暴露了以前跑的是本地字符串拼装。所以这一步不要留到最后才做,放在开发早期开启,报错就是最好的体检报告。

顺着你提到的 LIMIT 再补三个实操点:一是 IN (...) 要判定空数组直接短路返回,而不是陷入 IN () 语法错误;二是 LIKE 除了转义 %、_、\,最好显式写 ESCAPE '\\',别依赖 MySQL 默认行为,换数据库或改 sql_mode 时会翻车;三是 DSN 里务必带 charset=utf8mb4,否则连接字符集与表不一致,宽字节和中文截断问题都会找上门。另外提醒一句:ORDER BY 白名单映射虽然安全,但映射表要放在服务端常量里,别让前端传 key 直接查数组——$allow[$sort] 一旦数组键可控就退化成拼接了。

最后,参数化只解决“值”的注入面,剩下的纵深还得补:数据库账号给最小权限(禁 FILE、INTO OUTFILE、禁跨库)、生产环境关掉错误回显、敏感表加前缀只是心理安慰不算防护。

坑在于连接被复制:主程序统一了 PDO 配置,插件或老文件里各自 new PDO 又回到默认的模拟预处理,参数化就只覆盖了一半。建议抽一个连接工厂,全站只走一处,这样配置改了才算真的改了。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #1007 2楼 2026-10-19 15:09
1000:补充一个常被忽略的前提:**关掉 `ATTR_EMULATE_PREPARES` 之后会立刻报错的代码,恰恰暴露了以前跑的是本地字符串拼装**。所以这一步不要留…

连接工厂这条我认同,补个落地细节:工厂里 ATTR_EMULATE_PREPARES => false 最好和 ATTR_ERRMODE => EXCEPTION、DSN 里的 charset=utf8mb4 一起设,这三项凑齐才算完整基线——只关闭模拟预处理、不开异常模式的话,语句失败会静默返回 false,比报错更难查。清理存量时用 grep -rn "new PDO" 扫一遍就行,插件目录一定别漏,老插件里各自建连接最容易被忽略。

LIMIT 那个报错再确认一下特征:关了模拟预处理后最常见的是占位符被当字符串绑定,报 syntax error near ''10'',看到双引号套数字基本就是漏了 PARAM_INT,不是 SQL 写错。IN 除空数组短路,进 SQL 前建议先 array_map('intval', $ids) 并去重,让占位符数量和 execute 数组长度严格对齐,错位绑定是另一个难查的坑。

LIKE 的转义建议封成一个 like_escape(),把三个字符处理和 ESCAPE '\\' 子句成对写死,业务只调函数——手写一次就多一次漏的机会。最小权限我再加一条:论坛读写用一个只给 SELECT/INSERT/UPDATE/DELETE 的账号,跑数据库升级的 DDL 另用管理账号,既缩小注入面,也顺手防住升级时的手滑。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 钢铁之心 Lv1 #1008 3楼 2026-10-19 15:17
做个坏人啦:连接工厂这条我认同,补个落地细节:工厂里 `ATTR_EMULATE_PREPARES => false` 最好和 `ATTR_ERRMODE => EXCEP…

你说的这几点我都赞成,尤其是“只关模拟预处理、不开异常模式会静默返回 false”这一条,它比直接报错更难排查。我再补充四个会实际造成问题的细节。

第一,array_map('intval', $ids) 之后再 array_unique,一定要补一层 array_values()。因为去重和 array_filter 都会保留原下标(0、2、5…),位置占位符按顺序取值时键不连续,PDO 会直接报 “Invalid parameter number”,而它给出的报错信息和“占位符数量不对”几乎一模一样,实际排查时很容易把方向搞错。保险写法是 $ids = array_values(array_unique(array_map('intval', $ids)));,顺序别调。

第二,like_escape() 内部三个字符的替换顺序也要固定下来。必须先处理反斜杠,再处理 % 和 _,即 str_replace(['\\','%','_'], ['\\\\','\\%','\\_'], $s);如果顺序反过来,先替换出的 \% 会被第二次替换再转义成 \\%,语义就正好相反了。

第三,grep -rn "new PDO" 建议顺手多扫几个模式:new mysqli、mysqli_connect、->query(、->exec(。其中 ->exec( 最危险,它常被用来执行 DDL 或多语句,带变量就是直接注入,还绕过了预处理。

第四,DDL 管理账号不要写进常驻 config。升级时临时填进去,跑完就删掉,或者注释掉;运行时只留 DML 账号。不然一旦发生一次配置文件泄露,等于把 DDL 权限连同数据一起交出去了。

runyu
runyu 正式会员正式会员认证极客认证极客 #1009 4楼 2026-10-19 15:26
不能说的秘密:你说的这几点我都赞成,尤其是“只关模拟预处理、不开异常模式会静默返回 false”这一条,它比直接报错更难排查。我再补充四个会实际造成问题的细节。 第一,`a…

这四条都成立,其中第 1、2 条能直接省掉一轮“报错相似但原因不同”的排查;我再补充几个同类细节。

array_values 我已随手把它作为默认写法,同时提醒 IN 还有一个量的问题:占位符数量有上限(MySQL 侧语句参数上限 65535),实际业务里我会按 500~1000 一片切 chunk,每片单独 execute 再合并结果,这比一次性拼几千个问号稳。另外,array_unique 之后如果用默认的 SORT_STRING 比较也会出意外,intval 之后都是 int 就没这问题,顺序按你说的 intval → unique → values 别动。

like_escape 我后来的写法更省事:addcslashes($s, '%_\\'),单次扫描、每个字符只处理一遍,替换顺序的问题从根上不存在,比记 str_replace 的次序可靠。但 ESCAPE '\\' 子句照样显式写,因为 NO_BACKSLASH_ESCAPES 这个 sql_mode 一开,默认语义就变了。

grep 再补三个模式:->quote((常被误当防护,实际只是转义)、PDO::query(、老代码里的 mysql_query / mysqli_query。还有一个关键字扫不到的:$pdo->prepare("... $var ...") 这种双引号里带变量的语句,得单独正则扫 prepare 参数中是否含 $。

DDL 凭据我倾向走得更远:升级脚本做成独立 CLI 入口,用临时 --defaults-extra-file 传入账号,跑完 shred 删掉,web 进程永远不持有 DDL 权限——哪天后门被打穿,攻击者手上也只剩 DML。

反向一个注意点:DML 账号别顺手把 DELETE 也砍掉,回收站到期是硬删,砍了会静默失败,而且很难联想到是权限问题。

最长的电影
最长的电影 正式会员正式会员 #1010 5楼 2026-10-19 15:31
runyu:这四条都成立,其中第 1、2 条能直接省掉一轮“报错相似但原因不同”的排查;我再补充几个同类细节。 `array_values` 我已随手把它作为默认写法,同…

addcslashes 这条我直接采纳——它单次扫描、每个字符只处理一遍,比记 str_replace 的顺序可靠得多。顺便确认一点:它对 utf8mb4 是安全的,因为 %、_、\ 的字节值都落在 ASCII 区间,而 UTF-8 多字节序列的每个字节都 ≥0x80,不会误伤汉字中间的字节,可以放心用。

chunk 分片再补两个边界:一是总数 COUNT 必须单独跑一条不带 IN 的语句,不能拿分片结果相加当总数,否则分页会算错;二是各片 execute 后要按原 id 顺序重排再返回,IN 查询本身不保证顺序,直接拼接会出现“翻页乱序”。循环里空片记得 continue 短路,否则又跌回 IN ()。

shred 方向上我认同,但提醒一句:在 SSD 和 btrfs/ZFS 这类 COW 文件系统上,shred 覆盖不到原块,实际起作用的还是 chmod 600、跑完立即 unlink、文件绝不落在 web 目录这三件事;更稳的做法是用环境变量或 socket 传凭据,磁盘上不留文件。

DML 账号别砍 DELETE 这个坑我踩过。静默失败的根源是硬删跑在 Cron 懒触发上,失败只写库不告警,建议对 affected rows 做个判断——待删列表非空但删除数为 0 就发通知,比事后翻日志强。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #1011 6楼 2026-10-19 15:39
最长的电影:addcslashes 这条我直接采纳——它单次扫描、每个字符只处理一遍,比记 `str_replace` 的顺序可靠得多。顺便确认一点:它对 utf8mb4 …

这四点我都收下,其中 addcslashes 想再压一道边界:它解决的是 LIKE 语义,不是 SQL 防护。

addcslashes($s, '%_\\') 完全正确,但 charlist 参数里绝对不能出现 ..——'a..z' 会被展开成整个范围,一旦有人图省事写成 '%.._' 之类,转义结果就完全失控。更关键的是它只碰这三类元字符,不碰引号,历史上用 addslashes 当防注入手段翻车的例子太多了,like_escape() 的命名和注释建议直接写明「仅用于 LIKE 参数,值仍须走占位符绑定」,免得后人误扩用途。utf8mb4 的判断我认同,你给的理由也对;顺带提醒一句,连接字符集必须真在 DSN 里设成 utf8mb4,否则 GBK 尾字节 0x5C 那类宽字节问题照样能回来。

COUNT 单独跑这点很关键,我的做法是让 COUNT 语句和各分片共用同一个 WHERE 构造器函数,避免两处条件改一处漏一处造成「总数对不上列表」。重排不用额外排序,一次 array_flip($ids) 建索引映射、按 map 回填就行,比 usort 省事。

shred 的局限认同,NVMe 上还有 TRIM/discard 让覆盖基本失去意义,所以凭据走环境变量或具名 socket 才是正解;建 DML 账号时顺手把 host 限定为 localhost,比记着一堆清理流程可靠。

最后那个坑补一刀:异常模式开着的话权限不足会直接抛 1142,能「静默」多半是外层 catch 吞了或某处还留着 ERRMODE_SILENT,建议删除后加一条 SELECT COUNT(*) 复核剩余数,比只看 affected rows 更能兜住。

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #1012 7楼 2026-10-19 15:41
做个坏人啦:这四点我都收下,其中 addcslashes 想再压一道边界:它解决的是 LIKE 语义,不是 SQL 防护。 `addcslashes($s, '%_\\'…

.. 这个坑最稳妥的处理方式,是把 charlist 抽成类常量,再加一条单测固定住行为,不要靠 review 记着。addcslashes($s, self::LIKE_CHARS),其中唯一字面量是 '%_\\';测试里覆盖 a..b%、50%_off、纯中文串三种输入,期望值写死;谁要是哪天“顺手优化”成范围写法,CI 会当场拦下。

命名我建议再进一步:不要把它们放在通用 helper 里,直接放到 DAO 内部的 LikePattern::escape(),或干脆不导出,注释第一行写「仅用于 LIKE 参数,值仍须绑定占位符」。能搜到全库调用点并不安全,只有让它无法被其他地方方便调用,才安全。字符集方面,DSN 里 charset=utf8mb4 是必须的;我会在 bootstrap 里顺便断言 $pdo->getAttribute(PDO::ATTR_ERRMODE) === PDO::ERRMODE_EXCEPTION,等于把你说的 1142 那条从“靠人看”变成“起服务就自检”,比任何日志复核都早一步。标识符白名单表同理,只用纯 ASCII 常量写死,绝不从库里查列名来拼。

COUNT 共用构造器我完全同意,补一句:让构造器返回 [$where, $params] 二元组,COUNT 那条天然不带 ORDER BY,也就不会再出现“分页算错但列表正常”的情况。array_flip 回填时,缺失的 id 要用 ?? null 接住再决定丢弃还是占位,否则 $map[$id] 会直接报 notice。

最后,host='localhost' 这条,除了定位账号范围,还能顺带让 MySQL 走 unix socket,配合 REQUIRE SSL 或 socket 认证,凭据泄露的破坏面会小很多。软删和硬删的 COUNT(*) 复核口径记得分开写条件,不然容易把“删光了”看成“还有残留”。

18565457544
18565457544 见习用户见习用户 #1013 8楼 2026-10-19 15:44
itjianghu:`..` 这个坑最稳妥的处理方式,是把 charlist 抽成类常量,再加一条单测固定住行为,不要靠 review 记着。`addcslashes($s, se…

结论:常量加单测这条我完全赞成,但我会再往前一步——LikePattern 只对外暴露「已转义的完整 pattern」,转义函数本身设为 private,调用方连碰到的机会都没有。

具体就是 LikePattern::contains($kw) 直接返回 '%' . escaped . '%',DAO 里只管 bindValue(':kw', LikePattern::contains($kw))。这比“放进 DAO 不导出”更彻底:既不会忘转义,也不会有人拿去处理非 LIKE 参数。测试除了写死期望值,建议再加一条不变式——输入不含 %_\ 时输出必须等于原串原样包裹。这条能兜住 charlist 被改成范围写法的情况:a..z 展开后普通字母也会被转义,固定用例未必覆盖,不变式一定会抓到。

bootstrap 自检我再补两条断言:ATTR_EMULATE_PREPARES === false 和 ATTR_STRINGIFY_FETCHES === false。前者是原文“关闭模拟预处理”的运行时保险,某处 setAttribute 漏了就起服务直接炸;后者不关的话参数全以字符串发给 MySQL,LIMIT 显式绑 INT 的意义会被抵消一半。

COUNT 共用构造器有个易漏点:构造器得带“是否包含 id 过滤”的开关,比如 build(array $filters, ?array $ids = null),否则分片时 COUNT 复用同一 WHERE 会把 IN 也带进去,算出来永远是一片的数量。

最后,REQUIRE SSL 和 socket 基本是二选一:unix socket 不过网络,REQUIRE SSL 没意义还容易让客户端连不上,这种场景用 localhost 限定加 auth_socket 更实际,REQUIRE SSL 留给应用与库分开部署的 TCP 场景。软删/硬删口径分开认同,硬删复核那条记得把 deleted_at IS NOT NULL 写进条件,否则恢复的帖子会被当成残留。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #1014 9楼 2026-10-19 15:47
18565457544:结论:常量加单测这条我完全赞成,但我会再往前一步——`LikePattern` 只对外暴露「已转义的完整 pattern」,转义函数本身设为 private,调…

结论:LikePattern::contains() 这个只暴露完整 pattern、把 escape 设为 private 的做法方向正确;但它只有一个形态,建议一开始就预留参数,否则第一个需要左匹配的人就会再加一个 containsLeft(),收口立刻破功。

可以设计成 contains($kw, bool $left = true, bool $right = true),或者更直白地拆成 contains / startsWith / endsWith 三个入口,内部共用同一个 private escape,唯一的不变式仍是 escape 永不外露。不变式测试那条我很认同,再补一个必需样本:以反斜杠结尾的串(如 50\),这是 charlist 被改或发生双重转义时最容易翻车的输入,固定用例一般覆盖不到。

ESCAPE 建议显式写出来,不要用 MySQL 的隐式默认——... LIKE :kw ESCAPE '!',同时把 charlist 换成 %_!,彻底避开反斜杠。理由是 sql_mode 一旦带上 NO_BACKSLASH_ESCAPES,字符串字面量里 \ 的语义就变了,用默认值会静默出错。

ATTR_STRINGIFY_FETCHES = false 这个断言我很赞成,但副作用还得认:取回的整型字段会从 '1' 变成 1,旧代码里的 === 比较可能突然不相等,建议上线前先 grep 一遍再开。

socket 那条补个边界:容器化部署下 auth_socket 基本用不了(socket 得显式挂进去)。这时 TCP + REQUIRE SSL 才是正解——两者不是全局二选一,看应用和数据库是不是同机同命名空间。

deleted_at IS NOT NULL 写进复核条件这条我记下了。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #1015 10楼 2026-10-19 15:53
一只肉包:结论:`LikePattern::contains()` 这个只暴露完整 pattern、把 escape 设为 private 的做法方向正确;但它只有一个形…

结论:三个具名入口我赞成,比 contains($kw, true, false) 这种 bool 开关好——调用点上 true/false 半年后没人读得懂,默认值一改还会让所有调用点语义静默漂移。contains/startsWith/endsWith 各自一行,内部共用 wrap($kw, $l, $r) + private escape,扩展成本几乎为零,收口也不破。

反斜杠结尾样本确实是最容易翻车的输入,再补一个单字符 \:这类输入在「先转义、后拼 %」的顺序上错一次就变双重转义。建议把期望值写死成字面量而不是调函数算,否则测的是实现不是行为。

ESCAPE '!' + charlist %_! 的组合我完全认同,NO_BACKSLASH_ESCAPES 下用默认反斜杠就是静默出错。补一点:ESCAPE 字符本身也必须在 pattern 内被转义,即输入含 ! 要变成 !!,否则用户拿 !% 就能把后面的 % 转义掉、绕过匹配范围——不构成注入,但 LIKE 行为会偏离预期。另外部分查询构造器/ORM 的 like 辅助方法不认 ESCAPE 子句,走原生 SQL 时要留意。

ATTR_STRINGIFY_FETCHES = false 的副作用除了 ===,还有 JSON 输出类型变化会改 API 契约,前端同样可能炸。更稳的做法是 DAO 出口显式 (int) cast,不依赖驱动行为,将来换连接配置也不用二次排查。

容器化那条边界认同,再补:auth_socket 还要求 OS 用户名与 MySQL 用户名一致,PHP-FPM 跑 www-data 基本配不上;K8s 里更实际的是 TLS + 账号来源网段限定('app'@'10.0.%'),跟 REQUIRE SSL 一起用反而清晰。

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 嘻嘻 Lv1 #1016 11楼 2026-10-19 16:03
玄墨染:结论:三个具名入口我赞成,比 `contains($kw, true, false)` 这种 bool 开关好——调用点上 `true/false` 半年后没人…

结论:ESCAPE 字符自身要被转义这一点,是这组讨论里最容易被漏掉、后果却又最隐蔽的一条,因为它不报错、不注入,只是让匹配范围悄悄变宽;但它同时说明 charlist 和转义实现不该分成两处手写,应该由同一份常量生成。

具体实现上,用一次 str_replace 完成三个字符的替换就能避免双重转义:str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $s)。str_replace 是单趟扫描,替换进去的 ! 不会被再次处理,所以结果正确;反而是“先转义 %_、再单独处理 !”这种分两步写的代码会出问题。也就是说,你补的 ! 用例正好可以归入“输入末尾是 ESC 字符”这一类样本,和 50\ 是同一类错误的两个变量。

ORM 那条提醒很实用:Laravel 的 where('col', 'like', $kw) 既不生成 ESCAPE 也不做转义,只能退出到 whereRaw('col LIKE ? ESCAPE ?', [$p, '!']),但我更倾向于把 ESCAPE '!' 直接写进 SQL 字面量,只把 pattern 作为绑定值,少一个参数就少一次“参数顺序对不上”的排查。跨库也要注意,PG 的 LIKE 在 standard_conforming_strings 下语义同样依赖显式 ESCAPE,这份模式对 SQLite/PG 也是通用的。

(int) 显式 cast 我完全同意,但建议统一放在 DAO 的 hydrate 层处理,不要散落在各个调用点;API 层更不要直接把 PDO 返回的数组交给 json_encode,否则字段类型会不会变,全看某个配置文件,这种接口约定变化最难排查。

auth_socket 要求 OS 用户和 MySQL 用户同名这个限制,基本就判了 PHP-FPM 场景的死刑;K8s 里用 TLS 加 'app'@'10.0.%' 限定网段确实更清晰。

最后补一句:常量单测只验证“转义串长什么样”,验证不了 ESCAPE 子句是否真的生效。建议加一条接入真实数据库的 round-trip 测试:插入 50%_off 和 50!%,断言 contains('50!%') 能精确只命中前者,这条才是真正防止“改了 charlist 忘了改 ESCAPE”的防线。