Rust in Everything
基于 tauri 2.12.5

配了 CSP,IPC 悄悄退回 postMessage

主线位置:调用主线的第二步,ipc-protocol.js 用 fetch 发 POST ipc://localhost/<cmd>。这个 fetch 是页面脚本发出的请求,受页面 CSP 的 connect-src 约束。退路的完整条件见 JS 侧:invoke 与回调的「退回 postMessage」,两条路的代价见 ipc 协议处理的「两条路的代价:5 MiB」;CSP 头怎么生成见 CSP、原型冻结与隔离模式。

sequenceDiagram
  participant P as 页面
  participant K as WebKit CSP
  participant R as Rust
  P->>K: fetch ipc://localhost/channel
  K-->>P: 拦下 TypeError Load failed
  P->>P: console.warn 并置 customProtocolIpcFailed
  P->>R: window.ipc.postMessage 同一条消息
  R-->>P: eval 回调
  P->>R: 之后每次都走 postMessage

实验 crate exp-tauri/pit3-csp:从模板 base 复制,三个命令。channel 收 tauri::ipc::Request,看请求头里有没有 Tauri-Invoke-Key,借此分辨这次请求走的是哪条路;upload 期望收到原始字节;report 打印结果并退出:

#[tauri::command]
fn channel(request: tauri::ipc::Request<'_>) -> String {
  // only the fetch(ipc://) path sends Tauri-Invoke-Key as an HTTP header;
  // postMessage puts the key in the JSON body and passes no headers
  if request.headers().contains_key("tauri-invoke-key") {
    "ipc:// custom protocol".into()
  } else {
    "postMessage".into()
  }
}

#[tauri::command]
fn upload(request: tauri::ipc::Request<'_>) -> Result<String, String> {
  match request.body() {
    tauri::ipc::InvokeBody::Raw(bytes) => Ok(format!("raw body, {} bytes", bytes.len())),
    tauri::ipc::InvokeBody::Json(v) => Err(format!(
      "expected raw bytes, got JSON {}",
      v.to_string().chars().take(40).collect::<String>()
    )),
  }
}

页面把 console.warn 和 securitypolicyviolation 都记下来,调两次 channel、一次 upload,再 fetch 一个自己的 data.json:

  const T = window.__TAURI_INTERNALS__;
  for (const i of [1, 2]) {
    try { log.push(`invoke #${i}: ${await T.invoke('channel')}`); }
    catch (e) { log.push(`invoke #${i} rejected: ${e}`); }
  }
  try { log.push('upload: ' + await T.invoke('upload', new Uint8Array([1, 2, 3, 4]))); }
  catch (e) { log.push('upload rejected: ' + e); }
  try { log.push('fetch data.json: ' + JSON.stringify(await (await fetch('data.json')).json())); }
  catch (e) { log.push('fetch data.json: ' + e); }

tauri =2.12.5,--features tauri/custom-protocol,隐藏窗口,debug 构建,平台 M2 Max、macOS 27、rustc 1.99.0(WKWebView)。

现象

一:CSP 只写 default-src 'self'。 这是最常见的「先配一个严格 CSP」的写法:

    "security": { "csp": "default-src 'self'" }
REPORT 
CSP violation: connect-src ipc://localhost/channel
console.warn: IPC custom protocol failed, Tauri will now use the postMessage interface instead TypeError: Load failed
invoke #1: postMessage
invoke #2: postMessage
upload rejected: expected raw bytes, got JSON [1,2,3,4]
fetch data.json: {"hello":"data"}

两次 invoke 都成功了,所以表面上一切正常;Rust 侧没有任何输出,也没有错误。实际发生的事:

  • 第一次 invoke 的 fetch 被 connect-src(没写就回落到 default-src 'self')拦下,ipc-protocol.js 打一条警告,把同一条消息改用 window.ipc.postMessage 重发。
  • 第二次直接走 postMessage,不再试 fetch。这个页面此后的每一次 IPC 都是 postMessage。
  • upload 收到的不是原始字节,而是一个 JSON 数组:postMessage 只能送字符串,Uint8Array 被转成了数组。按 InvokeBody::Raw 写的命令在这里直接失败。没有失败的命令也慢了:5 MiB 的字节在 postMessage 路上要几百毫秒,fetch 路上几毫秒到十几毫秒(ipc 协议处理的实测)。

