feat: block official provider switching during proxy takeover

Prevent users from switching to official providers (Anthropic/OpenAI/Google)
when proxy takeover is active, as using a proxy with official APIs may cause
account bans.

Defense-in-depth across 4 layers:
- Backend: ProviderService::switch(), hot_switch_provider(), switch_proxy_provider command
- Frontend: useProviderActions soft guard with error toast
- UI: ProviderActions button disabled with ShieldAlert icon
- Tray menu: official provider items disabled with  indicator

Also warns when enabling proxy takeover while current provider is official.
This commit is contained in:
Jason
2026-04-11 11:43:49 +08:00
parent 2937eb6766
commit a514f27937
11 changed files with 158 additions and 8 deletions
+13
View File
@@ -253,6 +253,19 @@ pub async fn switch_proxy_provider(
app_type: String,
provider_id: String,
) -> Result<(), String> {
// Block official providers during proxy takeover
let provider = state
.db
.get_provider_by_id(&provider_id, &app_type)
.map_err(|e| format!("读取供应商失败: {e}"))?
.ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
if provider.category.as_deref() == Some("official") {
return Err(
"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)"
.to_string(),
);
}
state
.proxy_service
.switch_proxy_target(&app_type, &provider_id)
+10
View File
@@ -1407,6 +1407,16 @@ impl ProviderService {
// Hot-switch only when BOTH: this app is taken over AND proxy server is actually running
let should_hot_switch = (is_app_taken_over || live_taken_over) && is_proxy_running;
// Block switching to official providers when proxy takeover is active.
// Using a proxy with official APIs (Anthropic/OpenAI/Google) may cause account bans.
if should_hot_switch && _provider.category.as_deref() == Some("official") {
return Err(AppError::localized(
"switch.official_blocked_by_proxy",
"代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁。请先关闭代理接管,或选择第三方供应商。",
"Cannot switch to official provider while proxy takeover is active. Using proxy with official APIs may cause account bans.",
));
}
if should_hot_switch {
// Proxy takeover mode: hot-switch only, don't write Live config
log::info!(
+29
View File
@@ -15,6 +15,7 @@ use crate::services::provider::{
use serde_json::{json, Value};
use std::str::FromStr;
use std::sync::Arc;
use tauri::Emitter;
use tokio::sync::RwLock;
/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)
@@ -375,6 +376,26 @@ impl ProxyService {
// 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)
let _ = self.db.set_live_takeover_active(true).await;
// 8) Warn if the current provider is official (risk of account ban via proxy)
if let Ok(Some(current_id)) =
crate::settings::get_effective_current_provider(&self.db, &app)
{
if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {
if provider.category.as_deref() == Some("official") {
if let Some(handle) = self.app_handle.read().await.as_ref() {
let _ = handle.emit(
"proxy-official-warning",
serde_json::json!({
"appType": app_type_str,
"providerName": provider.name,
}),
);
}
}
}
}
return Ok(());
}
@@ -1548,6 +1569,14 @@ impl ProxyService {
.map_err(|e| format!("读取供应商失败: {e}"))?
.ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
// Defense-in-depth: block official providers during proxy takeover
if provider.category.as_deref() == Some("official") {
return Err(
"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)"
.to_string(),
);
}
let logical_target_changed =
crate::settings::get_effective_current_provider(&self.db, &app_type_enum)
.map_err(|e| format!("读取当前供应商失败: {e}"))?
+22 -2
View File
@@ -297,6 +297,9 @@ pub fn create_tray_menu(
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?;
menu_builder = menu_builder.item(&show_main_item).separator();
// Pre-compute proxy running state (used to disable official providers in tray menu)
let is_proxy_running = futures::executor::block_on(app_state.proxy_service.is_running());
// 每个应用类型折叠为子菜单,避免供应商过多时菜单过长
for section in TRAY_SECTIONS.iter() {
if !visible_apps.is_visible(&section.app_type) {
@@ -327,15 +330,32 @@ pub fn create_tray_menu(
};
let submenu_id = format!("submenu_{}", app_type_str);
// Check if this app is under proxy takeover (for disabling official providers)
let is_app_taken_over = is_proxy_running
&& (futures::executor::block_on(app_state.db.get_live_backup(app_type_str))
.ok()
.flatten()
.is_some()
|| app_state
.proxy_service
.detect_takeover_in_live_config_for_app(&section.app_type));
let mut submenu_builder = SubmenuBuilder::with_id(app, &submenu_id, &submenu_label);
for (id, provider) in sort_providers(&providers) {
let is_current = current_id == *id;
let is_official_blocked =
is_app_taken_over && provider.category.as_deref() == Some("official");
let label = if is_official_blocked {
format!("{} \u{26D4}", &provider.name) // ⛔ emoji
} else {
provider.name.clone()
};
let item = CheckMenuItem::with_id(
app,
format!("{}{}", section.prefix, id),
&provider.name,
true,
&label,
!is_official_blocked, // disabled when blocked
is_current,
None::<&str>,
)
+31 -1
View File
@@ -270,7 +270,11 @@ function App() {
deleteProvider,
saveUsageScript,
setAsDefaultModel,
} = useProviderActions(activeApp, isProxyRunning);
} = useProviderActions(
activeApp,
isProxyRunning,
isProxyRunning && isCurrentAppTakeoverActive,
);
const disableOmoMutation = useDisableCurrentOmo();
const handleDisableOmo = () => {
@@ -401,6 +405,32 @@ function App() {
};
}, [queryClient, t]);
// Listen for proxy-official-warning: warn when takeover is enabled with an official provider
useEffect(() => {
let unsubscribe: (() => void) | undefined;
const setup = async () => {
unsubscribe = await listen("proxy-official-warning", (event) => {
const { providerName } = event.payload as {
appType: string;
providerName: string;
};
toast.warning(
t("notifications.proxyOfficialWarning", {
name: providerName,
defaultValue: `当前供应商 ${providerName} 是官方供应商,建议切换到第三方供应商后再使用代理接管`,
}),
{ duration: 8000 },
);
});
};
void setup();
return () => {
unsubscribe?.();
};
}, [t]);
useEffect(() => {
let active = true;
let unlistenResize: (() => void) | undefined;
@@ -7,6 +7,7 @@ import {
Minus,
Play,
Plus,
ShieldAlert,
Terminal,
TestTube2,
Trash2,
@@ -36,6 +37,7 @@ interface ProviderActionsProps {
isAutoFailoverEnabled?: boolean;
isInFailoverQueue?: boolean;
onToggleFailover?: (enabled: boolean) => void;
isOfficialBlockedByProxy?: boolean;
// OpenClaw: default model
isDefaultModel?: boolean;
onSetAsDefault?: () => void;
@@ -60,6 +62,7 @@ export function ProviderActions({
isAutoFailoverEnabled = false,
isInFailoverQueue = false,
onToggleFailover,
isOfficialBlockedByProxy = false,
// OpenClaw: default model
isDefaultModel = false,
onSetAsDefault,
@@ -166,6 +169,16 @@ export function ProviderActions({
};
}
if (isOfficialBlockedByProxy) {
return {
disabled: true,
variant: "secondary" as const,
className: "opacity-40 cursor-not-allowed",
icon: <ShieldAlert className="h-4 w-4" />,
text: t("provider.blockedByProxy", { defaultValue: "已拦截" }),
};
}
if (isCurrent) {
return {
disabled: true,
@@ -175,6 +175,8 @@ export function ProviderCard({
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
const isOfficial = isOfficialProvider(provider, appId);
const isOfficialBlockedByProxy =
isProxyTakeover && (provider.category === "official" || isOfficial);
const isCopilot =
provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT ||
provider.meta?.usage_script?.templateType === "github_copilot";
@@ -424,6 +426,7 @@ export function ProviderCard({
isInConfig={isInConfig}
isTesting={isTesting}
isProxyTakeover={isProxyTakeover}
isOfficialBlockedByProxy={isOfficialBlockedByProxy}
isOmo={isAnyOmo}
onSwitch={() => onSwitch(provider)}
onEdit={() => onEdit(provider)}
+25 -2
View File
@@ -23,7 +23,11 @@ import { openclawKeys } from "@/hooks/useOpenClaw";
* Hook for managing provider actions (add, update, delete, switch)
* Extracts business logic from App.tsx
*/
export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
export function useProviderActions(
activeApp: AppId,
isProxyRunning?: boolean,
isProxyTakeover?: boolean,
) {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -185,6 +189,18 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
);
}
// Block official providers when proxy takeover is active
if (isProxyTakeover && provider.category === "official") {
toast.error(
t("notifications.officialBlockedByProxy", {
defaultValue:
"代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁",
}),
{ duration: 6000 },
);
return;
}
try {
const result = await switchProviderMutation.mutateAsync(provider.id);
await syncClaudePlugin(provider);
@@ -220,7 +236,14 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
// 错误提示由 mutation 处理
}
},
[switchProviderMutation, syncClaudePlugin, activeApp, isProxyRunning, t],
[
switchProviderMutation,
syncClaudePlugin,
activeApp,
isProxyRunning,
isProxyTakeover,
t,
],
);
// 删除供应商
+4 -1
View File
@@ -108,6 +108,7 @@
"currentlyUsing": "Currently Using",
"enable": "Enable",
"inUse": "In Use",
"blockedByProxy": "Blocked",
"editProvider": "Edit Provider",
"editProviderHint": "Configuration will be applied to the current provider immediately after update.",
"deleteProvider": "Delete Provider",
@@ -206,7 +207,9 @@
"openclawDefaultModelSetFailed": "Failed to set default model",
"openclawNoModels": "No models configured",
"backfillWarning": "Switched successfully, but failed to save changes back to the previous provider",
"windowControlFailed": "Window control failed: {{error}}"
"windowControlFailed": "Window control failed: {{error}}",
"officialBlockedByProxy": "Cannot switch to official provider while proxy takeover is active. Using proxy with official APIs may cause account bans.",
"proxyOfficialWarning": "Current provider {{name}} is official. Consider switching to a third-party provider before using proxy takeover."
},
"confirm": {
"deleteProvider": "Delete Provider",
+4 -1
View File
@@ -108,6 +108,7 @@
"currentlyUsing": "現在使用中",
"enable": "有効化",
"inUse": "使用中",
"blockedByProxy": "ブロック",
"editProvider": "プロバイダーを編集",
"editProviderHint": "保存すると現在のプロバイダーにすぐ反映されます。",
"deleteProvider": "プロバイダーを削除",
@@ -206,7 +207,9 @@
"openclawDefaultModelSetFailed": "デフォルトモデルの設定に失敗しました",
"openclawNoModels": "モデルが設定されていません",
"backfillWarning": "切り替え成功しましたが、前のプロバイダーへの設定保存に失敗しました",
"windowControlFailed": "ウィンドウ操作に失敗しました: {{error}}"
"windowControlFailed": "ウィンドウ操作に失敗しました: {{error}}",
"officialBlockedByProxy": "プロキシ接管モード中は公式プロバイダーに切り替えできません。プロキシ経由で公式 API にアクセスするとアカウントが停止される可能性があります。",
"proxyOfficialWarning": "現在のプロバイダー {{name}} は公式です。プロキシ接管を使用する前にサードパーティプロバイダーに切り替えてください。"
},
"confirm": {
"deleteProvider": "プロバイダーを削除",
+4 -1
View File
@@ -108,6 +108,7 @@
"currentlyUsing": "当前使用",
"enable": "启用",
"inUse": "使用中",
"blockedByProxy": "已拦截",
"editProvider": "编辑供应商",
"editProviderHint": "更新配置后将立即应用到当前供应商。",
"deleteProvider": "删除供应商",
@@ -206,7 +207,9 @@
"openclawDefaultModelSetFailed": "设置默认模型失败",
"openclawNoModels": "该供应商没有配置模型",
"backfillWarning": "切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存",
"windowControlFailed": "窗口控制失败:{{error}}"
"windowControlFailed": "窗口控制失败:{{error}}",
"officialBlockedByProxy": "代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁",
"proxyOfficialWarning": "当前供应商 {{name}} 是官方供应商,建议切换到第三方供应商后再使用代理接管"
},
"confirm": {
"deleteProvider": "删除供应商",