From f8532be8afb6afca7b90a0b566a893e402c99c3a Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:27:01 +0400 Subject: [PATCH] refactor: update logic and locks around vpn extensions --- _typos.toml | 4 + e2e/app/Cargo.lock | 2 +- e2e/tests/browser.test.mjs | 84 ++++- ....0.8.patch => brace-expansion@5.0.9.patch} | 15 +- pnpm-lock.yaml | 57 +-- pnpm-workspace.yaml | 11 +- src-tauri/src/launch_gate.rs | 34 +- src-tauri/src/remote_handoff.rs | 42 ++- src-tauri/src/remote_session.rs | 8 + .../src/vpn_extension_detect/browser_scan.rs | 55 ++- src-tauri/src/vpn_extension_detect/mod.rs | 35 +- src-tauri/src/vpn_extension_detect/rules.rs | 326 ++++++++++++++---- src/app/page.tsx | 90 +++-- src/components/pre-launch-gate-dialog.tsx | 247 ++++++++++--- src/i18n/locales/en.json | 15 +- src/i18n/locales/es.json | 15 +- src/i18n/locales/fr.json | 15 +- src/i18n/locales/ja.json | 15 +- src/i18n/locales/ko.json | 15 +- src/i18n/locales/pt.json | 15 +- src/i18n/locales/ru.json | 15 +- src/i18n/locales/tr.json | 15 +- src/i18n/locales/vi.json | 15 +- src/i18n/locales/zh.json | 15 +- src/types.ts | 24 +- 25 files changed, 885 insertions(+), 299 deletions(-) rename patches/{brace-expansion@5.0.8.patch => brace-expansion@5.0.9.patch} (65%) diff --git a/_typos.toml b/_typos.toml index 0e1e5e0..2b5f207 100644 --- a/_typos.toml +++ b/_typos.toml @@ -12,3 +12,7 @@ extend-exclude = [ [default.extend-words] DBE = "DBE" nd = "nd" + +[default.extend-identifiers] +# Chrome Web Store extension name in the known-VPN list. +VeePN = "VeePN" diff --git a/e2e/app/Cargo.lock b/e2e/app/Cargo.lock index a4197c3..e999141 100644 --- a/e2e/app/Cargo.lock +++ b/e2e/app/Cargo.lock @@ -1785,7 +1785,7 @@ dependencies = [ [[package]] name = "donutbrowser" -version = "0.28.2" +version = "0.29.0" dependencies = [ "aes 0.9.1", "aes-gcm 0.11.0", diff --git a/e2e/tests/browser.test.mjs b/e2e/tests/browser.test.mjs index ac4d81e..7d23126 100644 --- a/e2e/tests/browser.test.mjs +++ b/e2e/tests/browser.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; -import { readdir, readFile, stat } from "node:fs/promises"; +import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -270,6 +270,78 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc "a consent token is only minted when a cached mismatch is blocking", ); + // Extension detection, against manifests written where Chromium puts + // them. The three cases are the whole point of the classifier: a real VPN + // is named as one, a known VPN with an unrevealing name is caught by its + // id, and a download manager holding the same `proxy` permission is + // reported as a capability and never as a VPN. + // `DONUTBROWSER_DATA_ROOT` puts the data dir at /data, so this + // is app_dirs::profiles_dir() plus the layout Chromium itself uses. + const extensionsDir = path.join( + app.dataRoot, + "data", + "profiles", + profile.id, + "profile", + "Default", + "Extensions", + ); + const seedExtension = async (id, version, manifest) => { + const dir = path.join(extensionsDir, id, `${version}_0`); + await mkdir(dir, { recursive: true }); + await writeFile( + path.join(dir, "manifest.json"), + JSON.stringify(manifest), + ); + }; + const IDM_ID = "ngpampappnmepgilojfohadhhmbhlaek"; + const HOTSPOT_SHIELD_ID = "nlbejmccbhkncgokjcmghpfloaajcffj"; + const NAMED_VPN_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + await seedExtension(IDM_ID, "6.43.1", { + name: "IDM Integration Module", + version: "6.43.1", + description: "Download files with Internet Download Manager", + permissions: ["downloads", "storage", "proxy", "nativeMessaging"], + }); + await seedExtension(HOTSPOT_SHIELD_ID, "10.0.0", { + name: "Hotspot Shield", + version: "10.0.0", + permissions: ["proxy"], + }); + await seedExtension(NAMED_VPN_ID, "1.0.0", { + name: "Turbo VPN Free", + version: "1.0.0", + permissions: ["proxy"], + }); + + const withExtensions = await app.invoke("get_profile_pre_launch_checks", { + profileId: profile.id, + }); + const detected = new Map( + withExtensions.vpn_extensions.map((item) => [item.key, item]), + ); + assert.equal(detected.size, 3, "every seeded extension must be reported"); + assert.equal(detected.get(`crx:${NAMED_VPN_ID}`).confidence, "confirmed"); + assert.equal( + detected.get(`crx:${HOTSPOT_SHIELD_ID}`).confidence, + "confirmed", + "a known VPN id must be named even when its name gives nothing away", + ); + assert.equal( + detected.get(`crx:${IDM_ID}`).confidence, + "capability", + "a download manager holding the proxy permission is not a VPN", + ); + assert.ok( + detected.get(`crx:${IDM_ID}`).proxy_control, + "it does still hold the permission, which is why it is listed at all", + ); + assert.equal( + withExtensions.exit_measurement_unreliable, + true, + "a proxy-capable extension makes the exit measurement a caveat", + ); + // Acknowledgements are per-profile and must be accepted for both kinds. await app.invoke("ack_launch_gate", { profileId: profile.id, @@ -279,8 +351,16 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc await app.invoke("ack_launch_gate", { profileId: profile.id, ackFingerprint: true, - ackExtensionKeys: [], + ackExtensionKeys: [`crx:${IDM_ID}`], }); + const afterAck = await app.invoke("get_profile_pre_launch_checks", { + profileId: profile.id, + }); + assert.deepEqual( + afterAck.vpn_extensions.map((item) => item.key).sort(), + [`crx:${HOTSPOT_SHIELD_ID}`, `crx:${NAMED_VPN_ID}`].sort(), + "an acknowledged extension stops being reported, the others do not", + ); assert.match( await app.invokeError("get_profile_pre_launch_checks", { profileId: "00000000-0000-0000-0000-000000000000", diff --git a/patches/brace-expansion@5.0.8.patch b/patches/brace-expansion@5.0.9.patch similarity index 65% rename from patches/brace-expansion@5.0.8.patch rename to patches/brace-expansion@5.0.9.patch index 9c7f4ec..f1d0c11 100644 --- a/patches/brace-expansion@5.0.8.patch +++ b/patches/brace-expansion@5.0.9.patch @@ -1,5 +1,5 @@ diff --git a/dist/commonjs/index.d.ts b/dist/commonjs/index.d.ts -index f3e2de9d87e1ce462517e49f35733bed8bdf85af..6c84d87835182d0670981dc15f488c2a7d061c98 100644 +index f3e2de9d87e1ce462517e49f35733bed8bdf85af..7a19917a209b84b30938957ea67d4ad60dd748c5 100644 --- a/dist/commonjs/index.d.ts +++ b/dist/commonjs/index.d.ts @@ -5,4 +5,5 @@ export type BraceExpansionOptions = { @@ -8,18 +8,20 @@ index f3e2de9d87e1ce462517e49f35733bed8bdf85af..6c84d87835182d0670981dc15f488c2a export declare function expand(str: string, options?: BraceExpansionOptions): string[]; +export default expand; //# sourceMappingURL=index.d.ts.map +\ No newline at end of file diff --git a/dist/commonjs/index.js b/dist/commonjs/index.js -index be9df86be09c7655787a65c55ae6da01858894c3..071ad97532f30155cb56d7f2662fa99122ca628a 100644 +index 869a6bee23807b9f01c18c99ab8e952b4b242f97..cd8fa65b1a1521aa0b27b3e661797763b8365138 100644 --- a/dist/commonjs/index.js +++ b/dist/commonjs/index.js -@@ -260,4 +260,5 @@ function expand_(str, max, maxLength, isTop) { +@@ -286,4 +286,5 @@ function expand_(str, max, maxLength, isTop) { } return acc; } +module.exports = Object.assign(expand, exports); //# sourceMappingURL=index.js.map +\ No newline at end of file diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts -index f3e2de9d87e1ce462517e49f35733bed8bdf85af..6c84d87835182d0670981dc15f488c2a7d061c98 100644 +index f3e2de9d87e1ce462517e49f35733bed8bdf85af..7a19917a209b84b30938957ea67d4ad60dd748c5 100644 --- a/dist/esm/index.d.ts +++ b/dist/esm/index.d.ts @@ -5,4 +5,5 @@ export type BraceExpansionOptions = { @@ -28,11 +30,12 @@ index f3e2de9d87e1ce462517e49f35733bed8bdf85af..6c84d87835182d0670981dc15f488c2a export declare function expand(str: string, options?: BraceExpansionOptions): string[]; +export default expand; //# sourceMappingURL=index.d.ts.map +\ No newline at end of file diff --git a/dist/esm/index.js b/dist/esm/index.js -index 6dc0392fc0feedb811e63d70a30be2736af17a3a..81ea182fa5dbc3c60fa4cec4cb549fa256a903e4 100644 +index fd68f57029207ac1bcafe7fb1c14ad5305b3ffa4..f3ef09ac8ad02d3fde8150e7f64f40ac874a47e3 100644 --- a/dist/esm/index.js +++ b/dist/esm/index.js -@@ -256,4 +256,5 @@ function expand_(str, max, maxLength, isTop) { +@@ -282,4 +282,5 @@ function expand_(str, max, maxLength, isTop) { } return acc; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83db260..eeda645 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,21 +9,22 @@ overrides: path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0' postcss@<8.5.18: '>=8.5.18' fast-xml-parser@<5.7.0: '>=5.7.2' - fast-uri@<3.1.2: '>=3.1.2 <4' + fast-uri@<3.1.5: '>=3.1.5 <4' fast-xml-builder@<1.2.0: '>=1.2.0' qs@>=6.11.1 <6.15.2: '>=6.15.2' js-cookie@<3.0.7: '>=3.0.7' + nanoid@<3.3.17: '>=3.3.17 <4' fast-uri@>=4.0.0 <4.1.1: '>=4.1.1 <5' multer@>=2.0.0 <2.2.0: '>=2.2.0' form-data@>=4.0.0 <4.0.6: '>=4.0.6' - js-yaml@<3.15.0: '>=3.15.0 <4' - js-yaml@>=4.0.0 <4.3.0: '>=4.3.0 <5' + js-yaml@<3.15.1: '>=3.15.1 <4' + js-yaml@>=4.0.0 <4.3.1: '>=4.3.1 <5' '@babel/core@<7.29.6': '>=7.29.6 <8' - brace-expansion@<5.0.8: 5.0.8 + brace-expansion@<5.0.9: 5.0.9 sharp@<0.35.0: '>=0.35.0 <0.36' patchedDependencies: - brace-expansion@5.0.8: 6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e + brace-expansion@5.0.9: bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208 importers: @@ -2852,8 +2853,8 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - brace-expansion@5.0.8: - resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} browserslist@4.28.4: @@ -3340,8 +3341,8 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@3.1.4: - resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} @@ -3773,12 +3774,12 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.15.0: - resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + js-yaml@3.15.1: + resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==} hasBin: true - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true jsesc@3.1.0: @@ -4093,8 +4094,8 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.17: + resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -6040,7 +6041,7 @@ snapshots: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 3.15.0 + js-yaml: 3.15.1 resolve-from: 5.0.0 '@istanbuljs/schema@0.1.6': {} @@ -7857,14 +7858,14 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.4 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.4 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -8038,7 +8039,7 @@ snapshots: bowser@2.14.1: {} - brace-expansion@5.0.8(patch_hash=6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e): + brace-expansion@5.0.9(patch_hash=bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208): dependencies: balanced-match: 4.0.4 @@ -8232,7 +8233,7 @@ snapshots: cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.1 - js-yaml: 4.3.0 + js-yaml: 4.3.1 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: @@ -8480,7 +8481,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - fast-uri@3.1.4: {} + fast-uri@3.1.5: {} fb-watchman@2.0.2: dependencies: @@ -9102,12 +9103,12 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.15.0: + js-yaml@3.15.1: dependencies: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.3.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -9332,15 +9333,15 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.8(patch_hash=6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e) + brace-expansion: 5.0.9(patch_hash=bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208) minimatch@3.1.5: dependencies: - brace-expansion: 5.0.8(patch_hash=6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e) + brace-expansion: 5.0.9(patch_hash=bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208) minimatch@9.0.9: dependencies: - brace-expansion: 5.0.8(patch_hash=6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e) + brace-expansion: 5.0.9(patch_hash=bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208) minimist@1.2.8: {} @@ -9371,7 +9372,7 @@ snapshots: mute-stream@2.0.0: {} - nanoid@3.3.16: {} + nanoid@3.3.17: {} napi-postinstall@0.3.4: {} @@ -9535,7 +9536,7 @@ snapshots: postcss@8.5.23: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index daf1a7c..744ba7b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,17 +24,18 @@ overrides: path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0' postcss@<8.5.18: '>=8.5.18' fast-xml-parser@<5.7.0: '>=5.7.2' - fast-uri@<3.1.2: '>=3.1.2 <4' + fast-uri@<3.1.5: '>=3.1.5 <4' fast-xml-builder@<1.2.0: '>=1.2.0' qs@>=6.11.1 <6.15.2: '>=6.15.2' js-cookie@<3.0.7: '>=3.0.7' + nanoid@<3.3.17: '>=3.3.17 <4' fast-uri@>=4.0.0 <4.1.1: '>=4.1.1 <5' multer@>=2.0.0 <2.2.0: '>=2.2.0' form-data@>=4.0.0 <4.0.6: '>=4.0.6' - js-yaml@<3.15.0: '>=3.15.0 <4' - js-yaml@>=4.0.0 <4.3.0: '>=4.3.0 <5' + js-yaml@<3.15.1: '>=3.15.1 <4' + js-yaml@>=4.0.0 <4.3.1: '>=4.3.1 <5' '@babel/core@<7.29.6': '>=7.29.6 <8' - brace-expansion@<5.0.8: 5.0.8 + brace-expansion@<5.0.9: 5.0.9 sharp@<0.35.0: '>=0.35.0 <0.36' allowBuilds: @@ -97,4 +98,4 @@ minimumReleaseAgeExclude: - '@aws-sdk/token-providers@3.1081.0' patchedDependencies: - brace-expansion@5.0.8: patches/brace-expansion@5.0.8.patch + brace-expansion@5.0.9: patches/brace-expansion@5.0.9.patch diff --git a/src-tauri/src/launch_gate.rs b/src-tauri/src/launch_gate.rs index e637b7c..014f8df 100644 --- a/src-tauri/src/launch_gate.rs +++ b/src-tauri/src/launch_gate.rs @@ -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 { pub async fn get_profile_pre_launch_checks(profile_id: String) -> Result { 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 = 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()); diff --git a/src-tauri/src/remote_session.rs b/src-tauri/src/remote_session.rs index fae1ed6..fc8c36e 100644 --- a/src-tauri/src/remote_session.rs +++ b/src-tauri/src/remote_session.rs @@ -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); diff --git a/src-tauri/src/vpn_extension_detect/browser_scan.rs b/src-tauri/src/vpn_extension_detect/browser_scan.rs index 2a6223c..0691591 100644 --- a/src-tauri/src/vpn_extension_detect/browser_scan.rs +++ b/src-tauri/src/vpn_extension_detect/browser_scan.rs @@ -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 Option ExtensionScan { // Collapse only exact duplicates of the same extension. `key` is the real // identity (`donut:` / `crx:`); 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) } diff --git a/src-tauri/src/vpn_extension_detect/rules.rs b/src-tauri/src/vpn_extension_detect/rules.rs index 195930c..1fd1eca 100644 --- a/src-tauri/src/vpn_extension_detect/rules.rs +++ b/src-tauri/src/vpn_extension_detect/rules.rs @@ -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, /// `"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, } @@ -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 `` -/// 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 `` 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 { +pub fn signal_labels( + extension_id: Option<&str>, + signals: &ManifestSignals, + keyword: bool, +) -> Vec { 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", "", "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": [""] })); 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": [""] })); 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", ""] })); 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); } } diff --git a/src/app/page.tsx b/src/app/page.tsx index f16f5c5..50c6168 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -409,9 +409,19 @@ export default function Home() { // a bulk run enqueues one per profile, and every waiter must settle or the // Promise.allSettled below it never resolves and the bulk spinner sticks. const gateQueueRef = useRef< - Array<{ req: GateRequest; resolve: (decision: GateDecision) => void }> + Array<{ + id: number; + req: GateRequest; + /// The bulk run this request belongs to, or undefined for a single + /// launch. Carried per entry so a blanket "apply to the rest" can only + /// ever claim the run its own dialog came from. + runId: number | undefined; + resolve: (decision: GateDecision) => void; + }> >([]); + const gateRequestSeqRef = useRef(0); const [gateState, setGateState] = useState<{ + id: number; req: GateRequest; remaining: number; } | null>(null); @@ -993,19 +1003,15 @@ export default function Home() { [selectedGroupId, t], ); - // Show the queue's head, and how many are waiting behind it. - // The backend gate downgrades to advisory rather than blocking when it - // cannot trust its own measurement (a confirmed VPN extension can reroute - // traffic away from the proxy it just probed), and for unattended launches. - // Without a listener that finding was emitted into the void. + // Unattended launches — REST and MCP automation — are the only ones the + // backend gate lets past a measured mismatch, because there is no dialog for + // them to answer. Without a listener that finding was emitted into the void. useEffect(() => { const unlisten = listen( "fingerprint-consistency-warning", (event) => { const { exit_timezone, fingerprint_timezone } = event.payload; showErrorToast(t("backendErrors.fingerprintExitMismatch"), { - // The cause differs by path (an unverifiable measurement vs an - // unattended launch), so state the measurement rather than guess. description: exit_timezone && fingerprint_timezone ? t("consistencyWarning.timezoneDetail", { @@ -1024,11 +1030,12 @@ export default function Home() { }; }, [t]); + // Show the queue's head, and how many are waiting behind it. const syncGateUi = useCallback(() => { const queue = gateQueueRef.current; setGateState( queue.length > 0 - ? { req: queue[0].req, remaining: queue.length - 1 } + ? { id: queue[0].id, req: queue[0].req, remaining: queue.length - 1 } : null, ); }, []); @@ -1053,7 +1060,13 @@ export default function Home() { }); } return new Promise((resolve) => { - gateQueueRef.current.push({ req, resolve }); + gateRequestSeqRef.current += 1; + gateQueueRef.current.push({ + id: gateRequestSeqRef.current, + req, + runId, + resolve, + }); syncGateUi(); }); }, @@ -1063,24 +1076,37 @@ export default function Home() { const settleGate = useCallback( (decision: GateDecision) => { const entry = gateQueueRef.current.shift(); - entry?.resolve(decision); + if (!entry) { + return; + } + entry.resolve(decision); if (decision.applyToRemaining) { - const coversBlocking = entry?.req.findings.fingerprint !== null; - blanketGateDecisionRef.current = { - decision, - coversBlocking, - runId: bulkRunIdRef.current, - }; + const coversBlocking = entry.req.findings.fingerprint !== null; + // Only a bulk run gets a standing blanket, and it claims the run the + // answered dialog belonged to — never whichever run happens to be in + // flight when the dialog is settled. Outside a run there is nothing to + // scope one to, and a session-wide blanket would silently answer + // unrelated launches later. The queue is still drained either way, + // which is what the checkbox actually promises. + if (entry.runId !== undefined) { + blanketGateDecisionRef.current = { + decision, + coversBlocking, + runId: entry.runId, + }; + } // Drain the queue rather than leaving promises pending forever — but // only those the blanket actually covers. A hard block still deserves - // its own dialog even after the user blanket-approved a warning. + // its own dialog even after the user blanket-approved a warning, and a + // launch started outside this run was never part of the answer. const remaining = gateQueueRef.current.splice(0); - const kept = remaining.filter( - (queued) => - !coversBlocking && queued.req.findings.fingerprint !== null, - ); + const kept = []; for (const queued of remaining) { - if (kept.includes(queued)) { + const covered = + queued.runId === entry.runId && + (coversBlocking || queued.req.findings.fingerprint === null); + if (!covered) { + kept.push(queued); continue; } queued.resolve({ @@ -1167,6 +1193,12 @@ export default function Home() { // verdict. No network, no worker started, so a profile whose exit is // already known blocks before the launch touches anything. let consentToken: string | null = null; + // Kept for the tier-2 dialog below: the extensions are the same ones, + // and a mismatch measured mid-launch is exactly when knowing that one of + // them can change the proxy matters most. Minus anything the user just + // acknowledged, so a box they ticked seconds ago is not shown again. + let localChecks: PreLaunchChecks | null = null; + let ackedExtensionKeys: string[] = []; try { // One-shot migration of the old per-profile "don't warn again" flag, // so a user who already dismissed this profile isn't hard-blocked by @@ -1188,6 +1220,7 @@ export default function Home() { "get_profile_pre_launch_checks", { profileId: profile.id }, ); + localChecks = checks; const blocked = checks.consistency.checked && !checks.consistency.consistent; if (blocked || checks.vpn_extensions.length > 0) { @@ -1208,6 +1241,7 @@ export default function Home() { if (!decision.proceed) { return { status: "cancelled" }; } + ackedExtensionKeys = decision.ackExtensionKeys; consentToken = checks.consent_token; } } catch (err) { @@ -1234,10 +1268,13 @@ export default function Home() { { profile, findings: { - vpnExtensions: [], - scanState: "scanned", + vpnExtensions: (localChecks?.vpn_extensions ?? []).filter( + (ext) => !ackedExtensionKeys.includes(ext.key), + ), + scanState: localChecks?.scan_state ?? "scanned", fingerprint: consistencyFromErrorParams(parsed.params), - measurementUnreliable: false, + measurementUnreliable: + localChecks?.exit_measurement_unreliable ?? false, probePending: false, }, }, @@ -2186,6 +2223,7 @@ export default function Home() { isOpen={gateState !== null} profileName={gateState?.req.profile.name ?? ""} profileId={gateState?.req.profile.id ?? ""} + requestId={gateState?.id ?? 0} findings={gateState?.req.findings ?? null} remainingCount={gateState?.remaining ?? 0} onResult={settleGate} diff --git a/src/components/pre-launch-gate-dialog.tsx b/src/components/pre-launch-gate-dialog.tsx index ae09d97..4092988 100644 --- a/src/components/pre-launch-gate-dialog.tsx +++ b/src/components/pre-launch-gate-dialog.tsx @@ -1,7 +1,7 @@ "use client"; import { invoke } from "@tauri-apps/api/core"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { LuTriangleAlert } from "react-icons/lu"; import { Checkbox } from "@/components/ui/checkbox"; @@ -15,16 +15,21 @@ import { import { Label } from "@/components/ui/label"; import { translateBackendError } from "@/lib/backend-errors"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; -import type { ConsistencyResult, DetectedVpnExtension } from "@/types"; +import type { + ConsistencyResult, + DetectedVpnExtension, + ExtensionScanState, +} from "@/types"; import { RippleButton } from "./ui/ripple"; export interface GateFindings { - /// Extensions that can reroute traffic. A warning: the user may proceed. + /// Extensions that could reroute traffic. A warning: the user may proceed. vpnExtensions: DetectedVpnExtension[]; - scanState: string; + scanState: ExtensionScanState; /// A measured exit/fingerprint mismatch. A block: the browser has not started. fingerprint: ConsistencyResult | null; - /// A confirmed proxy-permission extension makes any exit measurement suspect. + /// An extension holds the proxy permission, so the exit measurement may not + /// describe the route the browser takes. A caveat on the block, not a waiver. measurementUnreliable: boolean; /// The exit has not been measured yet; the launch itself will still check. probePending: boolean; @@ -41,6 +46,9 @@ interface PreLaunchGateDialogProps { isOpen: boolean; profileName: string; profileId: string; + /// Identifies this specific request, so state resets even when one gate + /// replaces another without the dialog ever closing. + requestId: number; findings: GateFindings | null; /// How many further profiles are queued behind this one; >0 offers to apply /// the same decision to all of them. @@ -50,51 +58,155 @@ interface PreLaunchGateDialogProps { onResult: (decision: GateDecision) => void; } +/// Everything the user can change while one gate is on screen, stamped with +/// the gate it belongs to. +interface GateAnswerState { + requestId: number; + ackFingerprint: boolean; + ackExtensions: boolean; + applyToRemaining: boolean; + isMatching: boolean; + decided: boolean; +} + +/// How long after a decision the footer stops accepting another one. Long +/// enough that a double-click cannot answer the gate promoted by its first +/// half, short enough that nobody deliberately answering two queued gates in a +/// row notices it. +const DECISION_COOLDOWN_MS = 500; + +function answersFor(requestId: number): GateAnswerState { + return { + requestId, + ackFingerprint: false, + ackExtensions: false, + applyToRemaining: false, + isMatching: false, + decided: false, + }; +} + +function ExtensionEntry({ extension }: { extension: DetectedVpnExtension }) { + const { t } = useTranslation(); + const capability = t( + extension.confidence === "confirmed" + ? "prelaunchGate.vpnExtensionConfirmed" + : extension.confidence === "likely" + ? "prelaunchGate.vpnExtensionLikely" + : "prelaunchGate.vpnExtensionCapability", + ); + const source = t( + extension.source === "donut" + ? "prelaunchGate.sourceDonut" + : "prelaunchGate.sourceBrowser", + ); + + return ( +
  • + {extension.name} + + {/* A version-less manifest is legal, and interpolating an empty string + into the one template left a doubled space before the dash. */} + {extension.version + ? t("prelaunchGate.vpnExtensionEntry", { + version: extension.version, + capability, + source, + }) + : t("prelaunchGate.vpnExtensionEntryNoVersion", { + capability, + source, + })} + +
  • + ); +} + export function PreLaunchGateDialog({ isOpen, profileName, profileId, + requestId, findings, remainingCount, onResult, }: PreLaunchGateDialogProps) { const { t } = useTranslation(); - const [ackFingerprint, setAckFingerprint] = useState(false); - const [ackExtensions, setAckExtensions] = useState(false); - const [applyToRemaining, setApplyToRemaining] = useState(false); - const [isMatching, setIsMatching] = useState(false); - // The dialog node is reused as the queue advances, so without this a double - // click would decide for the next profile too. - const [decided, setDecided] = useState(false); + // All mutable state is stamped with the request it belongs to, and anything + // stamped with an older request is ignored rather than reset. The dialog + // never unmounts and a queued gate promotes the next profile without ever + // closing it, so state carried across that boundary would tick a checkbox + // for a profile the user never saw — and `decided` carried across it left + // every button disabled on a dialog that also refused Escape, which is the + // freeze this shape exists to make unrepresentable. + // + // Deliberately not an effect keyed on `requestId`: a reset effect whose body + // reads none of its dependencies is exactly what a lint autofix reduces to + // `[]`, and that is how the freeze shipped. + const [state, setState] = useState(() => answersFor(0)); + const answers = state.requestId === requestId ? state : answersFor(requestId); - // Keyed on profileId, not just isOpen: a queued gate promotes the next - // profile without ever closing the dialog, so an isOpen-only reset would - // carry the previous profile's ticked boxes — and persist an acknowledgement - // against a profile the user never saw. + // The gate on screen right now, readable from an async callback whose + // closure was captured while an earlier gate was showing. + const liveRequestRef = useRef(requestId); useEffect(() => { - setAckFingerprint(false); - setAckExtensions(false); - setIsMatching(false); - setDecided(false); - }, []); + liveRequestRef.current = requestId; + }, [requestId]); - useEffect(() => { - if (isOpen) { - setApplyToRemaining(false); + const patch = (next: Partial) => { + // A callback that resumes after its gate was answered must not write into + // the slot the next gate is now using — that would silently untick boxes + // the user has since ticked on a different profile. + if (liveRequestRef.current !== requestId) { + return; } - }, [isOpen]); + setState((prev) => ({ + ...(prev.requestId === requestId ? prev : answersFor(requestId)), + ...next, + requestId, + })); + }; + const { + ackFingerprint, + ackExtensions, + applyToRemaining, + isMatching, + decided, + } = answers; const fingerprint = findings?.fingerprint ?? null; const extensions = findings?.vpnExtensions ?? []; + // Two different claims, kept visually apart. The first names extensions as + // VPN/proxy tools; the second says only that an extension holds Chromium's + // proxy permission, which a download manager needs to route its own + // transfers and which says nothing about what the extension is. + const vpnExtensions = extensions.filter((e) => e.confidence !== "capability"); + const proxyCapableExtensions = extensions.filter( + (e) => e.confidence === "capability", + ); const mismatches = fingerprint?.mismatches ?? []; const exitIp = fingerprint?.exit_ip ?? null; const isBlocked = fingerprint !== null; + // Two guards, because answering a gate promotes the next one into the same + // DOM node rather than closing the dialog. The ref settles one gate exactly + // once even if two clicks land in the same React batch; the cooldown stops + // the second half of a double-click from answering a dialog that appeared + // between the two clicks and that nobody has read. + const decidedRef = useRef(null); + const lastDecisionAtRef = useRef(Number.NEGATIVE_INFINITY); + const decide = (proceed: boolean) => { - if (decided) { + if (decided || decidedRef.current === requestId) { return; } - setDecided(true); + const now = performance.now(); + if (now - lastDecisionAtRef.current < DECISION_COOLDOWN_MS) { + return; + } + decidedRef.current = requestId; + lastDecisionAtRef.current = now; + patch({ decided: true }); onResult({ proceed, ackFingerprint: ackFingerprint && isBlocked, @@ -107,21 +219,29 @@ export function PreLaunchGateDialog({ if (!exitIp) { return; } - setIsMatching(true); + const request = requestId; + patch({ isMatching: true }); try { await invoke("match_profile_fingerprint_to_exit", { profileId, exitIp, }); showSuccessToast(t("consistencyWarning.matchSuccess")); + patch({ isMatching: false }); + // Rewriting the fingerprint takes long enough for the user to dismiss + // this gate meanwhile. The profile change still stands, but the launch + // it belonged to is already settled, and deciding now would answer + // whichever gate took its place. + if (liveRequestRef.current !== request) { + return; + } // The fingerprint the block was measured against no longer exists, so // this launch is abandoned rather than forced through with a stale // consent token; the user relaunches against the corrected profile. decide(false); } catch (e) { showErrorToast(translateBackendError(t, e)); - } finally { - setIsMatching(false); + patch({ isMatching: false }); } }; @@ -141,8 +261,19 @@ export function PreLaunchGateDialog({ })(); return ( - - + // Dismissible on purpose: cancelling is the safe outcome, so every way out + // of this dialog — Escape, the close X, a click outside — resolves the + // waiting launch as "don't start". A gate that can only be answered by two + // buttons is one disabled button away from trapping the whole app. + { + if (!open) { + decide(false); + } + }} + > + @@ -184,7 +315,7 @@ export function PreLaunchGateDialog({ )} - {extensions.length > 0 && ( + {vpnExtensions.length > 0 && (

    {t("prelaunchGate.vpnExtensionHeading")} @@ -193,23 +324,8 @@ export function PreLaunchGateDialog({ {t("prelaunchGate.vpnExtensionIntro")}

      - {extensions.map((ext) => ( -
    • - {ext.name} - - {t("prelaunchGate.vpnExtensionEntry", { - version: ext.version ?? "", - capability: - ext.confidence === "confirmed" - ? t("prelaunchGate.vpnExtensionConfirmed") - : t("prelaunchGate.vpnExtensionLikely"), - source: - ext.source === "donut" - ? t("prelaunchGate.sourceDonut") - : t("prelaunchGate.sourceBrowser"), - })} - -
    • + {vpnExtensions.map((ext) => ( + ))}

    @@ -218,6 +334,22 @@ export function PreLaunchGateDialog({

    )} + {proxyCapableExtensions.length > 0 && ( +
    +

    + {t("prelaunchGate.proxyCapableHeading")} +

    +

    + {t("prelaunchGate.proxyCapableIntro")} +

    +
      + {proxyCapableExtensions.map((ext) => ( + + ))} +
    +
    + )} + {findings?.measurementUnreliable && isBlocked && (

    {t("prelaunchGate.measurementUnreliable")} @@ -240,7 +372,7 @@ export function PreLaunchGateDialog({ setAckFingerprint(v === true)} + onCheckedChange={(v) => patch({ ackFingerprint: v === true })} />