diff --git a/src-tauri/src/deeplink/mod.rs b/src-tauri/src/deeplink/mod.rs index f883d3b8f..6bee98878 100644 --- a/src-tauri/src/deeplink/mod.rs +++ b/src-tauri/src/deeplink/mod.rs @@ -114,7 +114,8 @@ pub struct DeepLinkImportRequest { pub config_url: Option, // ============ Usage script fields (v3.9+) ============ - /// Whether to enable usage query (default: true if usage_script is provided) + /// Whether to enable usage query. Defaults to **disabled** — carrying a script + /// is not itself a decision to run it; the link must say `usageEnabled=true`. #[serde(skip_serializing_if = "Option::is_none")] pub usage_enabled: Option, /// Base64 encoded usage query script code diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 5febfda3f..7adaf346b 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -254,8 +254,18 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result has code > false - let enabled = request.usage_enabled.unwrap_or(!code.is_empty()); + // Determine enabled state: explicit param only, defaulting to disabled. + // + // 「携带了代码」不构成用户的启用决定。此处的输入来自 deeplink——即第三方 + // 构造、经浏览器抵达的不可信载荷——而 `code` 是一段会在查询用量时执行的 + // JavaScript。若以 `!code.is_empty()` 作默认,一条链接就能让脚本在用户 + // 从未勾选过的情况下进入启用态。 + // + // 要启用,链接必须显式携带 `usageEnabled=true`。注意该参数是**链接作者**的 + // 请求,不构成用户的同意;用户的同意体现在确认框展示了完整脚本正文与启用 + // 徽章之后仍点了导入——所以那两处展示是本设计的承重部分,不可省略。 + // 用户在应用内手动配置的脚本不走这条路径。 + let enabled = request.usage_enabled.unwrap_or(false); let usage_script = UsageScript { enabled, diff --git a/src-tauri/src/deeplink/tests.rs b/src-tauri/src/deeplink/tests.rs index 54681b444..332086084 100644 --- a/src-tauri/src/deeplink/tests.rs +++ b/src-tauri/src/deeplink/tests.rs @@ -342,6 +342,87 @@ fn test_deeplink_usage_script_does_not_copy_provider_credentials() { assert_eq!(script.base_url, None); } +/// 构造一个只带用量脚本字段的 provider 请求,其余保持最小。 +fn usage_script_request(code: &str, usage_enabled: Option) -> DeepLinkImportRequest { + DeepLinkImportRequest { + version: "v1".to_string(), + resource: "provider".to_string(), + app: Some("claude".to_string()), + name: Some("Test Claude".to_string()), + homepage: Some("https://example.com".to_string()), + endpoint: Some("https://api.example.com/v1/".to_string()), + api_key: Some("sk-main".to_string()), + icon: None, + model: None, + notes: None, + haiku_model: None, + sonnet_model: None, + opus_model: None, + config: None, + config_format: None, + config_url: None, + apps: None, + repo: None, + directory: None, + branch: None, + content: None, + description: None, + enabled: None, + usage_enabled, + usage_script: Some(BASE64_STANDARD.encode(code)), + usage_api_key: None, + usage_base_url: None, + usage_access_token: None, + usage_user_id: None, + usage_auto_interval: None, + } +} + +#[test] +fn test_deeplink_usage_script_is_not_enabled_merely_by_carrying_code() { + use super::provider::build_provider_from_request; + + // deeplink 是第三方构造、经浏览器抵达的不可信载荷。「带了代码」不是用户的 + // 启用决定——否则一条链接就能让这段 JS 在用户从未勾选的情况下进入启用态。 + let code = "export async function query() { return { cost: 0 }; }"; + let request = usage_script_request(code, None); + + let provider = build_provider_from_request(&AppType::Claude, &request).unwrap(); + let script = provider + .meta + .as_ref() + .and_then(|meta| meta.usage_script.as_ref()) + .expect("usage script should still be created"); + + assert!( + !script.enabled, + "缺省必须是未启用;`带了代码`不构成用户的启用决定" + ); + // 代码本身仍要保留:确认框要展示它,用户之后也可在应用内手动开启。 + assert_eq!(script.code, code); +} + +#[test] +fn test_deeplink_usage_script_honors_an_explicit_enable_request_from_the_link() { + use super::provider::build_provider_from_request; + + // `usageEnabled=true` 是**链接作者**的请求,不是用户的选择——用户的同意体现在 + // 看过确认框里完整的脚本正文与启用状态之后点了导入。收紧默认值不能顺手把这条 + // 正常通路改坏:合作伙伴的预设链接靠它一次性配好用量查询。 + let code = "export async function query() { return { cost: 0 }; }"; + let request = usage_script_request(code, Some(true)); + + let provider = build_provider_from_request(&AppType::Claude, &request).unwrap(); + let script = provider + .meta + .as_ref() + .and_then(|meta| meta.usage_script.as_ref()) + .expect("usage script should be created"); + + assert!(script.enabled); + assert_eq!(script.code, code); +} + #[test] fn test_deeplink_usage_script_omits_explicit_credentials_that_match_provider() { use super::provider::build_provider_from_request; diff --git a/src/components/DeepLinkImportDialog.tsx b/src/components/DeepLinkImportDialog.tsx index 49299eb7c..b41eedb58 100644 --- a/src/components/DeepLinkImportDialog.tsx +++ b/src/components/DeepLinkImportDialog.tsx @@ -20,9 +20,11 @@ import { ProviderIcon } from "./ProviderIcon"; import { classifyEndpoint, classifyEnvKey, + decodeDeeplinkPayload, maskValue, riskI18nKey, } from "@/utils/deeplinkRisk"; +import { decodeBase64Utf8 } from "@/lib/utils/base64"; interface DeeplinkError { url: string; @@ -649,14 +651,19 @@ export function DeepLinkImportDialog() { })}
+ {/* + 判据是 `=== true`,与后端 `usage_enabled.unwrap_or(false)` + 严格对齐。此前用的 `!== false` 会把"链接没说"渲染成绿色的 + 「已启用」——徽章必须显示实际会发生的事,不能比后端更乐观。 + */} - {request.usageEnabled !== false + {request.usageEnabled === true ? t("deeplink.usageScriptEnabled", { defaultValue: "已启用", }) @@ -667,6 +674,33 @@ export function DeepLinkImportDialog() {
+ {/* + 脚本正文必须完整展示。这段是会执行的 JavaScript,而 payload + 常常整条藏在中间——`whitespace-pre-wrap break-all` + 可滚动容器, + 不用 truncate,任何字符都不得被 CSS 藏起来。 + */} +
+
+ {t("deeplink.usageScriptCode")} +
+
+                          {decodeDeeplinkPayload(
+                            request.usageScript,
+                            decodeBase64Utf8,
+                          )}
+                        
+
+ + {/* + 无条件显示,不看 `usageEnabled`:代码无论启用与否都会被写入供应商 + 配置,用户之后在应用内一键即可开启。挂条件等于让攻击者省略参数就能 + 关掉这条警告。 + */} +
+ + {t("deeplink.usageScriptWarning")} +
+ {/* Usage API Key (if different from provider) */} {request.usageApiKey && request.usageApiKey !== request.apiKey && ( diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 54c5eaa4b..4f9de91da 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2509,6 +2509,8 @@ "usageScript": "Usage Query", "usageScriptEnabled": "Enabled", "usageScriptDisabled": "Disabled", + "usageScriptCode": "Script code", + "usageScriptWarning": "This is JavaScript that runs when usage is queried, once enabled. Import it only if you trust the source.", "usageApiKey": "Usage API Key", "usageBaseUrl": "Usage Query URL", "usageAutoInterval": "Auto Query", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index d1d8a80bb..569e9b2e2 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2509,6 +2509,8 @@ "usageScript": "使用量クエリ", "usageScriptEnabled": "有効", "usageScriptDisabled": "無効", + "usageScriptCode": "スクリプトコード", + "usageScriptWarning": "これは有効化すると使用量クエリ時に実行される JavaScript です。提供元が信頼できる場合のみインポートしてください。", "usageApiKey": "使用量 API キー", "usageBaseUrl": "使用量クエリ URL", "usageAutoInterval": "自動クエリ", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index f5bddb351..5da1d0aa6 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -2480,6 +2480,8 @@ "usageScript": "用量查詢", "usageScriptEnabled": "已啟用", "usageScriptDisabled": "未啟用", + "usageScriptCode": "指令碼程式碼", + "usageScriptWarning": "這是一段 JavaScript 程式碼,啟用後會在查詢用量時執行。請確認來源可信後再匯入。", "usageApiKey": "用量 API Key", "usageBaseUrl": "用量查詢位址", "usageAutoInterval": "自動查詢", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index a4f7cee6c..25ae90b26 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2509,6 +2509,8 @@ "usageScript": "用量查询", "usageScriptEnabled": "已启用", "usageScriptDisabled": "未启用", + "usageScriptCode": "脚本代码", + "usageScriptWarning": "这是一段 JavaScript 代码,启用后会在查询用量时执行。请确认来源可信后再导入。", "usageApiKey": "用量 API Key", "usageBaseUrl": "用量查询地址", "usageAutoInterval": "自动查询", diff --git a/src/utils/deeplinkRisk.test.ts b/src/utils/deeplinkRisk.test.ts index b4a9682a8..958901fff 100644 --- a/src/utils/deeplinkRisk.test.ts +++ b/src/utils/deeplinkRisk.test.ts @@ -3,6 +3,7 @@ import { classifyCommand, classifyEndpoint, classifyEnvKey, + decodeDeeplinkPayload, maskValue, } from "./deeplinkRisk"; @@ -158,6 +159,38 @@ describe("classifyCommand", () => { }); }); +describe("decodeDeeplinkPayload", () => { + const ok = (v: string) => `decoded:${v}`; + const boom = () => { + throw new Error("bad base64"); + }; + + it("returns the decoded payload on success", () => { + expect(decodeDeeplinkPayload("abc", ok)).toBe("decoded:abc"); + }); + + it("falls back to the raw string when decoding throws", () => { + // 确认框必须展示即将写入的东西。解不开也要原样显示——返回空串会让整块 + // 内容消失,界面看起来像"没有脚本",那正是攻击者要的效果。 + expect(decodeDeeplinkPayload("!!!not-base64!!!", boom)).toBe( + "!!!not-base64!!!", + ); + }); + + it("falls back to the raw string when decoding yields empty", () => { + // 解出空串同样可疑:不能让 payload 静默消失 + expect(decodeDeeplinkPayload("d293", () => "")).toBe("d293"); + }); + + it("returns empty for a non-string field instead of throwing", () => { + // 该字段来自解码后的任意 JSON,形状不可信;抛错会让确认框整个渲染失败 + for (const hostile of [42, null, undefined, { a: 1 }, ["x"]]) { + expect(() => decodeDeeplinkPayload(hostile, ok)).not.toThrow(); + expect(decodeDeeplinkPayload(hostile, ok)).toBe(""); + } + }); +}); + describe("maskValue", () => { it("masks credential-shaped keys but keeps ordinary values readable", () => { expect(maskValue("ANTHROPIC_AUTH_TOKEN", "sk-ant-1234567890abcdef")).toBe( diff --git a/src/utils/deeplinkRisk.ts b/src/utils/deeplinkRisk.ts index cc1b7cee8..69705f35d 100644 --- a/src/utils/deeplinkRisk.ts +++ b/src/utils/deeplinkRisk.ts @@ -211,3 +211,24 @@ export function maskValue(key: string, value: string): string { export function riskI18nKey(kind: RiskKind): string { return `deeplink.risk.${kind}`; } + +/** + * 解码 deeplink 携带的 base64 载荷,供确认框展示。 + * + * 解码失败时**回落到原始串,绝不返回空串**。确认框的职责是让用户看见即将写入 + * 什么;一段解不开的 payload 也必须以原样出现。返回空会让整块内容凭空消失, + * 界面上看起来就像"没有脚本"——那正是攻击者想要的效果。 + */ +export function decodeDeeplinkPayload( + encoded: unknown, + decode: (value: string) => string, +): string { + if (typeof encoded !== "string") return ""; + try { + const decoded = decode(encoded); + // 解出空串同样可疑:宁可显示原始 base64,也不显示"什么都没有"。 + return decoded === "" ? encoded : decoded; + } catch { + return encoded; + } +}