Skip to content

Configuration

Juwan-Hwang edited this page Apr 11, 2026 · 7 revisions

安全架构

Zephyr 是基于 Tauri v2 的 Mihomo GUI 代理客户端,实施了多层纵深防御(Defense in Depth)策略,从网络请求校验到文件系统隔离,覆盖全攻击面。本文档以安全审计视角,逐一拆解每一层防护机制的设计意图、实现细节与威胁模型。


目录


安全纵深防御总览

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
Loading

设计原则:每一层独立运作,单层失效不导致整体突破。所有外部输入(URL、配置、文件)均视为不可信,必须经过完整校验链。


第一层:内容安全策略(CSP)

设计意图

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 仅允许从应用包内加载。完全禁止内联脚本(evalonclick 等)、外部 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-inlineunsafe-eval,即使攻击者能在样式中注入内容,也无法执行 JavaScript 代码。


第二层:Tauri 最小权限原则

设计意图

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 防护

威胁模型

SSRF(Server-Side Request Forgery)是代理客户端面临的核心威胁之一。攻击者可能通过以下方式利用 SSRF:

  1. 订阅 URL 注入:诱导用户添加指向内网服务的订阅地址
  2. DNS Rebinding:首次 DNS 解析返回合法 IP,后续解析返回内网 IP
  3. 开放重定向:通过 HTTP 重定向将请求引导至内网
  4. 协议走私:使用 file://gopher:// 等非 HTTP 协议访问本地资源

SSRF 防护决策链

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
Loading

3.1 URL 验证与 DNS Pinning

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);
}

3.2 私有 IP 检测

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
        }
    }
}

3.3 私有主机名检测

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 保留域名

3.4 重定向链校验

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
Loading

AES-256-GCM 加密格式(v2)

加密后的数据使用以下格式存储,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
Loading

5.1 文件名净化

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)
}

5.2 URL 解码(防嵌套编码绕过)

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
}

5.3 路径边界验证

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 要求文件存在)。

5.4 归档解压安全

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 配置文件支持 scriptscript-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
Loading

清除规则总结

字段 移除条件 风险等级 说明
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: 更新完成
Loading

7.1 可信域名白名单

仅允许从以下域名下载更新:

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
}

7.2 SHA256 完整性校验

下载完成后,从 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))
    }
}

7.3 版本格式验证

版本字符串经过严格格式校验,防止路径遍历和命令注入:

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
}

7.4 更新 URL 解析

更新 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
}

7.5 文件大小限制

场景 大小限制 说明
更新包下载 100 MB download_release_asset 中检查 content_length 和累计字节数
GZ 解压 200 MB extract_from_gz 中跟踪解压后总大小
订阅响应 10 MB read_response_body 中检查 content_length 和累计字节数

7.6 TOCTOU 防护

使用 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));

7.7 Windows 替换重试

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
Loading

限流配置

命令 限流间隔 冷却时间 说明
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 函数在每次写入文件后立即设置严格的访问权限:

Unix (macOS/Linux)

#[cfg(unix)]
{
    use std::os::unix::fs::PermissionsExt;
    let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
}

0o600rw-------):仅文件所有者可读写,其他用户无任何权限。

Windows

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)
所有配置文件 用户导入的代理配置

第十层:运行时安全加固

API 绑定隔离

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)),
);

API Secret 注入

每次启动内核时自动生成随机 secret 并注入配置,防止未授权 API 访问:

// 生成 32 字节随机 secret
let secret: String = thread_rng()
    .sample_iter(&Alphanumeric)
    .take(32)
    .collect();

Secret 泄露防护

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()));
    }
    // ...
}

YAML 合并深度限制

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 安全

全局 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 安全流水线

设计意图

安全策略不仅依赖运行时防护,还通过 CI/CD 流水线在代码合并前强制执行自动化安全扫描,确保安全策略不会被意外绕过。

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
Loading

触发条件

  • 触发条件:push to main/dev 分支、Pull Request、每日定时 0 3 * * *
  • Source: .github/workflows/security.yml:8-14

Job 分组说明

流水线的 10 个 Job 按技术栈分为三组:

Rust 后端检查

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

前端 JS 检查

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 详细说明

# 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 构建验证 + 单元测试

Job 1:Rust 依赖漏洞扫描

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 中所有依赖的已知安全漏洞。

Job 2:cargo-deny 许可证/来源检查

使用项目根目录的 deny.toml 配置文件执行检查(详见依赖许可证治理)。

Job 3:Rust Clippy(12 条额外规则)

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 减少不必要的异步开销

Job 5:前端依赖审计

npm audit --omit=dev --audit-level=moderate

仅检查生产依赖,moderate 级别即阻断构建。

Job 6:PR 依赖审查

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 许可证的依赖。

Job 8:密钥检测

采用自定义 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
Loading

Job 9:Tauri 安全配置审计

自动化检查 Tauri 安全配置:

检查项 说明
devtools 是否禁用 生产构建不应启用开发者工具
CSP WebSocket 通配符 警告 ws://127.0.0.1:* 中的端口通配符
withGlobalTauri 警告全局 Tauri API 暴露
capabilities 权限 检查通配符 window 权限和 allow-emit
Cargo.lock 存在性 确保可重现构建
package-lock.json 存在性 确保前端依赖锁定

Actions 安全

所有第三方 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@72eb03d02c7872a771aacd928f3123ac62ad6d3a

Semgrep 自定义规则

Zephyr 定义了 8 条自定义 Semgrep 规则(.semgrep.yml),覆盖 Rust 和 JavaScript 两种语言的安全检测。这些规则可供本地开发使用(semgrep --config .semgrep.yml),但 CI 流水线中的 Semgrep Job 使用 --config auto 运行标准规则集,并未引用此文件。

Rust 规则(5 条)

规则 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(硬编码密钥)

JavaScript 规则(3 条)

规则 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: MEDIUM

依赖许可证治理

deny.toml 配置

deny.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)。

Advisory 处理

配置项 说明 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 权限

安全设计原则

  1. Fail-closed:所有安全检查失败时拒绝操作,而非降级放行
  2. Defense in Depth:每类威胁都有多层独立防护,单层失效不导致整体突破
  3. Least Privilege:Tauri 权限、文件权限、API 权限均遵循最小权限原则
  4. Zero Trust:所有外部输入(URL、配置、文件)均视为不可信,必须经过校验
  5. Secure by Default:安全默认配置(API 绑定 127.0.0.1、随机 secret、0o600 权限)

返回 首页

Clone this wiki locally