配了 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(配了的话),两份要一起改。
怎么确认没中招
- 打开开发者工具(
devtoolsfeature)看控制台:出现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会失败。