diff --git a/injected/src/detectors/README.md b/injected/src/detectors/README.md new file mode 100644 index 0000000000..d6754af3a6 --- /dev/null +++ b/injected/src/detectors/README.md @@ -0,0 +1,190 @@ +# Web Interference Detection + +This directory contains web interference detection functionality for content-scope-scripts. Detectors identify CAPTCHAs, fraud warnings, and other interference patterns to support breakage reporting and PIR automation. + +## Architecture + +The system uses a **ContentFeature** wrapper with simple detection utilities: + +- **`WebInterferenceDetection`** - ContentFeature that auto-runs detectors on page load +- **Detection utilities** - Pure functions (`runBotDetection`, `runFraudDetection`) with module-level caching +- **Direct imports** - Other features (breakage reporting, PIR) import detection functions directly + + +## Directory Layout + +``` +detectors/ +├── detections/ +│ ├── bot-detection.js # CAPTCHA/bot detection utility +│ └── fraud-detection.js # fraud/phishing warning utility +├── utils/ +│ └── detection-utils.js # DOM helpers (selectors, text matching, visibility) +└── default-config.js # fallback detector settings +``` + +## How It Works + +### 1. Initialization + +The `WebInterferenceDetection` ContentFeature runs detectors automatically: + +1. Feature loads via standard content-scope-features lifecycle +2. `init()` method schedules detectors to run after `autoRunDelayMs` (default: 100ms) +3. Each detector runs once and caches results in module scope +4. Other features can import and call detection functions to get cached results + +### 2. Configuration + +Detectors are configured via `privacy-configuration/features/web-interference-detection.json`: + +```json +{ + "state": "enabled", + "settings": { + "autoRunDelayMs": 100, + "interferenceTypes": { + "botDetection": { + "hcaptcha": { + "state": "enabled", + "vendor": "hcaptcha", + "selectors": [".h-captcha"], + "windowProperties": ["hcaptcha"] + } + }, + "fraudDetection": { + "phishingWarning": { + "state": "enabled", + "type": "phishing", + "selectors": [".warning-banner"] + } + } + } + } +} +``` + +**Domain-specific configuration** using `conditionalChanges`: + +```json +{ + "settings": { + "conditionalChanges": [ + { + "condition": { + "urlPattern": "https://*.example.com/*" + }, + "patchSettings": [ + { + "op": "add", + "path": "/interferenceTypes/customDetector", + "value": { "state": "enabled", "selectors": [".custom"] } + } + ] + } + ] + } +} +``` + +The framework automatically applies conditional changes based on the current URL before passing settings to the feature. + +### 3. Using Detection Results + +**Internal features** (same content script context): + +```javascript +import { runBotDetection, runFraudDetection } from '../detectors/detections/bot-detection.js'; + +// Get cached results from auto-run +const botData = runBotDetection(); +const fraudData = runFraudDetection(); +``` + +**External:** + +```javascript +// Via messaging +this.messaging.request('detectInterference', { + types: ['botDetection', 'fraudDetection'] +}); +``` + +## Adding New Detectors + +1. **Create detection utility** in `detections/`: + +```javascript +// detections/my-detector.js +let cachedResult = null; + +export function runMyDetection(config = {}, options = {}) { + if (cachedResult && !options.refresh) return cachedResult; + + // Run detection logic + const detected = checkSelectors(config.selectors); + + cachedResult = { + detected, + type: 'myDetector', + timestamp: Date.now(), + }; + + return cachedResult; +} +``` + +2. **Add to WebInterferenceDetection feature**: + +```javascript +// features/web-interference-detection.js +import { runMyDetection } from '../detectors/detections/my-detector.js'; + +init(args) { + const settings = this.getFeatureSetting('interferenceTypes'); + + setTimeout(() => { + if (settings?.myDetector) { + runMyDetection(settings.myDetector); + } + }, autoRunDelayMs); +} +``` + +3. **Add config** to `web-interference-detection.json`: + +```json +{ + "settings": { + "interferenceTypes": { + "myDetector": { + "state": "enabled", + "selectors": [".my-selector"] + } + } + } +} +``` + +## Caching Strategy + +- **Module-level cache**: Each detector uses a simple variable (`let cachedResult = null`) +- **Automatic**: First call runs detection and caches, subsequent calls return cached result +- **Per-tab**: Each browser tab has its own cache (separate content script instance) +- **Lifetime**: Cache persists for page lifetime, cleared on navigation +- **Refresh option**: Callers can force fresh detection with `{ refresh: true }` + +**Examples:** +```javascript +// Get cached result (fast) +const data = runBotDetection(config); + +// Force fresh scan (slower, bypasses cache) +const freshData = runBotDetection(config, { refresh: true }); + +// Via messaging (native layer) +messaging.request('detectInterference', { + types: ['botDetection'], + refresh: true // Optional: force rescan +}); +``` diff --git a/injected/src/detectors/default-config.js b/injected/src/detectors/default-config.js new file mode 100644 index 0000000000..e569ce19e7 --- /dev/null +++ b/injected/src/detectors/default-config.js @@ -0,0 +1,53 @@ +export const DEFAULT_DETECTOR_SETTINGS = Object.freeze({ + botDetection: { + cloudflareTurnstile: { + state: 'enabled', + vendor: 'cloudflare', + selectors: ['.cf-turnstile', 'script[src*="challenges.cloudflare.com"]'], + windowProperties: ['turnstile'], + statusSelectors: [ + { + status: 'solved', + selectors: ['[data-state="success"]'], + }, + { + status: 'failed', + selectors: ['[data-state="error"]'], + }, + ], + }, + cloudflareChallengePage: { + state: 'enabled', + vendor: 'cloudflare', + selectors: ['#challenge-form', '.cf-browser-verification', '#cf-wrapper', 'script[src*="challenges.cloudflare.com"]'], + windowProperties: ['_cf_chl_opt', '__CF$cv$params', 'cfjsd'], + }, + hcaptcha: { + state: 'enabled', + vendor: 'hcaptcha', + selectors: [ + '.h-captcha', + '[data-hcaptcha-widget-id]', + 'script[src*="hcaptcha.com"]', + 'script[src*="assets.hcaptcha.com"]', + ], + windowProperties: ['hcaptcha'], + }, + }, + fraudDetection: { + phishingWarning: { + state: 'enabled', + type: 'phishing', + selectors: ['.warning-banner', '#security-alert'], + textPatterns: ['suspicious.*activity', 'unusual.*login', 'verify.*account'], + textSources: ['innerText'], + }, + accountSuspension: { + state: 'enabled', + type: 'suspension', + selectors: ['.account-suspended', '#suspension-notice'], + textPatterns: ['account.*suspended', 'access.*restricted'], + textSources: ['innerText'], + }, + }, +}); diff --git a/injected/src/detectors/detections/bot-detection.js b/injected/src/detectors/detections/bot-detection.js new file mode 100644 index 0000000000..c078cb8bd1 --- /dev/null +++ b/injected/src/detectors/detections/bot-detection.js @@ -0,0 +1,55 @@ +import { checkSelectors, checkWindowProperties, matchesSelectors, matchesTextPatterns } from '../utils/detection-utils.js'; + +// Cache result to avoid redundant DOM scans +let cachedResult = null; + +/** + * Run bot detection and cache results. + * @param {Record} config + * @param {Object} [options] + * @param {boolean} [options.refresh] - Force fresh detection, bypassing cache + */ +export function runBotDetection(config = {}, options = {}) { + if (cachedResult && !options.refresh) return cachedResult; + const results = Object.entries(config) + .filter(([_, challengeConfig]) => challengeConfig?.state === 'enabled') + .map(([challengeId, challengeConfig]) => { + const detected = checkSelectors(challengeConfig.selectors) || checkWindowProperties(challengeConfig.windowProperties || []); + if (!detected) { + return null; + } + + const challengeStatus = findStatus(challengeConfig.statusSelectors); + return { + detected: true, + vendor: challengeConfig.vendor, + challengeType: challengeId, + challengeStatus, + }; + }) + .filter(Boolean); + + // Cache and return + cachedResult = { + detected: results.length > 0, + type: 'botDetection', + results, + timestamp: Date.now(), + }; + + return cachedResult; +} + +function findStatus(statusSelectors) { + if (!Array.isArray(statusSelectors)) { + return null; + } + + const match = statusSelectors.find((statusConfig) => { + const { selectors, textPatterns, textSources } = statusConfig; + return matchesSelectors(selectors) || matchesTextPatterns(document.body, textPatterns, textSources); + }); + + return match?.status ?? null; +} + diff --git a/injected/src/detectors/detections/fraud-detection.js b/injected/src/detectors/detections/fraud-detection.js new file mode 100644 index 0000000000..85d68f40aa --- /dev/null +++ b/injected/src/detectors/detections/fraud-detection.js @@ -0,0 +1,41 @@ +import { checkSelectorsWithVisibility, checkTextPatterns } from '../utils/detection-utils.js'; + +// Cache result to avoid redundant DOM scans +let cachedResult = null; + +/** + * Run fraud detection and cache results. + * @param {Record} config + * @param {Object} [options] + * @param {boolean} [options.refresh] - Force fresh detection, bypassing cache + */ +export function runFraudDetection(config = {}, options = {}) { + if (cachedResult && !options.refresh) return cachedResult; + const results = Object.entries(config) + .filter(([_, alertConfig]) => alertConfig?.state === 'enabled') + .map(([alertId, alertConfig]) => { + const detected = + checkSelectorsWithVisibility(alertConfig.selectors) || + checkTextPatterns(alertConfig.textPatterns, alertConfig.textSources); + if (!detected) { + return null; + } + + return { + detected: true, + alertId, + category: alertConfig.type, + }; + }) + .filter(Boolean); + + // Cache and return + cachedResult = { + detected: results.length > 0, + type: 'fraudDetection', + results, + timestamp: Date.now(), + }; + + return cachedResult; +} diff --git a/injected/src/detectors/utils/detection-utils.js b/injected/src/detectors/utils/detection-utils.js new file mode 100644 index 0000000000..7419596b7d --- /dev/null +++ b/injected/src/detectors/utils/detection-utils.js @@ -0,0 +1,117 @@ +/** + * @param {string[]} [selectors] + * @returns {boolean} + */ +export function checkSelectors(selectors) { + if (!selectors || !Array.isArray(selectors)) { + return false; + } + return selectors.some((selector) => document.querySelector(selector)); +} + +/** + * @param {string[]} [selectors] + * @returns {boolean} + */ +export function checkSelectorsWithVisibility(selectors) { + if (!selectors || !Array.isArray(selectors)) { + return false; + } + return selectors.some((selector) => { + const element = document.querySelector(selector); + return element && isVisible(element); + }); +} + +/** + * @param {string[]} [properties] + * @returns {boolean} + */ +export function checkWindowProperties(properties) { + if (!properties || !Array.isArray(properties)) { + return false; + } + return properties.some((prop) => typeof window?.[prop] !== 'undefined'); +} + +/** + * @param {Element} element + * @returns {boolean} + */ +export function isVisible(element) { + const computedStyle = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return ( + rect.width > 0.5 && + rect.height > 0.5 && + computedStyle.display !== 'none' && + computedStyle.visibility !== 'hidden' && + +computedStyle.opacity > 0.05 + ); +} + +/** + * @param {Element} element + * @param {string[]} [sources] + * @returns {string} + */ +export function getTextContent(element, sources) { + if (!sources || sources.length === 0) { + return element.textContent || ''; + } + return sources.map((source) => element[source] || '').join(' '); +} + +/** + * @param {string[]} [selectors] + * @returns {boolean} + */ +export function matchesSelectors(selectors) { + if (!selectors || !Array.isArray(selectors)) { + return false; + } + const elements = queryAllSelectors(selectors); + return elements.length > 0; +} + +/** + * @param {Element} element + * @param {string[]} [patterns] + * @param {string[]} [sources] + * @returns {boolean} + */ +export function matchesTextPatterns(element, patterns, sources) { + if (!patterns || !Array.isArray(patterns)) { + return false; + } + const text = getTextContent(element, sources); + return patterns.some((pattern) => { + const regex = new RegExp(pattern, 'i'); + return regex.test(text); + }); +} + +/** + * @param {string[]} [patterns] + * @param {string[]} [sources] + * @returns {boolean} + */ +export function checkTextPatterns(patterns, sources) { + if (!patterns || !Array.isArray(patterns)) { + return false; + } + return matchesTextPatterns(document.body, patterns, sources); +} + +/** + * @param {string[]} selectors + * @param {Element|Document} [root] + * @returns {Element[]} + */ +export function queryAllSelectors(selectors, root = document) { + if (!selectors || !Array.isArray(selectors) || selectors.length === 0) { + return []; + } + const elements = root.querySelectorAll(selectors.join(',')); + return Array.from(elements); +} diff --git a/injected/src/features.js b/injected/src/features.js index f704269a41..4d8c087181 100644 --- a/injected/src/features.js +++ b/injected/src/features.js @@ -24,6 +24,7 @@ const otherFeatures = /** @type {const} */ ([ 'duckAiDataClearing', 'harmfulApis', 'webCompat', + 'webInterferenceDetection', 'windowsPermissionUsage', 'brokerProtection', 'performanceMetrics', @@ -37,7 +38,7 @@ const otherFeatures = /** @type {const} */ ([ /** @typedef {baseFeatures[number]|otherFeatures[number]} FeatureName */ /** @type {Record} */ export const platformSupport = { - apple: ['webCompat', 'duckPlayerNative', ...baseFeatures, 'duckAiDataClearing', 'pageContext'], + apple: ['webCompat', 'duckPlayerNative', ...baseFeatures, 'webInterferenceDetection', 'duckAiDataClearing', 'pageContext'], 'apple-isolated': [ 'duckPlayer', 'duckPlayerNative', @@ -48,7 +49,7 @@ export const platformSupport = { 'messageBridge', 'favicon', ], - android: [...baseFeatures, 'webCompat', 'breakageReporting', 'duckPlayer', 'messageBridge'], + android: [...baseFeatures, 'webCompat', 'webInterferenceDetection', 'breakageReporting', 'duckPlayer', 'messageBridge'], 'android-broker-protection': ['brokerProtection'], 'android-autofill-import': ['autofillImport'], 'android-adsjs': [ @@ -65,6 +66,7 @@ export const platformSupport = { windows: [ 'cookie', ...baseFeatures, + 'webInterferenceDetection', 'webTelemetry', 'windowsPermissionUsage', 'duckPlayer', diff --git a/injected/src/features/breakage-reporting.js b/injected/src/features/breakage-reporting.js index b94172bfa7..8ae8c6e41f 100644 --- a/injected/src/features/breakage-reporting.js +++ b/injected/src/features/breakage-reporting.js @@ -1,5 +1,7 @@ import ContentFeature from '../content-feature'; import { getExpandedPerformanceMetrics, getJsPerformanceMetrics } from './breakage-reporting/utils.js'; +import { runBotDetection } from '../detectors/detections/bot-detection.js'; +import { runFraudDetection } from '../detectors/detections/fraud-detection.js'; export default class BreakageReporting extends ContentFeature { init() { @@ -7,9 +9,17 @@ export default class BreakageReporting extends ContentFeature { this.messaging.subscribe('getBreakageReportValues', async () => { const jsPerformance = getJsPerformanceMetrics(); const referrer = document.referrer; + + // Call detection functions directly (get cached results) + const detectorData = { + botDetection: runBotDetection(), + fraudDetection: runFraudDetection(), + }; + const result = { jsPerformance, referrer, + detectorData, }; if (isExpandedPerformanceMetricsEnabled) { const expandedPerformanceMetrics = await getExpandedPerformanceMetrics(); diff --git a/injected/src/features/web-interference-detection.js b/injected/src/features/web-interference-detection.js new file mode 100644 index 0000000000..670632553e --- /dev/null +++ b/injected/src/features/web-interference-detection.js @@ -0,0 +1,41 @@ +import ContentFeature from '../content-feature.js'; +import { runBotDetection } from '../detectors/detections/bot-detection.js'; +import { runFraudDetection } from '../detectors/detections/fraud-detection.js'; + +/** + * @typedef {object} DetectInterferenceParams + * @property {string[]} [types] + * @property {boolean} [refresh] + */ + +export default class WebInterferenceDetection extends ContentFeature { + init() { + // Get settings with conditionalChanges already applied by framework + const settings = this.getFeatureSetting('interferenceTypes'); + const autoRunDelayMs = this.getFeatureSetting('autoRunDelayMs') ?? 100; + + // Auto-run enabled detectors after delay to capture transient interference + setTimeout(() => { + if (settings?.botDetection) { + runBotDetection(settings.botDetection); + } + if (settings?.fraudDetection) { + runFraudDetection(settings.fraudDetection); + } + }, autoRunDelayMs); + + // Register messaging handler for PIR/native requests + this.messaging.subscribe('detectInterference', (params) => { + const { types = [], refresh = false } = /** @type {DetectInterferenceParams} */ (params ?? {}); + const results = {}; + if (types.includes('botDetection')) { + results.botDetection = runBotDetection(settings?.botDetection, { refresh }); + } + if (types.includes('fraudDetection')) { + results.fraudDetection = runFraudDetection(settings?.fraudDetection, { refresh }); + } + return results; + }); + } +} +