feat(http): enhance scope URL matching via urlpattern (#1030)

* feat(http): enhance scope URL matching via urlpattern

* update schema
This commit is contained in:
Lucas Fernandes Nogueira
2024-03-06 13:44:51 -03:00
committed by GitHub
parent d9870f1948
commit ac520a2841
8 changed files with 184 additions and 76 deletions
+4 -2
View File
@@ -17,7 +17,8 @@ tauri-plugin = { workspace = true, features = [ "build" ] }
schemars = { workspace = true }
serde = { workspace = true }
url = { workspace = true }
glob = "0.3"
urlpattern = "0.2"
regex = "1"
[dependencies]
serde = { workspace = true }
@@ -25,7 +26,8 @@ serde_json = { workspace = true }
tauri = { workspace = true }
thiserror = { workspace = true }
tauri-plugin-fs = { path = "../fs", version = "2.0.0-beta.1" }
glob = "0.3"
urlpattern = "0.2"
regex = "1"
http = "0.2"
reqwest = { version = "0.11", default-features = false }
url = { workspace = true }
+13 -3
View File
@@ -12,11 +12,15 @@ const COMMANDS: &[&str] = &["fetch", "fetch_cancel", "fetch_send", "fetch_read_b
#[derive(schemars::JsonSchema)]
struct ScopeEntry {
/// A URL that can be accessed by the webview when using the HTTP APIs.
/// The scoped URL is matched against the request URL using a glob pattern.
/// Wildcards can be used following the URL pattern standard.
///
/// See [the URL Pattern spec](https://urlpattern.spec.whatwg.org/) for more information.
///
/// Examples:
///
/// - "https://*" or "https://**" : allows all HTTPS urls
/// - "https://*" : allows all HTTPS origin on port 443
///
/// - "https://*:*" : allows all HTTPS origin on any port
///
/// - "https://*.github.com/tauri-apps/tauri": allows any subdomain of "github.com" with the "tauri-apps/api" path
///
@@ -28,7 +32,13 @@ struct ScopeEntry {
impl From<ScopeEntry> for scope::Entry {
fn from(value: ScopeEntry) -> Self {
scope::Entry {
url: value.url.parse().unwrap(),
url: urlpattern::UrlPattern::parse(
urlpattern::UrlPatternInit::parse_constructor_string::<regex::Regex>(
&value.url, None,
)
.unwrap(),
)
.unwrap(),
}
}
}
+59 -23
View File
@@ -4,11 +4,17 @@
use serde::{Deserialize, Deserializer};
use url::Url;
use urlpattern::{UrlPattern, UrlPatternInit, UrlPatternMatchInput};
#[allow(rustdoc::bare_urls)]
#[derive(Debug)]
pub struct Entry {
pub url: glob::Pattern,
pub url: UrlPattern,
}
fn parse_url_pattern(s: &str) -> Result<UrlPattern, urlpattern::quirks::Error> {
let init = UrlPatternInit::parse_constructor_string::<regex::Regex>(s, None)?;
UrlPattern::parse(init)
}
impl<'de> Deserialize<'de> for Entry {
@@ -23,9 +29,9 @@ impl<'de> Deserialize<'de> for Entry {
EntryRaw::deserialize(deserializer).and_then(|raw| {
Ok(Entry {
url: glob::Pattern::new(&raw.url).map_err(|e| {
url: parse_url_pattern(&raw.url).map_err(|e| {
serde::de::Error::custom(format!(
"URL `{}` is not a valid glob pattern: {e}",
"`{}` is not a valid URL pattern: {e}",
raw.url
))
})?,
@@ -50,19 +56,19 @@ impl<'a> Scope<'a> {
/// Determines if the given URL is allowed on this scope.
pub fn is_allowed(&self, url: &Url) -> bool {
let denied = self.denied.iter().any(|entry| {
entry.url.matches(url.as_str())
|| entry
.url
.matches(url.as_str().strip_suffix('/').unwrap_or_default())
entry
.url
.test(UrlPatternMatchInput::Url(url.clone()))
.unwrap_or_default()
});
if denied {
false
} else {
self.allowed.iter().any(|entry| {
entry.url.matches(url.as_str())
|| entry
.url
.matches(url.as_str().strip_suffix('/').unwrap_or_default())
entry
.url
.test(UrlPatternMatchInput::Url(url.clone()))
.unwrap_or_default()
})
}
}
@@ -75,16 +81,24 @@ mod tests {
use super::Entry;
impl FromStr for Entry {
type Err = glob::PatternError;
type Err = urlpattern::quirks::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let pattern = s.parse()?;
let pattern = super::parse_url_pattern(s)?;
Ok(Self { url: pattern })
}
}
#[test]
fn is_allowed() {
fn denied_takes_precedence() {
let allow = "http://localhost:8080/file.png".parse().unwrap();
let deny = "http://localhost:8080/*".parse().unwrap();
let scope = super::Scope::new(vec![&allow], vec![&deny]);
assert!(!scope.is_allowed(&"http://localhost:8080/file.png".parse().unwrap()));
}
#[test]
fn fixed_url() {
// plain URL
let entry = "http://localhost:8080".parse().unwrap();
let scope = super::Scope::new(vec![&entry], Vec::new());
@@ -96,13 +110,10 @@ mod tests {
assert!(!scope.is_allowed(&"https://localhost:8080".parse().unwrap()));
assert!(!scope.is_allowed(&"http://localhost:8081".parse().unwrap()));
assert!(!scope.is_allowed(&"http://local:8080".parse().unwrap()));
}
// deny takes precedence
let allow = "http://localhost:8080/file.png".parse().unwrap();
let deny = "http://localhost:8080/*".parse().unwrap();
let scope = super::Scope::new(vec![&allow], vec![&deny]);
assert!(!scope.is_allowed(&"http://localhost:8080/file.png".parse().unwrap()));
#[test]
fn fixed_path() {
// URL with fixed path
let entry = "http://localhost:8080/file.png".parse().unwrap();
let scope = super::Scope::new(vec![&entry], Vec::new());
@@ -112,8 +123,10 @@ mod tests {
assert!(!scope.is_allowed(&"http://localhost:8080".parse().unwrap()));
assert!(!scope.is_allowed(&"http://localhost:8080/file".parse().unwrap()));
assert!(!scope.is_allowed(&"http://localhost:8080/file.png/other.jpg".parse().unwrap()));
}
// URL with glob pattern
#[test]
fn pattern_wildcard() {
let entry = "http://localhost:8080/*.png".parse().unwrap();
let scope = super::Scope::new(vec![&entry], Vec::new());
@@ -121,18 +134,41 @@ mod tests {
assert!(scope.is_allowed(&"http://localhost:8080/assets/file.png".parse().unwrap()));
assert!(!scope.is_allowed(&"http://localhost:8080/file.jpeg".parse().unwrap()));
}
#[test]
fn domain_wildcard() {
let entry = "http://*".parse().unwrap();
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()));
assert!(!scope.is_allowed(&"http://something.else/path/to/file".parse().unwrap()));
assert!(!scope.is_allowed(&"https://something.else".parse().unwrap()));
let entry = "http://**".parse().unwrap();
let entry = "http://*/*".parse().unwrap();
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()));
}
#[test]
fn scheme_wildcard() {
let entry = "*://*".parse().unwrap();
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()));
assert!(scope.is_allowed(&"file://path".parse().unwrap()));
assert!(!scope.is_allowed(&"file://path/to/file".parse().unwrap()));
assert!(scope.is_allowed(&"https://something.else".parse().unwrap()));
let entry = "*://*/*".parse().unwrap();
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()));
assert!(scope.is_allowed(&"file://path/to/file".parse().unwrap()));
assert!(scope.is_allowed(&"https://something.else".parse().unwrap()));
}
}