Files
ulthon_admin/tests/McpCsrfExemptionTest.php

276 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace tests;
use app\admin\middleware\CsrfMiddleware;
use PHPUnit\Framework\TestCase;
use think\exception\HttpResponseException;
use think\facade\Cache;
use think\Request;
/**
* CsrfMiddleware 的 MCP 内部分发豁免单元测试mcp_internal 标记 token.
*
* ===== IS_CSRF=true 注入方案与理由(重要)=====
* CsrfMiddlewareBase 读 env('adminsystem.IS_CSRF', true)——即 think\Env 实例的
* $data['ADMINSYSTEM_IS_CSRF']。本项目 .env [ADMINSYSTEM] 段显式 IS_CSRF=false
* 不注入则豁免分支外的一切拦截逻辑根本不会被执行。
*
* 曾评估并放弃的方案:
* - putenv / $_ENVthink\Env 的 get() 先查 $data.env 已把键载入 $data
* OS 环境变量只是 $data 缺键时的 fallback——对已存在键无效learnings Task 1 结论)
* - 临时改容器 .env 再还原sync_env 每 3s 从宿主单向覆盖,时序不可控且污染共享栈
*
* 采用方案think\Env::set('adminsystem.IS_CSRF', true) 直接写 $data
* Env::set 是 public API键名规范化与 get() 完全一致),对同进程内的
* env() 读取立即生效tearDown 恢复原值。本测试进程与被测中间件同一容器进程,
* 这是唯一可靠、无副作用、无时序竞争的注入点。
*
* ===== 中间件调用方式 =====
* 直接 new CsrfMiddlewareapp 层空壳)+ 构造 think\RequestwithServer/withHeader
* 模拟 POST + Authorization 头并把构造请求绑定进容器JumpTrait 的 error()
* 经 request() 取当前请求判定响应类型,生产行为中中间件永远跑在容器当前请求上)。
* login 缓存预置带/不带 mcp_internal 标记的 token断言 next 放行(豁免)与
* HttpResponseException 拦截(错误文案)。
*
* 无 DB 写入(只读/写 login 文件缓存),无需事务隔离,不继承 app TestCase。
*/
class McpCsrfExemptionTest extends TestCase
{
private const HOST_NAME = '127.0.0.1';
private const SAME_ORIGIN_REFERER = 'http://127.0.0.1/admin/system.quick/index';
private const CROSS_ORIGIN_REFERER = 'http://evil.example/attack.html';
/** @var array<int, string> 本测试写入 login 缓存的 tokentearDown 清理) */
private array $cacheTokens = [];
private mixed $origIsCsrf = null;
protected function setUp(): void
{
// 注入 IS_CSRF=true方案见类注释先存原值
$env = app('env');
$this->origIsCsrf = $env->get('adminsystem.IS_CSRF');
$env->set('adminsystem.IS_CSRF', true);
// 注入必须真实生效,否则后续断言测的是「开关关闭全放行」的空路径
$this->assertTrue(
env('adminsystem.IS_CSRF', false),
'IS_CSRF 注入未生效Env::set 应压过 .env 载入值),后续拦截断言无意义'
);
}
protected function tearDown(): void
{
// 恢复原值(.env 默认 false避免污染同进程后续测试
app('env')->set('adminsystem.IS_CSRF', $this->origIsCsrf === null ? false : $this->origIsCsrf);
foreach ($this->cacheTokens as $token) {
Cache::store('login')->delete($token);
}
$this->cacheTokens = [];
}
// =========================================================================
// 构造工具
// =========================================================================
/**
* 构造 POST 请求(可选 Bearer / referer.
*/
private function makePostRequest(?string $bearer, string $referer): Request
{
$server = [
'REQUEST_METHOD' => 'POST',
'HTTP_HOST' => self::HOST_NAME,
'SERVER_NAME' => self::HOST_NAME,
'SERVER_PORT' => '80',
'REQUEST_URI' => '/admin/system.quick/index',
'QUERY_STRING' => '',
];
if ($referer !== '') {
$server['HTTP_REFERER'] = $referer;
}
$headers = [];
if ($bearer !== null) {
$server['HTTP_AUTHORIZATION'] = 'Bearer ' . $bearer;
$headers['authorization'] = 'Bearer ' . $bearer;
}
if ($referer !== '') {
$headers['referer'] = $referer;
}
$request = new Request();
$request
->withServer($server)
->withHeader($headers)
->withSession(new \think\Session(app()));
return $request;
}
/**
* 构造 GET 请求(方法白名单应直接放行).
*/
private function makeGetRequest(): Request
{
$request = $this->makePostRequest(null, '');
$request->withServer([
'REQUEST_METHOD' => 'GET',
'HTTP_HOST' => self::HOST_NAME,
'SERVER_NAME' => self::HOST_NAME,
'SERVER_PORT' => '80',
'REQUEST_URI' => '/admin/system.quick/index',
'QUERY_STRING' => '',
]);
return $request;
}
/**
* 预置一条 login 缓存 token模拟服务端写入的会话/内部分发 token.
*/
private function cacheToken(array $payload): string
{
$token = 'csrf_e2e_' . bin2hex(random_bytes(8));
Cache::store('login')->set($token, $payload, 60);
$this->cacheTokens[] = $token;
return $token;
}
/**
* 以「中间件的真实运行姿势」执行把请求绑定进容器JumpTrait error() 经
* request() 判定响应类型finally 恢复原容器请求.
*
* @return mixed next 闭包的返回值(哨兵)
*/
private function runMiddleware(Request $request, \Closure $next): mixed
{
$app = app();
$origRequest = $app->request;
$app->instance('request', $request);
try {
return (new CsrfMiddleware())->handle($request, $next);
} finally {
$app->instance('request', $origRequest);
}
}
// =========================================================================
// 豁免:带 mcp_internal 标记 token
// =========================================================================
public function test_mcp_internal_token_bypasses_csrf(): void
{
// McpDispatch 生成的一次性 creator token 形态mcp_internal 标记是唯一豁免依据)
$token = $this->cacheToken([
'id' => 12345,
'username' => 'e2e_creator',
'expire_time' => time() + 300,
'mcp_internal' => true,
]);
$result = $this->runMiddleware(
$this->makePostRequest($token, self::SAME_ORIGIN_REFERER),
fn (Request $r) => 'NEXT_CALLED'
);
$this->assertSame('NEXT_CALLED', $result, '带 mcp_internal 标记的 token 必须放行next 被调用)');
}
// =========================================================================
// 不豁免:无标记 token / MCP 密钥明文 / 无 Authorization
// =========================================================================
public function test_plain_login_token_without_marker_is_blocked(): void
{
// 普通登录 token缓存存在但无 mcp_internal 标记):同源 referer + 无 __token__ -> 拦截
$token = $this->cacheToken([
'id' => 12345,
'username' => 'e2e_user',
'expire_time' => time() + 300,
]);
try {
$this->runMiddleware(
$this->makePostRequest($token, self::SAME_ORIGIN_REFERER),
fn (Request $r) => 'NEXT_CALLED'
);
$this->fail('无 mcp_internal 标记的 token 应被 CSRF 拦截');
} catch (HttpResponseException $e) {
$data = $e->getResponse()->getData();
$this->assertSame('请求验证失败,请重新刷新页面!', $data['msg'] ?? null, '应命中 __token__ 校验失败文案');
// error() 信封 code=500json() 助手默认 HTTP 200两者语义不同
$this->assertSame(500, $data['code'] ?? null);
}
}
public function test_mcp_key_plaintext_bearer_is_not_exempt(): void
{
// MCP 密钥明文不是 login 缓存 token缓存查不到——豁免只认服务端写入的
// mcp_internal 标记,密钥 Bearer 不构成豁免通道T8 实测结论的回归锁)
$bearer = 'sk-mcp-e2e-' . bin2hex(random_bytes(8)); // 不预置任何缓存
try {
$this->runMiddleware(
$this->makePostRequest($bearer, self::SAME_ORIGIN_REFERER),
fn (Request $r) => 'NEXT_CALLED'
);
$this->fail('MCP 密钥明文 Bearer 不应享受 CSRF 豁免');
} catch (HttpResponseException $e) {
$data = $e->getResponse()->getData();
$this->assertSame('请求验证失败,请重新刷新页面!', $data['msg'] ?? null);
}
}
public function test_cross_origin_post_without_marker_is_blocked(): void
{
$token = $this->cacheToken(['id' => 12345, 'mcp_internal' => false]);
try {
$this->runMiddleware(
$this->makePostRequest($token, self::CROSS_ORIGIN_REFERER),
fn (Request $r) => 'NEXT_CALLED'
);
$this->fail('外域 referer 的 POST 应被跨站校验拦截');
} catch (HttpResponseException $e) {
$data = $e->getResponse()->getData();
$this->assertSame('当前请求不合法!', $data['msg'] ?? null, '应命中跨域校验文案');
}
}
// =========================================================================
// 开关语义IS_CSRF=false 全放行 / GET 方法白名单
// =========================================================================
public function test_is_csrf_disabled_passes_all_posts(): void
{
// 生产默认(.env IS_CSRF=false开关关闭时即使无 token 无 referer 也放行
app('env')->set('adminsystem.IS_CSRF', false);
$result = $this->runMiddleware(
$this->makePostRequest(null, ''),
fn (Request $r) => 'NEXT_CALLED'
);
$this->assertSame('NEXT_CALLED', $result, 'IS_CSRF=false 时不应有任何拦截');
}
public function test_get_request_bypasses_csrf_even_when_enabled(): void
{
$result = $this->runMiddleware(
$this->makeGetRequest(),
fn (Request $r) => 'NEXT_CALLED'
);
$this->assertSame('NEXT_CALLED', $result, 'GET 在方法白名单内IS_CSRF=true 也不拦截');
}
}