二:补了 connect-src,但漏了 'self'。

    "security": { "csp": "default-src 'self'; connect-src ipc: http://ipc.localhost" }
REPORT 
invoke #1: ipc:// custom protocol
invoke #2: ipc:// custom protocol
upload: raw body, 4 bytes
CSP violation: connect-src tauri://localhost/data.json
fetch data.json: TypeError: Load failed

IPC 回到了 ipc://,可页面自己的 fetch('data.json') 被拦了。connect-src 一旦写出来,就不再回落到 default-src,页面要请求自己的资源,得把 'self' 也写进去。这个页面把 fetch 包在了 try 里;没包的代码会抛一个未处理的拒绝,后面的逻辑一起停住,CSP、原型冻结与隔离模式的实验第一次运行就是这样什么也没报回来。

出处

失败处理在 sendIpcMessage 的 fetch Promise 链末尾:

          (e) => {
            console.warn(
              'IPC custom protocol failed, Tauri will now use the postMessage interface instead',
              e
            )
            // failed to use the custom protocol IPC (either the webview blocked a custom protocol or it was a CSP error)
            // so we need to fallback to the postMessage interface
            customProtocolIpcFailed = true
            sendIpcMessage(message)
          }

customProtocolIpcFailed 是这段脚本闭包里的一个变量,置为 true 之后没有地方再把它改回来,直到页面重新加载。退路本来是为「webview 不支持自定义协议」准备的,CSP 拦截走的是同一个分支,脚本分不清两者,所以只给一条警告。

Rust 侧收到的 postMessage 由 handle_ipc_message 处理(见 ipc 协议处理):消息整个是一个 JSON 字符串,payload 反序列化成 serde_json::Value,InvokeRequest 的头只有 options.headers 里页面显式传的那些。这就是上面 channel 能分辨两条路、upload 拿不到 Raw 的原因。

tauri 不会替你往 connect-src 里补 ipc:。它改写 CSP 时只往 script-src、style-src 里加哈希和 nonce(set_csp),connect-src 原样保留。

修法

connect-src 写全:'self' 给页面自己的请求,ipc: 给 macOS 上的 ipc://localhost(Linux 按源码也是它),http://ipc.localhost 给 Windows 上的 IPC 地址(Windows 没有实测,地址来自 convertFileSrc 和 protocol::origin 的源码):

    "security": { "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost" }
REPORT 
invoke #1: ipc:// custom protocol
invoke #2: ipc:// custom protocol
upload: raw body, 4 bytes
fetch data.json: {"hello":"data"}

没有违规,没有警告,字节以 Raw 到达。页面还要连别的地址(后端 API、asset: 协议等),继续往 connect-src 里加;asset: 的情形见 convertFileSrc 的图片加载失败。开发模式下生效的是 devCsp(配了的话),两份要一起改。

怎么确认没中招

  • 打开开发者工具(devtools feature)看控制台:出现 IPC custom protocol failed, Tauri will now use the postMessage interface instead 就是中招了。它只出现一次,在第一次 invoke 时。
  • 页面里挂一个 securitypolicyviolation 监听器,开发构建里把 violatedDirective 和 blockedURI 报给 Rust:connect-src ipc://localhost/<cmd> 就是这一条。
  • 想在 Rust 侧断言,可以照上面的 channel 写一个调试命令:fetch 路的请求带 Tauri-Invoke-Key 头,postMessage 路的没有。
  • 收原始字节的命令(InvokeBody::Raw、tauri::ipc::Request 读正文)对两条路的差别最敏感,给它们写一条走真实页面的测试最划算。

小结

  • 页面 CSP 的 connect-src(或回落到的 default-src)不放行 ipc: 时,第一次 invoke 的 fetch 被拦,ipc-protocol.js 只打一条 console.warn,此后整个页面改走 postMessage。
  • 功能看起来正常,但原始字节变成 JSON 数组,大块字节的传输慢一个多数量级;Rust 侧什么也看不到。
  • connect-src 写成 'self' ipc: http://ipc.localhost,再加页面需要的其他来源;漏了 'self',页面自己的 fetch 会失败。

💬 关联讨论

发起讨论
加载中...