本文档以中文撰写。

安全架构

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


目录


安全纵深防御总览

Zephyr 的安全体系由前端隔离、后端运行时防护、特权边界、更新完整性和 CI/CD 持续验证几大部分组成,形成从外到内的纵深防御链。下图展示了各层级之间的关系与数据流向:

%%{init: {'themeVariables': {'fontSize': '9px', 'nodeBorder': '1px', 'clusterBorder': '1px', 'edgeLabelHeight': '8px'}}}%%
graph TB
    subgraph AttackSurface["攻击面(外部输入)"]
        direction LR
        A1["订阅 / 覆写 / 规则 URL"]
        A2["配置文件与备份包"]
        A3["clash:// Deep Link"]
        A4["内核 / Geo / 客户端更新包"]
        A5["网络响应与重定向"]
    end

    subgraph L1["前端隔离"]
        CSP["CSP 策略<br/>script-src 'self'<br/>connect-src 仅本地"]
        XSS["escapeHtml / sanitizeHtml<br/>html / safeHtml 模板标签<br/>ESLint no-unsanitized"]
    end

    subgraph L2["Tauri 权限"]
        NoFS["无 fs / shell / http 插件权限"]
        NoEmit["无 emit 权限<br/>仅 invoke 通信"]
        Opener["openUrl 仅 http(s)"]
    end

    subgraph L3["Rust 后端防护"]
        SSRF["SSRF 防护<br/>协议白名单 + DNS Pinning<br/>私网/保留地址拦截 + fake-IP 识别<br/>逐跳重定向校验"]
        Crypto["AES-256-GCM 加密<br/>PBKDF2 密钥派生"]
        PathGuard["路径遍历防护<br/>5 轮 URL 解码"]
        Sanitize["危险键清除<br/>控制面键清除"]
    end

    subgraph L4["特权边界"]
        Elev["Windows UAC 重启<br/>macOS 管理员授权 + PID 复核<br/>Linux setcap + polkit"]
    end

    subgraph L5["更新完整性"]
        SHA256["SHA256 完整性校验"]
        Minisign["客户端 Minisign 签名"]
        Trusted["可信域名 + 版本/路径格式校验"]
    end

    subgraph L6["CI/CD 持续验证"]
        CI1["cargo audit / cargo-deny"]
        CI2["clippy / fmt"]
        CI3["dependency-review"]
        CI4["Semgrep / CodeQL"]
        CI5["TruffleHog / GitGuardian"]
        CI6["Snyk / OSSF Scorecard"]
        CI7["Tauri 配置审计 / SBOM"]
        CI8["许可证漂移检查"]
    end

    AttackSurface --> L1
    L1 --> L2
    L2 --> L3
    L3 --> L4
    L4 --> L5
    L5 --> L6

    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:#16a085,stroke:#117a65,color:#fff
    style L5 fill:#2ecc71,stroke:#27ae60,color:#fff
    style L6 fill:#9b59b6,stroke:#8e44ad,color:#fff

设计原则:每一层独立运作,单层失效不导致整体突破。所有外部输入(URL、配置、文件、Deep Link、更新包)均视为不可信,必须经过完整校验链;无法确认安全时一律拒绝(fail-closed)。


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

设计意图

CSP(Content Security Policy)是浏览器级别的第一道防线,用于限制 WebView 可以加载的资源来源,从根本上阻断 XSS、数据外泄和代码注入攻击。

策略配置

CSP 策略在 apps/desktop/src-tauri/tauri.conf.json 中声明,Tauri v2 会在应用启动时将其注入到所有页面:

json
{
  "app": {
    "withGlobalTauri": false,
    "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; frame-ancestors 'none'; form-action 'none'; base-uri 'self'"
    }
  }
}

指令逐一解析

CSP 指令 值 安全含义
default-src 'self' 默认拒绝一切外部资源。所有未显式指定的资源类型仅允许从应用包内加载
img-src 'self' data: 图片仅允许本地资源和 Base64 内嵌数据(用于图标渲染),禁止从外部 URL 加载图片(防止追踪像素和数据外泄)
style-src 'self' 'unsafe-inline' 样式允许本地文件和内联样式。前端模板中存在大量内联 style 属性(进度条宽度、颜色变量等),unsafe-inline 是为此做出的妥协
script-src 'self' JavaScript 仅允许从应用包内加载。完全禁止内联脚本(<script> 内联块、onclick 等事件属性)、外部 CDN 和 eval 类动态代码执行。innerHTML 赋值由 ESLint no-unsanitized 规则在构建期把关(见第十层)
connect-src 'self' ws://127.0.0.1:* http://127.0.0.1:* ipc://localhost http://ipc.localhost 网络连接严格限制:仅允许与本地 Mihomo 内核通信(WebSocket/HTTP)和 Tauri IPC 通道。禁止连接任何外部服务器
frame-ancestors 'none' 禁止 iframe 嵌入。防止 Zephyr 被嵌入第三方网页中遭受点击劫持(Clickjacking)攻击
form-action 'none' 禁止表单提交。防止攻击者通过表单将用户数据发送到外部服务器
base-uri 'self' 限制 <base> 标签。防止攻击者通过 <base href="..."> 劫持页面中所有相对 URL

安全分析

connect-src 的设计是最关键的安全决策。前端 JavaScript 无法直接向互联网发起请求 —— 所有外部网络通信(订阅下载、覆写/规则拉取、更新检查)都必须通过 Tauri Command 交给 Rust 后端,从而强制经过 SSRF 防护层的校验。

style-src 'unsafe-inline' 不影响脚本执行:由于 script-src 不包含 unsafe-inline 或 unsafe-eval,即使攻击者能在样式中注入内容,也无法执行 JavaScript 代码。

CI 中的 Tauri 安全审计 Job 会对 ws://127.0.0.1:* 端口通配符发出提示性警告(端口由用户配置决定,无法写死),见第十三层。


第二层:Tauri 最小权限原则

设计意图

Tauri v2 采用 Capability-based 权限模型。Zephyr 遵循最小权限原则,仅授予应用正常运行所必需的最小权限集,从进程级别限制攻击面。

已授予权限

权限配置位于 apps/desktop/src-tauri/capabilities/default.json(这也是唯一的 capability 文件,仅作用于 main 窗口):

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:window:allow-is-visible",
    "core:window:allow-is-maximized",
    "core:window:allow-set-title",
    "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",
    "notification:default",
    "window-state:default"
  ]
}

权限分类说明

类别 已授予权限 用途
窗口管理 close, start-dragging, minimize, maximize, hide, show, set-focus, is-visible, is-maximized, set-title 无边框窗口的基本交互 + 可见性/最大化状态查询 + 标题设置
系统托盘 tray:default 托盘图标与菜单管理
事件系统 listen, unlisten 前端监听后端事件(单向接收)
开机自启 enable, disable, is-enabled 用户主动控制的自启管理(前端通过 `plugin:autostart
对话框 message 显示提示对话框(仅消息类型)
通知 notification:default 系统通知插件默认权限集
窗口状态 window-state:default 窗口位置/尺寸持久化
资源访问 image, menu, resources, app, opener 读取应用内置资源 + 打开外部链接

说明:前端实际发出的系统通知与全局快捷键注册并不直接调用插件 IPC,而是走带限流的自定义命令 rate_limited_send_notification(标题截断至 128 字符、正文截断至 1024 字符)和 rate_limited_register_shortcut / rate_limited_unregister_shortcut,由 Rust 端再调用插件(见第八层)。全局快捷键插件的权限并未授予前端。

外部链接打开限制

opener:default 允许前端打开外部链接,api.js 中的 openUrl 封装在调用插件前强制只接受 http:// / https://,阻止 file:、javascript:、自定义协议处理器等被借道执行:

javascript
async function openUrl(url) {
  const trimmed = typeof url === 'string' ? url.trim() : '';
  if (!/^https?:\/\//i.test(trimmed)) {
    throw new ApiError('openUrl only supports http and https URLs');
  }
  return invoke('plugin:opener|open_url', { url: trimmed });
}

更新对话框中的 Release Notes 链接还要再经过 releaseNoteHref():以 GitHub Releases 页为基准解析,只放行 http: / https: 协议。

关键未授予权限

以下高危权限被明确拒绝授予,这是 Zephyr 安全架构的核心决策:

未授予权限 风险说明 影响
fs(文件系统) 前端无法直接读写任意文件 所有文件操作必须通过 Rust 后端的安全函数(文件名净化 + 目录边界校验)
shell(命令执行) 前端无法执行系统命令 防止通过 WebView 进行命令注入
http(网络请求) 前端无法通过插件发起 HTTP 请求 配合 CSP connect-src 实现双重网络隔离
clipboard-manager(剪贴板插件) 未接入 Tauri 剪贴板插件 前端的粘贴按钮使用标准 Web navigator.clipboard API,受 WebView 自身权限约束
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 重定向将请求引导至内网或云元数据服务(如 169.254.169.254、100.100.100.200)
  4. 协议走私:使用 file://、gopher:// 等非 HTTP 协议访问本地资源
  5. 代理借道:利用"直连失败后改走代理"的回退路径,让内核替攻击者访问内网
  6. fake-IP 混淆:TUN fake-ip 模式下 DNS 返回合成地址(默认 198.18.0.0/15),若被当成真实目的地,会被内核映射到任意域名

代码组织

SSRF 防护分布在两个模块中:

模块 内容
crates/core/src/config/subscription.rs 平台无关的纯函数:validate_subscription_url_basic、is_private_host、is_private_ip、is_single_label_host、is_literal_private_host、FakeIpRanges、validate_public_host_addrs_with_ranges
crates/core/src/config/fetch_util.rs 平台无关的重定向目标校验 check_redirect_target、SSRF 错误标记常量、供 UniFFI 使用的 URL 校验封装
apps/desktop/src-tauri/src/core/fetch_util.rs 依赖 reqwest 的部分:SafeDnsResolver、safe_redirect_policy、build_http_client、fetch_url_content、代理目的地预检 verify_proxy_destination、逐跳重定向校验 validate_redirect_hop

fetch_url_content 是通用远程拉取入口,被 fetch_text 命令、Prism 覆写远程拉取(overrides_commands.rs)和规则库 URL 导入(rule_library.rs)共用;订阅下载(core/subscription.rs)复用同一套校验原语,并在其上增加"全局模式"回退层。

SSRF 防护决策链

%%{init: {'themeVariables': {'fontSize': '9px', 'nodeBorder': '1px', 'clusterBorder': '1px', 'edgeLabelHeight': '8px'}, 'flowchart': {'curve': 'basis', 'nodeSpacing': 12, 'rankSpacing': 15}}}%%
flowchart TD
    A["输入 URL"] --> B{"基础校验<br/>无控制字符 / 恰好 // + authority<br/>仅 http/https / 有 host"}
    B -- "不通过" --> R1["拒绝"]
    B -- "通过" --> C{"读取运行中内核配置<br/>(fake-IP 段 + 托管代理端点)"}
    C -- "内核运行但读取失败" --> R2["拒绝(fail-closed)"]
    C --> D{"host 是 fake-IP 字面量?"}
    D -- "是" --> R3["拒绝<br/>synthetic fake-IP"]
    D -- "否" --> E{"用户显式输入的内网目的地?<br/>私网 IP / localhost / 私有后缀 / 单标签名"}
    E -- "是" --> F["仅直连<br/>(名称:本地解析并过滤 fake-IP 后 pin)<br/>重定向只允许回到同一主机"]
    E -- "否" --> G["直连层<br/>DNS 预解析:任一地址为私网 → LocalDnsSsrf<br/>Pin 首个公网地址 + SafeDnsResolver<br/>safe_redirect_policy 逐跳复检"]
    G -- "成功" --> OK["返回数据(≤10 MB)"]
    G -- "SSRF 拦截 / 确定性 4xx / 超限" --> R4["直接失败<br/>不回退代理"]
    G -- "传输失败 / LocalDnsSsrf /<br/>路由相关状态 403/407/408/429/451" --> H{"代理目的地预检<br/>本地 DNS + 内核 DNS 并行"}
    H -- "私网" --> R5["拒绝(SSRF)"]
    H -- "无法确认公网" --> R6["跳过代理层"]
    H -- "确认公网" --> I["代理层<br/>禁用自动重定向<br/>validate_redirect_hop 逐跳校验"]
    I --> OK
    F --> OK

    style A fill:#3498db,stroke:#2980b9,color:#fff
    style OK 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:#95a5a6,stroke:#7f8c8d,color:#fff

3.1 URL 基础校验

validate_subscription_url_basic 在任何 DNS 解析之前完成语法层校验,返回 (host, port, user_entered_private):

rust
pub fn validate_subscription_url_basic(url: &str) -> Result<(String, u16, bool), String> {
    let trimmed = url.trim();
    // WHATWG 解析器会静默丢弃 tab/LF/CR:`http://\t/sub` 会被解析成 host `sub`。
    // 合法订阅链接不含控制字符,直接拒绝。
    if trimmed.chars().any(|c| c.is_ascii_control()) {
        return Err("Invalid URL: must not contain control characters".to_owned());
    }
    // 特殊 scheme 的解析会忽略多余/缺失的斜杠(`http:/sub`、`HTTP:///sub`),
    // 会把畸形 URL 变成"被授权的单标签内网主机"。要求恰好 `//` + authority。
    let lower = trimmed.to_ascii_lowercase();
    if let Some(rest) = lower.strip_prefix("http:").or_else(|| lower.strip_prefix("https:")) {
        let mut chars = rest.chars();
        let slashes_ok = matches!((chars.next(), chars.next()), (Some('/'), Some('/')));
        if !slashes_ok || matches!(chars.next(), None | Some('/' | '\\')) {
            return Err("URL must have a host".to_owned());
        }
    }

    let parsed_url = url::Url::parse(trimmed).map_err(|e| format!("Invalid URL: {e}"))?;
    let scheme = parsed_url.scheme();
    if scheme != "http" && scheme != "https" {
        return Err("Only HTTP and HTTPS URLs are allowed".to_owned());
    }
    let host = parsed_url.host_str().ok_or("URL must have a host")?;
    // ...
    // 显式输入的单标签名(`nas`、`router`)与私有后缀名同等对待:仅直连
    let user_entered_private = is_private_host(host) || is_single_label_host(host);
    let default_port = if scheme == "https" { 443 } else { 80 };
    let port = parsed_url.port().unwrap_or(default_port);
    Ok((host.to_owned(), port, user_entered_private))
}

