本测试写入 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 也不拦截'); } }