修复: FR-S1 api_key IPC mask + 编辑不回填(防前端拿明文,P0 安全)

- ai_list_providers: 返回前 mask api_key(首尾各4+••••),IPC 不传明文给前端 realm
- ai_save_provider: api_key 空(编辑不改)→保留原 DB 值
- Settings: 编辑不回填 apiKey(留空=不改) + saveProvider 校验编辑时 apiKey 可空
- 注:DB 仍明文存储(c,keyring 加密存储)留迁移单列;本轮解 IPC 传明文 + 前端 realm 持有明文
来源 fullstack-review §2 P0 安全;cargo/vue-tsc 0 err
This commit is contained in:
2026-06-14 22:18:34 +08:00
parent f58743efbc
commit 49ac0601e1
2 changed files with 35 additions and 3 deletions

View File

@@ -265,10 +265,28 @@ pub async fn ai_chat_stop(state: State<'_, AppState>, app: AppHandle) -> Result<
// 提供商管理 // 提供商管理
// ============================================================ // ============================================================
/// api_key 脱敏:IPC 不传明文给前端(FR-S1),保留首尾各 4 字符便于辨识
fn mask_api_key(key: &str) -> String {
let chars: Vec<char> = key.chars().collect();
if chars.len() <= 8 {
return "".repeat(chars.len());
}
let prefix: String = chars[..4].iter().collect();
let suffix: String = chars[chars.len() - 4..].iter().collect();
format!("{}••••{}", prefix, suffix)
}
/// 列出所有已配置的 AI 提供商is_default 真相源为 DB重启不丢 /// 列出所有已配置的 AI 提供商is_default 真相源为 DB重启不丢
#[tauri::command] #[tauri::command]
pub async fn ai_list_providers(state: State<'_, AppState>) -> Result<Vec<AiProviderRecord>, String> { pub async fn ai_list_providers(state: State<'_, AppState>) -> Result<Vec<AiProviderRecord>, String> {
state.ai_providers.list_all().await.map_err(|e| e.to_string()) let mut providers = state.ai_providers.list_all().await.map_err(|e| e.to_string())?;
// IPC 不传明文 api_key(FR-S1):前端编辑用空 apiKey 表示不改,mask 后前端 realm 不持有明文
for p in &mut providers {
if !p.api_key.is_empty() {
p.api_key = mask_api_key(&p.api_key);
}
}
Ok(providers)
} }
/// 保存/更新 AI 提供商配置 /// 保存/更新 AI 提供商配置
@@ -300,6 +318,18 @@ pub async fn ai_save_provider(
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.iter().any(|p| p.is_default), .iter().any(|p| p.is_default),
}; };
// api_key 空(编辑不改 key,前端 list 拿到 mask 故留空)→保留原 DB 值(FR-S1)
let api_key = if api_key.is_empty() {
match &id {
Some(pid) => state.ai_providers.get_by_id(pid).await
.map_err(|e| e.to_string())?
.map(|p| p.api_key)
.unwrap_or_default(),
None => String::new(),
}
} else {
api_key
};
let record = AiProviderRecord { let record = AiProviderRecord {
id: id.unwrap_or_else(new_id), id: id.unwrap_or_else(new_id),
name, name,

View File

@@ -443,7 +443,7 @@ function openProviderForm(p?: AiProviderConfig) {
providerForm.editId = p.id providerForm.editId = p.id
providerForm.name = p.name providerForm.name = p.name
providerForm.baseUrl = p.base_url providerForm.baseUrl = p.base_url
providerForm.apiKey = p.api_key providerForm.apiKey = '' // 编辑不回填 api_key(list 返回 mask,留空=不改,FR-S1)
providerForm.defaultModel = p.default_model providerForm.defaultModel = p.default_model
providerForm.providerType = p.provider_type || 'openai_compat' providerForm.providerType = p.provider_type || 'openai_compat'
} else { } else {
@@ -458,7 +458,9 @@ function openProviderForm(p?: AiProviderConfig) {
} }
async function saveProvider() { async function saveProvider() {
if (!providerForm.name || !providerForm.baseUrl || !providerForm.apiKey || !providerForm.defaultModel) { // apiKey:新建必填;编辑可留空(不改,FR-S1 后端保留原 DB 值)
const needKey = !providerForm.editId
if (!providerForm.name || !providerForm.baseUrl || (needKey && !providerForm.apiKey) || !providerForm.defaultModel) {
showToast(t('settings.toastSaveIncomplete'), 'warning') showToast(t('settings.toastSaveIncomplete'), 'warning')
return return
} }