From 5e9d02fbfbb713def7cac3c5171633d8b0212657 Mon Sep 17 00:00:00 2001 From: madblex Date: Thu, 6 Nov 2025 17:41:18 +0100 Subject: [PATCH 01/27] feat: web interference detection prototype --- injected/src/error-utils.js | 16 ++ injected/src/features/broker-protection.js | 20 +++ .../features/web-interference-detection.js | 53 ++++++ .../web-interference-detection/README.md | 165 ++++++++++++++++++ .../default-config.js | 74 ++++++++ .../detections/bot-detection.js | 69 ++++++++ .../detections/detection-base.js | 74 ++++++++ .../detections/fraud-detection.js | 50 ++++++ .../detections/youtube-ads-detection.js | 89 ++++++++++ .../detector-service.js | 81 +++++++++ .../types/api.types.js | 14 ++ .../types/detection.types.js | 124 +++++++++++++ .../utils/detection-utils.js | 117 +++++++++++++ .../utils/result-factory.js | 17 ++ 14 files changed, 963 insertions(+) create mode 100644 injected/src/error-utils.js create mode 100644 injected/src/features/web-interference-detection.js create mode 100644 injected/src/services/web-interference-detection/README.md create mode 100644 injected/src/services/web-interference-detection/default-config.js create mode 100644 injected/src/services/web-interference-detection/detections/bot-detection.js create mode 100644 injected/src/services/web-interference-detection/detections/detection-base.js create mode 100644 injected/src/services/web-interference-detection/detections/fraud-detection.js create mode 100644 injected/src/services/web-interference-detection/detections/youtube-ads-detection.js create mode 100644 injected/src/services/web-interference-detection/detector-service.js create mode 100644 injected/src/services/web-interference-detection/types/api.types.js create mode 100644 injected/src/services/web-interference-detection/types/detection.types.js create mode 100644 injected/src/services/web-interference-detection/utils/detection-utils.js create mode 100644 injected/src/services/web-interference-detection/utils/result-factory.js diff --git a/injected/src/error-utils.js b/injected/src/error-utils.js new file mode 100644 index 0000000000..4ee3d300b5 --- /dev/null +++ b/injected/src/error-utils.js @@ -0,0 +1,16 @@ +/** + * @template T + * @param {function(): T} fn - The function to call safely + * @param {object} [options] + * @param {string} [options.errorMessage] - The error message to log + * @returns {T|null} - The result of the function call, or null if an error occurred + */ +export function safeCall(fn, { errorMessage } = {}) { + try { + return fn(); + } catch (e) { + console.error(errorMessage ?? '[safeCall] Error:', e); + // TODO fire pixel + return null; + } +} diff --git a/injected/src/features/broker-protection.js b/injected/src/features/broker-protection.js index 05294dc88e..c30ae0f741 100644 --- a/injected/src/features/broker-protection.js +++ b/injected/src/features/broker-protection.js @@ -1,7 +1,13 @@ +/** + * @typedef {import('../services/web-interference-detection/types/api.types.js').InterferenceDetectionRequest} InterferenceDetectionRequest + */ + import ContentFeature from '../content-feature.js'; import { execute } from './broker-protection/execute.js'; import { retry } from '../timer-utils.js'; import { ErrorResponse } from './broker-protection/types.js'; +import { createWebInterferenceService } from '../services/web-interference-detection/detector-service.js'; +import { DEFAULT_INTERFERENCE_CONFIG } from '../services/web-interference-detection/default-config.js'; export class ActionExecutorBase extends ContentFeature { /** @@ -87,10 +93,24 @@ export class ActionExecutorBase extends ContentFeature { */ export default class BrokerProtection extends ActionExecutorBase { init() { + const interferenceConfig = this.getFeatureAttr('interferenceTypes', DEFAULT_INTERFERENCE_CONFIG); + const service = createWebInterferenceService({ interferenceConfig }); + this.messaging.subscribe('onActionReceived', async (/** @type {any} */ params) => { const { action, data } = params.state; return await this.processActionAndNotify(action, data); }); + + this.messaging.subscribe('detectInterference', (/** @type {InterferenceDetectionRequest} */ request) => { + try { + const detectionResults = service.detect(request); + console.log('[BrokerProtection] Detection results:', detectionResults); + return this.messaging.notify('interferenceDetected', detectionResults); + } catch (error) { + console.error('[BrokerProtection] Error detecting interference:', error); + return this.messaging.notify('interferenceDetectionError', { error: error.toString() }); + } + }); } /** diff --git a/injected/src/features/web-interference-detection.js b/injected/src/features/web-interference-detection.js new file mode 100644 index 0000000000..4a61275bce --- /dev/null +++ b/injected/src/features/web-interference-detection.js @@ -0,0 +1,53 @@ +/** + * @typedef {import('../services/web-interference-detection/types/api.types.js').InterferenceDetectionRequest} InterferenceDetectionRequest + */ + +import ContentFeature from '../content-feature.js'; +import { createWebInterferenceService } from '../services/web-interference-detection/detector-service.js'; +import { DEFAULT_INTERFERENCE_CONFIG } from '../services/web-interference-detection/default-config.js'; + +export default class WebInterferenceDetection extends ContentFeature { + init() { + const featureEnabled = this.getFeatureSettingEnabled('state'); + if (!featureEnabled) { + return; + } + const interferenceConfig = this.getFeatureAttr('interferenceTypes', DEFAULT_INTERFERENCE_CONFIG); + const service = createWebInterferenceService({ + interferenceConfig, + onDetectionChange: (result) => { + this.messaging.notify('interferenceChanged', result); + }, + }); + + /** + * Example: One-time detection + * Native -> CSS: Call detectInterference + * CSS -> Native: Return interferenceDetected with immediate results + */ + this.messaging.subscribe('detectInterference', (/** @type {InterferenceDetectionRequest} */ request) => { + try { + const detectionResults = service.detect(request); + return this.messaging.notify('interferenceDetected', detectionResults); + } catch (error) { + console.error('[WebInterferenceDetection] Detection failed:', error); + return this.messaging.notify('interferenceDetectionError', { error: error.toString() }); + } + }); + + /** + * Example: Continuous monitoring + * Native -> CSS: Call startInterferenceMonitoring + * CSS -> Native: Return monitoringStarted with initial results + * CSS -> Native: Send interferenceChanged whenever detection changes (for types with observeDOMChanges: true) + */ + this.messaging.subscribe('startInterferenceMonitoring', (/** @type {InterferenceDetectionRequest} */ request) => { + try { + service.detect(request); + } catch (error) { + console.error('[WebInterferenceDetection] Monitoring failed:', error); + return this.messaging.notify('interferenceDetectionError', { error: error.toString() }); + } + }); + } +} diff --git a/injected/src/services/web-interference-detection/README.md b/injected/src/services/web-interference-detection/README.md new file mode 100644 index 0000000000..11a31a5593 --- /dev/null +++ b/injected/src/services/web-interference-detection/README.md @@ -0,0 +1,165 @@ +# Web Interference Detection Service + +Detects bot challenges, anti-fraud warnings, and video ads on web pages. Supports on-demand detection and continuous DOM monitoring based on configuration. + +## Architecture + +```mermaid +graph TB + subgraph C-S-S + Feature[WebInterferenceDetection
ContentFeature] + Service[WebInterferenceDetectionService] + + subgraph Detectors["Detection Classes"] + Bot[BotDetection] + Fraud[FraudDetection] + YouTube[YouTubeAdsDetection] + end + + Base[DetectionBase] + Utils[detection-utils] + end + + NativeApps <-->|"messaging
(detectInterference,
startInterferenceMonitoring)"| Feature + Feature --> Service + Service --> Bot + Service --> Fraud + Service --> YouTube + YouTube --> Base + Base -.MutationObserver, polling.-> Base + Bot --> Utils + Fraud --> Utils + YouTube --> Utils +``` + +### Folder Structure + +``` +web-interference-detection/ +├── detector-service.js # Main service orchestrator +├── default-config.js # Default configuration +├── detections/ +│ ├── bot-detection.js # CAPTCHA detection +│ ├── fraud-detection.js # Anti-fraud warnings +│ ├── youtube-ads-detection.js # Video ad detection +│ └── detection-base.js # Base class with observers +├── utils/ +│ ├── detection-utils.js # Shared utilities +│ └── result-factory.js # Result creation +└── types/ + ├── detection.types.js # Type definitions + └── api.types.js # API types +``` + +## Usage + +### On-Demand Detection + +```javascript +import { createWebInterferenceService } from './detector-service.js'; + +const service = createWebInterferenceService({ + interferenceConfig, + onDetectionChange: null, +}); + +const results = service.detect({ types: ['botDetection'] }); +``` + +**Full Response Example:** + +```javascript +{ + botDetection: { + detected: true, + interferenceType: 'botDetection', + results: [ + { + detected: true, + vendor: 'cloudflare', + challengeType: 'cloudflareTurnstile', + challengeStatus: 'visible' + } + ], + timestamp: 1699283942123 + } +} +``` + +### Continuous Monitoring + +```javascript +const service = createWebInterferenceService({ + interferenceConfig, + onDetectionChange: (result) => { + // Called when detection state changes (e.g., ad starts/stops) + console.log('Interference changed:', result); + }, +}); + +service.detect({ types: ['youtubeAds'] }); +// Service will monitor DOM changes and invoke callback + +service.cleanup(); // Stop observers and cleanup +``` + +## Configuration Structure + +```javascript +{ + settings: { + 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"]'] + } + ], + observeDOMChanges: false + } + }, + youtubeAds: { + rootSelector: '#movie_player', + watchAttributes: ['class', 'style', 'aria-label'], + selectors: ['.ytp-ad-text', '.ytp-ad-skip-button', '.ytp-ad-preview-text'], + adClasses: ['ad-showing', 'ad-interrupting'], + textPatterns: ['skip ad', 'sponsored'], + textSources: ['innerText', 'ariaLabel'], + pollInterval: 2000, + rerootInterval: 1000, + observeDOMChanges: true + }, + } +} +``` + +## Key Concepts + +**Interference Types**: + +- `botDetection` for bor detection mechanisms (captchas, cloudflare, etc) +- `fraudDetection` for anti-fraud warnings +- `youtubeAds` for youtube video ads + +**Config-Driven Behavior**: Each interference type has independent configuration. Set `observeDOMChanges: true` to enable continuous monitoring for that specific interference type. + +**Service Lifecycle**: The service is created once during feature initialization and reused throughout the page lifecycle. Call `detect(request)` with specific interference types whenever detection is needed. Call `cleanup()` when the page is unloaded to stop all active observers and polling. + +**Messaging**: Use `detectInterference` for on-demand checks. Use `startInterferenceMonitoring` for continuous observation with callbacks. + +## Caveats + +⚠️ **MutationObserver/Polling Reuse**: Current implementation creates new observers for each `detect()` call if `observeDOMChanges: true`. Multiple detections may create redundant observers and affect performance. Future iterations should consider one or more of: + +- Observer pooling/reuse across detections +- Debouncing detection calls +- Centralized DOM observation with multiplexed callbacks diff --git a/injected/src/services/web-interference-detection/default-config.js b/injected/src/services/web-interference-detection/default-config.js new file mode 100644 index 0000000000..1e307dab74 --- /dev/null +++ b/injected/src/services/web-interference-detection/default-config.js @@ -0,0 +1,74 @@ +/** + * @typedef {import('./types/detection.types.js').InterferenceConfig} InterferenceConfig + */ + +/** + * @type {InterferenceConfig} + */ +export const DEFAULT_INTERFERENCE_CONFIG = Object.freeze( + /** @type {InterferenceConfig} */ ({ + settings: { + 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'], + }, + }, + youtubeAds: { + rootSelector: '#movie_player', + watchAttributes: ['class', 'style', 'aria-label'], + selectors: ['.ytp-ad-text', '.ytp-ad-skip-button', '.ytp-ad-preview-text'], + adClasses: ['ad-showing', 'ad-interrupting'], + textPatterns: ['skip ad', 'sponsored'], + textSources: ['innerText', 'ariaLabel'], + pollInterval: 2000, + rerootInterval: 1000, + }, + }, + }), +); diff --git a/injected/src/services/web-interference-detection/detections/bot-detection.js b/injected/src/services/web-interference-detection/detections/bot-detection.js new file mode 100644 index 0000000000..1b1e754926 --- /dev/null +++ b/injected/src/services/web-interference-detection/detections/bot-detection.js @@ -0,0 +1,69 @@ +/** + * @typedef {import('../types/detection.types.js').BotDetectionConfig} BotDetectionConfig + * @typedef {import('../types/detection.types.js').TypeDetectionResult} TypeDetectionResult + * @typedef {import('../types/detection.types.js').StatusSelectorConfig} StatusSelectorConfig + * @typedef {import('../types/detection.types.js').ChallengeConfig} ChallengeConfig + * @typedef {import('../types/detection.types.js').InterferenceDetector} InterferenceDetector + */ + +import { checkSelectors, checkWindowProperties, matchesSelectors, matchesTextPatterns } from '../utils/detection-utils.js'; + +/** + * @implements {InterferenceDetector} + */ +export class BotDetection { + /** + * @param {BotDetectionConfig} config + */ + constructor(config) { + this.config = config; + } + + /** + * @returns {TypeDetectionResult} + */ + detect() { + const results = Object.entries(this.config || {}) + .filter(([_, challengeConfig]) => challengeConfig.state === 'enabled') + .map(([challengeId, challengeConfig]) => { + const detected = checkSelectors(challengeConfig.selectors) || checkWindowProperties(challengeConfig.windowProperties || []); + if (!detected) { + return null; + } + + const challengeStatus = this._findStatus(challengeConfig.statusSelectors); + return { + detected: true, + vendor: challengeConfig.vendor, + challengeType: challengeId, + challengeStatus, + }; + }) + .filter((result) => result !== null); + + return { + detected: results.length > 0, + interferenceType: 'botDetection', + results, + timestamp: Date.now(), + }; + } + + /** + * @param {StatusSelectorConfig[]} [statusSelectors] + * @returns {string|null} + */ + _findStatus(statusSelectors) { + if (!statusSelectors || !Array.isArray(statusSelectors)) { + return null; + } + + return ( + statusSelectors.find((statusConfig) => { + const { status, selectors, textPatterns, textSources } = statusConfig; + const hasMatch = matchesSelectors(selectors) || matchesTextPatterns(document.body, textPatterns, textSources); + return hasMatch ? status : null; + })?.status || null + ); + } +} diff --git a/injected/src/services/web-interference-detection/detections/detection-base.js b/injected/src/services/web-interference-detection/detections/detection-base.js new file mode 100644 index 0000000000..1c90be787d --- /dev/null +++ b/injected/src/services/web-interference-detection/detections/detection-base.js @@ -0,0 +1,74 @@ +/** + * @typedef {import('../types/detection.types.js').TypeDetectionResult} TypeDetectionResult + * @typedef {import('../types/detection.types.js').InterferenceDetector} InterferenceDetector + */ + +/** + * PROTOTYPE: Base class for complex detections with continuous monitoring + * TODO: Add mutation observer, re-rooting, callback timers, debouncing + * @implements {InterferenceDetector} + */ +export class DetectionBase { + /** + * @param {object} config + * @param {((result: TypeDetectionResult) => void)|null} [onInterferenceChange] + */ + constructor(config, onInterferenceChange = null) { + this.config = config; + this.onInterferenceChange = onInterferenceChange; + this.isRunning = false; + this.root = null; + this.pollTimer = null; + + if (this.onInterferenceChange) { + this.start(); + } + } + + start() { + if (this.isRunning) { + return; + } + this.isRunning = true; + + this.root = this.findRoot(); + if (!this.root) { + setTimeout(() => this.start(), 500); + return; + } + + if (this.config.pollInterval) { + this.pollTimer = setInterval(() => this.checkForInterference(), this.config.pollInterval); + } + + this.checkForInterference(); + } + + stop() { + if (!this.isRunning) { + return; + } + this.isRunning = false; + + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + } + + /** + * @returns {TypeDetectionResult} + */ + detect() { + throw new Error('detect() must be implemented by subclass'); + } + + /** + * @returns {Element|null} + */ + findRoot() { + return document.body; + } + + checkForInterference() {} +} diff --git a/injected/src/services/web-interference-detection/detections/fraud-detection.js b/injected/src/services/web-interference-detection/detections/fraud-detection.js new file mode 100644 index 0000000000..c822f21549 --- /dev/null +++ b/injected/src/services/web-interference-detection/detections/fraud-detection.js @@ -0,0 +1,50 @@ +/** + * @typedef {import('../types/detection.types.js').AntiFraudConfig} AntiFraudConfig + * @typedef {import('../types/detection.types.js').TypeDetectionResult} TypeDetectionResult + * @typedef {import('../types/detection.types.js').AntiFraudAlertConfig} AntiFraudAlertConfig + * @typedef {import('../types/detection.types.js').InterferenceDetector} InterferenceDetector + */ + +import { checkSelectorsWithVisibility, checkTextPatterns } from '../utils/detection-utils.js'; + +/** + * @implements {InterferenceDetector} + */ +export class FraudDetection { + /** + * @param {AntiFraudConfig} config + */ + constructor(config) { + this.config = config; + } + + /** + * @returns {TypeDetectionResult} + */ + detect() { + const results = Object.entries(this.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, + type: alertConfig.type, + }; + }) + .filter((result) => result !== null); + + return { + detected: results.length > 0, + interferenceType: 'fraudDetection', + results, + timestamp: Date.now(), + }; + } +} diff --git a/injected/src/services/web-interference-detection/detections/youtube-ads-detection.js b/injected/src/services/web-interference-detection/detections/youtube-ads-detection.js new file mode 100644 index 0000000000..33c47b1d7a --- /dev/null +++ b/injected/src/services/web-interference-detection/detections/youtube-ads-detection.js @@ -0,0 +1,89 @@ +/** + * @typedef {import('../types/detection.types.js').YouTubeAdsConfig} YouTubeAdsConfig + * @typedef {import('../types/detection.types.js').TypeDetectionResult} TypeDetectionResult + * @typedef {import('../types/detection.types.js').InterferenceDetector} InterferenceDetector + */ + +import { DetectionBase } from './detection-base.js'; +import { isVisible, queryAllSelectors } from '../utils/detection-utils.js'; +import { createEmptyResult } from '../utils/result-factory.js'; + +/** + * PROTOTYPE: YouTube ad detection + * TODO: Add mutation-based detection, ad lifecycle tracking, sponsored content badges + * @implements {InterferenceDetector} + */ +export class YouTubeAdsDetection extends DetectionBase { + /** + * @param {YouTubeAdsConfig} config + * @param {((result: TypeDetectionResult) => void)|null} [onInterferenceChange] + */ + constructor(config, onInterferenceChange = null) { + super(config, onInterferenceChange); + this.adCurrentlyPlaying = false; + } + + /** + * @returns {TypeDetectionResult} + */ + detect() { + const root = this.findRoot(); + if (!root) { + return createEmptyResult('youtubeAds'); + } + + const hasAdClass = this.config.adClasses.some((/** @type {string} */ cls) => root.classList.contains(cls)); + const adElements = queryAllSelectors(this.config.selectors, root); + const hasVisibleAdElement = adElements.some((el) => isVisible(el)); + + const detected = hasAdClass || hasVisibleAdElement; + + return { + detected, + interferenceType: 'youtubeAds', + results: detected + ? [ + { + adCurrentlyPlaying: true, + adType: 'video-ad', + source: 'one-time-detection', + }, + ] + : [], + timestamp: Date.now(), + }; + } + + findRoot() { + return document.querySelector(this.config.rootSelector); + } + + checkForInterference() { + if (!this.root) { + return; + } + + const hadAd = this.adCurrentlyPlaying; + const hasAdClass = this.config.adClasses.some((/** @type {string} */ cls) => this.root && this.root.classList.contains(cls)); + this.adCurrentlyPlaying = hasAdClass; + + if (this.onInterferenceChange && hadAd !== this.adCurrentlyPlaying) { + this.onInterferenceChange( + this.adCurrentlyPlaying + ? { + detected: true, + interferenceType: 'youtubeAds', + results: [ + { + adCurrentlyPlaying: true, + adType: 'video-ad', + source: 'detector', + }, + ], + timestamp: Date.now(), + } + : createEmptyResult('youtubeAds'), + ); + } + } +} diff --git a/injected/src/services/web-interference-detection/detector-service.js b/injected/src/services/web-interference-detection/detector-service.js new file mode 100644 index 0000000000..5868c4c6d6 --- /dev/null +++ b/injected/src/services/web-interference-detection/detector-service.js @@ -0,0 +1,81 @@ +/** + * @typedef {import('./types/detection.types.js').InterferenceType} InterferenceType + * @typedef {import('./types/detection.types.js').InterferenceConfig} InterferenceConfig + * @typedef {import('./types/detection.types.js').TypeDetectionResult} TypeDetectionResult + * @typedef {import('./types/detection.types.js').DetectionResults} DetectionResults + * @typedef {import('./types/detection.types.js').DetectInterferenceParams} DetectInterferenceParams + * @typedef {import('./types/detection.types.js').InterferenceDetector} InterferenceDetector + * @typedef {import('./types/api.types.js').InterferenceDetectionRequest} InterferenceDetectionRequest + */ + +import { BotDetection } from './detections/bot-detection.js'; +import { FraudDetection } from './detections/fraud-detection.js'; +import { YouTubeAdsDetection } from './detections/youtube-ads-detection.js'; +import { createEmptyResult } from './utils/result-factory.js'; + +const detectionClassMap = { + botDetection: BotDetection, + fraudDetection: FraudDetection, + youtubeAds: YouTubeAdsDetection, +}; + +class WebInterferenceDetectionService { + /** + * @param {DetectInterferenceParams} params + */ + constructor(params) { + const { interferenceConfig, onDetectionChange } = params; + this.interferenceConfig = interferenceConfig; + this.onDetectionChange = onDetectionChange; + this.activeDetections = []; + } + + /** + * @param {InterferenceDetectionRequest} request + * @returns {DetectionResults} + */ + detect(request) { + const { types } = request; + const results = /** @type {DetectionResults} */ ({}); + types.forEach((type) => { + const DetectionClass = detectionClassMap[type]; + if (!DetectionClass) { + throw new Error(`Unsupported interference type: "${type}". Supported types: ${Object.keys(detectionClassMap).join(', ')}`); + } + + const config = this.interferenceConfig.settings?.[type]; + if (!config) { + results[type] = createEmptyResult(type); + return; + } + + const { observeDOMChanges } = config ?? {}; + + const callback = + observeDOMChanges && this.onDetectionChange + ? (/** @type {TypeDetectionResult} */ result) => this.onDetectionChange?.({ [type]: result }) + : null; + + const detection = /** @type {InterferenceDetector} */ (new DetectionClass(config, callback)); + results[type] = detection.detect(); + + if (callback && typeof detection.stop === 'function') { + this.activeDetections.push(detection); + } + }); + + return results; + } + + cleanup() { + this.activeDetections.forEach((detection) => detection.stop()); + } +} + +/** + * @param {DetectInterferenceParams} params + * @returns {WebInterferenceDetectionService} + */ +export function createWebInterferenceService(params) { + return new WebInterferenceDetectionService(params); +} diff --git a/injected/src/services/web-interference-detection/types/api.types.js b/injected/src/services/web-interference-detection/types/api.types.js new file mode 100644 index 0000000000..8ad5a4f180 --- /dev/null +++ b/injected/src/services/web-interference-detection/types/api.types.js @@ -0,0 +1,14 @@ +/** + * API types for native messaging interface + */ + +/** + * @typedef {import('./detection.types').InterferenceType} InterferenceType + */ + +/** + * @typedef {object} InterferenceDetectionRequest + * @property {InterferenceType[]} types - Interference types to detect + */ + +export {}; diff --git a/injected/src/services/web-interference-detection/types/detection.types.js b/injected/src/services/web-interference-detection/types/detection.types.js new file mode 100644 index 0000000000..6923db39be --- /dev/null +++ b/injected/src/services/web-interference-detection/types/detection.types.js @@ -0,0 +1,124 @@ +/** + * Core detection types for web interference detection service + */ + +/** + * @typedef {'botDetection' | 'youtubeAds' | 'fraudDetection'} InterferenceType + */ + +/** + * @typedef {'cloudflare' | 'hcaptcha'} VendorName + */ + +/** + * @typedef {'turnstile' | 'challengePage'} ChallengeType + */ + +/** + * @typedef {'cloudflareTurnstile' | 'cloudflareChallengePage' | 'hcaptcha'} ChallengeIdentifier + */ + +/** + * @typedef {'enabled' | 'disabled'} FeatureState + */ + +/** + * @typedef {object} StatusSelectorConfig + * @property {string} status + * @property {string[]} [selectors] + * @property {string[]} [textPatterns] + * @property {string[]} [textSources] + */ + +/** + * @typedef {object} ChallengeConfig + * @property {FeatureState} state + * @property {VendorName} vendor + * @property {string[]} [selectors] + * @property {string[]} [windowProperties] + * @property {StatusSelectorConfig[]} [statusSelectors] + * @property {boolean} [observeDOMChanges] + */ + +/** + * @typedef {Partial>} BotDetectionConfig + */ + +/** + * @typedef {object} AntiFraudAlertConfig + * @property {FeatureState} state + * @property {string} type + * @property {string[]} [selectors] + * @property {string[]} [textPatterns] + * @property {string[]} [textSources] + * @property {boolean} [observeDOMChanges] + */ + +/** + * @typedef {Record} AntiFraudConfig + */ + +/** + * @typedef {object} YouTubeAdsConfig + * @property {string} rootSelector + * @property {string[]} watchAttributes + * @property {string[]} selectors + * @property {string[]} adClasses + * @property {string[]} [textPatterns] + * @property {string[]} [textSources] + * @property {number} [pollInterval] + * @property {number} [rerootInterval] + * @property {boolean} [observeDOMChanges] + */ + +/** + * @typedef {object} InterferenceSettings + * @property {BotDetectionConfig} [botDetection] + * @property {AntiFraudConfig} [fraudDetection] + * @property {YouTubeAdsConfig} [youtubeAds] + */ + +/** + * @typedef {object} InterferenceConfig + * @property {InterferenceSettings} settings + */ + +/** + * @typedef {object} VendorDetectionResult + * @property {boolean} detected - Whether vendor was detected + * @property {VendorName} vendor - Vendor identifier + * @property {string} challengeType - Challenge identifier + * @property {string | null} challengeStatus - Challenge status + */ + +/** + * @typedef {object} TypeDetectionResult + * @property {boolean} detected + * @property {InterferenceType} interferenceType + * @property {Record[]} [results] + * @property {number} timestamp + */ + +/** + * @typedef {object} InterferenceDetector + * @property {() => TypeDetectionResult} detect + * @property {() => void} [stop] + */ + +/** + * @callback TypeDetectorFunction + * @param {InterferenceConfig} interferenceConfig + * @returns {TypeDetectionResult} + */ + +/** + * @typedef {object} DetectInterferenceParams + * @property {InterferenceConfig} interferenceConfig + * @property {((result: DetectionResults) => void)|null} [onDetectionChange] + */ + +/** + * @typedef {Partial>} DetectionResults + */ + +export {}; diff --git a/injected/src/services/web-interference-detection/utils/detection-utils.js b/injected/src/services/web-interference-detection/utils/detection-utils.js new file mode 100644 index 0000000000..7419596b7d --- /dev/null +++ b/injected/src/services/web-interference-detection/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/services/web-interference-detection/utils/result-factory.js b/injected/src/services/web-interference-detection/utils/result-factory.js new file mode 100644 index 0000000000..60a02205cd --- /dev/null +++ b/injected/src/services/web-interference-detection/utils/result-factory.js @@ -0,0 +1,17 @@ +/** + * @typedef {import('../types/detection.types.js').InterferenceType} InterferenceType + * @typedef {import('../types/detection.types.js').TypeDetectionResult} TypeDetectionResult + */ + +/** + * @param {InterferenceType} type + * @returns {TypeDetectionResult} + */ +export function createEmptyResult(type) { + return { + detected: false, + interferenceType: type, + results: [], + timestamp: Date.now(), + }; +} From 3abdc0564fb015625f040059d07edff6fca4e557 Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Mon, 10 Nov 2025 10:26:13 -0700 Subject: [PATCH 02/27] detector prototype v2 --- injected/src/detectors/README.md | 71 ++++++++ injected/src/detectors/default-config.js | 58 ++++++ .../src/detectors/detections/bot-detection.js | 58 ++++++ .../detections/detection-base.js | 19 +- .../detectors/detections/fraud-detection.js | 36 ++++ .../detections/youtube-ads-detection.js | 52 ++++++ injected/src/detectors/detector-service.js | 92 ++++++++++ .../utils/detection-utils.js | 0 injected/src/features/broker-protection.js | 20 --- .../features/web-interference-detection.js | 73 ++++---- .../web-interference-detection/README.md | 165 ------------------ .../default-config.js | 74 -------- .../detections/bot-detection.js | 69 -------- .../detections/fraud-detection.js | 50 ------ .../detections/youtube-ads-detection.js | 89 ---------- .../detector-service.js | 81 --------- .../types/api.types.js | 14 -- .../types/detection.types.js | 124 ------------- .../utils/result-factory.js | 17 -- 19 files changed, 413 insertions(+), 749 deletions(-) create mode 100644 injected/src/detectors/README.md create mode 100644 injected/src/detectors/default-config.js create mode 100644 injected/src/detectors/detections/bot-detection.js rename injected/src/{services/web-interference-detection => detectors}/detections/detection-base.js (76%) create mode 100644 injected/src/detectors/detections/fraud-detection.js create mode 100644 injected/src/detectors/detections/youtube-ads-detection.js create mode 100644 injected/src/detectors/detector-service.js rename injected/src/{services/web-interference-detection => detectors}/utils/detection-utils.js (100%) delete mode 100644 injected/src/services/web-interference-detection/README.md delete mode 100644 injected/src/services/web-interference-detection/default-config.js delete mode 100644 injected/src/services/web-interference-detection/detections/bot-detection.js delete mode 100644 injected/src/services/web-interference-detection/detections/fraud-detection.js delete mode 100644 injected/src/services/web-interference-detection/detections/youtube-ads-detection.js delete mode 100644 injected/src/services/web-interference-detection/detector-service.js delete mode 100644 injected/src/services/web-interference-detection/types/api.types.js delete mode 100644 injected/src/services/web-interference-detection/types/detection.types.js delete mode 100644 injected/src/services/web-interference-detection/utils/result-factory.js diff --git a/injected/src/detectors/README.md b/injected/src/detectors/README.md new file mode 100644 index 0000000000..7360b474bc --- /dev/null +++ b/injected/src/detectors/README.md @@ -0,0 +1,71 @@ +# Detector Registry (Prototype) + +This directory contains a lightweight registry that runs inside content-scope-scripts. Detectors register with the shared service and any feature can query their latest results (breakage reporting, native PIR, debug tooling, etc.). + +The initial focus is synchronous, on-demand collection. Continuous monitoring (mutation observers, polling, batching) can be layered on later without changing the public API. + +## API Snapshot + +```mermaid +sequenceDiagram + participant Feature as Breakage Reporting + participant Service as detectorService + participant Detector as YouTubeDetector + + Detector->>Service: registerDetector('youtubeAds', { getData }) + Feature->>Service: getDetectorData('youtubeAds') + Service->>Detector: getData() + Detector-->>Service: snapshot + Service-->>Feature: snapshot +``` + +### Core helpers + +- `registerDetector(detectorId, { getData, refresh?, teardown? })` +- `unregisterDetector(detectorId)` +- `resetDetectors(reason?)` +- `getDetectorData(detectorId, { maxAgeMs }?)` +- `getDetectorBatch(detectorIds, options?)` + +Detectors return arbitrary JSON payloads. Include timestamps if consumers rely on freshness. + +## Directory Layout + +``` +detectors/ +├── detector-service.js # registry + caching helpers +├── default-config.js # sample configuration blobs +├── detections/ +│ ├── bot-detection.js # helpers for CAPTCHA/bot detection +│ ├── fraud-detection.js # helpers for anti-fraud banners +│ └── youtube-ads-detection.js # helper for YouTube ad snapshots +├── detections/detection-base.js # optional base for observer-style detectors +└── utils/ + └── detection-utils.js # DOM helpers (selectors, text matching, visibility) +``` + +## Example Usage + +```javascript +import { registerDetector, getDetectorData } from '../detectors/detector-service.js'; +import { createBotDetector } from '../detectors/detections/bot-detection.js'; +import { DEFAULT_DETECTOR_SETTINGS } from '../detectors/default-config.js'; + +// During feature init +registerDetector('botDetection', createBotDetector(DEFAULT_DETECTOR_SETTINGS.botDetection)); + +// Later, when preparing a breakage report +const snapshot = await getDetectorData('botDetection', { maxAgeMs: 1_000 }); +if (snapshot?.detected) { + payload.detectors.bot = snapshot; +} +``` + +## Extending + +1. Add a helper under `detections/` that exposes a `createXDetector(config)` returning `{ getData, refresh?, teardown? }`. +2. Register it during feature bootstrap or via a shared initializer. +3. (Optional) Add defaults to `default-config.js` or wire it to remote config. + +Future enhancements—shared observers, background aggregation, streaming updates—can build on this registry without breaking the public API. + diff --git a/injected/src/detectors/default-config.js b/injected/src/detectors/default-config.js new file mode 100644 index 0000000000..8bae5a8a9c --- /dev/null +++ b/injected/src/detectors/default-config.js @@ -0,0 +1,58 @@ +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'], + }, + }, + youtubeAds: { + rootSelector: '#movie_player', + selectors: ['.ytp-ad-text', '.ytp-ad-skip-button', '.ytp-ad-preview-text'], + adClasses: ['ad-showing', 'ad-interrupting'], + }, +}); diff --git a/injected/src/detectors/detections/bot-detection.js b/injected/src/detectors/detections/bot-detection.js new file mode 100644 index 0000000000..ca57510df8 --- /dev/null +++ b/injected/src/detectors/detections/bot-detection.js @@ -0,0 +1,58 @@ +import { checkSelectors, checkWindowProperties, matchesSelectors, matchesTextPatterns } from '../utils/detection-utils.js'; + +/** + * Create a detector registration for CAPTCHA/bot detection. + * @param {Record} config + */ +export function createBotDetector(config = {}) { + return { + async getData() { + return runBotDetection(config); + }, + }; +} + +/** + * Run detection immediately and return structured results. + * @param {Record} config + */ +export function runBotDetection(config = {}) { + 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); + + return { + detected: results.length > 0, + type: 'botDetection', + results, + timestamp: Date.now(), + }; +} + +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/services/web-interference-detection/detections/detection-base.js b/injected/src/detectors/detections/detection-base.js similarity index 76% rename from injected/src/services/web-interference-detection/detections/detection-base.js rename to injected/src/detectors/detections/detection-base.js index 1c90be787d..ee1f28c16f 100644 --- a/injected/src/services/web-interference-detection/detections/detection-base.js +++ b/injected/src/detectors/detections/detection-base.js @@ -1,17 +1,11 @@ -/** - * @typedef {import('../types/detection.types.js').TypeDetectionResult} TypeDetectionResult - * @typedef {import('../types/detection.types.js').InterferenceDetector} InterferenceDetector - */ - /** * PROTOTYPE: Base class for complex detections with continuous monitoring * TODO: Add mutation observer, re-rooting, callback timers, debouncing - * @implements {InterferenceDetector} */ export class DetectionBase { /** * @param {object} config - * @param {((result: TypeDetectionResult) => void)|null} [onInterferenceChange] + * @param {(result: any) => void=} onInterferenceChange */ constructor(config, onInterferenceChange = null) { this.config = config; @@ -19,6 +13,7 @@ export class DetectionBase { this.isRunning = false; this.root = null; this.pollTimer = null; + this.retryTimer = null; if (this.onInterferenceChange) { this.start(); @@ -33,7 +28,7 @@ export class DetectionBase { this.root = this.findRoot(); if (!this.root) { - setTimeout(() => this.start(), 500); + this.retryTimer = setTimeout(() => this.start(), 500); return; } @@ -54,11 +49,13 @@ export class DetectionBase { clearInterval(this.pollTimer); this.pollTimer = null; } + + if (this.retryTimer) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } } - /** - * @returns {TypeDetectionResult} - */ detect() { throw new Error('detect() must be implemented by subclass'); } diff --git a/injected/src/detectors/detections/fraud-detection.js b/injected/src/detectors/detections/fraud-detection.js new file mode 100644 index 0000000000..0754bf0a38 --- /dev/null +++ b/injected/src/detectors/detections/fraud-detection.js @@ -0,0 +1,36 @@ +import { checkSelectorsWithVisibility, checkTextPatterns } from '../utils/detection-utils.js'; + +export function createFraudDetector(config = {}) { + return { + async getData() { + return runFraudDetection(config); + }, + }; +} + +export function runFraudDetection(config = {}) { + 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); + + return { + detected: results.length > 0, + type: 'fraudDetection', + results, + timestamp: Date.now(), + }; +} diff --git a/injected/src/detectors/detections/youtube-ads-detection.js b/injected/src/detectors/detections/youtube-ads-detection.js new file mode 100644 index 0000000000..e51b687fdb --- /dev/null +++ b/injected/src/detectors/detections/youtube-ads-detection.js @@ -0,0 +1,52 @@ +import { isVisible, queryAllSelectors } from '../utils/detection-utils.js'; +const DEFAULT_CONFIG = { + rootSelector: '#movie_player', + adClasses: ['ad-showing', 'ad-interrupting'], + selectors: ['.ytp-ad-text', '.ytp-ad-skip-button', '.ytp-ad-preview-text'], +}; + +export function createYouTubeAdsDetector(config = {}) { + const mergedConfig = { ...DEFAULT_CONFIG, ...config }; + return { + async getData() { + return runYouTubeAdsDetection(mergedConfig); + }, + }; +} + +export function runYouTubeAdsDetection(config = DEFAULT_CONFIG) { + const root = document.querySelector(config.rootSelector); + if (!root) { + return emptyResult(); + } + + const hasAdClass = config.adClasses.some((cls) => root.classList.contains(cls)); + const adElements = queryAllSelectors(config.selectors, root); + const hasVisibleAdElement = adElements.some((el) => isVisible(el)); + + const detected = hasAdClass || hasVisibleAdElement; + + return detected + ? { + detected: true, + type: 'youtubeAds', + results: [ + { + adCurrentlyPlaying: true, + adType: 'video-ad', + source: 'snapshot', + }, + ], + timestamp: Date.now(), + } + : emptyResult(); +} + +function emptyResult() { + return { + detected: false, + type: 'youtubeAds', + results: [], + timestamp: Date.now(), + }; +} diff --git a/injected/src/detectors/detector-service.js b/injected/src/detectors/detector-service.js new file mode 100644 index 0000000000..522762a413 --- /dev/null +++ b/injected/src/detectors/detector-service.js @@ -0,0 +1,92 @@ +/** + * @typedef {object} DetectorRegistration + * @property {() => any | Promise} getData + * @property {(() => any | Promise)=} refresh + * @property {(() => void)=} teardown + * + * @typedef {object} CachedSnapshot + * @property {any} data + * @property {number} ts + */ + +const registrations = new Map(); +const cache = new Map(); + +/** + * Register a detector with the shared service. + * Subsequent calls replace the previous registration for the same id. + * @param {string} detectorId + * @param {DetectorRegistration} registration + */ +export function registerDetector(detectorId, registration) { + registrations.set(detectorId, registration); +} + +/** + * Remove a detector registration and drop any cached data. + * @param {string} detectorId + */ +export function unregisterDetector(detectorId) { + const registration = registrations.get(detectorId); + registration?.teardown?.(); + registrations.delete(detectorId); + cache.delete(detectorId); +} + +/** + * Reset all detector caches and invoke teardowns. + * @param {string} [reason] + */ +export function resetDetectors(reason = 'manual') { + for (const registration of registrations.values()) { + registration.teardown?.(reason); + } + cache.clear(); +} + +/** + * Fetch detector data. Uses cached value when available unless maxAgeMs is exceeded. + * @param {string} detectorId + * @param {{ maxAgeMs?: number }} [options] + * @returns {Promise} + */ +export async function getDetectorData(detectorId, options = {}) { + const { maxAgeMs } = options; + const cached = /** @type {CachedSnapshot | undefined} */ (cache.get(detectorId)); + if (cached) { + const age = Date.now() - cached.ts; + if (!maxAgeMs || age <= maxAgeMs) { + return cached.data; + } + } + + const registration = registrations.get(detectorId); + if (!registration) { + return null; + } + + const runner = registration.refresh ?? registration.getData; + try { + const data = await runner(); + cache.set(detectorId, { data, ts: Date.now() }); + return data; + } catch (error) { + console.error(`[detectorService] Failed to fetch data for ${detectorId}`, error); + return null; + } +} + +/** + * Convenience helper for fetching multiple detectors at once. + * @param {string[]} detectorIds + * @param {{ maxAgeMs?: number }} [options] + * @returns {Promise>} + */ +export async function getDetectorBatch(detectorIds, options = {}) { + const results = {}; + for (const detectorId of detectorIds) { + results[detectorId] = await getDetectorData(detectorId, options); + } + return results; +} + diff --git a/injected/src/services/web-interference-detection/utils/detection-utils.js b/injected/src/detectors/utils/detection-utils.js similarity index 100% rename from injected/src/services/web-interference-detection/utils/detection-utils.js rename to injected/src/detectors/utils/detection-utils.js diff --git a/injected/src/features/broker-protection.js b/injected/src/features/broker-protection.js index c30ae0f741..05294dc88e 100644 --- a/injected/src/features/broker-protection.js +++ b/injected/src/features/broker-protection.js @@ -1,13 +1,7 @@ -/** - * @typedef {import('../services/web-interference-detection/types/api.types.js').InterferenceDetectionRequest} InterferenceDetectionRequest - */ - import ContentFeature from '../content-feature.js'; import { execute } from './broker-protection/execute.js'; import { retry } from '../timer-utils.js'; import { ErrorResponse } from './broker-protection/types.js'; -import { createWebInterferenceService } from '../services/web-interference-detection/detector-service.js'; -import { DEFAULT_INTERFERENCE_CONFIG } from '../services/web-interference-detection/default-config.js'; export class ActionExecutorBase extends ContentFeature { /** @@ -93,24 +87,10 @@ export class ActionExecutorBase extends ContentFeature { */ export default class BrokerProtection extends ActionExecutorBase { init() { - const interferenceConfig = this.getFeatureAttr('interferenceTypes', DEFAULT_INTERFERENCE_CONFIG); - const service = createWebInterferenceService({ interferenceConfig }); - this.messaging.subscribe('onActionReceived', async (/** @type {any} */ params) => { const { action, data } = params.state; return await this.processActionAndNotify(action, data); }); - - this.messaging.subscribe('detectInterference', (/** @type {InterferenceDetectionRequest} */ request) => { - try { - const detectionResults = service.detect(request); - console.log('[BrokerProtection] Detection results:', detectionResults); - return this.messaging.notify('interferenceDetected', detectionResults); - } catch (error) { - console.error('[BrokerProtection] Error detecting interference:', error); - return this.messaging.notify('interferenceDetectionError', { error: error.toString() }); - } - }); } /** diff --git a/injected/src/features/web-interference-detection.js b/injected/src/features/web-interference-detection.js index 4a61275bce..5661d34a53 100644 --- a/injected/src/features/web-interference-detection.js +++ b/injected/src/features/web-interference-detection.js @@ -1,10 +1,9 @@ -/** - * @typedef {import('../services/web-interference-detection/types/api.types.js').InterferenceDetectionRequest} InterferenceDetectionRequest - */ - import ContentFeature from '../content-feature.js'; -import { createWebInterferenceService } from '../services/web-interference-detection/detector-service.js'; -import { DEFAULT_INTERFERENCE_CONFIG } from '../services/web-interference-detection/default-config.js'; +import { registerDetector, getDetectorBatch, resetDetectors } from '../detectors/detector-service.js'; +import { DEFAULT_DETECTOR_SETTINGS } from '../detectors/default-config.js'; +import { createBotDetector } from '../detectors/detections/bot-detection.js'; +import { createFraudDetector } from '../detectors/detections/fraud-detection.js'; +import { createYouTubeAdsDetector } from '../detectors/detections/youtube-ads-detection.js'; export default class WebInterferenceDetection extends ContentFeature { init() { @@ -12,42 +11,46 @@ export default class WebInterferenceDetection extends ContentFeature { if (!featureEnabled) { return; } - const interferenceConfig = this.getFeatureAttr('interferenceTypes', DEFAULT_INTERFERENCE_CONFIG); - const service = createWebInterferenceService({ - interferenceConfig, - onDetectionChange: (result) => { - this.messaging.notify('interferenceChanged', result); - }, - }); - /** - * Example: One-time detection - * Native -> CSS: Call detectInterference - * CSS -> Native: Return interferenceDetected with immediate results - */ - this.messaging.subscribe('detectInterference', (/** @type {InterferenceDetectionRequest} */ request) => { + const detectorSettings = { + ...DEFAULT_DETECTOR_SETTINGS, + ...this.getFeatureAttr('interferenceTypes', {}), + }; + + this._registerDefaults(detectorSettings); + + this.messaging.subscribe('detectInterference', async (params = {}) => { try { - const detectionResults = service.detect(request); - return this.messaging.notify('interferenceDetected', detectionResults); + const detectorIds = normalizeTypes(params.types); + const results = await getDetectorBatch(detectorIds); + return this.messaging.notify('interferenceDetected', { results }); } catch (error) { console.error('[WebInterferenceDetection] Detection failed:', error); return this.messaging.notify('interferenceDetectionError', { error: error.toString() }); } }); + } - /** - * Example: Continuous monitoring - * Native -> CSS: Call startInterferenceMonitoring - * CSS -> Native: Return monitoringStarted with initial results - * CSS -> Native: Send interferenceChanged whenever detection changes (for types with observeDOMChanges: true) - */ - this.messaging.subscribe('startInterferenceMonitoring', (/** @type {InterferenceDetectionRequest} */ request) => { - try { - service.detect(request); - } catch (error) { - console.error('[WebInterferenceDetection] Monitoring failed:', error); - return this.messaging.notify('interferenceDetectionError', { error: error.toString() }); - } - }); + destroy() { + resetDetectors('feature-destroyed'); + } + + _registerDefaults(settings) { + if (settings.botDetection) { + registerDetector('botDetection', createBotDetector(settings.botDetection)); + } + if (settings.fraudDetection) { + registerDetector('fraudDetection', createFraudDetector(settings.fraudDetection)); + } + if (settings.youtubeAds) { + registerDetector('youtubeAds', createYouTubeAdsDetector(settings.youtubeAds)); + } + } +} + +function normalizeTypes(types) { + if (!Array.isArray(types) || types.length === 0) { + return ['botDetection', 'fraudDetection', 'youtubeAds']; } + return types.filter((type) => typeof type === 'string'); } diff --git a/injected/src/services/web-interference-detection/README.md b/injected/src/services/web-interference-detection/README.md deleted file mode 100644 index 11a31a5593..0000000000 --- a/injected/src/services/web-interference-detection/README.md +++ /dev/null @@ -1,165 +0,0 @@ -# Web Interference Detection Service - -Detects bot challenges, anti-fraud warnings, and video ads on web pages. Supports on-demand detection and continuous DOM monitoring based on configuration. - -## Architecture - -```mermaid -graph TB - subgraph C-S-S - Feature[WebInterferenceDetection
ContentFeature] - Service[WebInterferenceDetectionService] - - subgraph Detectors["Detection Classes"] - Bot[BotDetection] - Fraud[FraudDetection] - YouTube[YouTubeAdsDetection] - end - - Base[DetectionBase] - Utils[detection-utils] - end - - NativeApps <-->|"messaging
(detectInterference,
startInterferenceMonitoring)"| Feature - Feature --> Service - Service --> Bot - Service --> Fraud - Service --> YouTube - YouTube --> Base - Base -.MutationObserver, polling.-> Base - Bot --> Utils - Fraud --> Utils - YouTube --> Utils -``` - -### Folder Structure - -``` -web-interference-detection/ -├── detector-service.js # Main service orchestrator -├── default-config.js # Default configuration -├── detections/ -│ ├── bot-detection.js # CAPTCHA detection -│ ├── fraud-detection.js # Anti-fraud warnings -│ ├── youtube-ads-detection.js # Video ad detection -│ └── detection-base.js # Base class with observers -├── utils/ -│ ├── detection-utils.js # Shared utilities -│ └── result-factory.js # Result creation -└── types/ - ├── detection.types.js # Type definitions - └── api.types.js # API types -``` - -## Usage - -### On-Demand Detection - -```javascript -import { createWebInterferenceService } from './detector-service.js'; - -const service = createWebInterferenceService({ - interferenceConfig, - onDetectionChange: null, -}); - -const results = service.detect({ types: ['botDetection'] }); -``` - -**Full Response Example:** - -```javascript -{ - botDetection: { - detected: true, - interferenceType: 'botDetection', - results: [ - { - detected: true, - vendor: 'cloudflare', - challengeType: 'cloudflareTurnstile', - challengeStatus: 'visible' - } - ], - timestamp: 1699283942123 - } -} -``` - -### Continuous Monitoring - -```javascript -const service = createWebInterferenceService({ - interferenceConfig, - onDetectionChange: (result) => { - // Called when detection state changes (e.g., ad starts/stops) - console.log('Interference changed:', result); - }, -}); - -service.detect({ types: ['youtubeAds'] }); -// Service will monitor DOM changes and invoke callback - -service.cleanup(); // Stop observers and cleanup -``` - -## Configuration Structure - -```javascript -{ - settings: { - 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"]'] - } - ], - observeDOMChanges: false - } - }, - youtubeAds: { - rootSelector: '#movie_player', - watchAttributes: ['class', 'style', 'aria-label'], - selectors: ['.ytp-ad-text', '.ytp-ad-skip-button', '.ytp-ad-preview-text'], - adClasses: ['ad-showing', 'ad-interrupting'], - textPatterns: ['skip ad', 'sponsored'], - textSources: ['innerText', 'ariaLabel'], - pollInterval: 2000, - rerootInterval: 1000, - observeDOMChanges: true - }, - } -} -``` - -## Key Concepts - -**Interference Types**: - -- `botDetection` for bor detection mechanisms (captchas, cloudflare, etc) -- `fraudDetection` for anti-fraud warnings -- `youtubeAds` for youtube video ads - -**Config-Driven Behavior**: Each interference type has independent configuration. Set `observeDOMChanges: true` to enable continuous monitoring for that specific interference type. - -**Service Lifecycle**: The service is created once during feature initialization and reused throughout the page lifecycle. Call `detect(request)` with specific interference types whenever detection is needed. Call `cleanup()` when the page is unloaded to stop all active observers and polling. - -**Messaging**: Use `detectInterference` for on-demand checks. Use `startInterferenceMonitoring` for continuous observation with callbacks. - -## Caveats - -⚠️ **MutationObserver/Polling Reuse**: Current implementation creates new observers for each `detect()` call if `observeDOMChanges: true`. Multiple detections may create redundant observers and affect performance. Future iterations should consider one or more of: - -- Observer pooling/reuse across detections -- Debouncing detection calls -- Centralized DOM observation with multiplexed callbacks diff --git a/injected/src/services/web-interference-detection/default-config.js b/injected/src/services/web-interference-detection/default-config.js deleted file mode 100644 index 1e307dab74..0000000000 --- a/injected/src/services/web-interference-detection/default-config.js +++ /dev/null @@ -1,74 +0,0 @@ -/** - * @typedef {import('./types/detection.types.js').InterferenceConfig} InterferenceConfig - */ - -/** - * @type {InterferenceConfig} - */ -export const DEFAULT_INTERFERENCE_CONFIG = Object.freeze( - /** @type {InterferenceConfig} */ ({ - settings: { - 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'], - }, - }, - youtubeAds: { - rootSelector: '#movie_player', - watchAttributes: ['class', 'style', 'aria-label'], - selectors: ['.ytp-ad-text', '.ytp-ad-skip-button', '.ytp-ad-preview-text'], - adClasses: ['ad-showing', 'ad-interrupting'], - textPatterns: ['skip ad', 'sponsored'], - textSources: ['innerText', 'ariaLabel'], - pollInterval: 2000, - rerootInterval: 1000, - }, - }, - }), -); diff --git a/injected/src/services/web-interference-detection/detections/bot-detection.js b/injected/src/services/web-interference-detection/detections/bot-detection.js deleted file mode 100644 index 1b1e754926..0000000000 --- a/injected/src/services/web-interference-detection/detections/bot-detection.js +++ /dev/null @@ -1,69 +0,0 @@ -/** - * @typedef {import('../types/detection.types.js').BotDetectionConfig} BotDetectionConfig - * @typedef {import('../types/detection.types.js').TypeDetectionResult} TypeDetectionResult - * @typedef {import('../types/detection.types.js').StatusSelectorConfig} StatusSelectorConfig - * @typedef {import('../types/detection.types.js').ChallengeConfig} ChallengeConfig - * @typedef {import('../types/detection.types.js').InterferenceDetector} InterferenceDetector - */ - -import { checkSelectors, checkWindowProperties, matchesSelectors, matchesTextPatterns } from '../utils/detection-utils.js'; - -/** - * @implements {InterferenceDetector} - */ -export class BotDetection { - /** - * @param {BotDetectionConfig} config - */ - constructor(config) { - this.config = config; - } - - /** - * @returns {TypeDetectionResult} - */ - detect() { - const results = Object.entries(this.config || {}) - .filter(([_, challengeConfig]) => challengeConfig.state === 'enabled') - .map(([challengeId, challengeConfig]) => { - const detected = checkSelectors(challengeConfig.selectors) || checkWindowProperties(challengeConfig.windowProperties || []); - if (!detected) { - return null; - } - - const challengeStatus = this._findStatus(challengeConfig.statusSelectors); - return { - detected: true, - vendor: challengeConfig.vendor, - challengeType: challengeId, - challengeStatus, - }; - }) - .filter((result) => result !== null); - - return { - detected: results.length > 0, - interferenceType: 'botDetection', - results, - timestamp: Date.now(), - }; - } - - /** - * @param {StatusSelectorConfig[]} [statusSelectors] - * @returns {string|null} - */ - _findStatus(statusSelectors) { - if (!statusSelectors || !Array.isArray(statusSelectors)) { - return null; - } - - return ( - statusSelectors.find((statusConfig) => { - const { status, selectors, textPatterns, textSources } = statusConfig; - const hasMatch = matchesSelectors(selectors) || matchesTextPatterns(document.body, textPatterns, textSources); - return hasMatch ? status : null; - })?.status || null - ); - } -} diff --git a/injected/src/services/web-interference-detection/detections/fraud-detection.js b/injected/src/services/web-interference-detection/detections/fraud-detection.js deleted file mode 100644 index c822f21549..0000000000 --- a/injected/src/services/web-interference-detection/detections/fraud-detection.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * @typedef {import('../types/detection.types.js').AntiFraudConfig} AntiFraudConfig - * @typedef {import('../types/detection.types.js').TypeDetectionResult} TypeDetectionResult - * @typedef {import('../types/detection.types.js').AntiFraudAlertConfig} AntiFraudAlertConfig - * @typedef {import('../types/detection.types.js').InterferenceDetector} InterferenceDetector - */ - -import { checkSelectorsWithVisibility, checkTextPatterns } from '../utils/detection-utils.js'; - -/** - * @implements {InterferenceDetector} - */ -export class FraudDetection { - /** - * @param {AntiFraudConfig} config - */ - constructor(config) { - this.config = config; - } - - /** - * @returns {TypeDetectionResult} - */ - detect() { - const results = Object.entries(this.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, - type: alertConfig.type, - }; - }) - .filter((result) => result !== null); - - return { - detected: results.length > 0, - interferenceType: 'fraudDetection', - results, - timestamp: Date.now(), - }; - } -} diff --git a/injected/src/services/web-interference-detection/detections/youtube-ads-detection.js b/injected/src/services/web-interference-detection/detections/youtube-ads-detection.js deleted file mode 100644 index 33c47b1d7a..0000000000 --- a/injected/src/services/web-interference-detection/detections/youtube-ads-detection.js +++ /dev/null @@ -1,89 +0,0 @@ -/** - * @typedef {import('../types/detection.types.js').YouTubeAdsConfig} YouTubeAdsConfig - * @typedef {import('../types/detection.types.js').TypeDetectionResult} TypeDetectionResult - * @typedef {import('../types/detection.types.js').InterferenceDetector} InterferenceDetector - */ - -import { DetectionBase } from './detection-base.js'; -import { isVisible, queryAllSelectors } from '../utils/detection-utils.js'; -import { createEmptyResult } from '../utils/result-factory.js'; - -/** - * PROTOTYPE: YouTube ad detection - * TODO: Add mutation-based detection, ad lifecycle tracking, sponsored content badges - * @implements {InterferenceDetector} - */ -export class YouTubeAdsDetection extends DetectionBase { - /** - * @param {YouTubeAdsConfig} config - * @param {((result: TypeDetectionResult) => void)|null} [onInterferenceChange] - */ - constructor(config, onInterferenceChange = null) { - super(config, onInterferenceChange); - this.adCurrentlyPlaying = false; - } - - /** - * @returns {TypeDetectionResult} - */ - detect() { - const root = this.findRoot(); - if (!root) { - return createEmptyResult('youtubeAds'); - } - - const hasAdClass = this.config.adClasses.some((/** @type {string} */ cls) => root.classList.contains(cls)); - const adElements = queryAllSelectors(this.config.selectors, root); - const hasVisibleAdElement = adElements.some((el) => isVisible(el)); - - const detected = hasAdClass || hasVisibleAdElement; - - return { - detected, - interferenceType: 'youtubeAds', - results: detected - ? [ - { - adCurrentlyPlaying: true, - adType: 'video-ad', - source: 'one-time-detection', - }, - ] - : [], - timestamp: Date.now(), - }; - } - - findRoot() { - return document.querySelector(this.config.rootSelector); - } - - checkForInterference() { - if (!this.root) { - return; - } - - const hadAd = this.adCurrentlyPlaying; - const hasAdClass = this.config.adClasses.some((/** @type {string} */ cls) => this.root && this.root.classList.contains(cls)); - this.adCurrentlyPlaying = hasAdClass; - - if (this.onInterferenceChange && hadAd !== this.adCurrentlyPlaying) { - this.onInterferenceChange( - this.adCurrentlyPlaying - ? { - detected: true, - interferenceType: 'youtubeAds', - results: [ - { - adCurrentlyPlaying: true, - adType: 'video-ad', - source: 'detector', - }, - ], - timestamp: Date.now(), - } - : createEmptyResult('youtubeAds'), - ); - } - } -} diff --git a/injected/src/services/web-interference-detection/detector-service.js b/injected/src/services/web-interference-detection/detector-service.js deleted file mode 100644 index 5868c4c6d6..0000000000 --- a/injected/src/services/web-interference-detection/detector-service.js +++ /dev/null @@ -1,81 +0,0 @@ -/** - * @typedef {import('./types/detection.types.js').InterferenceType} InterferenceType - * @typedef {import('./types/detection.types.js').InterferenceConfig} InterferenceConfig - * @typedef {import('./types/detection.types.js').TypeDetectionResult} TypeDetectionResult - * @typedef {import('./types/detection.types.js').DetectionResults} DetectionResults - * @typedef {import('./types/detection.types.js').DetectInterferenceParams} DetectInterferenceParams - * @typedef {import('./types/detection.types.js').InterferenceDetector} InterferenceDetector - * @typedef {import('./types/api.types.js').InterferenceDetectionRequest} InterferenceDetectionRequest - */ - -import { BotDetection } from './detections/bot-detection.js'; -import { FraudDetection } from './detections/fraud-detection.js'; -import { YouTubeAdsDetection } from './detections/youtube-ads-detection.js'; -import { createEmptyResult } from './utils/result-factory.js'; - -const detectionClassMap = { - botDetection: BotDetection, - fraudDetection: FraudDetection, - youtubeAds: YouTubeAdsDetection, -}; - -class WebInterferenceDetectionService { - /** - * @param {DetectInterferenceParams} params - */ - constructor(params) { - const { interferenceConfig, onDetectionChange } = params; - this.interferenceConfig = interferenceConfig; - this.onDetectionChange = onDetectionChange; - this.activeDetections = []; - } - - /** - * @param {InterferenceDetectionRequest} request - * @returns {DetectionResults} - */ - detect(request) { - const { types } = request; - const results = /** @type {DetectionResults} */ ({}); - types.forEach((type) => { - const DetectionClass = detectionClassMap[type]; - if (!DetectionClass) { - throw new Error(`Unsupported interference type: "${type}". Supported types: ${Object.keys(detectionClassMap).join(', ')}`); - } - - const config = this.interferenceConfig.settings?.[type]; - if (!config) { - results[type] = createEmptyResult(type); - return; - } - - const { observeDOMChanges } = config ?? {}; - - const callback = - observeDOMChanges && this.onDetectionChange - ? (/** @type {TypeDetectionResult} */ result) => this.onDetectionChange?.({ [type]: result }) - : null; - - const detection = /** @type {InterferenceDetector} */ (new DetectionClass(config, callback)); - results[type] = detection.detect(); - - if (callback && typeof detection.stop === 'function') { - this.activeDetections.push(detection); - } - }); - - return results; - } - - cleanup() { - this.activeDetections.forEach((detection) => detection.stop()); - } -} - -/** - * @param {DetectInterferenceParams} params - * @returns {WebInterferenceDetectionService} - */ -export function createWebInterferenceService(params) { - return new WebInterferenceDetectionService(params); -} diff --git a/injected/src/services/web-interference-detection/types/api.types.js b/injected/src/services/web-interference-detection/types/api.types.js deleted file mode 100644 index 8ad5a4f180..0000000000 --- a/injected/src/services/web-interference-detection/types/api.types.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * API types for native messaging interface - */ - -/** - * @typedef {import('./detection.types').InterferenceType} InterferenceType - */ - -/** - * @typedef {object} InterferenceDetectionRequest - * @property {InterferenceType[]} types - Interference types to detect - */ - -export {}; diff --git a/injected/src/services/web-interference-detection/types/detection.types.js b/injected/src/services/web-interference-detection/types/detection.types.js deleted file mode 100644 index 6923db39be..0000000000 --- a/injected/src/services/web-interference-detection/types/detection.types.js +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Core detection types for web interference detection service - */ - -/** - * @typedef {'botDetection' | 'youtubeAds' | 'fraudDetection'} InterferenceType - */ - -/** - * @typedef {'cloudflare' | 'hcaptcha'} VendorName - */ - -/** - * @typedef {'turnstile' | 'challengePage'} ChallengeType - */ - -/** - * @typedef {'cloudflareTurnstile' | 'cloudflareChallengePage' | 'hcaptcha'} ChallengeIdentifier - */ - -/** - * @typedef {'enabled' | 'disabled'} FeatureState - */ - -/** - * @typedef {object} StatusSelectorConfig - * @property {string} status - * @property {string[]} [selectors] - * @property {string[]} [textPatterns] - * @property {string[]} [textSources] - */ - -/** - * @typedef {object} ChallengeConfig - * @property {FeatureState} state - * @property {VendorName} vendor - * @property {string[]} [selectors] - * @property {string[]} [windowProperties] - * @property {StatusSelectorConfig[]} [statusSelectors] - * @property {boolean} [observeDOMChanges] - */ - -/** - * @typedef {Partial>} BotDetectionConfig - */ - -/** - * @typedef {object} AntiFraudAlertConfig - * @property {FeatureState} state - * @property {string} type - * @property {string[]} [selectors] - * @property {string[]} [textPatterns] - * @property {string[]} [textSources] - * @property {boolean} [observeDOMChanges] - */ - -/** - * @typedef {Record} AntiFraudConfig - */ - -/** - * @typedef {object} YouTubeAdsConfig - * @property {string} rootSelector - * @property {string[]} watchAttributes - * @property {string[]} selectors - * @property {string[]} adClasses - * @property {string[]} [textPatterns] - * @property {string[]} [textSources] - * @property {number} [pollInterval] - * @property {number} [rerootInterval] - * @property {boolean} [observeDOMChanges] - */ - -/** - * @typedef {object} InterferenceSettings - * @property {BotDetectionConfig} [botDetection] - * @property {AntiFraudConfig} [fraudDetection] - * @property {YouTubeAdsConfig} [youtubeAds] - */ - -/** - * @typedef {object} InterferenceConfig - * @property {InterferenceSettings} settings - */ - -/** - * @typedef {object} VendorDetectionResult - * @property {boolean} detected - Whether vendor was detected - * @property {VendorName} vendor - Vendor identifier - * @property {string} challengeType - Challenge identifier - * @property {string | null} challengeStatus - Challenge status - */ - -/** - * @typedef {object} TypeDetectionResult - * @property {boolean} detected - * @property {InterferenceType} interferenceType - * @property {Record[]} [results] - * @property {number} timestamp - */ - -/** - * @typedef {object} InterferenceDetector - * @property {() => TypeDetectionResult} detect - * @property {() => void} [stop] - */ - -/** - * @callback TypeDetectorFunction - * @param {InterferenceConfig} interferenceConfig - * @returns {TypeDetectionResult} - */ - -/** - * @typedef {object} DetectInterferenceParams - * @property {InterferenceConfig} interferenceConfig - * @property {((result: DetectionResults) => void)|null} [onDetectionChange] - */ - -/** - * @typedef {Partial>} DetectionResults - */ - -export {}; diff --git a/injected/src/services/web-interference-detection/utils/result-factory.js b/injected/src/services/web-interference-detection/utils/result-factory.js deleted file mode 100644 index 60a02205cd..0000000000 --- a/injected/src/services/web-interference-detection/utils/result-factory.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @typedef {import('../types/detection.types.js').InterferenceType} InterferenceType - * @typedef {import('../types/detection.types.js').TypeDetectionResult} TypeDetectionResult - */ - -/** - * @param {InterferenceType} type - * @returns {TypeDetectionResult} - */ -export function createEmptyResult(type) { - return { - detected: false, - interferenceType: type, - results: [], - timestamp: Date.now(), - }; -} From f241a6dfad4bcbf2ae2694e9973d442e8f38e342 Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Mon, 10 Nov 2025 14:26:22 -0700 Subject: [PATCH 03/27] init and service --- injected/src/content-scope-features.js | 4 ++ injected/src/detectors/detector-init.js | 44 ++++++++++++++++ injected/src/detectors/detector-service.js | 57 ++++++++++++--------- injected/src/features.js | 1 + injected/src/features/breakage-reporting.js | 10 ++++ 5 files changed, 92 insertions(+), 24 deletions(-) create mode 100644 injected/src/detectors/detector-init.js diff --git a/injected/src/content-scope-features.js b/injected/src/content-scope-features.js index 2b062f6c23..6cb73beb1b 100644 --- a/injected/src/content-scope-features.js +++ b/injected/src/content-scope-features.js @@ -3,6 +3,7 @@ import { platformSupport } from './features'; import { PerformanceMonitor } from './performance'; import platformFeatures from 'ddg:platformFeatures'; import { registerForURLChanges } from './url-change'; +import { initDetectors } from './detectors/detector-init.js'; let initArgs = null; const updates = []; @@ -44,6 +45,9 @@ export function load(args) { const bundledFeatureNames = typeof importConfig.injectName === 'string' ? platformSupport[importConfig.injectName] : []; + // Initialize detectors early so they're available when features init + initDetectors(args.bundledConfig); + // prettier-ignore const featuresToLoad = isGloballyDisabled(args) // if we're globally disabled, only allow `platformSpecificFeatures` diff --git a/injected/src/detectors/detector-init.js b/injected/src/detectors/detector-init.js new file mode 100644 index 0000000000..b90f736bd0 --- /dev/null +++ b/injected/src/detectors/detector-init.js @@ -0,0 +1,44 @@ +/** + * Detector Initialization + * + * Reads bundledConfig and registers detectors with the detector service. + * Called during content-scope-features load phase. + */ + +import { registerDetector } from './detector-service.js'; +import { DEFAULT_DETECTOR_SETTINGS } from './default-config.js'; +import { createBotDetector } from './detections/bot-detection.js'; +import { createFraudDetector } from './detections/fraud-detection.js'; +import { createYouTubeAdsDetector } from './detections/youtube-ads-detection.js'; + +/** + * Initialize detectors based on bundled configuration + * @param {any} bundledConfig - The bundled configuration object + */ +export function initDetectors(bundledConfig) { + // Check if web-interference-detection feature is enabled + const enabled = bundledConfig?.features?.['web-interference-detection']?.state === 'enabled'; + if (!enabled) { + return; + } + + // Merge default settings with remote config + const detectorSettings = { + ...DEFAULT_DETECTOR_SETTINGS, + ...bundledConfig?.features?.['web-interference-detection']?.settings?.interferenceTypes, + }; + + // Register each detector if its settings exist + if (detectorSettings.botDetection) { + registerDetector('botDetection', createBotDetector(detectorSettings.botDetection)); + } + + if (detectorSettings.fraudDetection) { + registerDetector('fraudDetection', createFraudDetector(detectorSettings.fraudDetection)); + } + + if (detectorSettings.youtubeAds) { + registerDetector('youtubeAds', createYouTubeAdsDetector(detectorSettings.youtubeAds)); + } +} + diff --git a/injected/src/detectors/detector-service.js b/injected/src/detectors/detector-service.js index 522762a413..a6642a0666 100644 --- a/injected/src/detectors/detector-service.js +++ b/injected/src/detectors/detector-service.js @@ -1,30 +1,37 @@ /** - * @typedef {object} DetectorRegistration - * @property {() => any | Promise} getData - * @property {(() => any | Promise)=} refresh - * @property {(() => void)=} teardown + * Detector Service * - * @typedef {object} CachedSnapshot - * @property {any} data - * @property {number} ts + * Central registry and caching layer for interference detectors. + * Provides a simple API for registering detectors and retrieving their data. */ const registrations = new Map(); const cache = new Map(); /** - * Register a detector with the shared service. - * Subsequent calls replace the previous registration for the same id. - * @param {string} detectorId - * @param {DetectorRegistration} registration + * @typedef {Object} DetectorRegistration + * @property {() => Promise} getData - Function to get current detector data + * @property {() => Promise} [refresh] - Optional function to refresh/re-run detection + */ + +/** + * @typedef {Object} CachedSnapshot + * @property {any} data - The cached detector data + * @property {number} ts - Timestamp when data was cached + */ + +/** + * Register a detector with the service + * @param {string} detectorId - Unique identifier for the detector + * @param {DetectorRegistration} registration - Detector registration object */ export function registerDetector(detectorId, registration) { registrations.set(detectorId, registration); } /** - * Remove a detector registration and drop any cached data. - * @param {string} detectorId + * Unregister a detector from the service + * @param {string} detectorId - Unique identifier for the detector */ export function unregisterDetector(detectorId) { const registration = registrations.get(detectorId); @@ -34,8 +41,8 @@ export function unregisterDetector(detectorId) { } /** - * Reset all detector caches and invoke teardowns. - * @param {string} [reason] + * Reset all detectors and clear cache + * @param {string} [reason] - Optional reason for reset */ export function resetDetectors(reason = 'manual') { for (const registration of registrations.values()) { @@ -45,14 +52,16 @@ export function resetDetectors(reason = 'manual') { } /** - * Fetch detector data. Uses cached value when available unless maxAgeMs is exceeded. - * @param {string} detectorId - * @param {{ maxAgeMs?: number }} [options] - * @returns {Promise} + * Get data from a specific detector + * @param {string} detectorId - Unique identifier for the detector + * @param {Object} [options] - Options for data retrieval + * @param {number} [options.maxAgeMs] - Maximum age of cached data in milliseconds + * @returns {Promise} Detector data or null if not registered */ export async function getDetectorData(detectorId, options = {}) { const { maxAgeMs } = options; const cached = /** @type {CachedSnapshot | undefined} */ (cache.get(detectorId)); + if (cached) { const age = Date.now() - cached.ts; if (!maxAgeMs || age <= maxAgeMs) { @@ -77,10 +86,11 @@ export async function getDetectorData(detectorId, options = {}) { } /** - * Convenience helper for fetching multiple detectors at once. - * @param {string[]} detectorIds - * @param {{ maxAgeMs?: number }} [options] - * @returns {Promise>} + * Get data from multiple detectors in a single call + * @param {string[]} detectorIds - Array of detector IDs + * @param {Object} [options] - Options for data retrieval + * @param {number} [options.maxAgeMs] - Maximum age of cached data in milliseconds + * @returns {Promise>} Object mapping detector IDs to their data */ export async function getDetectorBatch(detectorIds, options = {}) { const results = {}; @@ -89,4 +99,3 @@ export async function getDetectorBatch(detectorIds, options = {}) { } return results; } - diff --git a/injected/src/features.js b/injected/src/features.js index f704269a41..046a219a5e 100644 --- a/injected/src/features.js +++ b/injected/src/features.js @@ -32,6 +32,7 @@ const otherFeatures = /** @type {const} */ ([ 'favicon', 'webTelemetry', 'pageContext', + 'webInterferenceDetection', ]); /** @typedef {baseFeatures[number]|otherFeatures[number]} FeatureName */ diff --git a/injected/src/features/breakage-reporting.js b/injected/src/features/breakage-reporting.js index b94172bfa7..b38668c8d4 100644 --- a/injected/src/features/breakage-reporting.js +++ b/injected/src/features/breakage-reporting.js @@ -1,5 +1,6 @@ import ContentFeature from '../content-feature'; import { getExpandedPerformanceMetrics, getJsPerformanceMetrics } from './breakage-reporting/utils.js'; +import { getDetectorBatch } from '../detectors/detector-service.js'; export default class BreakageReporting extends ContentFeature { init() { @@ -7,9 +8,18 @@ export default class BreakageReporting extends ContentFeature { this.messaging.subscribe('getBreakageReportValues', async () => { const jsPerformance = getJsPerformanceMetrics(); const referrer = document.referrer; + + // Collect detector data + const detectorData = await getDetectorBatch([ + 'botDetection', + 'fraudDetection', + 'youtubeAds' + ]); + const result = { jsPerformance, referrer, + detectorData, }; if (isExpandedPerformanceMetricsEnabled) { const expandedPerformanceMetrics = await getExpandedPerformanceMetrics(); From 5653ae38576bb121b4a89549065cb6afde16ad87 Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Mon, 10 Nov 2025 14:29:48 -0700 Subject: [PATCH 04/27] update readme --- injected/src/detectors/README.md | 96 +++++++++++++++++++++++--------- 1 file changed, 71 insertions(+), 25 deletions(-) diff --git a/injected/src/detectors/README.md b/injected/src/detectors/README.md index 7360b474bc..866de90ee8 100644 --- a/injected/src/detectors/README.md +++ b/injected/src/detectors/README.md @@ -1,8 +1,8 @@ -# Detector Registry (Prototype) +# Detector Service -This directory contains a lightweight registry that runs inside content-scope-scripts. Detectors register with the shared service and any feature can query their latest results (breakage reporting, native PIR, debug tooling, etc.). +This directory contains a lightweight detector service that runs inside content-scope-scripts. Detectors are automatically registered during the `load()` phase and any feature can query their latest results (breakage reporting, native PIR, debug tooling, etc.). -The initial focus is synchronous, on-demand collection. Continuous monitoring (mutation observers, polling, batching) can be layered on later without changing the public API. +The current implementation focuses on synchronous, on-demand collection with caching. Continuous monitoring (mutation observers, polling, batching) can be layered on later without changing the public API. ## API Snapshot @@ -33,39 +33,85 @@ Detectors return arbitrary JSON payloads. Include timestamps if consumers rely o ``` detectors/ -├── detector-service.js # registry + caching helpers -├── default-config.js # sample configuration blobs +├── detector-service.js # registry + caching service +├── detector-init.js # initializes detectors from bundledConfig +├── default-config.js # default detector settings ├── detections/ -│ ├── bot-detection.js # helpers for CAPTCHA/bot detection -│ ├── fraud-detection.js # helpers for anti-fraud banners -│ └── youtube-ads-detection.js # helper for YouTube ad snapshots -├── detections/detection-base.js # optional base for observer-style detectors +│ ├── bot-detection.js # CAPTCHA/bot detection +│ ├── fraud-detection.js # anti-fraud/phishing warnings +│ ├── youtube-ads-detection.js # YouTube ad detection +│ └── detection-base.js # optional base for observer-style detectors └── utils/ └── detection-utils.js # DOM helpers (selectors, text matching, visibility) ``` -## Example Usage +## How It Works -```javascript -import { registerDetector, getDetectorData } from '../detectors/detector-service.js'; -import { createBotDetector } from '../detectors/detections/bot-detection.js'; -import { DEFAULT_DETECTOR_SETTINGS } from '../detectors/default-config.js'; +### Initialization + +Detectors are automatically registered during the content-scope-features `load()` phase: + +1. `content-scope-features.js` calls `initDetectors(bundledConfig)` +2. `detector-init.js` reads the `web-interference-detection` feature config +3. Default detector settings are merged with remote config +4. Detectors are registered with the service using `registerDetector()` + +### Remote Configuration -// During feature init -registerDetector('botDetection', createBotDetector(DEFAULT_DETECTOR_SETTINGS.botDetection)); +Detectors are controlled via `privacy-configuration/features/web-interference-detection.json`: -// Later, when preparing a breakage report -const snapshot = await getDetectorData('botDetection', { maxAgeMs: 1_000 }); -if (snapshot?.detected) { - payload.detectors.bot = snapshot; +```json +{ + "state": "enabled", + "settings": { + "interferenceTypes": { + "botDetection": { + "hcaptcha": { + "state": "enabled", + "vendor": "hcaptcha", + "selectors": [".h-captcha"], + "windowProperties": ["hcaptcha"] + } + } + } + } } ``` -## Extending +### Consuming Detector Data + +Features can directly import and use the detector service: + +```javascript +import { getDetectorBatch } from '../detectors/detector-service.js'; + +// In breakage reporting feature +const detectorData = await getDetectorBatch(['botDetection', 'fraudDetection', 'youtubeAds']); +// Returns: { botDetection: {...}, fraudDetection: {...}, youtubeAds: {...} } +``` + +## Adding New Detectors + +1. **Create detection logic** under `detections/`: + - Export a `createXDetector(config)` factory function + - Return an object with `{ getData, refresh?, teardown? }` + - Use shared utilities from `utils/detection-utils.js` + +2. **Add default config** to `default-config.js`: + - Define default selectors, patterns, and settings + - These serve as fallback if remote config is unavailable + +3. **Register in detector-init.js**: + - Import your detector factory + - Add registration logic in `initDetectors()` + +4. **Add remote config** to `privacy-configuration/features/web-interference-detection.json`: + - Define the detector's configuration schema + - This allows remote enabling/disabling and tuning -1. Add a helper under `detections/` that exposes a `createXDetector(config)` returning `{ getData, refresh?, teardown? }`. -2. Register it during feature bootstrap or via a shared initializer. -3. (Optional) Add defaults to `default-config.js` or wire it to remote config. +5. **Consume the detector** in your feature: + - Import `getDetectorData` or `getDetectorBatch` + - Call with your detector ID to get results -Future enhancements—shared observers, background aggregation, streaming updates—can build on this registry without breaking the public API. +Future enhancements—shared observers, background aggregation, streaming updates—can build on this service without breaking the public API. From be56f76ce59821b722c70943580225a6569adbda Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Mon, 10 Nov 2025 15:33:51 -0700 Subject: [PATCH 05/27] update readme --- injected/src/detectors/README.md | 111 ++++++++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 3 deletions(-) diff --git a/injected/src/detectors/README.md b/injected/src/detectors/README.md index 866de90ee8..3f4db9e248 100644 --- a/injected/src/detectors/README.md +++ b/injected/src/detectors/README.md @@ -42,7 +42,7 @@ detectors/ │ ├── youtube-ads-detection.js # YouTube ad detection │ └── detection-base.js # optional base for observer-style detectors └── utils/ - └── detection-utils.js # DOM helpers (selectors, text matching, visibility) + └── detection-utils.js # DOM helpers (selectors, text matching, visibility, domain matching) ``` ## How It Works @@ -64,6 +64,7 @@ Detectors are controlled via `privacy-configuration/features/web-interference-de { "state": "enabled", "settings": { + "domains": [], "interferenceTypes": { "botDetection": { "hcaptcha": { @@ -72,12 +73,71 @@ Detectors are controlled via `privacy-configuration/features/web-interference-de "selectors": [".h-captcha"], "windowProperties": ["hcaptcha"] } + }, + "youtubeAds": { + "domains": ["*.youtube.com", "youtube.com"], + "rootSelector": "#movie_player" } } } } ``` +#### Domain Gating + +Detectors can be restricted to specific domains using the `domains` field: + +- **Global domains** (`settings.domains`): Apply to all detectors unless overridden +- **Per-detector domains** (`interferenceTypes.{detectorId}.domains`): Override global setting +- **Domain patterns**: + - Exact match: `"youtube.com"` + - Wildcard: `"*.youtube.com"` (matches www.youtube.com, m.youtube.com, etc.) + - Substring: `"youtube.com"` also matches `www.youtube.com` for convenience +- **Empty array** (`[]`): Run on all domains (default) + +Examples: +```json +// Run bot detection only on banking sites +"botDetection": { + "domains": ["*.chase.com", "*.bankofamerica.com"], + ... +} + +// Run YouTube detector only on YouTube +"youtubeAds": { + "domains": ["*.youtube.com"], + ... +} +``` + +#### Auto-Run + +Detectors can be configured to run automatically on page load: + +- **`autoRun: true`** (default): Run detector automatically after page load + - Gates are checked (domain + custom `shouldRun()`) + - Results are cached immediately + - Runs after 100ms delay to let DOM settle + - Useful for detectors that should always gather data (bot detection, fraud detection) + +- **`autoRun: false`**: Only run when explicitly called + - Gates are skipped for manual calls + - Useful for expensive detectors or event-driven scenarios + - Example: YouTube detector only runs when explicitly requested + +Example: +```json +"botDetection": { + "autoRun": true, // Run automatically with gates + "domains": ["*.example.com"], + ... +}, +"expensiveDetector": { + "autoRun": false, // Only run on-demand, skip gates + ... +} +``` + ### Consuming Detector Data Features can directly import and use the detector service: @@ -85,16 +145,24 @@ Features can directly import and use the detector service: ```javascript import { getDetectorBatch } from '../detectors/detector-service.js'; -// In breakage reporting feature +// In breakage reporting feature - gates bypassed automatically for manual calls const detectorData = await getDetectorBatch(['botDetection', 'fraudDetection', 'youtubeAds']); // Returns: { botDetection: {...}, fraudDetection: {...}, youtubeAds: {...} } ``` +**Behavior:** +- **Manual calls** (like above): Gates are bypassed, detector always runs +- **Auto-run calls**: Gates are checked (domain + `shouldRun()`) +- **Caching**: Results cached with timestamp, use `maxAgeMs` to force refresh + +**Options:** +- `maxAgeMs`: Maximum age of cached data in milliseconds before forcing refresh + ## Adding New Detectors 1. **Create detection logic** under `detections/`: - Export a `createXDetector(config)` factory function - - Return an object with `{ getData, refresh?, teardown? }` + - Return an object with `{ getData, shouldRun?, refresh?, teardown? }` - Use shared utilities from `utils/detection-utils.js` 2. **Add default config** to `default-config.js`: @@ -107,11 +175,48 @@ const detectorData = await getDetectorBatch(['botDetection', 'fraudDetection', ' 4. **Add remote config** to `privacy-configuration/features/web-interference-detection.json`: - Define the detector's configuration schema + - Optionally add `domains` field for domain gating - This allows remote enabling/disabling and tuning 5. **Consume the detector** in your feature: - Import `getDetectorData` or `getDetectorBatch` - Call with your detector ID to get results +### Custom Gate Functions + +Detectors can optionally implement a `shouldRun()` gate function for custom precondition checks: + +```javascript +export function createMyDetector(config) { + return { + // Optional gate function runs before getData() + // Return false to skip detection entirely (returns null) + shouldRun() { + // Example: Only run if specific element exists + return document.querySelector('#app-root') !== null; + }, + + async getData() { + // This only runs if shouldRun() returns true + // and domain gate passes + return { detected: true, ... }; + } + }; +} +``` + +**When to use `shouldRun()`:** +- Lightweight DOM precondition checks (e.g., element exists) +- Dependency on another detector's results +- Runtime feature detection +- Performance optimization to avoid expensive operations + +**Gate execution order:** +1. Domain gate (from config) +2. Custom `shouldRun()` gate (if provided) +3. `getData()` (if all gates pass) + +If any gate fails, `getDetectorData()` returns `null`. + Future enhancements—shared observers, background aggregation, streaming updates—can build on this service without breaking the public API. From e2d83175915f353849ecb68795bb503069a308c4 Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Mon, 10 Nov 2025 15:44:24 -0700 Subject: [PATCH 06/27] update readme --- injected/src/detectors/README.md | 53 ++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/injected/src/detectors/README.md b/injected/src/detectors/README.md index 3f4db9e248..6d4357b44b 100644 --- a/injected/src/detectors/README.md +++ b/injected/src/detectors/README.md @@ -65,6 +65,7 @@ Detectors are controlled via `privacy-configuration/features/web-interference-de "state": "enabled", "settings": { "domains": [], + "autoRunDelayMs": 100, "interferenceTypes": { "botDetection": { "hcaptcha": { @@ -117,7 +118,7 @@ Detectors can be configured to run automatically on page load: - **`autoRun: true`** (default): Run detector automatically after page load - Gates are checked (domain + custom `shouldRun()`) - Results are cached immediately - - Runs after 100ms delay to let DOM settle + - Runs after configurable delay (see `autoRunDelayMs`) - Useful for detectors that should always gather data (bot detection, fraud detection) - **`autoRun: false`**: Only run when explicitly called @@ -125,16 +126,26 @@ Detectors can be configured to run automatically on page load: - Useful for expensive detectors or event-driven scenarios - Example: YouTube detector only runs when explicitly requested +- **`autoRunDelayMs`** (global setting, default: 100): Milliseconds to wait before running auto-run detectors + - Allows DOM to settle after page load + - Can be tuned per-site or globally + - Use 0 for immediate execution, higher values for slower-loading pages + Example: ```json -"botDetection": { - "autoRun": true, // Run automatically with gates - "domains": ["*.example.com"], - ... -}, -"expensiveDetector": { - "autoRun": false, // Only run on-demand, skip gates - ... +"settings": { + "autoRunDelayMs": 250, // Wait 250ms before auto-running detectors + "interferenceTypes": { + "botDetection": { + "autoRun": true, // Run automatically with gates + "domains": ["*.example.com"], + ... + }, + "expensiveDetector": { + "autoRun": false, // Only run on-demand, skip gates + ... + } + } } ``` @@ -205,9 +216,31 @@ export function createMyDetector(config) { } ``` +**Example: Detector that depends on another detector's results** + +```javascript +import { getDetectorData } from '../detector-service.js'; + +export function createAdvancedBotDetector(config) { + return { + // Only run advanced detection if basic bot detection found something + async shouldRun() { + const basicBotData = await getDetectorData('botDetection'); + // Only run if basic detector found a bot/CAPTCHA + return basicBotData?.detected === true; + }, + + async getData() { + // Run expensive/detailed analysis only when needed + return runAdvancedBotAnalysis(config); + } + }; +} +``` + **When to use `shouldRun()`:** - Lightweight DOM precondition checks (e.g., element exists) -- Dependency on another detector's results +- Dependency on another detector's results (use `getDetectorData()` inside `shouldRun()`) - Runtime feature detection - Performance optimization to avoid expensive operations From f5abfad15b5fbfde04a2e9fcfa66601ff96cce4c Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Mon, 10 Nov 2025 15:49:06 -0700 Subject: [PATCH 07/27] add auto run --- .../detections/youtube-ads-detection.js | 10 ++ injected/src/detectors/detector-init.js | 110 ++++++++++++++++-- injected/src/detectors/detector-service.js | 5 +- .../src/detectors/utils/detection-utils.js | 36 ++++++ injected/src/features/breakage-reporting.js | 2 +- 5 files changed, 153 insertions(+), 10 deletions(-) diff --git a/injected/src/detectors/detections/youtube-ads-detection.js b/injected/src/detectors/detections/youtube-ads-detection.js index e51b687fdb..7c8a6f89f9 100644 --- a/injected/src/detectors/detections/youtube-ads-detection.js +++ b/injected/src/detectors/detections/youtube-ads-detection.js @@ -8,6 +8,16 @@ const DEFAULT_CONFIG = { export function createYouTubeAdsDetector(config = {}) { const mergedConfig = { ...DEFAULT_CONFIG, ...config }; return { + /** + * Optional gate function - return false to skip detection entirely + * This runs before getData() and can be used for lightweight precondition checks + */ + shouldRun() { + // Only run if the YouTube player root element exists + // This avoids unnecessary DOM scanning on non-video pages + return document.querySelector(mergedConfig.rootSelector) !== null; + }, + async getData() { return runYouTubeAdsDetection(mergedConfig); }, diff --git a/injected/src/detectors/detector-init.js b/injected/src/detectors/detector-init.js index b90f736bd0..a2c3ddf63f 100644 --- a/injected/src/detectors/detector-init.js +++ b/injected/src/detectors/detector-init.js @@ -10,6 +10,71 @@ import { DEFAULT_DETECTOR_SETTINGS } from './default-config.js'; import { createBotDetector } from './detections/bot-detection.js'; import { createFraudDetector } from './detections/fraud-detection.js'; import { createYouTubeAdsDetector } from './detections/youtube-ads-detection.js'; +import { matchesDomainPatterns } from './utils/detection-utils.js'; + +/** + * Check if gates should run for this call + * @param {Object} options - Options passed to getData + * @param {boolean} [options._autoRun] - Internal flag indicating this is an auto-run call + * @param {string[]} [domains] - Domain patterns to check + * @param {Function} [shouldRun] - Custom gate function + * @returns {boolean} True if gates pass, false otherwise + */ +function checkGates(options, domains, shouldRun) { + // Only check gates for auto-run calls + // Manual calls bypass gates by default + if (!options?._autoRun) { + return true; + } + + // Check domain gate + if (!matchesDomainPatterns(domains)) { + return false; + } + + // Check custom gate function if provided + if (shouldRun && !shouldRun()) { + return false; + } + + return true; +} + +/** + * Create a gated detector registration that checks domain and custom gates + * Gates only apply for auto-run calls (when _autoRun: true is passed) + * Manual calls bypass gates by default + * + * @param {Object} registration - The detector registration object + * @param {Function} registration.getData - Function to get detector data + * @param {Function} [registration.shouldRun] - Optional gate function + * @param {Function} [registration.refresh] - Optional refresh function + * @param {Function} [registration.teardown] - Optional teardown function + * @param {string[]} [domains] - Optional array of domain patterns + * @returns {Object} Gated detector registration + */ +function createGatedDetector(registration, domains) { + const { getData, shouldRun, refresh, teardown } = registration; + + return { + getData: async (options) => { + // Check gates (only for auto-run, manual calls bypass) + if (!checkGates(options, domains, shouldRun)) { + return null; + } + + // All gates passed, run the detector + return getData(); + }, + refresh: refresh ? async (options) => { + if (!checkGates(options, domains, shouldRun)) { + return null; + } + return refresh(); + } : undefined, + teardown, + }; +} /** * Initialize detectors based on bundled configuration @@ -28,17 +93,46 @@ export function initDetectors(bundledConfig) { ...bundledConfig?.features?.['web-interference-detection']?.settings?.interferenceTypes, }; + // Get global domains config (applies to all detectors unless overridden) + const globalDomains = bundledConfig?.features?.['web-interference-detection']?.settings?.domains; + + // Track detectors to auto-run after registration + const autoRunDetectors = []; + + // Helper to register a detector with less repetition + const registerIfEnabled = (detectorId, detectorSettings, createDetectorFn) => { + if (!detectorSettings) return; + + const domains = detectorSettings.domains || globalDomains; + const autoRun = detectorSettings.autoRun !== false; // Default true + const registration = createDetectorFn(detectorSettings); + + registerDetector(detectorId, createGatedDetector(registration, domains)); + + if (autoRun) { + autoRunDetectors.push(detectorId); + } + }; + // Register each detector if its settings exist - if (detectorSettings.botDetection) { - registerDetector('botDetection', createBotDetector(detectorSettings.botDetection)); - } + registerIfEnabled('botDetection', detectorSettings.botDetection, createBotDetector); + registerIfEnabled('fraudDetection', detectorSettings.fraudDetection, createFraudDetector); + registerIfEnabled('youtubeAds', detectorSettings.youtubeAds, createYouTubeAdsDetector); - if (detectorSettings.fraudDetection) { - registerDetector('fraudDetection', createFraudDetector(detectorSettings.fraudDetection)); - } + // Auto-run detectors after a short delay to let page settle + if (autoRunDetectors.length > 0) { + // Get delay from config, default to 100ms + const autoRunDelayMs = bundledConfig?.features?.['web-interference-detection']?.settings?.autoRunDelayMs ?? 100; + + // Use setTimeout to avoid blocking page load + setTimeout(async () => { + const { getDetectorBatch } = await import('./detector-service.js'); + + // Run all auto-run detectors with _autoRun flag (gates will be checked) + await getDetectorBatch(autoRunDetectors, { _autoRun: true }); - if (detectorSettings.youtubeAds) { - registerDetector('youtubeAds', createYouTubeAdsDetector(detectorSettings.youtubeAds)); + console.log('[detectors] Auto-run complete for:', autoRunDetectors); + }, autoRunDelayMs); } } diff --git a/injected/src/detectors/detector-service.js b/injected/src/detectors/detector-service.js index a6642a0666..bc7946b781 100644 --- a/injected/src/detectors/detector-service.js +++ b/injected/src/detectors/detector-service.js @@ -56,6 +56,7 @@ export function resetDetectors(reason = 'manual') { * @param {string} detectorId - Unique identifier for the detector * @param {Object} [options] - Options for data retrieval * @param {number} [options.maxAgeMs] - Maximum age of cached data in milliseconds + * @param {boolean} [options._autoRun] - Internal flag indicating auto-run (gates apply) * @returns {Promise} Detector data or null if not registered */ export async function getDetectorData(detectorId, options = {}) { @@ -76,7 +77,8 @@ export async function getDetectorData(detectorId, options = {}) { const runner = registration.refresh ?? registration.getData; try { - const data = await runner(); + // Pass options to the runner so gates can check _autoRun flag + const data = await runner(options); cache.set(detectorId, { data, ts: Date.now() }); return data; } catch (error) { @@ -90,6 +92,7 @@ export async function getDetectorData(detectorId, options = {}) { * @param {string[]} detectorIds - Array of detector IDs * @param {Object} [options] - Options for data retrieval * @param {number} [options.maxAgeMs] - Maximum age of cached data in milliseconds + * @param {boolean} [options._autoRun] - Internal flag indicating auto-run (gates apply) * @returns {Promise>} Object mapping detector IDs to their data */ export async function getDetectorBatch(detectorIds, options = {}) { diff --git a/injected/src/detectors/utils/detection-utils.js b/injected/src/detectors/utils/detection-utils.js index 7419596b7d..7c63d2ce89 100644 --- a/injected/src/detectors/utils/detection-utils.js +++ b/injected/src/detectors/utils/detection-utils.js @@ -115,3 +115,39 @@ export function queryAllSelectors(selectors, root = document) { const elements = root.querySelectorAll(selectors.join(',')); return Array.from(elements); } + +/** + * Check if current domain matches any patterns in the domains list + * Supports exact matches, wildcards (*.domain.com), and substring matching + * + * @param {string[]} [domains] - Array of domain patterns + * @returns {boolean} True if current domain matches any pattern, or if no patterns provided + * + * @example + * matchesDomainPatterns(['youtube.com']) // Exact or substring match + * matchesDomainPatterns(['*.youtube.com']) // Wildcard match (www.youtube.com, m.youtube.com) + * matchesDomainPatterns([]) // Empty = match all domains + */ +export function matchesDomainPatterns(domains) { + if (!domains || !Array.isArray(domains) || domains.length === 0) { + return true; // No domain restrictions means match all + } + + const hostname = window.location.hostname; + + return domains.some((pattern) => { + // Exact match + if (pattern === hostname) { + return true; + } + + // Wildcard pattern (e.g., "*.youtube.com" or "youtube.*") + if (pattern.includes('*')) { + const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$'); + return regex.test(hostname); + } + + // Substring match for convenience (e.g., "youtube.com" matches "www.youtube.com") + return hostname.includes(pattern); + }); +} diff --git a/injected/src/features/breakage-reporting.js b/injected/src/features/breakage-reporting.js index b38668c8d4..40ebf13c3a 100644 --- a/injected/src/features/breakage-reporting.js +++ b/injected/src/features/breakage-reporting.js @@ -9,7 +9,7 @@ export default class BreakageReporting extends ContentFeature { const jsPerformance = getJsPerformanceMetrics(); const referrer = document.referrer; - // Collect detector data + // Collect detector data (gates bypassed by default for manual calls) const detectorData = await getDetectorBatch([ 'botDetection', 'fraudDetection', From 8096a9cd830182389c7c992b05923a330fcd953f Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Mon, 10 Nov 2025 16:02:12 -0700 Subject: [PATCH 08/27] drop global domains option --- injected/src/detectors/README.md | 72 ++++++++++++---------- injected/src/detectors/detector-init.js | 5 +- injected/src/detectors/detector-service.js | 22 ------- 3 files changed, 39 insertions(+), 60 deletions(-) diff --git a/injected/src/detectors/README.md b/injected/src/detectors/README.md index 6d4357b44b..9cceaed5cc 100644 --- a/injected/src/detectors/README.md +++ b/injected/src/detectors/README.md @@ -8,22 +8,24 @@ The current implementation focuses on synchronous, on-demand collection with cac ```mermaid sequenceDiagram - participant Feature as Breakage Reporting + participant Init as detector-init participant Service as detectorService - participant Detector as YouTubeDetector + participant Detector as BotDetector + participant Feature as Breakage Reporting - Detector->>Service: registerDetector('youtubeAds', { getData }) - Feature->>Service: getDetectorData('youtubeAds') - Service->>Detector: getData() - Detector-->>Service: snapshot - Service-->>Feature: snapshot + Init->>Detector: createBotDetector(config) + Detector-->>Init: { getData, shouldRun } + Init->>Service: registerDetector('botDetection', registration) + Note over Service: Auto-run after 100ms delay + Service->>Detector: getData({ _autoRun: true }) + Detector-->>Service: snapshot (cached) + Feature->>Service: getDetectorData('botDetection') + Service-->>Feature: snapshot (from cache) ``` ### Core helpers -- `registerDetector(detectorId, { getData, refresh?, teardown? })` -- `unregisterDetector(detectorId)` -- `resetDetectors(reason?)` +- `registerDetector(detectorId, { getData, shouldRun?, refresh?, teardown? })` - `getDetectorData(detectorId, { maxAgeMs }?)` - `getDetectorBatch(detectorIds, options?)` @@ -51,10 +53,14 @@ detectors/ Detectors are automatically registered during the content-scope-features `load()` phase: -1. `content-scope-features.js` calls `initDetectors(bundledConfig)` +1. `content-scope-features.js` calls `initDetectors(bundledConfig)` during page load 2. `detector-init.js` reads the `web-interference-detection` feature config 3. Default detector settings are merged with remote config 4. Detectors are registered with the service using `registerDetector()` +5. After `autoRunDelayMs` delay (default 100ms), detectors with `autoRun: true` execute automatically + - This delay lets the DOM settle after initial page load + - Auto-run calls check gates (domain + `shouldRun()`) + - Results are cached for later manual calls ### Remote Configuration @@ -64,7 +70,6 @@ Detectors are controlled via `privacy-configuration/features/web-interference-de { "state": "enabled", "settings": { - "domains": [], "autoRunDelayMs": 100, "interferenceTypes": { "botDetection": { @@ -75,9 +80,12 @@ Detectors are controlled via `privacy-configuration/features/web-interference-de "windowProperties": ["hcaptcha"] } }, - "youtubeAds": { - "domains": ["*.youtube.com", "youtube.com"], - "rootSelector": "#movie_player" + "fraudDetection": { + "phishingWarning": { + "state": "enabled", + "type": "phishing", + "selectors": [".warning-banner"] + } } } } @@ -86,28 +94,24 @@ Detectors are controlled via `privacy-configuration/features/web-interference-de #### Domain Gating -Detectors can be restricted to specific domains using the `domains` field: +Detectors can be restricted to specific domains using a per-detector `domains` field: -- **Global domains** (`settings.domains`): Apply to all detectors unless overridden -- **Per-detector domains** (`interferenceTypes.{detectorId}.domains`): Override global setting - **Domain patterns**: - Exact match: `"youtube.com"` - Wildcard: `"*.youtube.com"` (matches www.youtube.com, m.youtube.com, etc.) - Substring: `"youtube.com"` also matches `www.youtube.com` for convenience -- **Empty array** (`[]`): Run on all domains (default) -Examples: +Example: ```json -// Run bot detection only on banking sites -"botDetection": { - "domains": ["*.chase.com", "*.bankofamerica.com"], - ... -} - -// Run YouTube detector only on YouTube -"youtubeAds": { - "domains": ["*.youtube.com"], - ... +{ + "settings": { + "interferenceTypes": { + "fraudDetection": { + "domains": ["*.bank.com", "*.financial.com"], + ... + } + } + } } ``` @@ -124,12 +128,12 @@ Detectors can be configured to run automatically on page load: - **`autoRun: false`**: Only run when explicitly called - Gates are skipped for manual calls - Useful for expensive detectors or event-driven scenarios - - Example: YouTube detector only runs when explicitly requested - **`autoRunDelayMs`** (global setting, default: 100): Milliseconds to wait before running auto-run detectors - Allows DOM to settle after page load - Can be tuned per-site or globally - Use 0 for immediate execution, higher values for slower-loading pages + - **How it works**: After detectors are registered, a single `setTimeout` schedules all auto-run detectors to execute in batch after the delay Example: ```json @@ -157,8 +161,8 @@ Features can directly import and use the detector service: import { getDetectorBatch } from '../detectors/detector-service.js'; // In breakage reporting feature - gates bypassed automatically for manual calls -const detectorData = await getDetectorBatch(['botDetection', 'fraudDetection', 'youtubeAds']); -// Returns: { botDetection: {...}, fraudDetection: {...}, youtubeAds: {...} } +const detectorData = await getDetectorBatch(['botDetection', 'fraudDetection']); +// Returns: { botDetection: {...}, fraudDetection: {...} } ``` **Behavior:** @@ -182,7 +186,7 @@ const detectorData = await getDetectorBatch(['botDetection', 'fraudDetection', ' 3. **Register in detector-init.js**: - Import your detector factory - - Add registration logic in `initDetectors()` + - Add one line: `registerIfEnabled('myDetector', detectorSettings.myDetector, createMyDetector)` 4. **Add remote config** to `privacy-configuration/features/web-interference-detection.json`: - Define the detector's configuration schema diff --git a/injected/src/detectors/detector-init.js b/injected/src/detectors/detector-init.js index a2c3ddf63f..73b4897f92 100644 --- a/injected/src/detectors/detector-init.js +++ b/injected/src/detectors/detector-init.js @@ -93,9 +93,6 @@ export function initDetectors(bundledConfig) { ...bundledConfig?.features?.['web-interference-detection']?.settings?.interferenceTypes, }; - // Get global domains config (applies to all detectors unless overridden) - const globalDomains = bundledConfig?.features?.['web-interference-detection']?.settings?.domains; - // Track detectors to auto-run after registration const autoRunDetectors = []; @@ -103,7 +100,7 @@ export function initDetectors(bundledConfig) { const registerIfEnabled = (detectorId, detectorSettings, createDetectorFn) => { if (!detectorSettings) return; - const domains = detectorSettings.domains || globalDomains; + const domains = detectorSettings.domains; const autoRun = detectorSettings.autoRun !== false; // Default true const registration = createDetectorFn(detectorSettings); diff --git a/injected/src/detectors/detector-service.js b/injected/src/detectors/detector-service.js index bc7946b781..a551e98ec6 100644 --- a/injected/src/detectors/detector-service.js +++ b/injected/src/detectors/detector-service.js @@ -29,28 +29,6 @@ export function registerDetector(detectorId, registration) { registrations.set(detectorId, registration); } -/** - * Unregister a detector from the service - * @param {string} detectorId - Unique identifier for the detector - */ -export function unregisterDetector(detectorId) { - const registration = registrations.get(detectorId); - registration?.teardown?.(); - registrations.delete(detectorId); - cache.delete(detectorId); -} - -/** - * Reset all detectors and clear cache - * @param {string} [reason] - Optional reason for reset - */ -export function resetDetectors(reason = 'manual') { - for (const registration of registrations.values()) { - registration.teardown?.(reason); - } - cache.clear(); -} - /** * Get data from a specific detector * @param {string} detectorId - Unique identifier for the detector From cfe918c9602855015138f5f058f75ced1ccc332d Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Mon, 10 Nov 2025 16:11:28 -0700 Subject: [PATCH 09/27] update readme --- injected/src/detectors/README.md | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/injected/src/detectors/README.md b/injected/src/detectors/README.md index 9cceaed5cc..c99d7734b9 100644 --- a/injected/src/detectors/README.md +++ b/injected/src/detectors/README.md @@ -8,19 +8,14 @@ The current implementation focuses on synchronous, on-demand collection with cac ```mermaid sequenceDiagram - participant Init as detector-init + participant Feature as Content Feature participant Service as detectorService - participant Detector as BotDetector - participant Feature as Breakage Reporting - - Init->>Detector: createBotDetector(config) - Detector-->>Init: { getData, shouldRun } - Init->>Service: registerDetector('botDetection', registration) - Note over Service: Auto-run after 100ms delay - Service->>Detector: getData({ _autoRun: true }) - Detector-->>Service: snapshot (cached) + participant Detector as botDetection + Feature->>Service: getDetectorData('botDetection') - Service-->>Feature: snapshot (from cache) + Service->>Detector: getData() + Detector-->>Service: snapshot + Service-->>Feature: snapshot (cached) ``` ### Core helpers From 45b61154d800a350c5204a3c3c4e32c47037df8c Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Mon, 10 Nov 2025 16:37:23 -0700 Subject: [PATCH 10/27] remove old file --- injected/src/features.js | 1 - .../features/web-interference-detection.js | 56 ------------------- 2 files changed, 57 deletions(-) delete mode 100644 injected/src/features/web-interference-detection.js diff --git a/injected/src/features.js b/injected/src/features.js index 046a219a5e..f704269a41 100644 --- a/injected/src/features.js +++ b/injected/src/features.js @@ -32,7 +32,6 @@ const otherFeatures = /** @type {const} */ ([ 'favicon', 'webTelemetry', 'pageContext', - 'webInterferenceDetection', ]); /** @typedef {baseFeatures[number]|otherFeatures[number]} FeatureName */ diff --git a/injected/src/features/web-interference-detection.js b/injected/src/features/web-interference-detection.js deleted file mode 100644 index 5661d34a53..0000000000 --- a/injected/src/features/web-interference-detection.js +++ /dev/null @@ -1,56 +0,0 @@ -import ContentFeature from '../content-feature.js'; -import { registerDetector, getDetectorBatch, resetDetectors } from '../detectors/detector-service.js'; -import { DEFAULT_DETECTOR_SETTINGS } from '../detectors/default-config.js'; -import { createBotDetector } from '../detectors/detections/bot-detection.js'; -import { createFraudDetector } from '../detectors/detections/fraud-detection.js'; -import { createYouTubeAdsDetector } from '../detectors/detections/youtube-ads-detection.js'; - -export default class WebInterferenceDetection extends ContentFeature { - init() { - const featureEnabled = this.getFeatureSettingEnabled('state'); - if (!featureEnabled) { - return; - } - - const detectorSettings = { - ...DEFAULT_DETECTOR_SETTINGS, - ...this.getFeatureAttr('interferenceTypes', {}), - }; - - this._registerDefaults(detectorSettings); - - this.messaging.subscribe('detectInterference', async (params = {}) => { - try { - const detectorIds = normalizeTypes(params.types); - const results = await getDetectorBatch(detectorIds); - return this.messaging.notify('interferenceDetected', { results }); - } catch (error) { - console.error('[WebInterferenceDetection] Detection failed:', error); - return this.messaging.notify('interferenceDetectionError', { error: error.toString() }); - } - }); - } - - destroy() { - resetDetectors('feature-destroyed'); - } - - _registerDefaults(settings) { - if (settings.botDetection) { - registerDetector('botDetection', createBotDetector(settings.botDetection)); - } - if (settings.fraudDetection) { - registerDetector('fraudDetection', createFraudDetector(settings.fraudDetection)); - } - if (settings.youtubeAds) { - registerDetector('youtubeAds', createYouTubeAdsDetector(settings.youtubeAds)); - } - } -} - -function normalizeTypes(types) { - if (!Array.isArray(types) || types.length === 0) { - return ['botDetection', 'fraudDetection', 'youtubeAds']; - } - return types.filter((type) => typeof type === 'string'); -} From 6f45c6e736a5a73b166817e8821b8523278f5c4e Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Wed, 12 Nov 2025 08:34:08 -0700 Subject: [PATCH 11/27] pr review changes, drop yt detector --- injected/src/content-scope-features.js | 7 +- injected/src/detectors/README.md | 10 ++- injected/src/detectors/default-config.js | 5 -- .../detectors/detections/detection-base.js | 71 ------------------- .../detections/youtube-ads-detection.js | 62 ---------------- injected/src/detectors/detector-init.js | 6 +- injected/src/detectors/detector-service.js | 8 ++- injected/src/features/breakage-reporting.js | 7 +- 8 files changed, 20 insertions(+), 156 deletions(-) delete mode 100644 injected/src/detectors/detections/detection-base.js delete mode 100644 injected/src/detectors/detections/youtube-ads-detection.js diff --git a/injected/src/content-scope-features.js b/injected/src/content-scope-features.js index 6cb73beb1b..f27e01262a 100644 --- a/injected/src/content-scope-features.js +++ b/injected/src/content-scope-features.js @@ -46,7 +46,12 @@ export function load(args) { const bundledFeatureNames = typeof importConfig.injectName === 'string' ? platformSupport[importConfig.injectName] : []; // Initialize detectors early so they're available when features init - initDetectors(args.bundledConfig); + try { + initDetectors(args.bundledConfig); + } catch (error) { + console.error('[detectors] Initialization failed:', error); + // TODO: Consider firing error pixel if needed + } // prettier-ignore const featuresToLoad = isGloballyDisabled(args) diff --git a/injected/src/detectors/README.md b/injected/src/detectors/README.md index c99d7734b9..b4745fff0e 100644 --- a/injected/src/detectors/README.md +++ b/injected/src/detectors/README.md @@ -22,7 +22,7 @@ sequenceDiagram - `registerDetector(detectorId, { getData, shouldRun?, refresh?, teardown? })` - `getDetectorData(detectorId, { maxAgeMs }?)` -- `getDetectorBatch(detectorIds, options?)` +- `getDetectorsData(detectorIds, options?)` Detectors return arbitrary JSON payloads. Include timestamps if consumers rely on freshness. @@ -35,9 +35,7 @@ detectors/ ├── default-config.js # default detector settings ├── detections/ │ ├── bot-detection.js # CAPTCHA/bot detection -│ ├── fraud-detection.js # anti-fraud/phishing warnings -│ ├── youtube-ads-detection.js # YouTube ad detection -│ └── detection-base.js # optional base for observer-style detectors +│ └── fraud-detection.js # anti-fraud/phishing warnings └── utils/ └── detection-utils.js # DOM helpers (selectors, text matching, visibility, domain matching) ``` @@ -153,10 +151,10 @@ Example: Features can directly import and use the detector service: ```javascript -import { getDetectorBatch } from '../detectors/detector-service.js'; +import { getDetectorsData } from '../detectors/detector-service.js'; // In breakage reporting feature - gates bypassed automatically for manual calls -const detectorData = await getDetectorBatch(['botDetection', 'fraudDetection']); +const detectorData = await getDetectorsData(['botDetection', 'fraudDetection']); // Returns: { botDetection: {...}, fraudDetection: {...} } ``` diff --git a/injected/src/detectors/default-config.js b/injected/src/detectors/default-config.js index 8bae5a8a9c..e569ce19e7 100644 --- a/injected/src/detectors/default-config.js +++ b/injected/src/detectors/default-config.js @@ -50,9 +50,4 @@ export const DEFAULT_DETECTOR_SETTINGS = Object.freeze({ textSources: ['innerText'], }, }, - youtubeAds: { - rootSelector: '#movie_player', - selectors: ['.ytp-ad-text', '.ytp-ad-skip-button', '.ytp-ad-preview-text'], - adClasses: ['ad-showing', 'ad-interrupting'], - }, }); diff --git a/injected/src/detectors/detections/detection-base.js b/injected/src/detectors/detections/detection-base.js deleted file mode 100644 index ee1f28c16f..0000000000 --- a/injected/src/detectors/detections/detection-base.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * PROTOTYPE: Base class for complex detections with continuous monitoring - * TODO: Add mutation observer, re-rooting, callback timers, debouncing - */ -export class DetectionBase { - /** - * @param {object} config - * @param {(result: any) => void=} onInterferenceChange - */ - constructor(config, onInterferenceChange = null) { - this.config = config; - this.onInterferenceChange = onInterferenceChange; - this.isRunning = false; - this.root = null; - this.pollTimer = null; - this.retryTimer = null; - - if (this.onInterferenceChange) { - this.start(); - } - } - - start() { - if (this.isRunning) { - return; - } - this.isRunning = true; - - this.root = this.findRoot(); - if (!this.root) { - this.retryTimer = setTimeout(() => this.start(), 500); - return; - } - - if (this.config.pollInterval) { - this.pollTimer = setInterval(() => this.checkForInterference(), this.config.pollInterval); - } - - this.checkForInterference(); - } - - stop() { - if (!this.isRunning) { - return; - } - this.isRunning = false; - - if (this.pollTimer) { - clearInterval(this.pollTimer); - this.pollTimer = null; - } - - if (this.retryTimer) { - clearTimeout(this.retryTimer); - this.retryTimer = null; - } - } - - detect() { - throw new Error('detect() must be implemented by subclass'); - } - - /** - * @returns {Element|null} - */ - findRoot() { - return document.body; - } - - checkForInterference() {} -} diff --git a/injected/src/detectors/detections/youtube-ads-detection.js b/injected/src/detectors/detections/youtube-ads-detection.js deleted file mode 100644 index 7c8a6f89f9..0000000000 --- a/injected/src/detectors/detections/youtube-ads-detection.js +++ /dev/null @@ -1,62 +0,0 @@ -import { isVisible, queryAllSelectors } from '../utils/detection-utils.js'; -const DEFAULT_CONFIG = { - rootSelector: '#movie_player', - adClasses: ['ad-showing', 'ad-interrupting'], - selectors: ['.ytp-ad-text', '.ytp-ad-skip-button', '.ytp-ad-preview-text'], -}; - -export function createYouTubeAdsDetector(config = {}) { - const mergedConfig = { ...DEFAULT_CONFIG, ...config }; - return { - /** - * Optional gate function - return false to skip detection entirely - * This runs before getData() and can be used for lightweight precondition checks - */ - shouldRun() { - // Only run if the YouTube player root element exists - // This avoids unnecessary DOM scanning on non-video pages - return document.querySelector(mergedConfig.rootSelector) !== null; - }, - - async getData() { - return runYouTubeAdsDetection(mergedConfig); - }, - }; -} - -export function runYouTubeAdsDetection(config = DEFAULT_CONFIG) { - const root = document.querySelector(config.rootSelector); - if (!root) { - return emptyResult(); - } - - const hasAdClass = config.adClasses.some((cls) => root.classList.contains(cls)); - const adElements = queryAllSelectors(config.selectors, root); - const hasVisibleAdElement = adElements.some((el) => isVisible(el)); - - const detected = hasAdClass || hasVisibleAdElement; - - return detected - ? { - detected: true, - type: 'youtubeAds', - results: [ - { - adCurrentlyPlaying: true, - adType: 'video-ad', - source: 'snapshot', - }, - ], - timestamp: Date.now(), - } - : emptyResult(); -} - -function emptyResult() { - return { - detected: false, - type: 'youtubeAds', - results: [], - timestamp: Date.now(), - }; -} diff --git a/injected/src/detectors/detector-init.js b/injected/src/detectors/detector-init.js index 73b4897f92..709673e12b 100644 --- a/injected/src/detectors/detector-init.js +++ b/injected/src/detectors/detector-init.js @@ -9,7 +9,6 @@ import { registerDetector } from './detector-service.js'; import { DEFAULT_DETECTOR_SETTINGS } from './default-config.js'; import { createBotDetector } from './detections/bot-detection.js'; import { createFraudDetector } from './detections/fraud-detection.js'; -import { createYouTubeAdsDetector } from './detections/youtube-ads-detection.js'; import { matchesDomainPatterns } from './utils/detection-utils.js'; /** @@ -114,7 +113,6 @@ export function initDetectors(bundledConfig) { // Register each detector if its settings exist registerIfEnabled('botDetection', detectorSettings.botDetection, createBotDetector); registerIfEnabled('fraudDetection', detectorSettings.fraudDetection, createFraudDetector); - registerIfEnabled('youtubeAds', detectorSettings.youtubeAds, createYouTubeAdsDetector); // Auto-run detectors after a short delay to let page settle if (autoRunDetectors.length > 0) { @@ -123,10 +121,10 @@ export function initDetectors(bundledConfig) { // Use setTimeout to avoid blocking page load setTimeout(async () => { - const { getDetectorBatch } = await import('./detector-service.js'); + const { getDetectorsData } = await import('./detector-service.js'); // Run all auto-run detectors with _autoRun flag (gates will be checked) - await getDetectorBatch(autoRunDetectors, { _autoRun: true }); + await getDetectorsData(autoRunDetectors, { _autoRun: true }); console.log('[detectors] Auto-run complete for:', autoRunDetectors); }, autoRunDelayMs); diff --git a/injected/src/detectors/detector-service.js b/injected/src/detectors/detector-service.js index a551e98ec6..19ce471666 100644 --- a/injected/src/detectors/detector-service.js +++ b/injected/src/detectors/detector-service.js @@ -39,7 +39,9 @@ export function registerDetector(detectorId, registration) { */ export async function getDetectorData(detectorId, options = {}) { const { maxAgeMs } = options; - const cached = /** @type {CachedSnapshot | undefined} */ (cache.get(detectorId)); + // Include URL in cache key to handle SPA navigation (e.g., YouTube) + const cacheKey = `${detectorId}:${location.href}`; + const cached = /** @type {CachedSnapshot | undefined} */ (cache.get(cacheKey)); if (cached) { const age = Date.now() - cached.ts; @@ -57,7 +59,7 @@ export async function getDetectorData(detectorId, options = {}) { try { // Pass options to the runner so gates can check _autoRun flag const data = await runner(options); - cache.set(detectorId, { data, ts: Date.now() }); + cache.set(cacheKey, { data, ts: Date.now() }); return data; } catch (error) { console.error(`[detectorService] Failed to fetch data for ${detectorId}`, error); @@ -73,7 +75,7 @@ export async function getDetectorData(detectorId, options = {}) { * @param {boolean} [options._autoRun] - Internal flag indicating auto-run (gates apply) * @returns {Promise>} Object mapping detector IDs to their data */ -export async function getDetectorBatch(detectorIds, options = {}) { +export async function getDetectorsData(detectorIds, options = {}) { const results = {}; for (const detectorId of detectorIds) { results[detectorId] = await getDetectorData(detectorId, options); diff --git a/injected/src/features/breakage-reporting.js b/injected/src/features/breakage-reporting.js index 40ebf13c3a..e350185bd3 100644 --- a/injected/src/features/breakage-reporting.js +++ b/injected/src/features/breakage-reporting.js @@ -1,6 +1,6 @@ import ContentFeature from '../content-feature'; import { getExpandedPerformanceMetrics, getJsPerformanceMetrics } from './breakage-reporting/utils.js'; -import { getDetectorBatch } from '../detectors/detector-service.js'; +import { getDetectorsData } from '../detectors/detector-service.js'; export default class BreakageReporting extends ContentFeature { init() { @@ -10,10 +10,9 @@ export default class BreakageReporting extends ContentFeature { const referrer = document.referrer; // Collect detector data (gates bypassed by default for manual calls) - const detectorData = await getDetectorBatch([ + const detectorData = await getDetectorsData([ 'botDetection', - 'fraudDetection', - 'youtubeAds' + 'fraudDetection' ]); const result = { From 443628af721d0ef583b43f269e103575c45d8d4d Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Wed, 12 Nov 2025 08:44:15 -0700 Subject: [PATCH 12/27] fix type erros --- injected/src/detectors/detections/bot-detection.js | 2 +- injected/src/detectors/detections/fraud-detection.js | 2 +- injected/src/detectors/detector-init.js | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/injected/src/detectors/detections/bot-detection.js b/injected/src/detectors/detections/bot-detection.js index ca57510df8..9eeee4ada5 100644 --- a/injected/src/detectors/detections/bot-detection.js +++ b/injected/src/detectors/detections/bot-detection.js @@ -6,7 +6,7 @@ import { checkSelectors, checkWindowProperties, matchesSelectors, matchesTextPat */ export function createBotDetector(config = {}) { return { - async getData() { + getData() { return runBotDetection(config); }, }; diff --git a/injected/src/detectors/detections/fraud-detection.js b/injected/src/detectors/detections/fraud-detection.js index 0754bf0a38..92dbe74b3e 100644 --- a/injected/src/detectors/detections/fraud-detection.js +++ b/injected/src/detectors/detections/fraud-detection.js @@ -2,7 +2,7 @@ import { checkSelectorsWithVisibility, checkTextPatterns } from '../utils/detect export function createFraudDetector(config = {}) { return { - async getData() { + getData() { return runFraudDetection(config); }, }; diff --git a/injected/src/detectors/detector-init.js b/injected/src/detectors/detector-init.js index 709673e12b..9d83955b29 100644 --- a/injected/src/detectors/detector-init.js +++ b/injected/src/detectors/detector-init.js @@ -63,13 +63,13 @@ function createGatedDetector(registration, domains) { } // All gates passed, run the detector - return getData(); + return await getData(options); }, refresh: refresh ? async (options) => { if (!checkGates(options, domains, shouldRun)) { return null; } - return refresh(); + return await refresh(options); } : undefined, teardown, }; From 13eea370eebdab349bcc69481a939342c17f8bf6 Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Wed, 12 Nov 2025 13:46:19 -0700 Subject: [PATCH 13/27] remove unused' --- injected/src/error-utils.js | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 injected/src/error-utils.js diff --git a/injected/src/error-utils.js b/injected/src/error-utils.js deleted file mode 100644 index 4ee3d300b5..0000000000 --- a/injected/src/error-utils.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * @template T - * @param {function(): T} fn - The function to call safely - * @param {object} [options] - * @param {string} [options.errorMessage] - The error message to log - * @returns {T|null} - The result of the function call, or null if an error occurred - */ -export function safeCall(fn, { errorMessage } = {}) { - try { - return fn(); - } catch (e) { - console.error(errorMessage ?? '[safeCall] Error:', e); - // TODO fire pixel - return null; - } -} From 9f1aa4a9b24c384dc99c96756f19b526ea79b29a Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Mon, 17 Nov 2025 15:49:28 -0700 Subject: [PATCH 14/27] remove service and init" --- injected/src/content-scope-features.js | 9 - injected/src/detectors/README.md | 266 +++++++----------- .../src/detectors/detections/bot-detection.js | 25 +- .../detectors/detections/fraud-detection.js | 23 +- injected/src/detectors/detector-init.js | 133 --------- injected/src/detectors/detector-service.js | 84 ------ .../src/detectors/utils/detection-utils.js | 36 --- injected/src/features.js | 6 +- injected/src/features/breakage-reporting.js | 13 +- .../features/web-interference-detection.js | 34 +++ 10 files changed, 172 insertions(+), 457 deletions(-) delete mode 100644 injected/src/detectors/detector-init.js delete mode 100644 injected/src/detectors/detector-service.js create mode 100644 injected/src/features/web-interference-detection.js diff --git a/injected/src/content-scope-features.js b/injected/src/content-scope-features.js index f27e01262a..2b062f6c23 100644 --- a/injected/src/content-scope-features.js +++ b/injected/src/content-scope-features.js @@ -3,7 +3,6 @@ import { platformSupport } from './features'; import { PerformanceMonitor } from './performance'; import platformFeatures from 'ddg:platformFeatures'; import { registerForURLChanges } from './url-change'; -import { initDetectors } from './detectors/detector-init.js'; let initArgs = null; const updates = []; @@ -45,14 +44,6 @@ export function load(args) { const bundledFeatureNames = typeof importConfig.injectName === 'string' ? platformSupport[importConfig.injectName] : []; - // Initialize detectors early so they're available when features init - try { - initDetectors(args.bundledConfig); - } catch (error) { - console.error('[detectors] Initialization failed:', error); - // TODO: Consider firing error pixel if needed - } - // prettier-ignore const featuresToLoad = isGloballyDisabled(args) // if we're globally disabled, only allow `platformSpecificFeatures` diff --git a/injected/src/detectors/README.md b/injected/src/detectors/README.md index b4745fff0e..d6754af3a6 100644 --- a/injected/src/detectors/README.md +++ b/injected/src/detectors/README.md @@ -1,63 +1,42 @@ -# Detector Service +# Web Interference Detection -This directory contains a lightweight detector service that runs inside content-scope-scripts. Detectors are automatically registered during the `load()` phase and any feature can query their latest results (breakage reporting, native PIR, debug tooling, etc.). +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. -The current implementation focuses on synchronous, on-demand collection with caching. Continuous monitoring (mutation observers, polling, batching) can be layered on later without changing the public API. +## Architecture -## API Snapshot +The system uses a **ContentFeature** wrapper with simple detection utilities: -```mermaid -sequenceDiagram - participant Feature as Content Feature - participant Service as detectorService - participant Detector as botDetection +- **`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 - Feature->>Service: getDetectorData('botDetection') - Service->>Detector: getData() - Detector-->>Service: snapshot - Service-->>Feature: snapshot (cached) -``` - -### Core helpers - -- `registerDetector(detectorId, { getData, shouldRun?, refresh?, teardown? })` -- `getDetectorData(detectorId, { maxAgeMs }?)` -- `getDetectorsData(detectorIds, options?)` - -Detectors return arbitrary JSON payloads. Include timestamps if consumers rely on freshness. ## Directory Layout ``` detectors/ -├── detector-service.js # registry + caching service -├── detector-init.js # initializes detectors from bundledConfig -├── default-config.js # default detector settings ├── detections/ -│ ├── bot-detection.js # CAPTCHA/bot detection -│ └── fraud-detection.js # anti-fraud/phishing warnings -└── utils/ - └── detection-utils.js # DOM helpers (selectors, text matching, visibility, domain matching) +│ ├── 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 -### Initialization +### 1. Initialization -Detectors are automatically registered during the content-scope-features `load()` phase: +The `WebInterferenceDetection` ContentFeature runs detectors automatically: -1. `content-scope-features.js` calls `initDetectors(bundledConfig)` during page load -2. `detector-init.js` reads the `web-interference-detection` feature config -3. Default detector settings are merged with remote config -4. Detectors are registered with the service using `registerDetector()` -5. After `autoRunDelayMs` delay (default 100ms), detectors with `autoRun: true` execute automatically - - This delay lets the DOM settle after initial page load - - Auto-run calls check gates (domain + `shouldRun()`) - - Results are cached for later manual calls +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 -### Remote Configuration +### 2. Configuration -Detectors are controlled via `privacy-configuration/features/web-interference-detection.json`: +Detectors are configured via `privacy-configuration/features/web-interference-detection.json`: ```json { @@ -85,168 +64,127 @@ Detectors are controlled via `privacy-configuration/features/web-interference-de } ``` -#### Domain Gating - -Detectors can be restricted to specific domains using a per-detector `domains` field: - -- **Domain patterns**: - - Exact match: `"youtube.com"` - - Wildcard: `"*.youtube.com"` (matches www.youtube.com, m.youtube.com, etc.) - - Substring: `"youtube.com"` also matches `www.youtube.com` for convenience +**Domain-specific configuration** using `conditionalChanges`: -Example: ```json { "settings": { - "interferenceTypes": { - "fraudDetection": { - "domains": ["*.bank.com", "*.financial.com"], - ... + "conditionalChanges": [ + { + "condition": { + "urlPattern": "https://*.example.com/*" + }, + "patchSettings": [ + { + "op": "add", + "path": "/interferenceTypes/customDetector", + "value": { "state": "enabled", "selectors": [".custom"] } + } + ] } - } + ] } } ``` -#### Auto-Run - -Detectors can be configured to run automatically on page load: +The framework automatically applies conditional changes based on the current URL before passing settings to the feature. -- **`autoRun: true`** (default): Run detector automatically after page load - - Gates are checked (domain + custom `shouldRun()`) - - Results are cached immediately - - Runs after configurable delay (see `autoRunDelayMs`) - - Useful for detectors that should always gather data (bot detection, fraud detection) +### 3. Using Detection Results -- **`autoRun: false`**: Only run when explicitly called - - Gates are skipped for manual calls - - Useful for expensive detectors or event-driven scenarios +**Internal features** (same content script context): -- **`autoRunDelayMs`** (global setting, default: 100): Milliseconds to wait before running auto-run detectors - - Allows DOM to settle after page load - - Can be tuned per-site or globally - - Use 0 for immediate execution, higher values for slower-loading pages - - **How it works**: After detectors are registered, a single `setTimeout` schedules all auto-run detectors to execute in batch after the delay +```javascript +import { runBotDetection, runFraudDetection } from '../detectors/detections/bot-detection.js'; -Example: -```json -"settings": { - "autoRunDelayMs": 250, // Wait 250ms before auto-running detectors - "interferenceTypes": { - "botDetection": { - "autoRun": true, // Run automatically with gates - "domains": ["*.example.com"], - ... - }, - "expensiveDetector": { - "autoRun": false, // Only run on-demand, skip gates - ... - } - } -} +// Get cached results from auto-run +const botData = runBotDetection(); +const fraudData = runFraudDetection(); ``` -### Consuming Detector Data - -Features can directly import and use the detector service: +**External:** ```javascript -import { getDetectorsData } from '../detectors/detector-service.js'; - -// In breakage reporting feature - gates bypassed automatically for manual calls -const detectorData = await getDetectorsData(['botDetection', 'fraudDetection']); -// Returns: { botDetection: {...}, fraudDetection: {...} } +// Via messaging +this.messaging.request('detectInterference', { + types: ['botDetection', 'fraudDetection'] +}); ``` -**Behavior:** -- **Manual calls** (like above): Gates are bypassed, detector always runs -- **Auto-run calls**: Gates are checked (domain + `shouldRun()`) -- **Caching**: Results cached with timestamp, use `maxAgeMs` to force refresh - -**Options:** -- `maxAgeMs`: Maximum age of cached data in milliseconds before forcing refresh - ## Adding New Detectors -1. **Create detection logic** under `detections/`: - - Export a `createXDetector(config)` factory function - - Return an object with `{ getData, shouldRun?, refresh?, teardown? }` - - Use shared utilities from `utils/detection-utils.js` +1. **Create detection utility** in `detections/`: -2. **Add default config** to `default-config.js`: - - Define default selectors, patterns, and settings - - These serve as fallback if remote config is unavailable +```javascript +// detections/my-detector.js +let cachedResult = null; -3. **Register in detector-init.js**: - - Import your detector factory - - Add one line: `registerIfEnabled('myDetector', detectorSettings.myDetector, createMyDetector)` +export function runMyDetection(config = {}, options = {}) { + if (cachedResult && !options.refresh) return cachedResult; -4. **Add remote config** to `privacy-configuration/features/web-interference-detection.json`: - - Define the detector's configuration schema - - Optionally add `domains` field for domain gating - - This allows remote enabling/disabling and tuning + // Run detection logic + const detected = checkSelectors(config.selectors); -5. **Consume the detector** in your feature: - - Import `getDetectorData` or `getDetectorBatch` - - Call with your detector ID to get results + cachedResult = { + detected, + type: 'myDetector', + timestamp: Date.now(), + }; -### Custom Gate Functions + return cachedResult; +} +``` -Detectors can optionally implement a `shouldRun()` gate function for custom precondition checks: +2. **Add to WebInterferenceDetection feature**: ```javascript -export function createMyDetector(config) { - return { - // Optional gate function runs before getData() - // Return false to skip detection entirely (returns null) - shouldRun() { - // Example: Only run if specific element exists - return document.querySelector('#app-root') !== null; - }, +// features/web-interference-detection.js +import { runMyDetection } from '../detectors/detections/my-detector.js'; + +init(args) { + const settings = this.getFeatureSetting('interferenceTypes'); - async getData() { - // This only runs if shouldRun() returns true - // and domain gate passes - return { detected: true, ... }; + setTimeout(() => { + if (settings?.myDetector) { + runMyDetection(settings.myDetector); } - }; + }, autoRunDelayMs); } ``` -**Example: Detector that depends on another detector's results** +3. **Add config** to `web-interference-detection.json`: -```javascript -import { getDetectorData } from '../detector-service.js'; - -export function createAdvancedBotDetector(config) { - return { - // Only run advanced detection if basic bot detection found something - async shouldRun() { - const basicBotData = await getDetectorData('botDetection'); - // Only run if basic detector found a bot/CAPTCHA - return basicBotData?.detected === true; - }, - - async getData() { - // Run expensive/detailed analysis only when needed - return runAdvancedBotAnalysis(config); - } - }; +```json +{ + "settings": { + "interferenceTypes": { + "myDetector": { + "state": "enabled", + "selectors": [".my-selector"] + } + } + } } ``` -**When to use `shouldRun()`:** -- Lightweight DOM precondition checks (e.g., element exists) -- Dependency on another detector's results (use `getDetectorData()` inside `shouldRun()`) -- Runtime feature detection -- Performance optimization to avoid expensive operations +## Caching Strategy -**Gate execution order:** -1. Domain gate (from config) -2. Custom `shouldRun()` gate (if provided) -3. `getData()` (if all gates pass) +- **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 }` -If any gate fails, `getDetectorData()` returns `null`. +**Examples:** +```javascript +// Get cached result (fast) +const data = runBotDetection(config); -Future enhancements—shared observers, background aggregation, streaming updates—can build on this service without breaking the public API. +// 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/detections/bot-detection.js b/injected/src/detectors/detections/bot-detection.js index 9eeee4ada5..c078cb8bd1 100644 --- a/injected/src/detectors/detections/bot-detection.js +++ b/injected/src/detectors/detections/bot-detection.js @@ -1,22 +1,16 @@ import { checkSelectors, checkWindowProperties, matchesSelectors, matchesTextPatterns } from '../utils/detection-utils.js'; -/** - * Create a detector registration for CAPTCHA/bot detection. - * @param {Record} config - */ -export function createBotDetector(config = {}) { - return { - getData() { - return runBotDetection(config); - }, - }; -} +// Cache result to avoid redundant DOM scans +let cachedResult = null; /** - * Run detection immediately and return structured results. + * 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 = {}) { +export function runBotDetection(config = {}, options = {}) { + if (cachedResult && !options.refresh) return cachedResult; const results = Object.entries(config) .filter(([_, challengeConfig]) => challengeConfig?.state === 'enabled') .map(([challengeId, challengeConfig]) => { @@ -35,12 +29,15 @@ export function runBotDetection(config = {}) { }) .filter(Boolean); - return { + // Cache and return + cachedResult = { detected: results.length > 0, type: 'botDetection', results, timestamp: Date.now(), }; + + return cachedResult; } function findStatus(statusSelectors) { diff --git a/injected/src/detectors/detections/fraud-detection.js b/injected/src/detectors/detections/fraud-detection.js index 92dbe74b3e..85d68f40aa 100644 --- a/injected/src/detectors/detections/fraud-detection.js +++ b/injected/src/detectors/detections/fraud-detection.js @@ -1,14 +1,16 @@ import { checkSelectorsWithVisibility, checkTextPatterns } from '../utils/detection-utils.js'; -export function createFraudDetector(config = {}) { - return { - getData() { - return runFraudDetection(config); - }, - }; -} +// Cache result to avoid redundant DOM scans +let cachedResult = null; -export function runFraudDetection(config = {}) { +/** + * 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]) => { @@ -27,10 +29,13 @@ export function runFraudDetection(config = {}) { }) .filter(Boolean); - return { + // Cache and return + cachedResult = { detected: results.length > 0, type: 'fraudDetection', results, timestamp: Date.now(), }; + + return cachedResult; } diff --git a/injected/src/detectors/detector-init.js b/injected/src/detectors/detector-init.js deleted file mode 100644 index 9d83955b29..0000000000 --- a/injected/src/detectors/detector-init.js +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Detector Initialization - * - * Reads bundledConfig and registers detectors with the detector service. - * Called during content-scope-features load phase. - */ - -import { registerDetector } from './detector-service.js'; -import { DEFAULT_DETECTOR_SETTINGS } from './default-config.js'; -import { createBotDetector } from './detections/bot-detection.js'; -import { createFraudDetector } from './detections/fraud-detection.js'; -import { matchesDomainPatterns } from './utils/detection-utils.js'; - -/** - * Check if gates should run for this call - * @param {Object} options - Options passed to getData - * @param {boolean} [options._autoRun] - Internal flag indicating this is an auto-run call - * @param {string[]} [domains] - Domain patterns to check - * @param {Function} [shouldRun] - Custom gate function - * @returns {boolean} True if gates pass, false otherwise - */ -function checkGates(options, domains, shouldRun) { - // Only check gates for auto-run calls - // Manual calls bypass gates by default - if (!options?._autoRun) { - return true; - } - - // Check domain gate - if (!matchesDomainPatterns(domains)) { - return false; - } - - // Check custom gate function if provided - if (shouldRun && !shouldRun()) { - return false; - } - - return true; -} - -/** - * Create a gated detector registration that checks domain and custom gates - * Gates only apply for auto-run calls (when _autoRun: true is passed) - * Manual calls bypass gates by default - * - * @param {Object} registration - The detector registration object - * @param {Function} registration.getData - Function to get detector data - * @param {Function} [registration.shouldRun] - Optional gate function - * @param {Function} [registration.refresh] - Optional refresh function - * @param {Function} [registration.teardown] - Optional teardown function - * @param {string[]} [domains] - Optional array of domain patterns - * @returns {Object} Gated detector registration - */ -function createGatedDetector(registration, domains) { - const { getData, shouldRun, refresh, teardown } = registration; - - return { - getData: async (options) => { - // Check gates (only for auto-run, manual calls bypass) - if (!checkGates(options, domains, shouldRun)) { - return null; - } - - // All gates passed, run the detector - return await getData(options); - }, - refresh: refresh ? async (options) => { - if (!checkGates(options, domains, shouldRun)) { - return null; - } - return await refresh(options); - } : undefined, - teardown, - }; -} - -/** - * Initialize detectors based on bundled configuration - * @param {any} bundledConfig - The bundled configuration object - */ -export function initDetectors(bundledConfig) { - // Check if web-interference-detection feature is enabled - const enabled = bundledConfig?.features?.['web-interference-detection']?.state === 'enabled'; - if (!enabled) { - return; - } - - // Merge default settings with remote config - const detectorSettings = { - ...DEFAULT_DETECTOR_SETTINGS, - ...bundledConfig?.features?.['web-interference-detection']?.settings?.interferenceTypes, - }; - - // Track detectors to auto-run after registration - const autoRunDetectors = []; - - // Helper to register a detector with less repetition - const registerIfEnabled = (detectorId, detectorSettings, createDetectorFn) => { - if (!detectorSettings) return; - - const domains = detectorSettings.domains; - const autoRun = detectorSettings.autoRun !== false; // Default true - const registration = createDetectorFn(detectorSettings); - - registerDetector(detectorId, createGatedDetector(registration, domains)); - - if (autoRun) { - autoRunDetectors.push(detectorId); - } - }; - - // Register each detector if its settings exist - registerIfEnabled('botDetection', detectorSettings.botDetection, createBotDetector); - registerIfEnabled('fraudDetection', detectorSettings.fraudDetection, createFraudDetector); - - // Auto-run detectors after a short delay to let page settle - if (autoRunDetectors.length > 0) { - // Get delay from config, default to 100ms - const autoRunDelayMs = bundledConfig?.features?.['web-interference-detection']?.settings?.autoRunDelayMs ?? 100; - - // Use setTimeout to avoid blocking page load - setTimeout(async () => { - const { getDetectorsData } = await import('./detector-service.js'); - - // Run all auto-run detectors with _autoRun flag (gates will be checked) - await getDetectorsData(autoRunDetectors, { _autoRun: true }); - - console.log('[detectors] Auto-run complete for:', autoRunDetectors); - }, autoRunDelayMs); - } -} - diff --git a/injected/src/detectors/detector-service.js b/injected/src/detectors/detector-service.js deleted file mode 100644 index 19ce471666..0000000000 --- a/injected/src/detectors/detector-service.js +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Detector Service - * - * Central registry and caching layer for interference detectors. - * Provides a simple API for registering detectors and retrieving their data. - */ - -const registrations = new Map(); -const cache = new Map(); - -/** - * @typedef {Object} DetectorRegistration - * @property {() => Promise} getData - Function to get current detector data - * @property {() => Promise} [refresh] - Optional function to refresh/re-run detection - */ - -/** - * @typedef {Object} CachedSnapshot - * @property {any} data - The cached detector data - * @property {number} ts - Timestamp when data was cached - */ - -/** - * Register a detector with the service - * @param {string} detectorId - Unique identifier for the detector - * @param {DetectorRegistration} registration - Detector registration object - */ -export function registerDetector(detectorId, registration) { - registrations.set(detectorId, registration); -} - -/** - * Get data from a specific detector - * @param {string} detectorId - Unique identifier for the detector - * @param {Object} [options] - Options for data retrieval - * @param {number} [options.maxAgeMs] - Maximum age of cached data in milliseconds - * @param {boolean} [options._autoRun] - Internal flag indicating auto-run (gates apply) - * @returns {Promise} Detector data or null if not registered - */ -export async function getDetectorData(detectorId, options = {}) { - const { maxAgeMs } = options; - // Include URL in cache key to handle SPA navigation (e.g., YouTube) - const cacheKey = `${detectorId}:${location.href}`; - const cached = /** @type {CachedSnapshot | undefined} */ (cache.get(cacheKey)); - - if (cached) { - const age = Date.now() - cached.ts; - if (!maxAgeMs || age <= maxAgeMs) { - return cached.data; - } - } - - const registration = registrations.get(detectorId); - if (!registration) { - return null; - } - - const runner = registration.refresh ?? registration.getData; - try { - // Pass options to the runner so gates can check _autoRun flag - const data = await runner(options); - cache.set(cacheKey, { data, ts: Date.now() }); - return data; - } catch (error) { - console.error(`[detectorService] Failed to fetch data for ${detectorId}`, error); - return null; - } -} - -/** - * Get data from multiple detectors in a single call - * @param {string[]} detectorIds - Array of detector IDs - * @param {Object} [options] - Options for data retrieval - * @param {number} [options.maxAgeMs] - Maximum age of cached data in milliseconds - * @param {boolean} [options._autoRun] - Internal flag indicating auto-run (gates apply) - * @returns {Promise>} Object mapping detector IDs to their data - */ -export async function getDetectorsData(detectorIds, options = {}) { - const results = {}; - for (const detectorId of detectorIds) { - results[detectorId] = await getDetectorData(detectorId, options); - } - return results; -} diff --git a/injected/src/detectors/utils/detection-utils.js b/injected/src/detectors/utils/detection-utils.js index 7c63d2ce89..7419596b7d 100644 --- a/injected/src/detectors/utils/detection-utils.js +++ b/injected/src/detectors/utils/detection-utils.js @@ -115,39 +115,3 @@ export function queryAllSelectors(selectors, root = document) { const elements = root.querySelectorAll(selectors.join(',')); return Array.from(elements); } - -/** - * Check if current domain matches any patterns in the domains list - * Supports exact matches, wildcards (*.domain.com), and substring matching - * - * @param {string[]} [domains] - Array of domain patterns - * @returns {boolean} True if current domain matches any pattern, or if no patterns provided - * - * @example - * matchesDomainPatterns(['youtube.com']) // Exact or substring match - * matchesDomainPatterns(['*.youtube.com']) // Wildcard match (www.youtube.com, m.youtube.com) - * matchesDomainPatterns([]) // Empty = match all domains - */ -export function matchesDomainPatterns(domains) { - if (!domains || !Array.isArray(domains) || domains.length === 0) { - return true; // No domain restrictions means match all - } - - const hostname = window.location.hostname; - - return domains.some((pattern) => { - // Exact match - if (pattern === hostname) { - return true; - } - - // Wildcard pattern (e.g., "*.youtube.com" or "youtube.*") - if (pattern.includes('*')) { - const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$'); - return regex.test(hostname); - } - - // Substring match for convenience (e.g., "youtube.com" matches "www.youtube.com") - return hostname.includes(pattern); - }); -} 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 e350185bd3..8ae8c6e41f 100644 --- a/injected/src/features/breakage-reporting.js +++ b/injected/src/features/breakage-reporting.js @@ -1,6 +1,7 @@ import ContentFeature from '../content-feature'; import { getExpandedPerformanceMetrics, getJsPerformanceMetrics } from './breakage-reporting/utils.js'; -import { getDetectorsData } from '../detectors/detector-service.js'; +import { runBotDetection } from '../detectors/detections/bot-detection.js'; +import { runFraudDetection } from '../detectors/detections/fraud-detection.js'; export default class BreakageReporting extends ContentFeature { init() { @@ -9,11 +10,11 @@ export default class BreakageReporting extends ContentFeature { const jsPerformance = getJsPerformanceMetrics(); const referrer = document.referrer; - // Collect detector data (gates bypassed by default for manual calls) - const detectorData = await getDetectorsData([ - 'botDetection', - 'fraudDetection' - ]); + // Call detection functions directly (get cached results) + const detectorData = { + botDetection: runBotDetection(), + fraudDetection: runFraudDetection(), + }; const result = { jsPerformance, diff --git a/injected/src/features/web-interference-detection.js b/injected/src/features/web-interference-detection.js new file mode 100644 index 0000000000..d2dfafb277 --- /dev/null +++ b/injected/src/features/web-interference-detection.js @@ -0,0 +1,34 @@ +import ContentFeature from '../content-feature.js'; +import { runBotDetection } from '../detectors/detections/bot-detection.js'; +import { runFraudDetection } from '../detectors/detections/fraud-detection.js'; + +export default class WebInterferenceDetection extends ContentFeature { + init(args) { + // 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 results = {}; + if (params.types?.includes('botDetection')) { + results.botDetection = runBotDetection(settings?.botDetection, { refresh: params.refresh }); + } + if (params.types?.includes('fraudDetection')) { + results.fraudDetection = runFraudDetection(settings?.fraudDetection, { refresh: params.refresh }); + } + return results; + }); + } +} + From 4adc3a050177291221a5d3e8e8cdeebc55af5ede Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Tue, 18 Nov 2025 08:06:13 -0700 Subject: [PATCH 15/27] fix test --- .../src/features/web-interference-detection.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/injected/src/features/web-interference-detection.js b/injected/src/features/web-interference-detection.js index d2dfafb277..670632553e 100644 --- a/injected/src/features/web-interference-detection.js +++ b/injected/src/features/web-interference-detection.js @@ -2,8 +2,14 @@ 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(args) { + init() { // Get settings with conditionalChanges already applied by framework const settings = this.getFeatureSetting('interferenceTypes'); const autoRunDelayMs = this.getFeatureSetting('autoRunDelayMs') ?? 100; @@ -20,12 +26,13 @@ export default class WebInterferenceDetection extends ContentFeature { // Register messaging handler for PIR/native requests this.messaging.subscribe('detectInterference', (params) => { + const { types = [], refresh = false } = /** @type {DetectInterferenceParams} */ (params ?? {}); const results = {}; - if (params.types?.includes('botDetection')) { - results.botDetection = runBotDetection(settings?.botDetection, { refresh: params.refresh }); + if (types.includes('botDetection')) { + results.botDetection = runBotDetection(settings?.botDetection, { refresh }); } - if (params.types?.includes('fraudDetection')) { - results.fraudDetection = runFraudDetection(settings?.fraudDetection, { refresh: params.refresh }); + if (types.includes('fraudDetection')) { + results.fraudDetection = runFraudDetection(settings?.fraudDetection, { refresh }); } return results; }); From b626f15ebf9296cd25937012e32f10180b845323 Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Wed, 19 Nov 2025 13:05:39 -0700 Subject: [PATCH 16/27] pass config --- injected/src/features/breakage-reporting.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/injected/src/features/breakage-reporting.js b/injected/src/features/breakage-reporting.js index 8ae8c6e41f..a3d7c1c975 100644 --- a/injected/src/features/breakage-reporting.js +++ b/injected/src/features/breakage-reporting.js @@ -2,6 +2,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'; +import { DEFAULT_DETECTOR_SETTINGS } from '../detectors/default-config.js'; export default class BreakageReporting extends ContentFeature { init() { @@ -10,10 +11,10 @@ export default class BreakageReporting extends ContentFeature { const jsPerformance = getJsPerformanceMetrics(); const referrer = document.referrer; - // Call detection functions directly (get cached results) + // Get detector results (uses cached results from auto-run if available) const detectorData = { - botDetection: runBotDetection(), - fraudDetection: runFraudDetection(), + botDetection: runBotDetection(DEFAULT_DETECTOR_SETTINGS.botDetection), + fraudDetection: runFraudDetection(DEFAULT_DETECTOR_SETTINGS.fraudDetection), }; const result = { @@ -31,3 +32,4 @@ export default class BreakageReporting extends ContentFeature { }); } } + From f239af5eec7f1ab22e0d1073605fc496a57a3947 Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Wed, 19 Nov 2025 13:10:53 -0700 Subject: [PATCH 17/27] get settings from webInterference --- injected/src/features/breakage-reporting.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/injected/src/features/breakage-reporting.js b/injected/src/features/breakage-reporting.js index a3d7c1c975..f0ff08331c 100644 --- a/injected/src/features/breakage-reporting.js +++ b/injected/src/features/breakage-reporting.js @@ -2,19 +2,21 @@ 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'; -import { DEFAULT_DETECTOR_SETTINGS } from '../detectors/default-config.js'; export default class BreakageReporting extends ContentFeature { init() { const isExpandedPerformanceMetricsEnabled = this.getFeatureSettingEnabled('expandedPerformanceMetrics', 'enabled'); + + const detectorSettings = this.getFeatureSetting('webInterferenceDetection', 'interferenceTypes') || {}; + this.messaging.subscribe('getBreakageReportValues', async () => { const jsPerformance = getJsPerformanceMetrics(); const referrer = document.referrer; // Get detector results (uses cached results from auto-run if available) const detectorData = { - botDetection: runBotDetection(DEFAULT_DETECTOR_SETTINGS.botDetection), - fraudDetection: runFraudDetection(DEFAULT_DETECTOR_SETTINGS.fraudDetection), + botDetection: runBotDetection(detectorSettings.botDetection), + fraudDetection: runFraudDetection(detectorSettings.fraudDetection), }; const result = { From 37c2dc273768b4dcdd5055b3de31cce739705f9f Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Wed, 19 Nov 2025 13:23:34 -0700 Subject: [PATCH 18/27] comment auto-run --- .../src/features/web-interference-detection.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/injected/src/features/web-interference-detection.js b/injected/src/features/web-interference-detection.js index 670632553e..8a6217dcd9 100644 --- a/injected/src/features/web-interference-detection.js +++ b/injected/src/features/web-interference-detection.js @@ -13,26 +13,29 @@ export default class WebInterferenceDetection extends ContentFeature { // Get settings with conditionalChanges already applied by framework const settings = this.getFeatureSetting('interferenceTypes'); const autoRunDelayMs = this.getFeatureSetting('autoRunDelayMs') ?? 100; + console.log('[web-interference] init', this.args?.site?.url, { settings, autoRunDelayMs }); - // Auto-run enabled detectors after delay to capture transient interference + // Auto-run placeholder. Enable this when adding detectors that need early caching (e.g., ad detection, buffering) + /* 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 }); + const botResult = runBotDetection(settings?.botDetection, { refresh }); + results.botDetection = botResult; } if (types.includes('fraudDetection')) { - results.fraudDetection = runFraudDetection(settings?.fraudDetection, { refresh }); + const fraudResult = runFraudDetection(settings?.fraudDetection, { refresh }); + results.fraudDetection = fraudResult; } return results; }); From 5973a92157289828b3cf3f5092c5f10faa302c0e Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Wed, 19 Nov 2025 13:28:47 -0700 Subject: [PATCH 19/27] revert features --- injected/src/features.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/injected/src/features.js b/injected/src/features.js index 4d8c087181..702e9f739c 100644 --- a/injected/src/features.js +++ b/injected/src/features.js @@ -38,7 +38,7 @@ const otherFeatures = /** @type {const} */ ([ /** @typedef {baseFeatures[number]|otherFeatures[number]} FeatureName */ /** @type {Record} */ export const platformSupport = { - apple: ['webCompat', 'duckPlayerNative', ...baseFeatures, 'webInterferenceDetection', 'duckAiDataClearing', 'pageContext'], + apple: ['webCompat', 'duckPlayerNative', ...baseFeatures, 'duckAiDataClearing', 'pageContext'], 'apple-isolated': [ 'duckPlayer', 'duckPlayerNative', @@ -49,7 +49,7 @@ export const platformSupport = { 'messageBridge', 'favicon', ], - android: [...baseFeatures, 'webCompat', 'webInterferenceDetection', 'breakageReporting', 'duckPlayer', 'messageBridge'], + android: [...baseFeatures, 'webCompat', 'breakageReporting', 'duckPlayer', 'messageBridge'], 'android-broker-protection': ['brokerProtection'], 'android-autofill-import': ['autofillImport'], 'android-adsjs': [ @@ -66,7 +66,6 @@ export const platformSupport = { windows: [ 'cookie', ...baseFeatures, - 'webInterferenceDetection', 'webTelemetry', 'windowsPermissionUsage', 'duckPlayer', @@ -78,7 +77,7 @@ export const platformSupport = { 'duckAiDataClearing', ], firefox: ['cookie', ...baseFeatures, 'clickToLoad'], - chrome: ['cookie', ...baseFeatures, 'clickToLoad'], - 'chrome-mv3': ['cookie', ...baseFeatures, 'clickToLoad'], + chrome: ['cookie', ...baseFeatures, 'clickToLoad', 'breakageReporting'], + 'chrome-mv3': ['cookie', ...baseFeatures, 'clickToLoad', 'breakageReporting'], integration: [...baseFeatures, ...otherFeatures], }; From 0c9694bd9073567c5e33e410eaac7dbc5437d736 Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Wed, 19 Nov 2025 13:30:06 -0700 Subject: [PATCH 20/27] revert features --- injected/src/features.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/injected/src/features.js b/injected/src/features.js index 702e9f739c..f704269a41 100644 --- a/injected/src/features.js +++ b/injected/src/features.js @@ -24,7 +24,6 @@ const otherFeatures = /** @type {const} */ ([ 'duckAiDataClearing', 'harmfulApis', 'webCompat', - 'webInterferenceDetection', 'windowsPermissionUsage', 'brokerProtection', 'performanceMetrics', @@ -77,7 +76,7 @@ export const platformSupport = { 'duckAiDataClearing', ], firefox: ['cookie', ...baseFeatures, 'clickToLoad'], - chrome: ['cookie', ...baseFeatures, 'clickToLoad', 'breakageReporting'], - 'chrome-mv3': ['cookie', ...baseFeatures, 'clickToLoad', 'breakageReporting'], + chrome: ['cookie', ...baseFeatures, 'clickToLoad'], + 'chrome-mv3': ['cookie', ...baseFeatures, 'clickToLoad'], integration: [...baseFeatures, ...otherFeatures], }; From 45b03aa7959d37aefa39a6ebe6e3c1fa4f1d576d Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Wed, 19 Nov 2025 13:43:00 -0700 Subject: [PATCH 21/27] remove cache, refresh' --- .../src/detectors/detections/bot-detection.js | 16 +++------------- .../src/detectors/detections/fraud-detection.js | 16 +++------------- .../src/features/web-interference-detection.js | 12 ++++-------- 3 files changed, 10 insertions(+), 34 deletions(-) diff --git a/injected/src/detectors/detections/bot-detection.js b/injected/src/detectors/detections/bot-detection.js index c078cb8bd1..7c87d92853 100644 --- a/injected/src/detectors/detections/bot-detection.js +++ b/injected/src/detectors/detections/bot-detection.js @@ -1,16 +1,10 @@ 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. + * Run bot detection. * @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; +export function runBotDetection(config = {}) { const results = Object.entries(config) .filter(([_, challengeConfig]) => challengeConfig?.state === 'enabled') .map(([challengeId, challengeConfig]) => { @@ -29,15 +23,11 @@ export function runBotDetection(config = {}, options = {}) { }) .filter(Boolean); - // Cache and return - cachedResult = { + return { detected: results.length > 0, type: 'botDetection', results, - timestamp: Date.now(), }; - - return cachedResult; } function findStatus(statusSelectors) { diff --git a/injected/src/detectors/detections/fraud-detection.js b/injected/src/detectors/detections/fraud-detection.js index 85d68f40aa..16494fa633 100644 --- a/injected/src/detectors/detections/fraud-detection.js +++ b/injected/src/detectors/detections/fraud-detection.js @@ -1,16 +1,10 @@ import { checkSelectorsWithVisibility, checkTextPatterns } from '../utils/detection-utils.js'; -// Cache result to avoid redundant DOM scans -let cachedResult = null; - /** - * Run fraud detection and cache results. + * Run fraud detection. * @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; +export function runFraudDetection(config = {}) { const results = Object.entries(config) .filter(([_, alertConfig]) => alertConfig?.state === 'enabled') .map(([alertId, alertConfig]) => { @@ -29,13 +23,9 @@ export function runFraudDetection(config = {}, options = {}) { }) .filter(Boolean); - // Cache and return - cachedResult = { + return { detected: results.length > 0, type: 'fraudDetection', results, - timestamp: Date.now(), }; - - return cachedResult; } diff --git a/injected/src/features/web-interference-detection.js b/injected/src/features/web-interference-detection.js index 8a6217dcd9..05b8b00ce7 100644 --- a/injected/src/features/web-interference-detection.js +++ b/injected/src/features/web-interference-detection.js @@ -5,7 +5,6 @@ import { runFraudDetection } from '../detectors/detections/fraud-detection.js'; /** * @typedef {object} DetectInterferenceParams * @property {string[]} [types] - * @property {boolean} [refresh] */ export default class WebInterferenceDetection extends ContentFeature { @@ -26,18 +25,15 @@ export default class WebInterferenceDetection extends ContentFeature { // Register messaging handler for PIR/native requests this.messaging.subscribe('detectInterference', (params) => { - const { types = [], refresh = false } = /** @type {DetectInterferenceParams} */ (params ?? {}); + const { types = [] } = /** @type {DetectInterferenceParams} */ (params ?? {}); const results = {}; if (types.includes('botDetection')) { - const botResult = runBotDetection(settings?.botDetection, { refresh }); - results.botDetection = botResult; + results.botDetection = runBotDetection(settings?.botDetection); } if (types.includes('fraudDetection')) { - const fraudResult = runFraudDetection(settings?.fraudDetection, { refresh }); - results.fraudDetection = fraudResult; - } - return results; + results.fraudDetection = runFraudDetection(settings?.fraudDetection); + } return results; }); } } From a4a3673914b857a85959ba34fafabded9f676cd3 Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Wed, 19 Nov 2025 13:46:31 -0700 Subject: [PATCH 22/27] update readme --- injected/src/detectors/README.md | 89 ++++++++++++-------------------- 1 file changed, 34 insertions(+), 55 deletions(-) diff --git a/injected/src/detectors/README.md b/injected/src/detectors/README.md index d6754af3a6..7482f20bb6 100644 --- a/injected/src/detectors/README.md +++ b/injected/src/detectors/README.md @@ -4,11 +4,11 @@ This directory contains web interference detection functionality for content-sco ## Architecture -The system uses a **ContentFeature** wrapper with simple detection utilities: +The system provides simple detection utilities that can be called on-demand: -- **`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 +- **Detection utilities** - Pure functions (`runBotDetection`, `runFraudDetection`) that scan DOM when called +- **Direct imports** - Features (breakage reporting, PIR) import detection functions directly +- **`WebInterferenceDetection`** - Optional ContentFeature wrapper for messaging (PIR use, not currently bundled) ## Directory Layout @@ -25,14 +25,14 @@ detectors/ ## How It Works -### 1. Initialization +### 1. On-Demand Detection -The `WebInterferenceDetection` ContentFeature runs detectors automatically: +Detectors are simple functions that scan the DOM when called: -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 +1. Feature imports detector function (e.g., `runBotDetection`) +2. Feature calls detector with config when needed (e.g., user submits breakage report) +3. Detector scans DOM and returns results immediately (~few ms) +4. No caching - each call is fresh ### 2. Configuration @@ -91,17 +91,23 @@ The framework automatically applies conditional changes based on the current URL ### 3. Using Detection Results -**Internal features** (same content script context): +**Breakage reporting** (internal feature): ```javascript import { runBotDetection, runFraudDetection } from '../detectors/detections/bot-detection.js'; -// Get cached results from auto-run -const botData = runBotDetection(); -const fraudData = runFraudDetection(); +// Get detector config from privacy-configuration +const detectorSettings = this.getFeatureSetting('webInterferenceDetection', 'interferenceTypes'); + +if (detectorSettings) { + const result = { + botDetection: runBotDetection(detectorSettings.botDetection), + fraudDetection: runFraudDetection(detectorSettings.fraudDetection), + }; +} ``` -**External:** +**PIR/native** (via messaging, when `WebInterferenceDetection` is bundled): ```javascript // Via messaging @@ -116,38 +122,27 @@ this.messaging.request('detectInterference', { ```javascript // detections/my-detector.js -let cachedResult = null; - -export function runMyDetection(config = {}, options = {}) { - if (cachedResult && !options.refresh) return cachedResult; - +export function runMyDetection(config = {}) { // Run detection logic const detected = checkSelectors(config.selectors); - cachedResult = { + return { detected, type: 'myDetector', - timestamp: Date.now(), + results: [...], }; - - return cachedResult; } ``` -2. **Add to WebInterferenceDetection feature**: +2. **Use in breakage reporting or other feature**: ```javascript -// features/web-interference-detection.js +// features/breakage-reporting.js import { runMyDetection } from '../detectors/detections/my-detector.js'; -init(args) { - const settings = this.getFeatureSetting('interferenceTypes'); - - setTimeout(() => { - if (settings?.myDetector) { - runMyDetection(settings.myDetector); - } - }, autoRunDelayMs); +const detectorSettings = this.getFeatureSetting('webInterferenceDetection', 'interferenceTypes'); +if (detectorSettings?.myDetector) { + result.myDetectorData = runMyDetection(detectorSettings.myDetector); } ``` @@ -166,25 +161,9 @@ init(args) { } ``` -## 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); +## Performance -// 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 -}); -``` +- Detectors are simple DOM queries - typically < 5ms +- No caching overhead or stale results +- Only run when explicitly needed (e.g., breakage report submitted) +- Future: If frequent polling is needed, add a shared caching wrapper From f38a4f67789f239819074e058e23883fd0240f84 Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Wed, 19 Nov 2025 15:05:39 -0700 Subject: [PATCH 23/27] update features.js --- injected/src/features.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/injected/src/features.js b/injected/src/features.js index f704269a41..3d0995ad7d 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', @@ -76,7 +78,7 @@ export const platformSupport = { 'duckAiDataClearing', ], firefox: ['cookie', ...baseFeatures, 'clickToLoad'], - chrome: ['cookie', ...baseFeatures, 'clickToLoad'], - 'chrome-mv3': ['cookie', ...baseFeatures, 'clickToLoad'], + chrome: ['cookie', ...baseFeatures, 'clickToLoad', 'webInterferenceDetection', 'breakageReporting'], + 'chrome-mv3': ['cookie', ...baseFeatures, 'clickToLoad', 'webInterferenceDetection', 'breakageReporting'], integration: [...baseFeatures, ...otherFeatures], }; From 93bb05d175c29c8a9c766dcf9bf90ed9e41ec41a Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Wed, 19 Nov 2025 15:39:23 -0700 Subject: [PATCH 24/27] fix getFeature --- injected/src/features/breakage-reporting.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/injected/src/features/breakage-reporting.js b/injected/src/features/breakage-reporting.js index f0ff08331c..dc00f0e9e7 100644 --- a/injected/src/features/breakage-reporting.js +++ b/injected/src/features/breakage-reporting.js @@ -7,23 +7,24 @@ export default class BreakageReporting extends ContentFeature { init() { const isExpandedPerformanceMetricsEnabled = this.getFeatureSettingEnabled('expandedPerformanceMetrics', 'enabled'); - const detectorSettings = this.getFeatureSetting('webInterferenceDetection', 'interferenceTypes') || {}; - this.messaging.subscribe('getBreakageReportValues', async () => { const jsPerformance = getJsPerformanceMetrics(); const referrer = document.referrer; - // Get detector results (uses cached results from auto-run if available) - const detectorData = { - botDetection: runBotDetection(detectorSettings.botDetection), - fraudDetection: runFraudDetection(detectorSettings.fraudDetection), - }; - const result = { jsPerformance, referrer, - detectorData, }; + + // Only run detectors if explicitly configured + const detectorSettings = this.getFeatureSetting('interferenceTypes', 'webInterferenceDetection'); + if (detectorSettings) { + result.detectorData = { + botDetection: runBotDetection(detectorSettings.botDetection), + fraudDetection: runFraudDetection(detectorSettings.fraudDetection), + }; + } + if (isExpandedPerformanceMetricsEnabled) { const expandedPerformanceMetrics = await getExpandedPerformanceMetrics(); if (expandedPerformanceMetrics.success) { From 279c192c5b9666c02d11e918d386506d38f9540b Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Thu, 20 Nov 2025 08:14:47 -0700 Subject: [PATCH 25/27] detection tests --- .../breakage-reporting.spec.js | 109 ++++++++++++++---- .../breakage-reporting/config/config.json | 27 +++++ .../pages/captcha-cloudflare.html | 23 ++++ .../pages/captcha-recaptcha.html | 18 +++ .../pages/no-challenge.html | 13 +++ 5 files changed, 170 insertions(+), 20 deletions(-) create mode 100644 injected/integration-test/test-pages/breakage-reporting/pages/captcha-cloudflare.html create mode 100644 injected/integration-test/test-pages/breakage-reporting/pages/captcha-recaptcha.html create mode 100644 injected/integration-test/test-pages/breakage-reporting/pages/no-challenge.html diff --git a/injected/integration-test/breakage-reporting.spec.js b/injected/integration-test/breakage-reporting.spec.js index 2935f0c575..3508f63e23 100644 --- a/injected/integration-test/breakage-reporting.spec.js +++ b/injected/integration-test/breakage-reporting.spec.js @@ -3,22 +3,87 @@ import { ResultsCollector } from './page-objects/results-collector.js'; const HTML = '/breakage-reporting/index.html'; const CONFIG = './integration-test/test-pages/breakage-reporting/config/config.json'; -test('Breakage Reporting Feature', async ({ page }, testInfo) => { - const collector = ResultsCollector.create(page, testInfo.project.use); - await collector.load(HTML, CONFIG); - - const breakageFeature = new BreakageReportingSpec(page); - await breakageFeature.navigate(); - - await collector.simulateSubscriptionMessage('breakageReporting', 'getBreakageReportValues', {}); - await collector.waitForMessage('breakageReportResult'); - const calls = await collector.outgoingMessages(); - - expect(calls.length).toBe(1); - const result = /** @type {import("@duckduckgo/messaging").NotificationMessage} */ (calls[0].payload); - expect(result.params?.jsPerformance.length).toBe(1); - expect(result.params?.jsPerformance[0]).toBeGreaterThan(0); - expect(result.params?.referrer).toBe('http://localhost:3220/breakage-reporting/index.html'); + +test.describe('Breakage Reporting Feature', () => { + test('collects basic metrics without detectors', async ({ page }, testInfo) => { + const collector = ResultsCollector.create(page, testInfo.project.use); + await collector.load(HTML, CONFIG); + + const breakageFeature = new BreakageReportingSpec(page); + await breakageFeature.navigate(); + + await collector.simulateSubscriptionMessage('breakageReporting', 'getBreakageReportValues', {}); + await collector.waitForMessage('breakageReportResult'); + const calls = await collector.outgoingMessages(); + + expect(calls.length).toBe(1); + const result = /** @type {import("@duckduckgo/messaging").NotificationMessage} */ (calls[0].payload); + expect(result.params?.jsPerformance.length).toBe(1); + expect(result.params?.jsPerformance[0]).toBeGreaterThan(0); + expect(result.params?.referrer).toBe('http://localhost:3220/breakage-reporting/index.html'); + }); + + test('detects no challenges on clean page', async ({ page }, testInfo) => { + const collector = ResultsCollector.create(page, testInfo.project.use); + await collector.load(HTML, CONFIG); + + const breakageFeature = new BreakageReportingSpec(page); + await breakageFeature.navigateToPage('/breakage-reporting/pages/no-challenge.html'); + + await collector.simulateSubscriptionMessage('breakageReporting', 'getBreakageReportValues', {}); + await collector.waitForMessage('breakageReportResult'); + const calls = await collector.outgoingMessages(); + + const result = /** @type {import("@duckduckgo/messaging").NotificationMessage} */ (calls[0].payload); + expect(result.params?.detectorData).toBeDefined(); + expect(result.params?.detectorData?.botDetection.detected).toBe(false); + expect(result.params?.detectorData?.fraudDetection.detected).toBe(false); + }); + + test('detects Cloudflare challenge', async ({ page }, testInfo) => { + const collector = ResultsCollector.create(page, testInfo.project.use); + await collector.load(HTML, CONFIG); + + const breakageFeature = new BreakageReportingSpec(page); + await breakageFeature.navigateToPage('/breakage-reporting/pages/captcha-cloudflare.html'); + + await collector.simulateSubscriptionMessage('breakageReporting', 'getBreakageReportValues', {}); + await collector.waitForMessage('breakageReportResult'); + const calls = await collector.outgoingMessages(); + + const result = /** @type {import("@duckduckgo/messaging").NotificationMessage} */ (calls[0].payload); + expect(result.params?.detectorData).toBeDefined(); + expect(result.params?.detectorData?.botDetection.detected).toBe(true); + expect(result.params?.detectorData?.botDetection.results.length).toBeGreaterThan(0); + + const cloudflareResult = result.params?.detectorData?.botDetection.results[0]; + expect(cloudflareResult.vendor).toBe('Cloudflare'); + expect(cloudflareResult.challengeType).toBe('cloudflare'); + expect(cloudflareResult.detected).toBe(true); + }); + + test('detects reCAPTCHA challenge', async ({ page }, testInfo) => { + const collector = ResultsCollector.create(page, testInfo.project.use); + await collector.load(HTML, CONFIG); + + const breakageFeature = new BreakageReportingSpec(page); + await breakageFeature.navigateToPage('/breakage-reporting/pages/captcha-recaptcha.html'); + + await collector.simulateSubscriptionMessage('breakageReporting', 'getBreakageReportValues', {}); + await collector.waitForMessage('breakageReportResult'); + const calls = await collector.outgoingMessages(); + + const result = /** @type {import("@duckduckgo/messaging").NotificationMessage} */ (calls[0].payload); + expect(result.params?.detectorData).toBeDefined(); + expect(result.params?.detectorData?.botDetection.detected).toBe(true); + + const recaptchaResult = result.params?.detectorData?.botDetection.results.find( + r => r.challengeType === 'recaptcha' + ); + expect(recaptchaResult).toBeDefined(); + expect(recaptchaResult.vendor).toBe('reCAPTCHA'); + expect(recaptchaResult.detected).toBe(true); + }); }); export class BreakageReportingSpec { @@ -30,10 +95,14 @@ export class BreakageReportingSpec { } async navigate() { - await this.page.evaluate(() => { - window.location.href = '/breakage-reporting/pages/ref.html'; - }); - await this.page.waitForURL('**/ref.html'); + await this.navigateToPage('/breakage-reporting/pages/ref.html'); + } + + async navigateToPage(url) { + await this.page.evaluate((targetUrl) => { + window.location.href = targetUrl; + }, url); + await this.page.waitForURL(`**${url}`); // Wait for first paint event to ensure we can get the performance metrics await this.page.evaluate(() => { diff --git a/injected/integration-test/test-pages/breakage-reporting/config/config.json b/injected/integration-test/test-pages/breakage-reporting/config/config.json index 11e2165348..39ce43c6f7 100644 --- a/injected/integration-test/test-pages/breakage-reporting/config/config.json +++ b/injected/integration-test/test-pages/breakage-reporting/config/config.json @@ -6,6 +6,33 @@ "breakageReporting": { "state": "enabled", "exceptions": [] + }, + "webInterferenceDetection": { + "state": "enabled", + "exceptions": [], + "settings": { + "interferenceTypes": { + "botDetection": { + "cloudflare": { + "state": "enabled", + "vendor": "Cloudflare", + "selectors": ["#challenge-running", ".cf-browser-verification"] + }, + "recaptcha": { + "state": "enabled", + "vendor": "reCAPTCHA", + "selectors": [".g-recaptcha", "iframe[src*='google.com/recaptcha']"] + } + }, + "fraudDetection": { + "px": { + "state": "enabled", + "vendor": "PerimeterX", + "selectors": ["#px-captcha", "[id^='px-']"] + } + } + } + } } } } diff --git a/injected/integration-test/test-pages/breakage-reporting/pages/captcha-cloudflare.html b/injected/integration-test/test-pages/breakage-reporting/pages/captcha-cloudflare.html new file mode 100644 index 0000000000..3423d36dad --- /dev/null +++ b/injected/integration-test/test-pages/breakage-reporting/pages/captcha-cloudflare.html @@ -0,0 +1,23 @@ + + + + + + Mock Cloudflare Challenge + + +

