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 / $_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 也不拦截');
}
}