feat(mcp): tools/call 自请求分发、CSRF 内部标记豁免与调用审计

This commit is contained in:
augushong
2026-08-16 22:46:32 +08:00
parent c21bf9f240
commit 0d20fd7786
3 changed files with 175 additions and 11 deletions

View File

@@ -4,6 +4,8 @@ APP_DEBUG=true
DEFAULT_TIMEZONE=Asia/Shanghai DEFAULT_TIMEZONE=Asia/Shanghai
AUTO_CACHE_LOG=false AUTO_CACHE_LOG=false
AUTO_PARSE_API=true AUTO_PARSE_API=true
# MCP/自请求内部分发场景可显式指定(缺省时回退入站请求同源回环)
;APP_HOST = https://your-domain.com
[DATABASE] [DATABASE]
MAIN=main MAIN=main

View File

@@ -12,6 +12,22 @@ class CsrfMiddlewareBase
{ {
if (env('adminsystem.IS_CSRF', true)) { if (env('adminsystem.IS_CSRF', true)) {
if (!in_array($request->method(), ['GET', 'HEAD', 'OPTIONS'])) { if (!in_array($request->method(), ['GET', 'HEAD', 'OPTIONS'])) {
// MCP 内部分发豁免(唯一豁免通道,安全性论证):
// 1. mcp_internal 标记只能由服务端写入 login 缓存McpDispatch 生成的一次性
// creator token攻击者需先具备服务器内部写缓存能力才可伪造
// 2. 跨站表单/HTML 场景无法携带 Authorization 自定义头(浏览器表单不透传该头),
// 对该头的 CSRF 攻击模型本身不成立;
// 3. 豁免标记与 5 分钟一次性 token 同生命周期McpDispatch 调用结束即删
// try/finally缓存 TTL 到期自动失效,不残留长期豁免通道。
$authorization = $request->header('authorization', '');
$token = (strpos($authorization, ' ') !== false) ? trim(explode(' ', $authorization)[1]) : '';
if (!empty($token)) {
$cached = \think\facade\Cache::store('login')->get($token);
if (!empty($cached['mcp_internal'])) {
return $next($request); // MCP 内部分发标记,跳过 CSRF一次性短时 token
}
}
// 跨域校验 // 跨域校验
$refererUrl = $request->header('REFERER', null); $refererUrl = $request->header('REFERER', null);
$refererInfo = parse_url($refererUrl); $refererInfo = parse_url($refererUrl);

View File

@@ -4,28 +4,174 @@ declare(strict_types=1);
namespace base\mcp\service; namespace base\mcp\service;
use app\common\service\McpService;
use GuzzleHttp\Client;
use think\facade\Cache;
/** /**
* MCP 工具分发器(T8 实现真实分发. * MCP 工具分发器(以创建者身份自请求 admin 节点.
* *
* 职责T8以 McpService::verifyNode 还原编码工具名 -> 校验权限 -> * 职责链(单次 call
* 内部 HTTP 调用对应节点 -> McpService::logCall 审计。 * 1. McpService::verifyNode编码工具名还原节点并校验「密钥白名单 ∩ 创建者实时权限」
* * 2. 生成一次性 creator token 写入 login 缓存admin 认证链 get_session_admin 可识别),
* T7 仅提供空壳占位,保证 tools/list 与 tools/call 链路可用: * 携带 mcp_internal 标记CSRF 中间件的唯一豁免依据)
* call 返回固定错误文案,由 ToolResultFormatter 包装为 TextContent。 * 3. 节点串转 admin URLGuzzle POST 自请求(同进程外、同服务内,走完整 HTTP 中间件栈)
* 4. 响应三态判定JSON / HTML / 错误),原始文本返回给 SDK 包装为 TextContent
* 5. McpService::logCall 审计 + try/finally 删除一次性 token
*/ */
class McpDispatchBase class McpDispatchBase
{ {
/** 一次性内部分发 token 有效期(秒),与 CSRF 豁免标记同生命周期. */
protected int $internalTokenTtl = 300;
/** 内部 HTTP 请求超时(秒). */
protected int $httpTimeout = 30;
/** /**
* 分发一次 MCP 工具调用. * 分发一次 MCP 工具调用.
* *
* @param array $auth McpService::authenticate() 的返回结构key_row + creator * @param array $auth McpService::authenticate() 的返回结构key_row + creator
* @param string $toolName MCP 编码工具名(如 system-quick--index需 verifyNode 还原) * @param string $toolName MCP 编码工具名(如 system-quick--index需 verifyNode 还原)
* @param array $arguments 客户端原始参数(已剔除 _session/_request 内部键) * @param array $arguments 客户端原始参数(已剔除 _session/_request 内部键)
* @return string 工具执行结果文本) * @return string 工具执行结果文本JSON 原样 / [HTML页面内容] 前缀 / [ERROR] 前缀
*/ */
public function call(array $auth, string $toolName, array $arguments): string public function call(array $auth, string $toolName, array $arguments): string
{ {
// T8 实现真实分发逻辑 $mcpService = new McpService();
return '[ERROR] 分发器未就绪T8 实现)'; $keyId = (int) ($auth['key_row']->id ?? 0);
// 1. 权限校验:未授权(含权限已回收的漂移场景)直接拒绝并记失败审计
$node = $mcpService->verifyNode($auth, $toolName);
if ($node === null) {
$mcpService->logCall($keyId, $toolName, $arguments, false, 0);
return '[ERROR] 无权限或权限已回收: ' . $toolName;
}
// 2. base URL 三级策略:显式配置 -> 入站请求同源回环 -> 均不可用报错
$baseUrl = $this->resolveBaseUrl();
if ($baseUrl === '') {
$mcpService->logCall($keyId, $node, $arguments, false, 0);
return '[ERROR] 未配置 app.app_host无法内部分发';
}
// 3. 节点串转 admin URLsystem.quick/index -> /admin/system.quick/index
$url = '/admin/' . implode('/', explode('/', $node));
// 4. 一次性 creator token写入 login 缓存即被 admin 认证链接受;
// mcp_internal 标记仅供 CSRF 中间件豁免识别,不落库、不外发
$token = bin2hex(random_bytes(16));
$creatorData = $auth['creator'];
$creatorData['expire_time'] = time() + $this->internalTokenTtl;
$creatorData['mcp_internal'] = true;
Cache::store('login')->set($token, $creatorData, $this->internalTokenTtl);
$startTime = microtime(true);
$success = false;
$result = '';
try {
$client = new Client([
'timeout' => $this->httpTimeout,
'verify' => false,
'http_errors' => false,
]);
$jsonBody = json_encode($arguments, JSON_UNESCAPED_UNICODE);
if ($jsonBody === false) {
$jsonBody = '{}';
}
$response = $client->post($baseUrl . $url, [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
'X-Requested-With' => 'XMLHttpRequest',
'Authorization' => 'Bearer ' . $token,
],
'body' => $jsonBody,
]);
$statusCode = $response->getStatusCode();
$body = (string) $response->getBody();
if ($statusCode === 200) {
// 三态判定之一/二200 + 合法 JSONlayui code=0 与 success code=200
// 均算成功,不拆信封,原始 JSON 字符串原样返回200 + 非 JSON
// 视为 HTML 页面,加前缀标识返回(不自动追加 get_page_data=1
json_decode($body);
if (json_last_error() === JSON_ERROR_NONE) {
$result = $body;
} else {
$result = '[HTML页面内容] ' . $body;
}
$success = true;
} else {
// 三态判定之三:非 200优先取 JSON 错误信封的 msg
$result = '[ERROR] ' . $this->extractErrorMessage($body, 'HTTP ' . $statusCode);
}
} catch (\Throwable $e) {
// 超时 / 连接失败等Guzzle 抛出,含 ConnectException
$result = '[ERROR] ' . $e->getMessage();
} finally {
// 审计尽力而为logCall 内部吞异常);一次性 token 无论成败必删,
// 且本身带 TTL 兜底,不残留任何可复用的豁免通道
$costMs = (int) round((microtime(true) - $startTime) * 1000);
$mcpService->logCall($keyId, $node, $arguments, $success, $costMs);
Cache::store('login')->delete($token);
}
return $result;
}
/**
* base URL 三级策略.
*
* a. env('app.app_host') 显式配置优先(生产域名/反代场景明确指定)
* b. 当前入站请求同源回环scheme + host缺失端口时用 SERVER_PORT 补齐):
* MCP 端点收到什么域名,内部分发就打什么域名,天然绕开外部映射不确定性
* c. 两者皆不可用(如 CLI 上下文且未配置)返回空串,由调用方报错
*/
protected function resolveBaseUrl(): string
{
$appHost = env('app.app_host', '');
if (!empty($appHost)) {
return rtrim((string) $appHost, '/');
}
$request = app('request');
if ($request instanceof \think\Request) {
$scheme = $request->scheme();
$host = (string) $request->host();
if ($scheme !== '' && $host !== '') {
// nginx fastcgi_params 标准实践传 HTTP_HOST $host已去端口
// 非标准端口部署需用 SERVER_PORT 补齐,否则自请求会打到 80/443
if (!str_contains($host, ':') || str_ends_with($host, ']')) {
$port = (string) $request->server('SERVER_PORT', '');
$defaultPort = ($scheme === 'https') ? '443' : '80';
if ($port !== '' && $port !== $defaultPort) {
$host .= ':' . $port;
}
}
return $scheme . '://' . $host;
}
}
return '';
}
/**
* 提取错误响应文案:优先 JSON 错误信封的 msg否则使用兜底文案.
*/
protected function extractErrorMessage(string $body, string $fallback): string
{
$decoded = json_decode($body, true);
if (is_array($decoded) && !empty($decoded['msg'])) {
return (string) $decoded['msg'];
}
return $fallback;
} }
} }