Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions injected/src/error-utils.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
20 changes: 20 additions & 0 deletions injected/src/features/broker-protection.js
Original file line number Diff line number Diff line change
@@ -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 {
/**
Expand Down Expand Up @@ -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() });
}
});
Comment on lines +106 to +113
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runs detection synchronously? The way I did this in my version uses autoRun: true and caching with TTL. If you always need a fresh detection I could add a noCache option. So your use for this would look something like

this.messaging.subscribe('detectInterference', async (request) => {
    try {
        const detectorIds = ['botDetection'];
        
        // Default: Uses cached results from auto-run (fast)
        const detectionResults = await getDetectorsData(detectorIds);
        
        // If you need always-fresh results:
        // const detectionResults = await getDetectorsData(detectorIds, { noCache: true });
        
        return this.messaging.notify('interferenceDetected', { results: detectionResults });

}

/**
Expand Down
53 changes: 53 additions & 0 deletions injected/src/features/web-interference-detection.js
Original file line number Diff line number Diff line change
@@ -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() });
}
});
}
}
165 changes: 165 additions & 0 deletions injected/src/services/web-interference-detection/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# Web Interference Detection Service

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering instead of minting a whole new services/ directory just having these as injected/src/features/web-interference-detection/ that broker-protection imports is good enough?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'd be creating an artificial dependency between 2 features. TBH I don't know if we already have stuff like this in the code already, but theoretically, I don't think features should depend on each other. Happy to defer to you since you know this arch better.

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<br/>ContentFeature]
Service[WebInterferenceDetectionService]
subgraph Detectors["Detection Classes"]
Bot[BotDetection]
Fraud[FraudDetection]
YouTube[YouTubeAdsDetection]
end
Base[DetectionBase]
Utils[detection-utils]
end
NativeApps <-->|"messaging<br/>(detectInterference,<br/>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
Original file line number Diff line number Diff line change
@@ -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,
},
},
}),
);
Loading
Loading