Tauri 2.x 系列(九):安全模型——Capabilities、Permissions、Scope 与 CSP

核心目标:以 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 后台、系统能力和安装包之间的完整调用链。


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 安全不变量

本系列建议把下面五条写进架构决策记录:

  1. WebView 永远不直接获得任意 shell、任意 URL 或任意文件路径能力;
  2. 每个窗口标签都是安全边界,不以 UI 是否隐藏判断权限;
  3. localhost 服务只接受当前桌面实例的请求;
  4. 所有跨边界输入都执行类型、格式、数量、大小和时限校验;
  5. 安装包、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:* 开放多个来源。这能降低开发和动态端口接入成本,但也扩大了脚本注入后的可利用空间。

建议分阶段治理:

  1. 对打包进 Tauri 的 loading 页面使用严格 default-src 'self';
  2. 生产构建确认 Vite 和依赖不需要 unsafe-eval 后移除;
  3. 将样式内联逐步替换为 nonce/hash 或静态 CSS;
  4. connect-src 只保留实际使用的回环协议与实例端口;
  5. 不从 CDN 动态加载业务脚本,静态资源随安装包交付;
  6. 为 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. 常见误区

  1. "Tauri 比 Electron 安全,所以不需要威胁建模":框架提供机制,应用仍决定权限和输入边界。
  2. "127.0.0.1 只有我能访问":同机进程、浏览器页面和错误代理配置都可能成为请求者。
  3. "permission 已经限制了函数,不需要 scope":函数名不限制路径、URL 和程序参数。
  4. "Rust 没有内存安全问题,所以 command 就安全":路径穿越、权限放大和业务越权与内存安全无关。
  5. "CSP 会挡住所有 XSS":CSP 是纵深防御,不能代替安全渲染和输入校验。
  6. "代码签名等于 updater 验签":它们保护不同链路,应分别配置和演练。

12. 本篇小结与官方资料

这篇最重要的不是记住配置字段,而是形成一个判断顺序:

text 复制代码
谁发起 → 来自哪个窗口/来源 → 调用哪个领域能力
→ 参数能作用到哪里 → 输入限额是什么 → 失败是否泄密
→ 产物和更新如何证明完整性

对 EMS 而言,优先级最高的演进是拆分 main 与 message-* 权限、收紧远程来源能力、为 localhost 建立实例身份、移除宽松 CORS/CSP 和明文 secret。完成这些之后,Tauri 的"最小权限"才真正落到运行时,而不只是配置文件存在。

官方资料:

下一篇进入 Part 10:测试、日志、调试与性能,把这些边界变成能在 CI、安装包和生产现场重复验证的证据。

相关推荐
小灰灰搞电子6 小时前
Rust+Slint 实现一个终端软件源码分享(半成品)
开发语言·rust
恋喵大鲤鱼6 小时前
Rust str 与 String 类型的区别
rust
miofly7 小时前
GitHub 今日推荐|fsearch:Rust 打造 macOS 毫秒级全盘文件名与内容搜索工具
rust·开源·github
大鹏的NLP博客8 小时前
Rust 的本质:用数学约束实现编译期内存安全
安全·rust
Kapaseker8 小时前
Rust 读多写少还在用 Mutex?别让线程白白排队
rust
EatFan9 小时前
2026 Rust 后端技术栈选型:Axum 0.8 + Tokio + SQLx 全链路怎么搭
开发语言·后端·rust·tokio·serde·axum·sqlx
Zoom19 小时前
【开源】7 天,我用 Rust 复刻了 FinalShell!Rhost v1.0.0 发布
rust·electron·shell
wflynn1 天前
GitHub 今日推荐|ts-rust:把微软 TypeScript-Go 编译器逐行移植成 Rust 实现
rust·typescript·开源·github·compiler·tsc
miofly1 天前
GitHub 今日推荐|wordcraft:用 Rust 重写 Word 内核并开放给 AI 调用
rust·开源·github
Amos_Web1 天前
Rspack 源码解析(十八):Tree Shaking 如何用 SideEffects 重写模块连接
前端·rust·前端框架