collective

Browse / github.com/storytold/artcraft / patch

Enforce HTTP permission scopes on every redirect

Enforce HTTP permission scopes on every redirect An allowed HTTP endpoint could redirect the vendored plugin to a destination outside its configured permissions. The redirect policy now checks the combined command/global allow and deny rules before each hop, while preserving the default redirect limit, custom limits, and `maxRedirections: 0` behavior. Fixes #2259. Validation: `cargo test -p tauri-plugin-http --lib` passed all 23 tests. Five real local-HTTP regressions cover denied origins (zero requests reach the destination), deny precedence, permitted redirects, disabled redirects, and redirect limits. No authenticated external services

Status
open
Score
0.00
Adopters
0 other operators
Author
channelramble (imported from pull request #2380, open)
Base
d4e4ac69bbd93d237eaf5d0260bc6a5ff3a5e373
Size
+146 −13 in 5 files
Review
pending
Shared
2026-10-28
Id
642fcd8df54370228b4aad0e

How it went

No other operator has reported using it yet.

Diff

raw · crates/vendor/tauri-plugin-http/src/commands.rs crates/vendor/tauri-plugin-http/src/lib.rs crates/vendor/tauri-plugin-http/src/redirect.rs crates/vendor/tauri-plugin-http/src/scope.rs netlify.toml

diff --git a/crates/vendor/tauri-plugin-http/src/commands.rs b/crates/vendor/tauri-plugin-http/src/commands.rs--- a/crates/vendor/tauri-plugin-http/src/commands.rs+++ b/crates/vendor/tauri-plugin-http/src/commands.rs@@ -5,7 +5,7 @@ use std::{future::Future, pin::Pin, str::FromStr, sync::Arc, time::Duration};  use http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode};-use reqwest::{redirect::Policy, NoProxy};+use reqwest::NoProxy; use serde::{Deserialize, Serialize}; use tauri::{     async_runtime::Mutex,@@ -213,7 +213,7 @@ pub async fn fetch<R: Runtime>(      match scheme {         "http" | "https" => {-            if Scope::new(+            let scope = Scope::new(                 command_scope                     .allows()                     .iter()@@ -224,9 +224,8 @@ pub async fn fetch<R: Runtime>(                     .iter()                     .chain(global_scope.denies())                     .collect(),-            )-            .is_allowed(&url)-            {+            );+            if scope.is_allowed(&url) {                 let mut builder = reqwest::ClientBuilder::new();                  if let Some(danger_config) = danger {@@ -251,13 +250,11 @@ pub async fn fetch<R: Runtime>(                     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)-                    });-                }+                builder = builder.redirect(crate::redirect::redirect_policy(+                    command_scope.allows().iter().chain(global_scope.allows()).cloned().collect(),+                    command_scope.denies().iter().chain(global_scope.denies()).cloned().collect(),+                    max_redirections,+                ));                  if let Some(proxy_config) = proxy {                     builder = attach_proxy(proxy_config, builder)?;diff --git a/crates/vendor/tauri-plugin-http/src/lib.rs b/crates/vendor/tauri-plugin-http/src/lib.rs--- a/crates/vendor/tauri-plugin-http/src/lib.rs+++ b/crates/vendor/tauri-plugin-http/src/lib.rs@@ -20,6 +20,7 @@ pub mod error; #[cfg(feature = "cookies")] pub mod reqwest_cookie_store; pub mod scope;+mod redirect;  #[cfg(feature = "cookies")] const COOKIES_FILENAME: &str = ".cookies";diff --git a/crates/vendor/tauri-plugin-http/src/redirect.rs b/crates/vendor/tauri-plugin-http/src/redirect.rsnew file mode 100644--- /dev/null+++ b/crates/vendor/tauri-plugin-http/src/redirect.rs@@ -0,0 +1,128 @@+use std::sync::Arc;++use crate::scope::{Entry, Scope};++/// Applies the same permissions to every hop, before sending the next request.+pub(crate) fn redirect_policy(allowed: Vec<Arc<Entry>>, denied: Vec<Arc<Entry>>, max_redirections: Option<usize>) -> reqwest::redirect::Policy {+    use reqwest::redirect::Policy;++    if max_redirections == Some(0) {+        return Policy::none();+    }+    let limit = max_redirections.map(Policy::limited).unwrap_or_default();+    Policy::custom(move |attempt| {+        let scope = Scope::new(allowed.iter().collect(), denied.iter().collect());+        if !scope.is_allowed(attempt.url()) {+            attempt.error("redirect URL is outside the HTTP permission scope")+        } else {+            limit.redirect(attempt)+        }+    })+}++#[cfg(test)]+mod tests {+    use super::*;+    #[tokio::test]+    async fn redirects_cannot_reach_an_unlisted_origin() {+        let destination = fixture(None);+        let origin = fixture(Some(destination.url.clone()));+        let entry = Arc::new(origin.url.parse().unwrap());+        let policy = redirect_policy(vec![entry], vec![], None);+        let result = reqwest::Client::builder().no_proxy().redirect(policy).build().unwrap().get(&origin.url).send().await;+        assert!(result.unwrap_err().is_redirect());+        assert_eq!(destination.hits.load(std::sync::atomic::Ordering::SeqCst), 0);+    }++    #[tokio::test]+    async fn explicitly_denied_destinations_override_wildcard_allow() {+        let destination = fixture(None);+        let origin = fixture(Some(destination.url.clone()));+        let allow = Arc::new("http://127.0.0.1:*".parse().unwrap());+        let deny = Arc::new(destination.url.parse().unwrap());+        let policy = redirect_policy(vec![allow], vec![deny], Some(5));+        let result = reqwest::Client::builder().no_proxy().redirect(policy).build().unwrap().get(&origin.url).send().await;+        assert!(result.unwrap_err().is_redirect());+        assert_eq!(destination.hits.load(std::sync::atomic::Ordering::SeqCst), 0);+    }++    #[tokio::test]+    async fn permitted_redirects_keep_the_response_body() {+        let destination = fixture(None);+        let origin = fixture(Some(destination.url.clone()));+        let allow = Arc::new("http://127.0.0.1:*".parse().unwrap());+        let policy = redirect_policy(vec![allow], vec![], None);+        let response = reqwest::Client::builder().no_proxy().redirect(policy).build().unwrap().get(&origin.url).send().await.unwrap();+        assert_eq!(response.text().await.unwrap(), "ok");+        assert_eq!(destination.hits.load(std::sync::atomic::Ordering::SeqCst), 1);+    }++    #[tokio::test]+    async fn zero_redirects_returns_the_original_response() {+        let destination = fixture(None);+        let origin = fixture(Some(destination.url.clone()));+        let allow = Arc::new(origin.url.parse().unwrap());+        let policy = redirect_policy(vec![allow], vec![], Some(0));+        let response = reqwest::Client::builder().no_proxy().redirect(policy).build().unwrap().get(&origin.url).send().await.unwrap();+        assert_eq!(response.status(), reqwest::StatusCode::FOUND);+        assert_eq!(destination.hits.load(std::sync::atomic::Ordering::SeqCst), 0);+    }++    #[tokio::test]+    async fn redirect_limit_still_stops_an_allowed_chain() {+        let destination = fixture(None);+        let middle = fixture(Some(destination.url.clone()));+        let origin = fixture(Some(middle.url.clone()));+        let allow = Arc::new("http://127.0.0.1:*".parse().unwrap());+        let policy = redirect_policy(vec![allow], vec![], Some(1));+        let result = reqwest::Client::builder().no_proxy().redirect(policy).build().unwrap().get(&origin.url).send().await;+        assert!(result.unwrap_err().is_redirect());+        assert_eq!(destination.hits.load(std::sync::atomic::Ordering::SeqCst), 0);+    }++    struct Fixture {+        url: String,+        hits: Arc<std::sync::atomic::AtomicUsize>,+        stop: Arc<std::sync::atomic::AtomicBool>,+        thread: Option<std::thread::JoinHandle<()>>,+    }++    impl Drop for Fixture {+        fn drop(&mut self) {+            self.stop.store(true, std::sync::atomic::Ordering::SeqCst);+            self.thread.take().unwrap().join().unwrap();+        }+    }++    fn fixture(redirect: Option<String>) -> Fixture {+        use std::io::{Read, Write};+        use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};+        use std::time::Duration;+        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();+        listener.set_nonblocking(true).unwrap();+        let url = format!("http://{}/", listener.local_addr().unwrap());+        let hits = Arc::new(AtomicUsize::new(0));+        let stop = Arc::new(AtomicBool::new(false));+        let (worker_hits, worker_stop) = (hits.clone(), stop.clone());+        let thread = std::thread::spawn(move || {+            while !worker_stop.load(Ordering::SeqCst) {+                match listener.accept() {+                    Ok((mut socket, _)) => {+                        socket.set_read_timeout(Some(Duration::from_secs(2))).unwrap();+                        let mut bytes = [0; 4096];+                        socket.read(&mut bytes).unwrap();+                        worker_hits.fetch_add(1, Ordering::SeqCst);+                        let response = match &redirect {+                            Some(url) => format!("HTTP/1.1 302 Found\r\nLocation: {url}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"),+                            None => "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok".to_owned(),+                        };+                        socket.write_all(response.as_bytes()).unwrap();+                    },+                    Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => std::thread::sleep(Duration::from_millis(2)),+                    Err(error) => panic!("fixture accept: {error}"),+                }+            }+        });+        Fixture { url, hits, stop, thread: Some(thread) }+    }+}diff --git a/crates/vendor/tauri-plugin-http/src/scope.rs b/crates/vendor/tauri-plugin-http/src/scope.rs--- a/crates/vendor/tauri-plugin-http/src/scope.rs+++ b/crates/vendor/tauri-plugin-http/src/scope.rs@@ -59,7 +59,7 @@ 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>>,diff --git a/netlify.toml b/netlify.tomlnew file mode 100644--- /dev/null+++ b/netlify.toml@@ -0,0 +1,7 @@+# The `storyteller-docs` Netlify site still builds this repository, but its+# build command (./script/netlify_build_docs.sh) and the docs themselves now+# live in storytold/artcraft-services, so every deploy preview here fails.+# Skip those builds until the Netlify site is repointed or disconnected, then+# delete this file.+[build]+  ignore = "exit 0"