Rust in Everything
基于 tauri 2.12.5

远程页面调不到命令

主线位置:调用主线上 parse_invoke_request 取来源、on_message 判断本地或远程、再交给 ACL 的那一段。来源怎么判定、remote.urls 怎么匹配,运行时检查讲了机制;远程页面的 ipc:// 请求长什么样,见 ipc 协议处理。本页复现最常见的一种:窗口本来是本地页面,跳到了外部网站。

flowchart TB
  nav["窗口导航到 http 页面"] --> hdr["fetch ipc 带 Origin 头"]
  hdr --> loc{"is_local_url"}
  loc -->|否| rem["Origin Remote"]
  rem --> cap{"有 remote.urls 匹配 Origin"}
  cap -->|"模式带路径"| miss["匹配不上 拒绝"]
  cap -->|没有| miss
  cap -->|"模式只有源"| ok["按能力放行"]

实验 crate exp-tauri/pit3-acl(和 Command greet not allowed by ACL 同一个应用:有应用清单,能力 main 给 main 窗口 core:default 和 allow-greet,能力 report 给所有窗口和 http://127.0.0.1:19010 放行 allow-report)。远程页面由 python3 -m http.server --bind 127.0.0.1 19010 提供,内容和本地页面相同。tauri =2.12.5,--features tauri/custom-protocol,隐藏窗口,debug 构建,平台 M2 Max、macOS 27、rustc 1.99.0。

现象

main 窗口先加载本地的 nav.html:调一次 greet,报告,然后跳到外部页面,像 OAuth 登录回跳、或者「直接打开线上那个网页版」那样:

(async () => {
  const T = window.__TAURI_INTERNALS__;
  const r = await T.invoke('greet', { name: 'rie' });
  await T.invoke('report', { msg: `[local ${location.href}] greet -> ok ${JSON.stringify(r)}` });
  // e.g. an OAuth redirect or a link to the hosted web app
  location.href = 'http://127.0.0.1:19010/app/page.html';
})();

外部页面上同样调 greet、一个拼错的 gret、plugin:window|set_title:

REPORT [local tauri://localhost/nav.html] greet -> ok "hello, rie"
REPORT [main http://127.0.0.1:19010/app/page.html]
greet -> err greet not allowed on window "main", webview "main", URL: http://127.0.0.1:19010/

allowed on: [windows: "main", URL: local]

referenced by: capability: main, permission: allow-greet
gret -> err gret not allowed. Command not found
plugin:window|set_title -> err window.set_title not allowed. Permissions associated with this command: core:window:allow-set-title

同一个窗口、同一个 webview、同一个 greet,几毫秒之前还能调。页面上 __TAURI_INTERNALS__ 还在(tauri 的初始化脚本注入每一次导航后的主 frame),ipc:// 请求也照常到达 Rust,拦住它的是 ACL:来源从 local 变成了 http://127.0.0.1:19010/,main 能力只对 URL: local 生效。

按能力文档的写法补一份远程能力,模式写到页面所在的目录:

{
  "identifier": "remote",
  "windows": ["main"],
  "remote": { "urls": ["http://127.0.0.1:19010/app/*"] },
  "permissions": ["allow-greet"]
}
REPORT [local tauri://localhost/nav.html] greet -> ok "hello, rie"
REPORT [main http://127.0.0.1:19010/app/page.html]
greet -> err greet not allowed on window "main", webview "main", URL: http://127.0.0.1:19010/

allowed on: [windows: "main", URL: local], [windows: "main", URL: local], [windows: "main", URL: http://127.0.0.1:19010/app/*]

referenced by: capability: main, permission: allow-greet || capability: remote, permission: allow-greet || capability: remote, permission: allow-greet
gret -> err gret not allowed. Command not found
plugin:window|set_title -> err window.set_title not allowed. Permissions associated with this command: core:window:allow-set-title

还是拒绝。注意两处:拿来匹配的 URL 是 http://127.0.0.1:19010/,不是页面的 /app/page.html;allowed on 里 remote 能力贡献了两条,一条远程,还有一条 URL: local。

出处

ipc:// 这条路上,请求的来源 URL 是 parse_invoke_request 从 Origin 请求头解析出来的:

  let url = Url::parse(
    parts
      .headers
      .get("Origin")
      .ok_or("missing Origin header")?
      .to_str()
      .map_err(|_| "Origin header value must be a string")?,
  )
  .map_err(|_| "Origin header is not a valid URL")?;

Origin 只有 scheme、主机和端口,解析后路径永远是 /。is_local_url 判断它不是 tauri://localhost、不在 devUrl / frontendDist 之下、也不是用户注册的协议,于是来源是 Remote;on_message 的拒绝条件里有 !is_local,远程来源不管有没有应用清单都要过 ACL。remote.urls 的模式按 URLPattern 去测这个路径为 / 的 URL,/app/* 自然不中。postMessage 退路取的是 frame 的完整 URL,带路径的模式在那条路上才会匹配,这个差别和实测见运行时检查的「remote.urls:匹配的是源,不是页面」。

多出来的那条 URL: local 来自能力的 local 字段:

  /// Whether this capability is enabled for local app URLs or not. Defaults to `true`.
  #[serde(default = "default_capability_local")]
  pub local: bool,

只写了 remote、没写 local 的能力,对本地页面同样生效,编译期解析给 local 一条、每个远程模式一条。

修法

模式只写源,并且显式关掉 local:

{
  "identifier": "remote",
  "windows": ["main"],
  "local": false,
  "remote": { "urls": ["http://127.0.0.1:19010"] },
  "permissions": ["allow-greet"]
}
REPORT [local tauri://localhost/nav.html] greet -> ok "hello, rie"
REPORT [main http://127.0.0.1:19010/app/page.html]
greet -> ok "hello, rie"
gret -> err gret not allowed. Command not found
plugin:window|set_title -> err window.set_title not allowed. Permissions associated with this command: core:window:allow-set-title

再开一个本地的 settings 窗口看拒绝信息,allowed on 里 remote 能力只剩远程那一条:[windows: "main", URL: local], [windows: "main", URL: http://127.0.0.1:19010]。set_title 仍然被拒,因为远程能力里只给了 allow-greet:远程页面能调什么,就是这份能力列出的那几个,core:default 这类给本地页面的权限不会自动跟过去。

给远程页面开命令之前想清楚:这等于把这些命令交给那个源上能执行 JS 的任何东西,包括它加载的第三方脚本和它自己的 XSS 漏洞。授权的最小单位实际上是一个源(路径区分不了),远程能力里只放这个页面确实要调的命令。

怎么确认没中招

  • debug 拒绝信息里的 URL: 一栏就是参与匹配的来源。看到 URL: http://.../ 而你以为是本地页面,说明窗口已经导航走了。
  • 页面里打印 location.origin;Rust 侧可以给窗口挂 on_navigation,记录(或者返回 false 拦下)意料之外的导航。
  • 远程能力一律写成「只有源」的模式,并写上 "local": false;用 debug 拒绝信息核对 allowed on 里没有多出来的 URL: local。
  • 本地页面用 <iframe> 嵌远程页面是另一回事:tauri 的初始化脚本只进主 frame,iframe 里连 __TAURI_INTERNALS__ 都没有(见初始化脚本)。

小结

  • 窗口导航到 http(s) 页面后,来源变成远程,所有 local 能力不再适用,应用命令和插件命令一起被拒。
  • ipc:// 路上参与匹配的是 Origin 头,路径恒为 /;remote.urls 写成 https://host/path/* 永远匹配不上,只写源。
  • 能力的 local 默认是 true,只给远程用的能力要写 "local": false。
  • 远程能力是把命令交给一个源,里面只放必需的命令。

💬 关联讨论

发起讨论
加载中...