mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-08-30 12:45:32 +08:00
test(mcp): MCP 端点、CSRF 豁免与回归全量测试
This commit is contained in:
275
tests/McpCsrfExemptionTest.php
Normal file
275
tests/McpCsrfExemptionTest.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?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 / $_ENV:think\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 CsrfMiddleware(app 层空壳)+ 构造 think\Request(withServer/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 缓存的 token(tearDown 清理) */
|
||||
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=500;json() 助手默认 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 也不拦截');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user