Skip to content

Commit eba8337

Browse files
committed
AG-37034 add prevent-constructor scriptlet. #461
Squashed commit of the following: commit 65c010b Author: slvvko <v.leleka@adguard.com> Date: Fri Jan 23 08:07:54 2026 -0500 fix isMatchingSuspended reset commit 8019cfa Author: slvvko <v.leleka@adguard.com> Date: Thu Jan 22 23:29:21 2026 -0500 fix changelog commit e911046 Merge: 654078d 85ca4be Author: slvvko <v.leleka@adguard.com> Date: Thu Jan 22 23:28:11 2026 -0500 Merge branch 'master' into feature/AG-37034 commit 654078d Author: slvvko <v.leleka@adguard.com> Date: Thu Jan 22 23:03:31 2026 -0500 fix string arguments match for object commit 7ba8647 Author: slvvko <v.leleka@adguard.com> Date: Thu Jan 22 22:45:29 2026 -0500 add toString protection commit e8c21bf Author: slvvko <v.leleka@adguard.com> Date: Thu Jan 22 22:28:29 2026 -0500 prevent infinite loops commit 669254e Author: slvvko <v.leleka@adguard.com> Date: Wed Jan 21 22:41:45 2026 -0500 add prevent-constructor scriptlet
1 parent 85ca4be commit eba8337

6 files changed

Lines changed: 939 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic
1010
<!-- TODO: change `@added unknown` tag due to the actual version -->
1111
<!-- during new scriptlets or redirects releasing -->
1212

