Skip to content

Commit 4839c90

Browse files
committed
AG-31672 Fix 'trusted-replace-xhr-response' — modifying XHR response without changes causes breakage. #419
Squashed commit of the following: commit eab794b Author: Adam Wróblewski <adam@adguard.com> Date: Thu Jun 18 15:30:28 2026 +0200 Fix empty response headers on intercepted XHR in trusted-replace-xhr-response
1 parent 49c55d4 commit 4839c90

3 files changed

Lines changed: 70 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic
2828

2929
### Fixed
3030

31+
- `trusted-replace-xhr-response` response headers (`getResponseHeader`, `getAllResponseHeaders`)
32+
now return the correct values on intercepted XHR objects [#419].
3133
- Issue with `Maximum call stack size exceeded` error when
3234
`trusted-json-set` or `trusted-replace-argument` is called with method used inside scriptlet [#565].
3335
- Infinite loop in `getRecursiveCandidates` in `json-path-utils` [#563].
@@ -41,6 +43,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic
4143
[#560]: https://github.com/AdguardTeam/Scriptlets/issues/560
4244
[#529]: https://github.com/AdguardTeam/Scriptlets/issues/529
4345
[#467]: https://github.com/AdguardTeam/Scriptlets/issues/467
46+
[#419]: https://github.com/AdguardTeam/Scriptlets/issues/419
4447
[#292]: https://github.com/AdguardTeam/Scriptlets/issues/292
4548

4649
## [2.4.2] - 2026-04-24

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,10 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = ''
129129
// https://github.com/AdguardTeam/Scriptlets/issues/386
130130
const matchedXhrRequests = new Set();
131131
const xhrRequestHeaders = new Map();
132+
// Maps a matched XHR object to its forged request, which holds the real
133+
// response headers — needed because the original XHR is never sent.
134+
// https://github.com/AdguardTeam/Scriptlets/issues/419
135+
const forgedRequests = new WeakMap();
132136

133137
let xhrData;
134138

@@ -167,6 +171,25 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = ''
167171
// setRequestHeader can only be called on open xhr object,
168172
// so we can safely proxy it here
169173
thisArg.setRequestHeader = new Proxy(thisArg.setRequestHeader, setRequestHeaderHandler);
174+
175+
// The original XHR object is never actually sent — the forged request is —
176+
// so its response headers would be empty. Proxy the header getters of the
177+
// target xhr object to return the real headers from the forged request.
178+
// Proxying (instead of reassigning) keeps `toString()` reporting native code.
179+
const responseHeaderWrapper = (target, thisArg, args) => {
180+
const forgedRequest = forgedRequests.get(thisArg);
181+
if (forgedRequest) {
182+
return Reflect.apply(target, forgedRequest, args);
183+
}
184+
return Reflect.apply(target, thisArg, args);
185+
};
186+
187+
const responseHeaderHandler = {
188+
apply: responseHeaderWrapper,
189+
};
190+
191+
thisArg.getResponseHeader = new Proxy(thisArg.getResponseHeader, responseHeaderHandler);
192+
thisArg.getAllResponseHeaders = new Proxy(thisArg.getAllResponseHeaders, responseHeaderHandler);
170193
}
171194

172195
return Reflect.apply(target, thisArg, args);
@@ -236,6 +259,11 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = ''
236259
responseText: { value: responseContent, writable: false },
237260
});
238261

262+
// The original XHR object is never actually sent — the forged request is —
263+
// so its response headers would be empty. Keep a reference to the forged
264+
// request so the proxied header getters can return the real headers.
265+
forgedRequests.set(thisArg, forgedRequest);
266+
239267
// Mock events
240268
setTimeout(() => {
241269
const stateEvent = new Event('readystatechange');

tests/scriptlets/trusted-replace-xhr-response.test.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,45 @@ if (isSupported) {
418418
xhr.send();
419419
});
420420

421+
test('Matched, response headers are available on modified XHR', async (assert) => {
422+
const METHOD = 'GET';
423+
const URL = `${FETCH_OBJECTS_PATH}/test01.json`;
424+
const PATTERN = 'a1';
425+
const REPLACEMENT = 'z9';
426+
const MATCH_DATA = [PATTERN, REPLACEMENT, `${URL} method:${METHOD}`];
427+
428+
runScriptlet(name, MATCH_DATA);
429+
430+
const done = assert.async();
431+
432+
const xhr = new XMLHttpRequest();
433+
xhr.open(METHOD, URL);
434+
xhr.onload = () => {
435+
assert.strictEqual(xhr.readyState, 4, 'Response done');
436+
assert.notOk(xhr.response.includes(PATTERN), 'Response has been modified');
437+
438+
const allHeaders = xhr.getAllResponseHeaders();
439+
assert.ok(typeof allHeaders === 'string' && allHeaders.length > 0, 'getAllResponseHeaders is not empty');
440+
assert.ok(
441+
allHeaders.toLowerCase().includes('content-type'),
442+
'getAllResponseHeaders contains content-type header',
443+
);
444+
assert.ok(
445+
xhr.getResponseHeader('Content-Type')?.includes('application/json'),
446+
'getResponseHeader returns the correct content-type value',
447+
);
448+
assert.strictEqual(
449+
xhr.getResponseHeader('non-existent-header'),
450+
null,
451+
'getResponseHeader returns null for missing header',
452+
);
453+
454+
assert.strictEqual(window.hit, 'FIRED', 'hit function fired');
455+
done();
456+
};
457+
xhr.send();
458+
});
459+
421460
// bypass prevention: https://github.com/AdguardTeam/Scriptlets/issues/386
422461
test('Cannot be bypassed by setting shouldBePrevented = false on XHR object', async (assert) => {
423462
const METHOD = 'GET';

0 commit comments

Comments
 (0)