-
-
Notifications
You must be signed in to change notification settings - Fork 40
Configuration
Zephyr 是基于 Tauri v2 的 Mihomo GUI 代理客户端,实施了多层纵深防御(Defense in Depth)策略,从网络请求校验到文件系统隔离,覆盖全攻击面。本文档以安全审计视角,逐一拆解每一层防护机制的设计意图、实现细节与威胁模型。
- 安全纵深防御总览
- 第一层:内容安全策略(CSP)
- 第二层:Tauri 最小权限原则
- 第三层:SSRF 防护
- 第四层:加密存储体系
- 第五层:路径遍历防护
- 第六层:危险配置清除
- 第七层:供应链安全
- 第八层:命令限流
- 第九层:文件权限控制
- 第十层:运行时安全加固
- 第十一层:CI/CD 安全流水线
- Semgrep 自定义规则
- 依赖许可证治理
- 威胁模型总结
Zephyr 的安全体系由 5 个运行时防护层、1 个供应链安全层和 1 个 CI/CD 持续验证层组成,形成从外到内的纵深防御链。下图展示了各层级之间的关系与数据流向:
%%{init: {'themeVariables': {'fontSize': '9px', 'nodeBorder': '1px', 'clusterBorder': '1px', 'edgeLabelHeight': '8px'}}}%%
graph TB
subgraph AttackSurface["攻击面(外部输入)"]
direction LR
A1["订阅 URL"]
A2["配置文件"]
A3["拖拽导入"]
A4["更新包"]
A5["网络响应"]
end
subgraph L1["第一层:前端安全"]
CSP["CSP 策略<br/>script-src 'self'<br/>禁止 eval/内联脚本<br/>connect-src 仅本地"]
NoEval["无 eval / 无 document.write<br/>(innerHTML 通过 Semgrep 规则监控)"]
end
subgraph L2["第二层:Tauri 权限"]
NoFS["无 fs 权限<br/>无 shell 权限"]
NoHTTP["无 http 权限<br/>无 clipboard 权限"]
NoEmit["无 emit 权限<br/>仅 invoke 通信"]
NoFScreen["无 set-fullscreen<br/>防钓鱼攻击"]
end
subgraph L3["第三层:Rust 后端防护"]
SSRF["SSRF 防护<br/>协议白名单 + DNS Pinning<br/>私有 IP 拦截 + 重定向校验"]
Crypto["AES-256-GCM 加密<br/>PBKDF2 密钥派生<br/>机器指纹绑定"]
PathGuard["路径遍历防护<br/>5 轮 URL 解码<br/>9 步净化流程"]
end
subgraph L4["第四层:供应链安全"]
SHA256["SHA256 完整性校验"]
TrustedDomain["可信域名白名单<br/>github.com / api.github.com"]
VersionCheck["版本格式验证<br/>路径格式验证"]
end
subgraph L5["第五层:CI/CD 持续验证"]
CI1["cargo audit"]
CI2["cargo-deny"]
CI3["clippy"]
CI4["cargo fmt"]
CI5["npm audit"]
CI6["dependency-review"]
CI7["semgrep"]
CI8["自定义密钥检测脚本"]
CI9["tauri audit"]
CI10["build verification"]
end
AttackSurface --> L1
L1 --> L2
L2 --> L3
L3 --> L4
L4 --> L5
style AttackSurface fill:#ff6b6b,stroke:#c0392b,color:#fff
style L1 fill:#f39c12,stroke:#e67e22,color:#fff
style L2 fill:#e74c3c,stroke:#c0392b,color:#fff
style L3 fill:#3498db,stroke:#2980b9,color:#fff
style L4 fill:#2ecc71,stroke:#27ae60,color:#fff
style L5 fill:#9b59b6,stroke:#8e44ad,color:#fff
设计原则:每一层独立运作,单层失效不导致整体突破。所有外部输入(URL、配置、文件)均视为不可信,必须经过完整校验链。
CSP(Content Security Policy)是浏览器级别的第一道防线,用于限制 WebView 可以加载的资源来源,从根本上阻断 XSS、数据外泄和代码注入攻击。
CSP 策略在 tauri.conf.json 中声明,Tauri v2 会在应用启动时将其注入到所有页面:
{
"app": {
"security": {
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ws://127.0.0.1:* http://127.0.0.1:* ipc://localhost http://ipc.localhost"
}
}
}| CSP 指令 | 值 | 安全含义 |
|---|---|---|
default-src |
'self' |
默认拒绝一切外部资源。所有未显式指定的资源类型仅允许从应用包内加载 |
img-src |
'self' data: |
图片仅允许本地资源和 Base64 内嵌数据(用于图标渲染),禁止从外部 URL 加载图片(防止追踪像素和数据外泄) |
style-src |
'self' 'unsafe-inline' |
样式允许本地文件和内联样式。unsafe-inline 是 Tailwind CSS 运行所必需的妥协 |
script-src |
'self' |
JavaScript 仅允许从应用包内加载。完全禁止内联脚本(eval、onclick 等)、外部 CDN 和动态代码执行。注意:CSP script-src 'self' 可阻止内联脚本执行,但 innerHTML 仍被用于 DOM 操作(共 35 处,位于 ui.js)。由于 innerHTML 赋值不涉及用户可控数据直接插入,且已通过 Semgrep 自定义规则(js-innerhtml-assignment)进行静态监控,因此不构成 XSS 风险 |
connect-src |
'self' ws://127.0.0.1:* http://127.0.0.1:* ipc://localhost http://ipc.localhost |
网络连接严格限制:仅允许与本地 Mihomo 内核通信(WebSocket/HTTP)和 Tauri IPC 通道。禁止连接任何外部服务器 |
connect-src 的设计是最关键的安全决策。前端 JavaScript 无法直接向互联网发起请求 -- 所有外部网络通信必须通过 Tauri Command(Rust 后端)进行,从而强制经过 SSRF 防护层的校验。
style-src 'unsafe-inline' 的引入是为了支持 Tailwind CSS v4 的原子化样式注入。由于 script-src 不包含 unsafe-inline 或 unsafe-eval,即使攻击者能在样式中注入内容,也无法执行 JavaScript 代码。
Tauri v2 采用 Capability-based 权限模型。Zephyr 遵循最小权限原则,仅授予应用正常运行所必需的最小权限集,从进程级别限制攻击面。
权限配置位于 src-tauri/capabilities/default.json:
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"windows": ["main"],
"permissions": [
"opener:default",
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled",
"core:window:allow-close",
"core:window:allow-start-dragging",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-set-focus",
"core:event:allow-listen",
"core:event:allow-unlisten",
"core:image:default",
"core:menu:default",
"core:tray:default",
"core:resources:default",
"core:app:default",
"dialog:allow-message"
]
}| 类别 | 已授予权限 | 用途 |
|---|---|---|
| 窗口管理 | close, start-dragging, minimize, maximize, hide, show, set-focus | 无边框窗口的基本交互 |
| 系统托盘 | tray:default | 托盘图标与菜单管理 |
| 事件系统 | listen, unlisten | 前端监听后端事件(单向接收) |
| 开机自启 | enable, disable, is-enabled | 用户主动控制的自启管理 |
| 对话框 | message | 显示提示对话框(仅消息类型) |
| 资源访问 | image, menu, resources, app | 读取应用内置资源 |
| 外部打开 | opener:default | 通过系统默认程序打开链接 |
以下高危权限被明确拒绝授予,这是 Zephyr 安全架构的核心决策:
| 未授予权限 | 风险说明 | 影响 |
|---|---|---|
fs(文件系统) |
前端无法直接读写任意文件 | 所有文件操作必须通过 Rust 后端的安全函数 |
shell(命令执行) |
前端无法执行系统命令 | 防止通过 WebView 进行命令注入 |
http(网络请求) |
前端无法直接发起 HTTP 请求 | 配合 CSP connect-src 实现双重网络隔离 |
clipboard(剪贴板) |
前端无法读写系统剪贴板 | 防止敏感数据通过剪贴板外泄 |
emit(事件发送) |
前端无法主动向后端发送事件 | 仅允许 listen/unlisten(单向监听),防止伪造后端事件 |
set-fullscreen |
前端无法切换全屏模式 | 防止全屏钓鱼攻击(fullscreen phishing) |
注意:
core:event:allow-emit未被授予。这意味着前端 JavaScript 只能被动监听后端事件,不能主动向 Rust 后端发送事件。所有前端到后端的通信必须通过invoke(Tauri Command),从而经过参数校验和权限检查。
SSRF(Server-Side Request Forgery)是代理客户端面临的核心威胁之一。攻击者可能通过以下方式利用 SSRF:
- 订阅 URL 注入:诱导用户添加指向内网服务的订阅地址
- DNS Rebinding:首次 DNS 解析返回合法 IP,后续解析返回内网 IP
- 开放重定向:通过 HTTP 重定向将请求引导至内网
-
协议走私:使用
file://、gopher://等非 HTTP 协议访问本地资源
Zephyr 实现了完整的 SSRF 防护决策链,每个请求在到达网络层之前必须通过所有检查节点:
%%{init: {'themeVariables': {'fontSize': '9px', 'nodeBorder': '1px', 'clusterBorder': '1px', 'edgeLabelHeight': '8px'}, 'flowchart': {'curve': 'basis', 'nodeSpacing': 12, 'rankSpacing': 15}}}%%
flowchart TD
A["输入 URL"] --> B{"协议检查<br/>仅允许 http/https?"}
B -- "非 http/https" --> R1["拒绝<br/>Only HTTP and HTTPS<br/>URLs are allowed"]
B -- "http/https" --> C{"主机名检查<br/>is_private_host()?<br/>localhost / .local / .test 等"}
C -- "私有主机名" --> R2["拒绝<br/>Access to private/<br/>local addresses<br/>is not allowed"]
C -- "合法主机名" --> D["DNS 预解析<br/>ToSocketAddrs::to_socket_addrs()"]
D --> E{"IP 检查<br/>is_private_ip()?<br/>10.x / 172.16.x / 192.168.x<br/>127.x / ::1 / fc00:: 等"}
E -- "私有 IP" --> R3["拒绝<br/>Access to private/<br/>local resolved<br/>addresses is not allowed"]
E -- "公网 IP" --> F["发起请求<br/>DNS Pinning 锁定解析 IP"]
F --> G{"重定向检查<br/>Custom Redirect Policy<br/>最多跟踪 5 次?"}
G -- "重定向次数 > 5" --> R4["拒绝<br/>Too many redirects<br/>max 5"]
G -- "重定向目标" --> H{"重定向目标检查<br/>协议 + 主机名 + IP<br/>全链路复检"}
H -- "检查不通过" --> R5["拒绝<br/>Redirect to private<br/>host/IP blocked"]
H -- "检查通过" --> F
G -- "无重定向" --> I["返回数据"]
style A fill:#3498db,stroke:#2980b9,color:#fff
style I fill:#2ecc71,stroke:#27ae60,color:#fff
style R1 fill:#e74c3c,stroke:#c0392b,color:#fff
style R2 fill:#e74c3c,stroke:#c0392b,color:#fff
style R3 fill:#e74c3c,stroke:#c0392b,color:#fff
style R4 fill:#e74c3c,stroke:#c0392b,color:#fff
style R5 fill:#e74c3c,stroke:#c0392b,color:#fff
validate_subscription_url_with_ip 是 SSRF 防护的入口函数。它在发起实际 HTTP 请求之前完成所有校验,并将 DNS 解析结果返回给调用方用于 DNS Pinning:
fn validate_subscription_url_with_ip(
url: &str,
) -> Result<(String, Option<std::net::SocketAddr>), String> {
let parsed_url = reqwest::Url::parse(url)?;
// 1. 协议白名单:仅允许 http 和 https
let scheme = parsed_url.scheme();
if scheme != "http" && scheme != "https" {
return Err("Only HTTP and HTTPS URLs are allowed".to_string());
}
// 2. 主机名黑名单检查
let host = parsed_url.host_str().ok_or("URL must have a host")?;
if is_private_host(host) {
return Err("Access to private/local addresses is not allowed".to_string());
}
// 3. DNS 预解析 + IP 校验
let default_port = if scheme == "https" { 443 } else { 80 };
let addrs = std::net::ToSocketAddrs::to_socket_addrs(
&format!("{}:{}", host, default_port)
)?;
for addr in addrs {
if is_private_ip(addr.ip()) {
return Err(
"Access to private/local resolved addresses is not allowed".to_string()
);
}
}
// 返回解析结果,调用方使用 resolve_pin 防止 DNS Rebinding
Ok((host.to_string(), resolved_addr))
}DNS Pinning 机制:解析得到的 IP 地址通过 reqwest::Client::resolve() 固定到 HTTP 客户端,确保后续实际请求使用的是经过校验的 IP 地址,而非重新进行 DNS 解析。这有效防止了 DNS Rebinding(TOCTOU)攻击:
if let Some((host, addr)) = resolve_pin {
client_builder = client_builder.resolve(&host, addr);
}is_private_ip 函数覆盖了所有已知的私有和保留 IP 地址范围:
IPv4 检测范围:
| 范围 | CIDR | 检测方法 | 说明 |
|---|---|---|---|
| 私有地址 | 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 | ipv4.is_private() |
RFC 1918 私有网络 |
| 环回地址 | 127.0.0.0/8 | ipv4.is_loopback() |
本机回环 |
| 链路本地 | 169.254.0.0/16 | ipv4.is_link_local() |
自动分配地址 |
| 广播地址 | 255.255.255.255 | ipv4.is_broadcast() |
网络广播 |
| 文档用途 | 192.0.2.0/24 等 | ipv4.is_documentation() |
文档示例地址 |
| 未指定 | 0.0.0.0 | ipv4.is_unspecified() |
通配绑定地址 |
IPv6 检测范围:
| 范围 | CIDR | 说明 |
|---|---|---|
| 环回地址 | ::1 | 本机回环 |
| 未指定 | :: | 通配绑定 |
| 唯一本地地址 | fc00::/7 | IPv6 私有网络(ULA) |
| 链路本地 | fe80::/10 | IPv6 链路本地 |
fn is_private_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ipv4) => {
ipv4.is_private()
|| ipv4.is_loopback()
|| ipv4.is_link_local()
|| ipv4.is_broadcast()
|| ipv4.is_documentation()
|| ipv4.is_unspecified()
}
IpAddr::V6(ipv6) => {
ipv6.is_loopback()
|| ipv6.is_unspecified()
|| (ipv6.segments()[0] & 0xfe00) == 0xfc00 // ULA
|| (ipv6.segments()[0] & 0xff00) == 0xfe00 // Link Local
}
}
}is_private_host 函数拦截常见的本地主机名模式,防止绕过 IP 检测:
fn is_private_host(host: &str) -> bool {
let host_lower = host.to_lowercase();
// 拦截常见本地主机名
if host_lower == "localhost"
|| host_lower.ends_with(".localhost")
|| host_lower.ends_with(".local")
|| host_lower.ends_with(".test")
|| host_lower.ends_with(".example")
|| host_lower.ends_with(".invalid")
{
return true;
}
// 如果是直接 IP 地址,委托给 is_private_ip
if let Ok(ip) = host.parse::<IpAddr>() {
return is_private_ip(ip);
}
false
}| 主机名模式 | 说明 |
|---|---|
localhost |
本机回环的标准名称 |
*.localhost |
RFC 2606 保留域名 |
*.local |
mDNS/Bonjour 本地域名 |
*.test |
RFC 2606 保留域名 |
*.example |
RFC 2606 保留域名 |
*.invalid |
RFC 2606 保留域名 |
HTTP 客户端使用自定义重定向策略,对每次重定向执行完整的 SSRF 检查:
let redirect_policy = reqwest::redirect::Policy::custom(|attempt| {
// 最多跟踪 5 次重定向
if attempt.previous().len() > 5 {
return attempt.error("Too many redirects (max 5)");
}
let url = attempt.url().clone();
// 1. 协议白名单
let scheme = url.scheme();
if scheme != "http" && scheme != "https" {
return attempt.error(format!("Invalid redirect scheme: {}", scheme));
}
// 2. 主机名黑名单
let host = match url.host_str() {
Some(h) => h.to_string(),
None => return attempt.error("Redirect URL has no host"),
};
if is_private_host(&host) {
return attempt.error(format!("Redirect to private host blocked: {}", host));
}
// 3. DNS 解析 + IP 校验
let port = url.port().unwrap_or(if scheme == "https" { 443 } else { 80 });
match std::net::ToSocketAddrs::to_socket_addrs(&format!("{}:{}", host, port)) {
Ok(addrs) => {
for addr in addrs {
if is_private_ip(addr.ip()) {
return attempt.error(format!(
"Redirect to private IP blocked: {} -> {}",
host, addr.ip()
));
}
}
}
Err(e) => {
return attempt.error(format!("Failed to resolve redirect host {}: {}", host, e));
}
}
attempt.follow()
});关键设计点:
- 每次重定向都独立执行完整的 SSRF 检查链(协议 + 主机名 + DNS + IP)
- 即使初始 URL 合法,重定向到内网地址也会被拦截
- DNS Rebinding 在重定向场景中同样有效,因为每次重定向都会重新解析并校验
Zephyr 使用加密存储保护敏感数据(订阅 URL、流量信息等),确保即使配置文件被复制或泄露,攻击者也无法获取明文数据。加密密钥与机器硬件指纹绑定,防止跨设备解密。
| 参数 | 值 | 说明 |
|---|---|---|
| 加密算法 | AES-256-GCM | 认证加密(AEAD),同时保证机密性、完整性和真实性 |
| 密钥长度 | 256 位(32 字节) | AES-256 的标准密钥长度 |
| Nonce 长度 | 96 位(12 字节) | AES-GCM 推荐的 96-bit Nonce |
| 认证标签 | 128 位(16 字节) | GCM 模式自动附加 |
| 密钥派生 | PBKDF2-HMAC-SHA256 | 从机器指纹派生确定性密钥 |
| 迭代次数 | 100,000 次 | 有效抵御暴力破解 |
| 盐值 | Zephyr_AES256_Key_Derivation |
固定盐值(非秘密,用于防止彩虹表) |
密钥派生采用三级回退策略,优先使用硬件指纹生成确定性密钥。下图展示了完整的密钥派生链路:
%%{init: {'themeVariables': {'fontSize': '9px', 'nodeBorder': '1px', 'clusterBorder': '1px', 'edgeLabelHeight': '8px'}, 'flowchart': {'curve': 'basis', 'nodeSpacing': 12, 'rankSpacing': 15}}}%%
flowchart TD
A["收集机器指纹"] --> B{"操作系统检测"}
B -- "Windows" --> C1["注册表读取<br/>HKLM\\SOFTWARE\\Microsoft\\Cryptography<br/>MachineGuid"]
B -- "macOS" --> C2["ioreg -rd1 -c<br/>IOPlatformExpertDevice<br/>IOPlatformUUID + SerialNumber"]
B -- "Linux" --> C3["/etc/machine-id<br/>+<br/>/sys/class/dmi/id/board_serial"]
C1 --> D1["卷序列号<br/>vol C: 提取"]
C2 --> D2["序列号提取"]
C3 --> D3["board_serial 读取"]
D1 --> E["指纹拼接<br/>seed_parts.join('|')"]
D2 --> E
D3 --> E
E --> F["PBKDF2-HMAC-SHA256<br/>100,000 次迭代<br/>盐值: Zephyr_AES256_Key_Derivation"]
F --> G["32 字节密钥<br/>AES-256 密钥"]
G --> H{"密钥长度 = 32?"}
H -- "否" --> R["Fail-closed<br/>返回空字符串<br/>拒绝加密操作"]
H -- "是" --> I["OnceLock 缓存密钥<br/>避免重复 PBKDF2 计算"]
I --> J["持久化到平台路径"]
J --> K1["Windows:<br/>%APPDATA%/Zephyr/.machine_key"]
J --> K2["macOS:<br/>~/Library/Application Support/Zephyr/.machine_key"]
J --> K3["Linux:<br/>$XDG_CONFIG_HOME/Zephyr/.machine_key<br/>~/.config/Zephyr/.machine_key"]
style A fill:#3498db,stroke:#2980b9,color:#fff
style G fill:#2ecc71,stroke:#27ae60,color:#fff
style R fill:#e74c3c,stroke:#c0392b,color:#fff
加密后的数据使用以下格式存储,Base64 编码:
┌────────┬──────────────┬──────────────┬──────────────┐
│ "v2:" │ Nonce (12B) │ Ciphertext │ AuthTag(16B) │
│ 3 字节 │ 12 字节 │ N 字节 │ 16 字节 │
└────────┴──────────────┴──────────────┴──────────────┘
│◄──────────── Base64 编码 ──────────────────────────►│
对应的 Rust 数据结构表示:
// v2 加密格式伪代码
// Base64( "v2:" + nonce[0..12] + ciphertext[N] + auth_tag[0..16] )
//
// 其中:
// - "v2:" : 3 字节版本前缀,用于未来算法升级的向后兼容
// - nonce[0..12] : 12 字节随机 Nonce(每次加密唯一生成)
// - ciphertext[N] : N 字节密文(AES-GCM 加密后的数据)
// - auth_tag[0..16]: 16 字节认证标签(GCM 模式自动附加)
//
// 总最小长度: 3 + 12 + 0 + 16 = 31 字节(空明文)版本前缀 v2: 用于未来算法升级时的向后兼容。
fn obfuscate_string(s: &str) -> String {
use aes_gcm::{aead::{Aead, KeyInit}, Aes256Gcm, Nonce};
let key_bytes = get_machine_key();
// Fail-closed:密钥长度异常时返回空字符串而非降级
if key_bytes.len() != 32 {
eprintln!("[Security] CRITICAL: Invalid key length {}, expected 32", key_bytes.len());
return String::new();
}
let cipher = Aes256Gcm::new_from_slice(&key_bytes)?;
let nonce_bytes: [u8; 12] = rand::thread_rng().gen();
let nonce = Nonce::from_slice(&nonce_bytes);
// AES-256-GCM 加密(自动附加 16 字节认证标签)
let ciphertext = cipher.encrypt(nonce, s.as_bytes())?;
// 拼接:版本前缀 + Nonce + 密文(含认证标签)
let mut result = b"v2:".to_vec();
result.extend(&nonce_bytes);
result.extend(ciphertext);
base64::engine::general_purpose::STANDARD.encode(&result)
}fn deobfuscate_string(s: &str) -> String {
use aes_gcm::{aead::{Aead, KeyInit}, Aes256Gcm, Nonce};
let decoded = base64::engine::general_purpose::STANDARD.decode(s)?;
// 版本校验
if !decoded.starts_with(b"v2:") {
return String::new(); // 未知版本,拒绝解密
}
// 最小长度校验:v2:(3) + nonce(12) + auth_tag(16) = 31 字节
if decoded.len() < 31 {
return String::new();
}
// 提取 Nonce 和密文
let nonce_bytes = &decoded[3..15];
let ciphertext = &decoded[15..];
let cipher = Aes256Gcm::new_from_slice(&get_machine_key())?;
let nonce = Nonce::from_slice(nonce_bytes);
// 解密并验证认证标签(GCM 模式自动完成)
match cipher.decrypt(nonce, ciphertext) {
Ok(plaintext) => String::from_utf8_lossy(&plaintext).to_string(),
Err(_) => {
// 认证标签验证失败 → 数据被篡改
eprintln!("[Security] CRITICAL: AES decryption failed - data may be tampered");
String::new()
}
}
}派生后的密钥通过 OnceLock 缓存在内存中,避免重复执行昂贵的 PBKDF2 计算:
static DERIVED_KEY: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
fn get_machine_key() -> Vec<u8> {
DERIVED_KEY.get().cloned().unwrap_or_else(|| {
let key = compute_machine_key();
let _ = DERIVED_KEY.set(key.clone());
key
})
}三级回退策略:
| 优先级 | 策略 | 说明 |
|---|---|---|
| 第一优先级 | 硬件指纹 → PBKDF2 | 确定性密钥,跨重启稳定 |
| 第二优先级 | 持久化随机密钥 | 存储在平台配置目录,0o600 权限 |
| 第三优先级 | 可执行文件目录回退 | current_exe().parent()/.machine_key |
| 最终回退 | 会话密钥(仅内存) | 数据在重启后丢失,is_machine_key_persisted() 返回 false
|
加密存储主要用于保护 metadata.json 中的敏感字段:
fn save_metadata(paths: &AppPaths, meta: &ProfilesMetadata) {
let mut obf_meta = ProfilesMetadata::default();
for (k, v) in &meta.configs {
obf_meta.configs.insert(k.clone(), ConfigMetadata {
url: v.url.as_ref().map(|s| obfuscate_string(s)), // 订阅 URL 加密
sub_info: v.sub_info.as_ref().map(|s| obfuscate_string(s)), // 流量信息加密
});
}
// 写入 metadata.json(使用 write_file_secure 确保文件权限)
write_file_secure(&meta_path, &data);
}解密时通过启发式判断字段是否为加密数据:
// URL 判断:不以 "http" 开头则尝试解密
if !url.starts_with("http") {
config.url = Some(deobfuscate_string(url));
}
// 流量信息判断:不包含 ';' 则尝试解密
// (明文格式为 upload=X; download=Y; total=Z; expire=T)
if !info.contains(';') {
config.sub_info = Some(deobfuscate_string(info));
}路径遍历(Path Traversal)攻击试图通过 ../、编码绕过、空字节注入等手段访问或写入预期目录之外的文件。Zephyr 在所有文件操作入口实施了多层防护。
sanitize_config_file_name 函数对所有用户提供的文件名执行完整的净化流程,下图展示了每一步的检查逻辑:
%%{init: {'themeVariables': {'fontSize': '9px', 'nodeBorder': '1px', 'clusterBorder': '1px', 'edgeLabelHeight': '8px'}, 'flowchart': {'curve': 'basis', 'nodeSpacing': 12, 'rankSpacing': 15}}}%%
flowchart TD
A["输入路径<br/>config_path"] --> B["5 轮 URL 解码<br/>url_decode_complete()<br/>处理 %252e%252e%252f 等嵌套编码"]
B --> C["提取文件名<br/>Path::new().file_name()<br/>去除所有目录路径成分"]
C --> D{"检查 ..<br/>路径遍历字符?"}
D -- "包含 .." --> R1["拒绝<br/>Path traversal detected<br/>.. is not allowed"]
D -- "不包含" --> E{"检查 / 和 \\<br/>目录分隔符?"}
E -- "包含分隔符" --> R2["拒绝<br/>Path traversal detected<br/>directory separators<br/>not allowed"]
E -- "不包含" --> F{"检查 \\0<br/>空字节?"}
F -- "包含空字节" --> R3["拒绝<br/>Invalid character<br/>null byte detected"]
F -- "不包含" --> G{"检查控制字符<br/>is_control()?"}
G -- "包含控制字符" --> R4["拒绝<br/>Invalid character<br/>control characters<br/>not allowed"]
G -- "不包含" --> H{"扩展名白名单<br/>.yaml / .yml?"}
H -- "非白名单扩展名" --> R5["拒绝<br/>Invalid file type<br/>only .yaml and .yml<br/>permitted"]
H -- "白名单扩展名" --> I{"长度 <= 255?"}
I -- "超过 255" --> R6["拒绝<br/>Filename too long<br/>maximum 255 characters"]
I -- "长度合规" --> J{"Windows 保留名?<br/>CON/PRN/AUX/NUL<br/>COM1-9/LPT1-9"}
J -- "是保留名" --> R7["拒绝<br/>Reserved filename<br/>is not allowed"]
J -- "非保留名" --> K["通过<br/>返回安全文件名"]
style A fill:#3498db,stroke:#2980b9,color:#fff
style K fill:#2ecc71,stroke:#27ae60,color:#fff
style R1 fill:#e74c3c,stroke:#c0392b,color:#fff
style R2 fill:#e74c3c,stroke:#c0392b,color:#fff
style R3 fill:#e74c3c,stroke:#c0392b,color:#fff
style R4 fill:#e74c3c,stroke:#c0392b,color:#fff
style R5 fill:#e74c3c,stroke:#c0392b,color:#fff
style R6 fill:#e74c3c,stroke:#c0392b,color:#fff
style R7 fill:#e74c3c,stroke:#c0392b,color:#fff
fn sanitize_config_file_name(config_path: &str) -> Result<String, String> {
// Step 1: 5 轮 URL 解码,处理嵌套编码
let decoded_path = url_decode_complete(config_path);
// Step 2: 提取纯文件名
let config_file_name = Path::new(&decoded_path)
.file_name()
.ok_or("Invalid config path: no filename component")?
.to_str()
.ok_or("Invalid config filename encoding")?
.to_string();
// Step 3: 路径遍历检测
if config_file_name.contains("..") {
return Err("Path traversal detected: '..' is not allowed");
}
// Step 4: 目录分隔符检测
if config_file_name.contains('/') || config_file_name.contains('\\') {
return Err("Path traversal detected: directory separators not allowed");
}
// Step 5: 空字节检测(防止截断攻击)
if config_file_name.contains('\0') {
return Err("Invalid character: null byte detected");
}
// Step 5b: 控制字符检测
if config_file_name.chars().any(|c| c.is_control()) {
return Err("Invalid character: control characters not allowed");
}
// Step 6: 扩展名白名单
let lower_name = config_file_name.to_lowercase();
if !lower_name.ends_with(".yaml") && !lower_name.ends_with(".yml") {
return Err("Invalid file type: only .yaml and .yml permitted");
}
// Step 7a: 长度限制
if config_file_name.len() > 255 {
return Err("Filename too long: maximum 255 characters");
}
// Step 7b: Windows 保留名检测(跨平台一致性)
let reserved_names = [
"CON", "PRN", "AUX", "NUL",
"COM1".."COM9", "LPT1".."LPT9",
];
if reserved_names.contains(&base_name) {
return Err(format!("Reserved filename: '{}' is not allowed", config_file_name));
}
Ok(config_file_name)
}url_decode_complete 函数通过迭代解码处理多层 URL 编码攻击。攻击者可能使用 %252e%252e%252f(双重编码的 ../)绕过单次解码检查:
fn url_decode_complete(input: &str) -> String {
let mut result = input.to_string();
let mut changed = true;
let max_iterations = 5; // 防止无限循环
let mut iterations = 0;
while changed && iterations < max_iterations {
changed = false;
iterations += 1;
let mut decoded = String::new();
let chars: Vec<char> = result.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars.get(i) == Some(&'%') && i + 2 < chars.len() {
if let Some(hex_chars) = chars.get(i + 1..i + 3) {
let hex: String = hex_chars.iter().collect();
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
decoded.push(byte as char);
i += 3;
changed = true;
continue;
}
}
}
decoded.push(chars[i]);
i += 1;
}
result = decoded;
}
result
}validate_path_within_dir 函数确保解析后的路径始终在预期目录内:
fn validate_path_within_dir(resolved_path: &Path, base_dir: &Path) -> Result<(), String> {
if resolved_path.exists() {
// 文件已存在:使用 canonicalize 解析符号链接后比较
let canonical_resolved = resolved_path.canonicalize()?;
let canonical_base = base_dir.canonicalize()?;
if !canonical_resolved.starts_with(&canonical_base) {
return Err("Path traversal detected: resolved path outside allowed directory");
}
} else {
// 文件不存在:字符串级别前缀检查
let resolved_normalized = resolved_path.to_string_lossy().replace('\\', "/");
let base_normalized = base_dir.to_string_lossy().replace('\\', "/");
if !resolved_normalized.starts_with(&*base_normalized) {
return Err("Path traversal detected: resolved path outside allowed directory");
}
}
Ok(())
}双模式设计:对于已存在的文件使用 canonicalize(解析所有符号链接和 ..),对于新文件使用字符串前缀检查(因为 canonicalize 要求文件存在)。
ZIP 和 TAR.GZ 归档文件解压时,对每个条目的路径进行安全检查:
// ZIP 解压安全检查
for i in 0..archive.len() {
let file = archive.by_index(i)?;
let name = file.name();
// 拦截路径遍历和绝对路径
if name.contains("..") || name.starts_with('/') || name.starts_with('\\') {
return Err(format!("Malicious ZIP path detected: {}", name));
}
// ...
}
// TAR 解压安全检查
for entry in archive.entries()? {
let path = entry.path()?;
let path_str = path.to_string_lossy().replace('\\', "/");
if path_str.contains("..") || path_str.starts_with('/') {
return Err(format!("Malicious TAR path detected: {}", path_str));
}
// ...
}Mihomo 配置文件支持 script 和 script-path 字段,这些字段可能导致任意代码执行。Zephyr 在加载任何外部配置(订阅下载、文件导入)时,自动递归清除这些危险字段。
remove_dangerous_keys 函数采用递归遍历策略,确保嵌套在任意深度的危险字段都被移除:
pub(crate) fn remove_dangerous_keys(
value: &mut serde_yaml::Value,
in_provider_context: bool,
) {
match value {
serde_yaml::Value::Mapping(map) => {
// === 始终移除的字段(全局生效) ===
// script: 可执行任意 JavaScript 代码
// script-path: 可加载外部脚本文件
for key in ["script", "script-path"] {
map.remove(serde_yaml::Value::String(key.to_string()));
}
// === Provider 上下文条件移除 ===
// Provider 同时包含 type + url/path 字段时判定为 Provider
let is_provider = map.contains_key(&"type".into())
&& (map.contains_key(&"url".into())
|| map.contains_key(&"path".into()));
// 在 Provider 上下文中移除 path 字段(防止路径遍历)
if in_provider_context || is_provider {
map.remove(serde_yaml::Value::String("path".to_string()));
}
// 递归处理所有子节点
for (_, v) in map.iter_mut() {
remove_dangerous_keys(v, is_provider);
}
}
serde_yaml::Value::Sequence(seq) => {
for item in seq.iter_mut() {
remove_dangerous_keys(item, in_provider_context);
}
}
_ => {}
}
}下图展示了 remove_dangerous_keys 函数的完整递归处理流程:
%%{init: {'themeVariables': {'fontSize': '9px', 'nodeBorder': '1px', 'clusterBorder': '1px', 'edgeLabelHeight': '8px'}, 'flowchart': {'curve': 'basis', 'nodeSpacing': 12, 'rankSpacing': 15}}}%%
flowchart TD
A["输入 YAML Value"] --> B{"类型为 Mapping?"}
B -- "是" --> C["遍历所有键值对"]
C --> D["移除 script / script-path<br/>(始终移除)"]
D --> E{"当前处于<br/>Provider 上下文?"}
E -- "是" --> F["移除 path 字段"]
E -- "否" --> G["检测 is_provider<br/>(type + url/path)"]
F --> G
G --> H{"是 Provider?"}
H -- "是" --> I["递归处理所有子值<br/>remove_dangerous_keys(v, is_provider)"]
H -- "否" --> I
I --> J{"类型为 Sequence?"}
J -- "是" --> K["遍历所有元素"]
K --> L["递归处理每个元素<br/>remove_dangerous_keys(item, in_provider)"]
L --> J
J -- "否" --> M["其他类型 → 跳过"]
B -- "否" --> J
style A fill:#3498db,stroke:#2980b9,color:#fff
style D fill:#e74c3c,stroke:#c0392b,color:#fff
style F fill:#e74c3c,stroke:#c0392b,color:#fff
style I fill:#2ecc71,stroke:#27ae60,color:#fff
style M fill:#95a5a6,stroke:#7f8c8d,color:#fff
| 字段 | 移除条件 | 风险等级 | 说明 |
|---|---|---|---|
script |
始终移除 | 严重 | 可执行任意 JavaScript,导致 RCE |
script-path |
始终移除 | 严重 | 可加载外部脚本文件,导致 RCE |
path(Provider 上下文) |
Provider 内移除 | 高 | 可指向任意文件系统路径,导致信息泄露或配置注入 |
prepare_runtime_config 函数在每次启动内核时强制覆盖关键安全设置:
fn prepare_runtime_config(content: &str, secret: &str) -> Option<(String, u16)> {
let mut yaml_val = serde_yaml::from_str(content).ok()?;
if let Some(mapping) = yaml_val.as_mapping_mut() {
// 强制绑定到 127.0.0.1(防止外部访问 API)
mapping.insert(
"external-controller",
format!("127.0.0.1:{}", config_port),
);
// 注入随机 secret(防止未授权 API 访问)
mapping.insert("secret", secret.to_string());
}
}update_config 函数在合并用户配置后恢复安全关键设置:
// SECURITY: Restore critical security settings after merge
if let YamlValue::Mapping(ref mut map) = current_yaml {
// 恢复 external-controller 为 localhost 绑定
map.insert("external-controller",
YamlValue::String(format!("127.0.0.1:{}", port)));
// 恢复 secret(防止移除认证)
map.insert("secret", YamlValue::String(secret.clone()));
// 保护 TUN 状态(仅允许通过 UI 切换)
if !tun_enabled_before {
// TUN 原本禁用,确保合并后仍为禁用
}
}用户提供的自定义启动参数经过安全过滤,阻止覆盖安全关键配置:
fn validate_custom_args(custom_args: &[String]) -> Result<Vec<String>, String> {
let blocked_args = [
"-d", "--directory", // 工作目录(路径遍历)
"-f", "--config", // 配置文件路径(绕过安全检查)
"-ext-ctl", "--external-controller", // API 监听地址(绑定 0.0.0.0)
"-secret", "--secret", // API 认证密钥(绕过认证)
];
// ...
}Zephyr 从 GitHub 下载 Mihomo 内核和 GeoIP/GeoSite 数据库。供应链攻击可能通过以下方式发生:恶意更新包、DNS 劫持、中间人攻击、版本回退等。Zephyr 实施了完整的供应链安全链。
下图展示了从用户触发更新到内核替换完成的完整安全链路:
%%{init: {'themeVariables': {'fontSize': '10px', 'actorTextColor': '#ccc', 'signalColor': '#666', 'labelTextColor': '#999', 'labelBoxBkgColor': '#333', 'labelBoxBorderColor': '#555', 'lineColor': '#666', 'actorBkg': '#333', 'actorBorder': '#555', 'actorTextColor': '#ccc', 'signalTextColor': '#ccc', 'noteBkgColor': '#333', 'noteTextColor': '#ccc', 'noteBorderColor': '#555'}}}%%
sequenceDiagram
participant UI as 前端 UI
participant UC as update_core<br/>Tauri Command
participant Parse as URL 解析<br/>parse_github_release_info
participant Verify as 版本验证<br/>validate_version_format
participant DL as 下载模块<br/>download_release_asset
participant SHA as SHA256 校验<br/>verify_sha256
participant Extract as 解压模块<br/>extract_core_binary
participant Core as Mihomo 内核
UI->>UC: 触发核心更新
UC->>Parse: 解析更新 URL
Parse->>Parse: 域名白名单检查<br/>github.com
Parse->>Parse: 路径格式校验<br/>/MetaCubeX/mihomo/releases/download/...
Parse-->>UC: 返回 (version, asset_name)
UC->>Verify: 版本格式验证
Note right of Verify: vX.Y.Z 语义化版本<br/>长度 3-25 字符<br/>禁止路径/命令注入字符
Verify-->>UC: 验证通过
UC->>DL: 下载更新包
Note right of DL: UUID 临时文件名<br/>core_update_{uuid}.tmp<br/>100MB 大小限制
DL-->>UC: 下载完成
UC->>SHA: SHA256 完整性校验
Note right of SHA: 从 GitHub API 获取<br/>预期 digest (sha256:...)<br/>流式计算本地文件哈希
SHA-->>UC: 校验通过
UC->>Extract: 解压到临时文件
Note right of Extract: ZIP/TAR 路径遍历检查<br/>GZ 解压 200MB 限制<br/>临时文件: {name}_{uuid}.tmp
Extract-->>UC: 解压完成
UC->>Core: 停止当前内核
Core-->>UC: 内核已停止
UC->>UC: 替换二进制文件
Note right of UC: rename() 优先<br/>失败则 copy()<br/>最多 5 次重试<br/>每次间隔 500ms
UC->>Core: 重启内核
Core-->>UC: 内核已启动
UC-->>UI: 更新完成
仅允许从以下域名下载更新:
const TRUSTED_HOSTS: [&str; 3] = [
"github.com",
"api.github.com",
"objects.githubusercontent.com",
];
fn is_trusted_update_url(url: &str) -> bool {
if let Ok(parsed) = reqwest::Url::parse(url) {
if let Some(host) = parsed.host_str() {
return TRUSTED_HOSTS.iter()
.any(|&h| host == h || host.ends_with(&format!(".{}", h)));
}
}
false
}下载完成后,从 GitHub API 的 digest 字段获取预期哈希值,与本地文件比对:
async fn get_expected_sha256(version: &str, asset_name: &str) -> Result<String, String> {
let api_url = format!(
"https://api.github.com/repos/MetaCubeX/mihomo/releases/tags/{}",
version
);
// ...
for asset in assets {
if name == asset_name {
if let Some(digest) = asset["digest"].as_str() {
if digest.starts_with("sha256:") {
let hash = digest.strip_prefix("sha256:")?;
// 验证哈希格式:64 个十六进制字符
if hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Ok(hash.to_lowercase());
}
}
}
}
}
}
fn verify_sha256(file_path: &Path, expected_hash: &str) -> Result<(), String> {
let mut hasher = Sha256::new();
let mut buffer = [0u8; 8192];
// 流式计算,支持大文件
loop {
let n = file.read(&mut buffer)?;
if n == 0 { break; }
hasher.update(&buffer[..n]);
}
let result = hasher.finalize();
let hex_result = hex::encode(result);
if hex_result.to_lowercase() == expected_hash.to_lowercase() {
Ok(())
} else {
Err(format!("SHA256 mismatch: expected {}, got {}", expected_hash, hex_result))
}
}版本字符串经过严格格式校验,防止路径遍历和命令注入:
fn validate_version_format(version: &str) -> bool {
// 必须以 'v' 前缀开头
if !version.starts_with('v') { return false; }
// 长度限制:3-25 字符
if version.len() < 3 || version.len() > 25 { return false; }
// 禁止危险字符(路径遍历、命令注入、HTML 注入)
if version.contains("..") || version.contains('/') || version.contains('\\')
|| version.contains('\0') || version.contains('<') || version.contains('>')
|| version.contains('|') || version.contains('&') || version.contains(';')
|| version.contains('$') || version.contains('`') || version.contains('\n')
|| version.contains('\r')
{
return false;
}
// 必须符合 vX.Y.Z 语义化版本格式
let parts: Vec<&str> = version[1..].split('.').collect();
if parts.len() != 3 { return false; }
for part in parts {
if part.is_empty() || !part.chars().all(|c| c.is_ascii_digit()) {
return false;
}
}
true
}更新 URL 被严格解析,确保仅允许官方 MetaCubeX/mihomo 仓库的特定路径格式:
fn parse_github_release_info(url: &str) -> Option<(String, String)> {
let parsed = reqwest::Url::parse(url).ok()?;
// 仅允许 github.com 域名
if parsed.host_str() != Some("github.com") {
return None;
}
let segments: Vec<&str> = parsed.path_segments()?.collect();
// 路径格式: /MetaCubeX/mihomo/releases/download/{version}/{asset}
if segments.len() >= 5
&& segments[0] == "MetaCubeX"
&& segments[1] == "mihomo"
&& segments[2] == "releases"
&& segments[3] == "download"
{
let version = segments[4];
let asset_name = segments[5];
// 版本格式校验
if !validate_version_format(version) { return None; }
// 资产名称校验
if !asset_name.to_lowercase().starts_with("mihomo-") { return None; }
if !asset_name.to_lowercase().ends_with(".zip")
&& !asset_name.to_lowercase().ends_with(".gz")
{
return None;
}
return Some((version.to_string(), asset_name.to_string()));
}
None
}| 场景 | 大小限制 | 说明 |
|---|---|---|
| 更新包下载 | 100 MB |
download_release_asset 中检查 content_length 和累计字节数 |
| GZ 解压 | 200 MB |
extract_from_gz 中跟踪解压后总大小 |
| 订阅响应 | 10 MB |
read_response_body 中检查 content_length 和累计字节数 |
使用 UUID 生成不可预测的临时文件名,防止符号链接竞争攻击:
// 不可预测的临时文件名
let temp_suffix = uuid::Uuid::new_v4().to_string();
let archive_path = paths.core_dir.join(format!("core_update_{}.tmp", temp_suffix));
let temp_exe_path = paths.core_dir.join(format!("{}_{}.tmp", core_binary_name(), temp_suffix));Windows 系统可能因文件锁定导致替换失败。Zephyr 实现了最多 5 次重试机制,每次间隔 500ms:
let mut retries = 5;
loop {
if let Err(_) = std::fs::rename(&temp_exe_path, &exe_path) {
if let Err(e) = std::fs::copy(&temp_exe_path, &exe_path) {
retries -= 1;
if retries == 0 {
// 清理临时文件并返回错误
let _ = std::fs::remove_file(&temp_exe_path);
return Err(format!("Failed to replace core binary: {}", e));
}
tokio::time::sleep(Duration::from_millis(500)).await;
} else {
break; // copy 成功
}
} else {
break; // rename 成功
}
}关键 Tauri Command 实施频率限制,防止命令被滥用或误操作导致系统不稳定。
pub struct RateLimiter {
calls: Mutex<HashMap<String, Instant>>,
}
impl RateLimiter {
pub fn check_rate_limit(&self, command: &str, min_interval_ms: u64) -> bool {
let mut calls = self.calls.lock().unwrap();
let now = Instant::now();
// 清理 60 秒前的过期条目,防止内存无限增长
calls.retain(|_, last_call| {
now.duration_since(*last_call) < Duration::from_secs(60)
});
if let Some(last_call) = calls.get(command) {
if now.duration_since(*last_call) < Duration::from_millis(min_interval_ms) {
return false; // 被限流
}
}
calls.insert(command.to_string(), now);
true
}
}
// 便捷宏
macro_rules! rate_limit {
($limiter:expr, $cmd:expr, $ms:expr) => {
if !$limiter.check_rate_limit($cmd, $ms) {
return Err(format!("{} rate limited, please wait", $cmd));
}
};
}下图展示了 RateLimiter.check_rate_limit() 的完整决策流程,不同命令使用不同的冷却时间:
%%{init: {'themeVariables': {'fontSize': '9px', 'nodeBorder': '1px', 'clusterBorder': '1px', 'edgeLabelHeight': '8px'}, 'flowchart': {'curve': 'basis', 'nodeSpacing': 12, 'rankSpacing': 15}}}%%
flowchart TD
A["命令调用"] --> B["RateLimiter.check_rate_limit()"]
B --> C["清理 60 秒前过期条目<br/>retain(|_, last_call|)"]
C --> D{"该命令有记录?"}
D -- "否" --> E["记录调用时间<br/>calls.insert(command, now)"]
E --> F["允许执行"]
D -- "是" --> G{"距上次调用 >= 冷却时间?"}
G -- "是" --> H["更新调用时间"]
H --> F
G -- "否" --> I["拒绝<br/>(rate limited)"]
subgraph 冷却时间配置
C1["start_core<br/>冷却: 3s"]
C2["download_sub<br/>冷却: 5s"]
C3["exempt_uwp_apps<br/>冷却: 5min"]
end
style A fill:#3498db,stroke:#2980b9,color:#fff
style F fill:#2ecc71,stroke:#27ae60,color:#fff
style I fill:#e74c3c,stroke:#c0392b,color:#fff
style C1 fill:#f39c12,stroke:#e67e22,color:#fff
style C2 fill:#f39c12,stroke:#e67e22,color:#fff
style C3 fill:#9b59b6,stroke:#8e44ad,color:#fff
| 命令 | 限流间隔 | 冷却时间 | 说明 |
|---|---|---|---|
start_core |
3 秒 | 60 秒(条目过期) | 防止内核频繁启停导致端口冲突和资源泄漏 |
download_sub |
5 秒 | 60 秒 | 防止订阅下载过于频繁,避免对订阅服务器造成压力 |
exempt_uwp_apps |
5 分钟 | 60 秒 | UWP 环回免除涉及系统级操作(修改 CheckNetIsolation),需较长冷却。注意:此命令使用独立的 AtomicU64 机制(UWP_OPERATION_COOLDOWN_SECS = 300),而非通用 RateLimiter
|
限流器使用 HashMap<String, Instant> 存储调用记录,并通过 retain 方法定期清理 60 秒前的过期条目,确保内存使用有界。
敏感配置文件(包含加密密钥、订阅 URL、API secret 等)必须限制访问权限,防止本地其他用户或进程读取。
write_file_secure 函数在每次写入文件后立即设置严格的访问权限:
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
}0o600(rw-------):仅文件所有者可读写,其他用户无任何权限。
Windows 使用 DACL(Discretionary Access Control List)实现等效的权限控制:
#[cfg(target_os = "windows")]
{
use windows_sys::Win32::Security::Authorization::*;
use windows_sys::Win32::Security::*;
// 构建两条 ACL 规则:
let mut ea: [EXPLICIT_ACCESS_W; 2] = [std::mem::zeroed(), std::mem::zeroed()];
// 规则 1: 当前用户 → 完全访问 (GENERIC_ALL)
ea[0].grfAccessPermissions = GENERIC_ALL;
ea[0].grfAccessMode = GRANT_ACCESS;
ea[0].grfInheritance = NO_INHERITANCE;
ea[0].Trustee.TrusteeForm = TRUSTEE_IS_SID;
ea[0].Trustee.TrusteeType = TRUSTEE_IS_USER;
ea[0].Trustee.ptstrName = token_user.User.Sid;
// 规则 2: SYSTEM 账户 → 完全访问 (GENERIC_ALL)
// SYSTEM 是 Windows 服务所需的访问权限
ea[1].grfAccessPermissions = GENERIC_ALL;
ea[1].grfAccessMode = GRANT_ACCESS;
ea[1].grfInheritance = NO_INHERITANCE;
ea[1].Trustee.TrusteeForm = TRUSTEE_IS_SID;
ea[1].Trustee.TrusteeType = TRUSTEE_IS_WELL_KNOWN_GROUP;
ea[1].Trustee.ptstrName = system_sid;
// 应用 DACL(PROTECTED_DACL_SECURITY_INFORMATION 阻止继承)
SetSecurityInfo(
handle,
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
null, null,
new_acl, // 仅包含上述两条规则
null,
);
}| 平台 | 权限模型 | 效果 |
|---|---|---|
| Unix | 0o600 |
仅所有者可读写 |
| Windows | DACL(Owner + SYSTEM) | 当前用户和 SYSTEM 完全访问,其他用户无权限 |
注意:Windows 管理员始终可以通过获取文件所有权来访问文件。这与 Unix 的 root 权限类似 --
0o600保护的是普通用户之间的隔离,而非抵御特权账户。
以下文件均通过 write_file_secure 写入,自动获得严格的权限设置:
| 文件 | 内容 | 敏感级别 |
|---|---|---|
metadata.json |
加密的订阅 URL 和流量信息 | 高 |
.machine_key |
机器密钥(用于 AES 加密) | 极高 |
settings.json |
用户设置(含自定义参数) | 中 |
run_config.yaml |
运行时配置(含 API secret) | 高 |
| 所有配置文件 | 用户导入的代理配置 | 中 |
Mihomo 内核的 RESTful API 强制绑定到 127.0.0.1,防止局域网内的其他设备访问:
// prepare_runtime_config 强制覆盖
mapping.insert(
"external-controller",
serde_yaml::Value::String(format!("127.0.0.1:{}", config_port)),
);每次启动内核时自动生成随机 secret 并注入配置,防止未授权 API 访问:
// 生成 32 字节随机 secret
let secret: String = thread_rng()
.sample_iter(&Alphanumeric)
.take(32)
.collect();read_config 函数在向前端返回配置时自动剥离 secret 字段,防止 API 凭证泄露到 WebView:
pub fn read_config(app: AppHandle) -> Result<JsonValue, String> {
// ...
if let YamlValue::Mapping(ref mut map) = yaml_val {
map.remove(YamlValue::String("secret".to_string()));
}
// ...
}merge_yaml 函数限制递归深度为 50 层,防止栈溢出攻击:
fn merge_yaml(base: &mut YamlValue, patch: &YamlValue, depth: usize) -> Result<(), String> {
if depth > 50 {
return Err("YAML nesting depth exceeded limit".to_string());
}
// ...
}使用 AtomicBool 锁防止内核并发启停:
static CORE_STARTING: AtomicBool = AtomicBool::new(false);
// 带超时的自旋锁(最多等待 10 秒)
let mut wait_ms = 0;
while CORE_STARTING.compare_exchange(false, true, ...).is_err() {
if wait_ms > 10000 {
CORE_STARTING.store(false, Ordering::SeqCst); // 超时强制重置
// ...
}
tokio::time::sleep(Duration::from_millis(200)).await;
wait_ms += 200;
}全局 panic hook 确保异常退出时清理子进程:
std::panic::set_hook(Box::new(move |info| {
eprintln!("[PANIC] Application panicked, cleaning up child processes...");
crate::core_manager::kill_mihomo();
default_panic(info);
}));app.run(|_handle, event| {
if let tauri::RunEvent::Exit = event {
kill_mihomo(); // 终止普通进程
let _ = smart_kill_all_mihomo_as_root(); // 终止 root 进程(如 TUN 模式)
}
});安全策略不仅依赖运行时防护,还通过 CI/CD 流水线在代码合并前强制执行自动化安全扫描,确保安全策略不会被意外绕过。
安全扫描流水线(security.yml)包含 10 个独立 Job,在每次推送、PR 和每日定时任务中触发。下图展示了各 Job 的执行顺序与职责:
%%{init: {'themeVariables': {'fontSize': '10px', 'nodeBorder': '1px', 'clusterBorder': '1px', 'edgeLabelHeight': '10px'}, 'flowchart': {'curve': 'basis', 'nodeSpacing': 15, 'rankSpacing': 25}}}%%
graph LR
subgraph Jobs["CI/CD 安全流水线 — 10 层扫描"]
J1["Job 1<br/>cargo audit<br/>Rust CVE 漏洞扫描"]
J2["Job 2<br/>cargo-deny<br/>许可证/来源检查"]
J3["Job 3<br/>cargo clippy<br/>12 条安全 lint"]
J4["Job 4<br/>cargo fmt<br/>代码格式检查"]
J5["Job 5<br/>npm audit<br/>前端依赖审计"]
J6["Job 6<br/>dependency-review<br/>PR 依赖审查"]
J7["Job 7<br/>semgrep<br/>SAST 静态分析"]
J8["Job 8<br/>自定义密钥检测脚本<br/>密钥泄露检测"]
J9["Job 9<br/>tauri audit<br/>Tauri 配置审计"]
J10["Job 10<br/>build verification<br/>构建 + 单元测试"]
end
J1 --> J2 --> J3 --> J4 --> J5 --> J6 --> J7 --> J8 --> J9 --> J10
style J1 fill:#e74c3c,stroke:#c0392b,color:#fff
style J2 fill:#e67e22,stroke:#d35400,color:#fff
style J3 fill:#f39c12,stroke:#e67e22,color:#fff
style J4 fill:#f1c40f,stroke:#f39c12,color:#333
style J5 fill:#3498db,stroke:#2980b9,color:#fff
style J6 fill:#2ecc71,stroke:#27ae60,color:#fff
style J7 fill:#1abc9c,stroke:#16a085,color:#fff
style J8 fill:#9b59b6,stroke:#8e44ad,color:#fff
style J9 fill:#e91e63,stroke:#c2185b,color:#fff
style J10 fill:#2c3e50,stroke:#1a252f,color:#fff
-
触发条件:push to
main/dev分支、Pull Request、每日定时0 3 * * * - Source:
.github/workflows/security.yml:8-14
流水线的 10 个 Job 按技术栈分为三组:
| Job | 工具 | 说明 | Source |
|---|---|---|---|
| rust-dependency-audit | cargo-audit | Rust Advisory Database 漏洞扫描 | security.yml:39-43 |
| cargo-deny | cargo-deny + deny.toml | 许可证/禁令/来源合规检查 | security.yml:44-47 |
| rust-clippy | clippy | 启用安全相关 Lints | security.yml:48-100 |
| rust-fmt | rustfmt | 代码格式检查 | security.yml |
| Job | 工具 | 说明 | Source |
|---|---|---|---|
| frontend-audit | npm audit --omit=dev --audit-level=moderate | 生产依赖漏洞扫描 | security.yml:133 |
| dependency-review | GitHub Actions | PR 依赖变更审查 | security.yml |
| build-verification | npm run build | 前端构建验证 | security.yml |
| Job | 工具 | 说明 | Source |
|---|---|---|---|
| semgrep | Semgrep --config auto | 标准 SAST 规则 | security.yml |
| secret-scan | 自定义 grep 脚本 | 密钥模式匹配检测 | security.yml:179-206 |
| tauri-security | Tauri security audit | Tauri 权限审计 | security.yml |
| codeql-analysis | CodeQL | 语义代码分析 (security-extended) | codeql.yml:58(独立工作流,不属于 security.yml 流水线) |
CodeQL 补充说明:CodeQL 使用
security-extended查询套件,同时覆盖 JavaScript 和 Rust 两种语言。Rust 分析采用build-mode: none模式,无需完整编译即可进行源码级语义分析。Source:.github/workflows/codeql.yml:57-58
| # | Job 名称 | 工具 | 检查内容 |
|---|---|---|---|
| 1 | Rust Dependency Audit | cargo audit |
Rust 依赖中的已知 CVE 漏洞 |
| 2 | Cargo Deny Check | cargo-deny |
许可证合规、重复 crate、来源验证 |
| 3 | Rust Clippy | cargo clippy |
12 条额外安全/质量 lint 规则 |
| 4 | Rust Format Check | cargo fmt |
代码格式化一致性 |
| 5 | Frontend Dependency Audit | npm audit |
前端依赖漏洞(moderate 级别阻断) |
| 6 | Dependency Review | dependency-review-action |
PR 依赖变更审查(拒绝 GPL/AGPL) |
| 7 | Semgrep SAST Scan | semgrep --config auto |
静态应用安全测试(Semgrep 标准规则集) |
| 8 | Secret Detection | 自定义 grep 脚本 | 密钥和敏感信息泄露检测(grep 模式匹配) |
| 9 | Tauri Security Audit | 自定义脚本 | Tauri 安全配置审计 |
| 10 | Build Verification | cargo build/test |
构建验证 + 单元测试 |
rust-dependency-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
- run: |
cargo install cargo-audit --locked
cargo audit使用 RustSec Advisory Database 检查 Cargo.lock 中所有依赖的已知安全漏洞。
使用项目根目录的 deny.toml 配置文件执行检查(详见依赖许可证治理)。
cargo clippy --all-targets -- -D warnings \
-W clippy::indexing_slicing \
-W clippy::cast_possible_truncation \
-W clippy::cast_possible_wrap \
-W clippy::cast_sign_loss \
-W clippy::default_trait_access \
-W clippy::filetype_is_file \
-W clippy::large_include_file \
-W clippy::shadow_reuse \
-W clippy::implicit_clone \
-W clippy::unnecessary_safety_comment \
-W clippy::unused_async \
-W clippy::todo| 额外规则 | 安全价值 |
|---|---|
indexing_slicing |
防止数组越界 panic |
cast_possible_truncation |
防止数值截断导致的安全问题 |
cast_possible_wrap |
防止数值溢出绕过 |
cast_sign_loss |
防止有符号/无符号转换错误 |
large_include_file |
防止意外包含大文件 |
unused_async |
减少不必要的异步开销 |
npm audit --omit=dev --audit-level=moderate仅检查生产依赖,moderate 级别即阻断构建。
dependency-review:
if: github.event_name == 'pull_request'
steps:
- uses: actions/dependency-review-action@72eb03d02c7872a771aacd928f3123ac62ad6d3a
with:
deny-licenses: GPL-3.0, AGPL-3.0在 PR 中审查新增/变更的依赖,自动拒绝 GPL-3.0 和 AGPL-3.0 许可证的依赖。
采用自定义 grep 脚本进行密钥检测:
Rust 文件密钥模式:
grep -rnE '(password|secret|token|api_key|private_key)\s*[:=]\s*["\x27]' src-tauri/src/JavaScript 文件密钥模式:
grep -rnE '(password|secret|token|api_key)\s*[:=]\s*["\x27]' src/.gitignore 完整性检查:
for pattern in "*.env" "*.pem" "*.key" "*.p12" "id_rsa*" "id_ed25519*" "*.keystore"; do
grep -qF "$pattern" .gitignore
done密钥检测使用自定义 shell 脚本(grep 模式匹配),扫描源代码中的硬编码敏感信息模式。Source: .github/workflows/security.yml:179-206
-
Rust 硬编码凭证:
(password|secret|token|api_key|private_key)\s*[:=]\s*["']— 匹配密码、密钥、Token、API Key 和私钥赋值 -
JS 硬编码凭证:
(password|secret|token|api_key)\s*[:=]\s*["']— 匹配密码、密钥、Token 和 API Key 赋值 -
.gitignore 完整性检查:确保敏感文件模式(
*.env、*.pem、*.key等)已被忽略
%%{init: {'themeVariables': {'fontSize': '9px', 'nodeBorder': '1px', 'clusterBorder': '1px', 'edgeLabelHeight': '8px'}}}%%
graph TD
A["源代码"] --> B["自定义密钥检测脚本<br/>grep 模式匹配"]
B --> C{"Rust 文件匹配?<br/>(password|secret|token|api_key|private_key)"}
C -->|是| D["阻断构建"]
C -->|否| E{"JS 文件匹配?<br/>(password|secret|token|api_key)"}
E -->|是| D
E -->|否| F["通过"]
style D fill:#e74c3c,stroke:#c0392b,color:#fff
style F fill:#2ecc71,stroke:#27ae60,color:#fff
自动化检查 Tauri 安全配置:
| 检查项 | 说明 |
|---|---|
devtools 是否禁用 |
生产构建不应启用开发者工具 |
| CSP WebSocket 通配符 | 警告 ws://127.0.0.1:* 中的端口通配符 |
withGlobalTauri |
警告全局 Tauri API 暴露 |
| capabilities 权限 | 检查通配符 window 权限和 allow-emit
|
Cargo.lock 存在性 |
确保可重现构建 |
package-lock.json 存在性 |
确保前端依赖锁定 |
所有第三方 GitHub Actions 均通过不可变的 commit SHA 固定版本,防止供应链攻击:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
- uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/dependency-review-action@72eb03d02c7872a771aacd928f3123ac62ad6d3aZephyr 定义了 8 条自定义 Semgrep 规则(.semgrep.yml),覆盖 Rust 和 JavaScript 两种语言的安全检测。这些规则可供本地开发使用(semgrep --config .semgrep.yml),但 CI 流水线中的 Semgrep Job 使用 --config auto 运行标准规则集,并未引用此文件。
| 规则 ID | 检测目标 | 严重级别 | CWE |
|---|---|---|---|
rust-osascript-privilege-escalation |
with administrator privileges 模式 |
WARNING | CWE-78(命令注入) |
rust-osascript-command-pattern |
Command::new("osascript") 调用 |
WARNING | CWE-78 |
rust-command-format-arg |
.arg(format!(...)) 模式 |
WARNING | CWE-78 |
rust-unsafe-block |
unsafe { ... } 代码块 |
INFO | CWE-119(内存安全) |
rust-hardcoded-crypto-constant |
命名包含 key/secret/token 的 const
|
WARNING | CWE-321(硬编码密钥) |
| 规则 ID | 检测目标 | 严重级别 | CWE |
|---|---|---|---|
js-innerhtml-assignment |
obj.innerHTML = input 赋值 |
WARNING | CWE-79(XSS) |
js-insertadjacenthtml |
obj.insertAdjacentHTML(pos, html) 调用 |
INFO | CWE-79 |
js-document-write |
document.write(...) 调用 |
WARNING | CWE-79 |
# 检测 osascript 提权
- id: rust-osascript-privilege-escalation
languages: [rust]
message: |
Potential privilege escalation via osascript "with administrator privileges".
If format arguments include user-controlled paths, this could lead to
command injection with root privileges.
severity: WARNING
pattern-regex: 'with administrator privileges'
metadata:
category: security
cwe: "CWE-78"
confidence: MEDIUM
# 检测 innerHTML XSS
- id: js-innerhtml-assignment
languages: [javascript, typescript]
message: |
innerHTML assignment detected. If the value contains user-controlled data,
this could lead to XSS. Use textContent or DOMPurify.sanitize() instead.
severity: WARNING
pattern: |
$OBJ.innerHTML = $INPUT
metadata:
category: security
cwe: "CWE-79"
confidence: MEDIUMdeny.toml 文件定义了依赖管理的安全策略:
[graph]
targets = [
"x86_64-unknown-linux-gnu",
"x86_64-apple-darwin",
"x86_64-pc-windows-msvc",
"aarch64-apple-darwin"
]
[advisories]
ignore = []
unmaintained = "workspace"
[licenses]
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Unicode-DFS-2016",
"Unicode-3.0",
"OpenSSL",
"Zlib",
"MPL-2.0",
"LGPL-3.0", # Tauri / gtk-rs 生态所需
"0BSD",
]
[bans]
multiple-versions = "warn"
wildcards = "deny"
[sources]
unknown-registry = "deny"
unknown-git = "deny"graph.targets 配置确保依赖在所有目标平台上均可解析,防止平台特定的依赖引入安全风险。Source: deny.toml:5
-
x86_64-unknown-linux-gnu(Linux x86_64) -
x86_64-apple-darwin(macOS Intel) -
x86_64-pc-windows-msvc(Windows MSVC) -
aarch64-apple-darwin(macOS Apple Silicon)
仅允许以下 13 种宽松许可证,LGPL-3.0 为 Tauri/gtk-rs 生态特例。Source: deny.toml:13-28
| 许可证 | 说明 |
|---|---|
| MIT | 最宽松的开源许可证 |
| Apache-2.0 | Apache 许可证 2.0 |
| Apache-2.0 WITH LLVM-exception | LLVM 项目使用的 Apache 变体 |
| BSD-2-Clause | 简化 BSD 许可证 |
| BSD-3-Clause | 三条款 BSD 许可证 |
| ISC | ISC 许可证(功能等同 MIT) |
| Unicode-DFS-2016 | Unicode 数据文件许可 |
| Unicode-3.0 | Unicode 许可证 3.0 |
| Zlib | Zlib 许可证 |
| OpenSSL | OpenSSL 许可证(加密库常见) |
| MPL-2.0 | Mozilla 公共许可证 2.0 |
| LGPL-3.0 | Tauri / GTK 生态系统所需 |
| 0BSD | 零条款 BSD(功能等同公共领域) |
| 配置项 | 值 | 说明 | Source |
|---|---|---|---|
bans.wildcards |
deny |
严格禁止 * 通配符版本,防止意外接受破坏性更新 |
deny.toml:32 |
bans.multiple-versions |
warn |
同一 crate 多版本警告,减少攻击面 | deny.toml:31 |
| 配置项 | 值 | 说明 | Source |
|---|---|---|---|
sources.unknown-registry |
deny |
仅允许 crates.io,禁止未知注册表 | deny.toml:35 |
sources.unknown-git |
deny |
禁止未知 Git 依赖来源 | deny.toml:36 |
防依赖混淆攻击:通过严格限制依赖来源为 crates.io,有效防止攻击者通过公共注册表发布恶意同名包来替代内部依赖(Dependency Confusion Attack)。
| 配置项 | 值 | 说明 | Source |
|---|---|---|---|
advisories.vulnerability |
deny(默认) |
安全漏洞直接阻断构建 | deny.toml:9 |
advisories.unmaintained |
workspace |
未维护的 crate 阻断当前工作区 | deny.toml:10 |
advisories.ignore |
[] |
不忽略任何已知漏洞 | deny.toml |
| 攻击向量 | 威胁 | 防护层 | 防护机制 |
|---|---|---|---|
| 订阅 URL SSRF | 访问内网服务 | 第三层 | DNS 预解析 + IP 校验 + DNS Pinning + 重定向链校验 |
| DNS Rebinding | 首次合法,后续解析到内网 | 第三层 | DNS Pinning(reqwest::resolve) |
| 配置注入 | 恶意 YAML 执行代码 | 第六层 | 递归清除 script/script-path/path
|
| 路径遍历 | 读写预期目录外的文件 | 第五层 | 5 轮 URL 解码 + 9 步净化 + 路径边界验证 |
| XSS | 注入恶意前端代码 | 第一层 + 第二层 | CSP(script-src 'self')+ 无 emit 权限 |
| 供应链攻击 | 恶意更新包 | 第七层 | SHA256 校验 + 可信域名 + 版本验证 |
| 数据泄露 | 配置文件被复制 | 第四层 + 第九层 | AES-256-GCM 加密 + 0o600 文件权限 |
| 命令滥用 | 频繁启停内核 | 第八层 | 命令限流(3s/5s/5min) |
| 密钥泄露 | 源码中硬编码凭证 | 第十一层 | 自定义 grep 密钥模式匹配检测 |
| 依赖漏洞 | 第三方库 CVE | 第十一层 | cargo audit + npm audit |
| 全屏钓鱼 | 伪造系统界面 | 第二层 | 未授予 set-fullscreen 权限 |
| 网络隔离突破 | 前端直接访问外网 | 第一层 + 第二层 | CSP connect-src + 无 http 权限 |
- Fail-closed:所有安全检查失败时拒绝操作,而非降级放行
- Defense in Depth:每类威胁都有多层独立防护,单层失效不导致整体突破
- Least Privilege:Tauri 权限、文件权限、API 权限均遵循最小权限原则
- Zero Trust:所有外部输入(URL、配置、文件)均视为不可信,必须经过校验
- Secure by Default:安全默认配置(API 绑定 127.0.0.1、随机 secret、0o600 权限)
返回 首页