前端 utils/url-validate.js 的 validateHttpUrl 在对话框中按同样规则(控制字符、http(s)、恰好 // 后接 host)实时提示,避免用户提交后端必然拒绝的链接。

3.2 私有 / 保留 IP 检测

is_private_ip 是判定"可否作为公网目的地直连"的唯一入口,同时覆盖私网与保留地址空间:

IPv4 检测范围(is_unsafe_destination_v4):

范围 CIDR 说明
私有地址 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 RFC 1918
环回 127.0.0.0/8 本机回环
链路本地 169.254.0.0/16 含云元数据 169.254.169.254
"本网络" 0.0.0.0/8 RFC 1122,含未指定地址
运营商级 NAT 100.64.0.0/10 RFC 6598,Tailscale 与部分云元数据服务(如阿里云 100.100.100.200)
基准测试 198.18.0.0/15 RFC 2544,同时是 Mihomo 默认 fake-IP 段
IETF 协议分配 192.0.0.0/24 —
文档用途 192.0.2.0/24 等 is_documentation()
组播 224.0.0.0/4 —
保留 240.0.0.0/4 含广播地址

IPv6 检测范围(is_unsafe_destination_v6):

范围 说明
::1、:: 环回、未指定
ff00::/8 组播
fc00::/7 唯一本地地址(ULA)
fe80::/10 链路本地
2001:db8::/32 文档用途
嵌入 IPv4 的地址 按嵌入的 IPv4 地址判定:IPv4-mapped ::ffff:0:0/96、IPv4-compatible ::/96、NAT64 64:ff9b::/96(RFC 6052)与 64:ff9b:1::/48(RFC 8215,两种布局都检查)、6to4 2002::/16、Teredo 2001::/32(取反后的客户端地址)
rust
fn is_unsafe_destination_v6(ipv6: std::net::Ipv6Addr) -> bool {
    let bits = u128::from(ipv6);
    // 先判定直接属性:`::/96` 前缀包含 `::` 与 `::1`,若按嵌入地址判断会被当成 0.0.0.0 / 0.0.0.1
    if ipv6.is_loopback()
        || ipv6.is_unspecified()
        || ipv6.is_multicast()
        || (ipv6.segments()[0] & 0xfe00) == 0xfc00   // ULA
        || (ipv6.segments()[0] & 0xff00) == 0xfe00   // Link-local
        || v6_in_cidr(bits, 0x2001_0db8_0000_0000_0000_0000_0000_0000, 32)
    {
        return true;
    }
    // `::ffff:169.254.169.254` 与 `64:ff9b::a9fe:a9fe` 和 IPv4 地址是同一个目的地
    embedded_ipv4(bits).into_iter().flatten().any(is_unsafe_destination_v4)
}

3.3 主机名分类

三个函数共同决定一个主机名属于哪一类:

rust
pub fn is_private_host(host: &str) -> bool {
    // 去掉首尾空白与结尾的 '.',小写化
    // ...
    if host_lower == "localhost"
        || host_lower.ends_with(".localhost")
        || host_lower.ends_with(".local")
        || host_lower.ends_with(".internal")
        || host_lower.ends_with(".intranet")
        || host_lower.ends_with(".private")
        || host_lower.ends_with(".lan")
        || host_lower == "home.arpa"
        || host_lower.ends_with(".home.arpa")
        || host_lower.ends_with(".home")
        || host_lower.ends_with(".corp")
    {
        return true;
    }
    // IP 字面量(含 [IPv6])委托给 is_private_ip
    // ...
}
函数 判定 用途
is_private_host localhost、上表私有后缀、私网 IP 字面量 用户显式输入的内网目的地;重定向目标拦截
is_single_label_host 不含 . 且非 IP 的名称(nas、router) 视为内网名称:仅直连,禁止经代理访问;重定向禁止跳往
is_literal_private_host 仅 localhost / *.localhost / 私网 IP 字面量 只有字面量才免去 DNS Pinning;私有后缀域名仍需本地解析并 pin 到非 fake-IP 地址

3.4 用户显式输入 ≠ 攻击

Zephyr 区分"用户主动输入"和"被动跳转":

  • 用户直接输入内网地址(如 http://192.168.1.1/config.yaml、http://nas.lan/sub、http://router/sub):✅ 允许,但严格仅直连,不会回退到代理层,也不会进入全局模式层
  • 公网 URL 解析或重定向到内网:❌ 拦截
  • 内网目的地的重定向:只允许回到同一个被授权主机(allowed_private_host),跳往其他私网主机、单标签名或 fake-IP 一律拦截

当公网域名解析出私网地址时,错误信息会提示用户:"如果这是可信的内部订阅,请直接输入私网地址,而不是域名"。

3.5 fake-IP 识别

TUN fake-ip 模式下,系统 DNS 返回的是内核合成的地址,不能代表真实目的地。FakeIpRanges 从运行中的 run_config.yaml 读取 dns.fake-ip-range / dns.fake-ip-range6,缺省为 Mihomo 默认段 198.18.0.0/15 与 fdfe:dcba:9876::/64,并对 IPv4-mapped IPv6 地址按映射的 IPv4 地址判断:

rust
pub fn contains(&self, ip: IpAddr) -> bool {
    match ip {
        IpAddr::V4(ipv4) => { /* 任一 v4 段命中 */ }
        IpAddr::V6(ipv6) => {
            // v6 段命中,或 ::ffff:a.b.c.d 映射的 IPv4 命中 v4 段
        }
    }
}
场景 处理
URL host 本身是 fake-IP 字面量 在任何私网豁免之前直接拒绝(contains_host_literal),否则会被 is_literal_private_host 误当作用户授权的私网字面量
公网域名本地解析只得到 fake-IP 无法 pin,返回可操作提示:"DNS 被 TUN fake-ip 模式劫持,请关闭 TUN 或把内核 DNS enhanced-mode 切换为 redir-host"
私有后缀名解析结果 过滤掉 fake-IP 后 pin 第一个真实地址;全部为 fake-IP 时拒绝
重定向跳往 fake-IP 字面量 拦截
内核运行但配置读取/解析失败 fail-closed:自定义 fake-IP 段无法识别,直接拒绝请求,而不是退回默认段

3.6 直连层:DNS Pinning + 连接期解析器

直连请求的 HTTP 客户端由 build_http_client 构建,叠加三道保险:

rust
pub fn build_http_client(config: HttpClientConfig) -> Result<reqwest::Client, String> {
    // 默认 .no_proxy(),防止系统/环境代理泄漏(只有显式传入 proxy_url 才走代理)
    let mut client_builder = reqwest::Client::builder()
        .timeout(Duration::from_secs(config.timeout_secs))
        .connect_timeout(Duration::from_secs(config.connect_timeout_secs))
        .redirect(safe_redirect_policy(
            config.allowed_private_host.clone(),
            config.fake_ip_ranges.clone(),
        ))
        .no_proxy();

    if config.proxy_url.is_none() {
        // 直连:注册连接期安全解析器
        client_builder = client_builder.dns_resolver(std::sync::Arc::new(SafeDnsResolver {
            allowed_private_host: config.allowed_private_host.clone(),
            fake_ip_ranges: config.fake_ip_ranges.clone(),
        }));
    } else {
        // 代理:禁用自动重定向,改为逐跳手动校验
        client_builder = client_builder.redirect(reqwest::redirect::Policy::none());
    }
    // ... proxy、resolve_pin、User-Agent
}
  1. 预解析 + Pin:resolve_and_pin 解析目标主机,任一地址为私网即拒绝(防止"公网 + 私网混合应答"的 rebinding),fake-IP 被跳过,第一个公网地址通过 reqwest::ClientBuilder::resolve() 固定,实际连接不再重新解析
  2. SafeDnsResolver:对 Pin 之外的解析(例如重定向到新主机)在连接时再次检查:fake-IP 一律拒绝;非授权的私网 IP、单标签名、私网字面量拒绝;解析结果中出现私网地址即拒绝
  3. safe_redirect_policy:每一跳调用 check_redirect_target,最多 5 跳

DNS 并发保护:所有阻塞式解析经全局 DNS_SEMAPHORE(32 个 permit)调度,单次解析有截止时间。permit 随解析任务存活到 getaddrinfo 返回,这是有意的背压:卡死的解析不会无限堆积占满 Tokio 阻塞线程池。

3.7 重定向目标校验

直连与代理两条路径共用 check_redirect_target:

rust
pub fn check_redirect_target(
    url: &url::Url,
    allowed_private_host: Option<&str>,
    fake_ip_ranges: &FakeIpRanges,
) -> Result<(), String> {
    let scheme = url.scheme();
    if scheme != "http" && scheme != "https" {
        return Err(format!("{SSRF_BLOCK_MARKER} Invalid redirect scheme: {scheme}"));
    }
    let host = match url.host_str() {
        Some(h) => h,
        None => return Err(format!("{SSRF_BLOCK_MARKER} Redirect URL has no host")),
    };
    // 仅允许回到同一个被授权的私网主机(忽略大小写、结尾 '.'、IPv6 方括号)
    let same_allowed = allowed_private_host
        .is_some_and(|a| normalize_host(a).eq_ignore_ascii_case(normalize_host(host)));

    if !same_allowed && (is_private_host(host) || is_single_label_host(host)) {
        return Err(format!("{SSRF_BLOCK_MARKER} Redirect to private host blocked: {host}"));
    }
    let clean_host = host.trim_matches(['[', ']'].as_slice());
    if let Ok(ip) = clean_host.parse::<std::net::IpAddr>() {
        if fake_ip_ranges.contains(ip) {
            return Err(format!("{SSRF_BLOCK_MARKER} Redirect to fake IP blocked: {host}"));
        }
        if !same_allowed && is_private_ip(ip) {
            return Err(format!("{SSRF_BLOCK_MARKER} Redirect to private IP blocked: {host}"));
        }
    }
    Ok(())
}

主机名形式的重定向目标在直连路径上由 SafeDnsResolver 在连接期完成 IP 校验;在代理路径上由 validate_redirect_hop 先做 check_redirect_target,再对主机名执行与首跳相同的双 DNS 预检(见 3.8)。两条路径的跳数上限都是 5。

3.8 代理回退层与双 DNS 预检

在被墙或地域受限的网络中,直连失败后需要经 Mihomo 内核代理重试。代理会自行解析域名,客户端无法 pin,因此回退受到严格约束:

进入代理层的条件(全部满足):

  • 目的地不是用户显式输入的内网目的地(私网 IP / 私有后缀 / 单标签名)
  • 内核正在运行,且配置中存在托管代理端点
  • 直连失败的类型是"可能与路由有关"的:传输失败、本地 DNS 返回私网(LocalDnsSsrf,可能是 DNS 污染/sinkhole)、或路由相关状态码 403/407/408/429/451
  • 直连不是因 SSRF 拦截、确定性 4xx(如 404)或"与路由无关"的失败(响应超过 10 MB、重定向超过 5 跳)而失败

目的地预检 verify_proxy_destination:本地 DNS 与内核 DNS(/dns/query,带 Bearer secret,A 与 AAAA 都查)并行查询,判定矩阵如下:

内核 DNS 结论 本地 DNS 结论 结果
私网 任意 ❌ 拒绝(SSRF)
公网 私网(非规范 sinkhole) ❌ 拒绝(SSRF)
公网 规范 sinkhole(0.0.0.0 / 127.0.0.1 / :: / ::1)、失败或仅 fake-IP ✅ 允许(本地 DNS 只有否决权)
仅 fake-IP 确认公网 ✅ 允许
仅 fake-IP 仅 fake-IP ⏭ 跳过代理层,提示关闭 TUN 或切换 redir-host
不可用 / 超时 / 失败 任意 ⏭ 跳过代理层(本地 DNS 单独不能为内核将要访问的目的地作保)

内核 DNS 接口只有返回 4xx 才视为"该记录类型不受支持";5xx 或非 JSON 的 2xx 都按失败处理(fail-closed)。

rust
const fn is_proxy_destination_public(
    mihomo: super::subscription::MihomoDnsVerdict,
    local_verified_public: bool,
) -> bool {
    match mihomo {
        MihomoDnsVerdict::Public => true,
        MihomoDnsVerdict::FakeIpOnly => local_verified_public,
        MihomoDnsVerdict::Private | MihomoDnsVerdict::UnavailableOrFailed => false,
    }
}

已接受的残余风险:代理路径无法完全消除"预检与内核实际连接之间"的 DNS rebinding 竞争窗口 —— 内核会自行解析目标(远程 DNS、虚拟主机)。预检缩小但无法关闭这一窗口,这是兼容代理所做的权衡,代码中有明确注释。

3.9 订阅下载的全局模式层

订阅下载(download_sub)在直连层与代理层之后还有第三层:在规则分流导致不可达时,临时把 Mihomo 切到 global 模式并选择当前活跃节点重试,完成或中断后恢复原模式与原策略组选择。安全约束:

  • 仅在代理层已尝试、没有 SSRF 结论、也没有确定性 HTTP 错误时进入
  • 使用调用方那一次已校验过的 fake-IP 段,不重新读取(避免读取失败时退回默认段)
  • 同一时刻只允许一个回退在进行(GLOBAL_FALLBACK_ACTIVE + GLOBAL_MODE_LOCK);切换前写入恢复标记,进程异常退出后下次启动会先完成恢复

3.10 错误分类与响应限制

DownloadError 把"安全策略拒绝"与"瞬时传输失败"区分开,决定是否允许回退:

变体 含义 是否回退代理
Ssrf 连接期 / 重定向 SSRF 拦截(错误中带 [SSRF_BLOCKED] 标记) 否
LocalDnsSsrf 本地 DNS 返回私网地址(带 [LOCAL_DNS_SSRF] 标记) 是(经双 DNS 预检)
HttpStatus 4xx 确定性应答 否(路由相关状态码除外)
RouteIndependent 响应 > MAX_RESPONSE_SIZE(10 MB)或重定向超过 5 跳 否
Transport 连接、TLS、超时等 是

响应体限制:先检查 Content-Length(在 32 位平台上溢出时按超限处理),再流式读取并累计计数,超过 10 MB 立即中止。fetch_url_content 的整体截止时间为 30 秒;可回退代理时,直连层只占前 10 秒(连接超时 3 秒),并为代理层预留时间。远程内容按 UTF-8 宽松解码。


第四层:加密存储体系

设计意图

Zephyr 使用加密存储保护订阅元数据(metadata.json 中的订阅 URL、流量信息),并可选地加密订阅配置文件本身,确保即使文件被复制,攻击者也无法在其他机器上获取明文数据。加密密钥与机器硬件指纹绑定。

⚠️ 范围说明:

  • metadata.json 中的订阅 URL 和流量信息始终加密
  • 订阅配置文件(profiles/*.yaml)由设置项 encrypt_configs 控制,默认关闭(明文 + 文件权限保护);开启后整目录批量加密
  • 运行时配置 run_config.yaml 始终明文(内核需要直接读取),仅依赖文件权限保护
  • 如需更强保护,请配合系统级全盘加密(BitLocker、FileVault、LUKS)

加密方案

参数 值 说明
加密算法 AES-256-GCM 认证加密(AEAD),同时保证机密性、完整性和真实性
密钥长度 256 位(32 字节) AES-256 的标准密钥长度
Nonce 长度 96 位(12 字节) 每次加密随机生成
认证标签 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["注册表 HKLM\\SOFTWARE\\Microsoft\\Cryptography<br/>MachineGuid<br/>+ vol C: 卷序列号"]
    B -- "macOS" --> C2["ioreg IOPlatformExpertDevice<br/>IOPlatformUUID 段<br/>+ IOPlatformSerialNumber"]
    B -- "Linux" --> C3["/etc/machine-id<br/>+ /sys/class/dmi/id/board_serial"]

    C1 --> E{"指纹非空?"}
    C2 --> E
    C3 --> E

    E -- "是" --> F["seed_parts.join('|')<br/>PBKDF2-HMAC-SHA256 100,000 次"]
    F --> G["32 字节确定性密钥<br/>MACHINE_KEY_PERSISTED = true"]

    E -- "否" --> H["平台配置目录 .machine_key"]
    H --> H1{"已存在且 hex 解码为 32 字节?"}
    H1 -- "是" --> G2["使用持久化随机密钥"]
    H1 -- "否" --> H2["生成 32 字节随机密钥<br/>hex 写入(write_file_secure, 0o600)"]
    H2 -- "写入成功" --> G2
    H2 -- "写入失败" --> S["会话密钥(仅内存)<br/>MACHINE_KEY_PERSISTED = false<br/>emit_error 警告"]

    G --> K["OnceLock 缓存"]
    G2 --> K
    S --> K

    style A fill:#3498db,stroke:#2980b9,color:#fff
    style G fill:#2ecc71,stroke:#27ae60,color:#fff
    style G2 fill:#2ecc71,stroke:#27ae60,color:#fff
    style S fill:#e74c3c,stroke:#c0392b,color:#fff

持久化随机密钥的平台路径:

平台 路径
Windows %APPDATA%\Zephyr\.machine_key
macOS ~/Library/Application Support/Zephyr/.machine_key
Linux $XDG_CONFIG_HOME/Zephyr/.machine_key,否则 ~/.config/Zephyr/.machine_key

新建父目录时在 Unix 上设为 0o700。

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

┌────────┬──────────────┬──────────────┬──────────────┐
│ "v2:"  │ Nonce (12B)  │  Ciphertext  │ AuthTag(16B) │
│ 3 字节 │   12 字节     │   N 字节     │   16 字节     │
└────────┴──────────────┴──────────────┴──────────────┘
│◄──────────── Base64 编码 ──────────────────────────►│
  • 总最小长度:3 + 12 + 0 + 16 = 31 字节(空明文)
  • 前缀 v2: 的 Base64 编码为 djI6,is_encrypted_content() 据此识别已加密的配置文件

加密实现

rust
pub(super) fn obfuscate_string(s: &str) -> Result<String, String> {
    use aes_gcm::{aead::{Aead as _, KeyInit as _}, Aes256Gcm, Nonce};

    let key_bytes = get_machine_key();
    if key_bytes.len() != 32 {
        return Err(format!("Invalid key length {}, expected 32", key_bytes.len()));
    }
    let cipher = Aes256Gcm::new_from_slice(&key_bytes)
        .map_err(|e| format!("Failed to initialize AES cipher: {e}"))?;

    let nonce_bytes: [u8; 12] = rand::rng().random();
    let nonce: Nonce<_> = nonce_bytes.into();

    let ciphertext = cipher
        .encrypt(&nonce, s.as_bytes())
        .map_err(|e| format!("AES encryption failed: {e}"))?;

    let mut result = b"v2:".to_vec();
    result.extend(&nonce_bytes);
    result.extend(ciphertext);
    Ok(base64_standard.encode(&result))
}

解密实现

rust
pub(super) fn deobfuscate_string(s: &str) -> Result<String, String> {
    let decoded = base64_standard
        .decode(s)
        .map_err(|e| format!("Invalid base64 encoding: {e}"))?;

    if !decoded.starts_with(b"v2:") {
        return Err("Unknown or missing encryption version prefix".to_owned());
    }
    if decoded.len() < 31 {
        return Err("Invalid v2 ciphertext: too short".to_owned());
    }
    let nonce_bytes = decoded.get(3..15).ok_or("Invalid ciphertext: missing nonce")?;
    let ciphertext = decoded.get(15..).ok_or("Invalid ciphertext: missing data")?;

    let key_bytes = get_machine_key();
    if key_bytes.len() != 32 {
        return Err(format!("Invalid key length {}, expected 32", key_bytes.len()));
    }
    let cipher = Aes256Gcm::new_from_slice(&key_bytes)
        .map_err(|e| format!("Failed to initialize AES cipher: {e}"))?;
    let nonce: Nonce<_> = nonce_bytes
        .try_into()
        .map_err(|e| format!("Invalid nonce length: {e}"))?;

    // 解密同时校验认证标签
    let plaintext = cipher
        .decrypt(&nonce, ciphertext)
        .map_err(|e| format!("AES decryption failed - data may be tampered: {e}"))?;

    // 严格 UTF-8 校验
    String::from_utf8(plaintext).map_err(|e| format!("Decrypted data is not valid UTF-8: {e}"))
}

两个函数都返回 Result,失败不会静默降级为空串或乱码。

密钥缓存与持久化状态

rust
static DERIVED_KEY: OnceLock<Vec<u8>> = OnceLock::new();

pub(super) fn get_machine_key() -> Vec<u8> {
    DERIVED_KEY.get_or_init(compute_machine_key).clone()
}
优先级 策略 说明
第一优先级 硬件指纹 → PBKDF2 确定性密钥,跨重启稳定
第二优先级 持久化随机密钥 存储在平台配置目录,hex 编码,0o600 权限,读取时验证精确 32 字节
最终回退 会话密钥(仅内存) 重启后丢失,is_machine_key_persisted() 返回 false

会话密钥保护:只要密钥未持久化,加密就会导致数据下次启动不可读,因此:

  • patch_settings 拒绝开启 encrypt_configs("machine key is not persisted")
  • write_profile_file 在会话密钥下改写明文
  • 启动时的加密状态对账会跳过加密方向的批处理

配置文件加密(encrypt_configs)

开启/关闭 encrypt_configs 时,encrypt_all_profiles / decrypt_all_profiles 对 profiles/ 下所有 .yaml/.yml 批量转换(已处于目标状态的文件跳过,单个失败不中断批次)。一致性保障:

  • PROFILE_CRYPTO_TRANSFORM 互斥锁:整个批次以及每一次配置文件写入都持有该锁,避免保存操作落在批次"读文件"与"写文件"之间被旧内容覆盖;锁的获取顺序固定为"先变换锁、后设置锁",杜绝死锁
  • 权威值读取:authoritative_encrypt_configs() 在设置锁下读取当前生效值,被后来者取代的 patch 不会用过期值撤销新的变换
  • 原子写入:批次与单文件写入均使用 write_file_secure_atomic(临时文件 + fsync + rename + 目录 fsync),并发读者只会看到旧内容或新内容
  • 启动对账:每次启动在后台按 encrypt_configs 的方向补完批次(崩溃或写失败留下的半成品会被修正);若 settings.json 无法读取则跳过,避免按内存默认值把加密文件全部解密

read_profile_file 读取时按 djI6 前缀自动解密,内核启动、配置列表等路径无需关心文件是否加密。

元数据加密

rust
pub(super) fn save_metadata(paths: &AppPaths, meta: &ProfilesMetadata) -> Result<(), String> {
    let mut obf_meta = ProfilesMetadata::default();
    for (k, v) in &meta.configs {
        obf_meta.configs.insert(k.clone(), ConfigMetadata {
            url: v.url.as_ref().and_then(|s| obfuscate_string(s).ok()),           // 订阅 URL 加密
            sub_info: v.sub_info.as_ref().and_then(|s| obfuscate_string(s).ok()), // 流量信息加密
            last_updated: v.last_updated,                 // 明文
            auto_update_interval: v.auto_update_interval, // 明文
            user_agent: v.user_agent.clone(),             // 非敏感,明文
        });
    }
    // 序列化后通过 write_file_secure_atomic 持久替换;
    // 原子 rename 失败时先备份为 metadata.json.bak 再直接写入,备份失败则拒绝破坏性写入
    // ...
}

load_metadata 通过启发式判断字段是否已加密(URL 不以 http 开头、流量信息不含 ; 时尝试解密),主文件损坏时尝试从 metadata.json.bak 恢复。所有元数据读-改-写序列都在 METADATA_LOCK 下串行化,防止用户命令与后台订阅调度器之间的丢失更新。


第五层:路径遍历防护

威胁模型

路径遍历(Path Traversal)攻击试图通过 ../、编码绕过、空字节注入、Windows 规范化(结尾空格/点)等手段访问或写入预期目录之外的文件。Zephyr 在所有文件操作入口实施了多层防护,核心实现位于 crates/core/src/config/sanitizer.rs。

路径遍历防护流程

sanitize_config_file_name 先调用通用的 sanitize_base_filename,再追加配置文件专属检查:

%%{init: {'themeVariables': {'fontSize': '9px', 'nodeBorder': '1px', 'clusterBorder': '1px', 'edgeLabelHeight': '8px'}, 'flowchart': {'curve': 'basis', 'nodeSpacing': 12, 'rankSpacing': 15}}}%%
flowchart TD
    A["输入名称"] --> B["最多 5 轮 URL 解码<br/>url_decode_complete()<br/>处理 %252e%252e%252f 等嵌套编码"]
    B --> C["去除结尾空格与 '.'<br/>(防 Windows 规范化绕过)"]
    C --> D{"含 / 或 \\ ?"}
    D -- "是" --> R1["拒绝<br/>directory separators<br/>are not allowed"]
    D -- "否" --> E{"恰为 '..' ?"}
    E -- "是" --> R2["拒绝<br/>'..' is not allowed"]
    E -- "否" --> F{"恰为 '.' 或为空?"}
    F -- "是" --> R3["拒绝<br/>not a valid filename"]
    F -- "否" --> G{"含空字节 / 控制字符?"}
    G -- "是" --> R4["拒绝<br/>Invalid character"]
    G -- "否" --> H{"长度 ≤ 255?"}
    H -- "否" --> R5["拒绝<br/>Filename too long"]
    H -- "是" --> I{"扩展名 .yaml / .yml?<br/>(配置文件专属)"}
    I -- "否" --> R6["拒绝<br/>Invalid file type"]
    I -- "是" --> J{"Windows 保留名?<br/>CON/PRN/AUX/NUL<br/>COM1-9/LPT1-9"}
    J -- "是" --> R7["拒绝<br/>Reserved filename"]
    J -- "否" --> K["通过"]

    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

5.1 文件名净化

rust
pub fn sanitize_base_filename(raw: String) -> Result<String, crate::error::AppError> {
    // Step 1: 完整 URL 解码
    let decoded = url_decode_complete(&raw);
    // 去除结尾空格和点,防止 Windows 规范化绕过(如 ".. " 或 ".. .")
    let trimmed = decoded.trim_end_matches([' ', '.']);

    // Step 1.5: 提取文件名之前先检查目录分隔符
    if trimmed.contains('/') || trimmed.contains('\\') {
        return Err(ConfigError("Path traversal detected: directory separators are not allowed"));
    }
    // 单独的 ".." 是真正的父目录引用
    if trimmed == ".." {
        return Err(ConfigError("Path traversal detected: '..' is not allowed"));
    }
    if trimmed == "." || trimmed.is_empty() {
        return Err(ConfigError("'.' is not a valid filename"));
    }

    // Step 2: 提取纯文件名
    let filename = Path::new(trimmed).file_name().and_then(|f| f.to_str()).unwrap_or(trimmed).to_owned();

    // Step 3/4: 空字节与控制字符
    if filename.contains('\0') { return Err(/* null byte detected */); }
    if filename.chars().any(char::is_control) { return Err(/* control characters not allowed */); }

    // Step 5: 长度限制
    if filename.len() > 255 { return Err(/* maximum 255 characters */); }
    Ok(filename)
}

