feat: mask sensitive parts of a subscription URL for safe logging

This commit is contained in:
wonfen 2026-02-21 04:19:46 +08:00
parent 119aaee546
commit fa07dfbc9a
No known key found for this signature in database
GPG Key ID: CEAFD6C73AB2001F
3 changed files with 59 additions and 5 deletions

View File

@ -65,7 +65,7 @@ pub async fn enhance_profiles() -> CmdResult {
/// 导入配置文件
#[tauri::command]
pub async fn import_profile(url: std::string::String, option: Option<PrfOption>) -> CmdResult {
logging!(info, Type::Cmd, "[导入订阅] 开始导入: {}", url);
logging!(info, Type::Cmd, "[导入订阅] 开始导入: {}", help::mask_url(&url));
// 直接依赖 PrfItem::from_url 自身的超时/重试逻辑,不再使用 tokio::time::timeout 包裹
let item = &mut match PrfItem::from_url(&url, None, None, option.as_ref()).await {
@ -107,7 +107,7 @@ pub async fn import_profile(url: std::string::String, option: Option<PrfOption>)
handle::Handle::notify_profile_changed(uid);
}
logging!(info, Type::Cmd, "[导入订阅] 导入完成: {}", url);
logging!(info, Type::Cmd, "[导入订阅] 导入完成: {}", help::mask_url(&url));
AutoBackupManager::trigger_backup(AutoBackupTrigger::ProfileChange);
Ok(())
}

View File

@ -2,6 +2,7 @@ use crate::{
cmd,
config::{Config, PrfItem, PrfOption, profiles::profiles_draft_update_item_safe},
core::{CoreManager, handle, tray},
utils::help::mask_url,
};
use anyhow::{Result, bail};
use clash_verge_logging::{Type, logging, logging_error};
@ -83,9 +84,11 @@ async fn should_update_profile(uid: &String, ignore_auto_update: bool) -> Result
Type::Config,
"[订阅更新] {} 是远程订阅URL: {}",
uid,
item.url
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Profile URL is None"))?
mask_url(
item.url
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Profile URL is None"))?
)
);
Ok(Some((
item.url.clone().ok_or_else(|| anyhow::anyhow!("Profile URL is None"))?,

View File

@ -98,6 +98,57 @@ pub fn parse_str<T: FromStr>(target: &str, key: &str) -> Option<T> {
})
}
/// Mask sensitive parts of a subscription URL for safe logging.
/// Examples:
/// - `https://example.com/api/v1/clash?token=abc123` → `https://example.com/api/v1/clash?token=***`
/// - `https://example.com/abc123def456ghi789/clash` → `https://example.com/***/clash`
pub fn mask_url(url: &str) -> String {
// Split off query string
let (path_part, query_part) = match url.find('?') {
Some(pos) => (&url[..pos], Some(&url[pos + 1..])),
None => (url, None),
};
// Extract scheme+host prefix (everything up to the first '/' after "://")
let host_end = path_part
.find("://")
.and_then(|scheme_end| {
path_part[scheme_end + 3..]
.find('/')
.map(|slash| scheme_end + 3 + slash)
})
.unwrap_or(path_part.len());
let scheme_and_host = &path_part[..host_end];
let path = &path_part[host_end..]; // starts with '/' or empty
let mut result = scheme_and_host.to_owned();
// Mask path segments that look like tokens (longer than 16 chars)
if !path.is_empty() {
let masked: Vec<&str> = path
.split('/')
.map(|seg| if seg.len() > 16 { "***" } else { seg })
.collect();
result.push_str(&masked.join("/"));
}
// Keep query param keys, mask values
if let Some(query) = query_part {
result.push('?');
let masked_query: Vec<String> = query
.split('&')
.map(|param| match param.find('=') {
Some(eq) => format!("{}=***", &param[..eq]),
None => param.to_owned(),
})
.collect();
result.push_str(&masked_query.join("&"));
}
result
}
/// get the last part of the url, if not found, return empty string
pub fn get_last_part_and_decode(url: &str) -> Option<String> {
let path = url.split('?').next().unwrap_or(""); // Splits URL and takes the path part