Files
donutbrowser/src-tauri/src/vpn_extension_detect/rules.rs
T

530 lines
19 KiB
Rust

//! Pure classification rules for VPN/proxy extension detection.
//!
//! Deliberately free of crate-internal dependencies (`std` + `serde_json`
//! only): these rules are the heart of the feature and the part most worth
//! testing in isolation, so nothing here may reach for app state, the
//! filesystem, or the network. Enumerating the two extension sources and
//! reading them off disk lives in the parent module.
use serde::{Deserialize, Serialize};
/// Chrome Web Store ids of extensions whose whole purpose is routing the
/// browser somewhere else. Sorted, so membership is a binary search.
///
/// This list is what lets a VPN with an unrevealing name — "Hotspot Shield"
/// says nothing about what it does — be named as one instead of appearing as
/// an anonymous holder of the proxy permission. Every id was verified by
/// downloading the extension and reading its manifest; a wrong id is worse
/// than a missing one, because a stale list only ever loses recall while a
/// wrong one accuses the wrong extension.
const KNOWN_VPN_EXTENSION_IDS: &[&str] = &[
"adlpodnneegcnbophopdmhedicjbcgco", // Troywell VPN
"ailoabdmgclmfmhdagmlohpjlbpffblp", // Surfshark
"akcocjjpkmlniicdeemdceeajlmoabhg", // 1VPN
"apbcbecdpjefgklcokinpapmmdekecah", // Ninja VPN
"bihmplhobchoageeokmgbdihknkjbknd", // Touch VPN (delisted 2025, still installed in old profiles)
"blapeiihifiknfmceddkceklnpopgclm", // Proxy Switcher Pro
"bnlofglpdlboacepdieejiecfbfpmhlb", // Turbo VPN
"dookpfaalaaappcdneeahomimbllocnb", // FoxyProxy Basic
"eppiocemhmnlbhjplcgkofciiegomcon", // Urban VPN
"fcfhplploccackoneaefokcmbjfbkenj", // 1clickVPN
"fdcgdnkidjaadafnichfpabhfomcebme", // ZenMate (delisted 2025)
"ffbkglfijbcbgblgflchnbphjdllaogb", // CyberGhost
"fgddmllnllkalaagkghckoinaemmogpe", // ExpressVPN
"fjoaledfpmneenckfbpdfhkmimnjocfa", // NordVPN
"gcknhkkoolaabfmlnjonogaaifnjlfnp", // FoxyProxy
"gdpehpfhegefkjelaifkdbppjbhilaom", // Proxy-Cheap Proxy Manager
"gjakohbhfclfjmhhlenfdkldieofkpjl", // IPRoyal Proxy Manager
"gjknjjomckknofjidppipffbpoekiipm", // Betternet
"gkojfkhlekighikafcpjkiklfbnlmeio", // Hola VPN
"hnmpcagpplmpfojmgmnngilcnanddlhb", // Windscribe
"jaoafpkngncfpfggjefnekilbkcpjdgp", // uVPN
"jedieiamjmoflcknjdjhpieklepfglin", // FastestVPN
"jpadbaildllggkcgibilkeacpcodailn", // Planet VPN lite
"jplgfhpmjnbigmhklmmbgecoobifkmpa", // Proton VPN
"jplnlifepflhkbkgonidnobkakhmpnmh", // Private Internet Access
"kgepmkaldicdcljckhamnhkigddnbcbd", // PACify Proxy Manager
"kpiecbcckbofpmkkkdibbllpinceiihk", // DotVPN
"majdfhpaihoncoakbjgbdhglocklcgno", // VeePN
"nbcojefnccbanplpoffopkoepjmhgdgh", // Hoxx VPN
"nlbejmccbhkncgokjcmghpfloaajcffj", // Hotspot Shield
"ohjocgmpmlfahafbipehkhbaacoemojp", // hide.me Proxy
"omdakjcmkglenbhjadbccaookpfjihpa", // TunnelBear
"omghfjlpggmjjaagoclmmobgdodcjboh", // Browsec
"onnfghpihccifgojkpnnncpagjcdbjod", // Proxy Switcher and Manager
"oofgbpoabipfcfjapgnbbjjaenockbdp", // SetupVPN
"padekgcemlokbadohgkifijomclgjgif", // Proxy SwitchyOmega
"pphgdbgldlmicfdkhondlafkiomnelnk", // 1ClickVPN Proxy
];
/// Terms specific enough to name a VPN wherever they appear, including in a
/// 132-character manifest description.
const STRONG_KEYWORDS: &[&str] = &["vpn", "wireguard", "shadowsocks", "openvpn"];
/// Terms that only mean "VPN" in a product's *name*. In a description they are
/// ordinary English — "no proxy setup required", "carpal tunnel", "unblock
/// right click" — and matching them there is where the noise comes from.
const NAME_ONLY_KEYWORDS: &[&str] = &["proxy", "unblock"];
/// Matched as whole tokens rather than substrings, and in the name only. Too
/// short to be safe inside other words ("tussocks", "tunnelling").
const NAME_TOKEN_KEYWORDS: &[&str] = &["socks", "socks5", "tunnel"];
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DetectedVpnExtension {
/// Stable acknowledgement identity: `donut:<uuid>` or `crx:<32-char-id>`.
pub key: String,
pub name: String,
pub version: Option<String>,
/// `"donut"` (managed by Donut) or `"browser"` (installed inside the profile).
pub source: String,
/// `"confirmed"` and `"likely"` are claims that this IS a VPN/proxy tool.
/// `"capability"` claims only that it *could* change the proxy.
pub confidence: String,
/// Whether the manifest holds Chromium's `proxy` permission outright, so the
/// extension can call `chrome.proxy.settings.set` without asking again.
/// Separate from `confidence`: a download manager reading the browser's
/// proxy declares the identical permission as a VPN hijacking it.
pub proxy_control: bool,
/// Why it matched, for the dialog's detail line.
pub signals: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ManifestSignals {
pub proxy_permission: bool,
pub optional_proxy_permission: bool,
pub declarative_net_request: bool,
pub web_request_blocking: bool,
pub broad_host_permissions: bool,
}
fn string_list<'a>(manifest: &'a serde_json::Value, key: &str) -> Vec<&'a str> {
manifest
.get(key)
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default()
}
fn is_broad_host(pattern: &str) -> bool {
matches!(pattern, "<all_urls>" | "*://*/*")
}
pub fn signals_from_manifest(manifest: &serde_json::Value) -> ManifestSignals {
let permissions = string_list(manifest, "permissions");
let optional_permissions = string_list(manifest, "optional_permissions");
let host_permissions = string_list(manifest, "host_permissions");
let optional_host_permissions = string_list(manifest, "optional_host_permissions");
let has = |list: &[&str], name: &str| list.contains(&name);
// MV2 keeps host patterns inside `permissions`; MV3 splits them into
// `host_permissions`. Look in both so one manifest version isn't silently
// under-detected.
let all_hosts: Vec<&str> = permissions
.iter()
.chain(host_permissions.iter())
.chain(optional_host_permissions.iter())
.copied()
.collect();
let broad = all_hosts.iter().any(|p| is_broad_host(p))
|| (all_hosts.contains(&"http://*/*") && all_hosts.contains(&"https://*/*"));
ManifestSignals {
proxy_permission: has(&permissions, "proxy"),
optional_proxy_permission: has(&optional_permissions, "proxy"),
declarative_net_request: has(&permissions, "declarativeNetRequest")
|| has(&permissions, "declarativeNetRequestWithHostAccess"),
web_request_blocking: has(&permissions, "webRequest")
&& has(&permissions, "webRequestBlocking"),
broad_host_permissions: broad,
}
}
/// True when this is the id of an extension known to route browser traffic.
pub fn is_known_vpn_extension(extension_id: &str) -> bool {
KNOWN_VPN_EXTENSION_IDS.binary_search(&extension_id).is_ok()
}
fn has_token(haystack: &str, tokens: &[&str]) -> bool {
haystack
.split(|c: char| !c.is_alphanumeric())
.any(|token| tokens.contains(&token))
}
/// Does the extension describe itself as a VPN or proxy tool?
///
/// The name is weighted far more heavily than the description, because that is
/// where the evidence actually lives: a VPN vendor puts "VPN" in the name — it
/// is how the store surfaces them — while a description is 132 characters of
/// ordinary prose in which "proxy", "tunnel" and "unblock" are all innocent.
/// Matching those three against descriptions is what flags carpal-tunnel
/// reminders, right-click unblockers, and tools whose pitch is that they need
/// *no* proxy setup.
pub fn vpn_keyword_hit(name: &str, description: Option<&str>) -> bool {
let name = name.to_lowercase();
if STRONG_KEYWORDS.iter().any(|k| name.contains(k))
|| NAME_ONLY_KEYWORDS.iter().any(|k| name.contains(k))
|| has_token(&name, NAME_TOKEN_KEYWORDS)
{
return true;
}
description
.map(str::to_lowercase)
.is_some_and(|d| STRONG_KEYWORDS.iter().any(|k| d.contains(k)))
}
/// Classify an extension from its id, manifest signals and self-description.
///
/// Two different questions are answered here, and fusing them is what made an
/// ordinary download manager get reported as a VPN. Chromium has no read-only
/// variant of the `proxy` permission: `chrome.proxy.settings.get()` and
/// `.set()` sit behind the same manifest string, so an extension replicating
/// the browser's proxy for its own transfers declares exactly what a VPN
/// hijacking it declares. The permission therefore proves a *capability* and
/// nothing more; naming something a VPN needs separate evidence — a known id,
/// or the extension saying so itself.
///
/// The request-blocking tier's keyword requirement is not optional either:
/// `declarativeNetRequest` plus `<all_urls>` describes every content blocker in
/// the ecosystem, so without it the warning fires on uBlock Origin — which
/// would teach users to dismiss the dialog on sight, destroying the value of
/// the mismatch block that shares it.
///
/// An `optional_permissions` entry the user has never granted is deliberately
/// not a capability at all: the extension cannot call `chrome.proxy` until it
/// asks and is allowed.
pub fn classify(
extension_id: Option<&str>,
signals: &ManifestSignals,
keyword: bool,
) -> Option<&'static str> {
if extension_id.is_some_and(is_known_vpn_extension) {
return Some("confirmed");
}
if keyword {
if signals.proxy_permission {
return Some("confirmed");
}
if signals.optional_proxy_permission
|| ((signals.declarative_net_request || signals.web_request_blocking)
&& signals.broad_host_permissions)
{
return Some("likely");
}
}
if signals.proxy_permission {
return Some("capability");
}
None
}
pub fn signal_labels(
extension_id: Option<&str>,
signals: &ManifestSignals,
keyword: bool,
) -> Vec<String> {
let mut out = Vec::new();
if extension_id.is_some_and(is_known_vpn_extension) {
out.push("knownVpnExtension".to_string());
}
if signals.proxy_permission {
out.push("permissions:proxy".to_string());
}
if signals.optional_proxy_permission {
out.push("optionalPermissions:proxy".to_string());
}
if signals.declarative_net_request {
out.push("declarativeNetRequest".to_string());
}
if signals.web_request_blocking {
out.push("webRequestBlocking".to_string());
}
if signals.broad_host_permissions {
out.push("broadHostPermissions".to_string());
}
if keyword {
out.push("keyword".to_string());
}
out
}
/// `__MSG_someKey__` -> `someKey`.
pub fn message_placeholder_key(value: &str) -> Option<String> {
value
.strip_prefix("__MSG_")
.and_then(|rest| rest.strip_suffix("__"))
.map(str::to_string)
}
/// Chromium's `messages.json` shape: `{ "key": { "message": "..." } }`, with
/// keys compared case-insensitively.
pub fn lookup_message(messages: &serde_json::Value, key: &str) -> Option<String> {
let obj = messages.as_object()?;
obj
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(key))
.and_then(|(_, v)| v.get("message"))
.and_then(|v| v.as_str())
.map(str::to_string)
}
pub fn manifest_str(manifest: &serde_json::Value, key: &str) -> Option<String> {
manifest
.get(key)
.and_then(|v| v.as_str())
.map(str::to_string)
}
/// Sort key for an extension version directory (`1.10.0_0`), compared
/// numerically so `1.10.0` sorts above `1.9.0` where a lexicographic compare
/// would put it below.
pub fn version_dir_sort_key(name: &str) -> Vec<u64> {
name
.split(['.', '_'])
.map(|part| part.parse::<u64>().unwrap_or(0))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn signals_of(manifest: serde_json::Value) -> ManifestSignals {
signals_from_manifest(&manifest)
}
fn classify_named(
manifest: serde_json::Value,
name: &str,
description: Option<&str>,
) -> Option<&'static str> {
let s = signals_of(manifest);
classify(None, &s, vpn_keyword_hit(name, description))
}
#[test]
fn classify_confirms_a_self_described_vpn_holding_the_proxy_permission() {
let s = signals_of(json!({ "permissions": ["proxy", "storage"] }));
assert!(s.proxy_permission);
assert_eq!(
classify(None, &s, vpn_keyword_hit("Turbo VPN", None)),
Some("confirmed")
);
}
#[test]
fn classify_confirms_proxy_permission_in_mv2() {
// `proxy` is an API permission, so MV3's host_permissions split does not
// move it — the same key works for both manifest versions.
let s = signals_of(json!({
"manifest_version": 2,
"permissions": ["proxy", "<all_urls>", "webRequest"]
}));
assert_eq!(
classify(None, &s, vpn_keyword_hit("Hoxx VPN Proxy", None)),
Some("confirmed")
);
}
#[test]
fn a_download_manager_is_reported_as_a_capability_never_as_a_vpn() {
// The bug this whole split exists for. IDM Integration Module declares
// `proxy` so the desktop binary can replicate the browser's route for a
// handed-off download, and says nothing about VPNs anywhere. Verified
// against the real published manifest.
let verdict = classify_named(
json!({
"permissions": [
"scripting", "tabs", "cookies", "contextMenus", "webNavigation",
"webRequest", "declarativeNetRequest", "downloads", "downloads.shelf",
"downloads.ui", "management", "storage", "proxy", "nativeMessaging"
]
}),
"IDM Integration Module",
Some("Download files with Internet Download Manager"),
);
assert_eq!(verdict, Some("capability"));
}
#[test]
fn a_known_vpn_is_confirmed_from_its_id_alone() {
// Hotspot Shield's name contains no keyword at all, so without the id list
// the biggest VPN in the store would be indistinguishable from a download
// manager.
let s = signals_of(json!({ "permissions": ["proxy"] }));
let id = "nlbejmccbhkncgokjcmghpfloaajcffj";
assert_eq!(
classify(Some(id), &s, vpn_keyword_hit("Hotspot Shield", None)),
Some("confirmed")
);
assert!(signal_labels(Some(id), &s, false).contains(&"knownVpnExtension".to_string()));
}
#[test]
fn the_known_vpn_id_list_is_sorted_and_well_formed() {
// Membership is a binary search, so an unsorted entry is silently missed.
assert!(KNOWN_VPN_EXTENSION_IDS.windows(2).all(|w| w[0] < w[1]));
for id in KNOWN_VPN_EXTENSION_IDS {
assert_eq!(id.len(), 32, "{id} is not a Chrome extension id");
assert!(
id.bytes().all(|b| (b'a'..=b'p').contains(&b)),
"{id} is not a Chrome extension id"
);
}
}
#[test]
fn classify_ignores_content_blocker() {
// The regression guard: a content blocker declares exactly these and is
// not a VPN. Firing here would train users to dismiss the dialog.
let s = signals_of(json!({
"permissions": ["declarativeNetRequest"],
"host_permissions": ["<all_urls>"]
}));
assert!(s.declarative_net_request && s.broad_host_permissions);
assert_eq!(
classify(None, &s, vpn_keyword_hit("uBlock Origin", None)),
None
);
}
#[test]
fn classify_likely_on_dnr_plus_keyword() {
let s = signals_of(json!({
"permissions": ["declarativeNetRequest"],
"host_permissions": ["<all_urls>"]
}));
assert_eq!(
classify(None, &s, vpn_keyword_hit("Free VPN Proxy", None)),
Some("likely")
);
}
#[test]
fn classify_likely_on_optional_proxy_plus_keyword() {
// Optional and ungranted is not a capability, so it only matters when the
// extension also says what it is.
let s = signals_of(json!({ "optional_permissions": ["proxy"] }));
assert_eq!(
classify(None, &s, vpn_keyword_hit("Some VPN", None)),
Some("likely")
);
assert_eq!(
classify(None, &s, vpn_keyword_hit("Request Interceptor", None)),
None
);
}
#[test]
fn classify_ignores_keyword_only() {
// A name alone proves nothing; without a capability signal this is noise.
let s = signals_of(json!({ "permissions": ["storage"] }));
assert_eq!(
classify(None, &s, vpn_keyword_hit("VPN Deals Finder", None)),
None
);
}
#[test]
fn classify_requires_broad_hosts_for_the_blocking_tier() {
let s = signals_of(json!({
"permissions": ["declarativeNetRequest"],
"host_permissions": ["https://example.com/*"]
}));
assert_eq!(classify(None, &s, vpn_keyword_hit("Some VPN", None)), None);
}
#[test]
fn broad_hosts_detected_from_split_http_and_https() {
let s = signals_of(json!({
"permissions": ["webRequest", "webRequestBlocking"],
"host_permissions": ["http://*/*", "https://*/*"]
}));
assert!(s.broad_host_permissions);
assert!(s.web_request_blocking);
}
#[test]
fn broad_hosts_detected_from_mv2_permissions_array() {
// MV2 puts host patterns in `permissions`; the split-out key is absent.
let s = signals_of(json!({
"manifest_version": 2,
"permissions": ["webRequest", "webRequestBlocking", "<all_urls>"]
}));
assert!(s.broad_host_permissions);
assert_eq!(
classify(None, &s, vpn_keyword_hit("Turbo VPN", None)),
Some("likely")
);
}
#[test]
fn keyword_matching_reads_the_name_broadly_and_the_description_narrowly() {
assert!(vpn_keyword_hit("TouchVPN", None));
assert!(vpn_keyword_hit("Unblock Sites", None));
assert!(vpn_keyword_hit("Shadowsocks Client", None));
// Whole-token terms must not match inside longer words. "socks" in a name
// is the protocol often enough to keep; "tussocks" and "tunnelling" are
// exactly why it cannot be a substring.
assert!(vpn_keyword_hit("SOCKS5 Configurator", None));
assert!(!vpn_keyword_hit("Tussocks Field Guide", None));
assert!(!vpn_keyword_hit("Tunnelling Contractors CRM", None));
// A description says "VPN" only when it means one...
assert!(vpn_keyword_hit(
"Anything",
Some("a free VPN for your browser")
));
// ...but these three are ordinary English and must not promote anything.
assert!(!vpn_keyword_hit(
"Requestly",
Some("Modify HTTP requests, no proxy setup required")
));
assert!(!vpn_keyword_hit(
"Stretch Reminder",
Some("Avoid carpal tunnel syndrome while you work")
));
assert!(!vpn_keyword_hit(
"Absolute Right Click",
Some("Unblock right click and text selection on any site")
));
}
#[test]
fn message_placeholder_round_trip() {
assert_eq!(
message_placeholder_key("__MSG_appName__").as_deref(),
Some("appName")
);
assert_eq!(message_placeholder_key("Plain Name"), None);
let messages = json!({ "appName": { "message": "Nord VPN" } });
assert_eq!(
lookup_message(&messages, "appName").as_deref(),
Some("Nord VPN")
);
// Chromium compares message keys case-insensitively.
assert_eq!(
lookup_message(&messages, "APPNAME").as_deref(),
Some("Nord VPN")
);
}
#[test]
fn version_dirs_sort_numerically_not_lexicographically() {
let mut dirs = ["1.9.0_0", "1.10.0_0", "1.2.0_0"];
dirs.sort_by_key(|d| version_dir_sort_key(d));
assert_eq!(dirs.last(), Some(&"1.10.0_0"));
}
#[test]
fn malformed_manifest_yields_no_signals() {
// Arrays of non-strings, wrong types, and missing keys must not panic.
let s = signals_of(json!({ "permissions": [1, 2, {"a": "b"}], "host_permissions": "nope" }));
assert_eq!(s, ManifestSignals::default());
assert_eq!(classify(None, &s, true), None);
}
}