pub fn sanitize_config_file_name(config_path: String) -> Result<String, crate::error::AppError> {
    let config_file_name = sanitize_base_filename(config_path)?;
    // 扩展名白名单 .yaml / .yml(大小写不敏感)
    // Windows 保留名检测(所有平台一致执行)
    // ...
}

由于目录分隔符已被整体拒绝,名称中间出现的 ..(如 a..b.yaml)不构成遍历,因此只拒绝"恰为 .."的情形。

使用位置:配置文件读写/重命名/删除(core/config_manager.rs)、内核启动配置选择(core/core_process.rs)、拖拽导入(lib.rs)、订阅名称(validate_subscription_name)。Prism 侧的插件 ID 校验(prism/types.rs)同样先做 url_decode_complete,再拒绝分隔符等字符并限制 128 字节。

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

url_decode_complete 以字节为单位迭代解码,最多 5 轮,某轮没有变化或结果中已无 % 时提前结束:

rust
pub fn url_decode_complete(input: &str) -> String {
    if !input.contains('%') {
        return input.to_owned();
    }
    let mut current = input.as_bytes().to_vec();
    let mut decoded = Vec::with_capacity(current.len());

    for _ in 0..5 {
        let mut changed = false;
        decoded.clear();
        let mut i = 0;
        while let Some(&byte) = current.get(i) {
            if byte == b'%' {
                if let (Some(hi), Some(lo)) = (
                    current.get(i + 1).and_then(|&b| decode_hex_digit(b)),
                    current.get(i + 2).and_then(|&b| decode_hex_digit(b)),
                ) {
                    decoded.push((hi << 4) | lo);
                    i += 3;
                    changed = true;
                    continue;
                }
            }
            decoded.push(byte);
            i += 1;
        }
        std::mem::swap(&mut current, &mut decoded);
        if !changed || !current.contains(&b'%') {
            break;
        }
    }
    String::from_utf8(current)
        .unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
}

字节级解码可以正确还原多字节 UTF-8 序列(如 %E4%B8%AD),非法 UTF-8 按宽松方式替换,不会 panic。

5.3 路径边界验证

validate_path_within_dir 确保拼接后的路径始终在预期目录内:

rust
pub fn validate_path_within_dir(resolved_path: &Path, base_dir: &Path) -> Result<(), AppError> {
    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(ConfigError("Path traversal detected: resolved path is 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(ConfigError("Path traversal detected: resolved path is outside allowed directory"));
        }
    }
    Ok(())
}

双模式设计:对已存在的文件使用 canonicalize(解析所有符号链接和 ..),对新文件使用字符串前缀检查(canonicalize 要求文件存在)。由于文件名已先经过 5.1 的净化,新文件路径中不可能含有分隔符或 ..。

5.4 归档解压安全

内核更新包(updater.rs):

rust
// ZIP:符号链接直接报错(不是跳过)
if file.is_symlink() {
    return Err("Refusing to extract symlink from ZIP".to_owned());
}
let name = file.name().map_err(|e| format!("Invalid ZIP entry name: {e}"))?;
if name.contains("..") || name.starts_with('/') || name.starts_with('\\') {
    return Err(format!("Malicious ZIP path detected: {name}"));
}

// TAR:符号链接与硬链接直接报错
let entry_type = entry_inner.header().entry_type();
if entry_type.is_symlink() || entry_type.is_hard_link() {
    return Err("Refusing to extract linked TAR entry".to_owned());
}
if path_str.contains("..") || path_str.starts_with('/') {
    return Err(format!("Malicious TAR path detected: {path_str}"));
}

解压只写出唯一一个与内核二进制名匹配的条目到固定的临时路径,条目内的路径不会被用来构造输出位置;解出的二进制为空时报错。

备份恢复(backup.rs):导入的 ZIP 必须带 manifest.json(版本不高于当前支持版本),只解压 manifest 中列出的条目,并逐条执行 is_safe_archive_path:拒绝空路径、空字节、绝对路径(/、\、盘符)、\ 与 :,以及 .、..、空路径分量;拒绝符号链接条目;文件先解到 UUID 命名的暂存目录,校验 SHA-256 与声明大小后再原子换入,失败整体回滚。

5.5 压缩炸弹检测

场景 解压后上限 压缩比上限 细节
内核 ZIP 条目 200 MB 200:1 依据条目头的 size() / compressed_size() 预检
内核 .gz 200 MB 200:1 流式解压中累计计数,超过 64 KB 后开始做比例检查(避免小文件误报)
备份 ZIP 总计 200 MB 200:1 比例检查只针对 > 1 MB 的条目;按声明大小 take() 限流读取,防止伪造头部绕过
rust
// .gz 解压循环中的检查
total_decompressed += n as u64;
if total_decompressed > 200 * 1024 * 1024 {
    return Err("Decompressed gz too large".to_owned());
}
if total_decompressed > 64 * 1024
    && compressed_size > 0
    && total_decompressed > compressed_size.saturating_mul(200)
{
    return Err("Suspicious compression ratio detected (possible zip bomb)".to_owned());
}

第六层:危险配置清除

设计意图

Mihomo 配置文件中有两类字段会被滥用:

  1. 代码执行 / 文件访问字段:script、script-path,以及 Provider 中可指向任意本地路径的 path
  2. 控制面字段:开启额外控制入口的顶层键。Zephyr 自己管理控制器(把 external-controller 固定到 127.0.0.1 并注入随机 secret),这些键从不应由订阅、导入的配置或补丁提供

Zephyr 在加载任何外部 YAML 时,自动递归清除第一类字段,并清除根级的第二类字段。

控制面键黑名单

rust
pub const CONTROL_PLANE_KEYS: &[&str] = &[
    "external-controller-unix", // Unix socket 上的 REST API,Mihomo 不校验 secret
    "external-controller-pipe", // Windows 命名管道上的 REST API,不校验 secret
    "external-controller-tls",  // 可绑定任意地址的第二个(TLS)API 监听
    "external-controller-cors", // 放宽 API 的浏览器同源检查
    "external-doh-server",      // 控制器上不校验 secret 的 DoH 端点
    "external-ui",              // 从控制器源提供(并下载)任意 Web UI
    "external-ui-url",
    "external-ui-name",
];

external-controller 与 secret 不在此列:它们由 restore_security_settings 和运行时配置准备流程统一改写(见下文)。

清除策略

remove_dangerous_keys_internal 递归遍历整个 YAML,深度上限 MAX_YAML_DEPTH = 100,超过深度时把整个值置为 Null(防止危险键藏在深度限制之外存活,同时防御 Billion Laughs 类深层嵌套):

rust
const MAX_YAML_DEPTH: usize = 100;

