diff --git a/src/components/DeepLinkImportDialog.tsx b/src/components/DeepLinkImportDialog.tsx index 05acafa89..49299eb7c 100644 --- a/src/components/DeepLinkImportDialog.tsx +++ b/src/components/DeepLinkImportDialog.tsx @@ -17,6 +17,12 @@ import { PromptConfirmation } from "./deeplink/PromptConfirmation"; import { McpConfirmation } from "./deeplink/McpConfirmation"; import { SkillConfirmation } from "./deeplink/SkillConfirmation"; import { ProviderIcon } from "./ProviderIcon"; +import { + classifyEndpoint, + classifyEnvKey, + maskValue, + riskI18nKey, +} from "@/utils/deeplinkRisk"; interface DeeplinkError { url: string; @@ -277,16 +283,28 @@ export function DeepLinkImportDialog() { } }, [request?.config, request?.app]); - // Helper to mask sensitive values - const maskValue = (key: string, value: string): string => { - const sensitiveKeys = ["TOKEN", "KEY", "SECRET", "PASSWORD"]; - const isSensitive = sensitiveKeys.some((k) => - key.toUpperCase().includes(k), + /** + * env 行:值经 `maskValue` 脱敏,键命中加载器控制变量时标记。 + * + * `break-all` 而非 `truncate`——被截断的值等于没展示。 + */ + const EnvRow = ({ envKey, value }: { envKey: string; value: string }) => { + const risk = classifyEnvKey(envKey); + return ( +
+ + {risk && } + {envKey} + + {maskValue(envKey, value)} +
); - if (isSensitive && value.length > 8) { - return `${value.substring(0, 8)}${"*".repeat(12)}`; - } - return value; }; const getTitle = () => { @@ -391,22 +409,37 @@ export function DeepLinkImportDialog() { {t("deeplink.endpoint")}
- {request.endpoint?.split(",").map((ep, idx) => ( -
- {idx === 0 ? "🔹 " : "└ "} - {ep.trim()} - {idx === 0 && request.endpoint?.includes(",") && ( - - ({t("deeplink.primaryEndpoint")}) - - )} -
- ))} + {request.endpoint?.split(",").map((ep, idx) => { + const endpointRisk = classifyEndpoint(ep.trim()); + return ( +
+ {idx === 0 ? "🔹 " : "└ "} + {endpointRisk && ( + + )} + {ep.trim()} + {idx === 0 && request.endpoint?.includes(",") && ( + + ({t("deeplink.primaryEndpoint")}) + + )} + {endpointRisk && ( +
+ {t(riskI18nKey(endpointRisk))} +
+ )} +
+ ); + })}
@@ -527,17 +560,11 @@ export function DeepLinkImportDialog() {
{Object.entries(parsedConfig.env).map( ([key, value]) => ( -
- - {key} - - - {maskValue(key, String(value))} - -
+ envKey={key} + value={String(value)} + /> ), )}
@@ -552,21 +579,17 @@ export function DeepLinkImportDialog() {
Auth:
- {Object.entries(parsedConfig.auth).map( - ([key, value]) => ( -
- - {key} - - - {maskValue(key, String(value))} - -
- ), - )} +
+ {Object.entries(parsedConfig.auth).map( + ([key, value]) => ( + + ), + )} +
)} {parsedConfig.tomlConfig && ( @@ -590,17 +613,11 @@ export function DeepLinkImportDialog() {
{Object.entries(parsedConfig.env).map( ([key, value]) => ( -
- - {key} - - - {maskValue(key, String(value))} - -
+ envKey={key} + value={String(value)} + /> ), )}
diff --git a/src/components/deeplink/McpConfirmation.tsx b/src/components/deeplink/McpConfirmation.tsx index c2fb9074b..d3291505f 100644 --- a/src/components/deeplink/McpConfirmation.tsx +++ b/src/components/deeplink/McpConfirmation.tsx @@ -2,6 +2,14 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { DeepLinkImportRequest } from "../../lib/api/deeplink"; import { decodeBase64Utf8 } from "../../lib/utils/base64"; +import { + classifyCommand, + classifyEndpoint, + classifyEnvKey, + maskValue, + riskI18nKey, + type RiskKind, +} from "@/utils/deeplinkRisk"; export function McpConfirmation({ request, @@ -25,6 +33,47 @@ export function McpConfirmation({ const targetApps = request.apps?.split(",") || []; const serverCount = Object.keys(mcpServers || {}).length; + // 汇总所有条目命中的风险,在底部给一句总的提示——逐行的 ⚠ 容易被划过去。 + const risks = useMemo(() => { + const found = new Set(); + for (const spec of Object.values(mcpServers || {}) as any[]) { + const commandRisk = classifyCommand(spec?.command, spec?.args); + if (commandRisk) found.add(commandRisk); + if (typeof spec?.url === "string") { + const urlRisk = classifyEndpoint(spec.url); + if (urlRisk) found.add(urlRisk); + } + for (const key of Object.keys(spec?.env || {})) { + const envRisk = classifyEnvKey(key); + if (envRisk) found.add(envRisk); + } + } + return [...found]; + }, [mcpServers]); + + /** 一行 key/value。`break-all` 而非 `truncate`:payload 不得被 CSS 藏起来。 */ + const Row = ({ + label, + value, + risk, + }: { + label: string; + value: string; + risk?: RiskKind | null; + }) => ( +
+ {label} + + {risk && } + {value} + +
+ ); + return (

{t("deeplink.mcp.title")}

@@ -51,25 +100,83 @@ export function McpConfirmation({
{mcpServers && - Object.entries(mcpServers).map(([id, spec]: [string, any]) => ( -
-
{id}
-
- {spec.command - ? `Command: ${spec.command} ` - : `URL: ${spec.url} `} + Object.entries(mcpServers).map(([id, spec]: [string, any]) => { + const commandRisk = classifyCommand(spec?.command, spec?.args); + const argv: string[] = Array.isArray(spec?.args) + ? spec.args.map(String) + : []; + const env: Record = spec?.env || {}; + + return ( +
+
{id}
+
+ {spec?.command && ( + + )} + {/* 逐项展开而不是 join(" "):payload 常常整条藏在某一个 arg 里, + 拼成一行再 truncate 正是它此前得以隐身的原因。 */} + {argv.map((arg, index) => ( + + ))} + {spec?.url && ( + + )} + {Object.entries(env).map(([key, value], index) => ( + + ))} +
-
- ))} + ); + })}
- {request.enabled && ( -
- ⚠️ - {t("deeplink.mcp.enabledWarning")} + {risks.length > 0 && ( +
+ {risks.map((kind) => ( +
+ + {t(riskI18nKey(kind))} +
+ ))}
)} + + {/* + 无条件显示,不看 `request.enabled`。 + MCP 导入路径**根本不读这个字段**——`deeplink/mcp.rs` 里全文没有它, + 而 `:196` 是无条件的 `merged.set_enabled_for(&app, true)`。 + (prompt.rs / skill.rs / provider.rs 各自读了 `request.enabled`,唯独 MCP 没有, + 所以很容易误以为这里也生效。) + 挂条件的后果是:恶意链接省略 `enabled` 就能让这条警告消失,而写入行为 + 一模一样——把提示变成了可被攻击者关掉的开关。 + */} +
+ ⚠️ + {t("deeplink.mcp.enabledWarning")} +
); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 71a02d342..54c5eaa4b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2483,7 +2483,11 @@ "title": "Batch Import MCP Servers", "targetApps": "Target Apps", "serverCount": "MCP Servers ({{count}})", - "enabledWarning": "After import, configurations will be written to all specified apps immediately" + "enabledWarning": "After import, configurations will be written to all specified apps immediately", + "command": "Command", + "args": "Args", + "env": "Env", + "url": "URL" }, "prompt": { "title": "Import System Prompt", @@ -2508,7 +2512,12 @@ "usageApiKey": "Usage API Key", "usageBaseUrl": "Usage Query URL", "usageAutoInterval": "Auto Query", - "usageAutoIntervalValue": "Every {{minutes}} minutes" + "usageAutoIntervalValue": "Every {{minutes}} minutes", + "risk": { + "envHijack": "This config sets environment variables that change how processes load code (e.g. injecting libraries or replacing CA certificates). Import only from a source you trust.", + "privateEndpoint": "This address points at localhost or a private network. Do not import unless it is your own local service.", + "shellCommand": "This config runs a full command line through a shell — what actually executes is in the arguments. Review each line." + } }, "iconPicker": { "search": "Search Icons", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index db7f37b25..d1d8a80bb 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2483,7 +2483,11 @@ "title": "MCP サーバーを一括インポート", "targetApps": "ターゲットアプリ", "serverCount": "MCP サーバー({{count}} 件)", - "enabledWarning": "インポート後、指定したすべてのアプリに即座に書き込まれます" + "enabledWarning": "インポート後、指定したすべてのアプリに即座に書き込まれます", + "command": "コマンド", + "args": "引数", + "env": "環境変数", + "url": "URL" }, "prompt": { "title": "システムプロンプトをインポート", @@ -2508,7 +2512,12 @@ "usageApiKey": "使用量 API キー", "usageBaseUrl": "使用量クエリ URL", "usageAutoInterval": "自動クエリ", - "usageAutoIntervalValue": "{{minutes}} 分ごと" + "usageAutoIntervalValue": "{{minutes}} 分ごと", + "risk": { + "envHijack": "この設定には、プロセスの読み込み動作を変える環境変数(ライブラリの注入、CA 証明書の差し替えなど)が含まれています。信頼できる提供元か確認してください。", + "privateEndpoint": "このアドレスはローカルホストまたは内部ネットワークを指しています。自分で立てたローカルサービス以外はインポートしないでください。", + "shellCommand": "この設定はシェル経由でコマンド全体を実行します。実際に動作する内容は引数にあります。1 行ずつ確認してください。" + } }, "iconPicker": { "search": "アイコンを検索", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index f6bc8c30d..f5bddb351 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -2454,7 +2454,11 @@ "title": "批次匯入 MCP Servers", "targetApps": "目標應用程式", "serverCount": "MCP Servers ({{count}} 個)", - "enabledWarning": "匯入後將立即寫入所有指定應用程式的設定檔" + "enabledWarning": "匯入後將立即寫入所有指定應用程式的設定檔", + "command": "命令", + "args": "參數", + "env": "環境", + "url": "位址" }, "prompt": { "title": "匯入系統提示詞", @@ -2479,7 +2483,12 @@ "usageApiKey": "用量 API Key", "usageBaseUrl": "用量查詢位址", "usageAutoInterval": "自動查詢", - "usageAutoIntervalValue": "每 {{minutes}} 分鐘" + "usageAutoIntervalValue": "每 {{minutes}} 分鐘", + "risk": { + "envHijack": "該設定包含可改變處理程序載入行為的環境變數(例如注入動態程式庫、替換 CA 憑證),請確認來源可信。", + "privateEndpoint": "該位址指向本機或內部網路。若非你自建的本機服務,請勿匯入。", + "shellCommand": "該設定透過 shell 執行整段命令,實際執行的內容在參數裡,請逐行核對。" + } }, "iconPicker": { "search": "搜尋圖示", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 271db76bc..a4f7cee6c 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2483,7 +2483,11 @@ "title": "批量导入 MCP Servers", "targetApps": "目标应用", "serverCount": "MCP Servers ({{count}} 个)", - "enabledWarning": "导入后将立即写入所有指定应用的配置文件" + "enabledWarning": "导入后将立即写入所有指定应用的配置文件", + "command": "命令", + "args": "参数", + "env": "环境", + "url": "地址" }, "prompt": { "title": "导入系统提示词", @@ -2508,7 +2512,12 @@ "usageApiKey": "用量 API Key", "usageBaseUrl": "用量查询地址", "usageAutoInterval": "自动查询", - "usageAutoIntervalValue": "每 {{minutes}} 分钟" + "usageAutoIntervalValue": "每 {{minutes}} 分钟", + "risk": { + "envHijack": "该配置包含可改变进程加载行为的环境变量(如注入动态库、替换 CA 证书),请确认来源可信。", + "privateEndpoint": "该地址指向本机或内网。若非你自建的本地服务,请勿导入。", + "shellCommand": "该配置通过 shell 执行一整段命令,实际运行的内容在参数里,请逐行核对。" + } }, "iconPicker": { "search": "搜索图标",