Merge branch 'v2' into v3

This commit is contained in:
Lucas Nogueira
2026-09-21 13:45:38 -03:00
25 changed files with 1142 additions and 259 deletions
+22
View File
@@ -8,6 +8,28 @@
- Upgraded to `fs-js@3.0.0-alpha.0`
## [2.7.0]
- [`1198a524`](https://github.com/tauri-apps/plugins-workspace/commit/1198a524b710abf2abeb1d9bd7b252402d26ca6d) **Security:** Added the `scopeRedirects` plugin configuration option, which checks the URL scope on every hop of a redirect chain instead of only on the URL requested by the frontend. Without it, a server on an allowed origin can redirect the request to any other origin - including `localhost` services, internal hosts and cloud metadata endpoints - and the plugin follows it, returning the response to the webview.
```json
{
"plugins": {
"http": {
"scopeRedirects": true
}
}
}
```
It is opt-in because a redirect to a URL that is not allowed by the scope now fails with `url not allowed on the configured scope` instead of being followed, so applications that rely on being redirected outside of their scope must add the redirect target to the scope. **This will become the default in v3.**
Note that `tauri_plugin_http::init()` now returns `TauriPlugin<R, Option<Config>>` instead of `TauriPlugin<R>`.
## [2.6.1]
- [`a21555dd`](https://github.com/tauri-apps/plugins-workspace/commit/a21555ddd2eaadfed23848912fe2802c2ba7579e) ([#3566](https://github.com/tauri-apps/plugins-workspace/pull/3566) by [@followdarko](https://github.com/tauri-apps/plugins-workspace/../../followdarko)) Fix unhandled promise rejections on every `fetch` teardown: the request/body cleanup commands were fired as floating promises, and releasing an already-released resource rejects with `The resource id N is invalid.`. `dropBody` is now idempotent and both cleanup calls handle their own rejection.
## [2.6.0]
- [`f8053e65`](https://github.com/tauri-apps/plugins-workspace/commit/f8053e659e4ccd85c1f52833411ff8417cbc5e69) ([#3527](https://github.com/tauri-apps/plugins-workspace/pull/3527) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Documented Cargo feature flags in each plugin's crate-level documentation.
+4
View File
@@ -76,6 +76,10 @@ export interface ClientOptions {
/**
* Defines the maximum number of redirects the client should follow.
* If set to 0, no redirects will be followed.
*
* When the `scopeRedirects` plugin configuration is enabled, every redirect must
* also be allowed by the configured scope, otherwise the request fails
* instead of being followed.
*/
maxRedirections?: number
/** Timeout in milliseconds */
+330 -100
View File
@@ -22,6 +22,10 @@ use crate::{
const HTTP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
/// Matches the default [`reqwest`] redirect policy, used when the frontend does not
/// configure `maxRedirections`.
const DEFAULT_MAX_REDIRECTIONS: usize = 10;
struct ReqwestResponse(reqwest::Response);
impl tauri::Resource for ReqwestResponse {}
@@ -174,6 +178,56 @@ fn attach_proxy(
Ok(builder)
}
/// Builds the redirect policy for the request.
///
/// When `scope` is [`Some`], **every** hop is checked against it. Validating only the URL the
/// frontend asked for is not enough: a server on an allowed origin can answer with a redirect to
/// any other origin (an open redirect, or a server the attacker controls), and following it would
/// give the webview access to a URL the scope denies.
///
/// That check is opt-in through the `scopeRedirects` configuration because it breaks applications
/// that rely on being redirected outside of their scope.
// TODO(v3): always check the scope and take it by value instead of `Option`
fn redirect_policy(scope: Option<Scope>, max_redirections: Option<usize>) -> Policy {
if max_redirections == Some(0) {
return Policy::none();
}
let max_redirections = max_redirections.unwrap_or(DEFAULT_MAX_REDIRECTIONS);
let Some(scope) = scope else {
return Policy::limited(max_redirections);
};
Policy::custom(move |attempt| {
// the first URL in `previous` is the initial request, so it must be excluded
if attempt.previous().len() > max_redirections {
attempt.error("too many redirects")
} else if scope.is_allowed(attempt.url()) {
attempt.follow()
} else {
let url = attempt.url().clone();
attempt.error(Error::UrlNotAllowed(url))
}
})
}
/// [`reqwest`] wraps the error returned by the redirect policy, and its `Display` impl does not
/// include the source, so we unwrap our own scope error to keep the reason visible to the frontend.
fn map_request_error(error: reqwest::Error) -> Error {
if error.is_redirect() {
let mut source = std::error::Error::source(&error);
while let Some(err) = source {
if let Some(Error::UrlNotAllowed(url)) = err.downcast_ref::<Error>() {
return Error::UrlNotAllowed(url.clone());
}
source = err.source();
}
}
Error::Network(error)
}
#[command]
pub async fn fetch<R: Runtime>(
webview: Webview<R>,
@@ -214,118 +268,115 @@ pub async fn fetch<R: Runtime>(
match scheme {
"http" | "https" => {
if Scope::new(
let scope = Scope::new(
command_scope
.allows()
.iter()
.chain(global_scope.allows())
.cloned()
.collect(),
command_scope
.denies()
.iter()
.chain(global_scope.denies())
.cloned()
.collect(),
)
.is_allowed(&url)
{
let mut builder = reqwest::ClientBuilder::new();
);
if let Some(danger_config) = danger {
#[cfg(not(feature = "dangerous-settings"))]
{
#[cfg(debug_assertions)]
{
eprintln!("[\x1b[33mWARNING\x1b[0m] using dangerous settings requires `dangerous-settings` feature flag in your Cargo.toml");
}
let _ = danger_config;
return Err(Error::DangerousSettings);
}
#[cfg(feature = "dangerous-settings")]
{
builder = builder
.danger_accept_invalid_certs(danger_config.accept_invalid_certs)
.danger_accept_invalid_hostnames(danger_config.accept_invalid_hostnames)
}
}
if let Some(timeout) = connect_timeout {
builder = builder.connect_timeout(Duration::from_millis(timeout));
}
if let Some(max_redirections) = max_redirections {
builder = builder.redirect(if max_redirections == 0 {
Policy::none()
} else {
Policy::limited(max_redirections)
});
}
if let Some(proxy_config) = proxy {
builder = attach_proxy(proxy_config, builder)?;
}
#[cfg(feature = "cookies")]
{
builder = builder.cookie_provider(state.cookies_jar.clone());
}
let mut request = builder.build()?.request(method.clone(), url);
// POST and PUT requests should always have a 0 length content-length,
// if there is no body. https://fetch.spec.whatwg.org/#http-network-or-cache-fetch
if data.is_none() && matches!(method, Method::POST | Method::PUT) {
headers.append(header::CONTENT_LENGTH, HeaderValue::from_str("0")?);
}
if headers.contains_key(header::RANGE) {
// https://fetch.spec.whatwg.org/#http-network-or-cache-fetch step 18
// If httpRequest's header list contains `Range`, then append (`Accept-Encoding`, `identity`)
headers.append(header::ACCEPT_ENCODING, HeaderValue::from_str("identity")?);
}
if !headers.contains_key(header::USER_AGENT) {
headers.append(header::USER_AGENT, HeaderValue::from_str(HTTP_USER_AGENT)?);
}
// ensure we have an Origin header set
if cfg!(not(feature = "unsafe-headers")) || !headers.contains_key(header::ORIGIN) {
if let Ok(url) = webview.url() {
// The url crate returns OpaqueOrigin for tauri://localhost which serializes to "null"
let origin = if url.scheme() == "tauri" {
"tauri://localhost".to_string()
} else {
url.origin().ascii_serialization()
};
headers.append(header::ORIGIN, HeaderValue::from_str(&origin)?);
}
}
// In case empty origin is passed, remove it. Some services do not like Origin header
// so this way we can remove it in explicit way. The default behaviour is still to set it
if cfg!(feature = "unsafe-headers")
&& headers.get(header::ORIGIN) == Some(&HeaderValue::from_static(""))
{
headers.remove(header::ORIGIN);
};
if let Some(data) = data {
request = request.body(data);
}
request = request.headers(headers);
#[cfg(feature = "tracing")]
tracing::trace!("{:?}", request);
let fut = async move { request.send().await.map_err(Into::into) };
let mut resources_table = webview.resources_table();
let rid = resources_table.add_request(Box::pin(fut));
Ok(rid)
} else {
Err(Error::UrlNotAllowed(url))
if !scope.is_allowed(&url) {
return Err(Error::UrlNotAllowed(url));
}
let mut builder = reqwest::ClientBuilder::new();
if let Some(danger_config) = danger {
#[cfg(not(feature = "dangerous-settings"))]
{
#[cfg(debug_assertions)]
{
eprintln!("[\x1b[33mWARNING\x1b[0m] using dangerous settings requires `dangerous-settings` feature flag in your Cargo.toml");
}
let _ = danger_config;
return Err(Error::DangerousSettings);
}
#[cfg(feature = "dangerous-settings")]
{
builder = builder
.danger_accept_invalid_certs(danger_config.accept_invalid_certs)
.danger_accept_invalid_hostnames(danger_config.accept_invalid_hostnames)
}
}
if let Some(timeout) = connect_timeout {
builder = builder.connect_timeout(Duration::from_millis(timeout));
}
let scope = state.config.scope_redirects.then_some(scope);
builder = builder.redirect(redirect_policy(scope, max_redirections));
if let Some(proxy_config) = proxy {
builder = attach_proxy(proxy_config, builder)?;
}
#[cfg(feature = "cookies")]
{
builder = builder.cookie_provider(state.cookies_jar.clone());
}
let mut request = builder.build()?.request(method.clone(), url);
// POST and PUT requests should always have a 0 length content-length,
// if there is no body. https://fetch.spec.whatwg.org/#http-network-or-cache-fetch
if data.is_none() && matches!(method, Method::POST | Method::PUT) {
headers.append(header::CONTENT_LENGTH, HeaderValue::from_str("0")?);
}
if headers.contains_key(header::RANGE) {
// https://fetch.spec.whatwg.org/#http-network-or-cache-fetch step 18
// If httpRequest's header list contains `Range`, then append (`Accept-Encoding`, `identity`)
headers.append(header::ACCEPT_ENCODING, HeaderValue::from_str("identity")?);
}
if !headers.contains_key(header::USER_AGENT) {
headers.append(header::USER_AGENT, HeaderValue::from_str(HTTP_USER_AGENT)?);
}
// ensure we have an Origin header set
if cfg!(not(feature = "unsafe-headers")) || !headers.contains_key(header::ORIGIN) {
if let Ok(url) = webview.url() {
// The url crate returns OpaqueOrigin for tauri://localhost which serializes to "null"
let origin = if url.scheme() == "tauri" {
"tauri://localhost".to_string()
} else {
url.origin().ascii_serialization()
};
headers.append(header::ORIGIN, HeaderValue::from_str(&origin)?);
}
}
// In case empty origin is passed, remove it. Some services do not like Origin header
// so this way we can remove it in explicit way. The default behaviour is still to set it
if cfg!(feature = "unsafe-headers")
&& headers.get(header::ORIGIN) == Some(&HeaderValue::from_static(""))
{
headers.remove(header::ORIGIN);
};
if let Some(data) = data {
request = request.body(data);
}
request = request.headers(headers);
#[cfg(feature = "tracing")]
tracing::trace!("{:?}", request);
let fut = async move { request.send().await.map_err(map_request_error) };
let mut resources_table = webview.resources_table();
let rid = resources_table.add_request(Box::pin(fut));
Ok(rid)
}
"data" => {
let data_url =
@@ -486,3 +537,182 @@ fn is_unsafe_header(header: &HeaderName) -> bool {
lower.starts_with("proxy-") || lower.starts_with("sec-")
}
}
#[cfg(test)]
mod tests {
use std::{
io::{BufRead, BufReader, Write},
net::TcpListener,
sync::Arc,
};
use super::*;
/// Test server with an open redirect:
///
/// - `/redirect-external` answers a 302 to `http://127.0.0.1:{port}/secret`, which is a
/// different origin as far as the scope is concerned - the tests allow `localhost`;
/// - `/redirect/{n}` answers a 302 to `/redirect/{n-1}` on `localhost`, and `/redirect/0`
/// answers a 302 to `/target`, so the whole chain stays in scope;
/// - anything else answers a 200, so a bypass shows up as a successful response.
fn spawn_server() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
for stream in listener.incoming() {
let mut stream = stream.unwrap();
// the whole request must be consumed, otherwise closing the connection
// with unread data queued resets it before the client reads the response
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut request_line = String::new();
reader.read_line(&mut request_line).unwrap();
loop {
let mut header = String::new();
reader.read_line(&mut header).unwrap();
if matches!(header.as_str(), "" | "\r\n" | "\n") {
break;
}
}
let path = request_line.split(' ').nth(1).unwrap_or("/");
let location = match path.strip_prefix("/redirect/") {
Some("0") => Some(format!("http://localhost:{port}/target")),
Some(remaining) => Some(format!(
"http://localhost:{port}/redirect/{}",
remaining.parse::<usize>().unwrap() - 1
)),
None if path == "/redirect-external" => {
Some(format!("http://127.0.0.1:{port}/secret"))
}
None => None,
};
let response = match location {
Some(location) => format!(
"HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
),
None => "HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\nsecret"
.to_string(),
};
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
});
port
}
fn get(
url: &str,
scope: Option<Scope>,
max_redirections: Option<usize>,
) -> Result<reqwest::Response> {
let client = reqwest::ClientBuilder::new()
.redirect(redirect_policy(scope, max_redirections))
.build()
.unwrap();
let request = client.get(url);
tauri::async_runtime::block_on(
async move { request.send().await.map_err(map_request_error) },
)
}
fn localhost_scope(port: u16) -> Option<Scope> {
let entry = Arc::new(format!("http://localhost:{port}/*").parse().unwrap());
Some(Scope::new(vec![entry], Vec::new()))
}
#[test]
fn redirect_outside_of_scope_is_denied() {
let port = spawn_server();
let err = get(
&format!("http://localhost:{port}/redirect-external"),
localhost_scope(port),
None,
)
.unwrap_err();
match err {
Error::UrlNotAllowed(url) => {
assert_eq!(url.as_str(), format!("http://127.0.0.1:{port}/secret"))
}
e => panic!("expected the redirect to be denied by the scope, got {e:?}"),
}
}
#[test]
fn redirect_outside_of_scope_is_followed_when_not_configured() {
let port = spawn_server();
// `scopeRedirects` is disabled, so only the URL requested by the frontend is checked
let response = get(
&format!("http://localhost:{port}/redirect-external"),
None,
None,
)
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn redirect_denied_by_the_scope_is_denied() {
let port = spawn_server();
let allow = Arc::new(format!("http://localhost:{port}/*").parse().unwrap());
let deny = Arc::new(format!("http://localhost:{port}/target").parse().unwrap());
let scope = Some(Scope::new(vec![allow], vec![deny]));
let err = get(&format!("http://localhost:{port}/redirect/0"), scope, None).unwrap_err();
assert!(matches!(err, Error::UrlNotAllowed(_)));
}
#[test]
fn redirect_inside_of_scope_is_followed() {
let port = spawn_server();
let response = get(
&format!("http://localhost:{port}/redirect/2"),
localhost_scope(port),
None,
)
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.url().path(), "/target");
}
#[test]
fn max_redirections_is_enforced() {
let port = spawn_server();
for scope in [localhost_scope(port), None] {
let err = get(
&format!("http://localhost:{port}/redirect/5"),
scope,
Some(2),
)
.unwrap_err();
assert!(matches!(err, Error::Network(e) if e.is_redirect()));
}
}
#[test]
fn zero_max_redirections_does_not_follow() {
let port = spawn_server();
let response = get(
&format!("http://localhost:{port}/redirect/0"),
localhost_scope(port),
Some(0),
)
.unwrap();
assert_eq!(response.status(), StatusCode::FOUND);
}
}
+47
View File
@@ -0,0 +1,47 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use serde::Deserialize;
/// HTTP plugin configuration, defined on the `plugins > http` object of your `tauri.conf.json`.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Config {
/// Whether the scope is checked on every hop of a redirect chain instead of only on the
/// URL requested by the frontend. Defaults to `false`.
///
/// When disabled, a server on an allowed origin can answer with a redirect to any other
/// origin - an open redirect, or a server an attacker controls - and the plugin follows it,
/// handing the webview a response from a URL the scope denies, such as a `localhost`
/// service, an internal host or a cloud metadata endpoint.
///
/// When enabled, a redirect to a URL that is not allowed by the scope fails with
/// [`Error::UrlNotAllowed`](crate::Error::UrlNotAllowed) instead of being followed, so every
/// redirect target must also be part of the scope. This is opt-in because it breaks
/// applications that rely on being redirected outside of their configured scope.
// TODO(v3): enforce the scope on redirects by default and remove this option
#[serde(default)]
pub scope_redirects: bool,
}
#[cfg(test)]
mod tests {
use super::Config;
#[test]
fn deserializes_the_plugin_configuration() {
// the plugin configuration is optional, and the scope check is opt-in
let config: Option<Config> = serde_json::from_value(serde_json::Value::Null).unwrap();
assert!(config.is_none());
let config: Config = serde_json::from_str("{}").unwrap();
assert!(!config.scope_redirects);
let config: Config = serde_json::from_str(r#"{ "scopeRedirects": true }"#).unwrap();
assert!(config.scope_redirects);
// a typo must not silently disable the scope check
assert!(serde_json::from_str::<Config>(r#"{ "scopeRedirect": true }"#).is_err());
}
}
+22 -3
View File
@@ -43,6 +43,21 @@
//! - **tracing**: Adds request, response, and cookie-store diagnostics through `tracing`.
//! - **unsafe-headers**: Allows webview requests to send any headers.
//! - **dangerous-settings**: Allows dangerous client settings such as accepting invalid certificates or hostnames.
//!
//! ## Configuration
//!
//! See [`Config`] for the options that can be set on the `plugins > http` object of your
//! `tauri.conf.json`:
//!
//! ```json
//! {
//! "plugins": {
//! "http": {
//! "scopeRedirects": true
//! }
//! }
//! }
//! ```
pub use reqwest;
use tauri::{
@@ -50,9 +65,11 @@ use tauri::{
Manager, Runtime,
};
pub use config::Config;
pub use error::{Error, Result};
mod commands;
mod config;
mod error;
#[cfg(feature = "cookies")]
mod reqwest_cookie_store;
@@ -62,13 +79,14 @@ mod scope;
const COOKIES_FILENAME: &str = ".cookies";
pub(crate) struct Http {
pub(crate) config: Config,
#[cfg(feature = "cookies")]
cookies_jar: std::sync::Arc<crate::reqwest_cookie_store::CookieStoreMutex>,
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::<R>::new("http")
.setup(|app, _| {
pub fn init<R: Runtime>() -> TauriPlugin<R, Option<Config>> {
Builder::<R, Option<Config>>::new("http")
.setup(|app, api| {
#[cfg(feature = "cookies")]
let cookies_jar = {
use crate::reqwest_cookie_store::*;
@@ -96,6 +114,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
};
let state = Http {
config: api.config().clone().unwrap_or_default(),
#[cfg(feature = "cookies")]
cookies_jar: std::sync::Arc::new(cookies_jar),
};
+26 -27
View File
@@ -59,16 +59,16 @@ impl<'de> Deserialize<'de> for Entry {
}
}
/// Scope for filesystem access.
/// Scope for HTTP access.
#[derive(Debug)]
pub struct Scope<'a> {
allowed: Vec<&'a Arc<Entry>>,
denied: Vec<&'a Arc<Entry>>,
pub struct Scope {
allowed: Vec<Arc<Entry>>,
denied: Vec<Arc<Entry>>,
}
impl<'a> Scope<'a> {
impl Scope {
/// Creates a new scope from the scope configuration.
pub(crate) fn new(allowed: Vec<&'a Arc<Entry>>, denied: Vec<&'a Arc<Entry>>) -> Self {
pub(crate) fn new(allowed: Vec<Arc<Entry>>, denied: Vec<Arc<Entry>>) -> Self {
Self { allowed, denied }
}
@@ -94,25 +94,24 @@ impl<'a> Scope<'a> {
}
#[cfg(test)]
mod tests {
use std::{str::FromStr, sync::Arc};
impl std::str::FromStr for Entry {
type Err = urlpattern::quirks::Error;
use super::Entry;
impl FromStr for Entry {
type Err = urlpattern::quirks::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let pattern = super::parse_url_pattern(s)?;
Ok(Self { url: pattern })
}
fn from_str(s: &str) -> Result<Self, Self::Err> {
let url = parse_url_pattern(s)?;
Ok(Self { url })
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
#[test]
fn denied_takes_precedence() {
let allow = Arc::new("http://localhost:8080/file.png".parse().unwrap());
let deny = Arc::new("http://localhost:8080/*".parse().unwrap());
let scope = super::Scope::new(vec![&allow], vec![&deny]);
let scope = super::Scope::new(vec![allow], vec![deny]);
assert!(!scope.is_allowed(&"http://localhost:8080/file.png".parse().unwrap()));
assert!(!scope.is_allowed(&"http://localhost:8080?framework=tauri".parse().unwrap()));
}
@@ -121,7 +120,7 @@ mod tests {
fn fixed_url() {
// plain URL
let entry = Arc::new("http://localhost:8080".parse().unwrap());
let scope = super::Scope::new(vec![&entry], Vec::new());
let scope = super::Scope::new(vec![entry], Vec::new());
assert!(scope.is_allowed(&"http://localhost:8080".parse().unwrap()));
assert!(scope.is_allowed(&"http://localhost:8080/".parse().unwrap()));
assert!(scope.is_allowed(&"http://localhost:8080/file".parse().unwrap()));
@@ -137,7 +136,7 @@ mod tests {
fn fixed_path() {
// URL with fixed path
let entry = Arc::new("http://localhost:8080/file.png".parse().unwrap());
let scope = super::Scope::new(vec![&entry], Vec::new());
let scope = super::Scope::new(vec![entry], Vec::new());
assert!(scope.is_allowed(&"http://localhost:8080/file.png".parse().unwrap()));
assert!(scope.is_allowed(&"http://localhost:8080/file.png?q=1".parse().unwrap()));
@@ -150,7 +149,7 @@ mod tests {
#[test]
fn pattern_wildcard() {
let entry = Arc::new("http://localhost:8080/*.png".parse().unwrap());
let scope = super::Scope::new(vec![&entry], Vec::new());
let scope = super::Scope::new(vec![entry], Vec::new());
assert!(scope.is_allowed(&"http://localhost:8080/file.png".parse().unwrap()));
assert!(scope.is_allowed(&"http://localhost:8080/file.png#head".parse().unwrap()));
@@ -167,7 +166,7 @@ mod tests {
#[test]
fn domain_wildcard() {
let entry = Arc::new("http://*".parse().unwrap());
let scope = super::Scope::new(vec![&entry], Vec::new());
let scope = super::Scope::new(vec![entry], Vec::new());
assert!(scope.is_allowed(&"http://something.else".parse().unwrap()));
assert!(scope.is_allowed(&"http://something.else#tauri".parse().unwrap()));
@@ -182,7 +181,7 @@ mod tests {
assert!(!scope.is_allowed(&"https://something.else".parse().unwrap()));
let entry = Arc::new("http://*/*".parse().unwrap());
let scope = super::Scope::new(vec![&entry], Vec::new());
let scope = super::Scope::new(vec![entry], Vec::new());
assert!(scope.is_allowed(&"http://something.else".parse().unwrap()));
assert!(scope.is_allowed(&"http://something.else/path/to/file".parse().unwrap()));
@@ -191,7 +190,7 @@ mod tests {
#[test]
fn scheme_wildcard() {
let entry = Arc::new("*://*".parse().unwrap());
let scope = super::Scope::new(vec![&entry], Vec::new());
let scope = super::Scope::new(vec![entry], Vec::new());
assert!(scope.is_allowed(&"http://something.else".parse().unwrap()));
assert!(scope.is_allowed(&"http://something.else/path/to/file".parse().unwrap()));
@@ -201,7 +200,7 @@ mod tests {
assert!(scope.is_allowed(&"https://something.else?x=1#frag".parse().unwrap()));
let entry = Arc::new("*://*/*".parse().unwrap());
let scope = super::Scope::new(vec![&entry], Vec::new());
let scope = super::Scope::new(vec![entry], Vec::new());
assert!(scope.is_allowed(&"http://something.else".parse().unwrap()));
assert!(scope.is_allowed(&"http://something.else/path/to/file".parse().unwrap()));
@@ -212,7 +211,7 @@ mod tests {
#[test]
fn validate_query() {
let entry = Arc::new("https://tauri.app/path?x=*".parse().unwrap());
let scope = super::Scope::new(vec![&entry], Vec::new());
let scope = super::Scope::new(vec![entry], Vec::new());
assert!(scope.is_allowed(&"https://tauri.app/path?x=5".parse().unwrap()));
@@ -222,7 +221,7 @@ mod tests {
#[test]
fn validate_hash() {
let entry = Arc::new("https://tauri.app/path#frame*".parse().unwrap());
let scope = super::Scope::new(vec![&entry], Vec::new());
let scope = super::Scope::new(vec![entry], Vec::new());
assert!(scope.is_allowed(&"https://tauri.app/path#frame".parse().unwrap()));
+4
View File
@@ -4,6 +4,10 @@
- [`363438b5`](https://github.com/tauri-apps/plugins-workspace/commit/363438b50a09162379d57c7c7bfc132520c1e5d4) Update to tauri 3.0 alpha.
## [2.9.2]
- [`2df3d936`](https://github.com/tauri-apps/plugins-workspace/commit/2df3d9368187e1d2ce736d3e0b2d4bdcae35b011) ([#3574](https://github.com/tauri-apps/plugins-workspace/pull/3574)) Log webview log target locations after 2 colons instead of 1. (e.g. `webview:myFunction@http://localhost:5173/src/myFile.ts?t=1787480723620:47:4` to `webview::myFunction@http://localhost:5173/src/myFile.ts?t=1787480723620:47:4`)
## [2.9.1]
- [`f8053e65`](https://github.com/tauri-apps/plugins-workspace/commit/f8053e659e4ccd85c1f52833411ff8417cbc5e69) ([#3527](https://github.com/tauri-apps/plugins-workspace/pull/3527) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Documented Cargo feature flags in each plugin's crate-level documentation.
+2 -2
View File
@@ -20,9 +20,9 @@ pub fn log(
let level = log::Level::from(level);
let target = if let Some(location) = location {
format!("{WEBVIEW_TARGET}:{location}")
format!("{WEBVIEW_TARGET}::{location}")
} else {
WEBVIEW_TARGET.to_string()
WEBVIEW_TARGET.to_owned()
};
let mut builder = RecordBuilder::new();
+4
View File
@@ -8,6 +8,10 @@
- Upgraded to `deep-link@3.0.0-alpha.0`
## [2.4.5]
- [`67cd25a1`](https://github.com/tauri-apps/plugins-workspace/commit/67cd25a10ce3deb1b935e725228e528766f24ad7) ([#3592](https://github.com/tauri-apps/plugins-workspace/pull/3592)) On Windows, the second instance now allows the first instance to bring its window to the front before exiting, so focusing a window from the callback no longer gets refused by Windows.
## [2.4.4]
- [`f8053e65`](https://github.com/tauri-apps/plugins-workspace/commit/f8053e659e4ccd85c1f52833411ff8417cbc5e69) ([#3527](https://github.com/tauri-apps/plugins-workspace/pull/3527) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Documented Cargo feature flags in each plugin's crate-level documentation.
@@ -19,10 +19,11 @@ use windows_sys::Win32::{
Threading::{CreateMutexW, ReleaseMutex},
},
UI::WindowsAndMessaging::{
self as w32wm, CreateWindowExW, DefWindowProcW, DestroyWindow, FindWindowW,
RegisterClassExW, SendMessageW, CREATESTRUCTW, GWLP_USERDATA, GWL_STYLE,
WINDOW_LONG_PTR_INDEX, WM_COPYDATA, WM_CREATE, WM_DESTROY, WNDCLASSEXW, WS_EX_LAYERED,
WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT, WS_OVERLAPPED, WS_POPUP, WS_VISIBLE,
self as w32wm, AllowSetForegroundWindow, CreateWindowExW, DefWindowProcW, DestroyWindow,
FindWindowW, GetWindowThreadProcessId, RegisterClassExW, SendMessageW, CREATESTRUCTW,
GWLP_USERDATA, GWL_STYLE, WINDOW_LONG_PTR_INDEX, WM_COPYDATA, WM_CREATE, WM_DESTROY,
WNDCLASSEXW, WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT,
WS_OVERLAPPED, WS_POPUP, WS_VISIBLE,
},
};
@@ -74,6 +75,16 @@ pub fn init<R: Runtime>(callback: Box<SingleInstanceCallback<R>>) -> TauriPlugin
let hwnd = FindWindowW(class_name.as_ptr(), window_name.as_ptr());
if !hwnd.is_null() {
// Windows lets us bring a window to the front, but not the first
// instance. Hand that right over before we exit, so focusing a window
// from the callback works. Windows takes it back if the user switches
// to another app in the meantime.
let mut pid = 0;
GetWindowThreadProcessId(hwnd, &mut pid);
if pid != 0 {
AllowSetForegroundWindow(pid);
}
let cwd = std::env::current_dir().unwrap_or_default();
let cwd = cwd.to_str().unwrap_or_default();
+4
View File
@@ -4,6 +4,10 @@
- [`363438b5`](https://github.com/tauri-apps/plugins-workspace/commit/363438b50a09162379d57c7c7bfc132520c1e5d4) Update to tauri 3.0 alpha.
## [2.4.5]
- [`c050e073`](https://github.com/tauri-apps/plugins-workspace/commit/c050e073b68205bae7389fb86958cb9331e1a3be) ([#3572](https://github.com/tauri-apps/plugins-workspace/pull/3572) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Fix `apply_pending_auto_save` can deadlock the store
## \[2.4.4]
- [`2ed6d6c1`](https://github.com/tauri-apps/plugins-workspace/commit/2ed6d6c1de4f5051b87cb6a2c5ac45320c444989) ([#3499](https://github.com/tauri-apps/plugins-workspace/pull/3499) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Fix `StoreOptions` requires `defaults` field
+2 -1
View File
@@ -600,7 +600,8 @@ impl<R: Runtime> Store<R> {
fn apply_pending_auto_save(&self) {
// Cancel and save if auto save is pending
if let Some(sender) = self.auto_save_debounce_sender.lock().unwrap().take() {
let auto_save_debounce_sender = self.auto_save_debounce_sender.lock().unwrap().take();
if let Some(sender) = auto_save_debounce_sender {
let _ = sender.send(AutoSaveMessage::Cancel);
let _ = self.save();
};
+40
View File
@@ -4,6 +4,24 @@
- [`363438b5`](https://github.com/tauri-apps/plugins-workspace/commit/363438b50a09162379d57c7c7bfc132520c1e5d4) Update to tauri 3.0 alpha.
## [2.12.0]
- [`1308bfa3`](https://github.com/tauri-apps/plugins-workspace/commit/1308bfa399b962b3977c767100a6339d1cbfdd20) **Breaking change:** the `allowDowngrades` option was removed from the `check` command and is now read from the plugin configuration instead.
Previously any code running in the webview could pass `allowDowngrades: true` to `plugin:updater|check` and relax the version check from "the update must be newer" to "the update must be different", overriding the comparator the application had configured on the Rust side. The flag is now an application-level setting:
```json
{
"plugins": {
"updater": {
"allowDowngrades": true
}
}
}
```
It defaults to `false`, and is ignored when the application provides its own `Builder::default_version_comparator`, which continues to take precedence.
## [2.11.0]
- [`f8053e65`](https://github.com/tauri-apps/plugins-workspace/commit/f8053e659e4ccd85c1f52833411ff8417cbc5e69) ([#3527](https://github.com/tauri-apps/plugins-workspace/pull/3527) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Documented Cargo feature flags in each plugin's crate-level documentation.
@@ -219,3 +237,25 @@
## \[2.0.0-alpha.0]
- [`717ae67`](https://github.com/tauri-apps/plugins-workspace/commit/717ae670978feb4492fac1f295998b93f2b9347f)([#371](https://github.com/tauri-apps/plugins-workspace/pull/371)) First v2 alpha release!
7c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.12.
## \[2.0.0-alpha.3]
- [`e438e0a`](https://github.com/tauri-apps/plugins-workspace/commit/e438e0a62d4b430a5159f05f13ecd397dd891a0d)([#676](https://github.com/tauri-apps/plugins-workspace/pull/676)) Update to @tauri-apps/api v2.0.0-alpha.11.
## \[2.0.0-alpha.2]
- [`5c13736`](https://github.com/tauri-apps/plugins-workspace/commit/5c137365c60790e8d4037d449e8237aa3fffdab0)([#673](https://github.com/tauri-apps/plugins-workspace/pull/673)) Update to @tauri-apps/api v2.0.0-alpha.9.
## \[2.0.0-alpha.2]
- [`4e2cef9`](https://github.com/tauri-apps/plugins-workspace/commit/4e2cef9b702bbbb9cf4ee17de50791cb21f1b2a4)([#593](https://github.com/tauri-apps/plugins-workspace/pull/593)) Update to alpha.12.
## \[2.0.0-alpha.1]
- [`d74fc0a`](https://github.com/tauri-apps/plugins-workspace/commit/d74fc0a097996e90a37be8f57d50b7d1f6ca616f)([#555](https://github.com/tauri-apps/plugins-workspace/pull/555)) Update to alpha.11.
- [`4ab90f0`](https://github.com/tauri-apps/plugins-workspace/commit/4ab90f048eab2918344f97dc8e04413a404e392d)([#431](https://github.com/tauri-apps/plugins-workspace/pull/431)) The updater plugin is recieving a few changes to improve consistency and ergonomics of the Rust and JS APIs
## \[2.0.0-alpha.0]
- [`717ae67`](https://github.com/tauri-apps/plugins-workspace/commit/717ae670978feb4492fac1f295998b93f2b9347f)([#371](https://github.com/tauri-apps/plugins-workspace/pull/371)) First v2 alpha release!
-4
View File
@@ -22,10 +22,6 @@ interface CheckOptions {
* Target identifier for the running application. This is sent to the backend.
*/
target?: string
/**
* Allow downgrades to previous versions by not checking if the current version is greater than the available version.
*/
allowDowngrades?: boolean
}
/** Options used when downloading an update */
-4
View File
@@ -46,7 +46,6 @@ pub(crate) async fn check<R: Runtime>(
timeout: Option<u64>,
proxy: Option<String>,
target: Option<String>,
allow_downgrades: Option<bool>,
) -> Result<Option<Metadata>> {
let mut builder = webview.updater_builder();
if let Some(headers) = headers {
@@ -64,9 +63,6 @@ pub(crate) async fn check<R: Runtime>(
if let Some(target) = target {
builder = builder.target(target);
}
if allow_downgrades.unwrap_or(false) {
builder = builder.version_comparator(|current, update| update.version != current);
}
let updater = builder.build()?;
let update = updater.check().await?;
+38
View File
@@ -114,6 +114,38 @@ pub struct Config {
pub endpoints: Vec<Url>,
/// Signature public key.
pub pubkey: String,
/// Require the update signature to carry the version it was signed for, and reject the
/// update when that version differs from the one announced by the update endpoint.
///
/// The endpoint response is fetched over TLS but is not itself signed, and the signature
/// only covers the downloaded artifact. Without this flag, anyone able to serve a crafted
/// response can pair an inflated `version` field with the `url` and `signature` of an
/// older release and force a downgrade to a genuine but outdated build, since that older
/// artifact carries a valid signature.
///
/// The signed version is read from the signature's trusted comment, which is covered by
/// the signature. Releases signed before the Tauri CLI started recording it carry no
/// version, so enabling this rejects them. Re-sign and re-publish every release your users
/// can still update from before turning this on.
///
/// This is checked independently of the version comparison: it constrains which artifact a
/// given version number may resolve to, not whether that version is newer.
///
/// The default value of this flag is `false`.
pub require_signed_version: bool,
/// Allow the updater to install a release whose version is not newer than the
/// currently running one, changing the version check from "must be newer" to
/// "must be different".
///
/// Note that the updater only verifies the signature of the downloaded artifact,
/// not the version advertised by the update endpoint, so enabling this removes the
/// only guard against installing a previously released (and validly signed) version.
///
/// Ignored when the application sets a custom
/// [`Builder::default_version_comparator`](crate::Builder::default_version_comparator).
///
/// The default value of this flag is `false`.
pub allow_downgrades: bool,
/// The Windows configuration for the updater.
pub windows: Option<WindowsConfig>,
}
@@ -135,6 +167,10 @@ impl<'de> Deserialize<'de> for Config {
#[serde(default)]
pub endpoints: Vec<Url>,
pub pubkey: String,
#[serde(default, alias = "require-signed-version")]
pub require_signed_version: bool,
#[serde(default, alias = "allow-downgrades")]
pub allow_downgrades: bool,
pub windows: Option<WindowsConfig>,
}
@@ -152,6 +188,8 @@ impl<'de> Deserialize<'de> for Config {
dangerous_accept_invalid_hostnames: config.dangerous_accept_invalid_hostnames,
endpoints: config.endpoints,
pubkey: config.pubkey,
require_signed_version: config.require_signed_version,
allow_downgrades: config.allow_downgrades,
windows: config.windows,
})
}
+16
View File
@@ -89,6 +89,22 @@ pub enum Error {
/// The configured updater endpoint must use a secure protocol like `https`
#[error("The configured updater endpoint must use a secure protocol like `https`.")]
InsecureTransportProtocol,
/// The version the artifact was signed for does not match the version announced by the
/// update endpoint.
#[error(
"The update was signed for version {signed} but the update endpoint announced version {announced}. The endpoint response may have been tampered with to force installing a different release."
)]
SignedVersionMismatch {
/// The version read from the signature's trusted comment.
signed: String,
/// The version announced by the update endpoint.
announced: String,
},
/// `requireSignedVersion` is enabled but the signature does not carry a version.
#[error(
"The update signature does not specify the version it was signed for, which `requireSignedVersion` requires. Re-sign and re-publish this release, or disable `requireSignedVersion`."
)]
MissingSignedVersion,
#[error(transparent)]
Tauri(#[from] tauri::Error),
}
+5
View File
@@ -98,6 +98,11 @@ impl<R: Runtime, T: Manager<R>> UpdaterExt<R> for T {
builder.version_comparator = version_comparator.clone();
// a comparator set by the application takes precedence over the configuration
if builder.version_comparator.is_none() && config.allow_downgrades {
builder = builder.version_comparator(|current, update| update.version != current);
}
#[cfg(any(
target_os = "linux",
target_os = "dragonfly",
+126 -3
View File
@@ -737,7 +737,13 @@ impl Update {
}
on_download_finish();
verify_signature(&buffer, &self.signature, &self.context.config.pubkey)?;
verify_signature(
&buffer,
&self.signature,
&self.context.config.pubkey,
&self.version,
self.context.config.require_signed_version,
)?;
Ok(buffer)
}
@@ -1521,7 +1527,13 @@ where
}
// Validate signature
fn verify_signature(data: &[u8], release_signature: &str, pub_key: &str) -> Result<()> {
fn verify_signature(
data: &[u8],
release_signature: &str,
pub_key: &str,
announced_version: &str,
require_signed_version: bool,
) -> Result<()> {
// we need to convert the pub key
let pub_key_decoded = base64_to_string(pub_key)?;
let public_key = PublicKey::decode(&pub_key_decoded)?;
@@ -1530,7 +1542,65 @@ fn verify_signature(data: &[u8], release_signature: &str, pub_key: &str) -> Resu
// Validate signature or bail out
public_key.verify(data, &signature, true)?;
Ok(())
// Only now is the trusted comment usable: minisign's global signature covers it, and
// `verify` above is what checks that global signature. Reading it before this point would
// be trusting attacker controlled data.
verify_signed_version(
signature.trusted_comment(),
announced_version,
require_signed_version,
)
}
/// Checks the version the artifact was signed for against the version the update endpoint
/// announced.
///
/// The endpoint response is not signed, so its `version` field on its own does not prove which
/// release the `url` and `signature` actually point at. Comparing it against the signed version
/// is what stops a tampered response from pairing a new version number with an older release.
fn verify_signed_version(
trusted_comment: &str,
announced_version: &str,
require_signed_version: bool,
) -> Result<()> {
let Some(signed_version) = signed_version(trusted_comment) else {
// Signatures produced before the Tauri CLI started recording the version carry none, so
// this can only be enforced when the app opts in. Note that leaving it off means an
// attacker can bypass the check outright by serving one of those older signatures.
return if require_signed_version {
Err(Error::MissingSignedVersion)
} else {
Ok(())
};
};
// compare as semver so that equivalent spellings like `1.2.3` and `v1.2.3` match, falling
// back to a literal comparison for versions that are not valid semver
let matches = match (
Version::from_str(signed_version.trim_start_matches('v')),
Version::from_str(announced_version.trim_start_matches('v')),
) {
(Ok(signed), Ok(announced)) => signed == announced,
_ => signed_version == announced_version,
};
if matches {
Ok(())
} else {
Err(Error::SignedVersionMismatch {
signed: signed_version.to_string(),
announced: announced_version.to_string(),
})
}
}
/// Reads the `version` field out of a signature's trusted comment, which the Tauri CLI writes as
/// tab separated `key:value` pairs, e.g. `timestamp:1700000000\tfile:app.tar.gz\tversion:1.2.3`.
fn signed_version(trusted_comment: &str) -> Option<&str> {
trusted_comment
.split('\t')
.find_map(|field| field.strip_prefix("version:"))
}
fn base64_to_string(base64_string: &str) -> Result<String> {
@@ -1636,6 +1706,59 @@ fn escape_msi_property_arg(arg: impl AsRef<OsStr>) -> String {
#[cfg(test)]
mod tests {
use super::{signed_version, verify_signed_version};
use crate::error::Error;
const CURRENT: &str = "timestamp:1700000000\tfile:app_1.2.3_x64.msi.zip\tversion:1.2.3";
// signatures produced before the CLI started embedding the version
const LEGACY: &str = "timestamp:1600000000\tfile:app_1.0.0_x64.msi.zip";
#[test]
fn reads_the_signed_version() {
assert_eq!(signed_version(CURRENT), Some("1.2.3"));
assert_eq!(signed_version(LEGACY), None);
// must not match on a field that merely ends in `version:`
assert_eq!(signed_version("timestamp:1\tfile:app-version:2.zip"), None);
}
#[test]
fn accepts_a_matching_version() {
assert!(verify_signed_version(CURRENT, "1.2.3", true).is_ok());
assert!(verify_signed_version(CURRENT, "1.2.3", false).is_ok());
// the endpoint and the CLI may spell the same version differently
assert!(verify_signed_version(CURRENT, "v1.2.3", true).is_ok());
}
#[test]
fn rejects_a_version_the_artifact_was_not_signed_for() {
// the rollback the flag exists to stop: an old artifact announced as a new version
let err = verify_signed_version(CURRENT, "9.9.9", false).unwrap_err();
assert!(
matches!(err, Error::SignedVersionMismatch { ref signed, ref announced }
if signed == "1.2.3" && announced == "9.9.9"),
"unexpected error: {err}"
);
// rejected regardless of whether the app opted in, since the signature does say
// which version it covers
assert!(verify_signed_version(CURRENT, "9.9.9", true).is_err());
assert!(verify_signed_version(CURRENT, "1.2.4", true).is_err());
}
#[test]
fn only_requires_a_signed_version_when_configured() {
assert!(verify_signed_version(LEGACY, "9.9.9", false).is_ok());
assert!(matches!(
verify_signed_version(LEGACY, "9.9.9", true).unwrap_err(),
Error::MissingSignedVersion
));
}
#[test]
fn compares_non_semver_versions_literally() {
let comment = "timestamp:1700000000\tfile:app.zip\tversion:2024-01-01";
assert!(verify_signed_version(comment, "2024-01-01", true).is_ok());
assert!(verify_signed_version(comment, "2024-01-02", true).is_err());
}
#[test]
#[cfg(windows)]
+320 -31
View File
@@ -9,12 +9,16 @@ use std::{
fs::File,
path::{Path, PathBuf},
process::Command,
sync::Arc,
sync::{Arc, Mutex},
};
use serde::Serialize;
use tauri::utils::config::{Updater, V1Compatible};
/// Every test here drives `cargo tauri build` against the same crate and target directory, and
/// binds a fixed port, so they cannot overlap.
static BUILD_LOCK: Mutex<()> = Mutex::new(());
const UPDATER_PRIVATE_KEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IHJzaWduIGVuY3J5cHRlZCBzZWNyZXQga2V5ClJXUlRZMEl5TlFOMFpXYzJFOUdjeHJEVXY4WE1TMUxGNDJVUjNrMmk1WlR3UVJVUWwva0FBQkFBQUFBQUFBQUFBQUlBQUFBQUpVK3ZkM3R3eWhyN3hiUXhQb2hvWFVzUW9FbEs3NlNWYjVkK1F2VGFRU1FEaGxuRUtlell5U0gxYS9DbVRrS0YyZVJGblhjeXJibmpZeGJjS0ZKSUYwYndYc2FCNXpHalM3MHcrODMwN3kwUG9SOWpFNVhCSUd6L0E4TGRUT096TEtLR1JwT1JEVFU9Cg==";
const UPDATED_EXIT_CODE: i32 = 0;
const ERROR_EXIT_CODE: i32 = 1;
@@ -24,6 +28,10 @@ const UP_TO_DATE_EXIT_CODE: i32 = 2;
struct Config {
version: &'static str,
bundle: BundleConfig,
/// Merged into `plugins` of the app's `tauri.conf.json`. Only set by the signed version
/// tests, which need to flip `requireSignedVersion` and point at their own server.
#[serde(skip_serializing_if = "Option::is_none")]
plugins: Option<serde_json::Value>,
}
#[derive(Serialize)]
@@ -35,13 +43,13 @@ struct BundleConfig {
#[derive(Serialize)]
struct PlatformUpdate {
signature: String,
url: &'static str,
url: String,
with_elevated_task: bool,
}
#[derive(Serialize)]
struct Update {
version: &'static str,
version: String,
date: String,
platforms: HashMap<String, PlatformUpdate>,
}
@@ -56,6 +64,12 @@ fn build_app(cwd: &Path, config: &Config, target: Option<BundleTarget>) {
.env("TAURI_SIGNING_PRIVATE_KEY_PASSWORD", "")
.current_dir(cwd);
// linuxdeploy ships a `strip` that does not know the `.relr.dyn` sections recent distros emit,
// and it aborts the whole bundle when a strip call fails. Nothing here benefits from a smaller
// AppImage, so skip it.
#[cfg(target_os = "linux")]
command.env("NO_STRIP", "1");
if let Some(target) = target {
command.arg("--bundles").arg(target.name());
} else {
@@ -107,6 +121,61 @@ impl Default for BundleTarget {
}
}
/// Copies a bundle out of the bundler output directory, returning the path to run.
///
/// Every bundler wipes its output before writing — the AppImage one removes `bundle/appimage`
/// entirely, the macOS one the `.app` — so the app built for the initial version only survives
/// until the next `build_app` call. On Linux and macOS the update is also installed over the very
/// bundle being run, which would otherwise leave a 1.0.0 app behind in the bundler output.
///
/// Windows drives the executable `cargo build` leaves in `target/release`, which no bundler
/// touches, so there is nothing to copy there.
fn stage_app_under_test(root_dir: &Path, target: &str) -> PathBuf {
#[cfg(windows)]
{
let _ = target;
return root_dir.join("target/release/app-updater.exe");
}
#[cfg(not(windows))]
{
let bundle_path = test_cases(root_dir, "0.1.0", target.to_string())
.first()
.unwrap()
.1
.clone();
let staging_dir = root_dir.join("target/release/app-under-test");
let _ = std::fs::remove_dir_all(&staging_dir);
std::fs::create_dir_all(&staging_dir).expect("failed to create the staging directory");
let staged = staging_dir.join(bundle_path.file_name().unwrap());
copy_recursively(&bundle_path, &staged).unwrap_or_else(|e| {
panic!(
"failed to copy {} to {}: {e}",
bundle_path.display(),
staged.display()
)
});
return staged;
}
}
/// Copies a file, or a directory such as a macOS `.app`, preserving permissions.
fn copy_recursively(from: &Path, to: &Path) -> std::io::Result<()> {
if from.is_dir() {
std::fs::create_dir_all(to)?;
for entry in std::fs::read_dir(from)? {
let entry = entry?;
copy_recursively(&entry.path(), &to.join(entry.file_name()))?;
}
Ok(())
} else {
std::fs::copy(from, to).map(|_| ())
}
}
fn target_to_platforms(
update_platform: Option<String>,
signature: String,
@@ -117,7 +186,7 @@ fn target_to_platforms(
platform,
PlatformUpdate {
signature,
url: "http://localhost:3007/download",
url: "http://localhost:3007/download".into(),
with_elevated_task: false,
},
);
@@ -270,6 +339,8 @@ fn test_cases(
#[test]
fn update_app() {
let _lock = BUILD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let target =
tauri_plugin_updater::target().expect("running updater test in an unsupported platform");
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
@@ -281,12 +352,14 @@ fn update_app() {
bundle: BundleConfig {
create_updater_artifacts: Updater::Bool(true),
},
plugins: None,
},
Config {
version: "1.0.0",
bundle: BundleConfig {
create_updater_artifacts: Updater::String(V1Compatible::V1Compatible),
},
plugins: None,
},
] {
let v1_compatible = matches!(
@@ -359,7 +432,7 @@ fn update_app() {
target_to_platforms(update_platform.clone(), signature.clone());
let body = serde_json::to_vec(&Update {
version: "1.0.0",
version: "1.0.0".into(),
date: time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
@@ -393,41 +466,38 @@ fn update_app() {
config.version = "0.1.0";
// bundle initial app version
build_app(&manifest_dir, &config, None);
// bundle initial app version; Linux and macOS run the bundle itself, so it cannot be
// skipped there
build_app(
&manifest_dir,
&config,
if cfg!(windows) {
None
} else {
Some(bundle_target)
},
);
let app_path = stage_app_under_test(&root_dir, &target);
for expected_exit_code in status_checks {
let mut binary_cmd = if cfg!(windows) {
Command::new(root_dir.join("target/release/app-updater.exe"))
} else if cfg!(target_os = "macos") {
Command::new(
test_cases(&root_dir, "0.1.0", target.clone())
.first()
.unwrap()
.1
.join("Contents/MacOS/app-updater"),
)
} else if std::env::var("CI").map(|v| v == "true").unwrap_or_default() {
let mut binary_cmd = if cfg!(target_os = "macos") {
Command::new(app_path.join("Contents/MacOS/app-updater"))
} else if cfg!(target_os = "linux")
&& std::env::var("CI").map(|v| v == "true").unwrap_or_default()
{
let mut c = Command::new("xvfb-run");
c.arg("--auto-servernum").arg(
&test_cases(&root_dir, "0.1.0", target.clone())
.first()
.unwrap()
.1,
);
c.arg("--auto-servernum").arg(&app_path);
c
} else {
Command::new(
&test_cases(&root_dir, "0.1.0", target.clone())
.first()
.unwrap()
.1,
)
Command::new(&app_path)
};
binary_cmd.env("TARGET", bundle_target.name());
let status = binary_cmd.status().expect("failed to run app");
let status = binary_cmd
.status()
.unwrap_or_else(|e| panic!("failed to run {}: {e}", app_path.display()));
let code = status.code().unwrap_or(-1);
if code != expected_exit_code {
@@ -447,3 +517,222 @@ fn update_app() {
}
}
}
const SIGNED_VERSION_PORT: u16 = 3008;
/// Fragment of `Error::SignedVersionMismatch`. The app prints the updater error before exiting,
/// and a rejected update exits with the same code as a failed install, so the message is what
/// tells them apart.
const MISMATCH_ERROR: &str = "was signed for version";
struct ServedUpdate {
version: String,
}
fn app_binary(root_dir: &Path) -> PathBuf {
root_dir.join(if cfg!(windows) {
"target/release/app-updater.exe"
} else {
"target/release/app-updater"
})
}
/// Runs the app against the update server, restoring the binary from `pristine` first.
///
/// A case that clears the version check goes on to actually install, and on Linux installing means
/// writing the downloaded bytes over the running executable. Every case therefore has to start
/// from an untouched binary, or the first one that passes leaves the update behind as the app.
fn run_app(root_dir: &Path, pristine: &Path) -> String {
let binary = app_binary(root_dir);
std::fs::copy(pristine, &binary).expect("failed to restore the app binary");
let mut command = if cfg!(target_os = "linux")
&& std::env::var("CI").map(|v| v == "true").unwrap_or_default()
{
let mut c = Command::new("xvfb-run");
c.arg("--auto-servernum").arg(&binary);
c
} else {
Command::new(&binary)
};
let output = command.output().expect("failed to run app");
String::from_utf8_lossy(&output.stdout).into_owned()
}
/// The update endpoint response is not signed, so its `version` field alone does not prove which
/// release the `url` and `signature` point at. This bundles a genuine 1.0.0 release and then varies
/// only the version the endpoint announces for it, which is the shape of a forced downgrade: a
/// tampered response pairing a new version number with an older release's signature.
///
/// The check runs in `Update::download`, before anything is installed, so a rejected case never
/// reaches the installer while an accepted one does. Both exit non-zero, which is why these assert
/// on the error message rather than the exit code.
#[test]
fn update_validates_signed_version() {
let _lock = BUILD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let target =
tauri_plugin_updater::target().expect("running updater test in an unsupported platform");
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let root_dir = manifest_dir.join("../../../..");
// bundle the release the endpoint will point at; the CLI records 1.0.0 in its signature
let bundle_target = BundleTarget::default();
build_app(
&manifest_dir,
&Config {
version: "1.0.0",
bundle: BundleConfig {
create_updater_artifacts: Updater::Bool(true),
},
plugins: None,
},
Some(bundle_target),
);
let out_bundle_path = test_cases(&root_dir, "1.0.0", target.clone())
.first()
.unwrap()
.1
.clone();
let updater_extension = {
let bundle_ext = out_bundle_path
.extension()
.unwrap()
.to_str()
.unwrap()
.to_string();
if cfg!(target_os = "macos") {
format!("{bundle_ext}.tar.gz")
} else {
bundle_ext
}
};
let signature =
std::fs::read_to_string(out_bundle_path.with_extension(format!("{updater_extension}.sig")))
.expect("failed to read signature file");
// move it aside so building the running app cannot clobber it
let out_updater_path = out_bundle_path.with_extension(&updater_extension);
let updater_path = root_dir.join(format!(
"target/release/{}",
out_updater_path.file_name().unwrap().to_str().unwrap()
));
std::fs::rename(&out_updater_path, &updater_path).expect("failed to rename bundle");
let served = Arc::new(Mutex::new(ServedUpdate {
version: "1.0.0".into(),
}));
let server = Arc::new(
tiny_http::Server::http(format!("localhost:{SIGNED_VERSION_PORT}"))
.expect("failed to start updater server"),
);
let server_ = server.clone();
let served_ = served.clone();
let target_ = target.clone();
let updater_path_ = updater_path.clone();
std::thread::spawn(move || {
for request in server_.incoming_requests() {
match request.url() {
"/" => {
let mut platforms = HashMap::new();
platforms.insert(
target_.clone(),
PlatformUpdate {
// always the genuine 1.0.0 signature; only the version above it moves
signature: signature.clone(),
url: format!("http://localhost:{SIGNED_VERSION_PORT}/download"),
with_elevated_task: false,
},
);
let body = serde_json::to_vec(&Update {
version: served_.lock().unwrap().version.clone(),
date: time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
platforms,
})
.unwrap();
let len = body.len();
let _ = request.respond(tiny_http::Response::new(
tiny_http::StatusCode(200),
Vec::new(),
std::io::Cursor::new(body),
Some(len),
None,
));
}
"/download" => {
let _ = request.respond(tiny_http::Response::from_file(
File::open(&updater_path_).unwrap_or_else(|_| {
panic!("failed to open updater bundle {}", updater_path_.display())
}),
));
}
_ => (),
}
}
});
// `requireSignedVersion` is baked in by `generate_context!`, so each value needs its own build
let pristine = root_dir.join("target/release/app-updater.pristine");
let build = |require_signed_version: bool| {
build_app(
&manifest_dir,
&Config {
version: "0.1.0",
bundle: BundleConfig {
create_updater_artifacts: Updater::Bool(true),
},
plugins: Some(serde_json::json!({
"updater": {
"endpoints": [format!("http://localhost:{SIGNED_VERSION_PORT}")],
"requireSignedVersion": require_signed_version,
}
})),
},
None,
);
std::fs::copy(app_binary(&root_dir), &pristine)
.expect("failed to keep a pristine copy of the app binary");
};
let check = |announced: &str, expected: Option<&str>, unexpected: Option<&str>| {
served.lock().unwrap().version = announced.to_string();
let output = run_app(&root_dir, &pristine);
if let Some(expected) = expected {
assert!(
output.contains(expected),
"expected {expected:?} when announcing {announced} for a 1.0.0 signature, got: {output}"
);
}
if let Some(unexpected) = unexpected {
assert!(
!output.contains(unexpected),
"unexpected {unexpected:?} when announcing {announced} for a 1.0.0 signature, got: {output}"
);
}
};
build(true);
// the rollback this exists to stop: a 1.0.0 release dressed up as a newer one
check("1.5.0", Some(MISMATCH_ERROR), None);
// the announced version is what the artifact was signed for, so it must go through
check("1.0.0", None, Some(MISMATCH_ERROR));
build(false);
// a signature that names a version is held to it whether or not the option is on
check("1.5.0", Some(MISMATCH_ERROR), None);
server.unblock();
// leave a runnable binary behind rather than whichever update was installed last
let _ = std::fs::copy(&pristine, app_binary(&root_dir));
let _ = std::fs::remove_file(&pristine);
}