fn remove_dangerous_keys_internal(value: &mut serde_yaml::Value, in_provider_context: bool, depth: usize) {
    if depth > MAX_YAML_DEPTH {
        *value = serde_yaml::Value::Null;
        return;
    }
    match value {
        serde_yaml::Value::Mapping(map) => {
            // 全局移除:脚本相关
            remove_keys_resolved(map, &["script", "script-path"]);
            // 全局移除:Clash-for-Windows 遗留键
            remove_keys_resolved(map, &[
                "cfw-bypass", "cfw-bypass-domain", "cfw-profiles-path",
                "cfw-conn-break-strategy", "prepend-proxy-groups", "append-proxy-groups",
            ]);

            // Provider 判定:type + (url | path),键可以带显式标签,也可以经 `<<` 合并键继承
            let has_key = |name: &str| merged_mapping_has_key(map, name, depth);
            let is_provider = has_key("type") && (has_key("url") || has_key("path"));
            if in_provider_context || is_provider {
                remove_keys_resolved(map, &["path"]);
            }

            for (k, v) in map.iter_mut() {
                // `<<` 合并源在 Mihomo 解析后属于同一个映射,继承外层的 Provider 上下文
                let is_merge_source = resolved_key_str(k) == Some(YAML_MERGE_KEY);
                let child_ctx = if is_merge_source { in_provider_context || is_provider } else { is_provider };
                remove_dangerous_keys_internal(v, child_ctx, depth + 1);
            }
        }
        serde_yaml::Value::Sequence(seq) => {
            for item in seq.iter_mut() {
                remove_dangerous_keys_internal(item, in_provider_context, depth + 1);
            }
        }
        serde_yaml::Value::Tagged(tagged) => {
            // Mihomo 的解码器会透过标签读取内部节点,因此带标签的映射必须同样清理
            remove_dangerous_keys_internal(&mut tagged.value, in_provider_context, depth + 1);
        }
        _ => {}
    }
}

pub fn remove_dangerous_keys_internal_pub(value: &mut serde_yaml::Value, in_provider_context: bool) {
    remove_dangerous_keys_internal(value, in_provider_context, 0);
    remove_control_plane_keys(value);   // 作为配置文档根,同时清除控制面键
}

与 Mihomo 解析语义对齐

serde_yaml 与 Mihomo 的 YAML 解码器在两处存在差异,清理器显式补齐:

差异 风险 处理
显式标签键:!!str script: ... 在 serde_yaml 中是 Tagged 值,Mihomo 解析为普通键 script 按字符串比较会漏删 resolved_key_str 透过标签取内部字符串,remove_keys_resolved 按解析后的键名删除
带标签的值:!custom {script: ...} 跳过 Tagged 会漏掉整个子树 递归进入 Tagged 内部
YAML 合并键 <<:serde_yaml 保留为普通键,Mihomo 会展开 Provider 把 type/url 放在合并源里、path 写在本地,或把控制面键藏在合并源里 merged_mapping_has_key 按 Mihomo 的方式展开合并链(单映射、映射序列、带标签的两者、嵌套合并)判定 Provider;remove_control_plane_keys 同时清理根映射挂载的所有 << 合并源

递归清除流程

%%{init: {'themeVariables': {'fontSize': '9px', 'nodeBorder': '1px', 'clusterBorder': '1px', 'edgeLabelHeight': '8px'}, 'flowchart': {'curve': 'basis', 'nodeSpacing': 12, 'rankSpacing': 15}}}%%
flowchart TD
    A["输入 YAML Value"] --> Z{"depth > 100?"}
    Z -- "是" --> N["置为 Null"]
    Z -- "否" --> B{"节点类型"}
    B -- "Mapping" --> D["按解析后键名移除<br/>script / script-path / CFW 遗留键"]
    D --> G["判定 Provider<br/>type + (url|path)<br/>含标签键与 << 继承"]
    G --> E{"Provider 上下文?"}
    E -- "是" --> F["移除 path"]
    E -- "否" --> I
    F --> I["递归子节点<br/>(<< 合并源继承上下文)"]
    B -- "Sequence" --> K["递归每个元素"]
    B -- "Tagged" --> T["递归内部节点"]
    B -- "标量" --> M["跳过"]
    I --> CP["根级:移除 CONTROL_PLANE_KEYS<br/>(含 << 合并源)"]
    K --> CP
    T --> CP

    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 CP fill:#e74c3c,stroke:#c0392b,color:#fff
    style N fill:#95a5a6,stroke:#7f8c8d,color:#fff
    style M fill:#95a5a6,stroke:#7f8c8d,color:#fff

清除规则总结

字段 移除条件 风险等级 说明
script 任意深度,始终移除 严重 可执行脚本
script-path 任意深度,始终移除 严重 可加载外部脚本文件
path(Provider 上下文) Provider 内移除 高 可指向任意文件系统路径,导致信息泄露或配置注入
CONTROL_PLANE_KEYS 根级(含 << 合并源)移除 高 开启不校验 secret 的控制入口或扩大控制器暴露面
cfw-bypass 等 CFW 遗留键 任意深度,始终移除 中 在 Mihomo 中无效果,但可能被用于注入意外行为

清理的应用位置

入口 位置 说明
订阅下载 core/subscription.rs 解析前先 quote_short_id_values,解析后清理再写入
拖拽导入 lib.rs 文件名净化 + 目录边界校验 + 清理
配置补丁合并 config_manager.rs、crates/core/src/config/merge.rs 合并前后各清理一次,再 restore_security_settings
规则提取 / 规则库 core/config_manager.rs、prism/rule_library.rs sanitize_rule_yaml:无法解析的内容直接报错(fail-closed);没有危险键时原样保存,保留注释与格式
Prism 覆写 prism/overrides_commands.rs sanitize_override_yaml:创建、编辑、远程刷新、导入等写盘路径统一清理

REALITY short-id 科学计数法保护

REALITY 协议的 short-id 字段使用十六进制字符串(如 34010e92),YAML 解析器可能把它解释为科学计数法浮点数,导致配置损坏。crates/core/src/config/subscription.rs 中的 quote_short_id_values 在解析前给未加引号的 short-id 值加上引号:

rust
pub fn quote_short_id_values(content: &str) -> String {
    // 正则:(?:^|\n)(\s*short-id:\s*)([^\s"'\n][^\s\n]*)
    // 例如 short-id: 34010e92 → short-id: "34010e92"
    // ...
}

运行时配置保护

prepare_runtime_config(core/core_process.rs)在每次启动内核时强制覆盖关键安全设置:

rust
pub fn prepare_runtime_config(
    content: &str,
    secret: &str,
    prefs: Option<&GlobalPreferences>,
) -> Option<(String, u16, Option<ProxyEndpoint>)> {
    let mut yaml_val = serde_yaml::from_str::<serde_yaml::Value>(content).ok()?;
    if !yaml_val.is_mapping() {
        return None;
    }
    let config_port = parse_external_controller_port(&yaml_val);
    if let Some(mapping) = yaml_val.as_mapping_mut() {
        // 强制绑定到 127.0.0.1(防止外部访问 API)
        insert_yaml(mapping, "external-controller",
            serde_yaml::Value::String(format!("127.0.0.1:{config_port}")));
        // 注入随机 secret(防止未授权 API 访问)
        insert_yaml(mapping, "secret", serde_yaml::Value::String(secret.to_owned()));
        // ... 性能默认值与全局偏好(mode 仅接受 rule/global/direct)
    }
    // ...
}

restore_security_settings(crates/core/src/config/merge.rs)在合并用户补丁后恢复安全关键设置:

rust
pub fn restore_security_settings(yaml: &mut YamlValue, settings: &SecuritySettings) {
    remove_control_plane_keys(yaml);
    let YamlValue::Mapping(ref mut map) = yaml else { return; };

    // 先删除所有解析后同名的条目(包括 `!!str secret` 这类带标签写法),避免出现两份冲突设置
    remove_keys_resolved(map, &["external-controller"]);
    if settings.secret.is_some() {
        remove_keys_resolved(map, &["secret"]);
    }
    // external-controller 始终恢复为 127.0.0.1:<port>(端口非法时用 9090)
    // secret 恢复为原值,不允许被补丁移除
    // TUN 原本关闭时强制 tun.enable = false(只能通过 UI 开关切换)
    // ...
}

自定义参数过滤

用户提供的内核自定义启动参数经过过滤,阻止覆盖安全关键配置(大小写不敏感,同时匹配 --flag=value 形式):

rust
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 认证密钥(绕过认证)
    ];
    // 命中则返回 "Argument '...' is not allowed for security reasons"
    // ...
}

第七层:更新与供应链完整性

设计意图

Zephyr 从 GitHub 下载 Mihomo 内核、GeoIP/GeoSite 数据库以及 Zephyr 客户端自身的安装包。供应链攻击可能通过恶意更新包、DNS 劫持、中间人攻击、版本/路径注入等方式发生。Zephyr 对三类更新分别建立完整性校验链,任何一环无法确认时均拒绝安装(fail-closed)。

更新对象 来源 完整性校验 真实性校验
Mihomo 内核 MetaCubeX/mihomo Releases SHA256(GitHub API digest 字段,必需) —
GeoIP / GeoSite MetaCubeX/meta-rules-dat Releases SHA256(同一 Release 中的 .sha256sum) —
Zephyr 客户端 Juwan-Hwang/Zephyr Releases SHA256(GitHub API digest 字段,必需) Minisign Ed25519 签名(必需,公钥内置)

内核更新流程

%%{init: {'themeVariables': {'fontSize': '10px', 'actorTextColor': '#ccc', 'signalColor': '#666', 'labelTextColor': '#999', 'labelBoxBkgColor': '#333', 'labelBoxBorderColor': '#555', 'lineColor': '#666', 'actorBkg': '#333', 'actorBorder': '#555', 'signalTextColor': '#ccc', 'noteBkgColor': '#333', 'noteTextColor': '#ccc', 'noteBorderColor': '#555'}}}%%
sequenceDiagram
    participant UI as 前端 UI
    participant UC as update_core<br/>Tauri Command
    participant Parse as parse_github_release_info
    participant DL as download_release_asset
    participant SHA as get_expected_sha256<br/>+ verify_sha256
    participant Extract as extract_core_binary
    participant Core as Mihomo 内核

    UI->>UC: 触发内核更新(URL)
    UC->>Parse: 解析更新 URL
    Parse->>Parse: host 必须为 github.com
    Parse->>Parse: /MetaCubeX/mihomo/releases/download/{ver}/{asset}
    Parse->>Parse: 版本格式 + 资产名校验
    Parse-->>UC: (version, asset_name)

    UC->>DL: 下载到 core_update_{uuid}.tmp
    Note right of DL: 可信域名检查<br/>直连优先,失败时经托管内核重试<br/>100 MB 上限,fsync
    DL-->>UC: 下载完成

    UC->>SHA: 获取预期哈希并校验
    Note right of SHA: 优先使用元数据缓存<br/>否则查询 releases/tags/{ver}<br/>找不到哈希 → 拒绝安装
    SHA-->>UC: 校验通过

    UC->>Extract: 解压到 {binary}_{uuid}.tmp
    Note right of Extract: 链接条目拒绝 / 路径遍历拒绝<br/>200 MB + 200:1 压缩炸弹检测<br/>空二进制拒绝
    Extract-->>UC: 解压完成

    UC->>Core: 停止当前内核(失败则中止更新)
    UC->>UC: 旧二进制 → .backup(失败则中止)<br/>新二进制 → 目标(重试 5 次)<br/>设置可执行权限(失败回滚)
    UC->>Core: 重启内核
    alt 启动失败
        UC->>UC: 删除新二进制,恢复 .backup
    end
    UC-->>UI: 更新完成 / 已回滚

7.1 可信域名白名单

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

download_release_asset 在下载前检查请求 URL;下载地址本身由代码按固定模板拼出(https://github.com/MetaCubeX/mihomo/releases/download/{version}/{asset}、https://github.com/Juwan-Hwang/Zephyr/releases/download/{tag}/{asset})。GitHub 下载会重定向到 CDN 存储域名,最终文件的可信度由后续的哈希(以及客户端的签名)校验保证,而不依赖下载链路本身。

7.2 版本格式验证

rust
fn validate_version_format(version: &str) -> bool {
    if !version.starts_with('v') { return false; }
    if version.len() < 3 || version.len() > 25 { return false; }

    // 拒绝路径遍历与 shell/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 version_part = &version[1..];
    let (main_version, _pre_release) = match version_part.find('-') {
        Some(idx) => (&version_part[..idx], Some(&version_part[idx + 1..])),
        None => (version_part, None),
    };
    let parts: Vec<&str> = main_version.split('.').collect();
    if parts.len() != 3 { return false; }
    parts.iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
}

7.3 更新 URL 解析

rust
fn parse_github_release_info(url: &str) -> Option<(String, String)> {
    if let Ok(parsed) = reqwest::Url::parse(url) {
        if parsed.host_str() != Some("github.com") {
            return None; // 仅允许 github.com
        }
        let segments: Vec<&str> = parsed.path_segments()?.collect();
        if segments.len() >= 5
            && segments.first().is_some_and(|s| *s == "MetaCubeX")
            && segments.get(1).is_some_and(|s| *s == "mihomo")
            && segments.get(2).is_some_and(|s| *s == "releases")
            && segments.get(3).is_some_and(|s| *s == "download")
        {
            let version = segments.get(4).map(|s| (*s).to_owned()).unwrap_or_default();
            let asset_name = segments.get(5).map(|s| (*s).to_owned()).unwrap_or_default();
            if !validate_version_format(&version) { return None; }
            let asset_lower = asset_name.to_lowercase();
            if !asset_lower.starts_with("mihomo-") { return None; }
            if !asset_lower.ends_with(".zip") && !asset_lower.ends_with(".gz") { return None; }
            if asset_name.contains("..") || asset_name.contains('/') || asset_name.contains('\\')
                || asset_name.contains('\0') || asset_name.contains('<') || asset_name.contains('>')
            {
                return None;
            }
            return Some((version, asset_name));
        }
    }
    None
}

7.4 SHA256 完整性校验

版本来源:最新版本通过 REST API(/releases/latest)与 Atom Feed(/releases.atom,不受 API 限流影响)并行竞速获取。Atom Feed 只提供标签(跳过 -alpha/-beta/-rc/-pre/-draft/-dev 预发布),不含哈希;API 获胜时其资产 digest 被缓存到 DIGEST_CACHE,供随后的更新使用。

rust
fn parse_digest(digest: &str) -> Option<String> {
    let hash = digest.strip_prefix("sha256:")?;
    (hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())).then(|| hash.to_lowercase())
}

async fn get_expected_sha256(app: Option<&tauri::AppHandle>, version: &str, asset_name: &str)
    -> Result<String, String>
{
    // 1. 缓存命中(同一版本、同一资产名)
    // 2. 否则查询 https://api.github.com/repos/MetaCubeX/mihomo/releases/tags/{version}
    //    取对应资产的 digest 字段
    // ...
    Err(format!(
        "Could not find SHA256 hash for {asset_name}. Verification is required for security."
    ))
}

fn verify_sha256(file_path: &std::path::Path, expected_hash: &str) -> Result<(), String> {
    let mut file = std::fs::File::open(file_path).map_err(|e| e.to_string())?;
    let mut hasher = Sha256::new();
    let mut buffer = [0u8; 8192];
    loop {   // 流式计算,支持大文件
        let n = file.read(&mut buffer).map_err(|e| e.to_string())?;
        if n == 0 { break; }
        hasher.update(buffer.get(..n).unwrap_or(&[]));
    }
    let hex_result = hex::encode(hasher.finalize());
    if hex_result.to_lowercase() == expected_hash.to_lowercase() {
        Ok(())
    } else {
        Err(format!("SHA256 mismatch: expected {expected_hash}, got {hex_result}"))
    }
}

哈希获取失败或不匹配时,已下载的临时文件立即删除,更新中止。

7.5 客户端自更新:SHA256 + Minisign 双重校验

update_client 下载 Zephyr 安装包后,必须同时通过两道校验才会打开安装程序:

rust
// 1. Release 必须携带格式正确的 SHA256 digest,否则删除安装包并拒绝
let Some(hash) = info.download_digest.as_deref().and_then(|digest| {
    digest
        .strip_prefix("sha256:")
        .filter(|h| h.len() == 64 && h.chars().all(|c| c.is_ascii_hexdigit()))
}) else {
    let _ = std::fs::remove_file(&dest_path);
    return Err("No valid integrity digest available for this release. Installer was deleted for security.".to_owned());
};
verify_sha256(&dest_path, hash)?;

// 2. Minisign Ed25519 签名:下载 {download_url}.minisig(读取时即限制 4 KB)
const MAX_SIG_SIZE: usize = 4096;
let signature_content = sig_client.get_text_capped(&minisig_url, MAX_SIG_SIZE).await?;
crate::minisign_verify::verify_minisign_signature(&dest_path, &signature_content)?;

// 3. 全部通过后才用系统默认程序打开安装包
tauri_plugin_opener::open_path(&dest_path, None::<&str>)?;
rust
// minisign_verify.rs
const ZEPHYR_PUBLIC_KEY: &str = "RWRkVQqGWolsGBbhZRpFQ3sPG/i4A2dGlf2E0NXJvZAviVd/I+sUzw1Z";

pub fn verify_minisign_signature(file_path: &Path, signature_content: &str) -> Result<(), String> {
    let public_key = minisign::PublicKey::from_base64(ZEPHYR_PUBLIC_KEY)?;
    let signature_box = minisign::SignatureBox::from_string(signature_content)?;
    let mut file = std::fs::File::open(file_path)?;
    minisign::verify(&public_key, &signature_box, &mut file, true, false, false)?;
    Ok(())
}
  • 公钥硬编码在二进制中:即使 GitHub Release 被篡改(安装包与 digest 同时被替换),没有私钥也无法伪造签名
  • 任一步失败都会删除安装包("Installer deleted for security")
  • 安装包下载到系统临时目录下 UUID 命名的子目录
  • 便携版不支持自更新(运行中的 exe 被锁定),直接返回提示

签名私钥只在 Release 工作流中使用(见第十三层)。

7.6 GeoIP / GeoSite 更新

先用短超时的元数据客户端获取 geoip.dat.sha256sum / geosite.dat.sha256sum,再下载数据文件并逐一校验;两份文件都通过后才以"重命名备份 → 换入新文件"的方式应用,任一失败时两份同时回滚。临时文件名带 UUID,TempFileGuard 确保任何提前返回都不会遗留临时文件。

注意:Geo 数据的哈希与文件来自同一个 Release,提供的是传输完整性保证,而非独立的来源真实性保证。

7.7 更新网络通道

所有更新流量共用 UpdateClients:

设计 说明
直连优先 首先直连 GitHub;只有在请求失败、返回 5xx 或路由相关状态(403/407/408/429/451)时,才经 Zephyr 自己的托管内核重试
不使用环境代理 两条路径都 .no_proxy(),ALL_PROXY 等环境变量可能被无关软件设置
正文中途失败重试一次 已收到响应头但正文传输中断时,经托管内核从零重试一次(截断已写入的部分,不做拼接)
元数据上限 发布 JSON、Atom Feed、.sha256sum 正文上限 10 MB,客户端总超时 60 秒
资产上限 内核与客户端安装包 100 MB;Geo 数据单文件 100 MB;流式下载总时限 30 分钟、单次读超时 30 秒
错误页防护 非 2xx 响应不会被写入磁盘(否则会在哈希阶段误报为 "SHA256 mismatch")

7.8 TOCTOU 防护与原子替换

rust
// 不可预测的临时文件名
let temp_suffix = uuid::Uuid::new_v4();
let archive_path = paths.core_dir.join(format!("core_update_{temp_suffix}.tmp"));
let temp_exe_path = paths.core_dir.join(format!("{}_{}.tmp", core_manager::core_binary_name(), temp_suffix));

替换顺序:

  1. 停止内核必须成功:停止失败则删除已解压的二进制并中止,避免在旧二进制仍运行(macOS TUN 下为 root 进程)时报告成功
  2. 旧二进制移至 .backup:移动失败则中止(无备份就无法回滚)
  3. 新二进制移入:rename 最多尝试 5 次、间隔 500 ms(应对 Windows 文件锁),仍失败则恢复备份
  4. 设置可执行权限(macOS/Linux):失败则回滚
  5. 重启内核:成功后删除备份;失败则删除新二进制并恢复备份

7.9 Windows 代码签名(SignPath)

Windows 发布产物通过 SignPath Foundation 进行 Authenticode 代码签名,覆盖 x64 与 ARM64 架构。Release 工作流的签名步骤:

  1. 暂存:收集 5 个 Windows 产物(setup-full.exe、setup-lite.exe、full.msi、lite.msi、portable.exe)到 unsigned-staging/,每个模式必须恰好匹配 1 个文件
  2. 上传:通过 actions/upload-artifact 上传未签名产物(保留 3 天),获得 artifact-id
  3. 签名:signpath/github-action-submit-signing-request 提交 release-signing 策略的签名请求,同步等待,超时 60 分钟
  4. 验证替换:PowerShell 逐个验证已签名产物的 Authenticode 状态为 Valid 且签名者为 SignPath Foundation,验证通过后才替换 dist-artifacts/ 中的未签名文件;Portable ZIP 由签名后的 exe 打包

部分签名防护:任何一个产物签名缺失或验证失败,整个流程立即报错退出,防止未签名二进制混入最终 Release。


第八层:速率限制

Zephyr 实现了两套独立的速率限制机制,外加若干专用冷却机制。

8.1 通用命令限流 — RateLimiter + rate_limit! 宏

lib.rs 中的 RateLimiter 记录每个命令最近一次调用的时间,要求两次调用之间至少间隔给定毫秒数:

rust
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_or_else(std::sync::PoisonError::into_inner);
        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_owned(), now);
        true
    }
}

macro_rules! rate_limit {
    ($limiter:expr, $cmd:expr, $ms:expr) => {
        if !$limiter.check_rate_limit($cmd, $ms) {
            let msg = format!("{} rate limited (min {}ms interval), please wait", $cmd, $ms);
            emit_warn!(Core, CORE_START_FAILED, "RateLimit: {msg}");
            return Err(msg);
        }
    };
}
命令 最小间隔 说明
download_sub 5000 ms 订阅下载,避免对订阅服务器造成压力
rate_limited_send_notification 1000 ms OS 通知,防止通知轰炸(标题 ≤128、正文 ≤1024 字符)
rate_limited_register_shortcut 500 ms 全局快捷键注册
rate_limited_unregister_shortcut 500 ms 全局快捷键注销

锁中毒时通过 PoisonError::into_inner 恢复,一次 panic 不会让限流器永久失效。

8.2 Prism Engine — 滑动窗口限流器

prism/rate_limiter.rs 实现了多 key 滑动窗口限流器:每个 key 维护一个有序时间戳列表,检查时先剔除窗口外的记录,窗口内调用数达到上限则返回 Err(retry_after)(距离最早一条记录过期的时间)。未注册的 key 一律放行。

%%{init: {'themeVariables': {'fontSize': '9px', 'nodeBorder': '1px', 'clusterBorder': '1px', 'edgeLabelHeight': '8px'}, 'flowchart': {'curve': 'basis', 'nodeSpacing': 12, 'rankSpacing': 15}}}%%
flowchart TD
    A["state.check_rate_limit(key)"] --> B{"key 已注册?"}
    B -- "否" --> OK["允许"]
    B -- "是" --> C["剔除 ≤ now - window 的时间戳"]
    C --> D{"窗口内调用数 ≥ max_calls?"}
    D -- "否" --> E["记录 now"] --> OK
    D -- "是" --> R["拒绝<br/>Err(retry_after)"]

    style OK fill:#2ecc71,stroke:#27ae60,color:#fff
    style R fill:#e74c3c,stroke:#c0392b,color:#fff
key 限制 说明
script_execute 10 次 / 10 s 脚本测试执行,防止沙箱资源耗尽
script_execute_write 5 次 / 10 s 会写入配置的脚本执行
rule_import_url 5 次 / 10 s 规则库 URL 导入,限制远程拉取频率

8.3 专用冷却与互斥

机制 位置 说明
UWP 环回免除冷却 uwp_loopback.rs UWP_OPERATION_COOLDOWN_SECS = 300(5 分钟),基于 AtomicU64 时间戳;该操作会调用系统级 CheckNetIsolation
内核启动互斥 core/core_process.rs CORE_STARTING 原子锁防止内核并发启动(见第十层)
全局模式回退互斥 core/subscription.rs 同一时刻只允许一个订阅回退切换全局模式
Prism 输入大小 prism/types.rs 脚本/配置文本 ≤ 10 MB(MAX_INPUT_SIZE);KV 键、组名、覆写名等标识符 ≤ 1024 字节(MAX_NAME_SIZE,仅在新建时强制,旧数据仍可读取和删除)

第九层:文件权限控制

设计意图

敏感配置文件(加密密钥、订阅元数据、API secret 等)必须限制访问权限,防止本地其他用户或进程读取。实现位于 core/secure_io.rs。

两种写入原语

函数 行为 适用场景
write_file_secure fs::write 后立即设置 owner-only 权限,失败返回错误 只由单一写者访问的文件
write_file_secure_atomic 写入同目录下隐藏的唯一临时文件(.zephyr-write-{pid}-{seq}.tmp)→ fsync → 设置权限 → rename 覆盖(最多 3 次、间隔 50 ms)→ Unix 上 fsync 目录 可能被并发读取的文件:settings.json、metadata.json、配置文件、Prism 覆写等

write_file_secure_atomic 的关键性质:

  • Unix 上临时文件以 create_new + mode(0o600) 创建,内容从未以宽松权限存在过
  • 权限设置先于 rename 完成,所以返回 Err 时目标文件必定仍是旧内容;Windows 的 owner-only DACL 随文件一起被 rename
  • 临时文件名不以 .yaml 结尾且为隐藏文件,崩溃遗留物不会被配置列表或加密批次误读
  • 目录 fsync 保证"已告知保存成功"的数据在断电后不会回退

跨平台权限设置

Unix (macOS/Linux)

rust
#[cfg(unix)]
{
    use std::os::unix::fs::PermissionsExt as _;
    fs::set_permissions(path, fs::Permissions::from_mode(0o600))
        .map_err(|e| format!("Failed to set permissions on {path:?}: {e}"))?;
}

0o600(rw-------):仅文件所有者可读写。权限设置失败时返回错误而非静默忽略。

Windows

Windows 使用 DACL(Discretionary Access Control List)实现等效的权限控制:

rust
#[cfg(target_os = "windows")]
{
    // 构建两条 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 as *mut _;

    // 规则 2:SYSTEM 账户 → 完全访问(CreateWellKnownSid(WinLocalSystemSid))
    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 as *mut _;

    // SetEntriesInAclW 生成新 ACL,SetSecurityInfo 应用;
    // PROTECTED_DACL_SECURITY_INFORMATION 阻止继承父目录的 ACE
    SetSecurityInfo(
        handle,
        SE_FILE_OBJECT,
        DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
        ptr::null_mut(), ptr::null_mut(),
        new_acl,
        ptr::null_mut(),
    );
}

每一步 Win32 调用失败都返回带 [SECURITY] 前缀的错误("file permissions may be insecure"),调用方可以区分"写入成功且安全"与"已写入但权限不安全"。

平台 权限模型 效果
Unix 0o600 仅所有者可读写
Windows 受保护 DACL(当前用户 + SYSTEM) 当前用户和 SYSTEM 完全访问,其他用户无权限,不继承父目录 ACE

注意:Windows 管理员始终可以通过获取文件所有权来访问文件,这与 Unix 的 root 权限类似 —— 0o600 保护的是普通用户之间的隔离,而非抵御特权账户。

受保护文件

