安全架构
Zephyr 是基于 Tauri v2 的 Mihomo GUI 代理客户端,实施了多层纵深防御(Defense in Depth)策略,从网络请求校验到文件系统隔离、从特权提升到发布签名,覆盖全攻击面。本文档以安全审计视角,逐一拆解每一层防护机制的设计意图、实现细节与威胁模型(对应代码版本 v2.5.0)。
目录
- 安全纵深防御总览
- 第一层:内容安全策略(CSP)
- 第二层:Tauri 最小权限原则
- 第三层:SSRF 防护
- 第四层:加密存储体系
- 第五层:路径遍历防护
- 第六层:危险配置清除
- 第七层:更新与供应链完整性
- 第八层:速率限制
- 第九层:文件权限控制
- 第十层:运行时安全加固
- 第十一层:Deep Link 安全
- 第十二层:特权提升与 TUN 安全
- 第十三层:CI/CD 安全流水线
- Semgrep 自定义规则
- 依赖与许可证治理
- 漏洞披露与代码签名策略
- 威胁模型总结
安全纵深防御总览
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 会在应用启动时将其注入到所有页面:
{
"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 窗口):
{
"$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:、自定义协议处理器等被借道执行:
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:
- 订阅 URL 注入:诱导用户添加指向内网服务的订阅地址
- DNS Rebinding:首次 DNS 解析返回合法 IP,后续解析返回内网 IP
- 开放重定向:通过 HTTP 重定向将请求引导至内网或云元数据服务(如
169.254.169.254、100.100.100.200) - 协议走私:使用
file://、gopher://等非 HTTP 协议访问本地资源 - 代理借道:利用"直连失败后改走代理"的回退路径,让内核替攻击者访问内网
- 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:#fff3.1 URL 基础校验
validate_subscription_url_basic 在任何 DNS 解析之前完成语法层校验,返回 (host, port, user_entered_private):
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(取反后的客户端地址) |
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 主机名分类
三个函数共同决定一个主机名属于哪一类:
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 地址判断:
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 构建,叠加三道保险:
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
}- 预解析 + Pin:
resolve_and_pin解析目标主机,任一地址为私网即拒绝(防止"公网 + 私网混合应答"的 rebinding),fake-IP 被跳过,第一个公网地址通过reqwest::ClientBuilder::resolve()固定,实际连接不再重新解析 SafeDnsResolver:对 Pin 之外的解析(例如重定向到新主机)在连接时再次检查:fake-IP 一律拒绝;非授权的私网 IP、单标签名、私网字面量拒绝;解析结果中出现私网地址即拒绝safe_redirect_policy:每一跳调用check_redirect_target,最多 5 跳
DNS 并发保护:所有阻塞式解析经全局 DNS_SEMAPHORE(32 个 permit)调度,单次解析有截止时间。permit 随解析任务存活到 getaddrinfo 返回,这是有意的背压:卡死的解析不会无限堆积占满 Tokio 阻塞线程池。
3.7 重定向目标校验
直连与代理两条路径共用 check_redirect_target:
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)。
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()据此识别已加密的配置文件
加密实现
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))
}解密实现
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,失败不会静默降级为空串或乱码。
密钥缓存与持久化状态
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 前缀自动解密,内核启动、配置列表等路径无需关心文件是否加密。
元数据加密
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:#fff5.1 文件名净化
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 轮,某轮没有变化或结果中已无 % 时提前结束:
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 确保拼接后的路径始终在预期目录内:
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):
// 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() 限流读取,防止伪造头部绕过 |
// .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 配置文件中有两类字段会被滥用:
- 代码执行 / 文件访问字段:
script、script-path,以及 Provider 中可指向任意本地路径的path - 控制面字段:开启额外控制入口的顶层键。Zephyr 自己管理控制器(把
external-controller固定到127.0.0.1并注入随机secret),这些键从不应由订阅、导入的配置或补丁提供
Zephyr 在加载任何外部 YAML 时,自动递归清除第一类字段,并清除根级的第二类字段。
控制面键黑名单
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 类深层嵌套):
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 值加上引号:
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)在每次启动内核时强制覆盖关键安全设置:
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)在合并用户补丁后恢复安全关键设置:
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 形式):
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 可信域名白名单
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 版本格式验证
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 解析
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,供随后的更新使用。
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 安装包后,必须同时通过两道校验才会打开安装程序:
// 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>)?;// 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 防护与原子替换
// 不可预测的临时文件名
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));替换顺序:
- 停止内核必须成功:停止失败则删除已解压的二进制并中止,避免在旧二进制仍运行(macOS TUN 下为 root 进程)时报告成功
- 旧二进制移至
.backup:移动失败则中止(无备份就无法回滚) - 新二进制移入:
rename最多尝试 5 次、间隔 500 ms(应对 Windows 文件锁),仍失败则恢复备份 - 设置可执行权限(macOS/Linux):失败则回滚
- 重启内核:成功后删除备份;失败则删除新二进制并恢复备份
7.9 Windows 代码签名(SignPath)
Windows 发布产物通过 SignPath Foundation 进行 Authenticode 代码签名,覆盖 x64 与 ARM64 架构。Release 工作流的签名步骤:
- 暂存:收集 5 个 Windows 产物(
setup-full.exe、setup-lite.exe、full.msi、lite.msi、portable.exe)到unsigned-staging/,每个模式必须恰好匹配 1 个文件 - 上传:通过
actions/upload-artifact上传未签名产物(保留 3 天),获得artifact-id - 签名:
signpath/github-action-submit-signing-request提交release-signing策略的签名请求,同步等待,超时 60 分钟 - 验证替换:PowerShell 逐个验证已签名产物的 Authenticode 状态为
Valid且签名者为SignPath Foundation,验证通过后才替换dist-artifacts/中的未签名文件;Portable ZIP 由签名后的 exe 打包
部分签名防护:任何一个产物签名缺失或验证失败,整个流程立即报错退出,防止未签名二进制混入最终 Release。
第八层:速率限制
Zephyr 实现了两套独立的速率限制机制,外加若干专用冷却机制。
8.1 通用命令限流 — RateLimiter + rate_limit! 宏
lib.rs 中的 RateLimiter 记录每个命令最近一次调用的时间,要求两次调用之间至少间隔给定毫秒数:
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)
#[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)实现等效的权限控制:
#[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) |
用户脚本与规则 | 中 |
受保护目录
// 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:
// 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 并注入配置:
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 字段:
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 节点),转义 &、<、>、"、' 五个字符,结果可安全用于元素文本和带引号的属性值:
const HTML_ESCAPE_TEST_RE = /[&<>"']/;
const HTML_ESCAPE_RE = /[&<>"']/g;
const HTML_ESCAPE_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
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 — 安全模板标签
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 渲染(零依赖的子集实现):
- 代码块与行内代码先以私用区字符为界的占位符替换,内容单独
escapeHtml - 其余全文一次性
escapeHtml,此后所有格式化只在已转义文本上插入固定标签 - 链接:
javascript:/vbscript:/data:降级为纯文本,mailto:只显示地址,其余输出target="_blank" rel="noopener" - 图片:只显示 alt 文本,不生成
<img> - 最终结果再经
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() 用于动态配置引擎的对象合并,实现了多层原型污染防护:
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 层,防止深层嵌套补丁导致栈溢出:
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 原子锁防止内核并发启动:
// 等待前一次启动完成(最多 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 确保异常退出时清理子进程:
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 事件,而不是让整个命令线程崩溃。
应用退出清理
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 执行严格的边界校验,防止资源耗尽和配置错误:
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级别
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 配置时,使用双重验证确保配置真正生效:
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 工作线程复用导致的线程局部状态串扰
第十一层:Deep Link 安全
Zephyr 注册了 clash:// 自定义 URL 协议,允许从浏览器或其他应用快速导入订阅(clash://install-config?url=<encoded>&name=<encoded>)。由于 Deep Link 接收外部不可信输入,实现了完整的防护链。
输入验证
// 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 动词重启。
只对拆分管理员令牌提升:
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 "...":
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 的网络控制动作:
// 内核路径与 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 规则
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 历史扫描)
- 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:#fffTauri 安全配置审计
| 检查项 | 说明 | 级别 |
|---|---|---|
"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 固定版本,并在注释中标注版本号,例如:
- 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.0persist-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。
规则示例
- 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 依赖管理的安全策略:
[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 允许运行安装脚本:
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的licensesJob 重新生成该文件,与已提交版本有差异即失败;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 插件权限 |
安全设计原则
- Fail-closed:所有安全检查失败或无法确认时拒绝操作,而非降级放行(无法读取内核配置、无法确认公网目的地、缺少更新摘要或签名)
- Defense in Depth:每类威胁都有多层独立防护,单层失效不导致整体突破
- Least Privilege:Tauri 权限、文件权限、API 绑定、操作系统特权均遵循最小权限原则
- Zero Trust:所有外部输入(URL、配置、文件、Deep Link、更新包)均视为不可信,必须经过校验
- Secure by Default:安全默认配置(API 绑定 127.0.0.1、随机 secret、owner-only 权限、
.no_proxy()) - 显式说明残余风险:无法在客户端消除的风险(如代理路径的 DNS rebinding 窗口)在代码与文档中明确标注
返回 首页