Rust in Everything
基于 tauri 2.12.5

on_message 与分发

主线位置:调用主线的中段。ipc:// 协议处理函数用 parse_invoke_request 拼出 InvokeRequest(见 ipc 协议),交给 Webview::on_message;本页从这里讲到 generate_handler! 生成的闭包被调用为止。闭包里面的包装代码见 #[command] 与 generate_handler!,参数怎么取、结果怎么回去见参数、返回值与 InvokeResolver。

flowchart TB
  req["InvokeRequest"] --> key{"invoke key 对吗"}
  key -->|不对| drop["return 不应答"]
  key -->|对| loc["is_local_url 定来源"]
  loc --> acl["resolve_access 查表"]
  acl --> need{"要强制检查吗"}
  need -->|"要且没查到"| deny["reject not allowed"]
  need -->|"不要或查到了"| pfx{"有 plugin: 前缀"}
  pfx -->|有| plug["run_plugin_invoke_handler"]
  pfx -->|没有| apph["run_invoke_handler"]
  plug -->|"返回 false"| nf["Command X not found"]
  apph -->|"返回 false"| nf

Webview::on_message 只有一百多行(L2005–L2170),做四件事:比对 invoke key、判断来源、决定要不要拦、把请求交给应用或插件的处理函数。第一件在一次 invoke 里已经跑过:key 不对时只打一行日志就 return,不调用应答闭包,请求永远得不到回应。本页讲后三件。

本页用了三个实验 crate,都从模板 base 复制,tauri =2.12.5,--features tauri/custom-protocol,平台 M2 Max、macOS 27、rustc 1.99.0:

  • ipc2-noacl 和 ipc2-acl:同一份 main.rs 和页面,隐藏窗口,区别只在 build.rs 和能力文件。前者没有应用 ACL 清单,后者有。
  • ipc2-mock:不建真窗口,用 tauri::test 的 MockRuntime 和 get_ipc_response 直接调 on_message,伪造远程来源、手工放行插件命令,把所有分支跑一遍。

来源:本地还是远程

on_message 第一行就算出 is_local,依据是 InvokeRequest.url,也就是请求的 Origin 头(一次 invoke 讲了它为什么取 Origin 而不是 webview 的地址)。is_local_url 三个条件满足一个就算本地:

  fn is_local_url(&self, current_url: &Url) -> bool {
    let uses_https = current_url.scheme() == "https";

    // if from `tauri://` custom protocol
    ({
      let protocol_url = self.manager().tauri_protocol_url(uses_https);
      current_url.scheme() == protocol_url.scheme()
      && current_url.domain() == protocol_url.domain()
    }) ||

    // or if relative to `devUrl` or `frontendDist`
      self
          .manager()
          .get_app_url(uses_https)
          .make_relative(current_url)
          .is_some()

      // or from a custom protocol registered by the user
      || ({
        let scheme = current_url.scheme();
        let protocols = self.manager().webview.uri_scheme_protocols.lock().unwrap();

        #[cfg(all(not(windows), not(target_os = "android")))]
        let local = protocols.contains_key(scheme);
        // ...
        local
      })
  }
条件macOS 上的例子
scheme 和域名与 tauri 协议的地址相同tauri://localhost
能相对于应用地址(开发模式的 devUrl,否则 frontendDist 对应的地址)表示开发时的 http://localhost:1420/...
scheme 是用户自己注册过的 URI scheme 协议register_uri_scheme_protocol("app", ..) 之后的 app://...

第三条在 Windows 和 Android 上换了写法:那里自定义协议的地址形如 http://<协议名>.localhost/...,所以改为看 scheme 是否与 tauri 协议相同、域名去掉 .localhost 后是否是注册过的协议名。这部分只读了源码。

三条都不满足就是远程,Origin::Remote { url }。这个 Origin 和 webview 的标签、所在窗口的标签一起交给 RuntimeAuthority::resolve_access,查出这个命令在这个上下文里被哪些能力允许。查表的规则(窗口和 webview 的 glob、remote.urls 的 URL 模式、拒绝优先)属于运行时检查,这里只关心它的结果:Option<Vec<ResolvedCommand>>,None 表示没有任何能力允许。

先查,再决定拦不拦

