Protection CSRF (Origin + jeton scopé)
为什么要保护 POST 接口
若未将 CSRF 令牌绑定到会话,第三方站点可在已认证用户浏览其他网站时,向 PmaControl 提交 POST。浏览器会自动附带会话 Cookie,操作在用户毫无察觉的情况下执行——修改配置、触发任务、行内更新等。
将 GET 改为 POST 可阻止被动触发(预加载、爬虫抓取),但无法抵御从其他域发起的主动 CSRF 攻击。
共享的 Glial 助手
自 Glial v5.1.40 起,CSRF 逻辑与 Origin/Referer 校验已统一放入框架中,由所有 PmaControl 控制器共享:
Glial\Security\Csrf::issueToken()/Csrf::validateToken()— 按功能 scope 颁发和校验令牌。Glial\Http\Request::isSameSite()— 将 Origin(优先)或 Referer(回退)与当前来源比对。
PmaControl 控制器不会重复此逻辑:
Worker.php 仅包含与 POST /Worker/update 相关的 HTTP 编排。流程示意图
令牌颁发
在渲染含有表单或行内可编辑单元的视图之前,控制器会颁发与功能绑定的作用域令牌:
<?php
use Glial\Security\Csrf;
$token = Csrf::issueToken($_SESSION, 'worker.update');
$this->set('csrf_token', $token);
作用域可避免为
worker.update 颁发的令牌被重放到 daemon.start。令牌存储于 $_SESSION['csrf_tokens'][$scope],直至用户会话结束。客户端注入
对于行内可编辑单元(bootstrap-editable),令牌通过两个 data-* 属性注入:
<td class="editable"
data-name="nb_worker"
data-csrf-field="csrf_token"
data-csrf-token="<?= htmlspecialchars($csrf_token, ENT_QUOTES, 'UTF-8') ?>">
5
</td>
值会始终通过
htmlspecialchars(..., ENT_QUOTES, 'UTF-8') 进行转义,以阻止单元内任何 HTML/JS 注入。JS App/Webroot/js/Tree/index.js 检测到 data-csrf-token 存在后,会自动将令牌追加到 bootstrap-editable 发出的 POST 参数中:
$.fn.editable.defaults.params = function (params) {
var $cell = $(this).closest('[data-csrf-token]');
if ($cell.length) {
params[$cell.data('csrf-field')] = $cell.data('csrf-token');
}
return params;
};
AJAX 刷新(例如通过 Daemon)后,
window.pmacontrolInitLineEdit(context) 会在重新加载的上下文中被再次调用,以将新令牌绑定到新的单元。服务端校验链
在构造任何 SQL 之前,按以下顺序依次进行四项校验:
- HTTP 方法——仅允许
POST;其他方法直接返回405 Method Not Allowed。 - 同站点来源——优先取
Origin,缺失时回退到Referer;为null、protocol-relative 或 scheme/host/port 不一致的请求会返回403 Invalid request origin。 - CSRF 令牌——使用
hash_equals()对比请求 payload 中的csrf_token字段与$_SESSION['csrf_tokens'][$scope];缺失或不匹配时返回403 Invalid CSRF token。 - Payload 白名单——仅接受该端点预期的字段与类型;任何不在白名单内的 payload 返回
400 Invalid worker update payload。
<?php
class Worker
{
public function update()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
return;
}
if (!Glial\Http\Request::isSameSite($_SERVER)) {
http_response_code(403);
echo 'Invalid request origin';
return;
}
if (!Glial\Security\Csrf::validateToken($_POST, $_SESSION, 'worker.update')) {
http_response_code(403);
echo 'Invalid CSRF token';
return;
}
$sql = $this->buildWorkerUpdateSql($_POST);
if ($sql === null) {
http_response_code(400);
echo 'Invalid worker update payload';
return;
}
// SQL exécuté ici uniquement
}
}
只要四项校验未全部通过,就不会构造任何 SQL;纵深防御在任何一道关卡被绕过时仍能生效。
参考——返回码
| 状态码 | 原因 | 触发条件 |
|---|---|---|
405 |
Method Not Allowed | 非 POST 的 HTTP 方法。 |
403 |
Invalid request origin | 跨站 Origin/Referer、Origin: null、protocol-relative URL 或 scheme/host/port 不一致。 |
403 |
Invalid CSRF token | payload 中缺少令牌或与 $_SESSION['csrf_tokens'][$scope] 不匹配。 |
400 |
Invalid worker update payload | 字段不在白名单内、值非整数、pk 为零或负数。 |
切勿为变更类端点设置 GET 兜底「方便调试」:这会悄无声息地禁用 CSRF 保护,因为该保护仅作用于 POST。
已记录的豁免情况
该 CSRF 助手仅覆盖 Web POST 接口。以下场景明确豁免:
- CLI
php glial …——无会话 Cookie,亦无 CSRF 风险面。 - 机对机 REST API——使用独立的 API 令牌进行身份认证;这些路由禁用 Origin/Referer 校验,并在每个端点逐一记录豁免说明。
- 只读端点——已改为 GET,不在 CSRF 防护范围内。
专属测试
CSRF 保护由 PHPUnit 测试覆盖,这些测试明确再现了历史上的攻击场景:
./vendor/bin/phpunit tests/Glial/Security/CsrfTest.php
./vendor/bin/phpunit tests/Glial/Http/RequestTest.php
./vendor/bin/phpunit tests/Controller/WorkerUpdateSecurityTest.php
若使用
Origin: https://attacker.test 或其他外部站点,即便附带有效的 CSRF 令牌,payload 也会在写入前以 403 Invalid request origin 被拒;校验链会在 Origin 检查阶段就拦截请求,根本不会进入令牌校验。