mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 18:05:37 +08:00
fix(deeplink): import usage scripts disabled and show their code
An imported usage script is JavaScript that runs whenever usage is
queried. Two things made it possible to acquire one without seeing it:
- `usage_enabled.unwrap_or(!code.is_empty())` treated the presence of
code as a decision to run it, so a link that simply carried a script
got it enabled
- the confirmation dialog rendered only an enabled/disabled badge; the
script body was never displayed
Default to disabled. Enabling now requires `usageEnabled=true` in the
link -- which is the link author's request, not the user's consent. The
consent is the user pressing Import after seeing the full script body
and the badge, which is why both displays are load-bearing rather than
decorative.
The badge predicate moves from `!== false` to `=== true` to match the
new backend default. Left alone it would have started rendering "did not
say" as a green "Enabled" -- more optimistic than what would actually
happen.
Extracts the payload decode into `decodeDeeplinkPayload`, which falls
back to the raw string when decoding fails or yields empty. A dialog
whose job is to show what is about to be written must not let a payload
vanish just because it is malformed; empty reads as "there is no
script", which is exactly the wrong impression.
This commit is contained in:
@@ -114,7 +114,8 @@ pub struct DeepLinkImportRequest {
|
||||
pub config_url: Option<String>,
|
||||
|
||||
// ============ 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<bool>,
|
||||
/// Base64 encoded usage query script code
|
||||
|
||||
@@ -254,8 +254,18 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Determine enabled state: explicit param > 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,
|
||||
|
||||
@@ -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<bool>) -> 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;
|
||||
|
||||
@@ -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() {
|
||||
})}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm">
|
||||
{/*
|
||||
判据是 `=== true`,与后端 `usage_enabled.unwrap_or(false)`
|
||||
严格对齐。此前用的 `!== false` 会把"链接没说"渲染成绿色的
|
||||
「已启用」——徽章必须显示实际会发生的事,不能比后端更乐观。
|
||||
*/}
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium ${
|
||||
request.usageEnabled !== false
|
||||
request.usageEnabled === true
|
||||
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{request.usageEnabled !== false
|
||||
{request.usageEnabled === true
|
||||
? t("deeplink.usageScriptEnabled", {
|
||||
defaultValue: "已启用",
|
||||
})
|
||||
@@ -667,6 +674,33 @@ export function DeepLinkImportDialog() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
脚本正文必须完整展示。这段是会执行的 JavaScript,而 payload
|
||||
常常整条藏在中间——`whitespace-pre-wrap break-all` + 可滚动容器,
|
||||
不用 truncate,任何字符都不得被 CSS 藏起来。
|
||||
*/}
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.usageScriptCode")}
|
||||
</div>
|
||||
<pre className="max-h-48 overflow-auto rounded border border-border-default bg-muted/40 p-2 text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{decodeDeeplinkPayload(
|
||||
request.usageScript,
|
||||
decodeBase64Utf8,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
无条件显示,不看 `usageEnabled`:代码无论启用与否都会被写入供应商
|
||||
配置,用户之后在应用内一键即可开启。挂条件等于让攻击者省略参数就能
|
||||
关掉这条警告。
|
||||
*/}
|
||||
<div className="text-yellow-600 dark:text-yellow-500 text-sm flex items-start gap-2">
|
||||
<span aria-hidden="true">⚠️</span>
|
||||
<span>{t("deeplink.usageScriptWarning")}</span>
|
||||
</div>
|
||||
|
||||
{/* Usage API Key (if different from provider) */}
|
||||
{request.usageApiKey &&
|
||||
request.usageApiKey !== request.apiKey && (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2509,6 +2509,8 @@
|
||||
"usageScript": "使用量クエリ",
|
||||
"usageScriptEnabled": "有効",
|
||||
"usageScriptDisabled": "無効",
|
||||
"usageScriptCode": "スクリプトコード",
|
||||
"usageScriptWarning": "これは有効化すると使用量クエリ時に実行される JavaScript です。提供元が信頼できる場合のみインポートしてください。",
|
||||
"usageApiKey": "使用量 API キー",
|
||||
"usageBaseUrl": "使用量クエリ URL",
|
||||
"usageAutoInterval": "自動クエリ",
|
||||
|
||||
@@ -2480,6 +2480,8 @@
|
||||
"usageScript": "用量查詢",
|
||||
"usageScriptEnabled": "已啟用",
|
||||
"usageScriptDisabled": "未啟用",
|
||||
"usageScriptCode": "指令碼程式碼",
|
||||
"usageScriptWarning": "這是一段 JavaScript 程式碼,啟用後會在查詢用量時執行。請確認來源可信後再匯入。",
|
||||
"usageApiKey": "用量 API Key",
|
||||
"usageBaseUrl": "用量查詢位址",
|
||||
"usageAutoInterval": "自動查詢",
|
||||
|
||||
@@ -2509,6 +2509,8 @@
|
||||
"usageScript": "用量查询",
|
||||
"usageScriptEnabled": "已启用",
|
||||
"usageScriptDisabled": "未启用",
|
||||
"usageScriptCode": "脚本代码",
|
||||
"usageScriptWarning": "这是一段 JavaScript 代码,启用后会在查询用量时执行。请确认来源可信后再导入。",
|
||||
"usageApiKey": "用量 API Key",
|
||||
"usageBaseUrl": "用量查询地址",
|
||||
"usageAutoInterval": "自动查询",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user