From 5e9d02fbfbb713def7cac3c5171633d8b0212657 Mon Sep 17 00:00:00 2001 From: madblex Date: Thu, 6 Nov 2025 17:41:18 +0100 Subject: [PATCH] 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(), + }; +}