文件 写入方式 内容 敏感级别
.machine_key write_file_secure 持久化随机密钥(仅在无硬件指纹时生成) 极高
metadata.json write_file_secure_atomic 加密的订阅 URL 和流量信息 高
run_config.yaml write_file_secure 运行时配置(含 API secret,明文) 高
settings.json write_file_secure_atomic 用户设置(含自定义参数) 中
profiles/*.yaml write_file_secure_atomic(经 write_profile_file) 订阅/导入的代理配置(可选加密) 中
Prism 覆写 / 规则库 / KV write_file_secure(_atomic) 用户脚本与规则 中

受保护目录

rust
// profiles 目录 — 0o700(core_process.rs)
#[cfg(unix)]
{
    use std::os::unix::fs::PermissionsExt as _;
    let _ = fs::set_permissions(&paths.profiles_dir, fs::Permissions::from_mode(0o700));
}
目录 权限 内容 说明
profiles/ 0o700 订阅配置文件 防止其他用户列出或读取代理配置
加密密钥父目录 0o700(新建时) .machine_key 防止其他用户访问密钥文件

macOS TUN 模式下的 root 内核日志写入 /Library/Logs/Zephyr/,日志文件本身被 chown 给当前用户并 chmod 600(见第十二层)。


第十层:运行时安全加固

withGlobalTauri 已禁用

tauri.conf.json 中 withGlobalTauri 设为 false,window.__TAURI__ 不会暴露到全局作用域。前端通过 Tauri v2 始终注入的 window.__TAURI_INTERNALS__ 访问 IPC,所有桥接逻辑集中在 api.js:

javascript
// api.js — Tauri IPC 桥接层
const _t = typeof window !== 'undefined' ? window.__TAURI_INTERNALS__ : undefined;

function invoke(cmd, args) {
  if (!_t?.invoke) throw new Error('[API] Tauri IPC not available');
  return _t.invoke(cmd, args);
}

async function listen(event, handler) {
  if (!_t?.transformCallback) throw new Error('[API] Tauri IPC not available');
  const callbackId = _t.transformCallback(handler);
  const eventId = await invoke('plugin:event|listen', {
    event,
    target: { kind: 'Window', label: 'main' },
    handler: callbackId,
  });
  // ... 返回 unlisten 清理函数
}
  • 攻击面最小化:__TAURI_INTERNALS__ 无法通过配置禁用,但相比 __TAURI__ 不提供高级封装 API
  • 集中管理:所有 IPC 调用经 api.js 统一入口
  • CSP 配合:script-src 'self' 阻止内联脚本执行,即使 __TAURI_INTERNALS__ 存在也难以通过注入脚本滥用

API 绑定隔离

Mihomo 内核的 RESTful API 强制绑定到 127.0.0.1,防止局域网内的其他设备访问(prepare_runtime_config 与 restore_security_settings 双重保证,见第六层)。不校验 secret 的控制入口(Unix socket、命名管道、DoH 服务器等)在配置加载时即被清除。

API Secret 注入

每次启动内核时生成随机 secret 并注入配置:

rust
pub(super) fn generate_secret() -> String {
    rand::rng()
        .sample_iter(rand::distr::Alphanumeric)
        .take(32)
        .map(char::from)
        .collect()
}

macOS 以 root 重启内核时会沿用当前 secret;若配置中 secret 缺失或为空则重新生成,空 secret 会让任何未认证的控制器通过就绪探测。

Secret 泄露防护

read_config 命令向前端返回运行时配置时,由 read_config_with_io 调用 strip_secret_from_yaml 移除 secret 字段:

rust
pub fn strip_secret_from_yaml(yaml: &mut YamlValue) {
    if let YamlValue::Mapping(ref mut map) = yaml {
        map.remove(YamlValue::String("secret".to_owned()));
    }
}

日志与错误信息中的订阅 URL 经 redact_url_in_string / mask_url 脱敏后再输出(如 fetch_text 失败时的日志)。

XSS 防护:escapeHtml + sanitizeHtml + html/safeHtml

前端 apps/desktop/src/utils/sanitize.js 模块提供了完整的安全函数族。

escapeHtml / escapeAttr — HTML 实体转义

纯字符串替换实现(不分配 DOM 节点),转义 &、<、>、"、' 五个字符,结果可安全用于元素文本和带引号的属性值:

javascript
const HTML_ESCAPE_TEST_RE = /[&<>"']/;
const HTML_ESCAPE_RE = /[&<>"']/g;
const HTML_ESCAPE_MAP = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };

export function escapeHtml(str) {
    if (typeof str !== 'string') return '';
    if (!HTML_ESCAPE_TEST_RE.test(str)) return str;   // 快速路径:无特殊字符直接返回
    return str.replace(HTML_ESCAPE_RE, (ch) => HTML_ESCAPE_MAP[ch]);
}

export function escapeAttr(str) {
    return escapeHtml(str);   // 别名:escapeHtml 已转义引号
}
  • 不做 Unicode 规范化:显示内容与输入完全一致(节点名、订阅名中的全角字符被原样保留)。只有上述五个 ASCII 字符对 HTML 解析器有意义,规范化不增加安全性
  • 类型检查:非字符串输入返回空串
  • 限制:不适用于不带引号的属性(空白、=、反引号未转义),属性值必须加引号

sanitizeHtml — 白名单 HTML 清理器

用于需要保留部分 HTML 结构的场景(模态框自定义内容、Release Notes 等),采用严格白名单策略:

  • NFKC 规范化:input.normalize('NFKC'),防止 Unicode 同形字符绕过标签/属性检测
  • <template> 解析:使用 <template>.content(惰性 DocumentFragment,不执行脚本)替代 DOMParser,保留孤立的 <tr>/<td>/<th>/<li> 等元素
  • 标签白名单:格式化标签、列表、表格、a/img、表单控件、SVG 图标元素;不含 script/iframe/object/embed/style 等
  • STRIP_CONTENT_TAGS:script/style/iframe/noscript/object/embed/applet/template 被移除时内容一并丢弃,不会作为文本泄漏
  • 子节点安全:移除非白名单标签时,被上移的子节点会被递归 sanitizeNode(),防止因遍历快照而跳过检查
  • 属性白名单:按标签粒度控制允许的属性;data-* 与 aria-* 全局允许
  • 事件处理器过滤:/^on/i 拦截所有 on* 事件属性
  • URL 属性校验:href、src 以及 SVG <use> 的 xlink:href 都经过 isSafeUrl。校验前按浏览器解析 scheme 的方式规范化(去除首尾 C0 控制字符与空格、删除所有 tab/换行、转小写),再拒绝 javascript: / vbscript:;data: 仅在 allowDataImages=true 时允许用于 <img src>。首尾修剪使用下标循环而非正则,长空白串不会造成二次方回溯
  • style 属性清理:含 expression、javascript、url( 的 style 属性整体移除
  • 反向 Tabnabbing:带 target 的 <a> 强制设置 rel="noopener noreferrer"
  • 非浏览器环境:循环剥离标签直到结果稳定,防止 <<script>> → <script> 的重组攻击

html / safeHtml — 安全模板标签

javascript
import { html, safeHtml } from './utils/sanitize.js';

// html:所有插值自动 escapeAttr()(适用于纯文本)
element.innerHTML = html`<div>${userInput}</div>`;

// safeHtml:插值保留 HTML 结构,整体结果经 sanitizeHtml() 白名单过滤(适用于富文本)
element.innerHTML = safeHtml`<div>${htmlContent}</div>`;
  • 数组扁平化:${[a, b, c]} 递归展开为 abc(无逗号分隔),null/undefined 输出空串
  • html 的限制:不拦截 URL 属性中的 javascript:/data:(需要 URL 时改用 safeHtml 或先校验),也不支持嵌套(会二次转义)

Markdown 渲染

更新对话框的 Release Notes 由 utils/markdown.js 的 markdownToHtml 渲染(零依赖的子集实现):

  1. 代码块与行内代码先以私用区字符为界的占位符替换,内容单独 escapeHtml
  2. 其余全文一次性 escapeHtml,此后所有格式化只在已转义文本上插入固定标签
  3. 链接:javascript: / vbscript: / data: 降级为纯文本,mailto: 只显示地址,其余输出 target="_blank" rel="noopener"
  4. 图片:只显示 alt 文本,不生成 <img>
  5. 最终结果再经 sanitizeHtml();调用方 update-dialog.js 渲染前又包了一层 sanitizeHtml,点击链接时经 releaseNoteHref() + openUrl() 双重协议校验

构建期强制:ESLint no-unsanitized

apps/desktop/eslint.config.mjs 内联了一个 no-unsanitized 插件(等价于 eslint-plugin-no-unsanitized,内联以避免 pnpm 严格模式下的解析问题),以 error 级别检查:

  • innerHTML / outerHTML 赋值
  • insertAdjacentHTML / document.write / writeln 调用

只有字面量、或经 html/safeHtml/escapeHtml/escapeAttr/sanitizeHtml 处理的表达式才被认为安全。同时开启 no-eval、no-implied-eval、no-new-func、no-script-url。确需例外的位置必须以 eslint-disable-next-line 加注释说明理由(例如 sanitizeHtml 内部对惰性 <template> 的赋值;第三方许可证查看器正文通过 textContent 设置)。

DOM ID 生成:CSPRNG 替代 Math.random

apps/desktop/src/utils/dom-id.js 使用 Web Crypto API crypto.getRandomValues() 生成随机分量,并附加单调递增计数器保证会话内唯一;Web Crypto 不可用时只使用计数器,不回退到 Math.random()(满足 SAST 规则 S2245)。前缀会被规范化为合法的 DOM ID,避免破坏 aria-controls / aria-labelledby 的 IDREF 解析。

原型污染防护

apps/desktop/src/ui/advanced.js 中的 deepMerge() 用于动态配置引擎的对象合并,实现了多层原型污染防护:

javascript
function isSafeMergeKey(key) {
    return key !== '__proto__' && key !== 'constructor' && key !== 'prototype';
}

function deepMerge(target, source) {
    if (!isPlainObject(target) || Array.isArray(target)) return source;
    if (!isPlainObject(source) || Array.isArray(source)) return source;

    for (const key of Object.keys(source)) {
        if (!isSafeMergeKey(key)) continue;                                   // 1. 危险键过滤
        if (!Object.prototype.hasOwnProperty.call(source, key)) continue;     // 2. 仅自身属性
        // ...
        // 3. 新建对象使用 Object.create(null),不直接复用攻击者提供的对象引用
        // 4. Object.defineProperty 定义属性,不触发原型链上的 setter
    }
}

YAML 合并深度限制

merge_yaml(crates/core/src/config/merge.rs)限制递归深度为 50 层,防止深层嵌套补丁导致栈溢出:

rust
pub fn merge_yaml(base: &mut YamlValue, patch: &YamlValue, depth: usize) -> Result<(), String> {
    if depth > 50 {
        return Err("YAML nesting depth exceeded limit".to_owned());
    }
    // ... 补丁中的 null 值删除对应键
}

内核进程并发控制

使用 CORE_STARTING 原子锁防止内核并发启动:

rust
// 等待前一次启动完成(最多 10 秒)
let mut wait_ms = 0;
while CORE_STARTING
    .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
    .is_err()
{
    if wait_ms > 10000 {
        // 带期望启动时间的恢复性重启不抢锁(另一次启动仍在进行,如 macOS TUN 等待授权)
        if expect_started_at.is_some() {
            return Err(CORE_START_SUPERSEDED.to_owned());
        }
        emit_warn!(Core, CORE_START_FAILED, "Core start lock timeout, forcing reset");
        CORE_STARTING.store(false, Ordering::SeqCst);
        // 重新尝试获取
    }
    tokio::time::sleep(std::time::Duration::from_millis(200)).await;
    wait_ms += 200;
}
// ResetGuard 在函数退出(包括提前返回)时释放标志

Panic 安全

全局 panic hook 确保异常退出时清理子进程:

rust
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
    emit_error!(Core, CORE_CRASHED, "Application panicked, cleaning up child processes...");
    crate::core_manager::kill_mihomo();
    default_panic(info);
}));

write_config_file、update_core 等长耗时命令另外用 catch_unwind 包裹,panic 会被转换为错误返回给前端并记录 *_PANIC_GUARD 事件,而不是让整个命令线程崩溃。

应用退出清理

rust
tauri::RunEvent::Exit => {
    // 通知订阅调度器优雅停止
    scheduler_state.shutdown();
    kill_mihomo();
    // macOS:只清理本应用托管的 root 内核(以记录的可执行文件路径确认),
    // 无关的 root mihomo 服务不受影响
    #[cfg(target_os = "macos")]
    if has_confirmed_root_mihomo(root_core_exe.as_deref()) {
        let _ = kill_managed_root_mihomo_as_root(root_core_exe.as_deref());
    }
    // ...
}

Prism 沙盒边界验证

script_set_limits 命令通过 validate_script_limits 执行严格的边界校验,防止资源耗尽和配置错误:

rust
pub const MIN_EXECUTION_TIME_MS: u64 = 100;
pub const MAX_EXECUTION_TIME_MS: u64 = 300_000; // 5 minutes
pub const MIN_MEMORY_BYTES: usize = 1024 * 1024; // 1 MB
pub const MAX_MEMORY_BYTES: usize = 512 * 1024 * 1024; // 512 MB
pub const MIN_LOOP_ITERATIONS: u64 = 1_000;
pub const MAX_LOOP_ITERATIONS: u64 = 10_000_000;
pub const MIN_RECURSION_DEPTH: u32 = 8;
pub const MAX_RECURSION_DEPTH: u32 = 512;
参数 最小值 最大值 说明
执行时间 100 ms 300 s 防止长时间占用 QuickJS 线程
内存 1 MB 512 MB 防止内存耗尽
循环迭代 1,000 10,000,000 防止无限循环
递归深度 8 512 防止栈溢出

超出范围的值会被拒绝并返回包含实际值与合法范围的错误信息。

沙盒权限提升审计

script_set_sandbox 的变更通过 PRISM_SANDBOX_CHANGED(事件码 3007)进行结构化审计日志记录,按权限变更方向分级:

  • 权限提升(新授予 allow_filesystem 或 allow_child_process)→ WARN 级别,高可见性安全审计
  • 其他变更 → INFO 级别
rust
let newly_elevated = (!prev_fs && allow_filesystem) || (!prev_cp && allow_child_process);
if newly_elevated {
    emit_warn!(Prism, PRISM_SANDBOX_CHANGED,
        "[SECURITY WARNING] High-privilege script sandbox permissions granted: ...");
} else {
    emit_info!(Prism, PRISM_SANDBOX_CHANGED, "[SECURITY] Script sandbox config updated: ...");
}

前端保存处理(ui/plugins-smart.js)实现了三重防护:

  • fresh-state 确认:保存前重新读取后端当前配置,仅当文件系统或子进程权限从"关"变为"开"时弹出安全确认对话框
  • in-flight 守卫:保存进行中忽略重复点击
  • 取消回滚:用户取消时重新加载后端配置,恢复界面状态

网络切换 Fail-Closed 协议验证

NetworkChangeCoordinator 在网络切换后重新应用 Prism 配置时,使用双重验证确保配置真正生效:

rust
pub struct CoreApplyResult {
    /// Mihomo REST API 返回的 HTTP 状态码(连接失败或未知时为 None)
    pub http_status: Option<u16>,
    /// 应用层载荷确认的热重载结果(缺失或未知时为 None)
    pub hot_reload_success: Option<bool>,
}

pub fn is_success(&self) -> bool {
    matches!(
        (self.http_status, self.hot_reload_success),
        (Some(code), Some(true)) if is_http_success(code)   // 200..=299
    )
}
  • Fail-closed 默认:任一字段为 None 都视为失败,不做乐观假设
  • 不跨层合成:hot_reload_success 不推断 http_status,反之亦然;HTTP 状态码只来自 HTTP 层
  • 线程隔离:HttpStatusGuard(prism/host.rs)以 RAII 方式在同一 OS 线程内设置并取回本次应用的 HTTP 状态,防止 Tokio 工作线程复用导致的线程局部状态串扰

Zephyr 注册了 clash:// 自定义 URL 协议,允许从浏览器或其他应用快速导入订阅(clash://install-config?url=<encoded>&name=<encoded>)。由于 Deep Link 接收外部不可信输入,实现了完整的防护链。

输入验证

rust
// deep_link.rs
const MAX_NAME_LEN: usize = 128;

const DANGEROUS_CHARS: &[char] = &[
    '.', '/', '\\', '\0', '\n', '\r',
    // Windows 保留文件名字符(跨平台一致)
    '<', '>', ':', '"', '|', '?', '*',
];

pub fn parse_deep_link(raw: &str) -> Option<DeepLinkPayload> {
    // 1. 用 url::Url::parse 解析(而非 starts_with),仅接受 clash:// scheme
    let parsed = url::Url::parse(raw).ok()?;
    if parsed.scheme() != "clash" {
        return None;
    }
    let action = parsed.host_str()?.to_owned();
    // 2. 读取 url / name 查询参数,缺少 url 时丢弃
    // ...
    // 3. URL 截断到 2048 字节(回退到最近的 UTF-8 字符边界,避免多字节字符 panic)
    // 4. 再次用 url::Url::parse 校验:仅 http/https,且必须有 host
    //    (防止 `http://[email protected]` 一类基于字符串前缀的绕过)
    // 5. name 净化
    let name = sanitize_name(&name_val);
    Some(DeepLinkPayload { action, url: safe_url.to_owned(), name })
}

fn sanitize_name(raw: &str) -> String {
    // 危险字符替换为 '_'(而不是拒绝整个链接)
    let sanitized: String = raw.chars()
        .map(|c| if DANGEROUS_CHARS.contains(&c) { '_' } else { c })
        .collect();
    // 去除结尾空白(Windows 文件名非法)
    let trimmed = sanitized.trim_end();
    // Windows 保留设备名加 '_' 前缀:CON/PRN/AUX/NUL/COM1-9/LPT1-9
    // 截断到 128 个字符
    // ...
}

投递与用户确认

  • 待处理队列:解析通过的链接进入后端队列(最多 8 条,满时丢弃最旧的),后端只发出一个"有链接待取"的通知事件,页面在监听器就绪后通过 take_pending_deep_links 每次取一条;页面加载/重载期间链接不会丢失
  • 用户确认:前端 ui/deep-link.js 在下载前弹出确认对话框,展示订阅名称与完整 URL,用户确认后才调用 download_sub
  • 后续校验:确认后的下载完整走第三层的 SSRF 校验与第六层的危险键清除;订阅名再经 validate_subscription_name → sanitize_base_filename
  • Windows 管理员实例:以管理员身份运行时,单实例插件的消息窗口单独放行来自普通权限进程的 WM_COPYDATA,使后续 clash:// 启动仍能被转交(见第十二层)

安全设计要点

防护层 实现 防御目标
URL 解析 url::Url::parse() 防止 scheme 注入与字符串前缀绕过
协议白名单 仅 clash:// + http(s)://(且必须有 host) 防止 javascript:/file: 等
名称净化 DANGEROUS_CHARS 替换为 _ 防止目录穿越与非法文件名
Windows 保留设备名 CON/PRN/AUX/NUL/COM1-9/LPT1-9 加 _ 前缀 跨平台文件名安全
尾部空白清理 trim_end() Windows 文件名非法字符
输入截断 URL 2048 字节 / name 128 字符 防止内存滥用
队列上限 最多 8 条待处理 防止链接轰炸
用户确认 前端确认对话框 防止静默导入
快照测试 7 个 insta 快照测试 覆盖正常/异常/遍历/保留名/危险字符/截断

第十二层:特权提升与 TUN 安全

TUN 模式需要创建虚拟网卡并改写路由,三个平台的特权模型各不相同。Zephyr 的原则是:只在用户明确操作时请求特权、只提升必要的那部分、执行特权操作前复核目标身份。

平台 特权方式 实现
Windows 整个应用经 UAC 以管理员身份重启 elevation.rs
macOS 仅内核经管理员授权以 root 运行 core/tun_manager.rs(osascript ... with administrator privileges)
Linux 一次性为内核二进制授予 capability core/tun_manager.rs(pkexec + setcap + polkit 规则)

12.1 Windows:UAC 管理员重启

Windows 上内核继承应用的令牌,且应用通过管道与 Job Object 管理内核,无法单独提升内核,因此整个应用经 ShellExecuteExW + runas 动词重启。

只对拆分管理员令牌提升:

rust
fn has_linked_admin_token() -> bool {
    // GetTokenInformation(TokenElevationType) == TokenElevationTypeLimited
}

pub fn restart_as_admin_cmd(app: tauri::AppHandle, enable_tun: bool) -> Result<(), String> {
    if is_elevated() {
        return Err("already_elevated".to_owned());
    }
    if !has_linked_admin_token() {
        return Err("not_admin_user".to_owned());
    }
    // ...
}

在标准用户账户上,UAC 会要求另一个用户的凭据,提升后的进程将以那个用户身份运行、使用那个用户的配置目录 —— 当前用户的订阅和配置都会"消失"。因此只有 UAC 的过滤管理员令牌(Limited)才提供重启选项,can_elevate_as_self 命令让前端据此决定是否显示提示。

替换旧实例时的 PID 复核:

  • 新实例通过 --zephyr-wait-pid=<pid> 得知要替换的旧实例,启动后先等待其退出(最长 15 秒),超时则 TerminateProcess
  • 等待与终止之前用 QueryFullProcessImageNameW 确认目标进程运行的是同一个可执行文件(规范化路径、大小写不敏感比较),防止 PID 被复用或参数被伪造时误杀其他进程;参数指向自身 PID 时直接忽略

TUN 偏好与提升状态绑定:重启前先把 TUN 偏好保存为开启(保存失败则不重启);用户拒绝 UAC 或启动失败时恢复原偏好。非提升状态下 to_global_prefs 把 TUN 强制为关闭(而不是"未设置"),防止配置文件中的 tun.enable: true 被交给一个无法启动 TUN 的内核。

UIPI 与单实例转发:提升后的进程默认不接收来自普通进程的 WM_COPYDATA。allow_forwarding_from_unelevated 只对单实例插件自己的消息窗口(先确认窗口属于本进程)调用 ChangeWindowMessageFilterEx 放行该消息,进程其余部分保持默认过滤;forward_to_elevated_instance 让普通权限的二次启动把参数(含 clash:// 链接)按插件格式转交给管理员实例后退出,避免出现两个完整实例争用端口。

12.2 macOS:root 内核与 PID 复核

macOS 创建 utun 设备需要 root。restart_core_as_root 通过一次 osascript 管理员授权完成"结束旧内核 → 启动新内核":

脚本注入防护:所有路径经两阶段转义后嵌入 do shell script "...":

rust
fn shell_escape_single(value: &str) -> String {
    value
        .replace('\'', "'\\''")   // 1. shell 单引号上下文
        .replace('\\', "\\\\")    // 2. AppleScript 字符串字面量
        .replace('"', "\\\"")
}

日志文件防符号链接提权:root 内核的日志写入 /Library/Logs/Zephyr/。写入前,非特权侧若发现日志路径是符号链接先删除;特权脚本中若日志目录本身是符号链接则删除并重建为 root:wheel 0755,日志文件 rm -f 后重新创建,再 chown 给当前用户、chmod 600。这阻止了"预先放置符号链接,让 root 进程写入/截断任意文件"的攻击。

绝对路径启动:内核以绝对路径启动(而不是 cd 后 ./name),使 ps comm 报告精确的可执行文件路径,便于后续识别。

只结束本应用托管的内核:结束 root 内核的特权脚本(build_managed_root_kill_script)在 SIGKILL 前对每个 PID 重新核验身份,因为 PID 列表是在授权对话框弹出前收集的,期间可能被复用:

PID 类别 放行条件
已确认 PID ps comm 或 argv[0] 仍等于托管内核的绝对路径
旧版本候选 PID 工作目录(root 下用 lsof 读取)是本应用的内核目录,且 argv[0] 基名为 mihomo / zephyr-mihomo,且参数中仍有托管的 run_config.yaml

工作目录单独不足以授权终止。只有真正结束了本应用的内核(脚本变量 killed=1),才会删除 TUN 的 0.0.0.0/1、128.0.0.0/1 分流路由,不影响无关 root mihomo 服务的 TUN。授权等待最长 30 秒,超时则结束 osascript。

12.3 Linux:capability + polkit

grant_linux_tun_permission 通过 pkexec 一次性提权,为内核二进制授予 cap_net_admin,cap_net_bind_service+ep,并安装 polkit 规则 /etc/polkit-1/rules.d/50-zephyr-tun.rules,仅允许当前用户免密执行 systemd-resolved 的 DNS 设置与 NetworkManager 的网络控制动作:

rust
// 内核路径与 polkit 规则作为位置参数传入,而不是拼接进脚本文本
let output = std::process::Command::new("pkexec")
    .args(["bash", "-c", script, "bash", core_path_str, &polkit_rule])
    .output()?;
  • 写入规则的用户名经 whoami() 校验,只允许字母数字与 _ - . @,防止注入 JavaScript 规则文本
  • 内核路径必须是合法 UTF-8
  • 用户在认证对话框中取消时返回 canceled

第十三层:CI/CD 安全流水线

设计意图

安全策略不仅依赖运行时防护,还通过 CI/CD 流水线在代码合并前强制执行自动化安全扫描,确保安全策略不会被意外绕过。所有工作流的第三方 Actions 都固定到不可变的 commit SHA,顶层权限为 contents: read。

工作流一览

工作流 触发 职责
security.yml push main/dev、PR → main、每日 0 3 * * *(UTC)、手动 14 个安全 Job(见下)
ci.yml 所有 PR、push main 前端 i18n/ESLint/typecheck/测试(覆盖率)、第三方许可证漂移检查、Rust fmt/clippy/测试(覆盖率)、多平台测试
codeql.yml push/PR main、每周一 0 2 * * 1、手动 CodeQL(JavaScript + Rust,security-extended,build-mode: none)
gitguardian.yml push main/dev、PR → main、手动 ggshield 密钥扫描(补充 TruffleHog 只报告已验证密钥的不足);未配置 GITGUARDIAN_API_KEY 时跳过
snyk.yml 依赖清单变更时的 push/PR、每周一 0 4 * * 1、手动 Snyk JS 依赖测试(--severity-threshold=high,阻断)+ Snyk Code(观察模式,不阻断);未配置 SNYK_TOKEN 时跳过
release.yml Release 标签 构建、内置资源校验、SignPath 签名、Minisign 签名、SBOM
mac-debug.yml 仅手动 交互式 macOS 调试会话(见调试会话的访问控制)

此外仓库启用 pre-commit.ci(文件卫生检查:结尾空白、EOF、YAML/TOML/JSON 语法、大文件),依赖更新由 Renovate 负责(见依赖与许可证治理)。

security.yml 流水线概览

%%{init: {'themeVariables': {'fontSize': '10px', 'nodeBorder': '1px', 'clusterBorder': '1px', 'edgeLabelHeight': '10px'}, 'flowchart': {'curve': 'basis', 'nodeSpacing': 15, 'rankSpacing': 25}}}%%
graph LR
    subgraph Parallel["并行执行"]
        J1["Job 1<br/>cargo audit"]
        J2["Job 2<br/>cargo-deny"]
        J3["Job 3<br/>clippy"]
        J4["Job 4<br/>cargo fmt"]
        J5["Job 5<br/>前端依赖审计<br/>(PR)"]
        J6["Job 6<br/>依赖审查<br/>(PR)"]
        J7["Job 7<br/>Semgrep + SARIF"]
        J8["Job 8<br/>密钥检测"]
        J9["Job 9<br/>OSSF Scorecard"]
        J10["Job 10<br/>Tauri 配置审计"]
        J14["Job 14<br/>SBOM"]
    end

    subgraph Gated["依赖 clippy + fmt"]
        J11["Job 11<br/>构建验证 + 测试"]
        J12["Job 12<br/>macOS 编译检查"]
        J13["Job 13<br/>Windows 编译检查"]
    end

    J3 --> J11
    J4 --> J11
    J3 --> J12
    J4 --> J12
    J3 --> J13
    J4 --> J13

    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
    style J11 fill:#607d8b,stroke:#455a64,color:#fff
    style J12 fill:#607d8b,stroke:#455a64,color:#fff
    style J13 fill:#607d8b,stroke:#455a64,color:#fff
    style J14 fill:#795548,stroke:#5d4037,color:#fff

各 Job 详细说明

# Job 名称 工具 检查内容
1 Rust Dependency Audit cargo audit RustSec Advisory Database 已知漏洞(忽略列表见 .cargo/audit.toml)
2 Cargo Deny Check EmbarkStudios/cargo-deny-action 许可证合规、禁令、来源验证、advisory(deny.toml)
3 Rust Clippy cargo clippy --all-targets -- -D warnings workspace 统一 lint 规则(见下)
4 Rust Format Check cargo fmt --all -- --check 代码格式一致性
5 Frontend Dependency Audit actions/dependency-review-action(仅 PR) 基于 GitHub Advisory Database,fail-on-severity: moderate,同时拒绝 GPL-3.0/AGPL-3.0
6 Dependency Review actions/dependency-review-action(仅 PR) PR 依赖变更审查,fail-on-severity: high,拒绝 GPL-3.0/AGPL-3.0 全系列
7 Semgrep SAST Scan semgrep scan --config auto --config .semgrep.yml 官方规则 + 4 条自定义规则,SARIF 上传至 GitHub Security
8 Secret Detection TruffleHog(--only-verified)+ 自定义 grep 双层密钥检测(Git 历史 + 源码)
9 OSSF Scorecard ossf/scorecard-action 供应链健康检查(SARIF 上传 + artifact 存档 90 天)
10 Tauri Security Audit 自定义脚本 Tauri 安全配置审计
11 Build Verification ESLint + typecheck + cargo build/test --release --locked + vitest 构建验证 + 单元测试 + insta 快照一致性 + Cargo.lock 未被修改
12 macOS Build Check cargo check(x86_64 + aarch64) macOS 专属代码编译检查
13 Windows Build Check cargo check --target x86_64-pc-windows-msvc Windows 专属(#[cfg(target_os = "windows")])代码编译检查
14 SBOM (CycloneDX) cargo cyclonedx + @cyclonedx/cdxgen 生成并合并 Rust + NPM 软件物料清单,上传 artifact(90 天)

Rust 工具链在各 Job 中固定为 1.96.0(与 rust-toolchain.toml 保持一致)。

Clippy 规则

bash
cargo clippy --all-targets -- -D warnings

规则全部由 workspace 根 Cargo.toml 的 [workspace.lints.clippy] 定义,所有成员 crate 通过 [lints] workspace = true 继承,本地与 CI 使用完全相同的规则集,命令行只需 -D warnings。当前共 169 条 deny(含 correctness、suspicious 两个 lint 组)与 4 条显式 allow,按 9 层组织:

层级 deny 数 代表规则
第一层:正确性 & 安全性 16 correctness/suspicious 组、panic、todo、unwrap_used、expect_used、indexing_slicing、cast_possible_truncation、cast_possible_wrap、undocumented_unsafe_blocks、multiple_unsafe_ops_per_block
第二层:并发 & 内存安全 12 await_holding_lock、future_not_send、rc_mutex、mutex_atomic
第三层:性能(内存分配) 23 redundant_clone 等
第四层:性能(CPU & 算法) 20 float_cmp 等
第五层:性能(迭代器 & 闭包 & 循环) 27 —
第六层:性能(内存操作 & 数据结构) 9 —
第七层:性能(类型系统 & 编译) 13 —
第八层:代码质量 43 —
第九层:正确性辅助 6 防止静默丢弃与潜在 bug

显式 allow:needless_pass_by_value(UniFFI 导出函数需要 owned 参数)、multiple_crate_versions、missing_errors_doc、missing_panics_doc。desktop crate 通过 #![deny(clippy::needless_pass_by_value)] 重新启用前者;测试模块按需以 #[allow(clippy::unwrap_used)] 等局部豁免。.clippy.toml 额外配置 avoid-breaking-exported-api = true 和 cognitive-complexity-threshold = 25。

密钥检测

采用 TruffleHog + 自定义 grep 双层策略,另有独立的 GitGuardian 工作流。

第一层:TruffleHog(Git 历史扫描)

yaml
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
  with:
    persist-credentials: false
    fetch-depth: 0
- uses: trufflesecurity/trufflehog@e76dff8a7e57d6df0c60ad8cbf5d5cba505a8fc9 # v3.99.2
  with:
    extra_args: --only-verified
    path: .

--only-verified 只报告经过在线验证的真实密钥(如有效的云厂商密钥、GitHub Token),大幅降低误报率。

第二层:自定义 grep 脚本(源码扫描)

范围 模式 级别
Rust (password|passwd|pwd|secret)\s*[:=]\s*["'][^"']{8,}(排除含 test/example/placeholder/TODO/FIXME 的行) error
Rust (api_key|apikey|secret_key|private_key|access_token)\s*=\s*"[^"]{20,}"(排除 test/example) error
Rust env::var("PASSWORD") warning(提醒不要写入日志)
JavaScript (token|auth|bearer|api_key)\s*[:=]\s*["'][^"' ]{20,}(排除 test/example/placeholder/localhost/127.0.0.1) error
.gitignore 必须包含 *.env、*.env.*、*.pem、*.key、*.p12、*.pfx、id_rsa*、id_ed25519*、*.keystore warning
%%{init: {'themeVariables': {'fontSize': '9px', 'nodeBorder': '1px', 'clusterBorder': '1px', 'edgeLabelHeight': '8px'}}}%%
graph TD
    A["源代码"] --> B["Rust 密码/secret 模式"]
    A --> C["Rust API key/token 模式"]
    A --> D["JS token/auth 模式"]
    A --> E["env::var(PASSWORD)"]
    A --> F[".gitignore 完整性"]
    B -->|命中| X["阻断构建"]
    C -->|命中| X
    D -->|命中| X
    E -->|命中| W["警告"]
    F -->|缺失| W

    style X fill:#e74c3c,stroke:#c0392b,color:#fff
    style W fill:#f39c12,stroke:#e67e22,color:#fff

Tauri 安全配置审计

检查项 说明 级别
"devtools": true 生产构建不应启用开发者工具 警告
CSP ws://127.0.0.1:* 提示 WebSocket 端口通配符 警告
"withGlobalTauri": true 检查已禁用 警告
capabilities windows 无标签过滤 通配窗口权限 警告
capabilities 含 allow-emit 前端事件发送权限 警告
Cargo.lock 存在 可重现构建 警告
pnpm-lock.yaml 存在 前端依赖锁定 错误

Actions 安全

所有第三方 GitHub Actions 均通过不可变的 commit SHA 固定版本,并在注释中标注版本号,例如:

yaml
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1          # v7.0.1
  with:
    persist-credentials: false   # 防止 GITHUB_TOKEN 泄漏到后续步骤
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772    # v1
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6       # v2.9.2
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413         # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1        # v7.1.0
- uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294  # v5.0.0
- uses: trufflesecurity/trufflehog@e76dff8a7e57d6df0c60ad8cbf5d5cba505a8fc9 # v3.99.2
- uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e     # v2.5.0
- uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
  • persist-credentials: false:所有 actions/checkout 步骤显式禁用凭据持久化
  • 最小权限:工作流顶层 permissions: contents: read,需要上传 SARIF 的 Job 才单独申请 security-events: write
  • SHA 固定由 Renovate 维护:GitHub Actions 更新开启 pinDigests,且不自动合并
  • 安装时不运行脚本:CI 中 pnpm install --ignore-scripts,只对白名单包(esbuild、@parcel/watcher)显式 pnpm rebuild;npx 调用同样带 --ignore-scripts;semgrep 以 --only-binary :all: 安装,工具版本全部固定

Release 工作流的签名与完整性

步骤 说明
内置资源校验 Full 版内置的 mihomo 内核按 GitHub API digest 校验 SHA256、Geo 数据按 .sha256sum 校验,缺少摘要或不匹配即构建失败;下载使用 curl --proto =https
许可证重新生成 构建前运行 scripts/generate-licenses.sh,保证内置的 THIRD-PARTY-LICENSES.txt 与本次构建的锁文件完全一致
Windows Authenticode SignPath 签名并逐一验证签名者(见7.9)
Minisign 签名 对 dist-artifacts/ 中的每个产物生成 .minisig(客户端自更新校验所用);私钥来自仓库 secret,写入临时文件并在退出时删除;Linux x86_64 与 Windows 上下载的 minisign 预编译包先按固定哈希校验 SHA256(Linux ARM64 从源码编译,macOS 通过 Homebrew 安装)
SBOM 独立 Job 生成合并后的 CycloneDX SBOM,作为 Release 资产永久保存

调试会话的访问控制

mac-debug.yml 会打开一个可远程登录的 macOS runner,属于高敏感工作流,因此:

  • 只能手动触发(workflow_dispatch),且 Job 条件为 github.triggering_actor == 'Juwan-Hwang'(使用 triggering_actor,重新运行时也按实际触发者判断)
  • runner 用临时 auth key 加入仓库所有者的 Tailscale 网络,SSH/VNC 只在 tailnet 地址上提供
  • SSH 只接受仓库所有者 GitHub 账户上的公钥(gh api users/<owner>/keys 写入 authorized_keys,权限 600)
  • 会话时长有上限,工作流顶层权限为 contents: read

Semgrep 自定义规则

Zephyr 在 .semgrep.yml 中定义了 4 条自定义规则,CI 通过 semgrep scan --config auto --config .semgrep.yml --severity WARNING 同时加载官方规则集和自定义规则。

规则集只保留能在 AST 层面准确匹配的检测:基于 pattern-regex 的 osascript / Command 参数规则会匹配到注释和字符串字面量,// nosemgrep 又无法抑制 GitHub SARIF 中的告警,因此不再使用;innerHTML 赋值交给 ESLint no-unsanitized(能识别 escapeHtml/sanitizeHtml/html/safeHtml 等安全写法)。

Rust 规则(2 条)

规则 ID 检测目标 严重级别 CWE
rust-unsafe-block unsafe { ... } 代码块 INFO CWE-119(内存安全)
rust-hardcoded-crypto-constant 名称匹配 key/secret/token/password/credential/api_key/private 的 const ...: &str WARNING CWE-321(硬编码密钥)

Rust 安全检测主要依赖 Clippy 的 AST 级安全 lint(unwrap_used、indexing_slicing、undocumented_unsafe_blocks 等)。

JavaScript 规则(2 条)

规则 ID 检测目标 严重级别 CWE
js-insertadjacenthtml $OBJ.insertAdjacentHTML($POS, $HTML) 调用 INFO CWE-79
js-document-write document.write(...) 调用 WARNING CWE-79

所有规则都排除测试目录、*_test.rs / *.test.js 与 node_modules。

规则示例

yaml
- id: rust-unsafe-block
  languages: [rust]
  message: |
    Unsafe block detected. Review for memory safety issues.
    Ensure all unsafe operations have safety documentation comments.
  severity: INFO
  pattern: |
    unsafe { ... }
  paths:
    exclude:
      - "**/tests/**"
      - "**/*_test.rs"
      - "**/examples/**"
  metadata:
    category: security
    cwe: "CWE-119"
    confidence: LOW