Mock Cloudflare Challenge Page

+ + +
+

Checking your browser before accessing example.com

+

This process is automatic. Your browser will redirect shortly.

+
+ + + + + diff --git a/injected/integration-test/test-pages/breakage-reporting/pages/captcha-recaptcha.html b/injected/integration-test/test-pages/breakage-reporting/pages/captcha-recaptcha.html new file mode 100644 index 0000000000..45b458812e --- /dev/null +++ b/injected/integration-test/test-pages/breakage-reporting/pages/captcha-recaptcha.html @@ -0,0 +1,18 @@ + + + + + + Mock reCAPTCHA Challenge + + +

Mock reCAPTCHA Challenge Page

+ + +
+ + + + + + diff --git a/injected/integration-test/test-pages/breakage-reporting/pages/no-challenge.html b/injected/integration-test/test-pages/breakage-reporting/pages/no-challenge.html new file mode 100644 index 0000000000..a8647de43f --- /dev/null +++ b/injected/integration-test/test-pages/breakage-reporting/pages/no-challenge.html @@ -0,0 +1,13 @@ + + + + + + No Challenge Page + + +

Clean Page - No Challenges

+

This page has no bot detection or fraud detection challenges.

+ + + From 00c1396ac6dfbe9e231c43c46a443b5b46e39bff Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Thu, 20 Nov 2025 14:44:40 -0700 Subject: [PATCH 26/27] add px test --- .../breakage-reporting.spec.js | 19 +++++++++++++++++++ .../breakage-reporting/pages/fraud-px.html | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 injected/integration-test/test-pages/breakage-reporting/pages/fraud-px.html diff --git a/injected/integration-test/breakage-reporting.spec.js b/injected/integration-test/breakage-reporting.spec.js index 3508f63e23..a94d2dc2d9 100644 --- a/injected/integration-test/breakage-reporting.spec.js +++ b/injected/integration-test/breakage-reporting.spec.js @@ -84,6 +84,25 @@ test.describe('Breakage Reporting Feature', () => { expect(recaptchaResult.vendor).toBe('reCAPTCHA'); expect(recaptchaResult.detected).toBe(true); }); + + test('detects Fraud challenge (PerimeterX)', async ({ page }, testInfo) => { + const collector = ResultsCollector.create(page, testInfo.project.use); + await collector.load(HTML, CONFIG); + + const breakageFeature = new BreakageReportingSpec(page); + await breakageFeature.navigateToPage('/breakage-reporting/pages/fraud-px.html'); + + await collector.simulateSubscriptionMessage('breakageReporting', 'getBreakageReportValues', {}); + await collector.waitForMessage('breakageReportResult'); + const calls = await collector.outgoingMessages(); + + const result = /** @type {import("@duckduckgo/messaging").NotificationMessage} */ (calls[0].payload); + expect(result.params?.detectorData).toBeDefined(); + expect(result.params?.detectorData?.fraudDetection.detected).toBe(true); + + const fraudResult = result.params?.detectorData?.fraudDetection.results[0]; + expect(fraudResult.alertId).toBe('px'); + }); }); export class BreakageReportingSpec { diff --git a/injected/integration-test/test-pages/breakage-reporting/pages/fraud-px.html b/injected/integration-test/test-pages/breakage-reporting/pages/fraud-px.html new file mode 100644 index 0000000000..303d920d76 --- /dev/null +++ b/injected/integration-test/test-pages/breakage-reporting/pages/fraud-px.html @@ -0,0 +1,19 @@ + + + + + Mock PerimeterX Fraud Page + + + +

Mock PerimeterX Page

+ +
+ + From 7d8619cdb2e752f07cba2da5f9cae4c1eb81485e Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Fri, 21 Nov 2025 08:22:23 -0700 Subject: [PATCH 27/27] lint --- injected/integration-test/breakage-reporting.spec.js | 4 +--- injected/src/detectors/default-config.js | 7 +------ injected/src/detectors/detections/bot-detection.js | 1 - injected/src/detectors/detections/fraud-detection.js | 3 +-- injected/src/features/breakage-reporting.js | 1 - injected/src/features/web-interference-detection.js | 6 ++---- 6 files changed, 5 insertions(+), 17 deletions(-) diff --git a/injected/integration-test/breakage-reporting.spec.js b/injected/integration-test/breakage-reporting.spec.js index a94d2dc2d9..413bcb8a24 100644 --- a/injected/integration-test/breakage-reporting.spec.js +++ b/injected/integration-test/breakage-reporting.spec.js @@ -77,9 +77,7 @@ test.describe('Breakage Reporting Feature', () => { expect(result.params?.detectorData).toBeDefined(); expect(result.params?.detectorData?.botDetection.detected).toBe(true); - const recaptchaResult = result.params?.detectorData?.botDetection.results.find( - r => r.challengeType === 'recaptcha' - ); + const recaptchaResult = result.params?.detectorData?.botDetection.results.find((r) => r.challengeType === 'recaptcha'); expect(recaptchaResult).toBeDefined(); expect(recaptchaResult.vendor).toBe('reCAPTCHA'); expect(recaptchaResult.detected).toBe(true); diff --git a/injected/src/detectors/default-config.js b/injected/src/detectors/default-config.js index e569ce19e7..edde889a8e 100644 --- a/injected/src/detectors/default-config.js +++ b/injected/src/detectors/default-config.js @@ -25,12 +25,7 @@ export const DEFAULT_DETECTOR_SETTINGS = Object.freeze({ hcaptcha: { state: 'enabled', vendor: 'hcaptcha', - selectors: [ - '.h-captcha', - '[data-hcaptcha-widget-id]', - 'script[src*="hcaptcha.com"]', - 'script[src*="assets.hcaptcha.com"]', - ], + selectors: ['.h-captcha', '[data-hcaptcha-widget-id]', 'script[src*="hcaptcha.com"]', 'script[src*="assets.hcaptcha.com"]'], windowProperties: ['hcaptcha'], }, }, diff --git a/injected/src/detectors/detections/bot-detection.js b/injected/src/detectors/detections/bot-detection.js index 7c87d92853..737c8042a5 100644 --- a/injected/src/detectors/detections/bot-detection.js +++ b/injected/src/detectors/detections/bot-detection.js @@ -42,4 +42,3 @@ function findStatus(statusSelectors) { return match?.status ?? null; } - diff --git a/injected/src/detectors/detections/fraud-detection.js b/injected/src/detectors/detections/fraud-detection.js index 16494fa633..0fa2ae68a4 100644 --- a/injected/src/detectors/detections/fraud-detection.js +++ b/injected/src/detectors/detections/fraud-detection.js @@ -9,8 +9,7 @@ export function runFraudDetection(config = {}) { .filter(([_, alertConfig]) => alertConfig?.state === 'enabled') .map(([alertId, alertConfig]) => { const detected = - checkSelectorsWithVisibility(alertConfig.selectors) || - checkTextPatterns(alertConfig.textPatterns, alertConfig.textSources); + checkSelectorsWithVisibility(alertConfig.selectors) || checkTextPatterns(alertConfig.textPatterns, alertConfig.textSources); if (!detected) { return null; } diff --git a/injected/src/features/breakage-reporting.js b/injected/src/features/breakage-reporting.js index dc00f0e9e7..55cd074063 100644 --- a/injected/src/features/breakage-reporting.js +++ b/injected/src/features/breakage-reporting.js @@ -35,4 +35,3 @@ export default class BreakageReporting extends ContentFeature { }); } } - diff --git a/injected/src/features/web-interference-detection.js b/injected/src/features/web-interference-detection.js index 05b8b00ce7..bbbf4e65c5 100644 --- a/injected/src/features/web-interference-detection.js +++ b/injected/src/features/web-interference-detection.js @@ -11,8 +11,6 @@ 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; - console.log('[web-interference] init', this.args?.site?.url, { settings, autoRunDelayMs }); // Auto-run placeholder. Enable this when adding detectors that need early caching (e.g., ad detection, buffering) /* @@ -33,8 +31,8 @@ export default class WebInterferenceDetection extends ContentFeature { } if (types.includes('fraudDetection')) { results.fraudDetection = runFraudDetection(settings?.fraudDetection); - } return results; + } + return results; }); } } -