Rust in Everything
基于 tauri 2.12.5

运行时检查

主线位置:调用主线上 Webview::on_message 比对完 invoke key 之后、分给插件或 generate_handler! 之前的那一步。invoke key 怎么比、分发怎么走见 on_message 与分发;本页讲 ACL 这一步:查什么、什么时候查、拒绝时说什么,以及查到的结果怎么以作用域的形式交给命令。

flowchart TB
  req["InvokeRequest"] --> origin{"is_local_url"}
  origin -->|是| loc["Origin Local"]
  origin -->|否| rem["Origin Remote url"]
  loc --> ra["resolve_access"]
  rem --> ra
  ra --> denied{"有匹配来源的拒绝"}
  denied -->|是| none["None"]
  denied -->|否| allow{"来源和标签都匹配"}
  allow -->|否| none
  allow -->|是| some["Some 命中的条目"]
  none --> gate{"插件命令 或有应用清单 或远程"}
  gate -->|是| reject["拒绝"]
  gate -->|否| run["照常执行 acl 为 None"]
  some --> run2["执行 acl 交给命令"]

实验有三个:sec-acl 和 sec-noacl 跑在 MockRuntime 上(tauri::test::get_ipc_response 直接调 on_message,来源 URL 由测试指定);sec-win 是一个真正的 tauri 应用,三个隐藏窗口 main、other、remote,remote 加载本机 python3 -m http.server 在 127.0.0.1:18970 上提供的页面。依赖 tauri =2.12.5,--features tauri/custom-protocol,平台 M2 Max、macOS 27、rustc 1.99.0(WKWebView)。Windows、Linux 只读了源码。

在 on_message 里的位置

on_message 对每个请求都调一次 resolve_access,结果放进 Invoke::acl,然后才决定要不要拿它拦人:

    // 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)]
      {
        let (key, command_name) = plugin_command
          .clone()
          .unwrap_or_else(|| (tauri_utils::acl::APP_ACL_KEY, request.cmd.clone()));
        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;
    }

三个输入:命令名(plugin:files|read 这种完整形式)、窗口标签和 webview 标签、来源。FETCH_CHANNEL_DATA_COMMAND 是 Channel 取大消息用的内部命令,永远不拦,见 Channel。

来源:本地还是远程

