不灭的焱

革命尚未成功,同志仍须努力 下载Java21

作者:AlbertWen  添加时间:2026-07-10 14:10:14  修改时间:2026-07-14 05:57:00  分类:04.运维体系  编辑

目录

osTicket 的 CSRF 工作原理详解

以下基于当前 osTicket v1.18.4 源码说明。v1.18.4 发布于 2026 年 6 月 17 日。(GitHub)

一、CSRF 是什么

CSRF 全称:

Cross-Site Request Forgery,跨站请求伪造

它利用的核心问题是:

浏览器访问某个网站时,会自动携带该网站的 Session Cookie。

例如,你已经登录了 osTicket 管理后台:

https://support.example.com/scp/

浏览器保存了 osTicket 的 Session Cookie:

Cookie: OSTSESSID=abc123...

随后你访问了攻击者的网站:

https://evil.example.com/

攻击者页面中隐藏了一个表单:

<form
    action="https://support.example.com/scp/staff.php"
    method="post"
    id="attack-form"
>
    <input type="hidden" name="do" value="delete">
    <input type="hidden" name="id" value="15">
</form>

<script>
document.getElementById("attack-form").submit();
</script>

浏览器向 osTicket 发请求时,仍可能自动携带:

Cookie: OSTSESSID=abc123...

如果 osTicket 只检查 Session Cookie,就可能认为这是管理员本人操作,从而执行删除。

CSRF Token 的作用就是再增加一项验证:

Session Cookie 正确
        +
CSRF Token 正确
        =
请求才允许执行

攻击者虽然可以诱导浏览器发送请求,却通常无法读取 osTicket 页面里的 CSRF Token,因此无法构造完整有效的请求。(OWASP)

二、osTicket 采用什么 CSRF 方案

osTicket 使用的是典型的:

Synchronizer Token Pattern,服务器端 Session 同步 Token 模式

整体流程如下:

浏览器首次访问 osTicket
        ↓
osTicket 创建 Session
        ↓
生成随机 CSRF Token
        ↓
把 Token 保存到当前 Session
        ↓
把同一个 Token 放进 HTML 表单
        ↓
用户提交表单
        ↓
浏览器同时提交 Session Cookie 和 CSRF Token
        ↓
服务器取出 Session 中的 Token
        ↓
比较两个 Token
        ↓
一致:继续处理
不一致:拒绝请求

osTicket 默认使用:

Session Cookie 名称:OSTSESSID
CSRF 参数名称:__CSRFToken__

osTicket 初始化时先启动名为 OSTSESSID 的 Session,然后创建名称为 __CSRFToken__ 的 CSRF 对象。(GitHub)

三、核心源码文件

osTicket 的 CSRF 核心实现主要位于:

include/class.csrf.php
include/class.osticket.php

职责大致是:

class.csrf.php
├── 生成 Token
├── 保存 Token
├── 判断 Token 是否过期
├── 验证 Token
└── 生成隐藏表单字段

class.osticket.php
├── 初始化 Session
├── 初始化 CSRF 对象
├── 从 POST 或请求头读取 Token
├── 调用 CSRF 类验证
└── 验证失败时记录系统日志

四、CSRF 对象如何初始化

include/class.osticket.php 中的核心逻辑可以简化为:

if (!defined('SESSION_SESSID')) {
    define('SESSION_SESSID', 'OSTSESSID');
}

$this->session = osTicketSession::start(
    SESSION_SESSID,
    SESSION_TTL,
    $this->isUpgradePending()
);

$this->csrf = new CSRF('__CSRFToken__');

它完成了两件事。

1. 启动 Session

浏览器获得类似 Cookie:

Set-Cookie: OSTSESSID=s4n1k0...;

后续请求会自动带上:

Cookie: OSTSESSID=s4n1k0...

2. 创建 CSRF 对象

new CSRF('__CSRFToken__');

意味着默认表单字段名称是:

__CSRFToken__

