Skip to content

Commit 95d53f9

Browse files
committed
fix multiple response-related scriptlets. #386 #486
AG-28507, AG-40909 Squashed commit of the following: commit 62c6ef2 Merge: 5a95425 056c175 Author: slvvko <v.leleka@adguard.com> Date: Fri Jan 30 09:53:51 2026 -0500 merge parent branch into current one, resolve conflicts commit 5a95425 Author: slvvko <v.leleka@adguard.com> Date: Fri Jan 30 09:53:00 2026 -0500 prevent XHR scriptlet bypass via thisArg properties commit f14d75d Author: slvvko <v.leleka@adguard.com> Date: Thu Jan 29 19:28:28 2026 -0500 fix changelog commit c3447af Author: slvvko <v.leleka@adguard.com> Date: Thu Jan 29 19:26:47 2026 -0500 fix changelog commit ee4806c Author: slvvko <v.leleka@adguard.com> Date: Thu Jan 29 19:26:05 2026 -0500 fix xml-prune commit da017f7 Author: slvvko <v.leleka@adguard.com> Date: Thu Jan 29 19:25:51 2026 -0500 fix trusted-replace-xhr-response commit 6107e46 Author: slvvko <v.leleka@adguard.com> Date: Thu Jan 29 19:25:00 2026 -0500 fix trusted-replace-fetch-response
1 parent 056c175 commit 95d53f9

9 files changed

Lines changed: 357 additions & 49 deletions

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,22 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic
2323

