From c36c67379b73e846562d1a3efc305eccecf45263 Mon Sep 17 00:00:00 2001 From: Alexander Wang Date: Tue, 21 Jul 2026 14:03:52 -1000 Subject: [PATCH] Add experimental Decoy Mode --- .github/ISSUE_TEMPLATE/broken_site.yml | 11 + .github/pull_request_template.md | 4 +- CONTRIBUTING.md | 22 +- README.md | 66 +- SECURITY.md | 10 + background.js | 238 ++++++- decoy-bridge.js | 69 ++ decoy-interceptor.js | 238 +++++++ docs/ADDING_TRACKERS.md | 9 + docs/BROKEN_SITES.md | 6 + docs/DECOY_MODE.md | 74 ++ docs/DEVELOPMENT.md | 84 ++- docs/GOOD_FIRST_ISSUES.md | 9 + docs/LAUNCH_COPY.md | 6 +- manifest.json | 29 +- package.json | 10 +- popup/popup.css | 115 +++ popup/popup.html | 25 +- popup/popup.js | 65 +- scripts/browser-test.mjs | 928 +++++++++++++++++++++++++ scripts/check-safety.mjs | 6 +- scripts/test-decoy.mjs | 110 +++ shared/decoy-transform.js | 211 ++++++ test/manual-test.html | 25 +- 24 files changed, 2332 insertions(+), 38 deletions(-) create mode 100644 decoy-bridge.js create mode 100644 decoy-interceptor.js create mode 100644 docs/DECOY_MODE.md create mode 100644 scripts/browser-test.mjs create mode 100644 scripts/test-decoy.mjs create mode 100644 shared/decoy-transform.js diff --git a/.github/ISSUE_TEMPLATE/broken_site.yml b/.github/ISSUE_TEMPLATE/broken_site.yml index 5608c28..e7b31f4 100644 --- a/.github/ISSUE_TEMPLATE/broken_site.yml +++ b/.github/ISSUE_TEMPLATE/broken_site.yml @@ -34,6 +34,17 @@ body: - "Not sure" validations: required: true + - type: dropdown + id: decoy_mode + attributes: + label: Was experimental Decoy Mode on? + description: If possible, reproduce once with Decoy Mode off and include both results. + options: + - "No" + - "Yes" + - "Not sure" + validations: + required: true - type: textarea id: media attributes: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d0009ee..8153ac3 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -22,11 +22,13 @@ Closes # - [ ] I did not add broad/high-breakage domains. - [ ] I did not add payment processors, login providers, captcha services, core CDNs, or broad platform domains. -- [ ] I did not add analytics, telemetry, external network calls, remote logging, or servers. +- [ ] I did not add extension-owned analytics, telemetry, remote logging, servers, or network requests. Decoy Mode changes only eligible page-initiated requests. +- [ ] I did not fabricate or rewrite purchase, conversion, transaction, revenue, product, currency, or ad-click fields. - [ ] I did not add declarativeNetRequestFeedback. - [ ] I did not add webRequestBlocking. - [ ] I did not reintroduce privacy score. - [ ] I ran npm run generate:rules. - [ ] I ran npm run test:evidence. +- [ ] I ran npm run check. - [ ] I ran JSON/JS syntax checks. - [ ] I updated README/docs if behavior changed. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e3d3315..2759fbf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,8 +9,7 @@ Thanks for helping improve GetBlocked! The project is intentionally small, local 3. Run the relevant commands: ```bash -npm run generate:rules -npm run test:evidence +npm run check ``` 4. Open a pull request. @@ -33,8 +32,11 @@ Useful files: - `rules/rules.json`: generated DNR rules. - `shared/tracker-catalog.json`: source tracker catalog. - `shared/config.js`: generated content-script config. +- `shared/decoy-transform.js`: request-field replacement helpers. - `content-script.js`: visible tracking-signal detection. -- `background.js`: local report state and URL-cleaning counts. +- `decoy-bridge.js`: isolated-world Decoy Mode bridge. +- `decoy-interceptor.js`: experimental main-world request wrappers. +- `background.js`: local report, rule-mode, session-profile, and URL-cleaning state. - `popup/`: popup UI. ## How To Run Tests @@ -51,16 +53,14 @@ Run the evidence fixture: npm run test:evidence ``` -Run syntax checks: +Run the complete local check suite: ```bash -node --check background.js -node --check content-script.js -node --check popup/popup.js -node --check scripts/generate-rules.mjs -node --check scripts/evaluate-test-set.mjs +npm run check ``` +For Decoy Mode work, also run `npm run test:browser` when a compatible local Chromium-family browser is available. See [docs/DECOY_MODE.md](docs/DECOY_MODE.md) and [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). + ## How To Add A Tracker Domain The easiest first PR is adding one well-scoped tracker domain and one test fixture. @@ -84,6 +84,7 @@ For broken sites, include: - Steps to reproduce. - Chrome version and OS. - Whether disabling GetBlocked! fixes it. +- Whether Decoy Mode was off or on. - Suspected tracker category or domain, if known. - Screenshot or video when useful. @@ -109,6 +110,7 @@ You do not need to ask before opening a small PR for tracker-domain additions, b - [ ] I updated docs if behavior changed. - [ ] I ran `npm run generate:rules` if the catalog or params changed. - [ ] I ran `npm run test:evidence`. +- [ ] I ran `npm run check`. - [ ] I ran relevant JSON/JS syntax checks. - [ ] I did not add broad/high-breakage domains. - [ ] I did not add exaggerated privacy claims. @@ -122,6 +124,8 @@ You do not need to ask before opening a small PR for tracker-domain additions, b - Do not block captcha services. - Do not block core CDNs. - Do not add external analytics, telemetry, remote calls, or logging. +- Do not initiate, duplicate, replay, or fabricate tracker events. Decoy Mode may only modify eligible page-initiated requests to catalog domains. +- Do not rewrite purchase, conversion, transaction, revenue, product, currency, or ad-click fields. - Do not reintroduce `declarativeNetRequestFeedback`. - Do not reintroduce `webRequestBlocking`. - Do not reintroduce privacy score. diff --git a/README.md b/README.md index 45de436..fac85ee 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Cleaner pages. Fewer trackers. Local-only.** -GetBlocked! is a lightweight, local-only Chrome extension that helps reduce common third-party website tracking by blocking known tracker requests, cleaning tracking links, and explaining tracking attempts. +GetBlocked! is a lightweight, local-only Chrome extension that helps reduce common third-party website tracking by blocking known tracker requests, cleaning tracking links, and explaining tracking attempts. An optional experimental Decoy Mode can instead let known tracker requests through while replacing supported analytics identifiers with one consistent fake browser-session profile. It is built as a small, inspectable Manifest V3 project for people who want a friendly privacy tool and for contributors who want quick, useful pull requests. @@ -17,10 +17,12 @@ It is built as a small, inspectable Manifest V3 project for people who want a fr - Detects visible tracking attempts such as pixels, suspicious scripts, tracking iframes, and tracking links. - Shows a compact popup report with: - Blocked on this page + - Decoyed on this page - Tracking links cleaned - Visible tracking attempts detected - Detected categories - Keeps report data local in `chrome.storage.local`. +- Saves the Decoy Mode preference locally and keeps the generated fake profile only in `chrome.storage.session`. ## What GetBlocked! Can Block @@ -38,6 +40,8 @@ Current coverage includes: The tracker list is intentionally conservative. Rules are limited to third-party requests to reduce website breakage. +When experimental Decoy Mode is on, the main tracker-blocking rule is disabled, but tracking-parameter cleanup remains enabled. Page-level `fetch`, `XMLHttpRequest`, and `navigator.sendBeacon` calls to domains in the same catalog are intercepted where feasible. + ## What GetBlocked! Cannot Do - It does not block every tracker. @@ -46,6 +50,23 @@ The tracker list is intentionally conservative. Rules are limited to third-party - It does not stop server-side tracking. - It does not fully prevent advanced fingerprinting. - It is not meant to replace full-featured ad blockers. +- Decoy Mode cannot hide an IP address, TLS/network metadata, browser fingerprint, or identifiers added outside the supported request formats. +- Decoy Mode cannot rewrite image pixels, scripts, forms, WebSockets, or requests that begin before its session configuration is available. + +## Experimental Decoy Mode + +Decoy Mode is off by default. Use the clearly labeled **Experimental** toggle in the popup to enable it. + +While enabled: + +- Static rule `1`, the catalog-based third-party tracker blocker, is disabled. +- Static rule `1000`, tracking-parameter cleanup for top-level navigation, stays enabled. +- Known tracker requests made with `fetch`, `XMLHttpRequest`, and `navigator.sendBeacon` are inspected in the page's main JavaScript world. +- Common analytics identifiers and profile fields, including anonymous, client, user, device, and session IDs plus supported email/name/phone fields, are replaced when their request format is supported. +- One coherent fake profile is reused across tabs for the current browser/extension session. It is discarded when Chrome restarts or the extension is reloaded, disabled, or updated. +- A request counts as decoyed only when at least one supported field is actually replaced. + +Decoy Mode does not create requests and never changes event names, event types, order or transaction IDs, products, prices, amounts, currencies, conversions, purchases, or ad-click fields. Existing transactional events are not invented or reclassified. Because tracker requests are allowed to reach their destinations, those services may still observe the user's IP address and other network metadata. See [Decoy Mode details](docs/DECOY_MODE.md). ## How GetBlocked! Is Different @@ -64,9 +85,10 @@ GetBlocked! does not send browsing data to any server. The extension uses: - `declarativeNetRequest` for local Chrome-managed request blocking. - `chrome.storage.local` for page report data. +- `chrome.storage.session` for the in-memory Decoy Mode fake profile. - A content script that scans the current page locally for visible tracking signals. -There are no external analytics, telemetry endpoints, remote logs, or bundled remote assets in the extension. +There are no extension-owned analytics, telemetry endpoints, remote logs, or bundled remote assets. In Decoy Mode, the page's existing tracker requests are intentionally allowed through with supported identifiers replaced; GetBlocked! does not initiate additional tracker requests. ## Install Locally In Chrome @@ -77,7 +99,7 @@ There are no external analytics, telemetry endpoints, remote logs, or bundled re 5. Select the GetBlocked! project folder. 6. Pin GetBlocked! and open the popup on normal web pages. -The production build avoids debug-only DNR feedback permissions. The popup uses local page signals plus Chrome's production DNR action-count badge as a page-level estimate, while blocking and URL-cleaning rules remain enforced by MV3 `declarativeNetRequest`. +The production build avoids debug-only DNR feedback permissions. The popup uses local page signals plus Chrome's production DNR action-count badge as a page-level estimate. In normal mode, blocking and URL cleanup are enforced by MV3 `declarativeNetRequest`; in Decoy Mode, only the blocking rule is disabled. ## Contribute In 10 Minutes @@ -113,7 +135,7 @@ npm run generate:rules This updates: - [rules/rules.json](rules/rules.json), used by Chrome `declarativeNetRequest`. -- [shared/config.js](shared/config.js), used by the content script and popup category detection. +- [shared/config.js](shared/config.js), used by page detection and to scope Decoy Mode to the tracker catalog. Do not edit generated rules/config by hand unless you also update the generator or source catalog. @@ -134,12 +156,43 @@ Useful individual checks: ```bash npm run generate:rules npm run test:evidence +npm run test:decoy npm run check:syntax npm run check:json npm run check:generated npm run check:safety ``` +Run the automated browser test with a compatible Chromium-family browser: + +```bash +npm run test:browser +``` + +This launches Chrome with the unpacked extension loaded, navigates to +`test/manual-test.html` with common tracking URL parameters, and verifies: + +- Tracking parameters (`utm_source`, `fbclid`, `gclid`, …) are removed from the + final page URL by the extension's `declarativeNetRequest` redirect rules. +- The extension background service worker returns a report with nonzero page + activity detected by the content script. +- Decoy Mode disables only the blocking rule, reuses one session profile, + preserves URL cleanup, counts modified requests, and restores normal mode. +- No unexpected runtime exceptions are thrown by the extension. + +No npm packages are required. The test uses only Node.js built-ins and Chrome's +remote debugging protocol (CDP). Some branded browser builds ignore +`--load-extension`; if no compatible browser is found or the unpacked extension +does not load, the test exits with code 0 and prints a skip notice. + +Override defaults with environment variables: + +| Variable | Default | Purpose | +|---|---|---| +| `CHROME_PATH` | auto-detected | Path to a compatible Chromium-family executable | +| `TEST_PORT` | `8765` | Local static file server port | +| `DEBUG_PORT` | `9333` | Chrome CDP remote debugging port | + ## Manual Browser Check After loading the unpacked extension, start a simple local server from this folder: @@ -154,7 +207,9 @@ Then open: http://localhost:8080/test/manual-test.html?utm_source=demo&utm_medium=test&fbclid=manual ``` -With GetBlocked! enabled, the tracking parameters should be removed from the page URL and the popup should show page-level tracker activity. For a before/after check, compare the browser Network panel with the extension disabled and then enabled. +With GetBlocked! enabled and Decoy Mode off, the tracking parameters should be removed from the page URL and known third-party tracker requests should be blocked. The popup should show page-level tracker activity. + +Then turn on **Decoy Mode (Experimental)** in the popup and reload the fixture. URL cleanup should continue, supported identifiers in the fixture's `fetch`, XHR, and beacon calls should be replaced, and **Decoyed on this page** should increase. The tracker requests themselves may still fail because the example endpoints reject test/CORS traffic; the counter records the local replacement attempt. Turn Decoy Mode off again to restore normal blocking. ## Contributor Links @@ -162,6 +217,7 @@ With GetBlocked! enabled, the tracking parameters should be removed from the pag - [Adding trackers](docs/ADDING_TRACKERS.md) - [Broken sites](docs/BROKEN_SITES.md) - [Development guide](docs/DEVELOPMENT.md) +- [Decoy Mode](docs/DECOY_MODE.md) - [Good first issues](docs/GOOD_FIRST_ISSUES.md) - [Labels guide](docs/LABELS.md) - [Launch copy](docs/LAUNCH_COPY.md) diff --git a/SECURITY.md b/SECURITY.md index 10a2544..55c99c9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -24,6 +24,16 @@ GetBlocked! should remain local-only. - Remote logging should not be added. - Remote scripts, fonts, or assets should not be added. +Experimental Decoy Mode does not change this extension-owned local-only requirement. It has no backend and creates no tracker traffic, but it intentionally allows the page's existing catalog-matched tracker requests to reach their remote destinations after supported identifier/profile replacement. Those destinations can still observe IP address and network metadata. Privacy reports should distinguish extension-owned network activity from page-owned traffic modified by Decoy Mode. + +## Decoy Mode Safety Boundary + +- Only exact domains and subdomains generated from `shared/tracker-catalog.json` may be targeted. +- The fake profile must remain in `chrome.storage.session` and must not contain user-derived data. +- Request wrappers must not create, replay, duplicate, or schedule network events. +- Event names/types and transactional fields such as purchases, conversions, orders, products, prices, amounts, currencies, and ad clicks must not be fabricated or rewritten. +- The popup must continue warning that allowed tracker requests can reveal IP/network metadata. + ## Permission Expansion Permission expansion is security-sensitive. Any PR that changes `manifest.json` permissions should explain: diff --git a/background.js b/background.js index 9b4a804..dff4814 100644 --- a/background.js +++ b/background.js @@ -1,17 +1,27 @@ importScripts("shared/config.js"); -const { TRACKING_PARAMS } = globalThis.GetBlockedConfig; +const { + BLOCK_RULE_ID, + CLEAN_URL_RULE_ID, + TRACKING_PARAMS, + TRACKER_DOMAINS, + TRACKER_CATEGORIES +} = globalThis.GetBlockedConfig; const TAB_STATS_KEY = "getblockedTabStats"; const PENDING_NAVIGATION_KEY = "getblockedPendingNavigation"; const OBSOLETE_TOTALS_KEY = "getblockedTotals"; +const DECOY_MODE_KEY = "getblockedDecoyMode"; +const DECOY_SESSION_PROFILE_KEY = "getblockedDecoySessionProfile"; let updateQueue = Promise.resolve(); function queueUpdate(task) { - updateQueue = updateQueue.then(task).catch((error) => { + const operation = updateQueue.then(task); + updateQueue = operation.catch((error) => { console.warn("GetBlocked update failed:", error); }); + return operation; } function storageGet(keys) { @@ -53,6 +63,36 @@ function storageRemove(keys) { }); } +function sessionStorageGet(keys) { + return new Promise((resolve, reject) => { + chrome.storage.session.get(keys, (result) => { + const error = chrome.runtime.lastError; + if (error) { + reject(error); + return; + } + resolve(result); + }); + }); +} + +function sessionStorageSet(items) { + return new Promise((resolve, reject) => { + chrome.storage.session.set(items, () => { + const error = chrome.runtime.lastError; + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +function updateStaticRules(options) { + return chrome.declarativeNetRequest.updateStaticRules(options); +} + function getBadgeText(tabId) { return new Promise((resolve) => { if (!Number.isInteger(tabId) || tabId < 0) { @@ -81,6 +121,7 @@ function setDnrActionBadgeEnabled() { function createEmptyTabStats() { return { blockedOnPage: 0, + decoyedRequests: 0, trackingLinksCleaned: 0, trackingParamsRemoved: 0, visibleAttempts: 0, @@ -92,6 +133,104 @@ function createEmptyTabStats() { }; } +function isKnownTrackerHost(hostname) { + const normalizedHost = String(hostname || "").toLowerCase(); + return TRACKER_DOMAINS.some((domain) => { + return normalizedHost === domain || normalizedHost.endsWith(`.${domain}`); + }); +} + +function getTrackerCategories(hostname) { + return Object.entries(TRACKER_CATEGORIES) + .filter(([, domains]) => { + return domains.some((domain) => { + return hostname === domain || hostname.endsWith(`.${domain}`); + }); + }) + .map(([category]) => category); +} + +function createFakeSessionProfile() { + const firstNames = ["Alex", "Casey", "Jordan", "Morgan", "Riley", "Taylor"]; + const lastNames = ["Avery", "Hayes", "Parker", "Reed", "Rowan", "Sage"]; + const randomValues = crypto.getRandomValues(new Uint32Array(4)); + const token = Array.from(randomValues, (value) => { + return value.toString(16).padStart(8, "0"); + }).join(""); + const firstName = firstNames[randomValues[0] % firstNames.length]; + const lastName = lastNames[randomValues[1] % lastNames.length]; + const username = `${firstName}.${lastName}.${token.slice(0, 6)}`.toLowerCase(); + const phoneSuffix = String(randomValues[2] % 100).padStart(2, "0"); + + return Object.freeze({ + anonymousId: `anon_${token}`, + clientId: `${randomValues[0]}.${randomValues[1]}`, + userId: `user_${token.slice(0, 20)}`, + deviceId: `device_${token.slice(8, 28)}`, + sessionId: `session_${token.slice(16)}`, + email: `${username}@example.invalid`, + firstName, + lastName, + fullName: `${firstName} ${lastName}`, + username, + phone: `+120255501${phoneSuffix}` + }); +} + +async function getDecoyMode() { + const result = await storageGet(DECOY_MODE_KEY); + return result[DECOY_MODE_KEY] === true; +} + +async function ensureDecoySessionProfile() { + const result = await sessionStorageGet(DECOY_SESSION_PROFILE_KEY); + const existing = result[DECOY_SESSION_PROFILE_KEY]; + + if (existing && typeof existing === "object") { + return existing; + } + + const profile = createFakeSessionProfile(); + await sessionStorageSet({ [DECOY_SESSION_PROFILE_KEY]: profile }); + return profile; +} + +async function applyDecoyRuleState(enabled) { + await updateStaticRules({ + rulesetId: "getblocked_static_rules", + disableRuleIds: enabled ? [BLOCK_RULE_ID] : [], + enableRuleIds: enabled + ? [CLEAN_URL_RULE_ID] + : [BLOCK_RULE_ID, CLEAN_URL_RULE_ID] + }); +} + +async function syncDecoyRuleStateFromStorage() { + await applyDecoyRuleState(await getDecoyMode()); +} + +async function setDecoyMode(enabled) { + await applyDecoyRuleState(enabled); + await storageSet({ [DECOY_MODE_KEY]: enabled }); + + if (enabled) { + await clearBlockedCounts(); + } + + return { + enabled, + profile: enabled ? await ensureDecoySessionProfile() : null + }; +} + +async function getDecoyConfiguration() { + const enabled = await getDecoyMode(); + return { + enabled, + profile: enabled ? await ensureDecoySessionProfile() : null + }; +} + function normalizeTabStats(stats) { return { ...createEmptyTabStats(), @@ -130,6 +269,24 @@ async function setAllTabStats(tabStats) { }); } +async function clearBlockedCounts() { + const tabStats = await getAllTabStats(); + const updatedAt = Date.now(); + const nextStats = Object.fromEntries( + Object.entries(tabStats).map(([tabId, stats]) => { + return [ + tabId, + normalizeTabStats({ + ...stats, + blockedOnPage: 0, + updatedAt + }) + ]; + }) + ); + await setAllTabStats(nextStats); +} + async function getTabStats(tabId) { const tabStats = await getAllTabStats(); return normalizeTabStats(tabStats[String(tabId)]); @@ -282,18 +439,46 @@ async function updatePageSignals(tabId, signals) { detectedCategories }; - await updateTabStats(tabId, () => safeSignals); - await syncBlockedEstimateForTab(tabId, safeSignals.estimatedTrackerRequests); + await updateTabStats(tabId, (current) => ({ + ...safeSignals, + detectedCategories: Array.from( + new Set([...current.detectedCategories, ...safeSignals.detectedCategories]) + ) + })); + if (!(await getDecoyMode())) { + await syncBlockedEstimateForTab(tabId, safeSignals.estimatedTrackerRequests); + } +} + +async function recordDecoyedRequest(tabId, hostname) { + const normalizedHost = String(hostname || "").toLowerCase(); + if (!(await getDecoyMode()) || !isKnownTrackerHost(normalizedHost)) { + return; + } + + await updateTabStats(tabId, (current) => ({ + decoyedRequests: current.decoyedRequests + 1, + detectedCategories: Array.from( + new Set([ + ...current.detectedCategories, + ...getTrackerCategories(normalizedHost) + ]) + ) + })); } async function getReport(tabId) { await updateQueue; - await syncDnrActionCountForTab(tabId); + const decoyMode = await getDecoyMode(); + if (!decoyMode) { + await syncDnrActionCountForTab(tabId); + } const pageStats = await getTabStats(tabId); return { page: pageStats, + decoyMode, localOnly: true, counterMode: "production_estimate" }; @@ -310,6 +495,39 @@ function handleMessage(message, sender, sendResponse) { return false; } + if (message?.type === "GETBLOCKED_DECOYED_REQUEST") { + queueUpdate(() => { + return recordDecoyedRequest(sender.tab?.id, message.hostname); + }); + sendResponse({ ok: true }); + return false; + } + + if (message?.type === "GETBLOCKED_DECOY_CONFIG") { + getDecoyConfiguration() + .then((configuration) => sendResponse({ ok: true, configuration })) + .catch((error) => { + sendResponse({ + ok: false, + error: error.message || "Unable to load Decoy Mode" + }); + }); + return true; + } + + if (message?.type === "SET_GETBLOCKED_DECOY_MODE") { + const enabled = message.enabled === true; + queueUpdate(() => setDecoyMode(enabled)) + .then((configuration) => sendResponse({ ok: true, configuration })) + .catch((error) => { + sendResponse({ + ok: false, + error: error.message || "Unable to update Decoy Mode" + }); + }); + return true; + } + if (message?.type === "GETBLOCKED_REPORT") { getReport(message.tabId) .then((report) => sendResponse({ ok: true, report })) @@ -326,12 +544,18 @@ function handleMessage(message, sender, sendResponse) { } chrome.runtime.onInstalled.addListener(() => { - queueUpdate(clearTransientLocalData); + queueUpdate(async () => { + await clearTransientLocalData(); + await syncDecoyRuleStateFromStorage(); + }); setDnrActionBadgeEnabled(); }); chrome.runtime.onStartup.addListener(() => { - queueUpdate(clearTransientLocalData); + queueUpdate(async () => { + await clearTransientLocalData(); + await syncDecoyRuleStateFromStorage(); + }); setDnrActionBadgeEnabled(); }); diff --git a/decoy-bridge.js b/decoy-bridge.js new file mode 100644 index 0000000..ae3c9d2 --- /dev/null +++ b/decoy-bridge.js @@ -0,0 +1,69 @@ +(function connectGetBlockedDecoyBridge() { + const BRIDGE_CHANNEL = "getblocked-decoy-bridge-v1"; + const DECOY_MODE_KEY = "getblockedDecoyMode"; + const channel = new MessageChannel(); + const bridgePort = channel.port1; + + function hasValidExtensionContext() { + try { + return Boolean(globalThis.chrome?.runtime?.id); + } catch (error) { + return false; + } + } + + function publishConfiguration(configuration) { + bridgePort.postMessage({ + type: "configure", + enabled: configuration?.enabled === true, + profile: configuration?.profile || null + }); + } + + function requestConfiguration() { + if (!hasValidExtensionContext()) { + return; + } + + chrome.runtime + .sendMessage({ type: "GETBLOCKED_DECOY_CONFIG" }) + .then((response) => { + if (response?.ok) { + publishConfiguration(response.configuration); + } + }) + .catch(() => {}); + } + + bridgePort.onmessage = (event) => { + if ( + event.data?.type !== "request-decoyed" || + !hasValidExtensionContext() + ) { + return; + } + + chrome.runtime + .sendMessage({ + type: "GETBLOCKED_DECOYED_REQUEST", + hostname: event.data.hostname, + transport: event.data.transport + }) + .catch(() => {}); + }; + bridgePort.start(); + + window.postMessage( + { channel: BRIDGE_CHANNEL, type: "connect" }, + "*", + [channel.port2] + ); + + chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName === "local" && changes[DECOY_MODE_KEY]) { + requestConfiguration(); + } + }); + + requestConfiguration(); +})(); diff --git a/decoy-interceptor.js b/decoy-interceptor.js new file mode 100644 index 0000000..94e39a3 --- /dev/null +++ b/decoy-interceptor.js @@ -0,0 +1,238 @@ +(function installGetBlockedDecoyInterceptor() { + const { TRACKER_DOMAINS } = globalThis.GetBlockedConfig; + const transform = globalThis.GetBlockedDecoyTransform; + const BRIDGE_CHANNEL = "getblocked-decoy-bridge-v1"; + const xhrState = new WeakMap(); + let bridgePort = null; + const state = { + enabled: false, + profile: null + }; + + const nativeFetch = globalThis.fetch; + const nativeXhrOpen = XMLHttpRequest.prototype.open; + const nativeXhrSend = XMLHttpRequest.prototype.send; + const nativeXhrSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader; + const nativeSendBeacon = Navigator.prototype.sendBeacon; + + function isReady() { + return state.enabled && state.profile && typeof state.profile === "object"; + } + + function getContentType(headers) { + try { + return new Headers(headers).get("content-type") || ""; + } catch (error) { + return ""; + } + } + + function trackerHostname(rawUrl) { + try { + const url = new URL(rawUrl, window.location.href); + return transform.isKnownTrackerHost(url.hostname, TRACKER_DOMAINS) + ? url.hostname.toLowerCase() + : ""; + } catch (error) { + return ""; + } + } + + function reportDecoyed(hostname, transport) { + bridgePort?.postMessage({ + type: "request-decoyed", + hostname, + transport + }); + } + + function connectBridge(event) { + if ( + bridgePort || + event.source !== window || + event.data?.channel !== BRIDGE_CHANNEL || + event.data.type !== "connect" || + !event.ports?.[0] + ) { + return; + } + + bridgePort = event.ports[0]; + bridgePort.onmessage = (messageEvent) => { + if (messageEvent.data?.type !== "configure") { + return; + } + + state.enabled = messageEvent.data.enabled === true; + state.profile = messageEvent.data.profile || null; + }; + bridgePort.start(); + window.removeEventListener("message", connectBridge); + } + + window.addEventListener("message", connectBridge); + + if (typeof nativeFetch === "function") { + globalThis.fetch = async function getBlockedDecoyFetch(input, init) { + const rawUrl = input instanceof Request ? input.url : String(input); + const hostname = isReady() ? trackerHostname(rawUrl) : ""; + + if (!hostname) { + return Reflect.apply(nativeFetch, this, arguments); + } + + let changed = false; + let nextInput = input; + let nextInit = init; + const urlResult = transform.replaceUrl( + rawUrl, + window.location.href, + state.profile + ); + + if (urlResult.changed) { + changed = true; + nextInput = + input instanceof Request + ? new Request(urlResult.value, input) + : urlResult.value; + } + + try { + const suppliedBody = init?.body; + const contentType = getContentType(init?.headers || input?.headers); + + if (suppliedBody !== undefined && suppliedBody !== null) { + const bodyResult = transform.replaceBody( + suppliedBody, + state.profile, + contentType + ); + + if (bodyResult.changed) { + changed = true; + nextInit = { ...(init || {}), body: bodyResult.value }; + } + } else if ( + input instanceof Request && + !input.bodyUsed && + !["GET", "HEAD"].includes(input.method.toUpperCase()) && + (contentType.includes("json") || + contentType.includes("application/x-www-form-urlencoded") || + contentType.startsWith("text/")) + ) { + const bodyText = await input.clone().text(); + const bodyResult = transform.replaceText( + bodyText, + state.profile, + contentType + ); + + if (bodyResult.changed) { + changed = true; + nextInit = { ...(init || {}), body: bodyResult.value }; + } + } + } catch (error) { + // Unsupported, locked, or streaming bodies pass through unchanged. + } + + if (changed) { + reportDecoyed(hostname, "fetch"); + } + + return Reflect.apply(nativeFetch, this, [nextInput, nextInit]); + }; + } + + XMLHttpRequest.prototype.open = function getBlockedDecoyXhrOpen( + method, + rawUrl, + ...rest + ) { + const hostname = trackerHostname(rawUrl); + let nextUrl = rawUrl; + let changed = false; + + if (isReady() && hostname) { + const result = transform.replaceUrl( + rawUrl, + window.location.href, + state.profile + ); + nextUrl = result.value; + changed = result.changed; + } + + xhrState.set(this, { + changed, + headers: {}, + hostname, + reported: false + }); + + return Reflect.apply(nativeXhrOpen, this, [method, nextUrl, ...rest]); + }; + + XMLHttpRequest.prototype.setRequestHeader = function getBlockedDecoyXhrHeader( + name, + value + ) { + const requestState = xhrState.get(this); + if (requestState) { + requestState.headers[String(name).toLowerCase()] = String(value); + } + return Reflect.apply(nativeXhrSetRequestHeader, this, arguments); + }; + + XMLHttpRequest.prototype.send = function getBlockedDecoyXhrSend(body) { + const requestState = xhrState.get(this); + let nextBody = body; + + if (isReady() && requestState?.hostname) { + const result = transform.replaceBody( + body, + state.profile, + requestState.headers["content-type"] || "" + ); + nextBody = result.value; + requestState.changed = requestState.changed || result.changed; + } + + if (requestState?.changed && !requestState.reported) { + requestState.reported = true; + reportDecoyed(requestState.hostname, "xhr"); + } + + return Reflect.apply(nativeXhrSend, this, [nextBody]); + }; + + if (typeof nativeSendBeacon === "function") { + Navigator.prototype.sendBeacon = function getBlockedDecoySendBeacon( + rawUrl, + body + ) { + const hostname = isReady() ? trackerHostname(rawUrl) : ""; + + if (!hostname) { + return Reflect.apply(nativeSendBeacon, this, arguments); + } + + const urlResult = transform.replaceUrl( + rawUrl, + window.location.href, + state.profile + ); + const bodyResult = transform.replaceBody(body, state.profile); + + if (urlResult.changed || bodyResult.changed) { + reportDecoyed(hostname, "beacon"); + } + + return Reflect.apply(nativeSendBeacon, this, [ + urlResult.value, + bodyResult.value + ]); + }; + } +})(); diff --git a/docs/ADDING_TRACKERS.md b/docs/ADDING_TRACKERS.md index 00c8a2c..5c74b15 100644 --- a/docs/ADDING_TRACKERS.md +++ b/docs/ADDING_TRACKERS.md @@ -37,6 +37,8 @@ Example tracker entry: Keep the domain narrow. Avoid broad platform domains, login providers, payment processors, captcha services, and core CDNs. +The catalog is also Decoy Mode's hard scope boundary. When the experimental mode is on, page-level `fetch`, XHR, and beacon calls to this domain become eligible for identifier replacement instead of the normal DNR block. Confirm that the entry is genuinely a tracker endpoint and not a payment, login, captcha, or other transactional service. + ## 2. Add One Test Fixture Open [test/tracker-test-set.json](../test/tracker-test-set.json). @@ -79,6 +81,12 @@ npm run test:evidence Confirm the new fixture is blocked and category coverage still looks correct. +Also run the full suite so catalog scoping and Decoy Mode safety checks remain covered: + +```bash +npm run check +``` + ## 5. Confirm Generated Files Before opening a PR, quickly inspect: @@ -96,6 +104,7 @@ In your PR, include: - The category. - Why it should be blocked. - Any known breakage risk. +- Any Decoy Mode risk if the host accepts mixed tracking and transactional traffic. - The commands you ran. Small PRs are welcome. One domain plus one test fixture is enough. diff --git a/docs/BROKEN_SITES.md b/docs/BROKEN_SITES.md index ec9d678..b92b60c 100644 --- a/docs/BROKEN_SITES.md +++ b/docs/BROKEN_SITES.md @@ -10,6 +10,7 @@ Please include: - What broke. - Browser and OS version. - Whether disabling GetBlocked! fixes it. +- Whether Decoy Mode was off or on when the problem occurred. - Which tracker category might be responsible, if known. - Steps to reproduce. - Screenshots or video if useful. @@ -47,6 +48,8 @@ Helpful checks: 5. Note blocked domains near the broken feature (e.g., a form submission depends on a blocked analytics script). 6. Check if the domain is in `shared/tracker-catalog.json` and note its category. +If Decoy Mode was on, repeat once with it off. Normal mode can break a page by blocking a catalog domain; Decoy Mode can expose a different compatibility problem if a tracker library rejects a replaced identifier or unsupported request bodies pass through unchanged. Include both results, but never test with a real purchase or conversion merely to exercise Decoy Mode. + If you are not sure which domain caused the breakage: 1. Open `chrome://extensions` → GetBlocked! → **Service Worker**. @@ -62,6 +65,7 @@ If you are not sure which domain caused the breakage: | Login redirect loop | SSO provider keeps redirecting back to login page | Reclassify domain as `Essential / login` or remove from catalog | | Image gallery broken | Product images or lightbox does not load | Remove or narrow the CDN-like domain | | Captcha fails | "Are you a human?" check never completes | Remove the captcha provider domain from the blocker catalog | +| Analytics library errors only in Decoy Mode | Tracker rejects a replaced test identifier | Record the request format and narrow the supported field mapping without changing event or transaction fields | ## Possible Fixes @@ -93,6 +97,8 @@ A good PR description: ## When Not To File A Broken-Site Report +Seeing a known tracker request in DevTools while Decoy Mode is on is expected. The experimental mode allows catalog-matched requests through where normal mode blocks them; the popup counter only indicates requests whose supported fields were changed. Those requests can still expose IP and network metadata. + You do not need to file a report if: - **GetBlocked! is working as designed** — blocked tracker domains may still appear in the Network panel; that is expected. diff --git a/docs/DECOY_MODE.md b/docs/DECOY_MODE.md new file mode 100644 index 0000000..cd2309c --- /dev/null +++ b/docs/DECOY_MODE.md @@ -0,0 +1,74 @@ +# Decoy Mode (Experimental) + +Decoy Mode is an optional experiment for replacing supported analytics identifiers instead of blocking every catalog-matched tracker request. It is off by default. + +## What Changes When It Is On + +The popup toggle saves `getblockedDecoyMode` in `chrome.storage.local`. The background service worker then uses `chrome.declarativeNetRequest.updateStaticRules()` to: + +- Disable static rule `1`, the third-party tracker-domain blocking rule. +- Keep static rule `1000`, the top-level tracking-parameter cleanup rule, enabled. + +Document-start scripts run in all matching web frames. `decoy-interceptor.js` runs in the page's `MAIN` world so it can wrap page-owned `fetch`, `XMLHttpRequest`, and `navigator.sendBeacon`. `decoy-bridge.js` stays in the extension's isolated world and relays configuration and local counters to the service worker. Before page scripts run, the two worlds establish a one-time transferred `MessageChannel`; profile data and counters do not use a public, reusable window-message channel. + +Only exact domains and subdomains in `shared/tracker-catalog.json` are eligible. Decoy Mode does not broaden the tracker catalog. + +## The Fake Session Profile + +The service worker creates one profile and stores it in `chrome.storage.session`. The same values are reused across tabs and frames while the browser/extension session remains active. Chrome clears the profile when the browser restarts or the extension is reloaded, disabled, or updated. + +Supported replacements include common forms of: + +- Anonymous, client, user, visitor, customer, profile, device, and session IDs. +- Email address. +- First, last, full, display, and user names. +- Phone number. + +The generated email uses the reserved `.invalid` domain, and the phone number uses a North American fictional `555-01xx` range. The profile contains no user-derived personal data. + +## Supported Request Formats + +Decoy Mode can replace supported fields in: + +- URL query strings. +- JSON string bodies. +- URL-encoded string bodies and `URLSearchParams`. +- `FormData` string fields. +- Textual `Request` bodies that can be cloned safely by the `fetch` wrapper. + +Binary, locked, already-consumed, unsupported, and streaming bodies pass through unchanged. Image pixels, script tags, iframes, CSS resources, HTML form navigation, WebSockets, WebTransport, and browser- or library-internal traffic that bypasses the wrapped APIs are not rewritten. A very early request may also run before the asynchronous session configuration reaches the main-world wrapper. + +The popup increments **Decoyed on this page** only when at least one supported query or body field was replaced. Intercepted requests with no supported fields are not counted. + +## Transaction Safety + +Decoy Mode does not create, replay, duplicate, or schedule requests. It does not change event names or types, and it does not rewrite transaction/order IDs, products, items, prices, amounts, quantities, currencies, revenue, purchase/conversion markers, or ad-click fields. + +An existing page may still send a real transactional event to a tracker because the blocking rule is off. Decoy Mode changes only supported identifier/profile fields inside that already-existing request; it never turns another event into a purchase, conversion, or click. + +## Privacy Limits + +Decoy Mode is not anonymity. A tracker request that reaches a remote service can still expose or enable inference from: + +- IP address and network/TLS metadata. +- Request timing, headers, cookies, and server-issued identifiers. +- Browser and device fingerprinting signals. +- Account state or identifiers outside the supported fields/formats. +- Transaction details that Decoy Mode intentionally does not alter. + +Normal blocking mode provides the stronger default for preventing catalog-matched third-party tracker requests. Turn Decoy Mode off in the popup to restore the existing blocking rule immediately. + +## Local Data And Network Behavior + +The preference, per-tab counters, and fake session profile stay in extension storage. GetBlocked! has no analytics, telemetry, remote logging, or extension-owned backend. Decoy Mode never sends its own synthetic events; it only modifies eligible page-initiated requests before the page sends them to tracker destinations it already chose. + +## Verification + +Run: + +```bash +npm run check +npm run test:browser +``` + +`npm run check` includes focused transformation assertions that verify profile consistency, catalog scoping, identifier replacement, and preservation of purchase/conversion/transaction fields. The browser test loads the unpacked extension when a compatible Chrome installation is available. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 2dcdb27..b72d065 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -19,29 +19,92 @@ There are no runtime npm dependencies. The npm scripts use Node.js built-ins. ## Project Structure - `manifest.json`: Chrome MV3 manifest. -- `background.js`: background service worker for page report state and URL-cleaning counts. +- `background.js`: background service worker for page report state, Decoy Mode state/profile, rule switching, and URL-cleaning counts. - `content-script.js`: scans pages locally for visible tracking signals. +- `decoy-bridge.js`: isolated-world document-start bridge for Decoy Mode configuration and counters. +- `decoy-interceptor.js`: main-world wrappers for eligible tracker `fetch`, XHR, and beacon calls. - `popup/`: popup HTML, CSS, and JS. - `shared/tracker-catalog.json`: maintained source of truth for tracker domains. - `shared/tracking-params.json`: maintained source of truth for tracking URL parameters. - `shared/config.js`: generated config for content script/category detection. +- `shared/decoy-transform.js`: reusable, locally tested request-field transformation helpers. - `rules/rules.json`: generated Chrome DNR rules. - `scripts/generate-rules.mjs`: catalog-to-rules generator. - `scripts/evaluate-test-set.mjs`: local ruleset fixture test. +- `scripts/test-decoy.mjs`: Decoy Mode transformation and transaction-safety assertions. - `test/tracker-test-set.json`: local tracker fixture set. ## Useful Commands ```bash +npm run check npm run generate:rules npm run test:evidence +npm run test:decoy +npm run test:browser node --check background.js node --check content-script.js +node --check decoy-bridge.js +node --check decoy-interceptor.js node --check popup/popup.js node --check scripts/generate-rules.mjs node --check scripts/evaluate-test-set.mjs +node --check scripts/test-decoy.mjs +node --check scripts/browser-test.mjs ``` +`npm run check` is the complete non-browser validation used by CI. The optional browser harness remains separate because compatible extension support is not available in every headless environment. + +## Browser Test + +`npm run test:browser` (`scripts/browser-test.mjs`) is an automated end-to-end check +that runs in a compatible Chromium-based browser with the unpacked extension loaded. + +### What it does + +1. Starts a minimal local HTTP server (Node `node:http`) on `localhost:8765` serving + the project directory. +2. Launches Chrome with `--load-extension=` and + `--remote-debugging-port=9333` so the Chrome DevTools Protocol (CDP) is accessible. +3. Navigates to `test/manual-test.html` with tracking URL parameters: + ``` + http://localhost:8765/test/manual-test.html?utm_source=test&utm_medium=browser&fbclid=abc123&gclid=xyz789 + ``` +4. Waits for the extension's content script to fire at `document_idle`. +5. Connects directly to the GetBlocked! service worker and asserts via CDP: + - **URL param strip**: `utm_source`, `utm_medium`, `fbclid`, and `gclid` are absent + from the final `location.href` (stripped by the DNR redirect / queryTransform rules). + - **Background report**: the service worker returns `{ ok: true, report: { localOnly: true, ... } }` + with nonzero total page activity (tracker elements + tracking links + params removed). + - **Rule switching**: Decoy Mode disables rule `1` without disabling URL-cleanup rule `1000`. + - **Session profile**: repeated configuration reads return the same fake profile. + - **Interception**: the manual fixture's fetch, XHR, and beacon identifiers are replaced and counted. + - **Restoration**: the test turns Decoy Mode off and restores normal blocking before cleanup. + - **No runtime exceptions**: no uncaught errors captured via `Runtime.exceptionThrown`. +6. Exits with code 0 on success, 1 on failure. +7. Cleans up: kills Chrome, removes the temp profile directory, closes the file server. + +### No extra dependencies + +The harness uses only Node.js built-ins (`node:http`, `node:net`, `node:child_process`, +`node:fs`, `node:crypto`, `node:os`). It speaks the WebSocket wire protocol directly +without any npm package. + +### Environment variable overrides + +| Variable | Default | Purpose | +|---|---|---| +| `CHROME_PATH` | auto-detected | Path to a compatible Chromium-family executable | +| `TEST_PORT` | `8765` | Local static file server port | +| `DEBUG_PORT` | `9333` | Chrome CDP remote debugging port | + +### Headless caveat + +The harness uses `--headless=new` and `--load-extension`. Some branded browser +builds ignore unpacked-extension flags in automation. The script prefers compatible +local builds, supports an explicit `CHROME_PATH`, and prints `SKIP` with exit code 0 +when the browser cannot load the extension. + ## How The Tracker Catalog Generates DNR Rules `shared/tracker-catalog.json` contains tracker domains, categories, labels, and notes. @@ -50,6 +113,7 @@ node --check scripts/evaluate-test-set.mjs - `rules/rules.json`: domain-based `declarativeNetRequest` rules. - `shared/config.js`: category mappings used by the content script and popup. +- The same generated domain list scopes Decoy Mode, so request interception never expands beyond the maintained catalog. Rules stay limited to `domainType: "thirdParty"` to reduce website breakage. @@ -58,12 +122,15 @@ Rules stay limited to `domainType: "thirdParty"` to reduce website breakage. The popup requests a local report from the background service worker. It shows: - Blocked on this page +- Decoyed on this page - Tracking links cleaned - Visible tracking attempts detected - Detected categories It does not show a privacy score. +The popup also owns the **Decoy Mode (Experimental)** toggle. It asks the service worker to update the static-rule state before the saved setting changes, then refreshes the report. When the mode is on, the footer warns that tracker requests may still reveal IP and network metadata. + ## How Content-Script Detection Works `content-script.js` scans the current page for: @@ -74,10 +141,23 @@ It does not show a privacy score. The scan runs once at `document_idle`, stays local in the browser, and sends category/count signals to the background service worker. It intentionally avoids a long-lived DOM observer so reloading an unpacked extension does not leave stale callbacks in already-open tabs. +## How Decoy Mode Works + +Decoy Mode is off by default. Its saved preference uses `chrome.storage.local`; its generated fake profile uses `chrome.storage.session`, which gives the service worker one coherent profile across tabs without persisting it beyond the browser/extension session. + +Two document-start content-script declarations are used: + +1. `shared/config.js`, `shared/decoy-transform.js`, and `decoy-interceptor.js` run in the page's `MAIN` world. This is required to wrap the page's own `fetch`, `XMLHttpRequest`, and `navigator.sendBeacon` APIs. +2. `decoy-bridge.js` runs in the default isolated world. It can use extension messaging and relays the saved setting, session profile, and decoy-count messages across a one-time `MessageChannel` port transferred before page scripts run. The main-world interceptor ignores later connection attempts, which prevents ordinary page code from spoofing configuration or count messages over a fixed public channel. + +Both declarations run in matching web frames. The interceptor checks every destination against the generated `TRACKER_DOMAINS` list before inspecting it. It never creates a request. See [Decoy Mode](DECOY_MODE.md) for supported fields, body formats, transaction safety, and privacy limits. + ## MV3 And DNR Notes GetBlocked! uses Manifest V3 and `declarativeNetRequest`. +The generated static ruleset intentionally keeps two stable rule IDs. `BLOCK_RULE_ID` (`1`) blocks catalog domains in normal mode. `CLEAN_URL_RULE_ID` (`1000`) removes tracking parameters from top-level navigation. `updateStaticRules()` disables only rule `1` in Decoy Mode and explicitly leaves rule `1000` enabled. The service worker reapplies the saved state on install/update and browser startup because static-rule overrides do not survive extension updates. + Avoid: - Debug-only DNR feedback permissions. @@ -99,6 +179,8 @@ Do not add: Report data should stay local in `chrome.storage.local`. +Decoy Mode is the narrow exception to the project's source-code guard against page network API wrappers. Only `decoy-interceptor.js` may reference `fetch`, `XMLHttpRequest`, or `sendBeacon`, and only to modify page-initiated requests to catalog domains. It must not initiate analytics traffic, fabricate events, or add extension-owned endpoints. The fake profile stays in `chrome.storage.session`. + ## Verify No Unsafe Permissions Were Added Check `manifest.json`. Current production permissions are: diff --git a/docs/GOOD_FIRST_ISSUES.md b/docs/GOOD_FIRST_ISSUES.md index f7840db..cb5f8cb 100644 --- a/docs/GOOD_FIRST_ISSUES.md +++ b/docs/GOOD_FIRST_ISSUES.md @@ -191,3 +191,12 @@ These issue ideas are designed to become small, focused pull requests. Copy any - Files to edit: `docs/BROKEN_SITES.md` - Commands to run: None required - Acceptance criteria: Notes help contributors propose safer fixes. + +### 21. Document Decoy Mode compatibility examples + +- Difficulty: Beginner +- Labels: `good first issue`, `help wanted`, `PR welcome`, `documentation`, `MV3` +- Goal: Add non-transactional examples of supported and unsupported request-body formats to `docs/DECOY_MODE.md`. +- Files to edit: `docs/DECOY_MODE.md` +- Commands to run: `npm run check` +- Acceptance criteria: Examples use catalog-scoped test URLs, contain no personal data, do not create real requests, and preserve all event/transaction fields. diff --git a/docs/LAUNCH_COPY.md b/docs/LAUNCH_COPY.md index a7ea36a..cbfeded 100644 --- a/docs/LAUNCH_COPY.md +++ b/docs/LAUNCH_COPY.md @@ -2,12 +2,12 @@ ## GitHub Repo Description -Lightweight, local-only MV3 Chrome extension for reducing common third-party web tracking. +Lightweight, local-only MV3 Chrome extension for blocking common third-party trackers, with an optional experimental identifier Decoy Mode. ## Hacker News / Reddit Post -I'm building GetBlocked!, a small local-only MV3 tracker-blocking extension. I'm specifically looking for PRs: tracker-domain additions, broken-site tests, docs, and UI polish. I made the contribution path intentionally small: one domain + one test fixture. +I'm building GetBlocked!, a small local-only MV3 tracker-blocking extension. It now includes an optional experimental Decoy Mode that keeps URL cleanup on while replacing supported identifiers in page-initiated requests to catalog trackers. It does not hide IP/network metadata or fabricate transactions. I'm specifically looking for PRs: tracker-domain additions, broken-site tests, docs, and UI polish. ## Short Social Post -GetBlocked! is a local-only Chrome extension that blocks common trackers, cleans tracking links, and helps users understand tracking attempts. PRs welcome. +GetBlocked! blocks common trackers, cleans tracking links, and offers an optional experimental Decoy Mode for supported analytics identifiers. It never fabricates events, and allowed tracker requests can still reveal IP/network metadata. PRs welcome. diff --git a/manifest.json b/manifest.json index a9f968c..05fc984 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "GetBlocked!", - "version": "0.1.0", + "version": "0.2.0", "minimum_chrome_version": "120", "description": "GetBlocked! is a free browser extension that reduces common third-party website trackers.", "action": { @@ -30,6 +30,33 @@ ] }, "content_scripts": [ + { + "matches": [ + "http://*/*", + "https://*/*" + ], + "js": [ + "shared/config.js", + "shared/decoy-transform.js", + "decoy-interceptor.js" + ], + "all_frames": true, + "match_about_blank": true, + "run_at": "document_start", + "world": "MAIN" + }, + { + "matches": [ + "http://*/*", + "https://*/*" + ], + "js": [ + "decoy-bridge.js" + ], + "all_frames": true, + "match_about_blank": true, + "run_at": "document_start" + }, { "matches": [ "http://*/*", diff --git a/package.json b/package.json index 6ea70cb..1d44325 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,17 @@ { "name": "getblocked", - "version": "0.1.0", + "version": "0.2.0", "private": true, "description": "GetBlocked! is a free browser extension that reduces common third-party website trackers.", "scripts": { "generate:rules": "node scripts/generate-rules.mjs", "test:evidence": "node scripts/evaluate-test-set.mjs", - "check": "npm run generate:rules && npm run test:evidence && npm run check:syntax && npm run check:json && npm run check:generated && npm run check:safety", - "check:syntax": "node --check background.js && node --check content-script.js && node --check popup/popup.js && node --check scripts/generate-rules.mjs && node --check scripts/evaluate-test-set.mjs && node --check scripts/check-json.mjs && node --check scripts/check-safety.mjs && node --check shared/config.js", + "test:decoy": "node scripts/test-decoy.mjs", + "check": "npm run generate:rules && npm run test:evidence && npm run test:decoy && npm run check:syntax && npm run check:json && npm run check:generated && npm run check:safety", + "check:syntax": "node --check background.js && node --check content-script.js && node --check decoy-bridge.js && node --check decoy-interceptor.js && node --check popup/popup.js && node --check scripts/generate-rules.mjs && node --check scripts/evaluate-test-set.mjs && node --check scripts/check-json.mjs && node --check scripts/check-safety.mjs && node --check scripts/test-decoy.mjs && node --check shared/config.js && node --check shared/decoy-transform.js", "check:json": "node scripts/check-json.mjs", "check:generated": "git diff --exit-code -- rules/rules.json shared/config.js", - "check:safety": "node scripts/check-safety.mjs" + "check:safety": "node scripts/check-safety.mjs", + "test:browser": "node scripts/browser-test.mjs" } } diff --git a/popup/popup.css b/popup/popup.css index 03967b4..a6a05bb 100644 --- a/popup/popup.css +++ b/popup/popup.css @@ -9,9 +9,22 @@ --accent-soft: #eff6ff; --mint: #a7f3d0; --success: #10b981; + --warning: #d97706; --shadow: 0 14px 30px rgba(31, 41, 55, 0.08); } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + * { box-sizing: border-box; } @@ -106,6 +119,98 @@ h1 { gap: 8px; } +.mode-card { + margin-bottom: 10px; + padding: 12px 13px; + border: 1px solid #fde68a; + border-radius: 14px; + background: #fffbeb; + box-shadow: 0 8px 20px rgba(31, 41, 55, 0.04); +} + +.mode-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.mode-title { + font-weight: 800; +} + +.experimental-badge { + display: inline-block; + margin-left: 5px; + padding: 2px 6px; + border-radius: 999px; + color: #92400e; + background: #fef3c7; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.mode-card p { + margin: 7px 0 0; + color: var(--muted); + font-size: 11.5px; +} + +.switch { + position: relative; + display: inline-flex; + flex: 0 0 auto; + cursor: pointer; +} + +.switch input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.switch-track { + position: relative; + width: 40px; + height: 22px; + border-radius: 999px; + background: #d1d5db; + transition: background 140ms ease; +} + +.switch-track::after { + position: absolute; + top: 3px; + left: 3px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 1px 3px rgba(31, 41, 55, 0.3); + content: ""; + transition: transform 140ms ease; +} + +.switch input:checked + .switch-track { + background: var(--accent); +} + +.switch input:checked + .switch-track::after { + transform: translateX(18px); +} + +.switch input:focus-visible + .switch-track { + outline: 3px solid rgba(59, 130, 246, 0.28); + outline-offset: 2px; +} + +.switch input:disabled + .switch-track { + cursor: wait; + opacity: 0.6; +} + .stat { display: flex; align-items: center; @@ -201,3 +306,13 @@ h1 { font-size: 12px; font-weight: 650; } + +.footer.is-warning { + border-color: rgba(217, 119, 6, 0.34); + background: rgba(254, 243, 199, 0.68); +} + +.footer.is-warning .status-dot { + background: var(--warning); + box-shadow: 0 0 0 4px rgba(217, 119, 6, 0.12); +} diff --git a/popup/popup.html b/popup/popup.html index c38a358..5a46049 100644 --- a/popup/popup.html +++ b/popup/popup.html @@ -15,15 +15,36 @@

