mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-08-30 04:35:33 +08:00
feat(mcp): tools/call 自请求分发、CSRF 内部标记豁免与调用审计
This commit is contained in:
@@ -4,6 +4,8 @@ APP_DEBUG=true
|
||||
DEFAULT_TIMEZONE=Asia/Shanghai
|
||||
AUTO_CACHE_LOG=false
|
||||
AUTO_PARSE_API=true
|
||||
# MCP/自请求内部分发场景可显式指定(缺省时回退入站请求同源回环)
|
||||
;APP_HOST = https://your-domain.com
|
||||
|
||||
[DATABASE]
|
||||
MAIN=main
|
||||
|
||||
@@ -12,6 +12,22 @@ class CsrfMiddlewareBase
|
||||
{
|
||||
if (env('adminsystem.IS_CSRF', true)) {
|
||||
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);
|
||||
$refererInfo = parse_url($refererUrl);
|
||||
|
||||
@@ -4,28 +4,174 @@ declare(strict_types=1);
|
||||
|
||||
namespace base\mcp\service;
|
||||
|
||||
use app\common\service\McpService;
|
||||
use GuzzleHttp\Client;
|
||||
use think\facade\Cache;
|
||||
|
||||
/**
|
||||
* MCP 工具分发器(T8 实现真实分发).
|
||||
* MCP 工具分发器(以创建者身份自请求 admin 节点).
|
||||
*
|
||||
* 职责(T8):以 McpService::verifyNode 还原编码工具名 -> 校验权限 ->
|
||||
* 内部 HTTP 调用对应节点 -> McpService::logCall 审计。
|
||||
*
|
||||
* T7 仅提供空壳占位,保证 tools/list 与 tools/call 链路可用:
|
||||
* call 返回固定错误文案,由 ToolResultFormatter 包装为 TextContent。
|
||||
* 职责链(单次 call):
|
||||
* 1. McpService::verifyNode:编码工具名还原节点并校验「密钥白名单 ∩ 创建者实时权限」
|
||||
* 2. 生成一次性 creator token 写入 login 缓存(admin 认证链 get_session_admin 可识别),
|
||||
* 携带 mcp_internal 标记(CSRF 中间件的唯一豁免依据)
|
||||
* 3. 节点串转 admin URL,Guzzle POST 自请求(同进程外、同服务内,走完整 HTTP 中间件栈)
|
||||
* 4. 响应三态判定(JSON / HTML / 错误),原始文本返回给 SDK 包装为 TextContent
|
||||
* 5. McpService::logCall 审计 + try/finally 删除一次性 token
|
||||
*/
|
||||
class McpDispatchBase
|
||||
{
|
||||
/** 一次性内部分发 token 有效期(秒),与 CSRF 豁免标记同生命周期. */
|
||||
protected int $internalTokenTtl = 300;
|
||||
|
||||
/** 内部 HTTP 请求超时(秒). */
|
||||
protected int $httpTimeout = 30;
|
||||
|
||||
/**
|
||||
* 分发一次 MCP 工具调用.
|
||||
*
|
||||
* @param array $auth McpService::authenticate() 的返回结构(key_row + creator)
|
||||
* @param string $toolName MCP 编码工具名(如 system-quick--index,需 verifyNode 还原)
|
||||
* @param array $auth McpService::authenticate() 的返回结构(key_row + creator)
|
||||
* @param string $toolName MCP 编码工具名(如 system-quick--index,需 verifyNode 还原)
|
||||
* @param array $arguments 客户端原始参数(已剔除 _session/_request 内部键)
|
||||
* @return string 工具执行结果(文本)
|
||||
* @return string 工具执行结果文本(JSON 原样 / [HTML页面内容] 前缀 / [ERROR] 前缀)
|
||||
*/
|
||||
public function call(array $auth, string $toolName, array $arguments): string
|
||||
{
|
||||
// T8 实现真实分发逻辑
|
||||
return '[ERROR] 分发器未就绪(T8 实现)';
|
||||
$mcpService = new McpService();
|
||||
$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 URL:system.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 + 合法 JSON(layui 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user