Rust in Everything
基于 tauri 2.12.5

CSP、原型冻结与隔离模式

主线位置:三样东西分别挂在两条主线上。CSP 在启动主线的最后一步:tauri:// 协议返回 index.html 时生成 Content-Security-Policy 头。freezePrototype 和隔离模式的脚本在启动主线的「补初始化脚本」那一步注入。隔离模式还改了调用主线的前半段:invoke 的参数先进一个沙箱 iframe 加密,再由 ipc:// 送到 Rust、在 parse_invoke_request 里解密。

sequenceDiagram
  participant P as 页面脚本
  participant M as ipc.js
  participant F as 隔离 iframe
  participant W as ipc 协议
  participant D as on_message
  P->>M: invoke ping
  M->>F: postMessage 明文消息
  F->>F: 钩子检查 再加密 payload
  F->>M: postMessage 密文消息
  M->>W: fetch POST 正文是密文
  W->>W: parse_invoke_request 解密
  W->>D: InvokeRequest 明文参数
  D-->>P: 结果直接回到页面

本页的实验 crate:sec-csp(配 CSP 和 freezePrototype,页面把测到的东西报回来)和 sec-iso(隔离模式,tauri 和 tauri-build 都开 isolation feature)。都是隐藏窗口的真实应用,--features tauri/custom-protocol,debug 构建,平台 M2 Max、macOS 27、rustc 1.99.0(WKWebView)。Windows、Linux 上的差别只读了源码,文中会注明。CSP 头在 tauri:// 协议里怎么挂到响应上见 tauri:// 协议,编译期怎么算哈希、怎么在 HTML 里放 nonce 占位符见 generate_context!;本页讲它作为安全机制的整体效果。

CSP:编译期哈希,运行时 nonce

配置里有 app > security > csp(开发模式优先 devCsp)时,get_asset 对每个 .html 响应调 set_csp,把配置里的 CSP 解析成「指令 → 来源列表」的 HashMap,往 script-src、style-src 里补东西,再拼回字符串当响应头。补进去的有两类:

来源给谁什么时候算存在哪
'sha256-...'有内容的内联 <script>(按 HTML 分开)、frontendDist 里的每个 .js / .mjs(全局)编译期,generate_context!二进制里的 EmbeddedAssets
'nonce-...'外链到 http(s) 的 <script>、每个 <style>每次响应只在这次响应的 HTML 和头里

为什么分两种:哈希要求内容在编译期已知且不变,内联脚本和打包进来的 .js 满足;外链的远程脚本内容不在手里,<style> 的哈希 tauri 干脆不算(见 generate_context!),只能靠 nonce。nonce 的替换在 replace_csp_nonce:

fn replace_csp_nonce(
  asset: &mut String,
  token: &str,
  csp: &mut HashMap<String, CspDirectiveSources>,
  directive: &str,
  hashes: Vec<String>,
) {
  let mut nonces = Vec::new();
  *asset = replace_with_callback(asset, token, || {
    let nonce = getrandom::u64().expect("failed to get random bytes");
    nonces.push(nonce);
    nonce.to_string()
  });

  if !(nonces.is_empty() && hashes.is_empty()) {
    let nonce_sources = nonces
      .into_iter()
      .map(|n| format!("'nonce-{n}'"))
      .collect::<Vec<String>>();
    let sources = csp.entry(directive.into()).or_default();
    let self_source = "'self'".to_string();
    if !sources.contains(&self_source) {
      sources.push(self_source);
    }
    sources.extend(nonce_sources);
    sources.extend(hashes);
  }
}

每个 __TAURI_SCRIPT_NONCE__ / __TAURI_STYLE_NONCE__ 占位符各换成一个新的随机 u64。只要补了任何哈希或 nonce,tauri 还会顺手往这条指令里加 'self':配置里写的是 default-src 'self',生成的 script-src 没有回退到 default-src 的机会,不加 'self' 的话页面自己的 .js 文件就被挡了。

sec-csp 的配置:

    "security": {
      "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost",
      "freezePrototype": true
    }