最终 HTML 中会看到:

<input
    type="hidden"
    name="__CSRFToken__"
    value="7ac96b336bd3d11fe2e8d120f949a2550fab1234"
/>

五、Token 保存在哪里

CSRF 构造函数中有以下核心代码:

function __construct($name='__CSRFToken__', $timeout=0) {
    $this->name = $name;
    $this->timeout = $timeout;
    $this->csrf = &$_SESSION['csrf'];
}

这里:

$this->csrf = &$_SESSION['csrf'];

表示 $this->csrf 是对当前 Session 中 csrf 数据的引用。

Session 里的数据概念上类似:

$_SESSION = [
    'csrf' => [
        'token' => '7ac96b336bd3d11fe2e8d120f949a2550fab1234',
        'time'  => 1783638000,
    ],

    '_staff' => [
        // 当前登录员工信息
    ],
];

所以 CSRF Token 并不是简单存在浏览器 Cookie 里,而是:

浏览器 Cookie
    └── OSTSESSID:Session 标识

服务器 Session
    └── csrf.token:真正的 CSRF Token

需要注意,class.csrf.php 文件顶部的旧注释写着 Token“不存储在 Session”,但 v1.18.4 的实际代码明确引用了 $_SESSION['csrf']。理解运行机制时,应以实际代码为准。(GitHub)

六、Token 是怎样生成的

核心代码:

function rotate() {
    $this->csrf['token'] =
        sha1(session_id() . Crypto::random(16) . SECRET_SALT);

    $this->csrf['time'] = time();
}

可以抽象成:

CSRF Token =
SHA1(
    当前 Session ID
    + 16 字节随机数据
    + 系统 SECRET_SALT
)

例如:

Session ID:
f83d7c1a2e9...

随机数据:
random-16-bytes

SECRET_SALT:
系统安装时使用的秘密盐值

拼接以后计算 SHA-1:

sha1(
    'f83d7c1a2e9...'
    . $randomBytes
    . $secretSalt
);

最终得到长度为 40 个十六进制字符的 Token:

7ac96b336bd3d11fe2e8d120f949a2550fab1234

生成逻辑包含三部分:

组成 作用
session_id() 将 Token 与当前 Session 关联
Crypto::random(16) 提供不可预测的随机性
SECRET_SALT 加入服务端秘密值
sha1() 将内容整理为固定长度字符串

其安全性主要依靠随机值和服务端秘密,而不是依靠 SHA-1 的抗碰撞能力。

Token 的生成、保存和隐藏字段输出代码都位于 include/class.csrf.php。(GitHub)

七、什么时候生成 Token

Token 通常不是每次调用都重新生成。

核心逻辑:

function getToken() {
    if (
        (!is_array($this->csrf) || !$this->csrf['token'])
        || $this->isExpired()
    ) {
        $this->rotate();
    } else {
        $this->csrf['time'] = time();
    }

    return $this->csrf['token'];
}

流程可以理解为:

调用 getToken()
        ↓
Session 中有 Token 吗?
   ├── 没有:生成一个新 Token
   └── 有
        ↓
      过期了吗?
        ├── 是:生成新 Token
        └── 否:继续使用原 Token

因此,同一 Session 中访问多个普通表单时,通常可以看到相同 Token:

工单回复表单:Token A
个人资料表单:Token A
新建工单表单:Token A

但是某些重要流程会主动调用:

$ost->getCSRF()->rotate();

这时会生成 Token B,旧 Token A 立即失效。

八、Token 默认会过期吗

构造函数默认参数是:

$timeout = 0

过期判断:

function isExpired() {
    return (
        $this->timeout
        && (time() - $this->csrf['time']) > $this->timeout
    );
}

因为默认:

$this->timeout = 0;

所以默认情况下,CSRF Token 本身没有独立的超时时间。

它通常随着以下情况失效:

Session 失效
Session 被清理
用户退出登录
Session ID 变化
程序主动 rotate()
服务器端 Session 数据丢失

