refactor: update logic and locks around vpn extensions

This commit is contained in:
zhom
2026-08-08 19:27:39 +04:00
parent 70a8deb7eb
commit f8532be8af
25 changed files with 885 additions and 299 deletions
+16 -18
View File
@@ -264,23 +264,16 @@ pub async fn enforce_fingerprint_gate(
return Ok(());
}
// Only now is the extension scan worth its disk walk. A confirmed
// proxy-permission extension can redirect the browser's traffic away from the
// upstream we just measured, so the measurement describes an exit the browser
// may not take. Report it, but do not hard-block on a number known to be
// unreliable.
let measurement_unreliable =
vpn_extension_detect::has_confirmed(&vpn_extension_detect::scan_profile(profile));
if matches!(gate, FingerprintGate::Advisory) || measurement_unreliable {
// Automation is the only caller allowed past a measured mismatch, because it
// has no dialog to answer. A proxy-capable extension in the profile does NOT
// earn the same pass: it makes the measurement less trustworthy, and a route
// that might be worse than measured is a reason for more scrutiny, not less.
// Waiving the block on it also meant any download manager holding Chromium's
// `proxy` permission silently disarmed the gate for good.
if matches!(gate, FingerprintGate::Advisory) {
log::warn!(
"Fingerprint gate: {} launching with a {} exit mismatch ({})",
"Fingerprint gate: {} launching with a known exit mismatch ({})",
profile.name,
if measurement_unreliable {
"unverifiable"
} else {
"known"
},
result.mismatches.join(", ")
);
if let Err(e) = crate::events::emit("fingerprint-consistency-warning", &result) {
@@ -304,8 +297,9 @@ pub struct PreLaunchChecks {
/// True when the enforcing gate will still probe during the launch, so the
/// UI can say the check is not finished rather than implying it passed.
pub exit_probe_pending: bool,
/// A confirmed proxy-permission extension is present, so any exit
/// measurement describes a route the browser may not take.
/// An extension holding the `proxy` permission is present, so any exit
/// measurement describes a route the browser may not take. Informational
/// only — it never relaxes the block.
pub exit_measurement_unreliable: bool,
/// Present only when a cached mismatch is already blocking, so "launch
/// anyway" can proceed without a second round trip.
@@ -325,6 +319,10 @@ fn load_profile(profile_id: &str) -> Result<BrowserProfile, String> {
pub async fn get_profile_pre_launch_checks(profile_id: String) -> Result<PreLaunchChecks, String> {
let profile = load_profile(&profile_id)?;
// The setting suppresses the extension report entirely, which is safe
// precisely because nothing enforcing depends on it: the scan feeds the
// dialog's warning and the "measurement may be unreliable" note, never the
// decision to block.
let scan = if extension_warning_disabled() {
vpn_extension_detect::ExtensionScan {
extensions: Vec::new(),
@@ -344,7 +342,7 @@ pub async fn get_profile_pre_launch_checks(profile_id: String) -> Result<PreLaun
})
.cloned()
.collect();
let exit_measurement_unreliable = vpn_extension_detect::has_confirmed(&scan);
let exit_measurement_unreliable = vpn_extension_detect::has_proxy_control(&scan);
let disabled = gate_disabled();
let key = fingerprint_consistency::exit_cache_key(&profile);
+30 -12
View File
@@ -382,34 +382,52 @@ pub fn schedule_pull(app_handle: tauri::AppHandle, profile_id: String) {
});
}
/// Serialises every test that can reach [`STORE`], wherever it lives.
///
/// `remote_session`'s tests drive session transitions through `note_running`
/// and `note_ended`, so they mutate this module's global store too — with the
/// same `p1`/`p2` fixture ids. Two mutexes meant the two groups could interleave
/// and clobber each other, which showed up as an intermittent failure in the
/// suite guarding a data-loss bug.
#[cfg(test)]
pub(crate) static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Take the store lock and start from an empty store. Callers must hold the
/// returned guard for the whole test.
#[cfg(test)]
pub(crate) fn lock_for_test() -> std::sync::MutexGuard<'static, ()> {
let lock = TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*STORE
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Store::new());
lock
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
/// Serialises the tests.
///
/// `TEST_DATA_DIR` is thread-local but [`STORE`] is process-global, so two
/// tests running at once would share one store while pointing at different
/// directories. That fails intermittently, which is the worst way for a test
/// guarding a data-loss bug to fail.
static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Point the store at a scratch directory and start it empty.
///
/// Everything returned must outlive the test body: dropping the guard
/// restores the real data directory, and a test that let it drop early would
/// write a gate file into the developer's own app data.
/// write a gate file into the developer's own app data. `TEST_DATA_DIR` is
/// thread-local but [`STORE`] is process-global, so [`lock_for_test`] is what
/// keeps two tests from sharing one store while pointing at different
/// directories.
fn isolated() -> (
tempfile::TempDir,
crate::app_dirs::TestDirGuard,
std::sync::MutexGuard<'static, ()>,
) {
let lock = TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let lock = lock_for_test();
let dir = tempfile::TempDir::new().expect("a scratch directory");
let guard = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf());
// Re-taken after the data dir is redirected, so nothing loads from the
// real one.
*STORE
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Store::new());
+8
View File
@@ -1569,6 +1569,14 @@ mod tests {
let _guard = INDEX_TESTS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
// Applying a session transition also drives `remote_handoff`: it mutates
// that module's process-global store and persists the launch gate to the
// data directory. Its lock keeps the two test groups from clobbering each
// other's `p1`/`p2` fixtures, and the scratch directory keeps the gate file
// out of the developer's own app data.
let _handoff = crate::remote_handoff::lock_for_test();
let dir = tempfile::TempDir::new().expect("a scratch directory");
let _data_dir = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf());
with_index(|map| map.clear());
with_endpoints(|map| map.clear());
INDEX_AUTHORITATIVE.store(false, Ordering::SeqCst);
@@ -10,8 +10,8 @@ use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use super::rules::{
classify, keyword_hit, lookup_message, manifest_str, message_placeholder_key, signal_labels,
signals_from_manifest, version_dir_sort_key, DetectedVpnExtension,
classify, lookup_message, manifest_str, message_placeholder_key, signal_labels,
signals_from_manifest, version_dir_sort_key, vpn_keyword_hit, DetectedVpnExtension,
};
/// Upper bound on extension directories walked per profile. A launch must not
@@ -262,8 +262,8 @@ fn detect_in_version_dir(crx_id: &str, version_dir: &Path) -> Option<DetectedVpn
});
let signals = signals_from_manifest(&manifest);
let keyword = keyword_hit(&name, description.as_deref());
let confidence = classify(&signals, keyword)?;
let keyword = vpn_keyword_hit(&name, description.as_deref());
let confidence = classify(Some(crx_id), &signals, keyword)?;
Some(DetectedVpnExtension {
key: format!("crx:{crx_id}"),
@@ -271,7 +271,8 @@ fn detect_in_version_dir(crx_id: &str, version_dir: &Path) -> Option<DetectedVpn
version: manifest_str(&manifest, "version"),
source: "browser".to_string(),
confidence: confidence.to_string(),
signals: signal_labels(&signals, keyword),
proxy_control: signals.proxy_permission,
signals: signal_labels(Some(crx_id), &signals, keyword),
})
}
@@ -489,6 +490,50 @@ mod tests {
assert_eq!(out[0].name, CRX_ID, "never show a raw __MSG_ placeholder");
}
#[test]
fn scan_reports_a_proxy_holding_download_manager_as_a_capability() {
// End-to-end shape of the false positive that prompted the audit: the
// extension must still be surfaced (it really can change the proxy) but
// never as a VPN.
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
&root
.join("Default")
.join("Extensions")
.join("ngpampappnmepgilojfohadhhmbhlaek")
.join("6.43.1_0")
.join("manifest.json"),
r#"{"name":"IDM Integration Module","version":"6.43.1","description":"Download files with Internet Download Manager","permissions":["downloads","storage","proxy","nativeMessaging"]}"#,
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert_eq!(out.len(), 1);
assert_eq!(out[0].confidence, "capability");
assert!(out[0].proxy_control);
}
#[test]
fn scan_confirms_a_known_vpn_whose_name_gives_nothing_away() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
&root
.join("Default")
.join("Extensions")
.join("nlbejmccbhkncgokjcmghpfloaajcffj")
.join("10.0.0_0")
.join("manifest.json"),
r#"{"name":"Hotspot Shield","version":"10.0.0","permissions":["proxy"]}"#,
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert_eq!(out.len(), 1);
assert_eq!(out[0].confidence, "confirmed");
}
#[test]
fn scan_ignores_an_ordinary_extension() {
let tmp = tempfile::tempdir().unwrap();
+24 -11
View File
@@ -7,6 +7,11 @@
//! except Donut cannot observe it from the outside — hence a launch-time
//! warning rather than a measurement.
//!
//! That permission is a capability, not an identity. Chromium exposes no
//! read-only variant of it, so a download manager replicating the browser's
//! route for its own transfers declares exactly what a VPN hijacking it
//! declares. The two are reported as different things — see `rules::classify`.
//!
//! Two sources, deliberately both: Donut-managed extensions live in the app's
//! own store and are handed to Chromium via `--load-extension` from *outside*
//! the profile directory, while extensions the user installed from the Web
@@ -17,7 +22,7 @@ mod rules;
// `message_placeholder_key`/`lookup_message` are shared with
// `extension_manager`, which resolves the same placeholders out of a zip.
use rules::{classify, keyword_hit, manifest_str, signal_labels, signals_from_manifest};
use rules::{classify, manifest_str, signal_labels, signals_from_manifest, vpn_keyword_hit};
pub use rules::{lookup_message, message_placeholder_key, DetectedVpnExtension};
use serde::{Deserialize, Serialize};
@@ -90,8 +95,11 @@ fn scan_donut_extensions(profile: &BrowserProfile, out: &mut Vec<DetectedVpnExte
});
let signals = signals_from_manifest(&manifest);
let keyword = keyword_hit(&name, description.as_deref());
let Some(confidence) = classify(&signals, keyword) else {
let keyword = vpn_keyword_hit(&name, description.as_deref());
// A Donut-managed extension is stored under Donut's own uuid, not the Web
// Store id the known-VPN list is keyed on, so it is classified on what its
// manifest says about itself.
let Some(confidence) = classify(None, &signals, keyword) else {
continue;
};
@@ -101,7 +109,8 @@ fn scan_donut_extensions(profile: &BrowserProfile, out: &mut Vec<DetectedVpnExte
version: manifest_str(&manifest, "version").or_else(|| ext.version.clone()),
source: "donut".to_string(),
confidence: confidence.to_string(),
signals: signal_labels(&signals, keyword),
proxy_control: signals.proxy_permission,
signals: signal_labels(None, &signals, keyword),
});
}
}
@@ -144,9 +153,8 @@ pub fn scan_profile(profile: &BrowserProfile) -> ExtensionScan {
// Collapse only exact duplicates of the same extension. `key` is the real
// identity (`donut:<uuid>` / `crx:<id>`); name+version is not, and two
// distinct extensions sharing a display name would silently fold into one
// dropping a `confirmed` detection would then flip `has_confirmed()` and stop
// the gate treating its own exit measurement as unreliable.
// distinct extensions sharing a display name would silently fold into one,
// hiding a real detection behind an unrelated namesake.
let mut seen = HashSet::new();
extensions.retain(|e| seen.insert(e.key.clone()));
@@ -156,8 +164,13 @@ pub fn scan_profile(profile: &BrowserProfile) -> ExtensionScan {
}
}
/// True when at least one detection is `confirmed` — the extension holds the
/// `proxy` permission and can actually redirect the browser's traffic.
pub fn has_confirmed(scan: &ExtensionScan) -> bool {
scan.extensions.iter().any(|e| e.confidence == "confirmed")
/// True when at least one extension holds the `proxy` permission outright, so
/// it can redirect the browser's traffic without asking for anything further.
///
/// Informational: it tells the user an exit measurement may describe a route
/// the browser will not take. It deliberately does not relax the gate — a
/// measurement that might be wrong is a reason for more scrutiny, not less,
/// and this signal is true for every download manager on the machine.
pub fn has_proxy_control(scan: &ExtensionScan) -> bool {
scan.extensions.iter().any(|e| e.proxy_control)
}
+260 -66
View File
@@ -8,21 +8,67 @@
use serde::{Deserialize, Serialize};
/// Substrings that corroborate a request-blocking extension being a VPN.
/// Matched case-insensitively against name + description.
const KEYWORDS: &[&str] = &[
"vpn",
"proxy",
"tunnel",
"unblock",
"wireguard",
"shadowsocks",
"socks",
/// 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
];
/// Matched as a whole token rather than a substring — too short to be safe
/// inside other words ("warped", "warpaint").
const TOKEN_KEYWORDS: &[&str] = &["warp"];
/// 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 {
@@ -32,8 +78,14 @@ pub struct DetectedVpnExtension {
pub version: Option<String>,
/// `"donut"` (managed by Donut) or `"browser"` (installed inside the profile).
pub source: String,
/// `"confirmed"` or `"likely"`.
/// `"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>,
}
@@ -90,50 +142,93 @@ pub fn signals_from_manifest(manifest: &serde_json::Value) -> ManifestSignals {
}
}
pub fn keyword_hit(name: &str, description: Option<&str>) -> bool {
let mut haystack = name.to_lowercase();
if let Some(d) = description {
haystack.push(' ');
haystack.push_str(&d.to_lowercase());
}
if KEYWORDS.iter().any(|k| haystack.contains(k)) {
return true;
}
haystack
.split(|c: char| !c.is_alphanumeric())
.any(|token| TOKEN_KEYWORDS.contains(&token))
/// 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()
}
/// Classify an extension from its manifest signals.
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 `proxy` permission is the only signal that *proves* the capability: it
/// is what Chromium requires to call `chrome.proxy`, and it stays in
/// `permissions` under both manifest versions because it is an API permission,
/// not a host pattern.
/// 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.
///
/// The request-blocking tier additionally requires a keyword, and that
/// corroboration is not optional: `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.
pub fn classify(signals: &ManifestSignals, keyword: bool) -> Option<&'static str> {
if signals.proxy_permission {
/// 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 signals.optional_proxy_permission {
return Some("likely");
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.declarative_net_request || signals.web_request_blocking)
&& signals.broad_host_permissions
&& keyword
{
return Some("likely");
if signals.proxy_permission {
return Some("capability");
}
None
}
pub fn signal_labels(signals: &ManifestSignals, keyword: bool) -> Vec<String> {
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());
}
@@ -201,11 +296,23 @@ mod tests {
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_on_proxy_permission() {
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(&s, false), Some("confirmed"));
assert_eq!(
classify(None, &s, vpn_keyword_hit("Turbo VPN", None)),
Some("confirmed")
);
}
#[test]
@@ -216,13 +323,57 @@ mod tests {
"manifest_version": 2,
"permissions": ["proxy", "<all_urls>", "webRequest"]
}));
assert_eq!(classify(&s, false), Some("confirmed"));
assert_eq!(
classify(None, &s, vpn_keyword_hit("Hoxx VPN Proxy", None)),
Some("confirmed")
);
}
#[test]
fn classify_likely_on_optional_proxy() {
let s = signals_of(json!({ "optional_permissions": ["proxy"] }));
assert_eq!(classify(&s, false), Some("likely"));
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]
@@ -234,7 +385,10 @@ mod tests {
"host_permissions": ["<all_urls>"]
}));
assert!(s.declarative_net_request && s.broad_host_permissions);
assert_eq!(classify(&s, keyword_hit("uBlock Origin", None)), None);
assert_eq!(
classify(None, &s, vpn_keyword_hit("uBlock Origin", None)),
None
);
}
#[test]
@@ -244,16 +398,34 @@ mod tests {
"host_permissions": ["<all_urls>"]
}));
assert_eq!(
classify(&s, keyword_hit("Free VPN Proxy", None)),
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(&s, keyword_hit("VPN Deals Finder", None)), None);
assert_eq!(
classify(None, &s, vpn_keyword_hit("VPN Deals Finder", None)),
None
);
}
#[test]
@@ -262,7 +434,7 @@ mod tests {
"permissions": ["declarativeNetRequest"],
"host_permissions": ["https://example.com/*"]
}));
assert_eq!(classify(&s, keyword_hit("Some VPN", None)), None);
assert_eq!(classify(None, &s, vpn_keyword_hit("Some VPN", None)), None);
}
#[test]
@@ -283,19 +455,41 @@ mod tests {
"permissions": ["webRequest", "webRequestBlocking", "<all_urls>"]
}));
assert!(s.broad_host_permissions);
assert_eq!(classify(&s, keyword_hit("Turbo VPN", None)), Some("likely"));
assert_eq!(
classify(None, &s, vpn_keyword_hit("Turbo VPN", None)),
Some("likely")
);
}
#[test]
fn keyword_matching_is_substring_but_token_bound_for_short_terms() {
assert!(keyword_hit("TouchVPN", None));
assert!(keyword_hit("Unblock Sites", None));
assert!(keyword_hit("Cloudflare WARP", None));
// "warp" only matches as a whole token, so this must not hit.
assert!(!keyword_hit("Time Warped Clock", None));
assert!(keyword_hit(
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 fast tunnel for your browser")
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")
));
}
@@ -330,6 +524,6 @@ mod tests {
// 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(&s, true), None);
assert_eq!(classify(None, &s, true), None);
}
}