Skip to content

Commit b378dd4

Browse files
committed
improve prevent-xhr - add mock getResponseHeader() and getAllResponseHeaders() methods
1 parent e60fdd1 commit b378dd4

File tree

2 files changed

+193
-36
lines changed

2 files changed

+193
-36
lines changed

src/scriptlets/prevent-xhr.js

Lines changed: 141 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
objectToString,
44
generateRandomResponse,
55
matchRequestProps,
6+
getXhrData,
67
logMessage,
78
// following helpers should be imported and injected
89
// because they are used by helpers above
@@ -95,16 +96,18 @@ export function preventXHR(source, propsToMatch, customResponseText) {
9596
return;
9697
}
9798

98-
let response = '';
99-
let responseText = '';
100-
let responseUrl;
99+
const nativeOpen = window.XMLHttpRequest.prototype.open;
100+
const nativeSend = window.XMLHttpRequest.prototype.send;
101+
102+
let xhrData;
103+
let modifiedResponse = '';
104+
let modifiedResponseText = '';
105+
101106
const openWrapper = (target, thisArg, args) => {
102-
// Get method and url from .open()
103-
const xhrData = {
104-
method: args[0],
105-
url: args[1],
106-
};
107-
responseUrl = xhrData.url;
107+
// Get original request properties
108+
// eslint-disable-next-line prefer-spread
109+
xhrData = getXhrData.apply(null, args);
110+
108111
if (typeof propsToMatch === 'undefined') {
109112
// Log if no propsToMatch given
110113
logMessage(source, `xhr( ${objectToString(xhrData)} )`, true);
@@ -113,6 +116,22 @@ export function preventXHR(source, propsToMatch, customResponseText) {
113116
thisArg.shouldBePrevented = true;
114117
}
115118

119+
// Trap setRequestHeader of target xhr object to mimic request headers later;
120+
// needed for getResponseHeader() and getAllResponseHeaders() methods
121+
if (thisArg.shouldBePrevented) {
122+
thisArg.collectedHeaders = [];
123+
const setRequestHeaderWrapper = (target, thisArg, args) => {
124+
// Collect headers
125+
thisArg.collectedHeaders.push(args);
126+
return Reflect.apply(target, thisArg, args);
127+
};
128+
const setRequestHeaderHandler = {
129+
apply: setRequestHeaderWrapper,
130+
};
131+
// setRequestHeader() can only be called on xhr.open(),
132+
// so we can safely proxy it here
133+
thisArg.setRequestHeader = new Proxy(thisArg.setRequestHeader, setRequestHeaderHandler);
134+
}
116135
return Reflect.apply(target, thisArg, args);
117136
};
118137

@@ -122,57 +141,143 @@ export function preventXHR(source, propsToMatch, customResponseText) {
122141
}
123142

124143
if (thisArg.responseType === 'blob') {
125-
response = new Blob();
144+
modifiedResponse = new Blob();
126145
}
127-
128146
if (thisArg.responseType === 'arraybuffer') {
129-
response = new ArrayBuffer();
147+
modifiedResponse = new ArrayBuffer();
130148
}
131149

132150
if (customResponseText) {
133151
const randomText = generateRandomResponse(customResponseText);
134152
if (randomText) {
135-
responseText = randomText;
153+
modifiedResponseText = randomText;
136154
} else {
155+
// FIXME: improve error text
137156
logMessage(source, `Invalid range: ${customResponseText}`);
138157
}
139158
}
140-
// Mock response object
141-
Object.defineProperties(thisArg, {
142-
readyState: { value: 4, writable: false },
143-
response: { value: response, writable: false },
144-
responseText: { value: responseText, writable: false },
145-
responseURL: { value: responseUrl, writable: false },
146-
responseXML: { value: '', writable: false },
147-
status: { value: 200, writable: false },
148-
statusText: { value: 'OK', writable: false },
159+
160+
/**
161+
* Create separate XHR request with original request's input
162+
* to be able to collect response data without triggering
163+
* listeners on original XHR object
164+
*/
165+
const forgedRequest = new XMLHttpRequest();
166+
forgedRequest.addEventListener('readystatechange', () => {
167+
if (forgedRequest.readyState !== 4) {
168+
return;
169+
}
170+
171+
const {
172+
readyState,
173+
responseURL,
174+
responseXML,
175+
status,
176+
statusText,
177+
} = forgedRequest;
178+
179+
// Mock response object
180+
Object.defineProperties(thisArg, {
181+
// original values
182+
readyState: { value: readyState, writable: false },
183+
status: { value: status, writable: false },
184+
statusText: { value: statusText, writable: false },
185+
responseURL: { value: responseURL, writable: false },
186+
responseXML: { value: responseXML, writable: false },
187+
// modified values
188+
response: { value: modifiedResponse, writable: false },
189+
responseText: { value: modifiedResponseText, writable: false },
190+
});
191+
192+
// Mock events
193+
setTimeout(() => {
194+
const stateEvent = new Event('readystatechange');
195+
thisArg.dispatchEvent(stateEvent);
196+
197+
const loadEvent = new Event('load');
198+
thisArg.dispatchEvent(loadEvent);
199+
200+
const loadEndEvent = new Event('loadend');
201+
thisArg.dispatchEvent(loadEndEvent);
202+
}, 1);
203+
204+
hit(source);
149205
});
150-
// Mock events
151-
setTimeout(() => {
152-
const stateEvent = new Event('readystatechange');
153-
thisArg.dispatchEvent(stateEvent);
154206

155-
const loadEvent = new Event('load');
156-
thisArg.dispatchEvent(loadEvent);
207+
nativeOpen.apply(forgedRequest, [xhrData.method, xhrData.url]);
208+
209+
// Mimic request headers before sending
210+
// setRequestHeader can only be called on open request objects
211+
thisArg.collectedHeaders.forEach((header) => {
212+
const name = header[0];
213+
const value = header[1];
214+
forgedRequest.setRequestHeader(name, value);
215+
});
157216

158-
const loadEndEvent = new Event('loadend');
159-
thisArg.dispatchEvent(loadEndEvent);
160-
}, 1);
217+
try {
218+
nativeSend.call(forgedRequest, args);
219+
} catch {
220+
return Reflect.apply(target, thisArg, args);
221+
}
161222

162-
hit(source);
163223
return undefined;
164224
};
165225

226+
const getHeaderWrapper = (target, thisArg, args) => {
227+
if (!thisArg.collectedHeaders.length) {
228+
return null;
229+
}
230+
// The search for the header name is case-insensitive
231+
// https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/getResponseHeader
232+
const searchHeaderName = args[0].toLowerCase();
233+
const matchedHeader = thisArg.collectedHeaders.find((header) => {
234+
const headerName = header[0].toLowerCase();
235+
return headerName === searchHeaderName;
236+
});
237+
return matchedHeader
238+
? matchedHeader[1]
239+
: null;
240+
};
241+
242+
const getAllHeadersWrapper = (target, thisArg) => {
243+
if (!thisArg.collectedHeaders.length) {
244+
return '';
245+
}
246+
const allHeadersStr = thisArg.collectedHeaders
247+
.map((header) => {
248+
const headerName = header[0];
249+
const headerValue = header[1];
250+
// In modern browsers, the header names are returned in all lower case, as per the latest spec.
251+
// https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/getAllResponseHeaders
252+
return `${headerName.toLowerCase()}: ${headerValue}`;
253+
})
254+
.join('\r\n');
255+
return allHeadersStr;
256+
};
257+
166258
const openHandler = {
167259
apply: openWrapper,
168260
};
169-
170261
const sendHandler = {
171262
apply: sendWrapper,
172263
};
264+
const getHeaderHandler = {
265+
apply: getHeaderWrapper,
266+
};
267+
const getAllHeadersHandler = {
268+
apply: getAllHeadersWrapper,
269+
};
173270

174271
XMLHttpRequest.prototype.open = new Proxy(XMLHttpRequest.prototype.open, openHandler);
175272
XMLHttpRequest.prototype.send = new Proxy(XMLHttpRequest.prototype.send, sendHandler);
273+
XMLHttpRequest.prototype.getResponseHeader = new Proxy(
274+
XMLHttpRequest.prototype.getResponseHeader,
275+
getHeaderHandler,
276+
);
277+
XMLHttpRequest.prototype.getAllResponseHeaders = new Proxy(
278+
XMLHttpRequest.prototype.getAllResponseHeaders,
279+
getAllHeadersHandler,
280+
);
176281
}
177282

178283
preventXHR.names = [
@@ -185,10 +290,11 @@ preventXHR.names = [
185290

186291
preventXHR.injections = [
187292
hit,
188-
logMessage,
189293
objectToString,
190-
matchRequestProps,
191294
generateRandomResponse,
295+
matchRequestProps,
296+
getXhrData,
297+
logMessage,
192298
toRegExp,
193299
isValidStrPattern,
194300
escapeRegExp,

tests/scriptlets/prevent-xhr.test.js

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ if (isSupported) {
5757
if (input.indexOf('trace') > -1) {
5858
return;
5959
}
60-
const EXPECTED_LOG_STR = `${name}: xhr( method:"${METHOD}" url:"${URL}" )`;
60+
// eslint-disable-next-line max-len
61+
const EXPECTED_LOG_STR = `${name}: xhr( method:"${METHOD}" url:"${URL}" async:"undefined" user:"undefined" password:"undefined" )`;
6162
assert.ok(startsWith(input, EXPECTED_LOG_STR), 'console.hit input');
6263
};
6364

@@ -96,6 +97,56 @@ if (isSupported) {
9697
xhr.send();
9798
});
9899

100+
test('Empty arg to prevent all, check getResponseHeader() and getAllResponseHeaders() methods', async (assert) => {
101+
const METHOD = 'GET';
102+
const URL = `${FETCH_OBJECTS_PATH}/test01.json`;
103+
const MATCH_DATA = [''];
104+
const HEADER_NAME_1 = 'Test-Type';
105+
const HEADER_VALUE_1 = 'application/json';
106+
const HEADER_NAME_2 = 'Test-Length';
107+
const HEADER_VALUE_2 = '12345';
108+
const ABSENT_HEADER_NAME = 'Test-Absent';
109+
110+
runScriptlet(name, MATCH_DATA);
111+
112+
const done = assert.async();
113+
114+
const xhr = new XMLHttpRequest();
115+
xhr.open(METHOD, URL);
116+
xhr.setRequestHeader(HEADER_NAME_1, HEADER_VALUE_1);
117+
xhr.setRequestHeader(HEADER_NAME_2, HEADER_VALUE_2);
118+
119+
xhr.onload = () => {
120+
assert.strictEqual(xhr.readyState, 4, 'Response done');
121+
assert.strictEqual(xhr.response, '', 'Response data mocked');
122+
assert.strictEqual(window.hit, 'FIRED', 'hit function fired');
123+
done();
124+
};
125+
xhr.send();
126+
127+
assert.strictEqual(
128+
xhr.getResponseHeader(HEADER_NAME_1),
129+
HEADER_VALUE_1,
130+
'getResponseHeader() is mocked, value 1 returned',
131+
);
132+
assert.strictEqual(
133+
xhr.getResponseHeader(HEADER_NAME_2),
134+
HEADER_VALUE_2,
135+
'getResponseHeader() is mocked',
136+
);
137+
assert.strictEqual(
138+
xhr.getResponseHeader(ABSENT_HEADER_NAME),
139+
null,
140+
'getResponseHeader() is mocked, null returned for non-existent header',
141+
);
142+
143+
const expectedAllHeaders = [
144+
`${HEADER_NAME_1.toLowerCase()}: ${HEADER_VALUE_1}`,
145+
`${HEADER_NAME_2.toLowerCase()}: ${HEADER_VALUE_2}`,
146+
].join('\r\n');
147+
assert.strictEqual(xhr.getAllResponseHeaders(), expectedAllHeaders, 'getAllResponseHeaders() is mocked');
148+
});
149+
99150
test('Empty arg, prevent all, randomize response text', async (assert) => {
100151
const METHOD = 'GET';
101152
const URL = `${FETCH_OBJECTS_PATH}/test01.json`;

0 commit comments

Comments
 (0)