也就是说:

默认情况下,CSRF Token 的有效生命周期主要依赖 osTicket Session 生命周期,而不是单独的 CSRF 超时参数。

九、osTicket 如何向表单中加入 Token

1. 底层方法

function getFormInput($name='') {
    if (!$name) {
        $name = $this->name;
    }

    return sprintf(
        '<input type="hidden" name="%s" value="%s" />',
        $name,
        $this->getToken()
    );
}

输出类似:

<input
    type="hidden"
    name="__CSRFToken__"
    value="7ac96b336bd3d11fe2e8d120f949a2550fab1234"
/>

2. 全局辅助函数

osTicket 还定义了:

function csrf_token() {
    global $ost;

    if ($ost && $ost->getCSRF()) {
        echo $ost->getCSRFFormInput();
    }
}

所以开发表单时,通常直接写:

<form method="post" action="profile.php">
    <?php csrf_token(); ?>

    <input type="text" name="name">

    <button type="submit">保存</button>
</form>

浏览器最终获得:

<form method="post" action="profile.php">
    <input
        type="hidden"
        name="__CSRFToken__"
        value="7ac96b336bd3d11fe2e8d120f949a2550fab1234"
    >

    <input type="text" name="name">

    <button type="submit">保存</button>
</form>

osTicket 员工登录表单就是在 <form> 开始位置调用 csrf_token()。(GitHub)

十、提交表单时发送了什么

假设表单如下:

<form method="post" action="profile.php">
    <?php csrf_token(); ?>

    <input type="text" name="name" value="Albert">
    <button type="submit">保存</button>
</form>

浏览器发送:

POST /scp/profile.php HTTP/1.1
Host: support.example.com
Cookie: OSTSESSID=f83d7c1a2e9...
Content-Type: application/x-www-form-urlencoded

__CSRFToken__=7ac96b336bd3d11fe2e8d120f949a2550fab1234
&name=Albert

请求里包含两项关键数据:

Cookie:
OSTSESSID=f83d7c1a2e9...

POST:
__CSRFToken__=7ac96b336bd3...

服务器根据 OSTSESSID 找到 Session:

$_SESSION['csrf']['token']

然后将其与 POST 中的:

$_POST['__CSRFToken__']

进行比较。

十一、osTicket 如何校验 Token

include/class.osticket.php 中的核心代码:

function checkCSRFToken($name=false, $rotate=false) {
    $name = $name ?: $this->getCSRF()->getTokenName();

    $token =
        $_POST[$name]
        ?: $_SERVER['HTTP_X_CSRFTOKEN'];

    if ($token && $this->validateCSRFToken($token)) {
        if ($rotate) {
            $this->getCSRF()->rotate();
        }

        return true;
    }

    $msg = sprintf(
        __('Invalid CSRF token [%1$s] on %2$s'),
        Format::htmlchars(Format::sanitize($token)),
        THISPAGE
    );

    $this->logWarning(
        __('Invalid CSRF Token') . ' ' . $name,
        $msg,
        false
    );

    return false;
}

它会从两个地方查找 Token:

$_POST['__CSRFToken__']

或者 HTTP 请求头:

X-CSRFToken: 7ac96b336bd3...

完整顺序是:

POST 参数 __CSRFToken__
            ↓
如果不存在,再读取 X-CSRFToken 请求头
            ↓
调用 validateCSRFToken()
            ↓
Token 正确:返回 true
Token 错误:记录警告日志并返回 false

这使普通 HTML 表单和 AJAX 请求都能使用 CSRF 防护。(GitHub)

十二、底层比较逻辑

class.csrf.php 中:

function validateToken($token) {
    return (
        $token
        && trim($token) == $this->getToken()
        && !$this->isExpired()
    );
}

等价于:

$submittedToken = trim($token);
$sessionToken = $this->getToken();

if (
    $submittedToken !== ''
    && $submittedToken == $sessionToken
    && !$this->isExpired()
) {
    return true;
}