- id: rust-hardcoded-crypto-constant
  languages: [rust]
  severity: WARNING
  patterns:
    - pattern: |
        const $KEY: &str = $VALUE
    - metavariable-regex:
        metavariable: $KEY
        regex: '(?i)(key|secret|token|password|credential|api_key|private)'
  metadata:
    category: security
    cwe: "CWE-321"
    confidence: MEDIUM

minisign_verify.rs 中的 ZEPHYR_PUBLIC_KEY 会触发 rust-hardcoded-crypto-constant(WARNING)。它是公钥,公开分发是预期行为。


依赖与许可证治理

deny.toml 配置

deny.toml 定义了 Rust 依赖管理的安全策略:

toml
[graph]
targets = ["x86_64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "aarch64-apple-darwin"]

[advisories]
# Review by: 2027-12-31
ignore = [
  # gtk-rs GTK3 bindings — unmaintained; fix requires Tauri GTK4 migration
  "RUSTSEC-2024-0411", "RUSTSEC-2024-0412", "RUSTSEC-2024-0413", "RUSTSEC-2024-0414",
  "RUSTSEC-2024-0415", "RUSTSEC-2024-0416", "RUSTSEC-2024-0417", "RUSTSEC-2024-0418",
  "RUSTSEC-2024-0419", "RUSTSEC-2024-0420",
  # glib 0.18.x — unsound VariantStrIter; locked by gtk-rs GTK3
  "RUSTSEC-2024-0429",
  # h2 unbounded empty DATA frames; transitive via hyper/h2 (Tauri reqwest stack)
  "RUSTSEC-2026-0258",
]
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 ecosystem
  "0BSD",
  "CC0-1.0",       # notify (via clash-prism-core)
  "bzip2-1.0.6",   # libbz2-rs-sys (via zip → bzip2)
  "BSL-1.0",       # clipboard-win (via arboard)
]