on_message 对每一个请求都调 resolve_access,顺手取出 has_app_manifest();查到的结果放进 Invoke.acl,命令参数里的 CommandScope / GlobalScope 从这里取作用域。查完之后才决定要不要用这个结果拦人:

    let plugin_command = request.cmd.strip_prefix("plugin:").map(|raw_command| {
      let mut tokens = raw_command.split('|');
      // safe to unwrap: split always has a least one item
      let plugin = tokens.next().unwrap();
      let command = tokens.next().map(|c| c.to_string()).unwrap_or_default();
      (plugin, command)
    });

    // Check ACL on plugin commands, when the app defined its ACL manifest,
    // or when the request comes from a non-local (remote) origin.  This
    // ensures remote content can never reach custom commands unless an
    // explicit `remote` capability has been configured for them.
    if (plugin_command.is_some() || has_app_acl_manifest || !is_local)
      // TODO: Remove this special check in v3
      && request.cmd != crate::ipc::channel::FETCH_CHANNEL_DATA_COMMAND
      && invoke.acl.is_none()
    {
      #[cfg(debug_assertions)]
      {
        // ...
        invoke.resolver.reject(
          manager
            .runtime_authority
            .lock()
            .unwrap()
            .resolve_access_message(
              key,
              &command_name,
              invoke.message.webview.window().label(),
              invoke.message.webview.label(),
              &acl_origin,
            ),
        );
      }
      #[cfg(not(debug_assertions))]
      invoke
        .resolver
        .reject(format!("Command {} not allowed by ACL", request.cmd));
      return;
    }

拦下的条件是三项相与:

  1. 这个请求「需要检查」:是插件命令(plugin: 开头),或应用声明了自己的 ACL 清单,或来源是远程;
  2. 命令不是 plugin:__TAURI_CHANNEL__|fetch(下面单独讲);
  3. resolve_access 没查到允许它的能力。

反过来读第一项:应用自己的命令、本地页面、应用没有 ACL 清单,三者同时成立时根本不看查表结果。has_app_acl_manifest 来自编译期:tauri-build 的 acl::build 只有在应用自己定义了权限时,才往 ACL 清单里加一个键 __app-acl__。应用的权限有两个来源:build.rs 里 Attributes::app_manifest(AppManifest::new().commands(..)) 为每个命令生成的 allow-* / deny-*,以及应用 permissions/ 目录下手写的权限文件。Resolved 的 has_app_acl 就是「清单里有没有这个键」(见 tauri-build)。光写能力文件不算。

实验:同一个页面,有没有应用清单

两个 crate 共用的 main.rs,四个命令都注册了:

#[tauri::command]
fn greet(name: &str) -> String {
  format!("hello, {name}")
}

#[tauri::command]
fn secret() -> &'static str {
  "the secret"
}

#[tauri::command]
fn hidden() -> &'static str {
  "not in the app manifest"
}

#[tauri::command]
fn report(app: tauri::AppHandle, msg: String) {
  println!("REPORT {msg}");
  app.exit(0);
}

fn main() {
  // ... 15 秒看门狗
  tauri::Builder::default()
    .invoke_handler(tauri::generate_handler![greet, secret, hidden, report])
    .run(tauri::generate_context!())
    .expect("error while running tauri application");
}

页面依次调五个命令,把结果交回 report:

<script>
(async () => {
  const inv = window.__TAURI_INTERNALS__.invoke;
  const out = {};
  for (const [cmd, args] of [['greet', { name: 'rie' }], ['secret', {}], ['hidden', {}], ['nope', {}], ['plugin:event|nope', {}]]) {
    try { out[cmd] = { ok: await inv(cmd, args) }; } catch (e) { out[cmd] = { err: e }; }
  }
  await inv('report', { msg: JSON.stringify(out, null, 1) });
})();
</script>

ipc2-noacl 的 build.rs 是模板的 tauri_build::build(),能力文件只给 main 窗口 core:default。ipc2-acl 声明了应用清单,清单里有 greet、secret、report,没有 hidden;能力只放行 greet 和 report:

fn main() {
  let attributes = tauri_build::Attributes::new()
    .app_manifest(tauri_build::AppManifest::new().commands(&["greet", "secret", "report"]));
  tauri_build::try_build(attributes).expect("failed to run tauri-build");
}
{
  "identifier": "default",
  "windows": ["main"],
  "permissions": ["core:default", "allow-greet", "allow-report"]
}

