Skip to content

Commit 34a06e7

Browse files
authored
refactor(http)!: always enforce the scope on redirects (#3600)
* refactor(http)!: always enforce the scope on redirects Removes the `scopeRedirects` option (and the `Config` struct with it) that was added as an opt-in in 2.7.0. Every hop of a redirect chain is now checked against the URL scope, so a server on an allowed origin can no longer redirect the request to a URL the scope denies. `tauri_plugin_http::init()` returns `TauriPlugin<R>` again. * chore(http): compile without warnings when the cookies feature is disabled
1 parent b566f09 commit 34a06e7

5 files changed

Lines changed: 37 additions & 116 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"http": major
3+
"http-js": major
4+
---
5+
6+
**Breaking:** The URL scope is now always checked on every hop of a redirect chain, so every redirect target must be allowed by the scope or the request fails with `url not allowed on the configured scope`. This was previously opt-in through the `scopeRedirects` plugin configuration, which has been removed along with the `Config` struct: `tauri_plugin_http::init()` returns `TauriPlugin<R>` again, and any `plugins > http` object must be removed from `tauri.conf.json`.

‎plugins/http/guest-js/index.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,8 @@ export interface ClientOptions {
7777
* Defines the maximum number of redirects the client should follow.
7878
* If set to 0, no redirects will be followed.
7979
*
80-
* When the `scopeRedirects` plugin configuration is enabled, every redirect must
81-
* also be allowed by the configured scope, otherwise the request fails
82-
* instead of being followed.
80+
* Every redirect must also be allowed by the configured scope,
81+
* otherwise the request fails instead of being followed.
8382
*/
8483
maxRedirections?: number
8584
/** Timeout in milliseconds */

‎plugins/http/src/commands.rs‎

Lines changed: 18 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -180,25 +180,17 @@ fn attach_proxy(
180180

181181
/// Builds the redirect policy for the request.
182182
///
183-
/// When `scope` is [`Some`], **every** hop is checked against it. Validating only the URL the
184-
/// frontend asked for is not enough: a server on an allowed origin can answer with a redirect to
185-
/// any other origin (an open redirect, or a server the attacker controls), and following it would
186-
/// give the webview access to a URL the scope denies.
187-
///
188-
/// That check is opt-in through the `scopeRedirects` configuration because it breaks applications
189-
/// that rely on being redirected outside of their scope.
190-
// TODO(v3): always check the scope and take it by value instead of `Option`
191-
fn redirect_policy(scope: Option<Scope>, max_redirections: Option<usize>) -> Policy {
183+
/// **Every** hop is checked against the scope. Validating only the URL the frontend asked for is
184+
/// not enough: a server on an allowed origin can answer with a redirect to any other origin (an
185+
/// open redirect, or a server the attacker controls), and following it would give the webview
186+
/// access to a URL the scope denies.
187+
fn redirect_policy(scope: Scope, max_redirections: Option<usize>) -> Policy {
192188
if max_redirections == Some(0) {
193189
return Policy::none();
194190
}
195191

196192
let max_redirections = max_redirections.unwrap_or(DEFAULT_MAX_REDIRECTIONS);
197193

198-
let Some(scope) = scope else {
199-
return Policy::limited(max_redirections);
200-
};
201-
202194
Policy::custom(move |attempt| {
203195
// the first URL in `previous` is the initial request, so it must be excluded
204196
if attempt.previous().len() > max_redirections {
@@ -228,6 +220,8 @@ fn map_request_error(error: reqwest::Error) -> Error {
228220
Error::Network(error)
229221
}
230222

223+
// `state` is only read when the `cookies` feature is enabled
224+
#[cfg_attr(not(feature = "cookies"), allow(unused_variables))]
231225
#[command]
232226
pub async fn fetch<R: Runtime>(
233227
webview: Webview<R>,
@@ -311,7 +305,6 @@ pub async fn fetch<R: Runtime>(
311305
builder = builder.connect_timeout(Duration::from_millis(timeout));
312306
}
313307

314-
let scope = state.config.scope_redirects.then_some(scope);
315308
builder = builder.redirect(redirect_policy(scope, max_redirections));
316309

317310
if let Some(proxy_config) = proxy {
@@ -605,11 +598,7 @@ mod tests {
605598
port
606599
}
607600

608-
fn get(
609-
url: &str,
610-
scope: Option<Scope>,
611-
max_redirections: Option<usize>,
612-
) -> Result<reqwest::Response> {
601+
fn get(url: &str, scope: Scope, max_redirections: Option<usize>) -> Result<reqwest::Response> {
613602
let client = reqwest::ClientBuilder::new()
614603
.redirect(redirect_policy(scope, max_redirections))
615604
.build()
@@ -620,9 +609,9 @@ mod tests {
620609
)
621610
}
622611

623-
fn localhost_scope(port: u16) -> Option<Scope> {
612+
fn localhost_scope(port: u16) -> Scope {
624613
let entry = Arc::new(format!("http://localhost:{port}/*").parse().unwrap());
625-
Some(Scope::new(vec![entry], Vec::new()))
614+
Scope::new(vec![entry], Vec::new())
626615
}
627616

628617
#[test]
@@ -644,27 +633,12 @@ mod tests {
644633
}
645634
}
646635

647-
#[test]
648-
fn redirect_outside_of_scope_is_followed_when_not_configured() {
649-
let port = spawn_server();
650-
651-
// `scopeRedirects` is disabled, so only the URL requested by the frontend is checked
652-
let response = get(
653-
&format!("http://localhost:{port}/redirect-external"),
654-
None,
655-
None,
656-
)
657-
.unwrap();
658-
659-
assert_eq!(response.status(), StatusCode::OK);
660-
}
661-
662636
#[test]
663637
fn redirect_denied_by_the_scope_is_denied() {
664638
let port = spawn_server();
665639
let allow = Arc::new(format!("http://localhost:{port}/*").parse().unwrap());
666640
let deny = Arc::new(format!("http://localhost:{port}/target").parse().unwrap());
667-
let scope = Some(Scope::new(vec![allow], vec![deny]));
641+
let scope = Scope::new(vec![allow], vec![deny]);
668642

669643
let err = get(&format!("http://localhost:{port}/redirect/0"), scope, None).unwrap_err();
670644

@@ -690,16 +664,14 @@ mod tests {
690664
fn max_redirections_is_enforced() {
691665
let port = spawn_server();
692666

693-
for scope in [localhost_scope(port), None] {
694-
let err = get(
695-
&format!("http://localhost:{port}/redirect/5"),
696-
scope,
697-
Some(2),
698-
)
699-
.unwrap_err();
667+
let err = get(
668+
&format!("http://localhost:{port}/redirect/5"),
669+
localhost_scope(port),
670+
Some(2),
671+
)
672+
.unwrap_err();
700673

701-
assert!(matches!(err, Error::Network(e) if e.is_redirect()));
702-
}
674+
assert!(matches!(err, Error::Network(e) if e.is_redirect()));
703675
}
704676

705677
#[test]

‎plugins/http/src/config.rs‎

Lines changed: 0 additions & 47 deletions
This file was deleted.

‎plugins/http/src/lib.rs‎

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -44,32 +44,22 @@
4444
//! - **unsafe-headers**: Allows webview requests to send any headers.
4545
//! - **dangerous-settings**: Allows dangerous client settings such as accepting invalid certificates or hostnames.
4646
//!
47-
//! ## Configuration
47+
//! ## Security
4848
//!
49-
//! See [`Config`] for the options that can be set on the `plugins > http` object of your
50-
//! `tauri.conf.json`:
51-
//!
52-
//! ```json
53-
//! {
54-
//! "plugins": {
55-
//! "http": {
56-
//! "scopeRedirects": true
57-
//! }
58-
//! }
59-
//! }
60-
//! ```
49+
//! The URL scope is checked on every hop of a redirect chain, not only on the URL requested by
50+
//! the frontend, so every redirect target must also be allowed by the scope. Otherwise a server on
51+
//! an allowed origin could redirect the request to any other origin - a `localhost` service, an
52+
//! internal host or a cloud metadata endpoint - and hand its response to the webview.
6153
6254
pub use reqwest;
6355
use tauri::{
6456
plugin::{Builder, TauriPlugin},
6557
Manager, Runtime,
6658
};
6759

68-
pub use config::Config;
6960
pub use error::{Error, Result};
7061

7162
mod commands;
72-
mod config;
7363
mod error;
7464
#[cfg(feature = "cookies")]
7565
mod reqwest_cookie_store;
@@ -79,14 +69,13 @@ mod scope;
7969
const COOKIES_FILENAME: &str = ".cookies";
8070

8171
pub(crate) struct Http {
82-
pub(crate) config: Config,
8372
#[cfg(feature = "cookies")]
8473
cookies_jar: std::sync::Arc<crate::reqwest_cookie_store::CookieStoreMutex>,
8574
}
8675

87-
pub fn init<R: Runtime>() -> TauriPlugin<R, Option<Config>> {
88-
Builder::<R, Option<Config>>::new("http")
89-
.setup(|app, api| {
76+
pub fn init<R: Runtime>() -> TauriPlugin<R> {
77+
Builder::new("http")
78+
.setup(|app, _api| {
9079
#[cfg(feature = "cookies")]
9180
let cookies_jar = {
9281
use crate::reqwest_cookie_store::*;
@@ -114,7 +103,6 @@ pub fn init<R: Runtime>() -> TauriPlugin<R, Option<Config>> {
114103
};
115104

116105
let state = Http {
117-
config: api.config().clone().unwrap_or_default(),
118106
#[cfg(feature = "cookies")]
119107
cookies_jar: std::sync::Arc::new(cookies_jar),
120108
};
@@ -138,6 +126,9 @@ pub fn init<R: Runtime>() -> TauriPlugin<R, Option<Config>> {
138126
}
139127
}
140128
}
129+
130+
#[cfg(not(feature = "cookies"))]
131+
let _ = (app, event);
141132
})
142133
.invoke_handler(tauri::generate_handler![
143134
commands::fetch,

0 commit comments

Comments
 (0)