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

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

* update schema
pull/1025/head^2
Lucas Fernandes Nogueira 1 year ago committed by GitHub
parent d9870f1948
commit ac520a2841
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
"http": patch
---
The scope URL now follows the URL pattern standard instead of a simple glob pattern.

59
Cargo.lock generated

@ -1659,7 +1659,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
dependencies = [ dependencies = [
"libloading 0.7.4", "libloading 0.8.1",
] ]
[[package]] [[package]]
@ -6546,8 +6546,8 @@ name = "tauri-plugin-http"
version = "2.0.0-beta.1" version = "2.0.0-beta.1"
dependencies = [ dependencies = [
"data-url", "data-url",
"glob",
"http 0.2.11", "http 0.2.11",
"regex",
"reqwest", "reqwest",
"schemars", "schemars",
"serde", "serde",
@ -6557,6 +6557,7 @@ dependencies = [
"tauri-plugin-fs", "tauri-plugin-fs",
"thiserror", "thiserror",
"url", "url",
"urlpattern",
] ]
[[package]] [[package]]
@ -7450,6 +7451,47 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "unic-char-property"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221"
dependencies = [
"unic-char-range",
]
[[package]]
name = "unic-char-range"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc"
[[package]]
name = "unic-common"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc"
[[package]]
name = "unic-ucd-ident"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987"
dependencies = [
"unic-char-property",
"unic-char-range",
"unic-ucd-version",
]
[[package]]
name = "unic-ucd-version"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4"
dependencies = [
"unic-common",
]
[[package]] [[package]]
name = "unicase" name = "unicase"
version = "2.7.0" version = "2.7.0"
@ -7542,6 +7584,19 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "urlpattern"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9bd5ff03aea02fa45b13a7980151fe45009af1980ba69f651ec367121a31609"
dependencies = [
"derive_more",
"regex",
"serde",
"unic-ucd-ident",
"url",
]
[[package]] [[package]]
name = "utf-8" name = "utf-8"
version = "0.7.6" version = "0.7.6"

@ -2242,7 +2242,7 @@
], ],
"properties": { "properties": {
"url": { "url": {
"description": "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.\n\nExamples:\n\n- \"https://*\" or \"https://**\" : allows all HTTPS urls\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"", "description": "A URL that can be accessed by the webview when using the HTTP APIs. Wildcards can be used following the URL pattern standard.\n\nSee [the URL Pattern spec](https://urlpattern.spec.whatwg.org/) for more information.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin on port 443\n\n- \"https://*:*\" : allows all HTTPS origin on any port\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string" "type": "string"
} }
} }
@ -2258,7 +2258,7 @@
], ],
"properties": { "properties": {
"url": { "url": {
"description": "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.\n\nExamples:\n\n- \"https://*\" or \"https://**\" : allows all HTTPS urls\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"", "description": "A URL that can be accessed by the webview when using the HTTP APIs. Wildcards can be used following the URL pattern standard.\n\nSee [the URL Pattern spec](https://urlpattern.spec.whatwg.org/) for more information.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin on port 443\n\n- \"https://*:*\" : allows all HTTPS origin on any port\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string" "type": "string"
} }
} }

@ -47,15 +47,22 @@
"default": "", "default": "",
"type": "string" "type": "string"
}, },
"context": { "remote": {
"description": "Execution context of the capability.\n\nAt runtime, Tauri filters the IPC command together with the context to determine whether it is allowed or not and its scope.", "description": "Configure remote URLs that can use the capability permissions.",
"default": "local", "anyOf": [
"allOf": [ {
"$ref": "#/definitions/CapabilityRemote"
},
{ {
"$ref": "#/definitions/CapabilityContext" "type": "null"
} }
] ]
}, },
"local": {
"description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.",
"default": true,
"type": "boolean"
},
"windows": { "windows": {
"description": "List of windows that uses this capability. Can be a glob pattern.\n\nOn multiwebview windows, prefer [`Self::webviews`] for a fine grained access control.", "description": "List of windows that uses this capability. Can be a glob pattern.\n\nOn multiwebview windows, prefer [`Self::webviews`] for a fine grained access control.",
"type": "array", "type": "array",
@ -78,7 +85,7 @@
} }
}, },
"platforms": { "platforms": {
"description": "Target platforms this capability applies. By default all platforms applies.", "description": "Target platforms this capability applies. By default all platforms are affected by this capability.",
"default": [ "default": [
"linux", "linux",
"macOS", "macOS",
@ -93,24 +100,8 @@
} }
} }
}, },
"CapabilityContext": { "CapabilityRemote": {
"description": "Context of the capability.", "description": "Configuration for remote URLs that are associated with the capability.",
"oneOf": [
{
"description": "Capability refers to local URL usage.",
"type": "string",
"enum": [
"local"
]
},
{
"description": "Capability refers to remote usage.",
"type": "object",
"required": [
"remote"
],
"properties": {
"remote": {
"type": "object", "type": "object",
"required": [ "required": [
"urls" "urls"
@ -124,11 +115,6 @@
} }
} }
} }
}
},
"additionalProperties": false
}
]
}, },
"PermissionEntry": { "PermissionEntry": {
"description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.",
@ -2256,7 +2242,7 @@
], ],
"properties": { "properties": {
"url": { "url": {
"description": "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.\n\nExamples:\n\n- \"https://*\" or \"https://**\" : allows all HTTPS urls\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"", "description": "A URL that can be accessed by the webview when using the HTTP APIs. Wildcards can be used following the URL pattern standard.\n\nSee [the URL Pattern spec](https://urlpattern.spec.whatwg.org/) for more information.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin on port 443\n\n- \"https://*:*\" : allows all HTTPS origin on any port\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string" "type": "string"
} }
} }
@ -2272,7 +2258,7 @@
], ],
"properties": { "properties": {
"url": { "url": {
"description": "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.\n\nExamples:\n\n- \"https://*\" or \"https://**\" : allows all HTTPS urls\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"", "description": "A URL that can be accessed by the webview when using the HTTP APIs. Wildcards can be used following the URL pattern standard.\n\nSee [the URL Pattern spec](https://urlpattern.spec.whatwg.org/) for more information.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin on port 443\n\n- \"https://*:*\" : allows all HTTPS origin on any port\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string" "type": "string"
} }
} }
@ -5756,6 +5742,13 @@
"webview:allow-print" "webview:allow-print"
] ]
}, },
{
"description": "webview:allow-reparent -> Enables the reparent command without any pre-configured scope.",
"type": "string",
"enum": [
"webview:allow-reparent"
]
},
{ {
"description": "webview:allow-set-webview-focus -> Enables the set_webview_focus command without any pre-configured scope.", "description": "webview:allow-set-webview-focus -> Enables the set_webview_focus command without any pre-configured scope.",
"type": "string", "type": "string",
@ -5826,6 +5819,13 @@
"webview:deny-print" "webview:deny-print"
] ]
}, },
{
"description": "webview:deny-reparent -> Denies the reparent command without any pre-configured scope.",
"type": "string",
"enum": [
"webview:deny-reparent"
]
},
{ {
"description": "webview:deny-set-webview-focus -> Denies the set_webview_focus command without any pre-configured scope.", "description": "webview:deny-set-webview-focus -> Denies the set_webview_focus command without any pre-configured scope.",
"type": "string", "type": "string",

@ -53,7 +53,7 @@
const form = new FormData(); const form = new FormData();
form.append("foo", foo); form.append("foo", foo);
form.append("bar", bar); form.append("bar", bar);
const response = await tauriFetch("http://localhost:3003", { const response = await tauriFetch("http://localhost:3003/tauri", {
method: "POST", method: "POST",
body: form, body: form,
}); });

@ -17,7 +17,8 @@ tauri-plugin = { workspace = true, features = [ "build" ] }
schemars = { workspace = true } schemars = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
url = { workspace = true } url = { workspace = true }
glob = "0.3" urlpattern = "0.2"
regex = "1"
[dependencies] [dependencies]
serde = { workspace = true } serde = { workspace = true }
@ -25,7 +26,8 @@ serde_json = { workspace = true }
tauri = { workspace = true } tauri = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tauri-plugin-fs = { path = "../fs", version = "2.0.0-beta.1" } tauri-plugin-fs = { path = "../fs", version = "2.0.0-beta.1" }
glob = "0.3" urlpattern = "0.2"
regex = "1"
http = "0.2" http = "0.2"
reqwest = { version = "0.11", default-features = false } reqwest = { version = "0.11", default-features = false }
url = { workspace = true } url = { workspace = true }

@ -12,11 +12,15 @@ const COMMANDS: &[&str] = &["fetch", "fetch_cancel", "fetch_send", "fetch_read_b
#[derive(schemars::JsonSchema)] #[derive(schemars::JsonSchema)]
struct ScopeEntry { struct ScopeEntry {
/// A URL that can be accessed by the webview when using the HTTP APIs. /// 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: /// 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 /// - "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 { impl From<ScopeEntry> for scope::Entry {
fn from(value: ScopeEntry) -> Self { fn from(value: ScopeEntry) -> Self {
scope::Entry { scope::Entry {
url: value.url.parse().unwrap(), url: urlpattern::UrlPattern::parse(
urlpattern::UrlPatternInit::parse_constructor_string::<regex::Regex>(
&value.url, None,
)
.unwrap(),
)
.unwrap(),
} }
} }
} }

@ -4,11 +4,17 @@
use serde::{Deserialize, Deserializer}; use serde::{Deserialize, Deserializer};
use url::Url; use url::Url;
use urlpattern::{UrlPattern, UrlPatternInit, UrlPatternMatchInput};
#[allow(rustdoc::bare_urls)] #[allow(rustdoc::bare_urls)]
#[derive(Debug)] #[derive(Debug)]
pub struct Entry { 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 { impl<'de> Deserialize<'de> for Entry {
@ -23,9 +29,9 @@ impl<'de> Deserialize<'de> for Entry {
EntryRaw::deserialize(deserializer).and_then(|raw| { EntryRaw::deserialize(deserializer).and_then(|raw| {
Ok(Entry { 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!( serde::de::Error::custom(format!(
"URL `{}` is not a valid glob pattern: {e}", "`{}` is not a valid URL pattern: {e}",
raw.url raw.url
)) ))
})?, })?,
@ -50,19 +56,19 @@ impl<'a> Scope<'a> {
/// Determines if the given URL is allowed on this scope. /// Determines if the given URL is allowed on this scope.
pub fn is_allowed(&self, url: &Url) -> bool { pub fn is_allowed(&self, url: &Url) -> bool {
let denied = self.denied.iter().any(|entry| { let denied = self.denied.iter().any(|entry| {
entry.url.matches(url.as_str()) entry
|| entry
.url .url
.matches(url.as_str().strip_suffix('/').unwrap_or_default()) .test(UrlPatternMatchInput::Url(url.clone()))
.unwrap_or_default()
}); });
if denied { if denied {
false false
} else { } else {
self.allowed.iter().any(|entry| { self.allowed.iter().any(|entry| {
entry.url.matches(url.as_str()) entry
|| entry
.url .url
.matches(url.as_str().strip_suffix('/').unwrap_or_default()) .test(UrlPatternMatchInput::Url(url.clone()))
.unwrap_or_default()
}) })
} }
} }
@ -75,16 +81,24 @@ mod tests {
use super::Entry; use super::Entry;
impl FromStr for Entry { impl FromStr for Entry {
type Err = glob::PatternError; type Err = urlpattern::quirks::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
let pattern = s.parse()?; let pattern = super::parse_url_pattern(s)?;
Ok(Self { url: pattern }) Ok(Self { url: pattern })
} }
} }
#[test] #[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 // plain URL
let entry = "http://localhost:8080".parse().unwrap(); let entry = "http://localhost:8080".parse().unwrap();
let scope = super::Scope::new(vec![&entry], Vec::new()); 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(&"https://localhost:8080".parse().unwrap()));
assert!(!scope.is_allowed(&"http://localhost:8081".parse().unwrap())); assert!(!scope.is_allowed(&"http://localhost:8081".parse().unwrap()));
assert!(!scope.is_allowed(&"http://local:8080".parse().unwrap())); assert!(!scope.is_allowed(&"http://local:8080".parse().unwrap()));
}
// deny takes precedence #[test]
let allow = "http://localhost:8080/file.png".parse().unwrap(); fn fixed_path() {
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()));
// URL with fixed path // URL with fixed path
let entry = "http://localhost:8080/file.png".parse().unwrap(); let entry = "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());
@ -112,8 +123,10 @@ mod tests {
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())); assert!(!scope.is_allowed(&"http://localhost:8080/file".parse().unwrap()));
assert!(!scope.is_allowed(&"http://localhost:8080/file.png/other.jpg".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 entry = "http://localhost:8080/*.png".parse().unwrap();
let scope = super::Scope::new(vec![&entry], Vec::new()); 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/assets/file.png".parse().unwrap()));
assert!(!scope.is_allowed(&"http://localhost:8080/file.jpeg".parse().unwrap())); assert!(!scope.is_allowed(&"http://localhost:8080/file.jpeg".parse().unwrap()));
}
#[test]
fn domain_wildcard() {
let entry = "http://*".parse().unwrap(); let entry = "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".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())); 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()); 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".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(&"file://path/to/file".parse().unwrap()));
assert!(scope.is_allowed(&"https://something.else".parse().unwrap()));
} }
} }

Loading…
Cancel
Save