ipc2-noacl,debug 构建:

REPORT {
 "greet": {
  "ok": "hello, rie"
 },
 "secret": {
  "ok": "the secret"
 },
 "hidden": {
  "ok": "not in the app manifest"
 },
 "nope": {
  "err": "Command nope not found"
 },
 "plugin:event|nope": {
  "err": "event.nope not allowed. Command not found"
 }
}

ipc2-acl,debug 构建:

REPORT {
 "greet": {
  "ok": "hello, rie"
 },
 "secret": {
  "err": "secret not allowed. Permissions associated with this command: allow-secret"
 },
 "hidden": {
  "err": "hidden not allowed. Command not found"
 },
 "nope": {
  "err": "nope not allowed. Command not found"
 },
 "plugin:event|nope": {
  "err": "event.nope not allowed. Command not found"
 }
}

ipc2-acl,release 构建:

REPORT {
 "greet": {
  "ok": "hello, rie"
 },
 "secret": {
  "err": "Command secret not allowed by ACL"
 },
 "hidden": {
  "err": "Command hidden not allowed by ACL"
 },
 "nope": {
  "err": "Command nope not allowed by ACL"
 },
 "plugin:event|nope": {
  "err": "Command plugin:event|nope not allowed by ACL"
 }
}
  • 没有应用清单时,本地页面能调到 generate_handler! 里注册的每一个应用命令,能力文件里写没写它们无关紧要;secret 照样返回。
  • 有了应用清单,三项条件的第一项对所有应用命令都成立,于是每个应用命令都得在能力里被放行:secret 在清单里但能力没放行,hidden 连清单都不在,结果都是拒绝。清单只是让 tauri-build 生成 allow-secret 这种权限的来源,放不放行由能力决定。
  • 插件命令不管有没有应用清单都要查,所以 plugin:event|nope 两边都是拒绝。
  • debug 和 release 的拒绝文本不同:debug 走 resolve_access_message,按清单说明「哪些权限关联了这个命令」或者「没有这个命令」;release 只有一句 Command <原始命令名> not allowed by ACL。文本怎么拼见运行时检查。

条件里的 || !is_local 是 2.11.1 才加上的:在那之前,没有应用清单的应用里,远程来源也能直接调应用命令,来龙去脉见运行时检查。

ACL 在找命令之前

注意上面 nope 的三种结果:没有应用清单时是 Command nope not found,有了清单就变成「not allowed」。on_message 不知道 generate_handler! 里有哪些命令,它只能把请求交出去、看处理函数返回 true 还是 false;而 ACL 判断在交出去之前,所以只要「需要检查」,一个不存在的命令先被 ACL 拒绝,永远走不到「not found」。

ipc2-mock 把这些分支逐个跑了一遍。测试用 mock_context 造上下文,用 Context::runtime_authority_mut() 改它的 RuntimeAuthority:要模拟「有应用清单」就换成一个 has_app_acl: true 的空 Resolved;要放行某个命令就调隐藏的 __allow_command(对本地来源、所有窗口放行)。应用里注册了一个内联插件 demo,它只有一个命令 ping:

fn demo<R: Runtime>() -> TauriPlugin<R> {
  tauri::plugin::Builder::new("demo")
    .invoke_handler(tauri::generate_handler![ping])
    .build()
}

  fn app_with(allow: &[&str], app_acl: bool) -> (tauri::App<MockRuntime>, WebviewWindow<MockRuntime>) {
    let mut ctx = mock_context(noop_assets());
    if app_acl {
      *ctx.runtime_authority_mut() = RuntimeAuthority::new(
        Default::default(),
        Resolved {
          has_app_acl: true,
          ..Default::default()
        },
      );
    }
    for c in allow {
      ctx
        .runtime_authority_mut()
        .__allow_command(c.to_string(), ExecutionContext::Local);
    }
    let app = handlers(mock_builder()).build(ctx).unwrap();
    let w = WebviewWindowBuilder::new(&app, "main", Default::default())
      .build()
      .unwrap();
    (app, w)
  }