return false;

校验条件包括:

1. 客户端确实提交了 Token
2. 去除首尾空格后与服务器 Token 相同
3. Token 没有过期

任意条件不满足,就会校验失败。

十三、员工登录的完整案例

osTicket 员工登录地址通常是:

/scp/login.php

第一步:访问登录页面

浏览器:

GET /scp/login.php HTTP/1.1
Host: support.example.com

服务器创建 Session:

OSTSESSID=SESSION_A

并生成 Token:

TOKEN_A

返回页面:

<form action="login.php" method="post">
    <input
        type="hidden"
        name="__CSRFToken__"
        value="TOKEN_A"
    >

    <input type="text" name="userid">
    <input type="password" name="passwd">
</form>

第二步:提交用户名密码

浏览器发送:

POST /scp/login.php HTTP/1.1
Cookie: OSTSESSID=SESSION_A

__CSRFToken__=TOKEN_A
&userid=admin
&passwd=123456

第三步:服务器读取 Session

服务器根据:

OSTSESSID=SESSION_A

取得:

$_SESSION['csrf']['token'] = 'TOKEN_A';

第四步:比较

请求 Token:TOKEN_A
Session Token:TOKEN_A

结果:一致

于是继续验证用户名和密码。

如果不一致:

请求 Token:TOKEN_X
Session Token:TOKEN_A

结果:不一致

osTicket 会提示:

Valid CSRF Token Required

员工登录处理程序会先调用 $ost->checkCSRFToken(),失败时将错误信息写入 Session 并重定向回登录页。(GitHub)

十四、为什么登录失败后 Token 可能变化

在员工普通登录失败后,osTicket 会执行:

$ost->getCSRF()->rotate();

这意味着:

原 Token:TOKEN_A
登录失败
        ↓
生成新 Token:TOKEN_B
        ↓
TOKEN_A 失效

目的之一是限制旧登录请求被重复提交。

例如攻击者截获了旧的登录请求:

__CSRFToken__=TOKEN_A
&userid=admin
&passwd=guess1

当服务端已经把 Token 更新为 TOKEN_B 后,再次重放 TOKEN_A:

请求 Token:TOKEN_A
Session Token:TOKEN_B

校验失败。

客户端登录入口也明确在 POST 校验通过后主动轮换 Token,使原 Token 不能再次用于下一次登录尝试。(GitHub)

但需要注意:

Token 是否每次请求后轮换,由具体处理入口决定,不是所有 osTicket 表单都自动使用一次性 Token。

checkCSRFToken() 本身提供了 $rotate 参数:

$ost->checkCSRFToken(false, true);

也可以由业务代码在验证后手动执行:

$ost->getCSRF()->rotate();

十五、AJAX 请求如何携带 Token

osTicket 支持通过以下请求头发送 Token:

X-CSRFToken: TOKEN_A

PHP 页面输出 Token

<script>
const csrfToken = <?php
    echo json_encode($ost->getCSRFToken());
?>;
</script>

使用 fetch 提交

fetch('ajax.php/custom/update', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRFToken': csrfToken
    },
    body: JSON.stringify({
        ticketId: 1001,
        status: 'closed'
    })
});

服务器端:

if (!$ost->checkCSRFToken()) {
    Http::response(400, __('Valid CSRF Token Required'));
}

PHP 会把:

X-CSRFToken

映射为:

$_SERVER['HTTP_X_CSRFTOKEN']

所以 osTicket 的代码可以读取:

$token = $_SERVER['HTTP_X_CSRFTOKEN'];

源码注释也明确说明,普通表单可使用隐藏字段,AJAX 可使用 X-CSRFToken 请求头。(GitHub)

十六、自定义 osTicket 页面如何正确加入 CSRF

假设你新增一个页面:

/scp/custom-settings.php

用于修改自定义配置。

完整示例

<?php

require_once('staff.inc.php');