[bans]
multiple-versions = "warn"
wildcards = "deny"

[sources]
unknown-registry = "deny"
unknown-git = "deny"

策略说明

目标架构

graph.targets 确保依赖图按所有发布平台解析,防止平台特定依赖被遗漏审计:Linux x86_64、macOS Intel、Windows MSVC、macOS Apple Silicon。

许可证白名单

仅允许以下 16 种许可证,LGPL-3.0 为 Tauri/gtk-rs 生态特例:

许可证 说明
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
OpenSSL OpenSSL 许可证
Zlib Zlib 许可证
MPL-2.0 Mozilla 公共许可证 2.0(文件级 copyleft)
LGPL-3.0 Tauri / GTK 生态所需
0BSD 零条款 BSD
CC0-1.0 公共领域奉献(notify crate)
bzip2-1.0.6 类 BSD,FSF 认可(libbz2-rs-sys)
BSL-1.0 Boost 软件许可证(clipboard-win)

PR 中的依赖变更另由 dependency-review-action 拒绝 GPL-3.0 / AGPL-3.0 的所有变体(-only / -or-later)。

禁令策略

配置项 值 说明
bans.wildcards deny 禁止 * 通配符版本,防止意外接受破坏性更新
bans.multiple-versions warn 同一 crate 多版本警告

来源限制

配置项 值 说明
sources.unknown-registry deny 仅允许 crates.io
sources.unknown-git deny 禁止未知 Git 依赖来源

防依赖混淆攻击:严格限制依赖来源为 crates.io,防止攻击者通过其他注册表或 Git 源注入恶意同名包。

Advisory 处理

配置项 值 说明
advisories 漏洞 默认阻断 安全漏洞直接阻断构建
advisories.unmaintained workspace 仅当工作区直接依赖未维护 crate 时失败,传递依赖只做提示
advisories.ignore 12 个 ID gtk-rs GTK3(10 个)、glib 0.18(1 个):需等待 Tauri 迁移到 GTK4;h2 RUSTSEC-2026-0258:受 Tauri 的 reqwest/hyper 版本阻塞

同一批已接受风险在三处工具配置中保持一致:

文件 工具 说明
deny.toml cargo-deny 不支持到期时间,注释标注 "Review by: 2027-12-31"
.cargo/audit.toml cargo-audit 额外列出未维护的传递依赖(bincode、proc-macro-error、paste、unic-*),因 cargo-audit 没有 unmaintained 级别设置
osv-scanner.toml(仓库根与 apps/desktop/src-tauri/ 各一份,内容一致) OSV Scanner / OSSF Scorecard 每条 [IgnoredVulns](/docs/IgnoredVulns) 带 ignoreUntil = 2027-12-31 与原因,到期强制重新评估

前端依赖固定(pnpm overrides)

pnpm-workspace.yaml 通过 overrides 强制固定存在已知漏洞的传递依赖版本,并以 onlyBuiltDependencies 限定只有 @parcel/watcher 与 esbuild 允许运行安装脚本:

yaml
onlyBuiltDependencies:
  - '@parcel/watcher'
  - esbuild

overrides:
  vite: 8.1.6
  brace-expansion: 5.0.12   # CVE-2024-4068 ReDoS
  ws: 8.21.1                # CVE-2024-37890 DoS
  nanoid@3: 3.3.18          # 保持 CJS 兼容的 3.x 最新补丁
  undici: 7.29.1            # CRLF/cookie 注入、缓存投毒、响应失步等
  tar: 7.5.21               # CVE-2026-69152 无界递归 DoS

根 package.json 声明 engines.node >= 20.18.1。

依赖更新策略(Renovate)

依赖更新由 Renovate 负责(renovate.json):

规则 说明
时间窗口 每周一早 6 点前(Asia/Shanghai),开启 Dependency Dashboard
minor / patch 合并为 patch-and-minor 组并自动合并(需 CI 通过)
GitHub Actions 不自动合并;开启 pinDigests,始终以 commit SHA 固定
npm minimumReleaseAge: 3 days:新版本发布满 3 天才会被提议,降低"被投毒版本刚发布即被拉入"的风险
webview2-com 禁用自动更新(需人工评估)
pre-commit hooks 通过 :enablePreCommit 由 Renovate 统一更新

第三方许可证分发

每个发布包都附带完整的第三方许可证文本 apps/desktop/src-tauri/THIRD-PARTY-LICENSES.txt,并可在 设置 → 关于 → 第三方许可证 中查看:

来源 生成方式
Rust 组件 cargo about generate(about.toml 中的 accepted 列表 + about.hbs 模板),以 Cargo.lock 为准;本仓库自身的 crate 被过滤掉
JavaScript 组件 scripts/generate-licenses-js.mjs,以 pnpm-lock.yaml 为准
内置组件 scripts/third-party-assets-licenses.txt(人工维护:mihomo 二进制、Geo 数据、子集字体、vendored 库等)
  • ci.yml 的 licenses Job 重新生成该文件,与已提交版本有差异即失败;Release 工作流在构建前再次生成,保证与本次构建的锁文件一致
  • 文件作为 Tauri bundle 资源(tauri.conf.json 的 bundle.resources)分发,同时经 include_str! 编译进二进制供应用内查看器使用;查看器以 textContent 显示正文,不经过 HTML 解析
  • about.toml 的 accepted 列表(含 MIT-0、Unlicense、BlueOak-1.0.0、CDLA-Permissive-2.0 等)用于生成许可证文本,准入策略仍以 deny.toml 为准

漏洞披露与代码签名策略

漏洞披露(SECURITY.md)

  • 支持版本:仅最新稳定版接收安全更新
  • 首选渠道:GitHub Private Vulnerability Reporting(仓库 Security 页 → Report a vulnerability)
  • 备用渠道:邮件 [email protected],主题 [SECURITY REPORT] <摘要>,可按需提供 GPG 公钥
  • 语言:英文、中文
  • 请勿通过公开 Issue / Discussion / PR 报告漏洞
  • 响应时间:48 小时内确认收到,5 个工作日内完成分级与验证,14–30 天内开发并测试修复(遵循协调披露 CVD 原则)
  • 严重度分级:Critical(经配置/订阅/Deep Link 的 RCE、Prism 沙盒逃逸、更新签名校验绕过)、High(代理规则/DNS 泄漏防护失效、SSRF 绕过、本地提权或密钥提取)、Medium(日志/IPC 敏感信息泄漏、远程输入可达的 DoS、临时文件竞争)、Low(无直接可利用性的加固项)

.github/security.txt(RFC 9116)给出同样的联系方式、披露政策与致谢页链接。

代码签名策略(CODE_SIGNING_POLICY.md)

  • 免费代码签名由 SignPath.io 提供,证书由 SignPath Foundation 签发
  • 维护者同时担任作者、审阅者与批准者,每一次 Release 签名请求都经人工批准后才应用证书
  • 只签名官方 Windows 发布产物,且只能由本仓库的 GitHub Actions 从源码构建后提交
  • 用户可用 PowerShell Get-AuthenticodeSignature 验证:状态必须为 Valid、签名者必须为 SignPath Foundation
  • 隐私承诺:除非用户或安装者明确请求,程序不会向其他联网系统传输任何信息

威胁模型总结

攻击面与对应防护

攻击向量 威胁 防护层 防护机制
订阅/覆写/规则 URL SSRF 访问内网服务、云元数据 第三层 协议白名单 + DNS 预解析 + 私网/保留地址拦截 + DNS Pinning + 连接期解析器 + 逐跳重定向校验
DNS Rebinding 首次合法,后续解析到内网 第三层 DNS Pinning + SafeDnsResolver;代理路径双 DNS 预检(残余窗口已知并接受)
代理借道 利用回退让内核访问内网 第三层 内网目的地严格仅直连 + 双 DNS 预检 + 代理路径逐跳校验
fake-IP 混淆 合成地址被当作真实目的地 第三层 FakeIpRanges(读取运行配置,失败 fail-closed)
配置注入 恶意 YAML 执行代码或读取文件 第六层 递归清除 script/script-path/Provider path,对齐标签与 << 合并语义
控制面暴露 订阅开启不校验 secret 的 API 入口 第六层 清除 CONTROL_PLANE_KEYS + 强制 127.0.0.1 + 随机 secret
路径遍历 读写预期目录外的文件 第五层 5 轮 URL 解码 + 文件名净化 + 路径边界验证 + 归档条目校验
压缩炸弹 资源耗尽 第五层 200 MB + 200:1 压缩比限制
XSS 注入恶意前端代码 第一层 + 第十层 CSP script-src 'self' + 转义/白名单清理 + ESLint no-unsanitized
协议处理器滥用 借 openUrl 执行本地程序 第二层 openUrl 仅接受 http(s)
恶意更新包 替换内核或安装包 第七层 SHA256(必需)+ 客户端 Minisign 签名 + 可信域名 + 版本/路径校验 + 原子替换与回滚
Deep Link 投毒 静默导入恶意订阅 第十一层 URL 解析 + 协议白名单 + 名称净化 + 用户确认 + 后续 SSRF/清理
本地提权 借特权操作攻击系统 第十二层 仅拆分管理员令牌提升、PID 可执行文件复核、日志符号链接防护、AppleScript 两阶段转义、polkit 用户名校验
元数据/配置泄露 文件被复制 第四层 + 第九层 AES-256-GCM(元数据始终、配置可选)+ owner-only 文件权限
命令滥用 频繁调用敏感命令 第八层 最小间隔限流 + 滑动窗口限流 + 专用冷却
沙盒资源耗尽 脚本无限占用 CPU/内存 第十层 validate_script_limits 边界校验
沙盒权限提升 静默授予文件系统/子进程权限 第十层 PRISM_SANDBOX_CHANGED 审计日志 + UI 确认 + 取消回滚
网络切换配置失效 配置未真正生效 第十层 Fail-closed 双重验证(HTTP 2xx + ACK)
密钥泄露 源码中硬编码凭证 第十三层 TruffleHog + GitGuardian + 自定义 grep
依赖漏洞 / 投毒 第三方库 CVE 或恶意版本 第十三层 + 依赖治理 cargo audit + cargo-deny + dependency-review + Snyk + OSV + Renovate 延迟采纳 + pnpm overrides
CI 供应链 Actions 被篡改、凭据泄漏 第十三层 SHA 固定 + persist-credentials: false + 最小权限 + --ignore-scripts
全屏钓鱼 伪造系统界面 第二层 未授予 set-fullscreen 权限
网络隔离突破 前端直接访问外网 第一层 + 第二层 CSP connect-src + 无 http 插件权限

安全设计原则

  1. Fail-closed:所有安全检查失败或无法确认时拒绝操作,而非降级放行(无法读取内核配置、无法确认公网目的地、缺少更新摘要或签名)
  2. Defense in Depth:每类威胁都有多层独立防护,单层失效不导致整体突破
  3. Least Privilege:Tauri 权限、文件权限、API 绑定、操作系统特权均遵循最小权限原则
  4. Zero Trust:所有外部输入(URL、配置、文件、Deep Link、更新包)均视为不可信,必须经过校验
  5. Secure by Default:安全默认配置(API 绑定 127.0.0.1、随机 secret、owner-only 权限、.no_proxy())
  6. 显式说明残余风险:无法在客户端消除的风险(如代理路径的 DNS rebinding 窗口)在代码与文档中明确标注

返回 首页

↑↓ 选择↵ 打开