13+
## Unreleased
14+
15+
### Added
16+
17+
- `prevent-constructor` scriptlet to prevent constructor calls
18+
like `new Promise()` or `new MutationObserver()` [#461].
19+
20+
[#461]: https://github.com/AdguardTeam/Scriptlets/issues/461
21+
1322
## [v2.2.15] - 2026-01-22
1423

1524
### Added
@@ -25,8 +34,8 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic
2534

2635
- Do not throw error on `null` event type in `prevent-addEventListener` scriptlet [#539].
2736

37+
[v2.2.15]: https://github.com/AdguardTeam/Scriptlets/compare/v2.2.14...v2.2.15
2838
[#539]: https://github.com/AdguardTeam/Scriptlets/issues/539
29-
3039
[#542]: https://github.com/AdguardTeam/Scriptlets/issues/542
3140

3241
## [v2.2.14] - 2025-12-16

scripts/compatibility-table.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,9 @@
153153
"adg": "prevent-canvas",
154154
"ubo": "prevent-canvas.js"
155155
},
156+
{
157+
"adg": "prevent-constructor"
158+
},
156159
{
157160
"adg": "prevent-element-src-loading"
158161
},
@@ -609,4 +612,4 @@
609612
"ubo": "adthrive_abd.js"
610613
}
611614
]
612-
}
615+
}
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import {
2+
hit,
3+
toRegExp,
4+
logMessage,
5+
noopFunc,
6+
} from '../helpers';
7+
import { type Source } from './scriptlets';
8+
9+
/* eslint-disable max-len */
10+
/**
11+
* @scriptlet prevent-constructor
12+
*
13+
* @description
14+
* Prevents a constructor call if the constructor name
15+
* and optionally the first argument match the specified criteria.
16+
* This scriptlet is useful for blocking constructors like `Promise` or `MutationObserver`
17+
* that can be used to circumvent existing scriptlets like `prevent-addEventListener`.
18+
*
19+
* ### Syntax
20+
*
21+
* ```text
22+
* example.org#%#//scriptlet('prevent-constructor', constructorName[, argumentSearch])
23+
* ```
24+
*
25+
* - `constructorName` — required, string, the name of the constructor to prevent,
26+
* e.g., "Promise", "MutationObserver".
27+
* Must be a property of the global `window` object.
28+
* - `argumentsMatch` — optional, string or regular expression,
29+
* or JSON array of patterns matching arguments passed to the constructor.
30+
* Defaults to match all constructors if not specified.
31+
* Possible values:
32+
* - string — matches the first argument only;
33+
* - JSON array — matches arguments positionally, should be wrapped in `[]`;
34+
* use `"*"` to skip positions, e.g., if only second argument should be matched.
35+
* Invalid regular expression or JSON will cause exit and rule will not work.
36+
*
37+
* ### Examples
38+
*
39+
* 1. Prevent all `MutationObserver` constructor calls
40+
*
41+
* ```adblock
42+
* example.org#%#//scriptlet('prevent-constructor', 'MutationObserver')
43+
* ```
44+
*
45+
* 1. Prevent `Promise` constructor calls where the first argument contains `adblock`
46+
*
47+
* ```adblock
48+
* example.org#%#//scriptlet('prevent-constructor', 'Promise', 'adblock')
49+
* ```
50+
*
51+
* 1. Prevent `MutationObserver` calls where the first argument matches a regexp
52+
*
53+
* ```adblock
54+
* example.org#%#//scriptlet('prevent-constructor', 'MutationObserver', '/detect.*ad/')
55+
* ```
56+
*
57+
* 1. Prevent `MutationObserver` calls where the second argument contains `attributes`,
58+
* and matching of the first argument is skipped
59+
*
60+
* ```adblock
61+
* example.org#%#//scriptlet('prevent-constructor', 'MutationObserver', '["*", "attributes"]')
62+
* ```
63+
*
64+
* @added unknown
65+
*/
66+
/* eslint-enable max-len */
67+
export function preventConstructor(
68+
source: Source,
69+
constructorName: string,
70+
argumentsMatch?: string,
71+
) {
72+
if (!constructorName) {
73+
return;
74+
}
75+
76+
const nativeConstructor = (window as any)[constructorName];
77+
78+
if (typeof nativeConstructor !== 'function') {
79+
logMessage(source, `"${constructorName}" is not a function`);
80+
return;
81+
}
82+
83+
/**
84+
* Checks if the argumentsMatch string represents a valid array of patterns
85+
* needed to match constructor arguments respectively.
86+
*
87+
* @param input The argumentsMatch string to parse.
88+
*
89+
* @returns Array of patterns or null if parsing fails.
90+
*/
91+
const parseArgumentsMatchAsArray = (input: string | undefined): string[] | null => {
92+
if (!input) {
93+
return null;
94+
}
95+
96+
if (
97+
input.trim().startsWith('[')
98+
&& input.trim().endsWith(']')
99+
) {
100+
try {
101+
const parsed = JSON.parse(input);
102+
103+
if (Array.isArray(parsed)) {
104+
return parsed.map((p: any) => String(p));
105+
}
106+
107+
logMessage(source, 'Invalid argumentsMatch: not an array');
108+
109+
return null;
110+
} catch (e) {
111+
logMessage(source, `Invalid JSON in argumentsMatch: ${input}`);
112+
113+
return null;
114+
}
115+
}
116+
117+
// Plain string - will be used for first argument matching
118+
return null;
119+
};
120+
121+
const arrayArgPatterns = parseArgumentsMatchAsArray(argumentsMatch);
122+
123+
/**
124+
* This flag allows to prevent infinite loops when trapping constructors
125+
* that are used by scriptlet's own code, e.g., RegExp used by toRegExp).
126+
*/
127+
let isMatchingSuspended = false;
128+
129+
const handlerWrapper = (target: any, args: any[], newTarget: any) => {
130+
// If matching is suspended, pass through to the original constructor
131+
// to avoid infinite recursion
132+
if (isMatchingSuspended) {
133+
return Reflect.construct(target, args, newTarget);
134+
}
135+
136+
// Prevent matching to avoid infinite recursion
137+
isMatchingSuspended = true;
138+
139+
let shouldPrevent = false;
140+
141+
if (!argumentsMatch) {
142+
// No argument search specified - match all
143+
shouldPrevent = true;
144+
} else if (arrayArgPatterns !== null) {
145+
// Array syntax — match arguments positionally.
146+
// Assume it is matched until proven otherwise
147+
shouldPrevent = true;
148+
149+
for (let i = 0; i < arrayArgPatterns.length; i += 1) {
150+
const pattern = arrayArgPatterns[i];
151+
152+
// Some arguments may be skipped, e.g. ['*', 'callback']
153+
if (pattern === '*') {
154+
continue;
155+
}
156+
157+
// Check if argument exists at this position
158+
if (i >= args.length) {
159+
// Pattern expects an argument that does not exist - do not match
160+
// eslint-disable-next-line max-len
161+
const msg = `Pattern expects argument at position ${i}, but constructor called with ${args.length} arguments`;
162+
logMessage(source, msg);
163+
shouldPrevent = false;
164+
break;
165+
}
166+
167+
const arg = args[i];
168+
let argStr: string;
169+
170+
if (typeof arg === 'function') {
171+
argStr = arg.toString();
172+
} else if (typeof arg === 'object' && arg !== null) {
173+
try {
174+
argStr = JSON.stringify(arg);
175+
} catch (e) {
176+
argStr = String(arg);
177+
}
178+
} else {
179+
argStr = String(arg);
180+
}
181+
182+
const patternRegexp = toRegExp(pattern);
183+
184+
if (!patternRegexp.test(argStr)) {
185+
shouldPrevent = false;
186+
break;
187+
}
188+
}
189+
} else {
190+
// if argumentsMatch is set and is not an array, it should be a plain string,
191+
// so only the first argument should be matched
192+
const firstArg = args[0];
193+
let firstArgStr: string;
194+
195+
if (typeof firstArg === 'function') {
196+
firstArgStr = firstArg.toString();
197+
} else if (typeof firstArg === 'object' && firstArg !== null) {
198+
try {
199+
firstArgStr = JSON.stringify(firstArg);
200+
} catch (e) {
201+
firstArgStr = String(firstArg);
202+
}
203+
} else {
204+
firstArgStr = String(firstArg);
205+
}
206+
207+
const argumentsMatchRegexp = toRegExp(argumentsMatch);
208+
shouldPrevent = argumentsMatchRegexp.test(firstArgStr);
209+
}
210+
211+
if (!shouldPrevent) {
212+
isMatchingSuspended = false;
213+
return Reflect.construct(target, args, newTarget);
214+
}
215+
216+
hit(source);
217+
// Construct with noop callback to prevent original code execution
218+
// while maintaining proper instanceof checks
219+
try {
220+
const result = Reflect.construct(target, [noopFunc], newTarget);
221+
isMatchingSuspended = false;
222+
return result;
223+
} catch (e) {
224+
// If construction fails, return an empty object with proper prototype
225+
isMatchingSuspended = false;
226+
return Object.create(target.prototype || null);
227+
}
228+
};
229+
230+
const constructorHandler: ProxyHandler<typeof nativeConstructor> = {
231+
construct: handlerWrapper,
232+
get(target, prop, receiver) {
233+
if (prop === 'toString') {
234+
return Function.prototype.toString.bind(target);
235+
}
236+
return Reflect.get(target, prop, receiver);
237+
},
238+
};
239+
240+
(window as any)[constructorName] = new Proxy(nativeConstructor, constructorHandler);
241+
}
242+
243+
export const preventConstructorNames = [
244+
'prevent-constructor',
245+
];
246+
247+
// eslint-disable-next-line prefer-destructuring
248+
preventConstructor.primaryName = preventConstructorNames[0];
249+
250+
preventConstructor.injections = [
251+
hit,
252+
toRegExp,
253+
logMessage,
254+
noopFunc,
255+
];

src/scriptlets/scriptlets-list.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export { trustedReplaceOutboundText } from './trusted-replace-outbound-text';
7676
export { preventCanvas } from './prevent-canvas';
7777
export { trustedReplaceArgument } from './trusted-replace-argument';
7878
export { preventInnerHTML } from './prevent-innerHTML';
79+
export { preventConstructor } from './prevent-constructor';
7980
// redirects as scriptlets
8081
// https://github.com/AdguardTeam/Scriptlets/issues/300
8182
export { AmazonApstag } from './amazon-apstag';

src/scriptlets/scriptlets-names-list.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export { trustedReplaceOutboundTextNames } from './trusted-replace-outbound-text
7676
export { preventCanvasNames } from './prevent-canvas';
7777
export { trustedReplaceArgumentNames } from './trusted-replace-argument';
7878
export { preventInnerHTMLNames } from './prevent-innerHTML';
79+
export { preventConstructorNames } from './prevent-constructor';
7980
// redirects as scriptlets
8081
// https://github.com/AdguardTeam/Scriptlets/issues/300
8182
export { AmazonApstagNames } from './amazon-apstag';

0 commit comments

Comments
 (0)