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

核心目标:以 EMS Simulate 的"本地 Vue 页面 + Rust 桌面壳 + Python/FastAPI sidecar"为例,建立从 WebView、Tauri IPC 到 localhost 服务和本地文件的完整信任边界,并把"能调用"收紧为"只有指定窗口能以指定参数调用指定能力"。

前置知识 :已阅读 Part 1Part 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 同时匹配一个窗口时,权限会合并,而不是相互覆盖。

这带来两个重要结论:

  • mainmessage-* 放在同一个宽权限文件中,会让消息窗口继承主窗口能力;
  • 新增一个"临时调试 capability"也可能悄悄扩大既有窗口权限。

EMS 当前 default.jsonbackend-remote.json 都覆盖 mainmessage-*,并包含窗口创建、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-inlineunsafe-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 的权限又发生合并。

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

官方资料:

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

相关推荐
Source.Liu1 小时前
【Dioxus】Windows 环境下 Rust + Dioxus 安装配置笔记
windows·rust·dioxus
zLLM_Lab7 小时前
不装 Python/PyTorch:8.1 MB Rust 程序在 MacBook Air M5 跑多模态大模型
rust
梦醒沉醉8 小时前
std1.97.1——result模块细览
rust
Thneonl11 小时前
同一资源、不同 ID:多源拓扑的 Identity Resolution
架构·rust
绍磊leo11 小时前
【保姆级】dora-rs 一键安装脚本:类ros2的fishros 风格交互菜单 + 国内镜像加速,把坑都替你踩完了
rust·dora-rs
Ramble_Naylor1 天前
只借不占:Rust 的引用与借用
开发语言·rust
k4m7v2pz1 天前
交叉编译 Rust 到 aarch64 Linux 掌机:glibc 版本与 sysroot 重建的完整踩坑记录
rust·glibc·交叉编译·aarch64·zigbuild·sysroot
k4m7v2pz1 天前
用 Rust 重造 Java 的 CLI 分发体验:从 java -jar 到 wasmtime run
rust·跨平台·webassembly·wasmtime·wasi·java-jar
Source.Liu1 天前
【IT】Rust实现win下系统信息采集工具的开发
rust