前言
页面 A 上有个按钮,点击后要把一个变量(订单号、用户 ID、筛选条件)带到页面 B。很多人的第一版代码是把值拼进 URL,B 页面直接用$_GET读出来就用。功能能跑,问题在后面才冒出来:用户在地址栏把id=1改成id=2,看到了别人的订单;有人把带删除参数的链接丢进群里,搜索爬虫顺着链接就把数据删了;日志里出现一条带着脚本标签的参数,页面开始弹窗。
根因只有一句话:HTTP 是无状态的,两次请求之间没有任何「记忆」,变量必须落在客户端或服务端某一侧。落在客户端的那部分,用户随时能改;落在服务端的那部分,又要考虑多进程、多台机器怎么共享。很多人记住了「用 POST 更安全」,但 POST 只是把参数从 URL 挪进了请求体,它本身不提供任何安全保证——用 curl 一样能随意构造。
这篇文章按「先决定数据放哪 → 接收端怎么校验 → 令牌与转义 → 完整可运行示例」的顺序讲一遍。文中示例最低需要 PHP 8.1(用到了never返回类型,它是 PHP 8.1 引入的),可以直接用 PHP 内置服务器跑起来看到输出。
一、四种传递方式,先想清楚把数据放在哪
| 方式 | 数据在哪 | 用户能否篡改 | 适合传什么 | 不要传什么 |
|---|---|---|---|---|
| URL 查询串(GET) | 地址栏、Referer、访问日志、浏览器历史 | 完全可以 | 无副作用的筛选条件、分页页码 | 口令、令牌、金额、被当作授权依据的 ID |
| 表单 POST | 请求体 | 可以(伪造一个请求即可) | 会改变服务端状态的提交动作 | 同样不能当授权依据 |
| Session | 服务端存储,客户端只留会话 ID | 不能直接改,但可能被会话固定或劫持 | 登录态、CSRF 令牌、跨页面中间数据 | 海量数据(会撑爆会话存储) |
| Cookie | 客户端 | 可以(除非做了签名校验) | 偏好设置、带签名的标识 | 任何没签名的「可信」数据 |
表格里最关键的一列是「用户能否篡改」。结论很朴素:凡是经过客户端走一圈的东西,都要当成不可信输入(untrusted input)重新校验。这不是「加固」,而是正确性的一部分——你永远不知道中间那台代理、那个浏览器插件、那个手快的用户做了什么。
二、按钮点击的三种形态
- 表单提交:按钮是表单的一部分,点下去就是把表单里的字段以 POST(或 GET)发往 action 指向的地址。这是唯一「点击即提交」的形态。
- 链接跳转:按钮其实是个超链接,点下去就是一次 GET。用 GET 做删除是经典错误——浏览器预取、爬虫遍历、用户按刷新,都会重复触发。
- 前端异步请求:用 fetch 之类的接口发到后端,拿到结果再跳转。此时参数在请求体里,语义上和表单 POST 一样。
不管是哪种形态,接收端都要做同样三件事,而且顺序不能颠倒:
1. 确认请求方法正确(该 POST 的接口就不要接受 GET) 2. 校验 CSRF 令牌(针对会改变服务端状态的操作) 3. 校验并转换参数(白名单、类型、长度),任一失败立刻拒绝第 2 步失败,说明这个请求根本不是从你自己的页面发出来的,直接拒绝即可,别抱着「先处理后校验」的侥幸。
三、接收端的三道防线
1. 类型校验:把「一串字符」变成「我认识的值」
安全做法是「只接受我认识的值」,而不是「过滤掉我讨厌的字符」。黑名单永远列不全,白名单天然安全。
$id = filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT); // 注意这是三态返回值:合法整数 / false(格式不对)/ null(字段压根不存在) if (!is_int($id) || $id < 1) { // 拒绝 }filter_input()返回三态是高频踩坑点:很多人只判断=== false,字段完全缺失时拿到的是null,于是一路绿灯走进业务逻辑。
2. 输出转义:离开 PHP 那一刻才转义
数据存进数据库时不要转义(否则你会得到一堆&),在输出到另一个上下文时才转义。输出到 HTML 用htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8');输出到 SQL 用预处理绑定;输出到 shell 用escapeshellarg()。
补充一个版本事实:PHP 8.1 起,htmlspecialchars()的默认flags 从ENT_COMPAT变成了ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401。但这不代表你可以省略参数——显式写出来,同一份代码在 PHP 8.0 上也一样安全。
3. CSRF 令牌:证明请求来自你自己的页面
$_SESSION['csrf_token'] ??= bin2hex(random_bytes(32));比较时用hash_equals()而不是==,避免逐字节比较泄露时序信息。登录成功后要session_regenerate_id(true),防止会话固定(session fixation)。改变状态的操作一律用 POST 加令牌,处理完再用 303 跳转,也就是常说的 Post/Redirect/Get 模式。
四、完整可运行示例
把下面这段保存成handle.php,在它所在目录启动内置服务器即可。
<?php declare(strict_types=1); /** * handle.php —— 按钮点击后提交的目标页面 * 最低 PHP 8.1(never 返回类型是 PHP 8.1 引入的) * * 启动:php -S 127.0.0.1:8000 -t . */ session_start(); if (!isset($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } if (!isset($_SESSION['items'])) { $_SESSION['items'] = []; } /** 输出到 HTML 上下文时才必须转义;这里输出纯文本,仍然走一遍,方便你换成页面时直接复用 */ function esc(string $value): string { return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8'); } function fail(int $status, string $message): never { http_response_code($status); header('Content-Type: text/plain; charset=utf-8'); echo $message, PHP_EOL; exit; } header('Content-Type: text/plain; charset=utf-8'); // ---------- 分支一:按钮点击后的提交 ---------- if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') { // 第一道:CSRF。用 hash_equals 做恒定时间比较 $token = (string)($_POST['csrf_token'] ?? ''); if ($token === '' || !hash_equals($_SESSION['csrf_token'], $token)) { fail(419, 'CSRF 校验失败:请求不是从本站页面发起的'); } // 第二道:类型校验。filter_input 是三态返回,别只判断 false $id = filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT); if (!is_int($id) || $id < 1) { fail(400, 'id 必须是大于 0 的整数'); } // 第三道:长度与存在性 $title = trim((string)($_POST['title'] ?? '')); if ($title === '' || mb_strlen($title, 'UTF-8') > 60) { fail(400, 'title 长度必须在 1~60 之间'); } // 业务写入(这里用 session 代替数据库) $_SESSION['items'][$id] = $title; // Post/Redirect/Get:303 让浏览器改用 GET 请求新地址,用户刷新不会重复提交 header('Location: /handle.php?done=' . $id, true, 303); exit; } // ---------- 分支二:GET,展示状态并下发令牌 ---------- $done = filter_input(INPUT_GET, 'done', FILTER_VALIDATE_INT); echo 'csrf_token=', $_SESSION['csrf_token'], PHP_EOL; if (is_int($done)) { echo '提交成功,id=', $done, PHP_EOL; } foreach ($_SESSION['items'] as $itemId => $itemTitle) { echo esc((string)$itemId), ' => ', esc($itemTitle), PHP_EOL; }跑起来验证一遍:
# 1. 起服务(另开一个终端) php -S 127.0.0.1:8000 -t . # 2. 取令牌,同时用 cookie jar 保存会话 TOKEN=$(curl -s -c /tmp/jar.txt http://127.0.0.1:8000/handle.php | sed -n 's/^csrf_token=//p') # 3. 带令牌提交;-L 才会跟着 303 跳转 curl -s -b /tmp/jar.txt -L -X POST http://127.0.0.1:8000/handle.php \ -d "csrf_token=$TOKEN" -d "id=7" -d "title=hello" # 4. 不带令牌提交,看拒绝效果 curl -s -o /dev/null -w 'HTTP %{http_code}\n' -b /tmp/jar.txt \ -X POST http://127.0.0.1:8000/handle.php -d "id=7" -d "title=hello"提交成功,id=7 7 => hello HTTP 419常见坑点
- ❌ 直接用
echo $_GET['name'];输出用户参数。
✅ 输出前统一走esc()转义;写入数据库时保持原样,不要提前转义。
- ❌ 用
if ($id == 0)之类的松散比较判断参数合法性。
✅ 用filter_input()显式校验类型,注意它的三态返回值(合法值 /false/null)。
- ❌ 把「删除」做成一个超链接,参数放在 URL 上。
✅ 删除一律 POST + CSRF 令牌 + 服务端权限校验;GET 只做无副作用的读取。
- ❌ 只在前端校验,认为「按钮是我控制的,用户改不了」。
✅ 前端校验只是体验优化,服务端必须独立完整地再校验一遍。
- ❌
if ($token == $_SESSION['csrf_token'])直接比较字符串。
✅ 用hash_equals($_SESSION['csrf_token'], $token)。
- ❌ 把数组或对象
serialize()后塞进隐藏字段,接收端unserialize()。
✅ 只传标识(如 ID),数据从服务端或 Session 重新取;反序列化不可信输入会造成对象注入。
- ❌ 令牌签发后永不轮换,一个页面用一年。
✅ 登录成功后session_regenerate_id(true),令牌随会话生命周期更新。
- ❌ 用
header('Location: ' . $_GET['back'])做「返回上一页」。
✅ 只允许跳转到白名单内的站内路径,否则就是一个开放重定向(open redirect)漏洞。
总结
| 场景 | 推荐方式 | 必须配套的动作 |
|---|---|---|
| 无副作用的筛选、分页 | GET 查询串 | 类型校验、输出转义 |
| 会改变状态的操作 | 表单 POST | CSRF 令牌、权限校验、Post/Redirect/Get |
| 多页面共享的中间数据 | Session | 登录后重建会话 ID,敏感数据不落 Cookie |
| 跨请求的一次性提示信息 | Session 中的 flash 数据 | 取用后立即删除 |
安全传递变量的关键不是「用 POST」,而是承认客户端传来的一切都不可信,然后在服务端按顺序完成「CSRF 令牌 → 类型校验 → 权限校验 → 转义输出」这四步。把这四步固化成项目里一个统一的入口函数,比每次写页面时凭记忆加判断要可靠得多。