
前言页面 A 上有个按钮点击后要把一个变量订单号、用户 ID、筛选条件带到页面 B。很多人的第一版代码是把值拼进 URLB 页面直接用$_GET读出来就用。功能能跑问题在后面才冒出来用户在地址栏把id1改成id2看到了别人的订单有人把带删除参数的链接丢进群里搜索爬虫顺着链接就把数据删了日志里出现一条带着脚本标签的参数页面开始弹窗。根因只有一句话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 那一刻才转义数据存进数据库时不要转义否则你会得到一堆amp;在输出到另一个上下文时才转义。输出到 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_types1); /** * handle.php —— 按钮点击后提交的目标页面 * 最低 PHP 8.1never 返回类型是 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; charsetutf-8); echo $message, PHP_EOL; exit; } header(Content-Type: text/plain; charsetutf-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/Get303 让浏览器改用 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 id7 -d titlehello # 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 id7 -d titlehello提交成功id7 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 查询串类型校验、输出转义会改变状态的操作表单 POSTCSRF 令牌、权限校验、Post/Redirect/Get多页面共享的中间数据Session登录后重建会话 ID敏感数据不落 Cookie跨请求的一次性提示信息Session 中的 flash 数据取用后立即删除安全传递变量的关键不是「用 POST」而是承认客户端传来的一切都不可信然后在服务端按顺序完成「CSRF 令牌 → 类型校验 → 权限校验 → 转义输出」这四步。把这四步固化成项目里一个统一的入口函数比每次写页面时凭记忆加判断要可靠得多。