Rust in Everything
基于 tauri 2.12.5

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-cspdefault-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-mindefault-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。

路径怎么变成资源

AppManager::get_asset:

  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 掉,再当空路径处理。

📝NOTE
冷知识:这四级回退是 1.0 之前分三次加起来的

最早只有原路径。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 摘录,四个二进制对照):

protoproto-cspproto-nomodproto-min
内联脚本(报告本身)执行执行(哈希)执行('unsafe-inline')执行(哈希)
onclick 属性ranblockedranblocked
外链 app.jsranranranran
远程 remote.js(<script src>)ranranranran
内联 <style>红红(nonce)黑(被拦)红(nonce)
动态插入的 http://127.0.0.1:18940/dyn.jsloadedloadedloadedblocked/error
fetch('tauri://other/app.js')200TypeError: 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 侧没有收到这个请求。
❗IMPORTANT
动脑筋: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。

💬 关联讨论

发起讨论
加载中...