fc_dioxus/behavior/url.rs
1//! Which addresses an embedded frame may load: a port of `isSafeFrameUrl` in upstream's
2//! `webview.ts`.
3//!
4//! An allowlist, because a webview's address bar is a navigation primitive: `javascript:`
5//! or `data:` in it is a script-injection sink aimed at the host page. Unknown schemes
6//! fail closed.
7
8/// Schemes a frame may load.
9const SAFE_SCHEMES: [&str; 3] = ["http", "https", "about"];
10
11/// Why an address was refused.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Refusal {
14 /// Nothing to load.
15 Empty,
16 /// A scheme outside the allowlist, such as `javascript` or `data`.
17 Scheme,
18}
19
20/// The address to load for what the user typed, or why it was refused.
21///
22/// Matches upstream's allowlist, after the cleaning the URL standard does first: leading
23/// and trailing controls and spaces are trimmed and tabs and newlines removed anywhere,
24/// so `java\nscript:` is caught as `javascript:`. Two address-bar conveniences go
25/// beyond upstream, which resolves everything against the host page: a bare host such
26/// as `example.com` or `localhost:8080` gets `https://`, and `//host` takes `https:`.
27/// Paths, queries, and fragments still resolve against the page.
28///
29/// ```
30/// use fc_dioxus::behavior::url::{frame_url, Refusal};
31/// assert_eq!(frame_url("https://example.com/docs"), Ok("https://example.com/docs".into()));
32/// assert_eq!(frame_url("example.com"), Ok("https://example.com".into()));
33/// assert_eq!(frame_url(" JavaScript:alert(1)"), Err(Refusal::Scheme));
34/// assert_eq!(frame_url("java\nscript:alert(1)"), Err(Refusal::Scheme));
35/// ```
36///
37/// # Errors
38/// [`Refusal::Empty`] for blank input; [`Refusal::Scheme`] for a scheme outside
39/// `http`, `https`, and `about`.
40pub fn frame_url(raw: &str) -> Result<String, Refusal> {
41 let cleaned: String = raw
42 .trim_matches(|c: char| c <= ' ')
43 .chars()
44 .filter(|c| !matches!(c, '\t' | '\n' | '\r'))
45 .collect();
46 if cleaned.is_empty() {
47 return Err(Refusal::Empty);
48 }
49 if let Some(rest) = cleaned.strip_prefix("//") {
50 return Ok(format!("https://{rest}"));
51 }
52 if cleaned.starts_with(['/', '.', '?', '#']) {
53 return Ok(cleaned);
54 }
55 match scheme(&cleaned) {
56 Some(scheme) if SAFE_SCHEMES.contains(&scheme.to_ascii_lowercase().as_str()) => Ok(cleaned),
57 Some(_) => Err(Refusal::Scheme),
58 None => Ok(format!("https://{cleaned}")),
59 }
60}
61
62/// Whether the address is served over TLS, for the address bar's lock. `None` for
63/// addresses with no transport, such as `about:blank`.
64#[must_use]
65pub fn is_secure(url: &str) -> Option<bool> {
66 match scheme(url).map(str::to_ascii_lowercase).as_deref() {
67 Some("https") => Some(true),
68 Some("http") => Some(false),
69 _ => None,
70 }
71}
72
73/// The scheme, when the address has one: a letter, then letters, digits, `+`, `-`, or
74/// `.`, then a colon. `host:8080` is a host and port, not a scheme.
75fn scheme(url: &str) -> Option<&str> {
76 let (candidate, rest) = url.split_once(':')?;
77 let mut chars = candidate.chars();
78 let valid = chars.next().is_some_and(|c| c.is_ascii_alphabetic())
79 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
80 let port = rest
81 .split(['/', '?', '#'])
82 .next()
83 .is_some_and(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
84 (valid && !port).then_some(candidate)
85}