每次调用构造一个 InvokeRequest,url 取 tauri://localhost(本地)或 https://evil.example(远程),invoke_key 用 tauri::test::INVOKE_KEY。cargo test --release -- --nocapture 的输出:

== dispatch (release), no app ACL manifest, nothing allowed
greet        local  => Ok(Json "hello, rie")
greet        remote => Err("Command greet not allowed by ACL")
nope         local  => Err("Command nope not found")
nope         remote => Err("Command nope not allowed by ACL")
plugin:demo|ping    local  => Err("Command plugin:demo|ping not allowed by ACL")
plugin:__TAURI_CHANNEL__|fetch local  => Err("missing channel id header")
plugin:__TAURI_CHANNEL__|fetch remote => Err("missing channel id header")
== dispatch (release), plugin commands allowed for local
plugin:demo|ping    local  => Ok(Json "pong")
plugin:demo|ping    remote => Err("Command plugin:demo|ping not allowed by ACL")
plugin:demo|missing local  => Err("Command missing not found")
plugin:ghost|x      local  => Err("plugin ghost not found")
plugin:demo         local  => Err("Command  not found")
== dispatch (release), has_app_acl = true
greet        local  => Err("Command greet not allowed by ACL")
nope         local  => Err("Command nope not allowed by ACL")
greet (allowed) local => Ok(Json "hello, rie")

debug 构建下拒绝的文本换成 resolve_access_message 的长格式,其余相同。mock_context 的 ACL 清单是空的,所以 debug 文本在这里是「greet not allowed. Plugin not found」这类,和真实应用里的不一样,以上一节真实应用的输出为准。

把结果按「要不要查」和「查到没有」整理一下:

请求要强制检查查到允许结果
应用命令,本地,无应用清单否—直接分发
应用命令,远程是否not allowed
应用命令,本地,有应用清单是看能力放行或 not allowed
不存在的命令,本地,无应用清单否—Command nope not found
不存在的命令,其他情形是否not allowed
插件命令,任何来源是看能力放行或 not allowed
plugin:__TAURI_CHANNEL__|fetch,任何来源不查—直接分发
📝NOTE
没有蠢问题

问: 没有应用清单,就等于应用命令完全不设防?

答: 只对本地来源不设防。2.11.1 起远程来源一律要查,没有 remote 能力就调不到。本地来源指的是 tauri://localhost 上(以及应用地址下、自定义协议下)的页面,也就是你打包进去的前端代码。威胁在于「本地页面里跑了不该跑的 JS」:打包进来的第三方脚本、渲染不可信内容时的 XSS,它们和你自己的代码拿到的是同一个 invoke,能调 generate_handler! 里的每一个命令。声明应用清单之后,每个命令要在能力里按窗口、webview 放行,才算把应用命令也纳入了最小权限。CSP 和隔离模式是另外两道防线,见 CSP、原型冻结与隔离模式。

plugin: 分流

过了 ACL,on_message 看命令名有没有 plugin: 前缀。有前缀时,strip_prefix("plugin:") 之后按 | 切,第一段是插件名,第二段是命令名(没有 | 时是空字符串),然后把 invoke.message.command 改写成不带前缀的命令名,交给 AppManager::run_plugin_invoke_handler:

    if let Some((plugin, command_name)) = plugin_command {
      invoke.message.command = command_name;

      #[cfg(desktop)]
      let command = invoke.message.command.clone();
      // ...
      #[allow(unused_mut)]
      let mut handled = manager.run_plugin_invoke_handler(plugin, invoke);

      if handled {
        return;
      }
      // ... (mobile: 交给 Android / iOS 插件)
      #[cfg(desktop)]
      resolver.reject(format!("Command {command} not found"));
    } else {
      let command = invoke.message.command.clone();
      let handled = manager.run_invoke_handler(invoke);
      if !handled {
        resolver.reject(format!("Command {command} not found"));
      }
    }

插件表里按名字找插件,找到就调它的 extend_api(plugin::Builder 建的插件就是它的 generate_handler! 闭包),找不到直接 reject("plugin {plugin} not found") 并返回 true。这一段和插件表的那把锁见插件。实验里对应三种结果:

  • plugin:demo|missing:插件在,闭包的 match 落到 _ 返回 false,拒绝文本是 Command missing not found,不带插件名,因为 command 是改写之后的值。
  • plugin:ghost|x:插件不在,plugin ghost not found。
  • plugin:demo:没有 |,命令名是空字符串,得到 Command not found(两个空格之间是空的命令名)。

