use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PageState {
pub url: String,
pub title: String,
pub domain: String,
pub has_forms: bool,
pub is_authenticated: bool,
pub page_loaded: bool,
pub tab_count: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub enum BrowserStatus {
Connecting,
Ready,
Busy,
TimedOut,
Terminated,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TabInfo {
pub id: String,
pub url: String,
pub title: String,
#[serde(rename = "type")]
pub tab_type: String,
#[serde(rename = "webSocketDebuggerUrl")]
pub ws_url: Option<String>,
}
pub struct BrowserScopeChecker {
pub allowed_domains: Vec<String>,
pub blocked_domains: Vec<String>,
pub allow_external: bool,
}
impl BrowserScopeChecker {
pub fn new(scope: &super::manifest::BrowserScopeDef) -> Self {
Self {
allowed_domains: scope.allowed_domains.clone(),
blocked_domains: scope.blocked_domains.clone(),
allow_external: scope.allow_external,
}
}
pub fn validate(&self) -> Result<(), String> {
if self.allowed_domains.len() + self.blocked_domains.len() > 256 {
return Err("browser scope has too many domain rules".into());
}
for rule in self.allowed_domains.iter().chain(&self.blocked_domains) {
canonical_domain(rule.strip_prefix("*.").unwrap_or(rule))?;
}
Ok(())
}
pub fn check_url(&self, url: &str) -> Result<(), String> {
let domain =
extract_domain(url).ok_or_else(|| format!("Cannot extract domain from: {}", url))?;
self.check_domain(&domain)
}
pub fn check_domain(&self, domain: &str) -> Result<(), String> {
let domain = canonical_domain(domain)?;
if self.allowed_domains.len() + self.blocked_domains.len() > 256 {
return Err("browser scope has too many domain rules".into());
}
let rules = |values: &[String]| {
values
.iter()
.map(|rule| match rule.strip_prefix("*.") {
Some(domain) => canonical_domain(domain).map(|domain| format!("*.{domain}")),
None => canonical_domain(rule),
})
.collect::<Result<Vec<_>, _>>()
};
let blocked_domains = rules(&self.blocked_domains)?;
let allowed_domains = rules(&self.allowed_domains)?;
for blocked in &blocked_domains {
if domain_matches(&domain, blocked) {
return Err(format!(
"Domain '{}' is blocked by scope rule '{}'",
domain, blocked
));
}
}
if allowed_domains.is_empty() {
return if self.allow_external {
Ok(())
} else {
Err("No allowed domains configured and allow_external is false".to_string())
};
}
for allowed in &allowed_domains {
if domain_matches(&domain, allowed) {
return Ok(());
}
}
if self.allow_external {
Ok(())
} else {
Err(format!(
"Domain '{}' not in allowed domains: {}",
domain,
self.allowed_domains.join(", ")
))
}
}
}
pub(super) fn parse_browser_url(value: &str) -> Result<url::Url, String> {
if value.len() > 8192
|| value
.bytes()
.any(|byte| byte.is_ascii_control() || byte == b'\\')
|| value.trim() != value
{
return Err("browser URL contains ambiguous characters or exceeds its limit".into());
}
let parsed = url::Url::parse(value).map_err(|_| "invalid browser URL")?;
if !matches!(parsed.scheme(), "http" | "https")
|| parsed.host().is_none()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
{
return Err("browser URL requires HTTP(S), a host and no credentials".into());
}
canonical_domain(parsed.host_str().unwrap())?;
Ok(parsed)
}
fn canonical_domain(value: &str) -> Result<String, String> {
let host = url::Host::parse(value).map_err(|_| "invalid browser scope domain")?;
let value = host.to_string();
if let url::Host::Domain(_) = host {
let value = value.strip_suffix('.').unwrap_or(&value);
if value.is_empty()
|| value.len() > 253
|| value.split('.').any(|label| {
label.is_empty()
|| label.len() > 63
|| !label
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
})
{
return Err("invalid browser scope domain".into());
}
Ok(value.into())
} else {
Ok(value)
}
}
fn extract_domain(url: &str) -> Option<String> {
let parsed = parse_browser_url(url).ok()?;
canonical_domain(parsed.host_str()?).ok()
}
fn domain_matches(domain: &str, pattern: &str) -> bool {
if pattern.starts_with("*.") {
let suffix = &pattern[1..]; domain.ends_with(suffix) || domain == &pattern[2..]
} else {
domain == pattern
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::toolclad::manifest::BrowserScopeDef;
#[test]
fn test_extract_domain() {
assert_eq!(
extract_domain("https://example.com/path"),
Some("example.com".to_string())
);
assert_eq!(
extract_domain("http://localhost:8080/"),
Some("localhost".to_string())
);
assert_eq!(extract_domain("not-a-url"), None);
}
#[test]
fn test_domain_matches_exact() {
assert!(domain_matches("example.com", "example.com"));
assert!(!domain_matches("other.com", "example.com"));
}
#[test]
fn test_domain_matches_wildcard() {
assert!(domain_matches("sub.example.com", "*.example.com"));
assert!(domain_matches("example.com", "*.example.com"));
assert!(!domain_matches("evil.com", "*.example.com"));
}
#[test]
fn test_scope_checker_allowed() {
let scope = BrowserScopeDef {
allowed_domains: vec!["*.example.com".to_string()],
blocked_domains: vec![],
allow_external: false,
};
let checker = BrowserScopeChecker::new(&scope);
assert!(checker.check_url("https://app.example.com/page").is_ok());
assert!(checker.check_url("https://evil.com/page").is_err());
}
#[test]
fn test_scope_checker_blocked() {
let scope = BrowserScopeDef {
allowed_domains: vec!["*.example.com".to_string()],
blocked_domains: vec!["admin.example.com".to_string()],
allow_external: false,
};
let checker = BrowserScopeChecker::new(&scope);
assert!(checker.check_url("https://app.example.com").is_ok());
assert!(checker.check_url("https://admin.example.com").is_err());
assert!(checker.check_url("HTTPS://ADMIN.EXAMPLE.COM./").is_err());
assert!(checker.check_url("https://ADMIN%2eEXAMPLE.COM/").is_err());
}
#[test]
fn browser_url_and_scope_rules_use_canonical_hosts() {
let mut checker = BrowserScopeChecker::new(&BrowserScopeDef {
allowed_domains: vec!["EXAMPLE.COM.".into(), "bücher.example".into()],
blocked_domains: vec![],
allow_external: false,
});
assert!(checker
.check_url("https://example.com/?next=https://other.test")
.is_ok());
assert!(checker.check_url("https://xn--bcher-kva.example/").is_ok());
for url in [
"file://example.com/etc/passwd",
"javascript://example.com/1",
"https://example.com@evil.test/",
"https://user@example.com/",
"https://example.com\\@evil.test/",
" https://example.com/",
"https://exam\nple.com/",
"https://example.com../",
] {
assert!(checker.check_url(url).is_err(), "{url}");
}
checker.allow_external = true;
checker.blocked_domains = vec!["example.com/path".into()];
assert!(checker.check_url("https://other.test/").is_err());
}
#[test]
fn test_scope_checker_allow_external() {
let scope = BrowserScopeDef {
allowed_domains: vec!["example.com".to_string()],
blocked_domains: vec![],
allow_external: true,
};
let checker = BrowserScopeChecker::new(&scope);
assert!(checker.check_url("https://example.com").is_ok());
assert!(checker.check_url("https://other.com").is_ok()); }
#[test]
fn test_scope_checker_no_external() {
let scope = BrowserScopeDef {
allowed_domains: vec![],
blocked_domains: vec![],
allow_external: false,
};
let checker = BrowserScopeChecker::new(&scope);
assert!(checker.check_url("https://any.com").is_err());
}
#[test]
fn test_page_state_default() {
let ps = PageState::default();
assert!(!ps.has_forms);
assert!(!ps.is_authenticated);
assert!(ps.url.is_empty());
}
}