diff --git a/.changes/enhance-http-scope.md b/.changes/enhance-http-scope.md new file mode 100644 index 00000000..791bf176 --- /dev/null +++ b/.changes/enhance-http-scope.md @@ -0,0 +1,5 @@ +--- +"http": patch +--- + +The scope URL now follows the URL pattern standard instead of a simple glob pattern. diff --git a/Cargo.lock b/Cargo.lock index ca97ee3b..d7f177f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1659,7 +1659,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" dependencies = [ - "libloading 0.7.4", + "libloading 0.8.1", ] [[package]] @@ -6546,8 +6546,8 @@ name = "tauri-plugin-http" version = "2.0.0-beta.1" dependencies = [ "data-url", - "glob", "http 0.2.11", + "regex", "reqwest", "schemars", "serde", @@ -6557,6 +6557,7 @@ dependencies = [ "tauri-plugin-fs", "thiserror", "url", + "urlpattern", ] [[package]] @@ -7450,6 +7451,47 @@ dependencies = [ "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]] name = "unicase" version = "2.7.0" @@ -7542,6 +7584,19 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "utf-8" version = "0.7.6" diff --git a/examples/api/src-tauri/gen/schemas/desktop-schema.json b/examples/api/src-tauri/gen/schemas/desktop-schema.json index 2d283f77..20b2fa3f 100644 --- a/examples/api/src-tauri/gen/schemas/desktop-schema.json +++ b/examples/api/src-tauri/gen/schemas/desktop-schema.json @@ -2242,7 +2242,7 @@ ], "properties": { "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" } } @@ -2258,7 +2258,7 @@ ], "properties": { "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" } } diff --git a/examples/api/src-tauri/gen/schemas/mobile-schema.json b/examples/api/src-tauri/gen/schemas/mobile-schema.json index 884d268f..900ededd 100644 --- a/examples/api/src-tauri/gen/schemas/mobile-schema.json +++ b/examples/api/src-tauri/gen/schemas/mobile-schema.json @@ -47,15 +47,22 @@ "default": "", "type": "string" }, - "context": { - "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.", - "default": "local", - "allOf": [ + "remote": { + "description": "Configure remote URLs that can use the capability permissions.", + "anyOf": [ + { + "$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": { "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", @@ -78,7 +85,7 @@ } }, "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": [ "linux", "macOS", @@ -93,42 +100,21 @@ } } }, - "CapabilityContext": { - "description": "Context of 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", - "required": [ - "urls" - ], - "properties": { - "urls": { - "description": "Remote domains this capability refers to. Can use glob patterns.", - "type": "array", - "items": { - "type": "string" - } - } - } - } - }, - "additionalProperties": false + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to. Can use glob patterns.", + "type": "array", + "items": { + "type": "string" + } } - ] + } }, "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.", @@ -2256,7 +2242,7 @@ ], "properties": { "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" } } @@ -2272,7 +2258,7 @@ ], "properties": { "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" } } @@ -5756,6 +5742,13 @@ "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.", "type": "string", @@ -5826,6 +5819,13 @@ "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.", "type": "string", diff --git a/examples/api/src/views/Http.svelte b/examples/api/src/views/Http.svelte index 842816b8..e1848803 100644 --- a/examples/api/src/views/Http.svelte +++ b/examples/api/src/views/Http.svelte @@ -53,7 +53,7 @@ const form = new FormData(); form.append("foo", foo); form.append("bar", bar); - const response = await tauriFetch("http://localhost:3003", { + const response = await tauriFetch("http://localhost:3003/tauri", { method: "POST", body: form, }); diff --git a/plugins/http/Cargo.toml b/plugins/http/Cargo.toml index 3b06c2a2..6bd71f19 100644 --- a/plugins/http/Cargo.toml +++ b/plugins/http/Cargo.toml @@ -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 } diff --git a/plugins/http/build.rs b/plugins/http/build.rs index 2eaf72ba..3ad55b46 100644 --- a/plugins/http/build.rs +++ b/plugins/http/build.rs @@ -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 for scope::Entry { fn from(value: ScopeEntry) -> Self { scope::Entry { - url: value.url.parse().unwrap(), + url: urlpattern::UrlPattern::parse( + urlpattern::UrlPatternInit::parse_constructor_string::( + &value.url, None, + ) + .unwrap(), + ) + .unwrap(), } } } diff --git a/plugins/http/src/scope.rs b/plugins/http/src/scope.rs index d80a55fb..5652e9a0 100644 --- a/plugins/http/src/scope.rs +++ b/plugins/http/src/scope.rs @@ -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 { + let init = UrlPatternInit::parse_constructor_string::(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 { - 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())); } }