mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-09-20 21:20:42 +02:00
Merge commit from fork
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.
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
---
|
||||
"http": minor
|
||||
---
|
||||
|
||||
**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>`.
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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()));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user