这三种都要先过 ACL,所以在正常配置下很难看到:插件不存在,就不会有它的权限清单,能力里也写不出放行它的权限,请求会先被 ACL 挡下。实验靠 __allow_command 绕过了这一步。

没有前缀的走 AppManager::run_invoke_handler,它只有一行 (self.webview.invoke_handler)(invoke),调的就是 Builder::invoke_handler 存下的闭包。返回 false 时拒绝 Command {command} not found,命令名原样。

移动端在插件没认领命令时还会把请求转给 Android / iOS 那一侧的插件实现(plugin::mobile::run_command,命令名转成 lowerCamelCase),本卷不展开。

特例:plugin:__TAURI_CHANNEL__|fetch

条件的第二项把一个命令整个排除在 ACL 之外:FETCH_CHANNEL_DATA_COMMAND,也就是 plugin:__TAURI_CHANNEL__|fetch。它是 Channel 发大消息时 JS 回头来取数据的命令(见 Channel),由 Builder::build 里注册的内置插件 __TAURI_CHANNEL__ 处理。实验里它在本地、远程两种来源下都没有被 ACL 拦,走到了命令本身,因为请求没带 Tauri-Channel-Id 头,得到 missing channel id header。

这条豁免是 #13032(「fix: channel no longer being always allowed」,首次发布于 tauri 2.4.0)加的,带着「TODO: Remove this special check in v3」;同一个提交删掉了 crates/tauri/build.rs 里给它生成权限的那一项 ("core:__TAURI_CHANNEL__", &[("fetch", true)]),换成一行注释掉的 ("core:channel", ..) 和「TODO: Enable this in v3」:这个命令不再有权限可写,只能硬编码放行。远程页面能调到它并不意味着能拿到别人的数据:队列按 webview 隔开,取数据要同一个 webview、对得上的 id,见 Channel。

❗IMPORTANT
动脑筋:ipc2-acl 的 release 构建里,本地页面调一个根本没注册的命令 nope。拒绝文本是 Command nope not found 还是别的什么?换成没有应用清单的 ipc2-noacl 呢?平台 M2 Max、macOS 27,tauri 2.12.5。

动脑筋答案

ipc2-acl,release:

 "nope": {
  "err": "Command nope not allowed by ACL"
 },

ipc2-noacl,release:

 "nope": {
  "err": "Command nope not found"
 },
  • 有应用清单时,has_app_acl_manifest 为真,应用的每个命令(包括不存在的)都「需要检查」;resolve_access("nope", ..) 当然查不到,于是在交给 generate_handler! 的闭包之前就被拒绝。「not found」这句在 on_message 的最后,走不到。
  • 没有应用清单、来源又是本地时,ACL 不起作用,请求进了闭包,match 落到 _ 返回 false,才是 Command nope not found。
  • 所以调试「not allowed by ACL」时,先确认命令名没有拼错:拼错的命令在有清单的应用里也报「not allowed」。debug 构建的文本更有用,拼错时它说「nope not allowed. Command not found」。

小结

  • on_message 按 Origin 判断本地还是远程:tauri 协议地址、应用地址(devUrl / frontendDist)、用户注册的 URI scheme 都算本地。
  • 每个请求都查 ACL,查到的结果放进 Invoke.acl 供作用域使用;但只在「插件命令、或有应用 ACL 清单、或远程来源」时才用它拦人,plugin:__TAURI_CHANNEL__|fetch 永远不拦。
  • 没有应用清单时,本地页面能调 generate_handler! 里的每一个应用命令,能力文件管不到它们;远程来源从 2.11.1(#15266)起一律要查。
  • ACL 判断在找命令之前:需要检查时,不存在的命令也报「not allowed」,不是「not found」。debug 和 release 的拒绝文本不同。
  • plugin:<名>|<命令> 改写成不带前缀的命令名交给插件,插件不在是 plugin X not found,命令不在是 Command <命令> not found(不带插件名);其余交给 run_invoke_handler。

💬 关联讨论

发起讨论
加载中...