GetBlocked!

Privacy report

-

Cleaner pages. Fewer trackers. Local-only.

+

Blocking known trackers and cleaning URLs.

+
+
+
+ Decoy Mode + Experimental +
+ +
+

+ Off: known third-party tracker requests are blocked as usual. +

+
+
Blocked on this page 0
+
+ Decoyed on this page + 0 +
Tracking links cleaned 0 @@ -43,7 +64,7 @@

Detected categories

-

No browsing data leaves your device.

+

GetBlocked! keeps its report data on your device.

diff --git a/popup/popup.js b/popup/popup.js index 790bdeb..ace4f08 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -1,10 +1,17 @@ const pageBlockedEl = document.querySelector("#page-blocked"); +const pageDecoyedEl = document.querySelector("#page-decoyed"); const trackingLinksCleanedEl = document.querySelector( "#tracking-links-cleaned" ); const visibleAttemptsEl = document.querySelector("#visible-attempts"); const detectedCategoriesEl = document.querySelector("#detected-categories"); const statusLineEl = document.querySelector("#status-line"); +const footerEl = document.querySelector(".footer"); +const modeSummaryEl = document.querySelector("#mode-summary"); +const decoyModeToggleEl = document.querySelector("#decoy-mode-toggle"); +const decoyModeDescriptionEl = document.querySelector( + "#decoy-mode-description" +); function formatCount(value) { return new Intl.NumberFormat().format(Number(value) || 0); @@ -45,13 +52,36 @@ function requestReport(tabId) { }); } +function setDecoyMode(enabled) { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage( + { type: "SET_GETBLOCKED_DECOY_MODE", enabled }, + (response) => { + const error = chrome.runtime.lastError; + if (error) { + reject(error); + return; + } + + if (!response?.ok) { + reject(new Error(response?.error || "Unable to update Decoy Mode")); + return; + } + + resolve(response.configuration); + } + ); + }); +} + function renderReport(report) { - const { page } = report; + const { page, decoyMode } = report; const categories = Array.isArray(page.detectedCategories) ? page.detectedCategories : []; pageBlockedEl.textContent = formatCount(page.blockedOnPage); + pageDecoyedEl.textContent = formatCount(page.decoyedRequests); trackingLinksCleanedEl.textContent = formatCount(page.trackingLinksCleaned); visibleAttemptsEl.textContent = formatCount(page.visibleAttempts); detectedCategoriesEl.replaceChildren( @@ -70,7 +100,22 @@ function renderReport(report) { emptyItem.className = "is-empty"; } - statusLineEl.textContent = "No browsing data leaves your device."; + decoyModeToggleEl.checked = decoyMode === true; + footerEl.classList.toggle("is-warning", decoyMode === true); + + if (decoyMode) { + modeSummaryEl.textContent = "Sending consistent fake identifiers where feasible."; + decoyModeDescriptionEl.textContent = + "On: tracker blocking is paused, URL cleanup stays on, and supported analytics identifiers are replaced."; + statusLineEl.textContent = + "Trackers may still see your IP address and other network metadata."; + } else { + modeSummaryEl.textContent = "Blocking known trackers and cleaning URLs."; + decoyModeDescriptionEl.textContent = + "Off: known third-party tracker requests are blocked as usual."; + statusLineEl.textContent = + "GetBlocked! keeps its report data on your device."; + } } async function loadReport() { @@ -87,4 +132,20 @@ async function loadReport() { } } +async function handleDecoyModeChange() { + const enabled = decoyModeToggleEl.checked; + decoyModeToggleEl.disabled = true; + + try { + await setDecoyMode(enabled); + await loadReport(); + } catch (error) { + decoyModeToggleEl.checked = !enabled; + statusLineEl.textContent = "Decoy Mode could not be updated. Try again."; + } finally { + decoyModeToggleEl.disabled = false; + } +} + document.addEventListener("DOMContentLoaded", loadReport); +decoyModeToggleEl.addEventListener("change", handleDecoyModeChange); diff --git a/scripts/browser-test.mjs b/scripts/browser-test.mjs new file mode 100644 index 0000000..faeab74 --- /dev/null +++ b/scripts/browser-test.mjs @@ -0,0 +1,928 @@ +/** + * GetBlocked! browser test harness. + * + * Uses only Node.js built-ins and Chrome's remote debugging protocol (CDP). + * No npm packages required. + * + * What it tests: + * 1. Tracking URL parameters are removed from the final page URL by the + * extension's declarativeNetRequest redirect rules. + * 2. The extension background service worker returns a report with + * reasonable (nonzero total activity) values. + * 3. The popup exposes an experimental Decoy Mode toggle and privacy warning. + * 4. Decoy Mode keeps URL cleanup on, disables only tracker blocking, reuses + * one session profile, counts modified requests, and avoids false blocks. + * 5. Turning Decoy Mode off through the popup restores normal blocking. + * 6. No uncaught runtime exceptions are thrown by the extension. + * + * Usage: + * node scripts/browser-test.mjs + * + * Environment variables: + * CHROME_PATH – override Chromium-family browser executable path + * TEST_PORT – override local static server port (default 8765) + * DEBUG_PORT – override Chrome CDP remote debugging port (default 9333) + */ + +import fs from "node:fs"; +import http from "node:http"; +import net from "node:net"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import os from "node:os"; +import crypto from "node:crypto"; + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const TEST_PORT = Number(process.env.TEST_PORT) || 8765; +const DEBUG_PORT = Number(process.env.DEBUG_PORT) || 9333; +const WAIT_MS = 3500; // ms to wait for extension content script after navigation + +/** Tracking params the extension should strip (subset to check). */ +const PARAMS_TO_STRIP = ["utm_source", "utm_medium", "fbclid", "gclid"]; + +/** URL loaded in the browser, with tracking params the extension should clean. */ +const TEST_PATH = "/test/manual-test.html"; +const TEST_PARAMS = "utm_source=test&utm_medium=browser&fbclid=abc123&gclid=xyz789"; + +// --------------------------------------------------------------------------- +// Logging helpers +// --------------------------------------------------------------------------- + +const PREFIX = "[browser-test]"; + +function log(...args) { + console.log(PREFIX, ...args); +} + +function pass(label) { + console.log(`${PREFIX} \u2705 PASS ${label}`); +} + +function fail(label, detail = "") { + console.error(`${PREFIX} \u274c FAIL ${label}${detail ? `: ${detail}` : ""}`); +} + +function warn(...args) { + console.warn(`${PREFIX} \u26a0\ufe0f `, ...args); +} + +// --------------------------------------------------------------------------- +// Chrome detection +// --------------------------------------------------------------------------- + +/** + * Returns the path to the Chrome (or Chromium) executable, or null if not found. + */ +function findChrome() { + if (process.env.CHROME_PATH) { + return process.env.CHROME_PATH; + } + + const candidates = [ + // Windows: prefer builds that still honor --load-extension in automation. + "C:\\Program Files\\Google\\Chrome for Testing\\Application\\chrome.exe", + path.join( + process.env.LOCALAPPDATA || "", + "Google\\Chrome for Testing\\Application\\chrome.exe" + ), + "C:\\Program Files\\Chromium\\Application\\chrome.exe", + "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", + path.join( + process.env.LOCALAPPDATA || "", + "Google\\Chrome\\Application\\chrome.exe" + ), + // macOS + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + // Linux + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/snap/bin/chromium", + ]; + + for (const candidate of candidates) { + if (candidate && fs.existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +// --------------------------------------------------------------------------- +// Local static file server +// --------------------------------------------------------------------------- + +/** + * Starts a minimal static file server serving files from `root`. + * Binds to 127.0.0.1 only. Returns { server }. + */ +function startFileServer(root, port) { + const MIME = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".ico": "image/x-icon", + }; + + const server = http.createServer((req, res) => { + const urlPath = new URL(req.url, `http://127.0.0.1:${port}`).pathname; + const filePath = path.join(root, urlPath); + const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep; + if (!filePath.startsWith(rootWithSep) && filePath !== root) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + let targetPath = filePath; + if (fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()) { + targetPath = path.join(targetPath, "index.html"); + } + if (!fs.existsSync(targetPath)) { + res.writeHead(404); + res.end("Not found"); + return; + } + const ext = path.extname(targetPath).toLowerCase(); + res.writeHead(200, { + "Content-Type": MIME[ext] || "application/octet-stream", + }); + fs.createReadStream(targetPath).pipe(res); + }); + + return new Promise((resolve, reject) => { + server.listen(port, "127.0.0.1", () => resolve({ server })); + server.once("error", reject); + }); +} + +// --------------------------------------------------------------------------- +// CDP helpers +// --------------------------------------------------------------------------- + +/** GETs a JSON endpoint from the Chrome CDP HTTP server, retrying on failure. */ +async function cdpHttpGet(debugPort, endpoint, retries = 15) { + for (let i = 0; i < retries; i++) { + try { + return await new Promise((resolve, reject) => { + const req = http.get( + `http://127.0.0.1:${debugPort}${endpoint}`, + (res) => { + let body = ""; + res.on("data", (chunk) => { body += chunk; }); + res.on("end", () => { + try { resolve(JSON.parse(body)); } + catch (e) { reject(e); } + }); + } + ); + req.on("error", reject); + req.setTimeout(2000, () => req.destroy(new Error("CDP HTTP timeout"))); + }); + } catch { + await sleep(500); + } + } + throw new Error(`CDP HTTP GET ${endpoint} failed after ${retries} retries`); +} + +/** + * Opens a CDP WebSocket session using a raw Node.js net.Socket. + * + * Per the WebSocket RFC 6455, client frames MUST be masked. Chrome enforces + * this and also requires --remote-allow-origins=* (or a matching origin) to + * accept connections from non-browser clients. + * + * Returns { send(method, params) → Promise, on(event, fn), close() } + */ +function openCdpSession(wsUrl) { + // Normalise hostname: Chrome lists "localhost" but binds to 127.0.0.1 + const normUrl = wsUrl.replace(/^ws:\/\/localhost/, "ws://127.0.0.1"); + const url = new URL(normUrl); + const wsKey = crypto.randomBytes(16).toString("base64"); + + return new Promise((resolve, reject) => { + const socket = net.createConnection( + { host: "127.0.0.1", port: Number(url.port) || 9222 }, + () => { + // No Origin header needed when Chrome is launched with --remote-allow-origins=* + socket.write( + [ + `GET ${url.pathname} HTTP/1.1`, + `Host: 127.0.0.1:${url.port}`, + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Key: ${wsKey}`, + "Sec-WebSocket-Version: 13", + "", + "", + ].join("\r\n") + ); + } + ); + socket.once("error", reject); + + let handshakeDone = false; + let buf = Buffer.alloc(0); + let idCounter = 1; + const pending = new Map(); + const eventHandlers = new Map(); + + /** + * Sends a WebSocket text frame with proper RFC 6455 client-side masking. + * The mask bit MUST be set for client→server frames. + */ + function sendWsFrame(text) { + const payload = Buffer.from(text, "utf8"); + const mask = crypto.randomBytes(4); + const masked = Buffer.allocUnsafe(payload.length); + for (let i = 0; i < payload.length; i++) { + masked[i] = payload[i] ^ mask[i % 4]; + } + const len = payload.length; + let header; + if (len < 126) { + // FIN=1, opcode=1 (text), MASK=1 + header = Buffer.from([0x81, 0x80 | len]); + } else if (len < 65536) { + header = Buffer.allocUnsafe(4); + header[0] = 0x81; + header[1] = 0x80 | 126; + header.writeUInt16BE(len, 2); + } else { + header = Buffer.allocUnsafe(10); + header[0] = 0x81; + header[1] = 0x80 | 127; + header.writeBigUInt64BE(BigInt(len), 2); + } + socket.write(Buffer.concat([header, mask, masked])); + } + + /** + * Parses complete WebSocket frames from a Buffer. + * Server→client frames are NOT masked (mask bit = 0). + * Returns { frames: [{opcode, payload}], remaining: Buffer } + */ + function parseFrames(inBuf) { + const frames = []; + let offset = 0; + while (offset + 2 <= inBuf.length) { + const b0 = inBuf[offset]; + const b1 = inBuf[offset + 1]; + const opcode = b0 & 0x0f; + const masked = (b1 & 0x80) !== 0; + let payloadLen = b1 & 0x7f; + let headerLen = 2; + if (payloadLen === 126) { + if (inBuf.length - offset < 4) break; + payloadLen = inBuf.readUInt16BE(offset + 2); + headerLen = 4; + } else if (payloadLen === 127) { + if (inBuf.length - offset < 10) break; + payloadLen = Number(inBuf.readBigUInt64BE(offset + 2)); + headerLen = 10; + } + const maskLen = masked ? 4 : 0; + const frameEnd = offset + headerLen + maskLen + payloadLen; + if (inBuf.length < frameEnd) break; + let payload = inBuf.slice(offset + headerLen + maskLen, frameEnd); + if (masked) { + const maskBytes = inBuf.slice( + offset + headerLen, + offset + headerLen + 4 + ); + payload = Buffer.from(payload.map((b, i) => b ^ maskBytes[i % 4])); + } + frames.push({ opcode, payload }); + offset = frameEnd; + } + return { frames, remaining: inBuf.slice(offset) }; + } + + function dispatchFrames(frames) { + for (const { opcode, payload } of frames) { + if (opcode === 8) { + // Close frame — Chrome is shutting down the connection + socket.destroy(); + return; + } + if (opcode !== 1 && opcode !== 2) continue; // skip ping/pong/continuation + let msg; + try { msg = JSON.parse(payload.toString("utf8")); } + catch { continue; } + + if (msg.id !== undefined && pending.has(msg.id)) { + const { resolve: res, reject: rej } = pending.get(msg.id); + pending.delete(msg.id); + if (msg.error) { + rej(new Error(msg.error.message || JSON.stringify(msg.error))); + } else { + res(msg.result); + } + } else if (msg.method) { + for (const h of (eventHandlers.get(msg.method) || [])) { + h(msg.params); + } + } + } + } + + socket.on("data", (chunk) => { + buf = Buffer.concat([buf, chunk]); + if (!handshakeDone) { + const idx = buf.indexOf("\r\n\r\n"); + if (idx === -1) return; + const headers = buf.slice(0, idx).toString(); + if (!headers.includes("101")) { + reject(new Error(`WS handshake failed:\n${headers.slice(0, 400)}`)); + socket.destroy(); + return; + } + handshakeDone = true; + buf = buf.slice(idx + 4); + resolve({ + send(method, params = {}) { + return new Promise((res, rej) => { + const id = idCounter++; + pending.set(id, { resolve: res, reject: rej }); + sendWsFrame(JSON.stringify({ id, method, params })); + }); + }, + on(event, handler) { + if (!eventHandlers.has(event)) eventHandlers.set(event, []); + eventHandlers.get(event).push(handler); + }, + close() { socket.destroy(); }, + }); + // Process any frames that arrived with the handshake response + } + const { frames, remaining } = parseFrames(buf); + buf = remaining; + dispatchFrames(frames); + }); + + socket.once("close", () => { + for (const { reject: rej } of pending.values()) { + rej(new Error("CDP socket closed unexpectedly")); + } + pending.clear(); + }); + }); +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Polls until TCP port is open or timeoutMs elapses. */ +function waitForPort(port, timeoutMs = 20000) { + const start = Date.now(); + return new Promise((resolve, reject) => { + function attempt() { + const s = net.createConnection({ host: "127.0.0.1", port }, () => { + s.destroy(); + resolve(); + }); + s.on("error", () => { + s.destroy(); + if (Date.now() - start > timeoutMs) { + reject(new Error(`Timed out waiting for port ${port}`)); + } else { + setTimeout(attempt, 300); + } + }); + } + attempt(); + }); +} + +// --------------------------------------------------------------------------- +// Main test runner +// --------------------------------------------------------------------------- + +async function main() { + let chromeProcess = null; + let fileServer = null; + let browserCdp = null; + let pageCdp = null; + let popupCdp = null; + let extensionCdp = null; + let decoyModeEnabledByTest = false; + let tempDir = null; + let passed = 0; + let failed = 0; + let skipped = false; + + function check(label, condition, detail = "") { + if (condition) { pass(label); passed++; } + else { fail(label, detail); failed++; } + } + + try { + // ------------------------------------------------------------------ + // 1. Locate Chrome + // ------------------------------------------------------------------ + const chromePath = findChrome(); + if (!chromePath) { + warn( + "Chrome not found. Set CHROME_PATH env var or install Chrome.\n" + + " Skipping browser test (exit 0)." + ); + process.exit(0); + } + log("Chrome:", chromePath); + + // ------------------------------------------------------------------ + // 2. Start static file server (must be HTTP, not file://, for DNR rules) + // ------------------------------------------------------------------ + ({ server: fileServer } = await startFileServer(ROOT, TEST_PORT)); + log(`Local server: http://127.0.0.1:${TEST_PORT}`); + + // ------------------------------------------------------------------ + // 3. Launch Chrome with unpacked extension + // ------------------------------------------------------------------ + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "getblocked-test-")); + log("Extension:", ROOT); + log("Temp profile:", tempDir); + + const chromeArgs = [ + `--load-extension=${ROOT}`, + `--disable-extensions-except=${ROOT}`, + `--user-data-dir=${tempDir}`, + `--remote-debugging-port=${DEBUG_PORT}`, + // Allow CDP WebSocket connections from non-browser clients. + // This is safe because the port is ephemeral and bound to 127.0.0.1 only. + "--remote-allow-origins=*", + "--no-first-run", + "--no-default-browser-check", + "--disable-sync", + "--disable-client-side-phishing-detection", + "--disable-default-apps", + "--disable-popup-blocking", + "--allow-running-insecure-content", + "--headless=new", + ]; + + chromeProcess = spawn(chromePath, chromeArgs, { + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }); + + // Capture the browser-level WS URL from Chrome's startup log line. + let browserWsUrl = null; + chromeProcess.stderr.on("data", (data) => { + const line = data.toString(); + const m = line.match(/DevTools listening on (ws:\/\/\S+)/); + if (m) browserWsUrl = m[1]; + }); + + chromeProcess.on("exit", (code) => { + if (code !== null && code !== 0) log(`Chrome exited with code ${code}`); + }); + + // Wait for CDP debug port to open + log(`Waiting for CDP on port ${DEBUG_PORT} ...`); + await waitForPort(DEBUG_PORT, 20000); + // Give the extension service worker time to initialise + await sleep(2000); + + // ------------------------------------------------------------------ + // 4. Connect to the browser-level CDP target + // ------------------------------------------------------------------ + if (!browserWsUrl) { + // Fallback: read from /json/version (Chrome also exposes this) + const versionInfo = await cdpHttpGet(DEBUG_PORT, "/json/version"); + browserWsUrl = versionInfo.webSocketDebuggerUrl; + } + log("Browser WS:", browserWsUrl); + + browserCdp = await openCdpSession(browserWsUrl); + + // Verify the connection works before proceeding + const versionResult = await browserCdp.send("Browser.getVersion"); + log("Chrome version:", versionResult?.product || "(unknown)"); + + // ------------------------------------------------------------------ + // 5. Create a new tab and connect to it at the page level + // ------------------------------------------------------------------ + const createResult = await browserCdp.send("Target.createTarget", { + url: "about:blank", + }); + const tabTargetId = createResult.targetId; + log("Created tab target:", tabTargetId); + + // Give Chrome a moment to register the new tab + await sleep(500); + + // Find the page-level WS URL for the tab we just created + const allTargets = await cdpHttpGet(DEBUG_PORT, "/json/list"); + log( + "All targets:", + allTargets.map((t) => `${t.type}:${t.url}`).join(", ") + ); + + const pageTarget = + allTargets.find((t) => t.targetId === tabTargetId) || + allTargets.find((t) => t.type === "page" && t.url === "about:blank"); + + if (!pageTarget || !pageTarget.webSocketDebuggerUrl) { + throw new Error( + `Could not find page WS URL for tab ${tabTargetId}` + ); + } + + log("Connecting to page target:", pageTarget.url); + pageCdp = await openCdpSession(pageTarget.webSocketDebuggerUrl); + + // Capture runtime exceptions + const runtimeExceptions = []; + pageCdp.on("Runtime.exceptionThrown", (params) => { + const desc = + params?.exceptionDetails?.exception?.description || + params?.exceptionDetails?.text || + JSON.stringify(params); + runtimeExceptions.push(desc); + }); + + await pageCdp.send("Runtime.enable"); + await pageCdp.send("Page.enable"); + + // Navigate to the test URL with tracking URL parameters. + // The extension's DNR redirect/queryTransform rules strip them before commit. + const testUrl = `http://127.0.0.1:${TEST_PORT}${TEST_PATH}?${TEST_PARAMS}`; + log(`Navigating to: ${testUrl}`); + await pageCdp.send("Page.navigate", { url: testUrl }); + + // Wait for page load + extension content script (runs at document_idle) + log(`Waiting ${WAIT_MS}ms for page load and content script ...`); + await sleep(WAIT_MS); + + // ------------------------------------------------------------------ + // 6. Assert: tracking params stripped from the final page URL + // ------------------------------------------------------------------ + const urlResult = await pageCdp.send("Runtime.evaluate", { + expression: "location.href", + returnByValue: true, + }); + + const finalUrl = urlResult?.result?.value || ""; + log("Final page URL:", finalUrl); + + const loadedTargets = await cdpHttpGet(DEBUG_PORT, "/json/list"); + const extensionLoaded = loadedTargets.some( + (target) => + target.type === "service_worker" && + target.url.startsWith("chrome-extension://") && + target.url.includes("background.js") + ); + if (!extensionLoaded) { + throw new Error("Unpacked extension was not loaded by this browser"); + } + + let finalSearch = ""; + try { finalSearch = new URL(finalUrl).search; } catch { /* ignore */ } + + for (const param of PARAMS_TO_STRIP) { + const present = new URLSearchParams(finalSearch).has(param); + check( + `URL param stripped: ${param}`, + !present, + present ? `param still present in final URL: ${finalUrl}` : "" + ); + } + + // ------------------------------------------------------------------ + // 7. Assert: background service worker returns a valid report + // ------------------------------------------------------------------ + // Identify and connect directly to the extension service worker target. + const latestTargets = await cdpHttpGet(DEBUG_PORT, "/json/list"); + const swTarget = latestTargets.find( + (t) => + t.type === "service_worker" && + t.url.startsWith("chrome-extension://") && + t.url.includes("background.js") + ); + const extensionId = swTarget ? new URL(swTarget.url).hostname : null; + log("Extension ID:", extensionId || "(not found)"); + + if (extensionId && swTarget.webSocketDebuggerUrl) { + extensionCdp = await openCdpSession(swTarget.webSocketDebuggerUrl); + await extensionCdp.send("Runtime.enable"); + + async function evaluateExtension(expression) { + const result = await extensionCdp.send("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true, + timeout: 6000, + }); + return result?.result?.value; + } + + const extensionIdentity = await evaluateExtension(`({ + id: chrome.runtime.id, + name: chrome.runtime.getManifest().name, + version: chrome.runtime.getManifest().version + })`); + log("Extension identity:", JSON.stringify(extensionIdentity)); + + const popupUrl = `chrome-extension://${extensionId}/popup/popup.html`; + const popupTargetResult = await browserCdp.send("Target.createTarget", { + url: popupUrl, + }); + await sleep(500); + const popupTargets = await cdpHttpGet(DEBUG_PORT, "/json/list"); + const popupTarget = popupTargets.find( + (target) => target.targetId === popupTargetResult.targetId + ) || popupTargets.find((target) => target.url === popupUrl); + if (!popupTarget?.webSocketDebuggerUrl) { + throw new Error( + `Could not open the extension popup page: ${popupTargets + .map((target) => `${target.type}:${target.url}`) + .join(", ")}` + ); + } + popupCdp = await openCdpSession(popupTarget.webSocketDebuggerUrl); + await popupCdp.send("Runtime.enable"); + await sleep(500); + + async function evaluatePopup(expression) { + const result = await popupCdp.send("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true, + timeout: 6000, + }); + return result?.result?.value; + } + + const initialPopup = await evaluatePopup(`({ + checked: document.querySelector("#decoy-mode-toggle")?.checked, + experimental: + document.querySelector(".experimental-badge")?.textContent?.trim() + })`); + check( + "Popup: experimental Decoy Mode toggle is present and off by default", + initialPopup?.checked === false && + initialPopup?.experimental === "Experimental", + JSON.stringify(initialPopup) + ); + + const tabId = await evaluateExtension(` + (async () => { + const tabs = await chrome.tabs.query({}); + return tabs.find((tab) => + String(tab.url || "").includes("/test/manual-test.html") + )?.id ?? -1; + })() + `); + const report = await evaluateExtension(` + getReport(${JSON.stringify(tabId)}).then((report) => ({ ok: true, report })) + `); + + log("Background report:", JSON.stringify(report)); + + if (report?.ok) { + check( + "Background report: ok === true", + report.ok === true, + JSON.stringify(report) + ); + check( + "Background report: localOnly flag present", + report.report?.localOnly === true, + JSON.stringify(report?.report) + ); + + // manual-test.html has a tracking pixel, iframe, tracker scripts, and + // a link with tracking params — at least one signal should be nonzero. + const page = report.report?.page || {}; + const totalActivity = + (page.trackerElementsDetected || 0) + + (page.trackingLinksDetected || 0) + + (page.estimatedTrackerRequests || 0) + + (page.trackingParamsRemoved || 0); + + check( + "Background report: nonzero page activity", + totalActivity > 0, + `totalActivity=${totalActivity}, page=${JSON.stringify(page)}` + ); + } else { + check("Background report: valid response", false, JSON.stringify(report)); + } + + const enabledPopup = await evaluatePopup(` + (async () => { + const toggle = document.querySelector("#decoy-mode-toggle"); + toggle.click(); + await new Promise((resolve) => setTimeout(resolve, 1000)); + return { + checked: toggle.checked, + status: document.querySelector("#status-line")?.textContent || "" + }; + })() + `); + decoyModeEnabledByTest = + enabledPopup?.checked === true && + enabledPopup?.status.includes("IP address"); + check( + "Popup: Decoy Mode enabled with network privacy warning", + decoyModeEnabledByTest, + JSON.stringify(enabledPopup) + ); + + const disabledRuleIds = await evaluateExtension(` + chrome.declarativeNetRequest.getDisabledRuleIds({ + rulesetId: "getblocked_static_rules" + }) + `); + check( + "Decoy Mode: blocking rule disabled and cleanup rule enabled", + Array.isArray(disabledRuleIds) && + disabledRuleIds.includes(1) && + !disabledRuleIds.includes(1000), + JSON.stringify(disabledRuleIds) + ); + + const firstConfiguration = await evaluateExtension(` + getDecoyConfiguration().then((configuration) => ({ + ok: true, + configuration + })) + `); + const secondConfiguration = await evaluateExtension(` + getDecoyConfiguration().then((configuration) => ({ + ok: true, + configuration + })) + `); + check( + "Decoy Mode: one coherent session profile", + firstConfiguration?.configuration?.profile?.userId && + firstConfiguration.configuration.profile.userId === + secondConfiguration?.configuration?.profile?.userId, + JSON.stringify({ firstConfiguration, secondConfiguration }) + ); + + await pageCdp.send("Page.navigate", { url: testUrl }); + await sleep(WAIT_MS); + + const decoyUrlResult = await pageCdp.send("Runtime.evaluate", { + expression: "location.href", + returnByValue: true, + }); + const decoyPageUrl = decoyUrlResult?.result?.value || ""; + const decoySearch = new URL(decoyPageUrl).searchParams; + check( + "Decoy Mode: URL cleanup remains enabled", + PARAMS_TO_STRIP.every((param) => !decoySearch.has(param)), + decoyPageUrl + ); + + const decoyReport = await evaluateExtension(` + getReport(${JSON.stringify(tabId)}).then((report) => ({ ok: true, report })) + `); + check( + "Decoy Mode: modified requests counted without false blocks", + decoyReport?.report?.decoyMode === true && + decoyReport?.report?.page?.decoyedRequests > 0 && + decoyReport?.report?.page?.blockedOnPage === 0, + JSON.stringify(decoyReport) + ); + + const disabledPopup = await evaluatePopup(` + (async () => { + const toggle = document.querySelector("#decoy-mode-toggle"); + toggle.click(); + await new Promise((resolve) => setTimeout(resolve, 1000)); + return { + checked: toggle.checked, + status: document.querySelector("#status-line")?.textContent || "" + }; + })() + `); + const disabledConfiguration = await evaluateExtension(` + getDecoyConfiguration() + `); + decoyModeEnabledByTest = disabledConfiguration?.enabled === true; + check( + "Popup: normal blocking restored and setting saved", + disabledPopup?.checked === false && + disabledConfiguration?.enabled === false, + JSON.stringify({ disabledPopup, disabledConfiguration }) + ); + } else { + warn( + "Could not determine extension ID from service-worker target.\n" + + " Skipping background report check." + ); + check("Background report: extension loaded", false); + } + + // ------------------------------------------------------------------ + // 8. Assert: no unexpected runtime exceptions in the page + // ------------------------------------------------------------------ + // Benign errors caused by the test page intentionally loading tracker + // scripts and pixels that are blocked by the extension's DNR rules. + const realErrors = runtimeExceptions.filter((e) => { + if (e.includes("Failed to fetch")) return false; + if (e.includes("NetworkError")) return false; + if (e.includes("net::ERR_")) return false; + if (e.includes("ERR_BLOCKED_BY_CLIENT")) return false; + if (e.includes("fbq is not defined")) return false; + return true; + }); + + check( + "No unexpected runtime exceptions", + realErrors.length === 0, + realErrors.length > 0 ? realErrors.slice(0, 3).join(" | ") : "" + ); + + } catch (err) { + // Graceful skip for known unsupported environments + if ( + err.message && + (err.message.includes("Timed out waiting for port") || + err.message.includes("Could not connect to CDP") || + err.message.includes("Unpacked extension was not loaded") || + err.message.includes("headless")) + ) { + warn( + "Could not run an unpacked extension in this headless browser.\n" + + " Use a compatible Chromium-family build or set CHROME_PATH.\n" + + " Skipping browser test (exit 0)." + ); + skipped = true; + } else { + console.error(PREFIX, "Unexpected error:", err); + failed++; + } + } finally { + // ------------------------------------------------------------------ + // Cleanup: close CDP connections, kill Chrome, clean up temp dir + // ------------------------------------------------------------------ + if (extensionCdp && decoyModeEnabledByTest) { + try { + await extensionCdp.send("Runtime.evaluate", { + expression: `setDecoyMode(false)`, + awaitPromise: true, + returnByValue: true, + }); + } catch { /* Temp profile cleanup also discards the setting. */ } + } + if (popupCdp) { try { popupCdp.close(); } catch { /* ignore */ } } + if (extensionCdp) { try { extensionCdp.close(); } catch { /* ignore */ } } + if (pageCdp) { try { pageCdp.close(); } catch { /* ignore */ } } + if (browserCdp) { try { browserCdp.close(); } catch { /* ignore */ } } + + if (chromeProcess) { + try { chromeProcess.kill(); } catch { /* ignore */ } + await sleep(500); // Allow Chrome to release file handles before rmSync + } + + if (fileServer) { fileServer.close(() => {}); } + + if (tempDir) { + try { fs.rmSync(tempDir, { recursive: true, force: true }); } + catch { /* Windows may briefly lock files after Chrome exits */ } + } + } + + // ------------------------------------------------------------------ + // Summary + // ------------------------------------------------------------------ + if (skipped) { + log("Browser test skipped because this browser did not load the unpacked extension."); + process.exit(0); + } + + log(""); + log(`Results: ${passed} passed, ${failed} failed`); + + if (failed === 0) { + log("All checks passed. \u2728"); + process.exit(0); + } else { + console.error(PREFIX, `${failed} check(s) failed.`); + process.exit(1); + } +} + +main(); diff --git a/scripts/check-safety.mjs b/scripts/check-safety.mjs index 304c3f1..85c9b6a 100644 --- a/scripts/check-safety.mjs +++ b/scripts/check-safety.mjs @@ -7,11 +7,14 @@ const sourceFiles = [ "manifest.json", "background.js", "content-script.js", + "decoy-bridge.js", + "decoy-interceptor.js", "popup/popup.html", "popup/popup.css", "popup/popup.js", "rules/rules.json", "shared/config.js", + "shared/decoy-transform.js", "shared/tracker-catalog.json", "shared/tracking-params.json", "scripts/generate-rules.mjs", @@ -31,6 +34,7 @@ const remoteCallPatterns = [ /\bXMLHttpRequest\b/, /\bsendBeacon\b/ ]; +const allowedRuntimeInterceptors = new Set(["decoy-interceptor.js"]); const allowedPermissions = new Set([ "declarativeNetRequest", "storage", @@ -48,7 +52,7 @@ for (const file of sourceFiles) { } } for (const pattern of remoteCallPatterns) { - if (pattern.test(text)) { + if (pattern.test(text) && !allowedRuntimeInterceptors.has(file)) { console.error(`${file}: possible external runtime call ${pattern}`); failed = true; } diff --git a/scripts/test-decoy.mjs b/scripts/test-decoy.mjs new file mode 100644 index 0000000..d71905c --- /dev/null +++ b/scripts/test-decoy.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import vm from "node:vm"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const source = fs.readFileSync( + path.join(rootDir, "shared", "decoy-transform.js"), + "utf8" +); +const context = vm.createContext({ URL, URLSearchParams }); +vm.runInContext(source, context); + +const transform = context.GetBlockedDecoyTransform; +const profile = { + anonymousId: "anon_consistent", + clientId: "12345.67890", + userId: "user_consistent", + deviceId: "device_consistent", + sessionId: "session_consistent", + email: "casey.reed@example.invalid", + firstName: "Casey", + lastName: "Reed", + fullName: "Casey Reed", + username: "casey_reed", + phone: "+12025550142" +}; + +assert.equal( + transform.isKnownTrackerUrl( + "https://api.segment.io/v1/track", + "https://example.com", + ["segment.io"] + ), + true +); +assert.equal( + transform.isKnownTrackerUrl( + "https://metrics.example/v1/track", + "https://example.com", + ["segment.io"] + ), + false +); + +const queryResult = transform.replaceUrl( + "https://api.segment.io/v1/track?anonymous_id=real&cid=old&event=purchase", + "https://example.com", + profile +); +const queryUrl = new URL(queryResult.value); +assert.equal(queryResult.changed, true); +assert.equal(queryUrl.searchParams.get("anonymous_id"), profile.anonymousId); +assert.equal(queryUrl.searchParams.get("cid"), profile.clientId); +assert.equal(queryUrl.searchParams.get("event"), "purchase"); + +const jsonResult = transform.replaceText( + JSON.stringify({ + event: "purchase", + event_name: "conversion", + transaction_id: "order-real", + ad_click_id: "click-real", + revenue: 49.99, + products: [{ sku: "sku-real", price: 49.99, quantity: 1 }], + user_id: "real-user", + properties: { + email: "real@example.com", + first_name: "Real", + name: "checkout" + } + }), + profile, + "application/json" +); +const jsonBody = JSON.parse(jsonResult.value); +assert.equal(jsonBody.user_id, profile.userId); +assert.equal(jsonBody.properties.email, profile.email); +assert.equal(jsonBody.properties.first_name, profile.firstName); +assert.equal(jsonBody.event, "purchase"); +assert.equal(jsonBody.event_name, "conversion"); +assert.equal(jsonBody.transaction_id, "order-real"); +assert.equal(jsonBody.ad_click_id, "click-real"); +assert.equal(jsonBody.revenue, 49.99); +assert.deepEqual(jsonBody.products, [ + { sku: "sku-real", price: 49.99, quantity: 1 } +]); +assert.equal(jsonBody.properties.name, "checkout"); + +const repeatedResult = transform.replaceText( + "user_id=another-real-user&email=another%40example.com", + profile, + "application/x-www-form-urlencoded" +); +const repeatedBody = new URLSearchParams(repeatedResult.value); +assert.equal(repeatedBody.get("user_id"), profile.userId); +assert.equal(repeatedBody.get("email"), profile.email); + +const unknownResult = transform.replaceText( + "event=page_view&order_id=order-real&amount=19.95", + profile, + "application/x-www-form-urlencoded" +); +assert.equal(unknownResult.changed, false); +assert.equal( + unknownResult.value, + "event=page_view&order_id=order-real&amount=19.95" +); + +console.log("Decoy transformation checks OK"); diff --git a/shared/decoy-transform.js b/shared/decoy-transform.js new file mode 100644 index 0000000..13efb64 --- /dev/null +++ b/shared/decoy-transform.js @@ -0,0 +1,211 @@ +(function exposeGetBlockedDecoyTransform(globalScope) { + const PROFILE_FIELD_MAP = Object.freeze({ + anonymousid: "anonymousId", + anonymousidentifier: "anonymousId", + anonid: "anonymousId", + distinctid: "userId", + ecid: "anonymousId", + clientid: "clientId", + cid: "clientId", + userid: "userId", + uid: "userId", + visitorid: "userId", + customerid: "userId", + personid: "userId", + profileid: "userId", + deviceid: "deviceId", + sessionid: "sessionId", + sid: "sessionId", + email: "email", + emailaddress: "email", + useremail: "email", + firstname: "firstName", + givenname: "firstName", + lastname: "lastName", + surname: "lastName", + familyname: "lastName", + fullname: "fullName", + displayname: "fullName", + username: "username", + phonenumber: "phone", + mobilephone: "phone", + phone: "phone" + }); + + function normalizeFieldName(fieldName) { + return String(fieldName || "") + .toLowerCase() + .replace(/[^a-z0-9]/g, ""); + } + + function replacementForField(fieldName, profile) { + const profileField = PROFILE_FIELD_MAP[normalizeFieldName(fieldName)]; + const replacement = profileField ? profile?.[profileField] : undefined; + + return typeof replacement === "string" && replacement.length > 0 + ? replacement + : null; + } + + function replaceObject(value, profile) { + if (Array.isArray(value)) { + let changed = false; + const nextValue = value.map((item) => { + const result = replaceObject(item, profile); + changed = changed || result.changed; + return result.value; + }); + + return { value: changed ? nextValue : value, changed }; + } + + if (!value || typeof value !== "object") { + return { value, changed: false }; + } + + let changed = false; + const nextValue = Object.create(null); + + for (const [key, currentValue] of Object.entries(value)) { + const replacement = replacementForField(key, profile); + + if (replacement !== null) { + nextValue[key] = replacement; + changed = changed || currentValue !== replacement; + continue; + } + + const nested = replaceObject(currentValue, profile); + nextValue[key] = nested.value; + changed = changed || nested.changed; + } + + return { value: changed ? nextValue : value, changed }; + } + + function replaceSearchParams(params, profile) { + let changed = false; + const nextParams = new URLSearchParams(); + + for (const [key, currentValue] of params.entries()) { + const replacement = replacementForField(key, profile); + const nextValue = replacement === null ? currentValue : replacement; + changed = changed || nextValue !== currentValue; + nextParams.append(key, nextValue); + } + + return { value: changed ? nextParams : params, changed }; + } + + function replaceUrl(rawUrl, baseUrl, profile) { + try { + const url = new URL(rawUrl, baseUrl); + const result = replaceSearchParams(url.searchParams, profile); + + if (!result.changed) { + return { value: rawUrl, changed: false, hostname: url.hostname }; + } + + url.search = result.value.toString(); + return { value: url.href, changed: true, hostname: url.hostname }; + } catch (error) { + return { value: rawUrl, changed: false, hostname: "" }; + } + } + + function replaceText(text, profile, contentType = "") { + if (typeof text !== "string" || text.length === 0) { + return { value: text, changed: false }; + } + + const normalizedType = String(contentType).toLowerCase(); + const trimmed = text.trim(); + const looksLikeJson = + normalizedType.includes("json") || + trimmed.startsWith("{") || + trimmed.startsWith("["); + + if (looksLikeJson) { + try { + const parsed = JSON.parse(text); + const result = replaceObject(parsed, profile); + return result.changed + ? { value: JSON.stringify(result.value), changed: true } + : { value: text, changed: false }; + } catch (error) { + // Fall through to form parsing when a body only resembles JSON. + } + } + + const looksLikeForm = + normalizedType.includes("application/x-www-form-urlencoded") || + (!trimmed.includes("\n") && trimmed.includes("=")); + + if (looksLikeForm) { + const result = replaceSearchParams(new URLSearchParams(text), profile); + return result.changed + ? { value: result.value.toString(), changed: true } + : { value: text, changed: false }; + } + + return { value: text, changed: false }; + } + + function replaceBody(body, profile, contentType = "") { + if (typeof body === "string") { + return replaceText(body, profile, contentType); + } + + if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) { + return replaceSearchParams(body, profile); + } + + if (typeof FormData !== "undefined" && body instanceof FormData) { + let changed = false; + const nextBody = new FormData(); + + for (const [key, currentValue] of body.entries()) { + const replacement = + typeof currentValue === "string" + ? replacementForField(key, profile) + : null; + const nextValue = replacement === null ? currentValue : replacement; + changed = changed || nextValue !== currentValue; + nextBody.append(key, nextValue); + } + + return { value: changed ? nextBody : body, changed }; + } + + return { value: body, changed: false }; + } + + function isKnownTrackerHost(hostname, trackerDomains) { + const normalizedHost = String(hostname || "").toLowerCase(); + return trackerDomains.some((domain) => { + return normalizedHost === domain || normalizedHost.endsWith(`.${domain}`); + }); + } + + function isKnownTrackerUrl(rawUrl, baseUrl, trackerDomains) { + try { + return isKnownTrackerHost( + new URL(rawUrl, baseUrl).hostname, + trackerDomains + ); + } catch (error) { + return false; + } + } + + globalScope.GetBlockedDecoyTransform = Object.freeze({ + isKnownTrackerHost, + isKnownTrackerUrl, + replaceBody, + replaceObject, + replaceSearchParams, + replaceText, + replaceUrl, + replacementForField + }); +})(globalThis); diff --git a/test/manual-test.html b/test/manual-test.html index dd5b3bc..9c10c7f 100644 --- a/test/manual-test.html +++ b/test/manual-test.html @@ -43,8 +43,29 @@

GetBlocked! Manual Test Page

height="80" >