页面有一个 <style>、一个 <script src="app.js">、一段内联脚本。内联脚本两次 fetch(location.href) 读 CSP 头,再试着做几件 CSP 应该挡住的事:

  const h1 = (await fetch(location.href)).headers.get('content-security-policy');
  const h2 = (await fetch(location.href)).headers.get('content-security-policy');
  out.csp1 = h1;
  out.sameNonceTwice = h1 === h2;
  const st = document.querySelector('style');
  out.styleNonceAttr = st.getAttribute('nonce');
  out.styleNonceProp = st.nonce;
  out.fromFile = window.fromFile;
  // dynamic inline script: no hash, no nonce
  const s = document.createElement('script'); s.textContent = 'window.dyn = 1'; document.body.append(s);
  out.dynamicInline = String(window.dyn);
  try { out.eval = eval('1 + 1'); } catch (e) { out.eval = String(e); }

输出里和 CSP 有关的部分:

 "csp1": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost; script-src 'self' 'sha256-MvvX12YBV3S0GyzTGMv8C0IfJoDyGDf921A+s28NDFU=' 'sha256-7og7i+0Rw2u/giZmgVn0g49qWB/iuxxlbTsbkgj+Wc4='; style-src 'self' 'nonce-17275991322689339750'",
 "sameNonceTwice": false,
 "styleNonceAttr": "",
 "styleNonceProp": "4580365091687138495",
 "fromFile": "app.js ran",
 "dynamicInline": "undefined",
 "eval": "EvalError: Refused to evaluate a string as JavaScript because 'unsafe-eval' or 'trusted-types-eval' is not an allowed source of script in the following Content Security Policy directive: \"script-src 'self' 'sha256-MvvX12YBV3S0GyzTGMv8C0IfJoDyGDf921A+s28NDFU=' 'sha256-7og7i+0Rw2u/giZmgVn0g49qWB/iuxxlbTsbkgj+Wc4='\".\n",

逐项看:

  • 第一个哈希是 app.js 的(openssl dgst -sha256 -binary dist/app.js | base64 得到同一个值),第二个是那段内联脚本的。两个都在,内联脚本和 app.js 都执行了。
  • style-src 里的 nonce 和页面里 <style> 的 nonce 不一样,两次 fetch 的头也不一样(sameNonceTwice: false):每次响应都重新生成。页面拿到的是它自己那次响应的 nonce,fetch 拿到的是另一次。
  • getAttribute('nonce') 是空串,.nonce 属性才有值:WebKit 按 HTML 标准在应用 CSP 后把 nonce 属性的内容藏起来,页面里注入的脚本没法通过读 DOM 属性偷到 nonce 再拿去用。
  • 动态插入的内联脚本没执行,eval 被拒绝。页面上的 securitypolicyviolation 事件记下了这两次:script-src-elem inline 和 script-src eval。
  • 指令的顺序每次运行都不同(另一次运行里 style-src 排在最前),因为中间经过一个 HashMap。

connect-src 里必须有 ipc: 和 http://ipc.localhost(后者是 Windows、Android 上 IPC 的地址),否则 fetch 被挡,IPC 悄悄退回 postMessage,一次 invoke 的最后一节已经演示过。页面要 fetch 自己的资源还得有 'self':本实验第一次运行时 connect-src 只写了 ipc: http://ipc.localhost,页面第一步就是 fetch(location.href),结果什么也没报回来,靠 15 秒看门狗退出;加上 'self' 后正常。

tauri 自己的脚本不受 CSP 约束

同一个页面接着用 plugin:event|listen 注册一个监听器、调一个命令让 Rust emit("ping", "pong"):

 "event": "pong",

事件到了。Rust 往页面送事件靠的是对 webview 的 eval(见 AppManager 与 Manager trait),而页面的 CSP 不允许 eval。两者不矛盾:WKWebView 的 evaluateJavaScript 和 WKUserScript 由宿主程序直接执行,不经过页面的 CSP 检查。tauri 的初始化脚本(__TAURI_INTERNALS__、ipc-protocol.js、事件分发函数)是 WKUserScript,回调结果和事件是 evaluateJavaScript。所以 CSP 约束的只是页面自己的内容,tauri 的通道不会因为一条严格的 CSP 断掉,除了上面说的 connect-src:fetch ipc:// 是页面脚本发出的请求。

dangerousDisableAssetCspModification 控制上面这些补充:true 全关,或者给一个指令列表只关这几条,判断函数是 DisabledCspModificationKind::can_modify。关掉以后 HTML 里的 nonce 占位符不再被替换,CSP 头就是配置里那一行。

