tauri:// 协议与 CSP
主线位置:启动主线上「加载
tauri://localhost/→ 内嵌资源 + CSP」这一步。一次启动看到了请求在主线程收到、在 tokio 线程上取资源,以及没有 CSP 时index.html的三个响应头;本页讲取资源的规则(路径怎么映射、找不到时回什么、Content-Type怎么定)和配了 CSP 之后响应头怎么拼出来。CSP 作为安全机制本身、isolation 模式对 CSP 的改动见 CSP、原型冻结与隔离模式;编译期算哈希、插 nonce 占位符见 generate_context!。
flowchart TB
req["WebKit 请求 tauri URL"] --> spawn["get: async_runtime::spawn"]
spawn --> strip["去掉查询串和 tauri://localhost"]
strip --> asset["get_asset"]
asset --> fb["原路径 / .html / /index.html / index.html"]
fb --> ishtml{"命中的键以 .html 结尾"}
ishtml -->|是, 且配了 CSP| csp["set_csp 换 nonce 拼 CSP"]
ishtml -->|否| mime["MimeType::parse"]
csp --> mime
mime --> resp["响应头 ACAO, 配置的 headers"]
resp --> hook["on_web_resource_request"]
hook --> back["responder.respond"]
本页实验 crate 是 exp-tauri/wv-proto:dist/ 里放一个带内联 <style>、外链 app.js、外链远程脚本 http://127.0.0.1:18940/remote.js、一个 onclick 属性和一段内联脚本的 index.html,再放 app.js、style.css、data.json、img.png、about.html、sub/index.html、a b.txt。同一份代码编成四个二进制,只有 tauri.conf.json 的 app.security 不同:
| 二进制 | app.security |
|---|---|
proto | 不设 CSP;headers 加一个 Cross-Origin-Opener-Policy: same-origin |
proto-csp | default-src 'self'; script-src 'self' 'unsafe-inline' http://127.0.0.1:18940; style-src 'self'; connect-src 'self' ipc: http://ipc.localhost |
proto-nomod | 同上,加 "dangerousDisableAssetCspModification": true |
proto-min | default-src 'self' http://127.0.0.1:18940; connect-src 'self' ipc: http://ipc.localhost |
窗口在 setup 里用 WebviewWindowBuilder 建,挂一个 on_web_resource_request,在 Rust 侧把 tauri 协议的每个响应打出来:
.on_web_resource_request(|req, resp| {
let h = |n: &str| resp.headers().get(n).map(|v| v.to_str().unwrap().to_string());
println!(
"RUST {} {} -> {} ct={:?} len={} csp={:?}",
req.method(),
req.uri(),
resp.status().as_u16(),
h("content-type").unwrap_or_default(),
resp.body().len(),
h("content-security-policy"),
);
})
页面脚本记下哪些东西生效了,再逐个 fetch 一串路径,记录状态码、Content-Type、正文长度、有没有 CSP 头和正文开头;最后把 index.html 连取两次,比较 nonce。远程脚本由 python3 -m http.server --bind 127.0.0.1 18940 提供。平台 M2 Max、macOS 27、rustc 1.99.0,debug 构建,--features tauri/custom-protocol。
处理函数:先交给 tokio
protocol::tauri::get 返回的闭包在主线程上被 wry 调用,立刻 spawn 到 async_runtime 上:
Box::new(move |_, request, responder| {
let context = context.clone();
crate::async_runtime::spawn(async move {
match get_response(&context, request).await {
Ok(response) => responder.respond(response),
Err(e) => responder.respond(
HttpResponse::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header(CONTENT_TYPE, mime::TEXT_PLAIN.essence_str())
.header("Access-Control-Allow-Origin", &context.window_origin)
.body(e.to_string().into_bytes())
.unwrap(),
),
}
});
})
Context 里的 window_origin 是 prepare_pending_webview 从 webview 的 URL 算出来的源(tauri://localhost),每个 tauri 协议的响应都带 Access-Control-Allow-Origin: <它>。get_response 先处理路径:
// use the entire URI as we are going to proxy the request
let path = if PROXY_DEV_SERVER {
request.uri().to_string()
} else {
// ignore query string and fragment
request
.uri()
.to_string()
.split(&['?', '#'])
.next()
.unwrap()
.into()
};
let path = path
.strip_prefix("tauri://localhost")
.map(|p| p.to_string())
// the `strip_prefix` only returns None when a request is made to `https://tauri.$P` on Windows and Android
// where `$P` is not `localhost/*`
.unwrap_or_default();
#[allow(unused_mut)]
let mut builder = HttpResponse::builder()
.add_configured_headers(manager.config.app.security.headers.as_ref())
.header("Access-Control-Allow-Origin", window_origin);
然后调 manager.get_asset(path, ..),把得到的 MIME 类型和 CSP 头加上;用户在 on_web_resource_request 里还能最后改一次响应。PROXY_DEV_SERVER 是 cfg!(all(dev, mobile)),桌面上永远是假:桌面开发模式下 webview 直接打开 devUrl(get_app_url 在 cfg(dev) 下返回它),页面根本不经过 tauri 协议,本页说的回退和 CSP 改写都不发生。
add_configured_headers 把 app.security.headers 里配置的头(Cross-Origin-Opener-Policy 之类,一共一组固定的名字)加到每个响应上,proto 的 fetch 拿到了 coop: same-origin。
路径怎么变成资源
pub fn get_asset(&self, mut path: String, _use_https_schema: bool) -> crate::Result<Asset> {
let assets = &self.assets;
if path.ends_with('/') {
path.pop();
}
path = percent_encoding::percent_decode(path.as_bytes())
.decode_utf8_lossy()
.to_string();
let path = if path.is_empty() {
// if the url is `tauri://localhost`, we should load `index.html`
"index.html".to_string()
} else {
// skip the leading `/`, if it starts with one.
path.strip_prefix('/').unwrap_or(path.as_str()).to_string()
};
let mut asset_path = AssetKey::from(path.as_str());
let asset_response = assets
.get(&asset_path)
.or_else(|| {
log::debug!("Asset `{path}` not found; fallback to {path}.html");
let fallback = format!("{path}.html").into();
let asset = assets.get(&fallback);
asset_path = fallback;
asset
})
.or_else(|| {
log::debug!("Asset `{path}` not found; fallback to {path}/index.html",);
let fallback = format!("{path}/index.html").into();
let asset = assets.get(&fallback);
asset_path = fallback;
asset
})
.or_else(|| {
log::debug!("Asset `{path}` not found; fallback to index.html");
let fallback = AssetKey::from("index.html");
let asset = assets.get(&fallback);
asset_path = fallback;
asset
})
.ok_or_else(|| {
let error = crate::Error::AssetNotFound(path.clone());
log::error!("{error}");
error
})?;
去掉末尾的 /、百分号解码、空路径当 index.html,然后四级回退:原路径、加 .html、加 /index.html、根上的 index.html。只要 frontendDist 里有 index.html,这个函数就不会返回 AssetNotFound,协议也就不会回 500。proto 的结果(fetch 侧,| 后是正文开头):
"(empty)": "200 text/html len=2322 csp=no | <!doctype html><html><",
"/": "200 text/html len=2322 csp=no | <!doctype html><html><",
"/index.html": "200 text/html len=2322 csp=no | <!doctype html><html><",
"/app.js": "200 text/javascript len=21 csp=no | window.__app = 'ran' ",
"/style.css": "200 text/css len=30 csp=no | #t { color: rgb(0, 0, ",
"/data.json": "200 application/json len=13 csp=no | {\"ok\": true} ",
"/img.png": "200 image/png len=104 csp=no | �PNG \u001a \u0000\u0000\u0000 IHDR\u0000\u0000\u0000 \u0000\u0000",
"/about": "200 text/html len=52 csp=no | <!doctype html><html><",
"/sub": "200 text/html len=51 csp=no | <!doctype html><html><",
"/sub/": "200 text/html len=51 csp=no | <!doctype html><html><",
"/missing.js": "200 text/html len=2322 csp=no | <!doctype html><html><",
"/missing.png": "200 text/html len=2322 csp=no | <!doctype html><html><",
"/a%20b.txt": "200 text/plain len=18 csp=no | file with a space ",
"/app.js?v=1": "200 text/javascript len=21 csp=no | window.__app = 'ran' ",
"tauri://other/app.js": "200 text/html len=2322 csp=no | <!doctype html><html><"
/about命中about.html(52 字节),/sub和/sub/都命中sub/index.html(51 字节)。/missing.js和/missing.png不存在,拿到的是状态 200、text/html的index.html。对前端路由(history 模式)这正是想要的;对一个写错路径的<script src>,浏览器拿到的是一段 HTML,报的是语法错误或 MIME 类型不对,而不是 404。- 查询串被丢掉,
/app.js?v=1就是app.js;%20被解码,a b.txt能取到。 tauri://other/app.js:URI 不以tauri://localhost开头,strip_prefix失败,unwrap_or_default()给出空路径,于是返回index.html。注释里说这只会在 Windows 和 Android 的http(s)://tauri.*上发生,在 macOS 上换一个主机名就能走到这条路。
Rust 侧看到的页面主导航是 GET tauri://localhost/ -> 200 ct="text/html" len=2322:WebKit 请求的 URI 带结尾的 /,get_asset 先把它 pop 掉,再当空路径处理。
最早只有原路径。PR #2022(「fix(core): fallback to index.html on asset loading」,tauri-v1.0.0-beta.2)加了最后一级,CHANGELOG 的理由是「Fallback to index.html on asset loading so router with history mode works.」PR #2329(tauri-v1.0.0-beta.6)在它前面插了 {path}/index.html:「Assets will now fallback to <uri>/index.html before /index.html, allowing anchor links to work as expected.」PR #3939(tauri-v1.0.0-rc.7)再插了 {path}.html:「Fallback to {path}.html when {path} is not found in the Tauri custom protocol handler.」中间还有一个 PR #3641(tauri-v1.0.0-rc.5):「Properly apply the CSP when loading a route that fallbacks to index.html.」它对应的就是下文 is_html 用回退后的 asset_path 而不是原路径来判断。
Content-Type 怎么定
get_asset 的最后几行:
let mut csp_header = None;
let is_html = asset_path.as_ref().ends_with(".html");
let final_data = if is_html {
let mut asset = String::from_utf8_lossy(&asset_response).into_owned();
if let Some(csp) = self.csp() {
#[allow(unused_mut)]
let mut csp_map = set_csp(&mut asset, &self.assets, &asset_path, self, csp);
// ...
csp_header.replace(Csp::DirectiveMap(csp_map).to_string());
}
asset.into_bytes()
} else {
asset_response.into_owned()
};
let mime_type = tauri_utils::mime_type::MimeType::parse(&final_data, &path);
这里有两个不同的路径:is_html 看的是回退后命中的键 asset_path,MIME 类型看的是请求的 path。MimeType::parse 先用 infer 按内容嗅探(SVG 除外),嗅不出来或嗅出 text/plain 才按扩展名查表;查表取的是最后一个 . 后面的部分,不在表里时(包括路径里根本没有 .)默认 text/html。所以 /missing.js 拿到 text/html 不是靠扩展名:infer 从内容认出了 HTML。img.png 是 infer 认出来的 image/png;app.js、style.css、data.json、a b.txt 是扩展名查表的结果。
配了 CSP 之后
csp() 在发布模式下取 app.security.csp,开发模式下优先取 devCsp。只要有值,每个命中 .html 的响应都经过 set_csp:取出编译期算好的哈希(这个 HTML 自己的内联脚本,加上所有 .js 文件的全局哈希),按指令调 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);
}
}
HTML 里编译期留下的每个占位符(__TAURI_SCRIPT_NONCE__、__TAURI_STYLE_NONCE__)换成一个新的随机 u64,同一批 nonce 和哈希追加到 script-src / style-src 后面;指令不存在就新建,并且保证有 'self'。proto-csp 主导航的响应头(Rust 侧):
RUST GET tauri://localhost/ -> 200 ct="text/html" len=2378 csp=Some("style-src 'self' 'nonce-17767490473118534858'; default-src 'self'; script-src 'self' 'unsafe-inline' http://127.0.0.1:18940 'nonce-305600598069571656' 'sha256-BTREx9ZDyKjGWdz5HC5IOZvadERqq0X+yyG/Xl9VNPA=' 'sha256-xUIyLn99oaiR/tHtvWU9ovl1YWuCUKgElgzxjd5SJHI='; connect-src 'self' ipc: http://ipc.localhost")
script-src多了一个 nonce(给外链的http://脚本)和两个哈希:sha256-BTRE…是app.js的全局哈希,sha256-xUIy…是页面里那段内联脚本。style-src多了一个 nonce,给内联<style>。- 正文变长了(2322 → 2378 字节):编译期把 HTML 解析再序列化过(
<!doctype html>变成<!DOCTYPE html>),又加了两个nonce="..."属性。 - 同一次运行里其他 HTML 也有 CSP 头。
/about拿到的script-src只多一个sha256-BTRE…:about.html没有内联脚本、没有占位符,但全局的.js哈希每个 HTML 都带;style-src原样是'self',因为没有 nonce 也没有哈希时不动它。/missing.js回退到index.html,带着完整的 CSP 和一组新的 nonce。
页面连取两次 index.html 的结果:
"index_twice": [
{
"body": [
"8241531850663135075",
"5380073773138385343",
"([^"
],
"header": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost; script-src 'self' 'unsafe-inline' http://127.0.0.1:18940 'nonce-5380073773138385343' 'sha256-BTREx9ZDyKjGWdz5HC5IOZvadERqq0X+yyG/Xl9VNPA=' 'sha256-xUIyLn99oaiR/tHtvWU9ovl1YWuCUKgElgzxjd5SJHI='; style-src 'self' 'nonce-8241531850663135075'",
...
},
{
"body": [
"2477963941207533640",
"2374367645713661052",
"([^"
],
"header": "default-src 'self'; style-src 'self' 'nonce-2477963941207533640'; connect-src 'self' ipc: http://ipc.localhost; script-src 'self' 'unsafe-inline' http://127.0.0.1:18940 'nonce-2374367645713661052' 'sha256-BTREx9ZDyKjGWdz5HC5IOZvadERqq0X+yyG/Xl9VNPA=' 'sha256-xUIyLn99oaiR/tHtvWU9ovl1YWuCUKgElgzxjd5SJHI='"
}
]
(body 是正文里所有 nonce="..." 的值;第三项 ([^ 是页面脚本里那个正则自己的源码被匹配到了。)每个响应的 nonce 都不同,正文里的值和头里的值一一对应。指令的顺序每次也不同:set_csp 返回的是 HashMap<String, CspDirectiveSources>,再经 Csp::DirectiveMap(..).to_string() 拼成字符串。
页面一侧的效果(REPORT 摘录,四个二进制对照):
proto | proto-csp | proto-nomod | proto-min | |
|---|---|---|---|---|
| 内联脚本(报告本身) | 执行 | 执行(哈希) | 执行('unsafe-inline') | 执行(哈希) |
onclick 属性 | ran | blocked | ran | blocked |
外链 app.js | ran | ran | ran | ran |
远程 remote.js(<script src>) | ran | ran | ran | ran |
内联 <style> | 红 | 红(nonce) | 黑(被拦) | 红(nonce) |
动态插入的 http://127.0.0.1:18940/dyn.js | loaded | loaded | loaded | blocked/error |
fetch('tauri://other/app.js') | 200 | TypeError: Load failed | 同左 | 同左 |
几处值得对着源码看:
proto-csp的script-src里写了'unsafe-inline',onclick却被拦了。CSP 规定:源列表里出现了 nonce 或哈希,'unsafe-inline'就被忽略。tauri 只要给script-src加了任何一个 nonce 或哈希(有.js文件就一定有),配置里的'unsafe-inline'就形同虚设,内联事件处理属性(onclick="...")全部失效。proto-nomod关掉了改写:响应头就是配置里的原文,script-src的'unsafe-inline'生效,onclick能跑;可style-src 'self'没有了 nonce,内联<style>被拦,字是黑的。正文里没有nonce属性(2322 字节,开头是被重新序列化过的<!DOCTYPE html>):dangerousDisableAssetCspModification为真时编译期也不插占位符。proto-min只写了default-src。tauri 新建了script-src 'self' 'nonce-..' 'sha256-..',script-src一旦存在,脚本就不再回落到default-src,于是default-src里允许的http://127.0.0.1:18940对脚本不再有效:带 nonce 的<script src>照样能加载,动态插入的同一来源脚本被拦(本地服务的日志里proto-min那一轮只有remote.js,没有dyn.js)。tauri://other/app.js在三个配了 CSP 的版本里都被connect-src 'self'拦在 WebKit 里,Rust 侧没有收到这个请求。
proto-csp 的 CSP 里明明写了 script-src 'self' 'unsafe-inline',页面上 <button onclick="window.__onclick = 'ran'"> 被 click() 之后,window.__onclick 是什么?把配置改成 dangerousDisableAssetCspModification: ["style-src"] 呢?平台 M2 Max、macOS 27,tauri 2.12.5,debug 构建。第二问不用跑,读 set_csp 回答。
dangerousDisableAssetCspModification 可以是布尔值,也可以是指令名列表(PR #4021 加的列表形式)。set_csp 用 can_modify("script-src") / can_modify("style-src") 分别判断,编译期的 inject_nonce_token、inject_script_hashes 用同一个判断。CSP 本身该怎么写、为什么默认要改写,见 CSP、原型冻结与隔离模式。
最后,CSP 只走响应头。set_csp 的文档注释还写着「Sets the CSP value to the asset HTML if needed (on Linux)」,但 2.12.5 里这个函数只改 nonce、不往 HTML 里写 <meta>;往 HTML 里注入 <meta http-equiv="Content-Security-Policy"> 的 html2::inject_csp 只在 prepare_webview 处理 data: URL 时调用(webview-data-url feature)。
Windows 和 Android 上的地址
以下只读了源码,没有实测。protocol::origin 决定所有自定义协议的源:
pub(crate) fn origin(scheme: &str, use_https: bool) -> String {
if cfg!(windows) || cfg!(target_os = "android") {
let http = if use_https { "https" } else { "http" };
format!("{http}://{scheme}.localhost")
} else {
format!("{scheme}://localhost")
}
}
tauri_protocol_url 用它得到应用的地址,所以 Windows 上页面在 http://tauri.localhost。原因在 wry:WebView2 不能像 WKURLSchemeHandler 那样注册任意 scheme,wry 给每个自定义协议注册一个 http(s)://<scheme>.localhost/* 的 WebResourceRequested 过滤器(webview2/mod.rs 的注释:「WebView2 supports non-standard protocols only on Windows 10+, so we have to use this workaround」),在回调里把请求交给协议处理函数,交出去之前把 URI 改回 <scheme>://localhost/... 的形式,所以 get_response 里的 strip_prefix("tauri://localhost") 在 Windows 上照样成立。用 http 还是 https 由 useHttpsScheme 决定,默认 http;配置文档提醒,这个值换了,源就变了,IndexedDB、cookie、localStorage 都会找不到旧数据。
动脑筋答案
proto-csp 的 REPORT:
REPORT {
"inline_onclick": "blocked",
"app_js": "ran",
"remote_js": "ran",
"inline_style": "rgb(255, 0, 0)",
"linked_style": "rgb(0, 0, 255)",
"dynamic_remote_js": "loaded",
window.__onclick没有被设置,页面记成blocked。响应头里的script-src是'self' 'unsafe-inline' http://127.0.0.1:18940 'nonce-..' 'sha256-..' 'sha256-..',出现了 nonce 和哈希,WebKit 按 CSP 的规定忽略'unsafe-inline',内联事件处理属性没有哈希可匹配,被拦。内联<script>能跑是因为它的哈希在列表里。- 设成
["style-src"]:can_modify("script-src")为真,script-src照样被追加 nonce 和哈希,onclick照样被拦;被关掉的只是style-src的改写,结果反而是内联<style>也被拦(style-src 'self'没有 nonce)。要让'unsafe-inline'对脚本生效,列表里得有script-src,代价是内联脚本不再靠哈希放行,而是靠'unsafe-inline'全部放行。
小结
- tauri 协议在主线程收请求、在
async_runtime上取资源;每个响应带Access-Control-Allow-Origin: <窗口的源>和app.security.headers里配置的头;桌面开发模式下页面直接走devUrl,不经过这里。 - 路径去掉查询串、去掉
tauri://localhost、去掉结尾/、百分号解码,然后按原路径、.html、/index.html、index.html回退;不存在的/foo.js回 200 和index.html,主机名不是localhost时也回index.html。 Content-Type先按内容嗅探、再按请求路径的扩展名;CSP 只加在命中的键以.html结尾的响应上。- 配了 CSP 时每次返回 HTML 都换新 nonce,连同编译期的哈希追加到
script-src/style-src,缺的指令会新建并补'self';指令顺序不固定。 - 一旦追加了 nonce 或哈希,配置里的
'unsafe-inline'就被忽略,onclick这类属性失效;只写default-src时新建的script-src会让default-src的来源对脚本失效。dangerousDisableAssetCspModification按指令关掉这套改写。 - Windows 和 Android 上源是
http(s)://tauri.localhost,由useHttpsScheme选http或https。