PHP 防 CSRF 攻击:Token 校验的完整实现与自愈重试设计

CLARA轻量论坛系统
CLARA轻量论坛系统 星耀SVIP管理员 黑卡会员
发布于 2026-09-15 11:43 ·12 浏览 ·0 回复

CSRF 防护的核心不是"加一个隐藏字段",而是「Session 里存 Token + POST 时用 hash_equals 比对 + 失败时明确返回可识别的错误码 + 前端自动拉新 Token 重发一次」这四件事完整闭环。只做前三步,用户就会频繁撞见"页面已过期";补上第四步,才谈得上既安全又不出戏。

最小正确实现:Session Token + 一次性校验

结论:Token 必须由服务端生成、存在 Session 里、用 hash_equals 做恒定时间比较,且每次用完即废。前端自己 JS 生成的随机字符串毫无意义,因为攻击者同样能生成。

生成侧,用一个带过期时间的 Token 池,避免单个 Token 长期有效:

function csrf_field(): string {
    $t = bin2hex(random_bytes(32));
    $_SESSION['csrf_tokens'][$t] = time() + 7200; // 2 小时有效
    foreach ($_SESSION['csrf_tokens'] as $k => $exp) {
        if ($exp < time()) unset($_SESSION['csrf_tokens'][$k]); // 顺手清理
    }
    return '<input type="hidden" name="csrf_token" value="' . $t . '">';
}

校验侧,一次消费、时间比对、恒定时间:

function csrf_verify(?string $token): bool {
    if (!$token || empty($_SESSION['csrf_tokens'][$token])) return false;
    $exp = $_SESSION['csrf_tokens'][$token];
    unset($_SESSION['csrf_tokens'][$token]); // 一次性,防重放
    return $exp >= time();
}

三个细节别省:一是用 hash_equals 而不是 `==`,避免时序侧信道;二是只对会改变数据的 POST/PUT/DELETE 校验,GET 永远不该写库;三是失败时返回结构化错误(如 `{"code":"csrf_expired"}`),而不是直接甩一个 403 空白页——前端拿不到错误码,就无法自愈。

为什么会「页面已过期」:四个真实原因

结论:Token 失效绝大多数不是被攻击,而是会话上下文变了或页面放太久,把它当成安全事件去排查是白费力气。

  1. 页面缓存太久:用户开着编辑页半小时才提交,Token 已过有效期。
  2. 登录态切换:登录、退出、切换账号后 Session ID 重生成,旧 Token 全部作废。
  3. 域名不统一:`www.example.com` 和裸域 `example.com` 混用,Cookie 作用域不同,浏览器带着另一份 Session 提交,服务端自然认不出。这类问题在后台「基本设置」里把站点地址写全(带 `https://` 与主域名)即可根治。
  4. 多标签页并发:一次性 Token 被 A 标签页消费掉,B 标签页再提交就失败了。

自愈重试:失败后拉新 Token 重发一次

结论:自愈重试的关键是「只重试一次、只重试明确标记为 Token 失效的请求」,否则容易变成重复发帖。

思路很朴素:页面里放一个 `<meta name="csrf-token">`,请求带上它;服务端返回 `csrf_expired` 时,前端先去取一个新 Token 写回 meta,再把原请求重发一次。

async function postWithCsrf(url, data, canRetry = true) {
  data.csrf_token = document.querySelector('meta[name="csrf-token"]').content;
  const res  = await fetch(url, { method: 'POST', credentials: 'same-origin',
                                  body: new URLSearchParams(data) });
  const json = await res.json();
  if (json.code === 'csrf_expired' && canRetry) {
    await fetch('/api/csrf-token', { credentials: 'same-origin' }); // 写回 meta
    return postWithCsrf(url, data, false);                            // 只重试一次
  }
  return json;
}

Clara BBS 的编辑器就是按这个模式做的:新版编辑器内置自动重试机制,遇到 CSRF 校验未通过会拉取新 Token 重发一次。若仍失败,刷新页面即可。系统本身是全站 CSRF 防护,且无需命令行、无需编译,插件也是保存即生效,所以这套逻辑对普通站长来说是"看不见的"。

要提醒的是:重试只对「明确失败」的操作安全。如果第一次请求其实已经写库成功、只是响应丢了,重试就会重复提交。因此业务层最好再叠一层防灌水——发帖/回帖最小间隔、内容去重——让重试不会变成刷帖工具。

加固与排查清单

结论:Token 是第一道防线,SameSite 与 Origin 校验是低成本高收益的第二、三道。

  • Cookie 设置 `SameSite=Lax`(跨站 POST 不带 Cookie),表单类站点基本够用;纯 API 场景可上 `Strict`。
  • 对敏感操作(改密码、转账)额外校验 `Origin`/`Referer` 是否属于本站。
  • 别把 Token 塞进 URL 或日志,会随 Referer 泄露。
  • 排查顺序固定:先看站点地址是否 www/裸域混用 → 再看 Token 有效期是否过短 → 最后看多标签页并发。

一句话收束:CSRF 的完整实现是服务端严谨校验,自愈重试是前端的体验补偿,两者缺一,安全和可用性就会二选一。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-371.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~