Add single hostname support to credential matcher logic (#1667)

This commit is contained in:
Leendert de Borst
2026-02-09 15:35:30 +01:00
committed by Leendert de Borst
parent fbb359c18d
commit 7f5dff41be
3 changed files with 210 additions and 10 deletions

View File

@@ -61,12 +61,14 @@ export async function filterItems(
await ensureInit();
// Map Items to the format expected by the WASM filter
const credentials = items.map(item => ({
Id: item.Id,
ItemName: item.Name ?? '',
ItemUrls: getFieldValues(item, FieldKey.LoginUrl)
}));
const result = wasmFilterItems({
credentials: items.map(item => ({
Id: item.Id,
ItemName: item.Name ?? '',
ItemUrls: getFieldValues(item, FieldKey.LoginUrl)
})),
credentials,
current_url: currentUrl,
page_title: pageTitle,
matching_mode: matchingMode

View File

@@ -131,7 +131,8 @@ pub fn extract_domain_with_port(url: &str) -> DomainWithPort {
let mut domain = url.to_lowercase();
// Check if it has a protocol
// Check if it has a protocol - this is important for allowing single-word hostnames
// like "http://plex" or "https://nas" which are common in self-hosted/homelab setups
let has_protocol = domain.starts_with("http://") || domain.starts_with("https://");
// If no protocol and starts with TLD + dot, it's likely an app package name
@@ -179,15 +180,18 @@ pub fn extract_domain_with_port(url: &str) -> DomainWithPort {
None
};
// Basic domain validation - must contain at least one dot and valid characters
if !domain.contains('.') {
// Domain validation:
// - If URL had a protocol (http:// or https://), allow single-word hostnames
// like "localhost", "plex", "nas", "router" - common in self-hosted/homelab setups
// - If no protocol, require at least one dot to distinguish from random text
if !domain.contains('.') && !has_protocol {
return DomainWithPort {
domain: String::new(),
port: None,
};
}
// Check for valid domain characters
// Check for valid domain characters (alphanumeric, dots, hyphens)
if !domain
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
@@ -198,7 +202,7 @@ pub fn extract_domain_with_port(url: &str) -> DomainWithPort {
};
}
// Ensure valid domain structure
// Ensure valid domain structure (no leading/trailing dots, no consecutive dots)
if domain.starts_with('.') || domain.ends_with('.') || domain.contains("..") {
return DomainWithPort {
domain: String::new(),
@@ -206,6 +210,14 @@ pub fn extract_domain_with_port(url: &str) -> DomainWithPort {
};
}
// Ensure domain is not empty after all processing
if domain.is_empty() {
return DomainWithPort {
domain: String::new(),
port: None,
};
}
DomainWithPort { domain, port }
}
@@ -315,6 +327,62 @@ mod tests {
// Invalid domains
assert_eq!(extract_domain(""), "");
assert_eq!(extract_domain("nodot"), "");
// Single-word hostnames WITH protocol should be supported
// (common in self-hosted/homelab setups with local DNS or /etc/hosts)
assert_eq!(extract_domain("http://localhost"), "localhost");
assert_eq!(extract_domain("https://localhost"), "localhost");
assert_eq!(extract_domain("http://localhost/path"), "localhost");
assert_eq!(extract_domain("http://localhost?query=1"), "localhost");
assert_eq!(extract_domain("http://plex"), "plex");
assert_eq!(extract_domain("https://nas"), "nas");
assert_eq!(extract_domain("http://router"), "router");
assert_eq!(extract_domain("http://homeassistant"), "homeassistant");
assert_eq!(extract_domain("http://pihole/admin"), "pihole");
// Single-word hostnames WITHOUT protocol should NOT be accepted
// (to avoid matching random text as domains)
assert_eq!(extract_domain("localhost"), "");
assert_eq!(extract_domain("plex"), "");
assert_eq!(extract_domain("randomword"), "");
}
#[test]
fn test_extract_domain_single_word_hostname_with_port() {
// Single-word hostnames with port (common for self-hosted services)
assert_eq!(extract_domain("http://localhost:8080"), "localhost");
assert_eq!(extract_domain("http://localhost:81"), "localhost");
assert_eq!(extract_domain("http://localhost:3000/path"), "localhost");
assert_eq!(extract_domain("http://plex:32400"), "plex");
assert_eq!(extract_domain("https://nas:5001"), "nas");
assert_eq!(extract_domain("http://router:8080/admin"), "router");
// Without protocol - should NOT work (could be ambiguous)
assert_eq!(extract_domain("localhost:8080"), "");
assert_eq!(extract_domain("plex:32400"), "");
// Test DomainWithPort struct with localhost
let result = extract_domain_with_port("http://localhost:81");
assert_eq!(result.domain, "localhost");
assert_eq!(result.port, Some("81".to_string()));
assert_eq!(result.with_port(), "localhost:81");
let result = extract_domain_with_port("http://localhost:8080/path");
assert_eq!(result.domain, "localhost");
assert_eq!(result.port, Some("8080".to_string()));
let result = extract_domain_with_port("http://localhost/path");
assert_eq!(result.domain, "localhost");
assert_eq!(result.port, None);
// Test with other single-word hostnames
let result = extract_domain_with_port("http://plex:32400");
assert_eq!(result.domain, "plex");
assert_eq!(result.port, Some("32400".to_string()));
let result = extract_domain_with_port("https://nas:5001/files");
assert_eq!(result.domain, "nas");
assert_eq!(result.port, Some("5001".to_string()));
}
#[test]

View File

@@ -876,3 +876,133 @@ fn test_ignore_port_ip_with_multiple_ports_and_no_port() {
let small_android = filter_ignore_port(small_credentials.clone(), "https://192.168.1.10", "");
assert_eq!(small_android.len(), 2, "With ignore_port, both credentials match");
}
/// [#41] - Localhost matching with ports (development scenarios)
/// Common scenario: developers running multiple services on localhost with different ports
#[test]
fn test_localhost_matching_with_ports() {
let credentials = vec![
create_test_credential("Local API", "http://localhost:3000", "dev@api"),
create_test_credential("Local Frontend", "http://localhost:8080", "dev@frontend"),
create_test_credential("Local Backend", "http://localhost:81", "dev@backend"),
create_test_credential("Local No Port", "http://localhost", "dev@default"),
];
// Visiting localhost:81 should only match the :81 credential
let matches_81 = filter(credentials.clone(), "http://localhost:81", "");
assert_eq!(matches_81.len(), 1, "Should only return exact localhost:81 match");
assert_eq!(matches_81[0].item_name.as_deref(), Some("Local Backend"));
// Visiting localhost:3000 should only match the :3000 credential
let matches_3000 = filter(credentials.clone(), "http://localhost:3000", "");
assert_eq!(matches_3000.len(), 1, "Should only return exact localhost:3000 match");
assert_eq!(matches_3000[0].item_name.as_deref(), Some("Local API"));
// Visiting localhost:8080 should only match the :8080 credential
let matches_8080 = filter(credentials.clone(), "http://localhost:8080", "");
assert_eq!(matches_8080.len(), 1, "Should only return exact localhost:8080 match");
assert_eq!(matches_8080[0].item_name.as_deref(), Some("Local Frontend"));
// Visiting localhost (no port) should only match the no-port credential
let matches_no_port = filter(credentials.clone(), "http://localhost", "");
assert_eq!(matches_no_port.len(), 1, "Should only return localhost without port match");
assert_eq!(matches_no_port[0].item_name.as_deref(), Some("Local No Port"));
// Visiting localhost with a port not in credentials should show domain matches (up to 3)
let matches_unknown_port = filter(credentials.clone(), "http://localhost:5000", "");
assert_eq!(matches_unknown_port.len(), 3, "Should return domain matches when no exact port match");
}
/// [#42] - Localhost matching - exact URL stored
/// User's specific scenario: credential with exactly "http://localhost:81" should match
#[test]
fn test_localhost_exact_url_stored() {
let credentials = vec![
create_test_credential("My Local Service", "http://localhost:81", "user@local"),
];
// Should match when visiting exactly http://localhost:81
let matches = filter(credentials.clone(), "http://localhost:81", "");
assert_eq!(matches.len(), 1, "Should match exact localhost:81 URL");
assert_eq!(matches[0].item_name.as_deref(), Some("My Local Service"));
// Should also match with path
let matches_with_path = filter(credentials.clone(), "http://localhost:81/some/path", "");
assert_eq!(matches_with_path.len(), 1, "Should match localhost:81 with path");
// Should also match with query string
let matches_with_query = filter(credentials.clone(), "http://localhost:81?debug=true", "");
assert_eq!(matches_with_query.len(), 1, "Should match localhost:81 with query");
}
/// [#43] - Single-word hostname matching (homelab/self-hosted scenarios)
/// Common scenario: self-hosted services with local DNS or /etc/hosts entries
/// like "plex", "nas", "router", "homeassistant", etc.
#[test]
fn test_single_word_hostname_matching() {
let credentials = vec![
create_test_credential("Plex Media Server", "http://plex:32400", "admin@plex"),
create_test_credential("Synology NAS", "https://nas:5001", "admin@nas"),
create_test_credential("Pi-hole", "http://pihole/admin", "admin@pihole"),
create_test_credential("Home Assistant", "http://homeassistant:8123", "admin@ha"),
create_test_credential("Router Admin", "http://router", "admin@router"),
];
// Plex with exact port
let plex_matches = filter(credentials.clone(), "http://plex:32400", "");
assert_eq!(plex_matches.len(), 1, "Should match plex:32400");
assert_eq!(plex_matches[0].item_name.as_deref(), Some("Plex Media Server"));
// Plex with path
let plex_path = filter(credentials.clone(), "http://plex:32400/web/index.html", "");
assert_eq!(plex_path.len(), 1, "Should match plex:32400 with path");
// NAS with HTTPS
let nas_matches = filter(credentials.clone(), "https://nas:5001", "");
assert_eq!(nas_matches.len(), 1, "Should match nas:5001");
assert_eq!(nas_matches[0].item_name.as_deref(), Some("Synology NAS"));
// Pi-hole with path
let pihole_matches = filter(credentials.clone(), "http://pihole/admin/index.php", "");
assert_eq!(pihole_matches.len(), 1, "Should match pihole with path");
assert_eq!(pihole_matches[0].item_name.as_deref(), Some("Pi-hole"));
// Home Assistant
let ha_matches = filter(credentials.clone(), "http://homeassistant:8123/dashboard", "");
assert_eq!(ha_matches.len(), 1, "Should match homeassistant:8123");
assert_eq!(ha_matches[0].item_name.as_deref(), Some("Home Assistant"));
// Router without port
let router_matches = filter(credentials.clone(), "http://router", "");
assert_eq!(router_matches.len(), 1, "Should match router without port");
assert_eq!(router_matches[0].item_name.as_deref(), Some("Router Admin"));
// Router with different port should still match via domain
let router_port = filter(credentials.clone(), "http://router:8080", "");
assert_eq!(router_port.len(), 1, "Should match router with different port via domain match");
}
/// [#44] - Single-word hostnames require protocol for extraction
/// Credentials stored WITH protocol should match current URLs with protocol.
/// Note: Browser always sends URLs with protocol, so this mainly affects
/// what URLs can be stored in credentials.
#[test]
fn test_single_word_hostname_extraction_requires_protocol() {
// Credential stored WITHOUT protocol - should NOT be matchable
let credentials_no_protocol = vec![
create_test_credential("Plex No Protocol", "plex:32400", "admin@plex"),
];
// Even with protocol in current URL, credential without protocol won't match
// because the credential URL "plex:32400" can't be extracted as a domain
let no_match = filter(credentials_no_protocol.clone(), "http://plex:32400", "");
assert_eq!(no_match.len(), 0, "Credential without protocol should not be matchable");
// Credential stored WITH protocol - should match
let credentials_with_protocol = vec![
create_test_credential("Plex With Protocol", "http://plex:32400", "admin@plex"),
];
let with_match = filter(credentials_with_protocol.clone(), "http://plex:32400", "");
assert_eq!(with_match.len(), 1, "Credential with protocol should match");
}