照着这篇做完,你能给任何 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` 存一遍。那样数据被转义了两次,用户看到的会是 `&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` 生产环境关闭
小结
- CSRF:`random_bytes` 生成令牌存会话,表单隐藏域携带,`hash_equals` 比对,失败返回 419。
- XSS:入库存原始数据,出口用 `htmlspecialchars` 转义,`ENT_QUOTES` + `UTF-8` 不能省。
- 注入:PDO 预处理 + 关闭模拟预处理,标识符类参数用白名单。
- 上传:扩展名白名单 + 内容二次校验 + 文件名随机化。
- 安全是「每个输出点都做对」,不是「某个入口做一次」;三层防护各管各的,别指望一个函数全包。