if (!defined('OSTSCPINC')) {
    define('OSTSCPINC', true);
}

$errors = [];
$msg = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!$ost->checkCSRFToken()) {
        Http::response(400, __('Valid CSRF Token Required'));
    }

    $value = trim($_POST['custom_value'] ?? '');

    if ($value === '') {
        $errors['custom_value'] = '配置值不能为空';
    } else {
        // 在这里保存配置
        // CustomConfig::save($value);

        $msg = '保存成功';
    }
}

require(STAFFINC_DIR . 'header.inc.php');
?>

<h2>自定义配置</h2>

<?php if ($msg): ?>
    <div id="msg_notice">
        <?php echo Format::htmlchars($msg); ?>
    </div>
<?php endif; ?>

<?php if (!empty($errors)): ?>
    <div id="msg_error">
        配置保存失败,请检查输入内容。
    </div>
<?php endif; ?>

<form method="post" action="custom-settings.php">

    <?php csrf_token(); ?>

    <label for="custom_value">配置值</label>

    <input
        id="custom_value"
        name="custom_value"
        type="text"
        value="<?php
            echo Format::htmlchars(
                $_POST['custom_value'] ?? ''
            );
        ?>"
    >

    <?php if (isset($errors['custom_value'])): ?>
        <div class="error">
            <?php echo Format::htmlchars(
                $errors['custom_value']
            ); ?>
        </div>
    <?php endif; ?>

    <button type="submit">保存</button>
</form>

<?php
require(STAFFINC_DIR . 'footer.inc.php');

核心只有两部分。

表单中生成:

<?php csrf_token(); ?>

服务端校验:

if (!$ost->checkCSRFToken()) {
    Http::response(400, __('Valid CSRF Token Required'));
}

十七、使用自定义 Token 字段名称

默认名称是:

__CSRFToken__

也可以使用自定义名称。

表单部分

<?php
echo $ost
    ->getCSRF()
    ->getFormInput('custom_csrf_token');
?>

输出:

<input
    type="hidden"
    name="custom_csrf_token"
    value="TOKEN_A"
/>

服务端校验

if (!$ost->checkCSRFToken('custom_csrf_token')) {
    Http::response(400, 'CSRF Token 校验失败');
}

不过在 osTicket 插件和自定义页面中,通常没有必要修改默认名称,直接使用:

csrf_token();

更符合原生代码习惯。

十八、没有 CSRF Token 会发生什么

假设直接使用 curl 提交:

curl -X POST \
  https://support.example.com/scp/login.php \
  -d "userid=admin&passwd=123456"

请求里没有:

__CSRFToken__

checkCSRFToken() 得到:

$token = null;

于是返回:

false

最终出现:

Valid CSRF Token Required

即使带了 Session Cookie,也仍然失败:

curl -X POST \
  https://support.example.com/scp/login.php \
  -H "Cookie: OSTSESSID=abc123" \
  -d "userid=admin&passwd=123456"

因为有效请求需要同时具有:

正确 Session Cookie
正确 CSRF Token

十九、为什么攻击者不能直接获取 Token

假设攻击者页面位于:

https://evil.example.com

osTicket 位于:

https://support.example.com

攻击者可以创建跨站表单并诱导浏览器提交:

<form action="https://support.example.com/scp/profile.php">

但攻击者通常不能通过 JavaScript直接读取:

https://support.example.com/scp/profile.php

页面内容,因此无法知道隐藏字段:

<input name="__CSRFToken__" value="TOKEN_A">

攻击者只能猜:

<input
    name="__CSRFToken__"
    value="some-guessed-token"
>

服务器比较:

攻击者提交:some-guessed-token
Session 保存:TOKEN_A

自然不一致。

二十、CSRF Token 不能防什么

1. 不能代替身份认证

CSRF Token 只能证明:

请求很可能来自服务器生成的合法页面。

它不能证明用户是谁。

因此仍然必须检查:

用户是否已登录
是否是员工
是否是管理员
是否有相关权限