📝NOTE
冷知识:set_csp 的注释还在说 Linux 的 meta 标签

set_csp 上方的文档注释是「Sets the CSP value to the asset HTML if needed (on Linux). Returns the CSP string for access on the response header (on Windows and macOS).」这描述的是 2.0 beta 之前的做法:编译期往 HTML 里插一个 <meta http-equiv="Content-Security-Policy" content="__TAURI_CSP__">,Linux 上返回页面时再把占位符换成真正的 CSP(被删掉的那段代码的注释是「if it's an HTML file, we need to set the CSP meta tag on Linux」)。#8984(「chore(linux): remove CSP tag on custom protocol response」,首次发布于 tauri 2.0.0-beta.7)删掉了 CSP_TOKEN 和 inject_csp_token,所有平台都只靠响应头,changefile 原文:「Do not include a CSP tag in the application HTML and rely on the custom protocol response header instead.」现在 set_csp 对 HTML 做的唯一修改是替换 nonce,注释没跟着改。

freezePrototype

配置项 freeze_prototype 默认 false。打开后,initialization_script 把 scripts/freeze_prototype.js 的一行内容拼进 init.js 模板的最前面:

;(function () {
  __RAW_freeze_prototype__

  __RAW_pattern_script__

  __RAW_ipc_script__

  __RAW_core_script__

  __RAW_event_initialization_script__
})()
Object.freeze(Object.prototype)

它在 pattern.js、ipc.js、core.js 之前执行,也在页面的任何脚本之前执行(初始化脚本的顺序见初始化脚本)。sec-csp 测的三项:

  out.frozen = Object.isFrozen(Object.prototype);
  try { (function () { 'use strict'; Object.prototype.polluted = 1; })(); out.strictWrite = 'no error'; }
  catch (e) { out.strictWrite = String(e); }
  Object.prototype.polluted2 = 1;
  out.sloppyWrite = String(({}).polluted2);

打开时:

 "frozen": true,
 "strictWrite": "TypeError: Attempting to define property on object that is not extensible.",
 "sloppyWrite": "undefined",

同一个应用用 TAURI_CONFIG='{"app":{"security":{"freezePrototype":false}}}' 重新构建:

 "frozen": false,
 "strictWrite": "no error",
 "sloppyWrite": "1",

严格模式下写 Object.prototype 抛 TypeError,非严格模式下静默失败。它防的是原型污染:一个有漏洞的依赖(比如不检查 __proto__ 键的深合并)把属性写到 Object.prototype 上,进而影响所有对象,包括 tauri 自己的 JS 读到的对象。它只冻结 Object.prototype 这一个对象,Array.prototype、Function.prototype 都没动;而且只在主 frame 里执行,iframe 不受影响。配置文档提醒的代价是会扩展内建原型的 polyfill 和老框架会坏掉。

隔离模式

01 一次 invoke 里 __TAURI_PATTERN__ 是 brownfield,ipc.js 把消息直接转给 postMessage。隔离模式把这一步换成「先送进一个由你写的、跑在沙箱 iframe 里的小应用,让它检查,再加密」。配置:

    "security": {
      "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost",
      "pattern": { "use": "isolation", "options": { "dir": "./isolation" } }
    }

isolation/ 是隔离应用,一个 index.html 加一个 index.js。钩子收到的是整条消息,这里拦下命令 secret:

window.__TAURI_ISOLATION_HOOK__ = (message) => {
  if (message.cmd === 'secret') throw new Error('blocked by isolation hook')
  return message
}

编译期

context_codegen 的 PatternKind::Isolation 分支:目录不存在就 panic;隔离应用的文件里没有出现 __TAURI_ISOLATION_HOOK__ 字样也 panic(「The isolation application does not contain a file setting the window.__TAURI_ISOLATION_HOOK__ value.」)。map_isolation 改写隔离应用的 HTML:注入隔离脚本、把外部脚本内联进来(注释说是「temporary workaround for windows not loading assets」)、算内联脚本的哈希、加 nonce 占位符,再把 iframe 隐藏样式 #__tauri_isolation__ { display: none !important } 的哈希记进样式哈希。

隔离 iframe 用的 scheme 名是 isolation-<uuid>,uuid 在宏展开时生成,每次重新编译都会变(两次构建分别得到 isolation-e3c2cfe6-... 和 isolation-fb238fe5-...)。生成的 Pattern::Isolation 里有一个 crypto_keys: Keys::new():这是一段运行时代码,所以 AES-256 密钥由 AesGcmPair::new 用 getrandom 在每次启动时生成,不在二进制里。同一个结构体还有一个 key 字段,编译期填一个 uuid,AppManager 构造时再换一个新的(manager/mod.rs),但整个 crate 里没有地方读它,注册协议时写的是 key: _。

运行时:iframe、钩子、加密

初始化脚本里多了一段 scripts/isolation.js:主 frame 的 DOMContentLoaded 时插入上面那条隐藏样式,再插入一个 <iframe id="__tauri_isolation__">,sandbox 只加 allow-scripts,src 是 isolation-<uuid>://localhost/。这个 scheme 由 protocol::isolation::get 服务:只认 index.html,CSP 固定为 default-src 'none'; frame-src <scheme>: 加上哈希和 nonce,并且用模板把 32 字节的密钥、主 frame 的源(tauri://localhost)渲染进隔离应用的脚本。密钥就写在 iframe 的 HTML 里。

主 frame 拿不到它,因为 iframe 没有 allow-same-origin,是一个不透明源。sec-iso 的页面测了 iframe 的属性,也试着读它的 document:

 "iframe": {
  "src": "isolation-e3c2cfe6-e53f-498c-af82-e9049865957d://localhost/",
  "sandbox": "allow-scripts",
  "display": "inline"
 },
 "iframeDoc": "SecurityError: Sandbox access violation: Blocked a frame at \"tauri://localhost\" from accessing a cross-origin frame.  The frame being accessed is sandboxed and lacks the \"allow-same-origin\" flag.",

display 是 inline,不是 none。同一次运行的 securitypolicyviolation 只有一条 style-src-elem inline:隐藏样式是 isolation.js 在主 frame 里用 document.createElement('style') 插入的,而它的哈希只记进了隔离应用的样式哈希,主页面的 CSP 里没有,于是被挡。结果是一个默认大小的空 iframe 出现在页面末尾。用 TAURI_CONFIG 去掉 CSP 重新构建,display 变成 none。配了 CSP 的隔离模式应用要自己在样式表里隐藏 #__tauri_isolation__。主页面的 CSP 头另外被 get_asset 的隔离分支在 default-src 末尾追加了隔离 scheme(default-src 'self' isolation-...://localhost/),这样 iframe 才能加载。

一次 invoke 在 ipc.js 定义的 __TAURI_INTERNALS__.ipc 里走 isolation 分支:iframe 还没发 __TAURI_ISOLATION_READY__ 时先排队,之后用 postMessage(data, '*') 把整条消息发给 iframe。iframe 里的 payloadHandler 先核对 event.origin 是主 frame 的源,调钩子,然后只加密 payload,cmd、callback、error、options 原样拷过去,再 postMessage 回父窗口。主 frame 的 message 监听器看到形状对的消息就交给 __TAURI_INTERNALS__.postMessage,后者把 {nonce, payload, contentType} 编成 JSON 作为 ipc:// 请求的正文。Rust 侧在 parse_invoke_request 的隔离分支里解密:

  #[cfg(feature = "isolation")]
  if let crate::Pattern::Isolation { crypto_keys, .. } = &*manager.pattern {
    // if the platform does not support request body, we ignore it
    if has_payload {
      #[cfg(feature = "tracing")]
      let _span = tracing::trace_span!("ipc::request::decrypt_isolation_payload").entered();

      (body, content_type) = crate::utils::pattern::isolation::RawIsolationPayload::try_from(&body)
        .and_then(|raw| {
          let content_type = raw
            .content_type()
            .parse()
            .unwrap_or(mime::APPLICATION_OCTET_STREAM);
          Ok((crypto_keys.decrypt(raw)?, content_type))
        })
        .map_err(|e| e.to_string())?;
    }
  }

解出明文以后,后面的路和不开隔离模式完全一样:invoke key、ACL、分发。postMessage 退路那条通道在 handle_ipc_message 里有一份对应的解密。

sec-iso 的页面在 window 上挂了一个 message 监听器,截下 iframe 发回来的那条消息(调用的是 invoke('ping', { word: 'hi' })):

 "ping": "pong hi",
 "captured": {
  "origin": "null",
  "keys": [
   "cmd",
   "callback",
   "error",
   "options",
   "payload"
  ],
  "cmd": "ping",
  "payloadKeys": [
   "nonce",
   "payload",
   "contentType"
  ],
  "contentType": "application/json",
  "nonceLen": 12,
  "cipherLen": 29,
  "cipherHead": [
   66,
   75,
   26,
   99,
   214,
   80,
   57,
   229
  ],
  "plaintextLen": 13
 },

origin 是 "null":消息来自沙箱 iframe 的不透明源。12 字节是 AES-GCM 的 IV,每条消息由 crypto.getRandomValues 新生成;密文 29 字节 = 明文 {"word":"hi"} 的 13 字节 + 16 字节认证标签。cmd 是明文 "ping"。

📝NOTE
冷知识:iframe 曾经替任何人加密

payloadHandler 开头的 event.origin !== origin 是 #10423(首次发布于 tauri 2.0.0-rc.0)加的,同时给模板加了 origin 字段。在此之前它只检查消息形状(isIsolationPayload),任何能拿到 iframe 窗口引用的 frame 发来一条形状对的消息,都会被钩子处理、加密并发回,CHANGELOG 原文:「Explicitly check that the main frame's origin is the sender of Isolation Payloads」。加密的价值在于「只有隔离应用能产出密文」,一个替任何来源加密的 iframe 让这一点失效。

它保证什么,不管什么

sec-iso 的页面接着绕过 ipc.js,直接调 __TAURI_INTERNALS__.postMessage(页面脚本可以访问它),交给 Rust 一条自己构造的明文参数,以及正常走钩子、但钩子拒绝的一次 invoke:

 "secretViaInvoke": "timeout",
 "forgedPlain": "err \"missing field `nonce` at line 1 column 17\"",
尝试结果原因
正常 invoke('secret'),钩子拒绝钩子抛异常,消息被丢弃,Promise 永远不结束钩子是唯一的检查点;它拒绝时没有任何错误回到页面
自己构造明文参数被拒:missing field \nonce``没有密钥就造不出 Rust 能解开的参数,这是隔离模式要保证的事

隔离模式管的是参数:Rust 解得开的参数只能来自隔离应用。它不决定「能调哪个命令」,那是 ACL 的事(见运行时检查);sec-iso 没有应用权限清单,所以页面能调到 secret 这类命令本身,靠的不是隔离模式。

隔离模式也不处理响应:结果由 ipc:// 的 HTTP 响应直接回到主 frame 的 runCallback,钩子看不到,也不加密。它不是针对远程页面的机制(远程页面同样被注入了 tauri 的初始化脚本,挡远程页面的是 remote.urls),也不能代替 CSP(主 frame 里的恶意脚本照样可以读 DOM、发网络请求,只要 CSP 允许)。用了隔离模式的应用,仍然要用 ACL 把每个窗口能调的命令收到最小。

小结

  • 配了 CSP 时,每个 HTML 响应都带 Content-Security-Policy 头:编译期算好的哈希(内联脚本、.js 文件)和每次响应新生成的 nonce(外链 http 脚本、<style>)被补进 script-src / style-src,同时补上 'self'。
  • 页面自己的动态内联脚本和 eval 被挡;tauri 的初始化脚本和 evaluateJavaScript 不受页面 CSP 约束;connect-src 要放行 ipc:、http://ipc.localhost 和需要的 'self'。
  • freezePrototype 在所有 tauri 脚本和页面脚本之前冻结 Object.prototype,只此一个对象、只在主 frame。
  • 隔离模式:沙箱 iframe 持有每次启动生成的 AES-256 密钥,钩子检查后只加密 payload;Rust 在 parse_invoke_request 里解密,之后照常过 ACL。
  • 它保证 Rust 解得开的参数是隔离应用产出的;决定能调哪个命令的仍是 ACL,响应不经过钩子;配了 CSP 时那个隐藏 iframe 的样式会被挡住。

💬 关联讨论

发起讨论
加载中...