Skip to content

Commit

Permalink
Add json-prune-xhr-response scriptlet
Browse files Browse the repository at this point in the history
  • Loading branch information
AdamWr committed Mar 15, 2024
1 parent 7855415 commit ea7eb36
Show file tree
Hide file tree
Showing 6 changed files with 830 additions and 0 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic
### Added

- `json-prune-fetch-response` scriptlet [#361]
- `json-prune-xhr-response` scriptlet [#360]
- `href-sanitizer` scriptlet [#327]
- `no-protected-audience` scriptlet [#395]
- multiple redirects can be used as scriptlets [#300]:
Expand Down Expand Up @@ -50,6 +51,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic
[#395]: https://github.com/AdguardTeam/Scriptlets/issues/395
[#377]: https://github.com/AdguardTeam/Scriptlets/issues/377
[#361]: https://github.com/AdguardTeam/Scriptlets/issues/361
[#360]: https://github.com/AdguardTeam/Scriptlets/issues/360
[#327]: https://github.com/AdguardTeam/Scriptlets/issues/327
[#300]: https://github.com/AdguardTeam/Scriptlets/issues/300

Expand Down
1 change: 1 addition & 0 deletions scripts/compatibility-table.json
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@
"ubo": "json-prune-fetch-response.js"
},
{
"adg": "json-prune-xhr-response",
"ubo": "json-prune-xhr-response.js"
},
{
Expand Down
4 changes: 4 additions & 0 deletions src/helpers/request-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ type SharedRequestData<T> = {
[key in LegalRequestProp]?: T;
};

export interface XMLHttpRequestSharedRequestData<T> extends SharedRequestData<T> {
async: boolean | string;
}

/**
* Object which is populated with request data from scriptlet arguments
*/
Expand Down
361 changes: 361 additions & 0 deletions src/scriptlets/json-prune-xhr-response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,361 @@
import {
hit,
logMessage,
toRegExp,
jsonPruner,
getPrunePath,
objectToString,
matchRequestProps,
getXhrData,
XMLHttpRequestSharedRequestData,
// following helpers should be imported and injected
// because they are used by helpers above
isPruningNeeded,
matchStackTrace,
getMatchPropsData,
getRequestProps,
isValidParsedData,
parseMatchProps,
isValidStrPattern,
escapeRegExp,
isEmptyObject,
getWildcardPropertyInChain,
shouldAbortInlineOrInjectedScript,
getNativeRegexpTest,
} from '../helpers/index';

/**
* @scriptlet json-prune-xhr-response
*
* @description
* Removes specified properties from the JSON response of a `XMLHttpRequest` call.
*
* Related UBO scriptlet:
* https://github.com/gorhill/uBlock/commit/3152896d428c54c76cfd66c3da110bd4d6506cbc
*
* ### Syntax
*
* ```text
* example.org#%#//scriptlet('json-prune-xhr-response'[, propsToRemove[, obligatoryProps[, propsToMatch[, stack]]]])
* ```
*
* - `propsToRemove` — optional, string of space-separated properties to remove
* - `obligatoryProps` — optional, string of space-separated properties
* which must be all present for the pruning to occur
* - `propsToMatch` — optional, string of space-separated properties to match for extra condition; possible props:
* - string or regular expression for matching the URL passed to `XMLHttpRequest.open()` call;
* - colon-separated pairs `name:value` where
* - `name` — string or regular expression for matching XMLHttpRequest property name
* - `value` — string or regular expression for matching the value of the option
* passed to `XMLHttpRequest.open()` call
* - `stack` — optional, string or regular expression that must match the current function call stack trace;
* if regular expression is invalid it will be skipped
*
* > Note please that you can use wildcard `*` for chain property name,
* > e.g. `ad.*.src` instead of `ad.0.src ad.1.src ad.2.src`.
*
* > Usage with with only propsToMatch argument will log XMLHttpRequest calls to browser console.
* > It may be useful for debugging but it is not allowed for prod versions of filter lists.
*
* > Scriptlet does nothing if response body can't be converted to JSON.
*
* ### Examples
*
* 1. Removes property `example` from the JSON response of any XMLHttpRequest call
*
* ```adblock
* example.org#%#//scriptlet('json-prune-xhr-response', 'example')
* ```
*
* For instance, if the JSON response of a XMLHttpRequest call is:
*
* ```js
* {one: 1, example: true}
* ```
*
* then the response will be modified to:
*
* ```js
* {one: 1}
* ```
*
* 2. A property in a list of properties can be a chain of properties
*
* ```adblock
* example.org#%#//scriptlet('json-prune-xhr-response', 'a.b', 'ads.url.first')
* ```
*
* 3. Removes property `content.ad` from the JSON response of a XMLHttpRequest call if URL contains `content.json`
*
* ```adblock
* example.org#%#//scriptlet('json-prune-xhr-response', 'content.ad', '', 'content.json')
* ```
*
* 4. Removes property `content.ad` from the JSON response of a XMLHttpRequest call
* if its error stack trace contains `test.js`
*
* ```adblock
* example.org#%#//scriptlet('json-prune-xhr-response', 'content.ad', '', '', 'test.js')
* ```
*
* 5. A property in a list of properties can be a chain of properties with wildcard in it
*
* ```adblock
* example.org#%#//scriptlet('json-prune-xhr-response', 'content.*.media.src', 'content.*.media.ad')
* ```
*
* 6. Log all JSON responses of a XMLHttpRequest call
*
* ```adblock
* example.org#%#//scriptlet('json-prune-xhr-response')
* ```
*
* @added unknown.
*/

interface CustomXMLHttpRequest extends XMLHttpRequest {
shouldBePrevented: boolean;
headersReceived: boolean;
collectedHeaders: string[];
}

export function jsonPruneXhrResponse(
source: Source,
propsToRemove: string,
obligatoryProps: string,
propsToMatch = '',
stack = '',
) {
// Do nothing if browser does not support Proxy (e.g. Internet Explorer)
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
if (typeof Proxy === 'undefined') {
return;
}

const shouldLog = !propsToRemove && !obligatoryProps;

const prunePaths = getPrunePath(propsToRemove);
const requiredPaths = getPrunePath(obligatoryProps);

const nativeParse = window.JSON.parse;
const nativeStringify = window.JSON.stringify;

const nativeOpen = window.XMLHttpRequest.prototype.open;
const nativeSend = window.XMLHttpRequest.prototype.send;

let xhrData: XMLHttpRequestSharedRequestData<any>;

const openWrapper = (
target: typeof XMLHttpRequest.prototype.open,
thisArg: CustomXMLHttpRequest,
args: [method: string, url: string, async: string, user: string, password: string],
): void => {
// eslint-disable-next-line prefer-spread
xhrData = getXhrData.apply(null, args);

if (matchRequestProps(source, propsToMatch, xhrData) || shouldLog) {
thisArg.shouldBePrevented = true;
thisArg.headersReceived = !!thisArg.headersReceived;
}

// Trap setRequestHeader of target xhr object to mimic request headers later
if (thisArg.shouldBePrevented && !thisArg.headersReceived) {
thisArg.headersReceived = true;
thisArg.collectedHeaders = [];
const setRequestHeaderWrapper = (
setRequestHeader: typeof XMLHttpRequest.prototype.setRequestHeader,
thisArgument: CustomXMLHttpRequest,
argsList: any,
): void => {
// Collect headers
thisArgument.collectedHeaders.push(argsList);
return Reflect.apply(setRequestHeader, thisArgument, argsList);
};

const setRequestHeaderHandler = {
apply: setRequestHeaderWrapper,
};

// setRequestHeader can only be called on open xhr object,
// so we can safely proxy it here
thisArg.setRequestHeader = new Proxy(thisArg.setRequestHeader, setRequestHeaderHandler);
}

return Reflect.apply(target, thisArg, args);
};

const sendWrapper = (
target: typeof XMLHttpRequest.prototype.send,
thisArg: CustomXMLHttpRequest,
args: any,
): void => {
// Stack trace cannot be checked in jsonPruner helper,
// because in this case it returns stack trace of our script,
// so it has to be checked earlier
const stackTrace = new Error().stack || '';

if (!thisArg.shouldBePrevented || (stack && !matchStackTrace(stack, stackTrace))) {
return Reflect.apply(target, thisArg, args);
}

/**
* Create separate XHR request with original request's input
* to be able to collect response data without triggering
* listeners on original XHR object
*/
const forgedRequest = new XMLHttpRequest();
forgedRequest.addEventListener('readystatechange', () => {
if (forgedRequest.readyState !== 4) {
return;
}

const {
readyState,
response,
responseText,
responseURL,
responseXML,
status,
statusText,
} = forgedRequest;

// Extract content from response
const content = responseText || response;
if (
typeof content !== 'string'
&& typeof content !== 'object'
) {
return;
}

let modifiedContent;
if (typeof content === 'string') {
try {
const jsonContent = nativeParse(content);
if (shouldLog) {
// eslint-disable-next-line max-len
logMessage(source, `${window.location.hostname}\n${nativeStringify(jsonContent, null, 2)}\nStack trace:\n${stackTrace}`, true);
logMessage(source, jsonContent, true, false);
modifiedContent = content;
} else {
modifiedContent = jsonPruner(
source,
jsonContent,
prunePaths,
requiredPaths,
stack = '',
{
nativeStringify,
},
);
}
if (thisArg.responseType === '' || thisArg.responseType === 'text') {
modifiedContent = nativeStringify(modifiedContent);
}
if (thisArg.responseType === 'arraybuffer') {
modifiedContent = nativeStringify(modifiedContent);
const encode = (string: string | undefined) => new TextEncoder().encode(string);
modifiedContent = encode(modifiedContent);
}
if (thisArg.responseType === 'blob') {
modifiedContent = nativeStringify(modifiedContent);
modifiedContent = new Blob([modifiedContent]);
}
} catch {
const message = `Response body can't be converted to json: ${content}`;
logMessage(source, message);
modifiedContent = content;
}
}

// Manually put required values into target XHR object
// as thisArg can't be redefined and XHR objects can't be (re)assigned or copied
Object.defineProperties(thisArg, {
// original values
readyState: { value: readyState, writable: false },
responseURL: { value: responseURL, writable: false },
responseXML: { value: responseXML, writable: false },
status: { value: status, writable: false },
statusText: { value: statusText, writable: false },
// modified values
response: { value: modifiedContent, writable: false },
responseText: { value: modifiedContent, writable: false },
});

// Mock events
setTimeout(() => {
const stateEvent = new Event('readystatechange');
thisArg.dispatchEvent(stateEvent);

const loadEvent = new Event('load');
thisArg.dispatchEvent(loadEvent);

const loadEndEvent = new Event('loadend');
thisArg.dispatchEvent(loadEndEvent);
}, 1);

hit(source);
});

nativeOpen.apply(forgedRequest, [xhrData.method, xhrData.url, xhrData.async as boolean]);

// Mimic request headers before sending
// setRequestHeader can only be called on open request objects
thisArg.collectedHeaders.forEach((header) => {
const name = header[0];
const value = header[1];

forgedRequest.setRequestHeader(name, value);
});
thisArg.collectedHeaders = [];

try {
nativeSend.call(forgedRequest, args);
} catch {
return Reflect.apply(target, thisArg, args);
}
return undefined;
};

const openHandler = {
apply: openWrapper,
};

const sendHandler = {
apply: sendWrapper,
};

XMLHttpRequest.prototype.open = new Proxy(XMLHttpRequest.prototype.open, openHandler);
XMLHttpRequest.prototype.send = new Proxy(XMLHttpRequest.prototype.send, sendHandler);
}

jsonPruneXhrResponse.names = [
'json-prune-xhr-response',
// aliases are needed for matching the related scriptlet converted into our syntax
'json-prune-xhr-response.js',
'ubo-json-prune-xhr-response.js',
'ubo-json-prune-xhr-response',
];

jsonPruneXhrResponse.injections = [
hit,
logMessage,
toRegExp,
jsonPruner,
getPrunePath,
objectToString,
matchRequestProps,
getXhrData,
isPruningNeeded,
matchStackTrace,
getMatchPropsData,
getRequestProps,
isValidParsedData,
parseMatchProps,
isValidStrPattern,
escapeRegExp,
isEmptyObject,
getWildcardPropertyInChain,
shouldAbortInlineOrInjectedScript,
getNativeRegexpTest,
];
Loading

0 comments on commit ea7eb36

Please sign in to comment.