核心目标:以 EMS Simulate 的"本地 Vue 页面 + Rust 桌面壳 + Python/FastAPI sidecar"为例,建立从 WebView、Tauri IPC 到 localhost 服务和本地文件的完整信任边界,并把"能调用"收紧为"只有指定窗口能以指定参数调用指定能力"。
前置知识 :已阅读 Part 1~Part 8,理解 command、plugin、窗口标签、sidecar 与 localhost 通信。
验证基线:EMS Simulate 5.0.0;Tauri 2.11.2、Rust 1.95.0、Python 3.11.6、Vue 3.5.26,Windows 11。本文对仓库现状与建议目标分别标注;最后复核日期:2026-08-26。
🚀 配套实战项目:EMS Simulate(能源管理系统模拟器)为了避免只讲零散 API,本系列统一使用我开发并持续维护的 EMS Simulate 作为贯穿案例:它是一款免费开源的工业协议仿真软件,支持 IEC 60870-5-104、IEC 61850、Modbus TCP/RTU、DL/T 645 等主流协议,可模拟 PCS 储能变流器、BMS 电池管理系统、电表等真实设备,并提供四遥(YC/YX/YK/YT)配置和报文实时查看。结合 Wireshark,读者可以直接观察协议报文,并验证 Tauri 界面、Python 后台、系统能力和安装包之间的完整调用链。
- 📦 GitHub 开源仓库(欢迎 Star ⭐)
- 📖 在线技术文档
- 🏪 Microsoft Store(Windows 10/11 免配置安装)
0. 问题场景:localhost 页面不是天然可信页面
EMS 的桌面窗口最终加载 Python sidecar 在 127.0.0.1 动态端口提供的 Vue 页面。它看起来"只在本机",但仍经过 HTTP、WebView、DOM 和 Tauri IPC 四层边界:
#mermaid-svg-zM15ZcqmOxU2ojEH{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zM15ZcqmOxU2ojEH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zM15ZcqmOxU2ojEH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zM15ZcqmOxU2ojEH .error-icon{fill:#552222;}#mermaid-svg-zM15ZcqmOxU2ojEH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zM15ZcqmOxU2ojEH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zM15ZcqmOxU2ojEH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zM15ZcqmOxU2ojEH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zM15ZcqmOxU2ojEH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zM15ZcqmOxU2ojEH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zM15ZcqmOxU2ojEH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zM15ZcqmOxU2ojEH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zM15ZcqmOxU2ojEH .marker.cross{stroke:#333333;}#mermaid-svg-zM15ZcqmOxU2ojEH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zM15ZcqmOxU2ojEH p{margin:0;}#mermaid-svg-zM15ZcqmOxU2ojEH .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-zM15ZcqmOxU2ojEH .cluster-label text{fill:#333;}#mermaid-svg-zM15ZcqmOxU2ojEH .cluster-label span{color:#333;}#mermaid-svg-zM15ZcqmOxU2ojEH .cluster-label span p{background-color:transparent;}#mermaid-svg-zM15ZcqmOxU2ojEH .label text,#mermaid-svg-zM15ZcqmOxU2ojEH span{fill:#333;color:#333;}#mermaid-svg-zM15ZcqmOxU2ojEH .node rect,#mermaid-svg-zM15ZcqmOxU2ojEH .node circle,#mermaid-svg-zM15ZcqmOxU2ojEH .node ellipse,#mermaid-svg-zM15ZcqmOxU2ojEH .node polygon,#mermaid-svg-zM15ZcqmOxU2ojEH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zM15ZcqmOxU2ojEH .rough-node .label text,#mermaid-svg-zM15ZcqmOxU2ojEH .node .label text,#mermaid-svg-zM15ZcqmOxU2ojEH .image-shape .label,#mermaid-svg-zM15ZcqmOxU2ojEH .icon-shape .label{text-anchor:middle;}#mermaid-svg-zM15ZcqmOxU2ojEH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zM15ZcqmOxU2ojEH .rough-node .label,#mermaid-svg-zM15ZcqmOxU2ojEH .node .label,#mermaid-svg-zM15ZcqmOxU2ojEH .image-shape .label,#mermaid-svg-zM15ZcqmOxU2ojEH .icon-shape .label{text-align:center;}#mermaid-svg-zM15ZcqmOxU2ojEH .node.clickable{cursor:pointer;}#mermaid-svg-zM15ZcqmOxU2ojEH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zM15ZcqmOxU2ojEH .arrowheadPath{fill:#333333;}#mermaid-svg-zM15ZcqmOxU2ojEH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zM15ZcqmOxU2ojEH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zM15ZcqmOxU2ojEH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zM15ZcqmOxU2ojEH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zM15ZcqmOxU2ojEH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zM15ZcqmOxU2ojEH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zM15ZcqmOxU2ojEH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zM15ZcqmOxU2ojEH .cluster text{fill:#333;}#mermaid-svg-zM15ZcqmOxU2ojEH .cluster span{color:#333;}#mermaid-svg-zM15ZcqmOxU2ojEH div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zM15ZcqmOxU2ojEH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zM15ZcqmOxU2ojEH rect.text{fill:none;stroke-width:0;}#mermaid-svg-zM15ZcqmOxU2ojEH .icon-shape,#mermaid-svg-zM15ZcqmOxU2ojEH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zM15ZcqmOxU2ojEH .icon-shape p,#mermaid-svg-zM15ZcqmOxU2ojEH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zM15ZcqmOxU2ojEH .icon-shape .label rect,#mermaid-svg-zM15ZcqmOxU2ojEH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zM15ZcqmOxU2ojEH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zM15ZcqmOxU2ojEH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zM15ZcqmOxU2ojEH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} HTTP / WebSocket
invoke / plugin API
用户导入的 SCL/CSV
设备名与消息
Vue / DOM
Python / FastAPI
Tauri / Rust
文件、进程、浏览器
窗口与操作系统
SQLite / 配置 / 日志
协议端口与现场设备
以下任一输入都不能因为"来自本机"而自动获得信任:
- 用户导入的 SCL、CSV、模型文件和文件名;
- IEC 104、IEC 61850、Modbus 等协议收到的文本和消息;
- 剪贴板内容、拖放路径和查询参数;
- sidecar 返回的错误详情和日志片段;
- 被其他进程抢占或仿冒的 localhost 端口;
- 被替换的 sidecar、资源文件或更新包。
真正危险的链路通常不是单个漏洞,而是能力串联:恶意文本进入 DOM,触发 XSS,再借当前窗口已有的 opener、文件或进程权限跨出 WebView。Tauri 的安全治理目标就是切断这条链。
1. 先画信任边界,再写 capability
1.1 EMS 的四个信任区
| 区域 | 典型对象 | 默认信任等级 | 应获得的能力 |
|---|---|---|---|
| Rust Core | 生命周期、sidecar 管理、路径策略 | 高 | 只暴露领域化 command |
| Python sidecar | 设备、协议、数据库业务 | 中高 | 仅绑定回环地址,需实例身份与业务授权 |
| 主业务 WebView | Vue 页面、用户交互 | 中低 | 完成业务所需的最小 IPC |
| 消息/诊断 WebView | 消息详情、日志预览 | 低 | 默认只读,不继承主窗口权限 |
| 外部输入 | 文件、协议数据、URL、剪贴板 | 不可信 | 校验、限额、编码后才能进入内部 |
这里最容易犯的错误是把"页面由自己的后端返回"推导为"页面拥有全部桌面权限"。后端模板、第三方依赖、用户数据渲染和 DOM API 都可能破坏这个前提。
1.2 安全不变量
本系列建议把下面五条写进架构决策记录:
- WebView 永远不直接获得任意 shell、任意 URL 或任意文件路径能力;
- 每个窗口标签都是安全边界,不以 UI 是否隐藏判断权限;
- localhost 服务只接受当前桌面实例的请求;
- 所有跨边界输入都执行类型、格式、数量、大小和时限校验;
- 安装包、sidecar 与更新元数据在执行前能验证来源和完整性。
2. Capabilities、Permissions 与 Scope 各负责什么
Tauri 2 将授权拆成三层:
text
Capability:谁可以调用------窗口、WebView、平台、远程来源
Permission:可以调用什么------command 或 plugin 能力
Scope:可以作用到哪里------路径、URL、程序或其他资源范围
2.1 Capability:按窗口划分能力集合
一个 capability 可以匹配具体窗口标签或通配标签,并挂载 permissions。多个 capability 同时匹配一个窗口时,权限会合并,而不是相互覆盖。
这带来两个重要结论:
- 把
main和message-*放在同一个宽权限文件中,会让消息窗口继承主窗口能力; - 新增一个"临时调试 capability"也可能悄悄扩大既有窗口权限。
EMS 当前 default.json 与 backend-remote.json 都覆盖 main、message-*,并包含窗口创建、dialog、opener 和自定义 command。由于 capability 会合并,当前远程来源页面和消息窗口的授权面偏大。这是现状审计结论,不是推荐配置。
建议拆成至少三类:
json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-business",
"description": "主业务窗口的最小桌面能力",
"windows": ["main"],
"remote": {
"urls": ["http://127.0.0.1:*", "http://localhost:*"]
},
"permissions": [
"core:default",
"dialog:allow-open",
"dialog:allow-save",
"opener:allow-open-url",
"allow-get-backend-url",
"allow-is-backend-ready"
]
}
json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "message-readonly",
"description": "消息窗口只保留渲染与关闭所需能力",
"windows": ["message-*"],
"remote": {
"urls": ["http://127.0.0.1:*", "http://localhost:*"]
},
"permissions": ["core:default"]
}
示例中的 permission 名称需要以项目生成的 schema 为准。拆分后的关键不是文件数量,而是 message-* 不再拥有保存文件、打开任意路径、重启进程或创建新窗口的能力。
此外,建议在 tauri.conf.json 中显式列出启用的 capabilities,避免目录中新增的实验文件被自动加载:
json
{
"app": {
"security": {
"capabilities": ["main-business", "message-readonly"]
}
}
}
2.2 Permission:把 command 变成可审计接口
permission 应表达业务动作,而不是底层工具。例如:
text
不推荐:run_command(command, args)
推荐:export_diagnostic_bundle(target)
不推荐:write_file(path, content)
推荐:export_device_config(file_name, payload)
不推荐:open_url(url)
推荐:open_project_help(topic)
值得特别注意:Tauri 注册的应用 command 默认可被应用窗口调用。若要让自定义 command 真正进入 capability 治理,应在 build.rs 的 app manifest 中声明 commands,并生成对应权限,再只把需要的权限分配给目标 capability。具体写法以当前 Tauri 生成的 schema 和构建 API 为准,不要只凭前端"没有按钮"作为保护。
2.3 Scope:限制资源,而不只限制函数名
允许 open 仍然太宽,必须继续回答"允许打开什么":
- URL:仅
https,限定主机与可选路径,拒绝file:、javascript:、自定义危险 scheme; - 文件:限定到用户明确选择的路径或应用数据目录;
- shell:限定可执行程序、固定子命令和参数形状;
- sidecar:限定已打包的二进制标识,不接收前端传入的可执行路径;
- 数据库:业务层限定实体和字段,不向页面开放任意 SQL。
Scope 是权限的参数边界,不能替代 Rust command 内的业务校验。二者应该同时存在。
3. 自定义 command 的安全设计
3.1 从通用文件写入改成领域导出
EMS 当前 save_file 已校验文件名和父目录,并使用 Rust 写入;open_directory 会规范化现有路径并确认它是目录。这比直接执行 shell 好,但仍可进一步收紧为领域操作。
rust
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct ExportRequest {
file_name: String,
content: String,
}
#[tauri::command]
fn export_device_config(
app: tauri::AppHandle,
request: ExportRequest,
) -> Result<String, String> {
const MAX_BYTES: usize = 4 * 1024 * 1024;
if request.content.len() > MAX_BYTES {
return Err("导出内容超过 4 MiB 限制".into());
}
if !request.file_name.ends_with(".json")
|| request.file_name.contains('/')
|| request.file_name.contains('\\')
|| request.file_name.contains("..")
{
return Err("文件名不合法".into());
}
let root = app
.path()
.app_local_data_dir()
.map_err(|_| "无法确定应用数据目录")?;
std::fs::create_dir_all(&root).map_err(|_| "无法创建导出目录")?;
let target = root.join(&request.file_name);
std::fs::write(&target, request.content.as_bytes())
.map_err(|_| "导出失败")?;
Ok(target.display().to_string())
}
生产实现还应考虑原子写入、符号链接/重解析点、同名覆盖策略和 Windows 长路径。返回给前端的错误应可操作,但不能包含内部堆栈、数据库口令或完整环境变量。
3.2 URL 必须按结构校验
不要使用 starts_with("https://docs.example.com"),它可能接受相似但不同的主机。应解析 URL 后逐字段校验:
rust
fn validate_help_url(raw: &str) -> Result<url::Url, &'static str> {
let url = url::Url::parse(raw).map_err(|_| "URL 格式错误")?;
if url.scheme() != "https" {
return Err("只允许 HTTPS");
}
if url.host_str() != Some("docs.example.com") {
return Err("不允许的帮助站点");
}
if url.username() != "" || url.password().is_some() {
return Err("URL 不允许包含凭据");
}
Ok(url)
}
如果 EMS 只需要打开固定帮助页面,最好让前端传 topic: "scl-import",由 Rust 映射为固定 URL,根本不接收任意 URL。
3.3 每个跨边界请求都要有限额
建议统一检查:
| 维度 | EMS 示例 | 防止的问题 |
|---|---|---|
| 大小 | SCL、CSV、消息体最大字节数 | 内存耗尽、长时间解析 |
| 数量 | 一次导入设备/点位上限 | 意外放大和 UI 卡死 |
| 格式 | 设备 ID、文件扩展名、URL 主机 | 路径/协议混淆 |
| 时间 | HTTP、协议启动、文件解析超时 | 请求永久挂起 |
| 并发 | 同设备只允许一个启动任务 | 重复启动和竞态 |
| 取消 | 页面关闭或用户取消后终止任务 | 僵尸任务和资源泄漏 |
校验要在真正拥有资源的一层执行。前端校验用于体验,不能替代 Rust 或 Python 后端校验。
4. CSP 与 DOM:防止 Web 漏洞升级为桌面漏洞
4.1 当前配置为什么需要收紧
EMS 当前 Tauri CSP 允许 unsafe-inline、unsafe-eval,并对 localhost:* 与 127.0.0.1:* 开放多个来源。这能降低开发和动态端口接入成本,但也扩大了脚本注入后的可利用空间。
建议分阶段治理:
- 对打包进 Tauri 的 loading 页面使用严格
default-src 'self'; - 生产构建确认 Vite 和依赖不需要
unsafe-eval后移除; - 将样式内联逐步替换为 nonce/hash 或静态 CSS;
connect-src只保留实际使用的回环协议与实例端口;- 不从 CDN 动态加载业务脚本,静态资源随安装包交付;
- 为 FastAPI 返回的远程业务页设置自己的 CSP HTTP 响应头。
最后一点经常被忽略:Tauri 配置中的 CSP 主要保护本地打包资产;由 http://127.0.0.1:<port> 返回的页面还需要在 FastAPI 响应层验证实际 CSP 是否生效。两种页面应分别用开发者工具查看响应头和控制台违规报告。
一个收紧方向如下,不能不经验证直接复制到生产:
text
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data: blob:;
font-src 'self';
connect-src 'self' http://127.0.0.1:PORT ws://127.0.0.1:PORT;
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
动态端口不能简单写死。可以在启动时由受控响应生成精准 CSP,或重新评估是否必须由 sidecar 托管前端。不要为了方便退回到任意网络来源。
4.2 Vue 默认转义不等于永远安全
Vue 模板插值默认会转义文本:
vue
<span>{{ device.name }}</span>
风险通常来自绕过默认机制:
vue
<!-- 设备名或协议消息绝不能直接这样渲染 -->
<div v-html="message.html"></div>
还应审计 innerHTML、动态 href/src、Markdown HTML、第三方富文本组件和 WebSocket 消息渲染。确实需要富文本时,定义允许标签/属性清单并使用成熟清洗器;最安全的消息查看器通常是纯文本渲染。
4.3 依赖供应链同样属于前端安全
- 使用 lockfile 和可复现安装;
- 在 CI 运行依赖审计与许可证检查;
- 谨慎对待 Vite plugin、构建脚本和 postinstall;
- 不在前端 bundle 中写入数据库口令、更新私钥或固定管理令牌;
- 对高风险升级先审查变更和构建产物。
5. localhost 与 sidecar:进程边界不是身份边界
5.1 动态端口解决冲突,不解决身份
EMS 已在高位端口范围动态选端口,并同时检查子进程存活和 HTTP 健康状态。这解决了固定端口冲突和部分残留进程问题,但还不能证明"请求来自本次 Tauri 实例"。
建议每次启动生成高熵随机实例令牌:
WebView Python sidecar Tauri WebView Python sidecar Tauri #mermaid-svg-ZDlUzecllYfML5qR{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ZDlUzecllYfML5qR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZDlUzecllYfML5qR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZDlUzecllYfML5qR .error-icon{fill:#552222;}#mermaid-svg-ZDlUzecllYfML5qR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ZDlUzecllYfML5qR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZDlUzecllYfML5qR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZDlUzecllYfML5qR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZDlUzecllYfML5qR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZDlUzecllYfML5qR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZDlUzecllYfML5qR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZDlUzecllYfML5qR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ZDlUzecllYfML5qR .marker.cross{stroke:#333333;}#mermaid-svg-ZDlUzecllYfML5qR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ZDlUzecllYfML5qR p{margin:0;}#mermaid-svg-ZDlUzecllYfML5qR .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZDlUzecllYfML5qR text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ZDlUzecllYfML5qR .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ZDlUzecllYfML5qR .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ZDlUzecllYfML5qR .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ZDlUzecllYfML5qR .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ZDlUzecllYfML5qR #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ZDlUzecllYfML5qR .sequenceNumber{fill:white;}#mermaid-svg-ZDlUzecllYfML5qR #sequencenumber{fill:#333;}#mermaid-svg-ZDlUzecllYfML5qR #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ZDlUzecllYfML5qR .messageText{fill:#333;stroke:none;}#mermaid-svg-ZDlUzecllYfML5qR .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZDlUzecllYfML5qR .labelText,#mermaid-svg-ZDlUzecllYfML5qR .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ZDlUzecllYfML5qR .loopText,#mermaid-svg-ZDlUzecllYfML5qR .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ZDlUzecllYfML5qR .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ZDlUzecllYfML5qR .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ZDlUzecllYfML5qR .noteText,#mermaid-svg-ZDlUzecllYfML5qR .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ZDlUzecllYfML5qR .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZDlUzecllYfML5qR .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZDlUzecllYfML5qR .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZDlUzecllYfML5qR .actorPopupMenu{position:absolute;}#mermaid-svg-ZDlUzecllYfML5qR .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-ZDlUzecllYfML5qR .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZDlUzecllYfML5qR .actor-man circle,#mermaid-svg-ZDlUzecllYfML5qR line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ZDlUzecllYfML5qR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 生成随机 token + 动态端口 通过受控环境/管道传递 仅绑定 127.0.0.1 通过最小 command 提供会话引导 Authorization: Bearer <token> 校验 token、Origin、Host 与请求限额
令牌要求:
- 每次进程启动重新生成,不写进仓库或长期配置;
- 不出现在 URL、常规日志、诊断包和崩溃报告中;
- 使用常量时间比较;
- sidecar 重启后轮换;
- WebSocket 握手同样验证;
- 只绑定
127.0.0.1/::1,生产环境不接受0.0.0.0。
EMS 当前 FastAPI CORS 使用宽松来源配置,实例令牌尚未形成完整闭环;请求 trace 中间件也处于未启用状态。这些都应列为安全演进项,而不是在文章中假装已经完成。
5.2 sidecar 参数不能由页面自由拼接
Rust 应决定:
- 执行哪个已打包二进制;
- 使用哪个动态端口;
- 数据目录在哪里;
- 环境变量白名单;
- 启动、健康检查、重启和终止策略。
页面只表达"启动后台""重试"这类领域意图,不能传入可执行路径、完整命令行或任意环境变量。开发环境的系统 Python 回退也必须被 release 构建彻底禁用。
5.3 敏感配置不能靠隐藏文件保护
EMS 示例配置中仍有明文 MySQL 口令,这适合作为反例。生产方案应优先使用系统凭据库或部署时注入,并做到:
- 日志与连接 URL 自动脱敏;
- 配置导出默认不包含 secret;
- 诊断包只报告"已配置/未配置";
- 卸载、升级和迁移策略明确;
- CI 密钥只存在于受保护 secret,不进入构建日志和 artifact。
6. 文件、路径与数据库的防线
6.1 用户选择路径仍需二次验证
文件对话框只证明用户选择过一个路径,不保证文件内容或后续路径仍安全。读取前应检查:
- 路径是否存在、类型是否符合;
- 实际大小是否在限制内,而不只看扩展名;
- 规范化后的目标是否仍在允许根目录;
- 是否为符号链接、junction 或 reparse point;
- 文件在选择后是否发生替换;
- XML/SCL 解析器是否禁用外部实体和网络加载。
保存时使用临时文件、刷盘和原子替换,避免应用崩溃留下半个配置或损坏数据库。
6.2 数据库安全不是"用了 ORM"就结束
- 使用参数绑定,不拼接查询;
- SQLite 数据目录与安装目录分离;
- 网络数据库启用 TLS、最小权限账户与连接超时;
- 迁移前备份并记录 schema 版本;
- 限制日志中的 SQL 参数;
- 不允许 WebView 提交任意 SQL 或文件数据库路径。
EMS 已启用 SQLite foreign keys、busy timeout 和 WAL 相关测试,这是可靠性底座;它们不能替代输入授权、凭据治理和备份恢复。
7. 更新、签名与运行时完整性
更新链路拥有替换整个应用的能力,因此是最高风险边界之一。
#mermaid-svg-oQXj8kN7qi3KhfPb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-oQXj8kN7qi3KhfPb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-oQXj8kN7qi3KhfPb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-oQXj8kN7qi3KhfPb .error-icon{fill:#552222;}#mermaid-svg-oQXj8kN7qi3KhfPb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-oQXj8kN7qi3KhfPb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-oQXj8kN7qi3KhfPb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-oQXj8kN7qi3KhfPb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-oQXj8kN7qi3KhfPb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-oQXj8kN7qi3KhfPb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-oQXj8kN7qi3KhfPb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-oQXj8kN7qi3KhfPb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-oQXj8kN7qi3KhfPb .marker.cross{stroke:#333333;}#mermaid-svg-oQXj8kN7qi3KhfPb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-oQXj8kN7qi3KhfPb p{margin:0;}#mermaid-svg-oQXj8kN7qi3KhfPb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-oQXj8kN7qi3KhfPb .cluster-label text{fill:#333;}#mermaid-svg-oQXj8kN7qi3KhfPb .cluster-label span{color:#333;}#mermaid-svg-oQXj8kN7qi3KhfPb .cluster-label span p{background-color:transparent;}#mermaid-svg-oQXj8kN7qi3KhfPb .label text,#mermaid-svg-oQXj8kN7qi3KhfPb span{fill:#333;color:#333;}#mermaid-svg-oQXj8kN7qi3KhfPb .node rect,#mermaid-svg-oQXj8kN7qi3KhfPb .node circle,#mermaid-svg-oQXj8kN7qi3KhfPb .node ellipse,#mermaid-svg-oQXj8kN7qi3KhfPb .node polygon,#mermaid-svg-oQXj8kN7qi3KhfPb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-oQXj8kN7qi3KhfPb .rough-node .label text,#mermaid-svg-oQXj8kN7qi3KhfPb .node .label text,#mermaid-svg-oQXj8kN7qi3KhfPb .image-shape .label,#mermaid-svg-oQXj8kN7qi3KhfPb .icon-shape .label{text-anchor:middle;}#mermaid-svg-oQXj8kN7qi3KhfPb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-oQXj8kN7qi3KhfPb .rough-node .label,#mermaid-svg-oQXj8kN7qi3KhfPb .node .label,#mermaid-svg-oQXj8kN7qi3KhfPb .image-shape .label,#mermaid-svg-oQXj8kN7qi3KhfPb .icon-shape .label{text-align:center;}#mermaid-svg-oQXj8kN7qi3KhfPb .node.clickable{cursor:pointer;}#mermaid-svg-oQXj8kN7qi3KhfPb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-oQXj8kN7qi3KhfPb .arrowheadPath{fill:#333333;}#mermaid-svg-oQXj8kN7qi3KhfPb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-oQXj8kN7qi3KhfPb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-oQXj8kN7qi3KhfPb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oQXj8kN7qi3KhfPb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-oQXj8kN7qi3KhfPb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oQXj8kN7qi3KhfPb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-oQXj8kN7qi3KhfPb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-oQXj8kN7qi3KhfPb .cluster text{fill:#333;}#mermaid-svg-oQXj8kN7qi3KhfPb .cluster span{color:#333;}#mermaid-svg-oQXj8kN7qi3KhfPb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-oQXj8kN7qi3KhfPb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-oQXj8kN7qi3KhfPb rect.text{fill:none;stroke-width:0;}#mermaid-svg-oQXj8kN7qi3KhfPb .icon-shape,#mermaid-svg-oQXj8kN7qi3KhfPb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oQXj8kN7qi3KhfPb .icon-shape p,#mermaid-svg-oQXj8kN7qi3KhfPb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-oQXj8kN7qi3KhfPb .icon-shape .label rect,#mermaid-svg-oQXj8kN7qi3KhfPb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oQXj8kN7qi3KhfPb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-oQXj8kN7qi3KhfPb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-oQXj8kN7qi3KhfPb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 构建
签名
HTTPS 下载
公钥验签
受保护 CI
安装包/更新包
离线或受保护私钥
发布源
Updater
安装
必须区分两类签名:
- 平台代码签名:证明 Windows/macOS 安装包或可执行文件发布者;
- Tauri updater 签名:证明更新 artifact 是由应用认可的更新密钥签发。
二者用途不同,不能相互替代。EMS 当前 createUpdaterArtifacts: false,尚未启用 updater;Windows MSIX 脚本使用的开发自签名证书也不能用于生产分发。完整发布方案放在 Part 11。
sidecar 与 runtime 也应随主安装包签名和校验。至少记录应用版本、sidecar 版本、协议 ABI/契约版本与文件摘要,启动时遇到不匹配就拒绝运行并给出可诊断错误。
8. EMS 威胁模型
| 资产 | 入口/威胁 | 可能后果 | 主要控制 |
|---|---|---|---|
| 桌面权限 | 恶意 SCL/消息触发 XSS | 调用 opener、文件或进程能力 | 纯文本渲染、CSP、窗口最小 capability |
| 用户文件 | 路径穿越、链接跳转、覆盖 | 任意读写或配置破坏 | 目录 scope、规范化、原子写、拒绝链接 |
| sidecar | 参数注入、二进制替换 | 执行非预期程序 | 固定 externalBin、签名/摘要、参数白名单 |
| localhost API | 端口抢占、跨站请求 | 未授权设备控制或数据读取 | 动态端口、实例 token、Origin/Host 校验 |
| 工业协议 | 巨大报文、重复启动、恶意文本 | 资源耗尽、UI 注入、现场误操作 | 限额、状态机、授权、编码输出 |
| 数据库凭据 | 配置/日志/诊断包泄漏 | 外部数据库被访问 | 系统凭据库、脱敏、最小账户 |
| 更新链 | 元数据或 artifact 被替换 | 执行恶意版本 | HTTPS、强制验签、密钥隔离、回滚演练 |
威胁建模不是一次会议。新增窗口、plugin、URL、导入格式、协议和更新渠道时,都要重新检查"入口 → 解析 → 权限 → 资产"链路。
9. 失败实验与根因
9.1 给所有窗口一个万能 capability
现象:开发最顺畅,消息窗口也能保存文件、打开目录、创建窗口。
根因:按"应用"授权,没有按窗口职责授权;多个 capability 的权限又发生合并。
修复 :按 main、message-*、diagnostics 分开,显式列出启用项,并生成 capability 差异报告。
9.2 只检查 URL 前缀
现象:看起来限制到帮助域名,实际可被相似主机或危险 scheme 绕过。
根因:把 URL 当普通字符串。
修复:结构化解析 scheme、host、port、userinfo 和路径;最好将 topic 映射为固定 URL。
9.3 认为动态端口足以保护 API
现象:同机其他页面或进程仍可能请求后台。
根因:端口是定位信息,不是身份凭证。
修复:每实例 token、严格回环绑定、Origin/Host 校验和短生命周期。
9.4 CSP 一收紧页面就白屏,于是永久恢复通配
根因:没有先用 report-only/开发者工具识别具体依赖,也没有区分本地 loading 页面与 FastAPI 远程页面。
修复:分层、分指令收紧,为必要资源设置精准来源,逐项消除 inline/eval。
9.5 前端隐藏按钮代替授权
现象:普通页面看不到危险操作,但脚本仍可直接调用 command。
根因:把展示逻辑当访问控制。
修复:授权和参数校验必须在 Rust/Python 可信层执行。
10. 测试与验收
10.1 自动化安全用例
至少覆盖:
message-*调用保存、opener、重启等能力必须被拒绝;- 非白名单 URL、危险 scheme、带凭据 URL 必须被拒绝;
../、绝对路径、链接逃逸、超长文件名必须被拒绝;- 超大 SCL/CSV、超量设备、重复启动和超时请求有稳定错误;
- 缺少、错误、过期的实例 token 无法访问 HTTP/WebSocket;
- 错误消息、日志和诊断包不包含密码、token、完整连接串;
- 被篡改的 sidecar/更新包不能执行或安装;
- 生产页面没有意外的 CSP 违规和
unsafe-eval依赖。
10.2 发布前权限审计表
| 检查项 | 证据 | 通过标准 |
|---|---|---|
| capability 清单 | 构建产物中的配置快照 | 每个窗口只有职责所需权限 |
| capability 合并结果 | 自动生成的窗口×权限矩阵 | 无意外继承 |
| 自定义 command | Rust manifest + permission 文件 | 默认拒绝,显式授予 |
| URL/path scope | 单元测试和拒绝样例 | 边界外输入全部失败 |
| CSP | 安装包实际响应头与控制台 | 无无理由通配、无意外违规 |
| localhost 身份 | 负向 HTTP/WebSocket 测试 | 无 token 请求失败 |
| secret | 仓库、日志、artifact 扫描 | 无明文生产凭据 |
| 更新完整性 | 篡改包演练 | 验签失败且不破坏当前版本 |
10.3 本篇完成定义
- 主窗口、消息窗口和诊断窗口具有独立 capability;
-
tauri.conf.json显式启用 capability 清单; - 任意 shell、任意路径、任意 URL 已替换为领域 command 或严格 scope;
- FastAPI 仅绑定回环地址,并完成每实例身份验证;
- 本地页和远程业务页分别验证 CSP;
- SCL/CSV/消息/日志按不可信输入处理;
- 明文 secret 从示例和生产配置中移除;
- 安全负向用例进入 CI 或 release 验收。
11. 常见误区
- "Tauri 比 Electron 安全,所以不需要威胁建模":框架提供机制,应用仍决定权限和输入边界。
- "127.0.0.1 只有我能访问":同机进程、浏览器页面和错误代理配置都可能成为请求者。
- "permission 已经限制了函数,不需要 scope":函数名不限制路径、URL 和程序参数。
- "Rust 没有内存安全问题,所以 command 就安全":路径穿越、权限放大和业务越权与内存安全无关。
- "CSP 会挡住所有 XSS":CSP 是纵深防御,不能代替安全渲染和输入校验。
- "代码签名等于 updater 验签":它们保护不同链路,应分别配置和演练。
12. 本篇小结与官方资料
这篇最重要的不是记住配置字段,而是形成一个判断顺序:
text
谁发起 → 来自哪个窗口/来源 → 调用哪个领域能力
→ 参数能作用到哪里 → 输入限额是什么 → 失败是否泄密
→ 产物和更新如何证明完整性
对 EMS 而言,优先级最高的演进是拆分 main 与 message-* 权限、收紧远程来源能力、为 localhost 建立实例身份、移除宽松 CORS/CSP 和明文 secret。完成这些之后,Tauri 的"最小权限"才真正落到运行时,而不只是配置文件存在。
官方资料:
- Tauri Capabilities
- Tauri Permissions
- Tauri Command Scopes
- Tauri Content Security Policy
- Tauri Runtime Authority
- Tauri Updater
下一篇进入 Part 10:测试、日志、调试与性能,把这些边界变成能在 CI、安装包和生产现场重复验证的证据。