2424
- Anti-adblock detection in `spoof-css` scriptlet
2525
by using cloaked bound functions instead of Proxies [#422].
26+
- Response corruption in `trusted-replace-fetch-response` and `trusted-replace-xhr-response`
27+
scriptlets when URL pattern matches but content pattern does not [#486].
28+
- XHR handling in `trusted-replace-xhr-response` and `xml-prune` scriptlets:
29+
- added `withCredentials` to forged requests,
30+
- fixed duplicate headers when multiple scriptlets are used,
31+
- used `ProgressEvent` for `load` and `loadend` events [#486].
32+
- XHR scriptlet bypass vulnerability in `trusted-replace-xhr-response`,
33+
`prevent-xhr`, and `xml-prune` scriptlets where
34+
setting `xhr.shouldBePrevented = false` could disable the scriptlet [#386].
2635

2736
[Unreleased]: https://github.com/AdguardTeam/Scriptlets/compare/v2.2.15...HEAD
2837
[#329]: https://github.com/AdguardTeam/Scriptlets/issues/329
38+
[#386]: https://github.com/AdguardTeam/Scriptlets/issues/386
2939
[#422]: https://github.com/AdguardTeam/Scriptlets/issues/422
3040
[#461]: https://github.com/AdguardTeam/Scriptlets/issues/461
41+
[#486]: https://github.com/AdguardTeam/Scriptlets/issues/486
3142

3243
## [v2.2.15] - 2026-01-22
3344

src/scriptlets/prevent-xhr.js

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ export function preventXHR(source, propsToMatch, customResponseText) {
112112
const nativeGetResponseHeader = window.XMLHttpRequest.prototype.getResponseHeader;
113113
const nativeGetAllResponseHeaders = window.XMLHttpRequest.prototype.getAllResponseHeaders;
114114

115+
// Store matched XHR requests and their data in private structures
116+
// to prevent bypass via thisArg property manipulation
117+
// https://github.com/AdguardTeam/Scriptlets/issues/386
118+
const matchedXhrRequests = new Map();
119+
const xhrRequestHeaders = new Map();
120+
115121
let xhrData;
116122
let modifiedResponse = '';
117123
let modifiedResponseText = '';
@@ -126,19 +132,21 @@ export function preventXHR(source, propsToMatch, customResponseText) {
126132
logMessage(source, `xhr( ${objectToString(xhrData)} )`, true);
127133
hit(source);
128134
} else if (matchRequestProps(source, propsToMatch, xhrData)) {
129-
thisArg.shouldBePrevented = true;
130-
// Add xhrData to thisArg to keep original values in case of multiple requests
135+
// Store xhrData in map to keep original values in case of multiple requests
131136
// https://github.com/AdguardTeam/Scriptlets/issues/347
132-
thisArg.xhrData = xhrData;
137+
matchedXhrRequests.set(thisArg, xhrData);
133138
}
134139

135140
// Trap setRequestHeader of target xhr object to mimic request headers later;
136141
// needed for getResponseHeader() and getAllResponseHeaders() methods
137-
if (thisArg.shouldBePrevented) {
138-
thisArg.collectedHeaders = [];
142+
if (matchedXhrRequests.has(thisArg) && !xhrRequestHeaders.has(thisArg)) {
143+
xhrRequestHeaders.set(thisArg, []);
139144
const setRequestHeaderWrapper = (target, thisArg, args) => {
140145
// Collect headers
141-
thisArg.collectedHeaders.push(args);
146+
const headers = xhrRequestHeaders.get(thisArg);
147+
if (headers) {
148+
headers.push(args);
149+
}
142150
return Reflect.apply(target, thisArg, args);
143151
};
144152
const setRequestHeaderHandler = {
@@ -152,10 +160,12 @@ export function preventXHR(source, propsToMatch, customResponseText) {
152160
};
153161

154162
const sendWrapper = (target, thisArg, args) => {
155-
if (!thisArg.shouldBePrevented) {
163+
if (!matchedXhrRequests.has(thisArg)) {
156164
return Reflect.apply(target, thisArg, args);
157165
}
158166

167+
const storedXhrData = matchedXhrRequests.get(thisArg);
168+
159169
if (thisArg.responseType === 'blob') {
160170
modifiedResponse = new Blob();
161171
}
@@ -198,7 +208,7 @@ export function preventXHR(source, propsToMatch, customResponseText) {
198208
Object.defineProperties(thisArg, {
199209
readyState: { value: 4, writable: false },
200210
statusText: { value: 'OK', writable: false },
201-
responseURL: { value: responseURL || thisArg.xhrData.url, writable: false },
211+
responseURL: { value: responseURL || storedXhrData.url, writable: false },
202212
responseXML: { value: responseXML, writable: false },
203213
status: { value: 200, writable: false },
204214
response: { value: modifiedResponse, writable: false },
@@ -237,15 +247,18 @@ export function preventXHR(source, propsToMatch, customResponseText) {
237247
thisArg.dispatchEvent(loadEndEvent);
238248
}, 1);
239249

240-
nativeOpen.apply(forgedRequest, [thisArg.xhrData.method, thisArg.xhrData.url]);
250+
nativeOpen.apply(forgedRequest, [storedXhrData.method, storedXhrData.url]);
241251

242252
// Mimic request headers before sending
243253
// setRequestHeader can only be called on open request objects
244-
thisArg.collectedHeaders.forEach((header) => {
254+
const collectedHeaders = xhrRequestHeaders.get(thisArg) || [];
255+
collectedHeaders.forEach((header) => {
245256
const name = header[0];
246257
const value = header[1];
247258
forgedRequest.setRequestHeader(name, value);
248259
});
260+
// Note: We do NOT delete from xhrRequestHeaders here because
261+
// getResponseHeader() and getAllResponseHeaders() need access to the headers later
249262

250263
return undefined;
251264
};
@@ -260,16 +273,17 @@ export function preventXHR(source, propsToMatch, customResponseText) {
260273
* @returns {string|null} Header value or null if header is not set.
261274
*/
262275
const getHeaderWrapper = (target, thisArg, args) => {
263-
if (!thisArg.shouldBePrevented) {
276+
const collectedHeaders = xhrRequestHeaders.get(thisArg);
277+
if (!collectedHeaders) {
264278
return nativeGetResponseHeader.apply(thisArg, args);
265279
}
266-
if (!thisArg.collectedHeaders.length) {
280+
if (!collectedHeaders.length) {
267281
return null;
268282
}
269283
// The search for the header name is case-insensitive
270284
// https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/getResponseHeader
271285
const searchHeaderName = args[0].toLowerCase();
272-
const matchedHeader = thisArg.collectedHeaders.find((header) => {
286+
const matchedHeader = collectedHeaders.find((header) => {
273287
const headerName = header[0].toLowerCase();
274288
return headerName === searchHeaderName;
275289
});
@@ -287,13 +301,14 @@ export function preventXHR(source, propsToMatch, customResponseText) {
287301
* @returns {string} All headers as a string. For no headers an empty string is returned.
288302
*/
289303
const getAllHeadersWrapper = (target, thisArg) => {
290-
if (!thisArg.shouldBePrevented) {
304+
const collectedHeaders = xhrRequestHeaders.get(thisArg);
305+
if (!collectedHeaders) {
291306
return nativeGetAllResponseHeaders.call(thisArg);
292307
}
293-
if (!thisArg.collectedHeaders.length) {
308+
if (!collectedHeaders.length) {
294309
return '';
295310
}
296-
const allHeadersStr = thisArg.collectedHeaders
311+
const allHeadersStr = collectedHeaders
297312
.map((header) => {
298313
/**
299314
* TODO: array destructuring may be used here

src/scriptlets/trusted-replace-fetch-response.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,12 +160,17 @@ export function trustedReplaceFetchResponse(
160160
// eslint-disable-next-line prefer-spread
161161
return nativeFetch.apply(null, args)
162162
.then((response) => {
163-
return response.text()
163+
return response.clone().text()
164164
.then((bodyText) => {
165165
const patternRegexp = pattern === '*'
166166
? /(\n|.)*/
167167
: toRegExp(pattern);
168168

169+
const isPatternFound = pattern === '*' || patternRegexp.test(bodyText);
170+
if (!isPatternFound) {
171+
return response;
172+
}
173+
169174
if (shouldLogContent) {
170175
logMessage(source, `Original text content: ${bodyText}`);
171176
}
@@ -183,7 +188,7 @@ export function trustedReplaceFetchResponse(
183188
const fetchDataStr = objectToString(fetchData);
184189
const message = `Response body can't be converted to text: ${fetchDataStr}`;
185190
logMessage(source, message);
186-
return Reflect.apply(target, thisArg, args);
191+
return response;
187192
});
188193
})
189194
.catch(() => Reflect.apply(target, thisArg, args));

src/scriptlets/trusted-replace-xhr-response.js

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,12 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = ''
124124
const nativeOpen = window.XMLHttpRequest.prototype.open;
125125
const nativeSend = window.XMLHttpRequest.prototype.send;
126126

127+
// Store matched XHR requests and their data in private structures
128+
// to prevent bypass via thisArg property manipulation
129+
// https://github.com/AdguardTeam/Scriptlets/issues/386
130+
const matchedXhrRequests = new Set();
131+
const xhrRequestHeaders = new Map();
132+
127133
let xhrData;
128134

129135
const openWrapper = (target, thisArg, args) => {
@@ -139,17 +145,18 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = ''
139145
}
140146

141147
if (matchRequestProps(source, propsToMatch, xhrData)) {
142-
thisArg.shouldBePrevented = true;
143-
thisArg.headersReceived = !!thisArg.headersReceived;
148+
matchedXhrRequests.add(thisArg);
144149
}
145150

146151
// Trap setRequestHeader of target xhr object to mimic request headers later
147-
if (thisArg.shouldBePrevented && !thisArg.headersReceived) {
148-
thisArg.headersReceived = true;
149-
thisArg.collectedHeaders = [];
152+
if (matchedXhrRequests.has(thisArg) && !xhrRequestHeaders.has(thisArg)) {
153+
xhrRequestHeaders.set(thisArg, []);
150154
const setRequestHeaderWrapper = (target, thisArg, args) => {
151155
// Collect headers
152-
thisArg.collectedHeaders.push(args);
156+
const headers = xhrRequestHeaders.get(thisArg);
157+
if (headers) {
158+
headers.push(args);
159+
}
153160
return Reflect.apply(target, thisArg, args);
154161
};
155162

@@ -166,7 +173,7 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = ''
166173
};
167174

168175
const sendWrapper = (target, thisArg, args) => {
169-
if (!thisArg.shouldBePrevented) {
176+
if (!matchedXhrRequests.has(thisArg)) {
170177
return Reflect.apply(target, thisArg, args);
171178
}
172179

@@ -176,6 +183,7 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = ''
176183
* listeners on original XHR object
177184
*/
178185
const forgedRequest = new XMLHttpRequest();
186+
forgedRequest.withCredentials = thisArg.withCredentials;
179187
forgedRequest.addEventListener('readystatechange', () => {
180188
if (forgedRequest.readyState !== 4) {
181189
return;
@@ -201,12 +209,17 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = ''
201209
? /(\n|.)*/
202210
: toRegExp(pattern);
203211

204-
if (shouldLogContent) {
205-
logMessage(source, `Original text content: ${content}`);
206-
}
207-
const modifiedContent = content.replace(patternRegexp, replacement);
208-
if (shouldLogContent) {
209-
logMessage(source, `Modified text content: ${modifiedContent}`);
212+
const isPatternFound = pattern === '*' || patternRegexp.test(content);
213+
214+
let responseContent = content;
215+
if (isPatternFound) {
216+
if (shouldLogContent) {
217+
logMessage(source, `Original text content: ${content}`);
218+
}
219+
responseContent = content.replace(patternRegexp, replacement);
220+
if (shouldLogContent) {
221+
logMessage(source, `Modified text content: ${responseContent}`);
222+
}
210223
}
211224

212225
// Manually put required values into target XHR object
@@ -218,37 +231,41 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = ''
218231
responseXML: { value: responseXML, writable: false },
219232
status: { value: status, writable: false },
220233
statusText: { value: statusText, writable: false },
221-
// modified values
222-
response: { value: modifiedContent, writable: false },
223-
responseText: { value: modifiedContent, writable: false },
234+
// modified values only if pattern matched, otherwise original
235+
response: { value: responseContent, writable: false },
236+
responseText: { value: responseContent, writable: false },
224237
});
225238

226239
// Mock events
227240
setTimeout(() => {
228241
const stateEvent = new Event('readystatechange');
229242
thisArg.dispatchEvent(stateEvent);
230243

231-
const loadEvent = new Event('load');
244+
const loadEvent = new ProgressEvent('load');
232245
thisArg.dispatchEvent(loadEvent);
233246

234-
const loadEndEvent = new Event('loadend');
247+
const loadEndEvent = new ProgressEvent('loadend');
235248
thisArg.dispatchEvent(loadEndEvent);
236249
}, 1);
237250

238-
hit(source);
251+
if (isPatternFound) {
252+
hit(source);
253+
}
239254
});
240255

241256
nativeOpen.apply(forgedRequest, [xhrData.method, xhrData.url]);
242257

243258
// Mimic request headers before sending
244259
// setRequestHeader can only be called on open request objects
245-
thisArg.collectedHeaders.forEach((header) => {
260+
const collectedHeaders = xhrRequestHeaders.get(thisArg) || [];
261+
collectedHeaders.forEach((header) => {
246262
const name = header[0];
247263
const value = header[1];
248264

249265
forgedRequest.setRequestHeader(name, value);
250266
});
251-
thisArg.collectedHeaders = [];
267+
xhrRequestHeaders.delete(thisArg);
268+
matchedXhrRequests.delete(thisArg);
252269

253270
try {
254271
nativeSend.call(forgedRequest, args);

src/scriptlets/xml-prune.js

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -294,22 +294,31 @@ export function xmlPrune(source, propsToRemove, optionalProp = '', urlToMatch =
294294
const nativeOpen = window.XMLHttpRequest.prototype.open;
295295
const nativeSend = window.XMLHttpRequest.prototype.send;
296296

297+
// Store matched XHR requests and their data in private structures
298+
// to prevent bypass via thisArg property manipulation
299+
// https://github.com/AdguardTeam/Scriptlets/issues/386
300+
const matchedXhrRequests = new Set();
301+
const xhrRequestHeaders = new Map();
302+
297303
let xhrData;
298304

299305
const openWrapper = (target, thisArg, args) => {
300306
// eslint-disable-next-line prefer-spread
301307
xhrData = getXhrData.apply(null, args);
302308

303309
if (matchRequestProps(source, urlToMatch, xhrData)) {
304-
thisArg.shouldBePruned = true;
310+
matchedXhrRequests.add(thisArg);
305311
}
306312

307313
// Trap setRequestHeader of target xhr object to mimic request headers later
308-
if (thisArg.shouldBePruned) {
309-
thisArg.collectedHeaders = [];
314+
if (matchedXhrRequests.has(thisArg) && !xhrRequestHeaders.has(thisArg)) {
315+
xhrRequestHeaders.set(thisArg, []);
310316
const setRequestHeaderWrapper = (target, thisArg, args) => {
311317
// Collect headers
312-
thisArg.collectedHeaders.push(args);
318+
const headers = xhrRequestHeaders.get(thisArg);
319+
if (headers) {
320+
headers.push(args);
321+
}
313322
return Reflect.apply(target, thisArg, args);
314323
};
315324

@@ -329,7 +338,10 @@ export function xmlPrune(source, propsToRemove, optionalProp = '', urlToMatch =
329338
const allowedResponseTypeValues = ['', 'text'];
330339
// Do nothing if request do not match
331340
// or response type is not a string
332-
if (!thisArg.shouldBePruned || !allowedResponseTypeValues.includes(thisArg.responseType)) {
341+
if (
342+
!matchedXhrRequests.has(thisArg)
343+
|| !allowedResponseTypeValues.includes(thisArg.responseType)
344+
) {
333345
return Reflect.apply(target, thisArg, args);
334346
}
335347

@@ -339,6 +351,7 @@ export function xmlPrune(source, propsToRemove, optionalProp = '', urlToMatch =
339351
* listeners on original XHR object
340352
*/
341353
const forgedRequest = new XMLHttpRequest();
354+
forgedRequest.withCredentials = thisArg.withCredentials;
342355
forgedRequest.addEventListener('readystatechange', () => {
343356
if (forgedRequest.readyState !== 4) {
344357
return;
@@ -389,26 +402,31 @@ export function xmlPrune(source, propsToRemove, optionalProp = '', urlToMatch =
389402
const stateEvent = new Event('readystatechange');
390403
thisArg.dispatchEvent(stateEvent);
391404

392-
const loadEvent = new Event('load');
405+
const loadEvent = new ProgressEvent('load');
393406
thisArg.dispatchEvent(loadEvent);
394407

395-
const loadEndEvent = new Event('loadend');
408+
const loadEndEvent = new ProgressEvent('loadend');
396409
thisArg.dispatchEvent(loadEndEvent);
397410
}, 1);
398-
hit(source);
411+
412+
if (shouldPruneResponse) {
413+
hit(source);
414+
}
399415
});
400416

401417
nativeOpen.apply(forgedRequest, [xhrData.method, xhrData.url]);
402418

403419
// Mimic request headers before sending
404420
// setRequestHeader can only be called on open request objects
405-
thisArg.collectedHeaders.forEach((header) => {
421+
const collectedHeaders = xhrRequestHeaders.get(thisArg) || [];
422+
collectedHeaders.forEach((header) => {
406423
const name = header[0];
407424
const value = header[1];
408425

409426
forgedRequest.setRequestHeader(name, value);
410427
});
411-
thisArg.collectedHeaders = [];
428+
xhrRequestHeaders.delete(thisArg);
429+
matchedXhrRequests.delete(thisArg);
412430

413431
try {
414432
nativeSend.call(forgedRequest, args);

0 commit comments

Comments
 (0)