feat(grok): 使用 UUID 替换 SSO token 进行资源代理以提高安全性

- 在 Grok 资源代理接口中优先使用 UUID 获取 token,避免 token 泄露在 URL 中
- 为 ProviderPoolManager 添加 findProviderByUuid 方法,支持通过 UUID 查找配置
- 重构 GrokConverter,将 SSO token 依赖改为 UUID 依赖
- 更新 VERSION 文件至 2.10.2.1
This commit is contained in:
hex2077 2026-03-02 00:14:37 +08:00
parent c91d2ce3ab
commit 3f8fdc0b8e
6 changed files with 77 additions and 58 deletions

View file

@ -1 +1 @@
2.10.2
2.10.2.1

View file

@ -13,9 +13,9 @@ import { MODEL_PROTOCOL_PREFIX } from '../../utils/common.js';
* 实现Grok协议到其他协议的转换
*/
export class GrokConverter extends BaseConverter {
// 静态属性,确保所有实例共享最新的认证和基础 URL 配置
static sharedSsoToken = null;
// 静态属性,确保所有实例共享最新的基础 URL 和 UUID 配置
static sharedRequestBaseUrl = "";
static sharedUuid = null;
constructor() {
super('grok');
@ -23,20 +23,6 @@ export class GrokConverter extends BaseConverter {
this.requestStates = new Map();
}
/**
* 设置 Grok SSO token
*/
setSsoToken(token) {
if (!token) return;
// 如果 token 包含 sso= 前缀,则去掉它
let processedToken = token;
if (processedToken.startsWith("sso=")) {
processedToken = processedToken.substring(4);
}
GrokConverter.sharedSsoToken = processedToken;
}
/**
* 设置请求的基础 URL
*/
@ -47,13 +33,22 @@ export class GrokConverter extends BaseConverter {
}
/**
* assets.grok.com 域名的资源 URL 添加 sso 参数并转换为本地代理 URL
* 设置账号的 UUID
*/
setUuid(uuid) {
if (uuid) {
GrokConverter.sharedUuid = uuid;
}
}
/**
* assets.grok.com 域名的资源 URL 添加 uuid 参数并转换为本地代理 URL
*/
_appendSsoToken(url, state = null) {
const ssoToken = state?.ssoToken || GrokConverter.sharedSsoToken;
const requestBaseUrl = state?.requestBaseUrl || GrokConverter.sharedRequestBaseUrl;
const uuid = state?.uuid || GrokConverter.sharedUuid;
if (!url || !ssoToken) return url;
if (!url || !uuid) return url;
// 检查是否为 assets.grok.com 域名或相对路径
const isGrokAsset = url.includes('assets.grok.com') || (!url.startsWith('http') && !url.startsWith('data:'));
@ -67,7 +62,10 @@ export class GrokConverter extends BaseConverter {
}
// 返回本地代理接口 URL
const proxyPath = `/api/grok/assets?url=${encodeURIComponent(originalUrl)}&sso=${encodeURIComponent(ssoToken)}`;
// 使用 uuid 以提高安全性,防止 token 泄露在链接中
const authParam = `uuid=${encodeURIComponent(uuid)}`;
const proxyPath = `/api/grok/assets?url=${encodeURIComponent(originalUrl)}&${authParam}`;
if (requestBaseUrl) {
return `${requestBaseUrl}${proxyPath}`;
}
@ -78,8 +76,8 @@ export class GrokConverter extends BaseConverter {
* 在文本中查找并替换所有 assets.grok.com 的资源链接为绝对代理链接
*/
_processGrokAssetsInText(text, state = null) {
const ssoToken = state?.ssoToken || GrokConverter.sharedSsoToken;
if (!text || !ssoToken) return text;
const uuid = state?.uuid || GrokConverter.sharedUuid;
if (!text || !uuid) return text;
// 更宽松的正则匹配 assets.grok.com 的 URL
const grokUrlRegex = /https?:\/\/assets\.grok\.com\/[^\s\)\"\'\>]+/g;
@ -106,8 +104,8 @@ export class GrokConverter extends BaseConverter {
has_tool_call: false,
rollout_id: "",
in_tool_call: false, // 是否处于 <tool_call> 块内
ssoToken: null,
requestBaseUrl: "",
uuid: null,
pending_text_buffer: "" // 用于处理流式输出中被截断的 URL
});
}
@ -345,32 +343,32 @@ export class GrokConverter extends BaseConverter {
/**
* 渲染图片为 Markdown
*/
_renderImage(url, imageId = "image") {
_renderImage(url, imageId = "image", state = null) {
let finalUrl = url;
if (!url.startsWith('http')) {
finalUrl = `https://assets.grok.com${url.startsWith('/') ? '' : '/'}${url}`;
}
finalUrl = this._appendSsoToken(finalUrl);
finalUrl = this._appendSsoToken(finalUrl, state);
return `![${imageId}](${finalUrl})`;
}
/**
* 渲染视频为 Markdown/HTML (render_video)
*/
_renderVideo(videoUrl, thumbnailImageUrl = "") {
_renderVideo(videoUrl, thumbnailImageUrl = "", state = null) {
let finalVideoUrl = videoUrl;
if (!videoUrl.startsWith('http')) {
finalVideoUrl = `https://assets.grok.com${videoUrl.startsWith('/') ? '' : '/'}${videoUrl}`;
}
finalVideoUrl = this._appendSsoToken(finalVideoUrl);
finalVideoUrl = this._appendSsoToken(finalVideoUrl, state);
let finalThumbUrl = thumbnailImageUrl;
if (thumbnailImageUrl && !thumbnailImageUrl.startsWith('http')) {
finalThumbUrl = `https://assets.grok.com${thumbnailImageUrl.startsWith('/') ? '' : '/'}${thumbnailImageUrl}`;
}
finalThumbUrl = this._appendSsoToken(finalThumbUrl);
finalThumbUrl = this._appendSsoToken(finalThumbUrl, state);
const defaultThumb = this._appendSsoToken('https://assets.grok.com/favicon.ico');
const defaultThumb = this._appendSsoToken('https://assets.grok.com/favicon.ico', state);
return `\n[![video](${finalThumbUrl || defaultThumb})](${finalVideoUrl})\n[Play Video](${finalVideoUrl})\n`;
}
@ -455,16 +453,12 @@ export class GrokConverter extends BaseConverter {
const modelHash = grokResponse.llmInfo?.modelHash || "";
const state = this._getState(this._formatResponseId(responseId));
if (grokResponse._ssoToken) {
let processedToken = grokResponse._ssoToken;
if (processedToken.startsWith("sso=")) {
processedToken = processedToken.substring(4);
}
state.ssoToken = processedToken;
}
if (grokResponse._requestBaseUrl) {
state.requestBaseUrl = grokResponse._requestBaseUrl;
}
if (grokResponse._uuid) {
state.uuid = grokResponse._uuid;
}
// 过滤内容并处理其中的 Grok 资源链接
content = this._filterToken(content, responseId);
@ -534,17 +528,13 @@ export class GrokConverter extends BaseConverter {
const responseId = this._formatResponseId(rawResponseId);
const state = this._getState(responseId);
// 从响应块中同步 token 和基础 URL
if (resp._ssoToken) {
let processedToken = resp._ssoToken;
if (processedToken.startsWith("sso=")) {
processedToken = processedToken.substring(4);
}
state.ssoToken = processedToken;
}
// 从响应块中同步 uuid 和基础 URL
if (resp._requestBaseUrl) {
state.requestBaseUrl = resp._requestBaseUrl;
}
if (resp._uuid) {
state.uuid = resp._uuid;
}
if (resp.llmInfo?.modelHash && !state.fingerprint) {
state.fingerprint = resp.llmInfo.modelHash;

View file

@ -115,7 +115,7 @@ export function createRequestHandler(config, providerPoolManager) {
// Grok assets proxy endpoint
if (method === 'GET' && path === '/api/grok/assets') {
await handleGrokAssetsProxy(req, res, currentConfig);
await handleGrokAssetsProxy(req, res, currentConfig, providerPoolManager);
return true;
}

View file

@ -108,10 +108,10 @@ export class GrokApiService {
this.chatApi = `${this.baseUrl}/rest/app-chat/conversations/new`;
this.isInitialized = false;
// 使用全局转换器实例,确保与适配器层使用的是同一个实例,从而共享 SSO token 和基础 URL 配置
// 使用全局转换器实例,确保与适配器层使用的是同一个实例
this.converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.GROK);
if (this.converter) {
this.converter.setSsoToken(this.token);
if (this.converter && this.uuid) {
this.converter.setUuid(this.uuid);
}
this.lastSyncAt = null;
@ -520,8 +520,8 @@ export class GrokApiService {
if (resp.rolloutId) collected.rolloutId = resp.rolloutId;
// 同步私有字段到最终结果
if (resp._ssoToken) collected._ssoToken = resp._ssoToken;
if (resp._requestBaseUrl) collected._requestBaseUrl = resp._requestBaseUrl;
if (resp._uuid) collected._uuid = resp._uuid;
if (resp.modelResponse) collected.modelResponse = resp.modelResponse;
if (resp.cardAttachment) collected.cardAttachment = resp.cardAttachment;
@ -595,9 +595,11 @@ export class GrokApiService {
}
async * generateContentStream(model, requestBody) {
// 确保全局转换器拥有最新的 SSO token 和基础 URL
// 确保全局转换器拥有最新的基础 URL 和 UUID
if (this.converter) {
this.converter.setSsoToken(this.token);
if (this.uuid) {
this.converter.setUuid(this.uuid);
}
if (requestBody._requestBaseUrl) {
this.converter.setRequestBaseUrl(requestBody._requestBaseUrl);
}
@ -705,9 +707,9 @@ export class GrokApiService {
try {
const json = JSON.parse(dataStr);
if (json.result?.response) {
// 注入 SSO token 和基础 URL以便全局转换器能获取到
json.result.response._ssoToken = this.token;
// 注入基础 URL 和 UUID以便全局转换器能获取到
json.result.response._requestBaseUrl = requestBody._requestBaseUrl;
json.result.response._uuid = this.uuid;
if (json.result.response.responseId) {
lastResponseId = json.result.response.responseId;
@ -730,8 +732,8 @@ export class GrokApiService {
response: {
isDone: true,
responseId: lastResponseId,
_ssoToken: this.token,
_requestBaseUrl: requestBody._requestBaseUrl
_requestBaseUrl: requestBody._requestBaseUrl,
_uuid: this.uuid
}
}
};

View file

@ -583,6 +583,20 @@ export class ProviderPoolManager {
return pool?.find(p => p.uuid === uuid) || null;
}
/**
* 根据 UUID 在所有池中查找提供商配置
* @param {string} uuid - 提供商 UUID
* @returns {object|null} 提供商配置对象或 null
*/
findProviderByUuid(uuid) {
if (!uuid) return null;
for (const type in this.providerStatus) {
const provider = this.providerStatus[type].find(p => p.uuid === uuid);
if (provider) return provider.config;
}
return null;
}
/**
* Initializes the status for each provider in the pools.
* Initially, all providers are considered healthy and have zero usage.

View file

@ -8,12 +8,14 @@ import { MODEL_PROVIDER } from './common.js';
* @param {http.IncomingMessage} req 原始请求
* @param {http.ServerResponse} res 原始响应
* @param {Object} config 全局配置
* @param {Object} providerPoolManager 提供商号池管理器
*/
export async function handleGrokAssetsProxy(req, res, config) {
export async function handleGrokAssetsProxy(req, res, config, providerPoolManager) {
try {
const requestUrl = new URL(req.url, `http://${req.headers.host}`);
const targetUrl = requestUrl.searchParams.get('url');
let ssoToken = requestUrl.searchParams.get('sso');
const uuid = requestUrl.searchParams.get('uuid');
if (!targetUrl) {
res.writeHead(400, { 'Content-Type': 'application/json' });
@ -21,9 +23,20 @@ export async function handleGrokAssetsProxy(req, res, config) {
return;
}
// 优先尝试从 uuid 换取 token提高安全性
if (!ssoToken && uuid && providerPoolManager) {
const providerConfig = providerPoolManager.findProviderByUuid(uuid);
if (providerConfig) {
ssoToken = providerConfig.GROK_COOKIE_TOKEN;
logger.debug(`[Grok Proxy] Resolved SSO token from uuid: ${uuid}`);
} else {
logger.warn(`[Grok Proxy] Could not find provider configuration for uuid: ${uuid}`);
}
}
if (!ssoToken) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing sso parameter' }));
res.end(JSON.stringify({ error: 'Missing sso parameter or valid uuid' }));
return;
}