mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-09 20:54:28 +02:00
refactor: update logic and locks around vpn extensions
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user