正确顺序通常是:

if (!$thisstaff) {
    Http::response(401, 'Authentication required');
}

if (!$thisstaff->hasPerm(...)) {
    Http::response(403, 'Permission denied');
}

if (!$ost->checkCSRFToken()) {
    Http::response(400, 'Valid CSRF Token Required');
}

2. 不能防止 XSS

如果 osTicket 页面存在 XSS,恶意脚本运行在 osTicket 自己的域名下,就可能读取:

document.querySelector(
    'input[name="__CSRFToken__"]'
).value;

然后使用正确 Token 发请求。

因此:

CSRF 防护 ≠ XSS 防护

CSRF Token 和输出转义、CSP、输入过滤必须共同使用。

3. 不能替代业务权限验证

即使 Token 正确,也不能直接认为用户可以删除工单。

还必须检查:

$thisstaff->hasPerm(Ticket::PERM_DELETE)

CSRF 检查解决的是:

“这个请求是不是被第三方网站伪造的?”

权限检查解决的是:

“当前员工有没有权力执行这个操作?”

二十一、为什么会出现 “Valid CSRF Token Required”

这类错误本质上说明:

浏览器提交的 Token
和
当前 Session 中的 Token
没有对应上

常见原因如下。

1. Session Cookie 没有成功保存

浏览器提交表单时没有携带:

OSTSESSID

服务器会认为这是新 Session,于是生成新 Token:

页面 Token:TOKEN_A
服务器新 Session Token:TOKEN_B

校验失败。

需要在浏览器开发者工具中检查:

Application
  → Cookies
  → OSTSESSID

以及请求头:

Cookie: OSTSESSID=...

2. 页面打开太久,Session 已过期

例如:

09:00 打开编辑页面,Token 为 A
12:00 Session 已过期
12:10 点击保存

服务器创建新 Session,并得到 Token B:

浏览器提交:Token A
当前 Session:Token B

解决方式通常是刷新页面后重新提交。

3. 多个标签页中的 Token 被轮换

例如:

标签页 1:Token A
标签页 2:Token A

标签页 1 执行某个会 rotate() 的操作:

Session Token 更新为 Token B

标签页 2 仍提交 Token A:

Token A != Token B

于是失败。

这在登录、2FA 或主动轮换 Token 的流程中更容易出现。

4. 反向代理导致 Session 不稳定

例如架构:

浏览器
   ↓
Nginx / Apache 反向代理
   ↓
osTicket

如果以下配置不一致,可能导致 Cookie 或 Session 异常:

HTTP 与 HTTPS 不一致
Host 头错误
Cookie Path 不正确
Cookie Domain 不正确
代理前后 URL 不一致
负载均衡节点没有共享 Session

典型问题:

用户访问:
https://support.example.com

后端认为:
http://192.168.1.20/osticket

此时需要正确传递:

proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;

并确保所有节点共享相同 Session 存储或启用粘性会话。历史上,osTicket 的相关问题报告也多次涉及 Session Cookie、反向代理和 Session 数据读取失败。(GitHub)

5. 页面缓存了旧 Token

如果 CDN、Nginx 或浏览器缓存了包含 Token 的 HTML:

<input name="__CSRFToken__" value="OLD_TOKEN">

但当前服务器 Session 中已经是:

NEW_TOKEN

提交就会失败。

因此登录页、后台页面和带有用户 Session 的页面不应被公共缓存。

可设置:

Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache

特别要避免 CDN 缓存:

/scp/*
/login.php
/tickets.php

6. 自定义表单漏掉 csrf_token()

错误代码:

<form method="post">
    <input name="name">
    <button type="submit">保存</button>
</form>

服务端却执行:

$ost->checkCSRFToken();

自然会失败。

正确代码:

<form method="post">
    <?php csrf_token(); ?>

    <input name="name">
    <button type="submit">保存</button>
</form>

7. AJAX 没有提交 Token

错误:

fetch('/scp/ajax.php/custom/update', {
    method: 'POST',
    body: JSON.stringify(data)
});

正确:

fetch('/scp/ajax.php/custom/update', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRFToken': csrfToken
    },
    body: JSON.stringify(data)
});

二十二、如何使用浏览器检查 osTicket CSRF

打开浏览器开发者工具。

第一步:检查页面表单

在 Elements 中搜索:

__CSRFToken__

应该看到:

<input
    type="hidden"
    name="__CSRFToken__"
    value="..."
>

第二步:检查 POST 请求

在:

Network → 请求 → Payload

应该看到:

__CSRFToken__: 7ac96b...

第三步:检查 Cookie

在:

Network → 请求 → Headers → Request Headers

检查:

Cookie: OSTSESSID=...

第四步:对比页面和请求 Token

正常情况:

页面 Token:7ac96b...
请求 Token:7ac96b...

第五步:检查服务器日志

checkCSRFToken() 校验失败时会记录:

Invalid CSRF Token __CSRFToken__

并包括类似:

Invalid CSRF token [提交的值] on 当前页面

相关日志可以在 osTicket 管理后台系统日志中查看。

二十三、完整工作流程总结

以员工修改个人资料为例:

1. 员工访问 profile.php
       ↓
2. 浏览器携带 OSTSESSID
       ↓
3. osTicket 根据 OSTSESSID 恢复 Session
       ↓
4. CSRF 对象绑定 $_SESSION['csrf']
       ↓
5. 如果没有 Token,则调用 rotate()
       ↓
6. 生成:
   SHA1(session_id + random + SECRET_SALT)
       ↓
7. csrf_token() 输出隐藏字段:
   __CSRFToken__=TOKEN_A
       ↓
8. 员工填写表单并提交
       ↓
9. 浏览器发送:
   Cookie: OSTSESSID=SESSION_A
   POST: __CSRFToken__=TOKEN_A
       ↓
10. osTicket 根据 SESSION_A 读取 Session Token
       ↓
11. 比较:
    POST TOKEN_A == Session TOKEN_A
       ↓
12. 相等则继续检查登录状态和权限
       ↓
13. 执行资料修改

失败流程:

POST Token 缺失或不匹配
       ↓
checkCSRFToken() 返回 false
       ↓
记录 Invalid CSRF Token 日志
       ↓
返回 400、重定向或显示:
Valid CSRF Token Required

二十四、开发 osTicket 插件时的推荐模板

<?php

require_once('staff.inc.php');

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    // 1. 验证登录身份
    if (!$thisstaff || !$thisstaff->isValid()) {
        Http::response(401, 'Authentication required');
    }

    // 2. 验证权限
    if (!$thisstaff->isAdmin()) {
        Http::response(403, 'Permission denied');
    }

    // 3. 验证 CSRF
    if (!$ost->checkCSRFToken()) {
        Http::response(
            400,
            __('Valid CSRF Token Required')
        );
    }

    // 4. 校验业务参数
    $name = trim($_POST['name'] ?? '');

    if ($name === '') {
        Http::response(422, 'Name is required');
    }

    // 5. 执行业务操作
    // save($name);

    Http::redirect('custom.php?saved=1');
}
?>

<form method="post" action="custom.php">

    <?php csrf_token(); ?>

    <input
        type="text"
        name="name"
        required
    >

    <button type="submit">
        保存
    </button>
</form>

记住三个层次不能混为一谈:

Session / 登录认证
    → 你是谁

权限验证
    → 你能做什么

CSRF 验证
    → 这个请求是否来自合法页面流程

osTicket 的 CSRF 核心可以浓缩成一句话:

服务端为当前 OSTSESSID Session 保存一个不可预测的 Token,通过 csrf_token() 将其放入表单,提交时由 checkCSRFToken() 对比 POST 字段或 X-CSRFToken 请求头;只有请求值与当前 Session Token 匹配,业务操作才继续执行。