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