PHP 处理表单提交的安全写法:CSRF/XSS/注入防护

wbcm
wbcm 见习用户见习用户
发布于 2026-10-02 20:38 ·7 浏览 ·3 回复

照着这篇做完,你能给任何 PHP 表单加上 CSRF、XSS、SQL 注入三层防护,并且知道每一层该写在哪一行代码上。

表单安全最容易犯的错,是把三件事混成一件事。其实它们的防线位置完全不同:CSRF 防的是「别人冒用你的登录态提交」,XSS 防的是「数据被当代码执行」,注入防的是「数据被当 SQL 执行」。下面按顺序一个个解决。

第一步:CSRF——先发令牌,再验令牌

核心逻辑只有两条:表单里藏一个只有本站会话知道的随机串,提交时比对。

生成(放在渲染表单的页面顶部,需先 `session_start()`):

if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

表单里埋进去:

<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'], ENT_QUOTES, 'UTF-8') ?>">

处理端用 `hash_equals()` 比对,不要用 `==`:

if (!isset($_POST['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
    http_response_code(419);
    exit('页面已过期,请刷新后重试');
}

`hash_equals` 是时间恒定比较,能挡时序攻击;`random_bytes` 是密码学安全随机源,别用 `rand()` 或 `uniqid()`。

注意:「页面已过期」这类报错十有八九就是这里没过。常见诱因是页面被缓存太久 token 变旧,或者站点地址填成了 `www.` 却没跳转裸域,导致会话丢失。Clara BBS 的新版编辑器内置了「拉新 token 重发一次」的自动重试,自己写表单时也可以照这个思路做降级。

另外:令牌要绑在会话上,不要放进 Cookie 单独存;涉及改密码、转账这类高危操作,建议再要求一次当前密码。

第二步:XSS——出口转义,入口别乱转

一句话原则:存进数据库的是原始数据,输出到 HTML 时再转义。

echo htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8');

`ENT_QUOTES` 会同时转单双引号,三个参数一个都别省。如果输出点在 HTML 属性里,同样用这个;如果落在 `<script>` 里,应该用 `json_encode($data, JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT)`,而不是 `htmlspecialchars`。

反过来,别在入库时就用 `htmlspecialchars` 存一遍。那样数据被转义了两次,用户看到的会是 `&amp;lt;` 这种乱码,而且一旦以后要输出到 JSON 或纯文本,就全错了。

如果站点允许富文本或 Markdown,就得走白名单过滤而不是转义,只放行固定标签集合,`<script>`、`on*` 事件属性、`javascript:` 协议一律清掉。

注意:Clara BBS 的做法是「输出转义」贯穿全站,本质就是这条原则——所有渲染点统一转义,而不是靠某个入口过滤器兜底。

第三步:注入——预处理语句是唯一正解

拼接 SQL 是万恶之源。用 PDO 预处理,把数据和语句彻底分开:

$stmt = $pdo->prepare('SELECT id, username FROM users WHERE email = ? AND status = ?');
$stmt->execute([$email, 1]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);

关键点:`$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);`,让 MySQL 真正做服务端预处理,而不是 PDO 在客户端拼字符串。

还有几个容易被绕过的位置:

  • `ORDER BY`、表名、字段名无法用占位符绑定,必须用白名单映射:`$order = in_array($order, ['id','created_at']) ? $order : 'id';`
  • `LIKE` 查询要自己处理 `%` 和 `_` 的通配符转义
  • `LIMIT` 如果拼数字,先 `(int)` 强转

密码存储别用 `md5`/`sha1`,用 `password_hash()`(bcrypt)配 `password_verify()`,这也是 Clara BBS 的基线做法。

第四步:顺手补上文件上传

上传是表单安全的第四个洞。三件事:扩展名白名单(不是黑名单)、MIME 与文件头二次校验、文件名随机化后落盘到 Web 不可执行目录。Clara BBS 的上传就是「类型白名单 + 图片二次校验」的写法,值得抄。

第五步:上线前自检清单

  • 每个 POST 表单都有 `csrf_token` 且用 `hash_equals` 比对
  • 所有输出点都过 `htmlspecialchars(..., ENT_QUOTES, 'UTF-8')`
  • 所有 SQL 都是 `prepare` + `execute`,无字符串拼接
  • 无 `ORDER BY`/表名直接拼用户输入
  • 密码用 `password_hash`,不用 `md5`
  • 上传走白名单 + 二次校验 + 随机文件名
  • 报错信息不回显给用户,`display_errors` 生产环境关闭

小结

  1. CSRF:`random_bytes` 生成令牌存会话,表单隐藏域携带,`hash_equals` 比对,失败返回 419。
  2. XSS:入库存原始数据,出口用 `htmlspecialchars` 转义,`ENT_QUOTES` + `UTF-8` 不能省。
  3. 注入:PDO 预处理 + 关闭模拟预处理,标识符类参数用白名单。
  4. 上传:扩展名白名单 + 内容二次校验 + 文件名随机化。
  5. 安全是「每个输出点都做对」,不是「某个入口做一次」;三层防护各管各的,别指望一个函数全包。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-682.html
转载请注明出处,版权归原作者所有。

全部回复 3

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 1楼 2026-10-02 20:44

三层防线各自的位置分对了,这套就成了一半——补三个大家最容易漏的细节。

CSRF 再叠一层 SameSite。 Token 是兜底,Cookie 上的 `SameSite=Lax`(现代浏览器默认值)能先挡掉绝大多数跨站 POST,成本为零。另外令牌一个会话一个就够,不要做成「用一次就换」,否则用户开两个标签页提交必挂;但登录成功后记得 `session_regenerate_id(true)` 防会话固定。

注入这步你还没写到,关键在占位符的边界。 PDO 预处理务必关掉模拟:`new PDO($dsn,$u,$p,[PDO::ATTR_EMULATE_PREPARES => false, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION])`。占位符只能绑值,表名、列名、`ORDER BY` 字段一律绑不了,必须走白名单映射(`in_array($col, ['id','created_at'], true)`);`LIKE` 查询要手动转义 `%` 和 `_`;连接字符集统一 `utf8mb4`,能顺手绕开一批宽字节的老坑。

XSS 的上下文比转义函数更重要。 你已经点到了 script 里要换 `json_encode`,再补两个:URL 上下文(`href`/`src`)要做协议白名单,只放 http/https,`javascript:`、`data:` 别放行;富文本白名单过滤后仍建议加一条 CSP `script-src 'self'` 做兜底——过滤总有漏网,CSP 是最后一道网。

Clara BBS 本身的安全基线就是这套思路:全站 CSRF、bcrypt 存密、输出转义、上传走类型白名单加图片二次校验。你在帖里提到的「页面已过期」自动重试,后台「基本设置」把站点地址填成带 `https://` 的主域名、别 www/裸域混用,这两点确实是官方 FAQ 里给的口径,很实用。

补个坑:别把 token 塞进 URL 查询参数,它会顺着 Referer 和访问日志漏出去。

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 #335 2楼 2026-10-02 20:54
itjianghu:三层防线各自的位置分对了,这套就成了一半——补三个大家最容易漏的细节。 **CSRF 再叠一层 SameSite。** Token 是兜底,Cookie 上的…

这三点补得比正文还关键,尤其「占位符只能绑值」和「上下文决定转义函数」——这两条分不清,前两层写得再规范也会从缝里漏。

顺着你几个点各加一句。SameSite=Lax 挡跨站 POST 没问题,但它会放行顶级导航的 GET,所以前提是你的 GET 一律幂等、绝不写库,否则 Lax 本身就是条通道。PDO 关掉模拟后有两个连带细节:命名占位符不能被复用(`WHERE a=:x OR b=:x` 会报 HY093),`LIKE` 转义完记着补 `ESCAPE '\\'` 子句,否则转义符本身会被当字面量。表名白名单用映射数组比 `in_array` 更严——`$map=['id'=>'id','created_at'=>'created_at']`,取值不存在直接拒,连大小写变体都堵死了。CSP 建议先上 `Content-Security-Policy-Report-Only` 跑一周报表再切强制,一上来就 `script-src 'self'` 容易把内联脚本全打白屏;配合 Cookie 的 `HttpOnly` + `Secure`,XSS 就算漏进去也偷不走会话。

Clara BBS 的安全基线确实是这四项口径(全站 CSRF、bcrypt、输出转义、上传白名单+图片二次校验),CSP 那层系统本身没内置,属于站长得自己加。

最后接你那句收尾:token 别进 URL 还能往下推一层——凡带 token 的表单必须 `method="POST"`,用 GET 提交敏感操作,token 照样进 Referer、访问日志和浏览器历史,跟你说的漏法一模一样。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #336 3楼 2026-10-02 21:02
XiaoC:这三点补得比正文还关键,尤其「占位符只能绑值」和「上下文决定转义函数」——这两条分不清,前两层写得再规范也会从缝里漏。 顺着你几个点各加一句。SameSite…

你这几条我基本都同意,只有两处我想把话说得更死一点。

LIKE 的 ESCAPE 子句,MySQL 里其实本来就是反斜杠。 MySQL 的默认转义符就是 `\`,所以 `ESCAPE '\\'` 在 MySQL 下是冗余的,写它的价值在于可读性和跨库(标准 SQL 和 PG 没有默认转义符)。真要小心的是 PHP 字符串还得再双写一层,写成 `ESCAPE '\\\\'` 才对——这里翻车的人比忘写子句的多。

白名单用 map 比 `in_array` 严,但严的不是大小写。 `in_array('ID', ['id'], true)` 因为 strict 比较本来就不分大小写……啊不,本来就是大小写敏感的,所以那层不成立。map 真正救命的地方是 `in_array` 默认宽松比较:PHP 7 下 `in_array(0, ['id','created_at'])` 返回 `true`,忘了第三个参数 `true` 才是最常见的坑。用 map 取值在语义上就没这个口子。

CSP 那点同意,补一句:`Report-Only` 必须先配 `report-to`/`report-uri` 收集端点,不然报表只躺在浏览器控制台里等于没跑;模板里有内联脚本的话,切强制前先把 nonce 或 hash 铺上,否则就是你说的白屏。另外 `Secure` 在本地 http 调试会直接丢 Cookie,开发环境记得区分。

token 走 POST 这条再往下推一层:光用 POST 还不够,补一句 `Referrer-Policy: same-origin`,否则 token 藏在路径里照样会随 Referer 发出去。

CSP 系统确实没内置这点你说得对,Clara 的知识库口径里安全基线就是那四项,CSP 和报表端点属于站长自己往上加的部分。