is_local_url 判断请求的 URL 算不算本地,三条满足一条就算:

  1. scheme 和域名与 tauri 协议的地址相同(macOS 上 tauri://localhost,Windows、Android 上 http(s)://tauri.localhost);
  2. 能相对于应用地址 get_app_url 表示:开发模式下是 devUrl,发布模式下 frontendDist 配成 URL 时就是它;
  3. scheme 是用户用 register_uri_scheme_protocol 注册过的协议(Windows、Android 上看 <协议名>.localhost 这一段域名)。

第 2 条的后果是:frontendDist 配成一个 https:// 地址的应用,那个站点的页面就是「本地」页面,不需要 remote.urls,也享受下面那条应用命令的豁免。

请求的 URL 从哪来,两条 IPC 通道不一样(见一次 invoke):ipc:// 自定义协议这条路,parse_invoke_request 取的是 Origin 请求头;postMessage 退路取的是 wry 交来的请求 URI,也就是发消息那个 frame 的完整地址。这个差别后面会咬人一口。

resolve_access:拒绝只看来源

  pub fn resolve_access(
    &self,
    command: &str,
    window: &str,
    webview: &str,
    origin: &Origin,
  ) -> Option<Vec<ResolvedCommand>> {
    if self.is_denied(command, origin) {
      None
    } else {
      self.allowed_commands.get(command).and_then(|resolved| {
        let resolved_cmds = resolved
          .iter()
          .filter(|cmd| {
            origin.matches(&cmd.context)
              && (cmd.webviews.iter().any(|w| w.matches(webview))
                || cmd.windows.iter().any(|w| w.matches(window)))
          })
          .cloned()
          .collect::<Vec<_>>();
        if resolved_cmds.is_empty() {
          None
        } else {
          Some(resolved_cmds)
        }
      })
    }
  }

Origin::matches:Local 只配 ExecutionContext::Local,Remote { url } 只配 Remote 且 URLPattern 测试通过。允许的一侧要求来源匹配、并且窗口或 webview 标签匹配;返回的是所有命中的条目,作用域要靠它们。拒绝的一侧是 is_denied → denied_on,只按来源过滤。resolve_access 的文档注释写得很直白:「window and webview labels are not taken into account for denial」。

❗IMPORTANT
动脑筋:sec-acl 里能力 main(windows: ["main"])允许 wipe,能力 audit(windows: ["audit"])拒绝 wipe。应用里根本没有叫 audit 的窗口。从 main 窗口的本地页面调 wipe,结果是什么?

拒绝和允许用的是同一个 Origin,所以「来源」在两边的含义一致:本地页面就是 Local,不区分是哪个本地页面;远程页面是一个 URL,按每条拒绝条目各自的 URLPattern 去测。

📝NOTE
冷知识:拒绝曾经连来源都不看

#16072(首次发布于 tauri 2.12.0)之前,resolve_access 的拒绝判断是 denied_commands.get(command).map(..).is_some(),提交说明指出它「is true whenever the command has any deny entry regardless of whether the origin matched」:一个只对远程 URL 生效的能力里写了 deny-xxx,本地页面也调不了 xxx。CHANGELOG 原文:「deny permissions are now correctly scoped to the capability's execution context (local/remote) instead of denying the command for every origin.」修复只把来源加了回来,窗口和 webview 标签仍然不参与拒绝,denied_on 的文档注释把这一点写成了规则。

拒绝时说什么

debug 构建里拒绝信息由 resolve_access_message 现场重算一遍,它要用完整的清单表,这也是 RuntimeAuthority 在 debug_assertions 下总是留着清单表的原因;release 构建一律是 Command <命令名> not allowed by ACL。sec-acl 的 matrix 测试,debug:

== window label
[main @ tauri://localhost] greet -> Ok("hello, rie")
[other @ tauri://localhost] greet -> Err:
    greet not allowed on window "other", webview "other", URL: local
    
    allowed on: [windows: "main", URL: local]
    
    referenced by: capability: main, permission: allow-greet
[main @ tauri://localhost] nope -> Err:
    nope not allowed. Command not found
情况debug 信息开头后面列出
有匹配来源的拒绝<cmd> explicitly denied on origin <来源>哪份能力的哪条权限拒绝的
有允许条目,但来源或标签对不上<cmd> not allowed on window "..", webview "..", URL: ..所有允许条目的窗口、webview、来源,和它们的出处
没有任何允许条目<cmd> not allowed. 清单里哪些权限能放行它;清单里没有就是 Command not found,连清单都没有是 Plugin not found

插件命令显示成 files.read 这样的「插件.命令」,最后一种的权限列表带清单前缀,插件那一页看到的「Permissions associated with this command: probe:allow-ping」就是它。源码里还有第四种格式「<cmd> not allowed on origin [..]. Please create a capability that has this origin on the context field.」,但它走不到:它在 allowed_commands.get(&command) 为 None 的分支里,条件却是 command_matches,而 command_matches 就是同一个 allowed_commands.get(&command),在这个分支里必然也是 None。来源不对的请求(下文的远程页面)实际得到的是第二种。同一个测试 --release:

[other @ tauri://localhost] greet -> Err:
    Command greet not allowed by ACL
[main @ tauri://localhost] nope -> Err:
    Command nope not allowed by ACL
== deny wins
[main @ tauri://localhost] wipe -> Err:
    Command wipe not allowed by ACL

release 下「不存在的命令」和「没授权的命令」得到同一句话:有应用清单时,nope 在走到 generate_handler! 之前就被 ACL 拦了,永远看不到「Command nope not found」。

窗口标签:在真实 webview 里

sec-win 的两份能力:main 只给 main 窗口 allow-greet;report 给所有窗口("windows": ["*"])和远程源 http://127.0.0.1:18970 放行 allow-report,好让每个页面都能把结果报回来。setup 里再建两个隐藏窗口:

      WebviewWindowBuilder::new(app, "other", WebviewUrl::App("index.html".into()))
        .visible(false)
        .build()?;
      WebviewWindowBuilder::new(
        app,
        "remote",
        WebviewUrl::External(
          format!(
            "http://127.0.0.1:18970/{}",
            std::env::var("SEC_PAGE").unwrap_or_else(|_| "remote.html".into())
          )
          .parse()
          .unwrap(),
        ),
      )
      .visible(false)
      .build()?;

三个页面跑同一段 JS:

(async () => {
  const T = window.__TAURI_INTERNALS__;
  const label = T.metadata.currentWindow.label;
  let r;
  try { r = 'ok ' + JSON.stringify(await T.invoke('greet', { name: 'rie' })); }
  catch (e) { r = 'err ' + JSON.stringify(e); }
  await T.invoke('report', { msg: `${label} ${location.origin} greet ${r}` });
})();

debug 构建:

REPORT other tauri://localhost greet err "greet not allowed on window \"other\", webview \"other\", URL: local\n\nallowed on: [windows: \"main\", URL: local]\n\nreferenced by: capability: main, permission: allow-greet"
REPORT main tauri://localhost greet ok "hello, rie"
REPORT remote http://127.0.0.1:18970 greet err "greet not allowed on window \"remote\", webview \"remote\", URL: http://127.0.0.1:18970/\n\nallowed on: [windows: \"main\", URL: local]\n\nreferenced by: capability: main, permission: allow-greet"

release 构建:

REPORT other tauri://localhost greet err "Command greet not allowed by ACL"
REPORT main tauri://localhost greet ok "hello, rie"
REPORT remote http://127.0.0.1:18970 greet err "Command greet not allowed by ACL"

JS 侧拿到的是一个字符串(invoke 的 Promise 以它 reject),不是 Error 对象。远程页面那一行的 URL 是 http://127.0.0.1:18970/:页面实际是 /remote.html,但 Origin 头只有源。远程页面照样有 __TAURI_INTERNALS__、照样能发 ipc:// 请求(tauri 的初始化脚本注入所有页面的主 frame,ipc:// 的响应带 Access-Control-Allow-Origin: *),拦住它的只有 ACL。

remote.urls:匹配的是源,不是页面

SEC_REMOTE 环境变量让 setup 在建窗口前 add_capability,把 allow-greet 给 remote 窗口和一个远程模式:

      if let Ok(pattern) = std::env::var("SEC_REMOTE") {
        app.add_capability(
          tauri::ipc::CapabilityBuilder::new("remote-greet")
            .remote(pattern)
            .local(false)
            .window("remote")
            .permission("allow-greet"),
        )?;
      }

模式写 http://127.0.0.1:18970/*:

REPORT main tauri://localhost greet ok "hello, rie"
REPORT other tauri://localhost greet err "greet not allowed on window \"other\", webview \"other\", URL: local\n\nallowed on: [windows: \"main\", URL: local], [windows: \"remote\", URL: http://127.0.0.1:18970/*]\n\nreferenced by: capability: main, permission: allow-greet || capability: remote-greet, permission: allow-greet"
REPORT remote http://127.0.0.1:18970 greet ok "hello, rie"

模式写成页面的确切地址 http://127.0.0.1:18970/remote.html:

REPORT remote http://127.0.0.1:18970 greet err "greet not allowed on window \"remote\", webview \"remote\", URL: http://127.0.0.1:18970/\n\nallowed on: [windows: \"main\", URL: local], [windows: \"remote\", URL: http://127.0.0.1:18970/remote.html]\n\nreferenced by: capability: main, permission: allow-greet || capability: remote-greet, permission: allow-greet"

被拒绝了,因为拿来匹配的 URL 是 Origin 头解析出来的 http://127.0.0.1:18970/,路径永远是 /。同一个模式、一个让 fetch 失败的页面(remote-csp.html 只多了 <meta http-equiv="Content-Security-Policy" content="connect-src 'self'">,ipc:// 请求被 CSP 拦下,ipc-protocol.js 退回 postMessage):

REPORT remote http://127.0.0.1:18970 greet ok "hello, rie"

放行了:postMessage 那条路用的是 frame 的完整 URL,/remote-csp.html 匹配上了。所以在 2.12.5 的 macOS 上,remote.urls 里带路径的模式经自定义协议永远不匹配,经 postMessage 才匹配;能力文档里「https://mydomain.dev/api/*: allows any subpath of mydomain.dev/api」这个例子,在默认的 IPC 通道上不成立。安全上的含义是:按路径区分权限的配置在 tauri 这里做不到,远程授权的最小单位实际上是一个源。

远程页面能做什么、不能做什么,可以归成三句:没有匹配的远程能力时,任何命令(应用命令、插件命令、core: 命令)都调不了;有了之后,它和本地页面走同样的窗口标签和作用域规则;配置里的 CSP 管不到远程页面,因为远程页面的响应头由远程服务器决定,而 freezePrototype 是初始化脚本的一部分,远程页面照样会被冻结,见 CSP、原型冻结与隔离模式。

没有应用权限清单时

回到 on_message 的条件。应用命令(不带 plugin: 前缀)、本地来源、has_app_acl_manifest 为假,三个都不成立,resolve_access 的结果被算出来但不用来拦人。has_app_acl 在编译期就定了:清单表里有没有 __app-acl__,而 tauri-build 只在应用有默认权限集、权限集或权限之一时才加这个键,也就是 AppManifest::commands 非空,或者项目的 permissions/ 目录下(不算内联插件的子目录)有权限文件。

sec-noacl 没有这两样,只有一份 windows: ["main"]、只引用 core:event:default 的能力,命令是 greet 和带 CommandScope 的 open_doc:

[main @ tauri://localhost] greet -> Ok("hello, rie")
[other @ tauri://localhost] greet -> Ok("hello, rie")
[other @ tauri://localhost] open_doc -> Ok("allow=[] deny=[] matches(/etc/passwd)=true")
[main @ http://127.0.0.1:18970/page.html] greet -> Err:
    greet not allowed. Plugin not found
[other @ tauri://localhost] plugin:event|emit -> Err:
    event.emit not allowed on window "other", webview "other", URL: local
    
    allowed on: [windows: "main", URL: local]
    
    referenced by: capability: main, permission: allow-emit

这一组输出就是「本地页面调 app 自己的命令不经检查」的准确含义:

  • 窗口限制对应用命令无效。能力只写了 main,other 窗口照样能调 greet。能力的 windows 只约束它引用到的权限,而应用命令根本没有权限可引用。
  • CommandScope 是空的,matches 恒为真。invoke.acl 是 None,CommandScope::from_command 给出两张空表,按 matches 的规则「allow 为空就放行」。一个依赖 CommandScope 做路径限制的应用命令,在没有应用清单的项目里什么也没限制住。
  • 远程来源仍然被拦(条件里的 !is_local),debug 信息说的是「Plugin not found」,因为 resolve_access_message 按 __app-acl__ 找清单没找到,用的是插件那一套措辞。
  • 插件命令、core: 命令不受影响,仍按能力的窗口检查。
  • 一个开关,全有或全无。项目里出现第一个应用权限文件(哪怕只是一个全局作用域),has_app_acl 就变成真,所有应用命令都要有能力授权,原来能用的命令一起变成「not allowed」。

从分发一侧看这条豁免,见 on_message 与分发。

📝NOTE
冷知识:远程页面曾经也享受这份豁免

条件里的 || !is_local 是 #15266(首次发布于 tauri 2.11.1)加的,上面那段四行注释也是那时写的。CHANGELOG 原文:「Previously, custom (non-plugin) commands bypassed ACL entirely without an AppManifest, allowing any origin to invoke them. Now, remote origins are always subject to ACL resolution, and can only reach custom commands if an explicit remote capability has been granted.」也就是说,2.11.1 之前,一个没声明应用清单的应用只要让某个窗口导航到外部网站,那个网站的脚本就能调用全部应用命令。

作用域怎么交给命令

resolve_access 返回的 Vec<ResolvedCommand> 存在 Invoke::acl 里,#[command] 生成的包装代码把它放进每个参数的 CommandItem(见 #[command] 与 generate_handler!)。两种作用域参数取法不同:

  • CommandScope<T>(CommandArg 实现):从命中的条目里收集 scope_id,逐个到 ScopeManager 取出、反序列化成 T,拼成一份。它只包含允许这次调用的那些能力带来的作用域,换个窗口、换个来源,同一个命令拿到的作用域可能不同。
  • GlobalScope<T>(CommandArg 实现):和这次调用命中哪些条目无关,按插件名去 global_scope 里取:
impl<'a, R: Runtime, T: ScopeObject> CommandArg<'a, R> for GlobalScope<T> {
  /// Grabs the [`ResolvedScope`] from the [`CommandItem`] and returns the associated [`GlobalScope`].
  fn from_command(command: CommandItem<'a, R>) -> Result<Self, InvokeError> {
    GlobalScope::resolve(
      &command.message.webview,
      command.plugin.unwrap_or(APP_ACL_KEY),
    )
    .map_err(InvokeError::from_error)
  }
}

command.plugin 不是运行时从命令名里拆出来的,而是 #[command] 宏在编译期写死的,取自定义命令的那个 crate 的包名:

  let plugin_name = var("CARGO_PKG_NAME")
    .expect("missing `CARGO_PKG_NAME` environment variable")
    .strip_prefix("tauri-plugin-")
    .map(|name| quote!(::core::option::Option::Some(#name)))
    .unwrap_or_else(|| quote!(::core::option::Option::None));

包名以 tauri-plugin- 开头才算插件。写在应用 crate 里的插件(sec-acl 的 inline),命令的 plugin 是 None,GlobalScope 取到的是应用的全局作用域 __app-acl__。sec-acl 单独先调 inline 的测试:

[main @ tauri://localhost] plugin:inline|read -> Ok("command allow=[] deny=[]; global allow=[\"/app/*\"] deny=[]; ok=true")

/app/* 是应用的 global-app 权限,不是 inline 的。sec-acl 的 files 和 notes 放在名为 tauri-plugin-files、tauri-plugin-notes 的两个本地 crate 里,就是为了避开这一点。

动脑筋答案

被拒绝。matrix 测试的输出(debug):

== deny wins
[main @ tauri://localhost] wipe -> Err:
    wipe explicitly denied on origin local
    
    referenced by: capability: audit, permission: deny-wipe

audit 的拒绝条目是 Local,main 窗口的本地页面来源也是 Local,denied_on 不看窗口标签,于是命中。能力的 windows 对拒绝来说只是装饰:任何能力里的 deny-* 都会作用于同一来源下的所有窗口。想「只对某个窗口拒绝」,做法是不把允许给那个窗口,而不是写一个针对它的拒绝。

小结

  • on_message 总是调 resolve_access;只有插件命令、有应用清单、远程来源三者之一成立时,结果为 None 才拒绝。
  • 来源由 is_local_url 判断:tauri 协议、devUrl / URL 形式的 frontendDist、用户注册的协议都算本地。
  • 允许要求来源匹配且窗口或 webview 标签匹配;拒绝只要求来源匹配,不看标签。
  • debug 拒绝信息列出允许条目、出处或可用的权限;release 一律 Command <cmd> not allowed by ACL。
  • remote.urls 经 ipc:// 匹配的是 Origin(没有路径),经 postMessage 匹配的是完整 URL;带路径的模式只在后者上生效。
  • 没有应用清单时,本地页面的应用命令不受窗口限制,CommandScope 为空且 matches 恒真;加上第一个应用权限文件后所有应用命令都要授权。
  • CommandScope 来自命中的条目;GlobalScope 按编译期的包名找插件(应用内插件拿到应用的全局作用域)。

💬 关联讨论

发起讨论
加载中...