From b18daa53aaa7c1cbe4f6d9acb09e8a165a3562c9 Mon Sep 17 00:00:00 2001 From: Raymond Hill Date: Fri, 28 Mar 2025 09:40:22 -0400 Subject: [PATCH] Add `json-edit` suite of scriptlets; extend `replace=` option Scriptlets added: - json-edit - trusted-json-edit - json-edit-xhr-response - trusted-json-edit-xhr-response - json-edit-fetch-response - trusted-json-edit-fetch-response - jsonl-edit-xhr-response - trusted-jsonl-edit-xhr-response - jsonl-edit-fetch-response - trusted-jsonl-edit-fetch-response These scriptlets are functionally similar to their `json-prune` counterpart, except that they all use the new uBO-flavored JSONPath syntax, and the `trusted-` versions allow to modify values instead of just removing them. The `replace=` filter option has been extended to support applying uBO-flavored JSONPath syntax to the response body. If the `replace=` value starts with `json:` or `jsonl:`, the remaining of the value will be interpreted as a JSONPath directive, which can be used to either remove or modify property in a JSON document. --- src/js/jsonpath.js | 82 +++- src/js/resources/json-edit.js | 666 ++++++++++++++++++++++++++++++ src/js/resources/jsonl-prune.js | 249 ----------- src/js/resources/scriptlets.js | 2 +- src/js/static-filtering-parser.js | 22 +- src/js/static-net-filtering.js | 2 +- src/js/traffic.js | 57 ++- 7 files changed, 801 insertions(+), 279 deletions(-) create mode 100644 src/js/resources/json-edit.js delete mode 100644 src/js/resources/jsonl-prune.js diff --git a/src/js/jsonpath.js b/src/js/jsonpath.js index 04cb95cdd..7f022d6e7 100644 --- a/src/js/jsonpath.js +++ b/src/js/jsonpath.js @@ -28,26 +28,62 @@ export class JSONPath { jsonp.compile(query); return jsonp; } + static toJSON(obj, stringifier, ...args) { + return (stringifier || JSON.stringify)(obj, ...args) + .replace(/\//g, '\\/'); + } + get value() { + return this.#compiled && this.#compiled.rval; + } + set value(v) { + if ( this.#compiled === undefined ) { return; } + this.#compiled.rval = v; + } + get valid() { + return this.#compiled !== undefined; + } compile(query) { - this.#compiled = this.#compile(query, 0); - return this.#compiled ? this.#compiled.i : 0; + const r = this.#compile(query, 0); + if ( r === undefined ) { return; } + if ( r.i !== query.length ) { + if ( query.startsWith('=', r.i) === false ) { return; } + try { r.rval = JSON.parse(query.slice(r.i+1)); } + catch { return; } + } + this.#compiled = r; } evaluate(root) { - if ( this.#compiled === undefined ) { return []; } - this.root = root; - return this.#evaluate(this.#compiled.steps, []); + if ( this.valid === false ) { return []; } + this.#root = root; + const paths = this.#evaluate(this.#compiled.steps, []); + this.#root = null; + return paths; } - resolvePath(path) { - if ( path.length === 0 ) { return { value: this.root }; } - const key = path.at(-1); - let obj = this.root - for ( let i = 0, n = path.length-1; i < n; i++ ) { - obj = obj[path[i]]; + apply(root) { + if ( this.valid === false ) { return 0; } + const { rval } = this.#compiled; + this.#root = root; + const paths = this.#evaluate(this.#compiled.steps, []); + const n = paths.length; + let i = n; + while ( i-- ) { + const { obj, key } = this.#resolvePath(paths[i]); + if ( rval !== undefined ) { + obj[key] = rval; + } else if ( Array.isArray(obj) && typeof key === 'number' ) { + obj.splice(key, 1); + } else { + delete obj[key]; + } } - return { obj, key, value: obj[key] }; + this.#root = null; + return n; } - toString() { - return JSON.stringify(this.#compiled); + toJSON(obj, ...args) { + return JSONPath.toJSON(obj, null, ...args) + } + get [Symbol.toStringTag]() { + return 'JSONPath'; } #UNDEFINED = 0; #ROOT = 1; @@ -57,6 +93,7 @@ export class JSONPath { #reUnquotedIdentifier = /^[A-Za-z_][\w]*|^\*/; #reExpr = /^([!=^$*]=|[<>]=?)(.+?)\)\]/; #reIndice = /^\[-?\d+\]/; + #root; #compiled; #compile(query, i) { if ( query.length === 0 ) { return; } @@ -88,7 +125,7 @@ export class JSONPath { if ( mv === this.#UNDEFINED ) { const step = steps.at(-1); if ( step === undefined ) { return; } - i = this.#compileExpr(step, query, i); + i = this.#compileExpr(query, step, i); break; } const s = this.#consumeUnquotedIdentifier(query, i); @@ -166,7 +203,7 @@ export class JSONPath { const listout = []; const recursive = step.mv === this.#DESCENDANTS; for ( const pathin of listin ) { - const { value: v } = this.resolvePath(pathin); + const { value: v } = this.#resolvePath(pathin); if ( v === null ) { continue; } if ( v === undefined ) { continue; } const { steps, k } = step; @@ -298,7 +335,7 @@ export class JSONPath { if ( match === null ) { return; } return match[0]; } - #compileExpr(step, query, i) { + #compileExpr(query, step, i) { const match = this.#reExpr.exec(query.slice(i)); if ( match === null ) { return i; } try { @@ -308,8 +345,17 @@ export class JSONPath { } return i + match[1].length + match[2].length; } + #resolvePath(path) { + if ( path.length === 0 ) { return { value: this.#root }; } + const key = path.at(-1); + let obj = this.#root + for ( let i = 0, n = path.length-1; i < n; i++ ) { + obj = obj[path[i]]; + } + return { obj, key, value: obj[key] }; + } #evaluateExpr(step, path, out) { - const { obj: o, key: k } = this.resolvePath(path); + const { obj: o, key: k } = this.#resolvePath(path); const hasOwn = o instanceof Object && Object.hasOwn(o, k); const v = o[k]; let outcome = true; diff --git a/src/js/resources/json-edit.js b/src/js/resources/json-edit.js new file mode 100644 index 000000000..8a2af5928 --- /dev/null +++ b/src/js/resources/json-edit.js @@ -0,0 +1,666 @@ +/******************************************************************************* + + uBlock Origin - a comprehensive, efficient content blocker + Copyright (C) 2019-present Raymond Hill + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + Home: https://github.com/gorhill/uBlock + +*/ + +import { + matchObjectPropertiesFn, + parsePropertiesToMatchFn, +} from './utils.js'; + +import { JSONPath } from './shared.js'; +import { proxyApplyFn } from './proxy-apply.js'; +import { registerScriptlet } from './base.js'; +import { safeSelf } from './safe-self.js'; + +/******************************************************************************/ + +function jsonEditFn(trusted, jsonq = '') { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix( + `${trusted ? 'trusted-' : ''}json-edit`, + jsonq + ); + const jsonp = JSONPath.create(jsonq); + if ( jsonp.valid === false || jsonp.value !== undefined && trusted !== true ) { + return safe.uboLog(logPrefix, 'Bad JSONPath query'); + } + proxyApplyFn('JSON.parse', function(context) { + const obj = context.reflect(); + if ( jsonp.apply(obj) !== 0 ) { return obj; } + safe.uboLog(logPrefix, 'Edited'); + if ( safe.logLevel > 1 ) { + safe.uboLog(logPrefix, `After edit:\n${safe.JSON_stringify(obj, null, 2)}`); + } + return obj; + }); +} +registerScriptlet(jsonEditFn, { + name: 'json-edit.fn', + dependencies: [ + JSONPath, + proxyApplyFn, + safeSelf, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet json-edit.js + * + * @description + * Edit object generated through JSON.parse(). + * Properties can only be removed. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * */ + +function jsonEdit(jsonq = '') { + jsonEditFn(false, jsonq); +} +registerScriptlet(jsonEdit, { + name: 'json-edit.js', + dependencies: [ + jsonEditFn, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet trusted-json-edit.js + * + * @description + * Edit object generated through JSON.parse(). + * Properties can be assigned new values. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * */ + +function trustedJsonEdit(jsonq = '') { + jsonEditFn(true, jsonq); +} +registerScriptlet(trustedJsonEdit, { + name: 'trusted-json-edit.js', + requiresTrust: true, + dependencies: [ + jsonEditFn, + ], +}); + +/******************************************************************************/ +/******************************************************************************/ + +function jsonEditXhrResponseFn(trusted, jsonq = '') { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix( + `${trusted ? 'trusted-' : ''}json-edit-xhr-response`, + jsonq + ); + const xhrInstances = new WeakMap(); + const jsonp = JSONPath.create(jsonq); + if ( jsonp.valid === false || jsonp.value !== undefined && trusted !== true ) { + return safe.uboLog(logPrefix, 'Bad JSONPath query'); + } + const extraArgs = safe.getExtraArgs(Array.from(arguments), 2); + const propNeedles = parsePropertiesToMatchFn(extraArgs.propsToMatch, 'url'); + self.XMLHttpRequest = class extends self.XMLHttpRequest { + open(method, url, ...args) { + const xhrDetails = { method, url }; + const matched = propNeedles.size === 0 || + matchObjectPropertiesFn(propNeedles, xhrDetails); + if ( matched ) { + if ( safe.logLevel > 1 && Array.isArray(matched) ) { + safe.uboLog(logPrefix, `Matched "propsToMatch":\n\t${matched.join('\n\t')}`); + } + xhrInstances.set(this, xhrDetails); + } + return super.open(method, url, ...args); + } + get response() { + const innerResponse = super.response; + const xhrDetails = xhrInstances.get(this); + if ( xhrDetails === undefined ) { return innerResponse; } + const responseLength = typeof innerResponse === 'string' + ? innerResponse.length + : undefined; + if ( xhrDetails.lastResponseLength !== responseLength ) { + xhrDetails.response = undefined; + xhrDetails.lastResponseLength = responseLength; + } + if ( xhrDetails.response !== undefined ) { + return xhrDetails.response; + } + let obj; + if ( typeof innerResponse === 'object' ) { + obj = innerResponse; + } else if ( typeof innerResponse === 'string' ) { + try { obj = safe.JSON_parse(innerResponse); } catch { } + } + if ( typeof obj !== 'object' || obj === null || jsonp.apply(obj) === 0 ) { + return (xhrDetails.response = innerResponse); + } + safe.uboLog(logPrefix, 'Edited'); + const outerResponse = typeof innerResponse === 'string' + ? JSONPath.toJSON(obj, safe.JSON_stringify) + : obj; + return (xhrDetails.response = outerResponse); + } + get responseText() { + const response = this.response; + return typeof response !== 'string' + ? super.responseText + : response; + } + }; +} +registerScriptlet(jsonEditXhrResponseFn, { + name: 'json-edit-xhr-response.fn', + dependencies: [ + JSONPath, + matchObjectPropertiesFn, + parsePropertiesToMatchFn, + safeSelf, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet json-edit-xhr-response.js + * + * @description + * Edit the object fetched through a XHR instance. + * Properties can only be removed. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * @param [propsToMatch, value] + * An optional vararg detailing the arguments to match when xhr.open() is + * called. + * + * */ + +function jsonEditXhrResponse(jsonq = '', ...args) { + jsonEditXhrResponseFn(false, jsonq, ...args); +} +registerScriptlet(jsonEditXhrResponse, { + name: 'json-edit-xhr-response.js', + dependencies: [ + jsonEditXhrResponseFn, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet trusted-json-edit-xhr-response.js + * + * @description + * Edit the object fetched through a XHR instance. + * Properties can be assigned new values. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * @param [propsToMatch, value] + * An optional vararg detailing the arguments to match when xhr.open() is + * called. + * + * */ + +function trustedJsonEditXhrResponse(jsonq = '', ...args) { + jsonEditXhrResponseFn(true, jsonq, ...args); +} +registerScriptlet(trustedJsonEditXhrResponse, { + name: 'trusted-json-edit-xhr-response.js', + requiresTrust: true, + dependencies: [ + jsonEditXhrResponseFn, + ], +}); + +/******************************************************************************/ +/******************************************************************************/ + +function jsonEditFetchResponseFn(trusted, jsonq = '') { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix( + `${trusted ? 'trusted-' : ''}json-edit-fetch-response`, + jsonq + ); + const jsonp = JSONPath.create(jsonq); + if ( jsonp.valid === false || jsonp.value !== undefined && trusted !== true ) { + return safe.uboLog(logPrefix, 'Bad JSONPath query'); + } + const extraArgs = safe.getExtraArgs(Array.from(arguments), 2); + const propNeedles = parsePropertiesToMatchFn(extraArgs.propsToMatch, 'url'); + proxyApplyFn('fetch', function(context) { + const args = context.callArgs; + const fetchPromise = context.reflect(); + if ( propNeedles.size !== 0 ) { + const objs = [ args[0] instanceof Object ? args[0] : { url: args[0] } ]; + if ( objs[0] instanceof Request ) { + try { + objs[0] = safe.Request_clone.call(objs[0]); + } catch(ex) { + safe.uboErr(logPrefix, 'Error:', ex); + } + } + if ( args[1] instanceof Object ) { + objs.push(args[1]); + } + const matched = matchObjectPropertiesFn(propNeedles, ...objs); + if ( matched === undefined ) { return fetchPromise; } + if ( safe.logLevel > 1 ) { + safe.uboLog(logPrefix, `Matched "propsToMatch":\n\t${matched.join('\n\t')}`); + } + } + return fetchPromise.then(responseBefore => { + const response = responseBefore.clone(); + return response.json().then(obj => { + if ( typeof obj !== 'object' ) { return responseBefore; } + if ( jsonp.apply(obj) === 0 ) { return responseBefore; } + safe.uboLog(logPrefix, 'Edited'); + const responseAfter = Response.json(obj, { + status: responseBefore.status, + statusText: responseBefore.statusText, + headers: responseBefore.headers, + }); + Object.defineProperties(responseAfter, { + ok: { value: responseBefore.ok }, + redirected: { value: responseBefore.redirected }, + type: { value: responseBefore.type }, + url: { value: responseBefore.url }, + }); + return responseAfter; + }).catch(reason => { + safe.uboErr(logPrefix, 'Error:', reason); + return responseBefore; + }); + }).catch(reason => { + safe.uboErr(logPrefix, 'Error:', reason); + return fetchPromise; + }); + }); +} +registerScriptlet(jsonEditFetchResponseFn, { + name: 'json-edit-fetch-response.fn', + dependencies: [ + JSONPath, + matchObjectPropertiesFn, + parsePropertiesToMatchFn, + proxyApplyFn, + safeSelf, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet json-edit-fetch-response.js + * + * @description + * Edit the object fetched through the fetch API. + * Properties can only be removed. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * @param [propsToMatch, value] + * An optional vararg detailing the arguments to match when xhr.open() is + * called. + * + * */ + +function jsonEditFetchResponse(jsonq = '', ...args) { + jsonEditFetchResponseFn(false, jsonq, ...args); +} +registerScriptlet(jsonEditFetchResponse, { + name: 'json-edit-fetch-response.js', + dependencies: [ + jsonEditFetchResponseFn, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet trusted-json-edit-fetch-response.js + * + * @description + * Edit the object fetched through the fetch API. The trusted version allows + * Properties can be assigned new values. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * @param [propsToMatch, value] + * An optional vararg detailing the arguments to match when xhr.open() is + * called. + * + * */ + +function trustedJsonEditFetchResponse(jsonq = '', ...args) { + jsonEditFetchResponseFn(true, jsonq, ...args); +} +registerScriptlet(trustedJsonEditFetchResponse, { + name: 'trusted-json-edit-fetch-response.js', + requiresTrust: true, + dependencies: [ + jsonEditFetchResponseFn, + ], +}); + +/******************************************************************************/ +/******************************************************************************/ + +function jsonlEditFn(jsonp, text = '') { + const safe = safeSelf(); + const linesBefore = text.split(/\n+/); + const linesAfter = []; + for ( const lineBefore of linesBefore ) { + let obj; + try { obj = safe.JSON_parse(lineBefore); } catch { } + if ( typeof obj !== 'object' || obj === null ) { + linesAfter.push(lineBefore); + continue; + } + if ( jsonp.apply(obj) === 0 ) { + linesAfter.push(lineBefore); + continue; + } + linesAfter.push(JSONPath.toJSON(obj, safe.JSON_stringify)); + } + return linesAfter.join('\n'); +} +registerScriptlet(jsonlEditFn, { + name: 'jsonl-edit.fn', + dependencies: [ + JSONPath, + safeSelf, + ], +}); + +/******************************************************************************/ + +function jsonlEditXhrResponseFn(trusted, jsonq = '') { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix( + `${trusted ? 'trusted-' : ''}jsonl-edit-xhr-response`, + jsonq + ); + const xhrInstances = new WeakMap(); + const jsonp = JSONPath.create(jsonq); + if ( jsonp.valid === false || jsonp.value !== undefined && trusted !== true ) { + return safe.uboLog(logPrefix, 'Bad JSONPath query'); + } + const extraArgs = safe.getExtraArgs(Array.from(arguments), 2); + const propNeedles = parsePropertiesToMatchFn(extraArgs.propsToMatch, 'url'); + self.XMLHttpRequest = class extends self.XMLHttpRequest { + open(method, url, ...args) { + const xhrDetails = { method, url }; + const matched = propNeedles.size === 0 || + matchObjectPropertiesFn(propNeedles, xhrDetails); + if ( matched ) { + if ( safe.logLevel > 1 && Array.isArray(matched) ) { + safe.uboLog(logPrefix, `Matched "propsToMatch":\n\t${matched.join('\n\t')}`); + } + xhrInstances.set(this, xhrDetails); + } + return super.open(method, url, ...args); + } + get response() { + const innerResponse = super.response; + const xhrDetails = xhrInstances.get(this); + if ( xhrDetails === undefined ) { + return innerResponse; + } + const responseLength = typeof innerResponse === 'string' + ? innerResponse.length + : undefined; + if ( xhrDetails.lastResponseLength !== responseLength ) { + xhrDetails.response = undefined; + xhrDetails.lastResponseLength = responseLength; + } + if ( xhrDetails.response !== undefined ) { + return xhrDetails.response; + } + if ( typeof innerResponse !== 'string' ) { + return (xhrDetails.response = innerResponse); + } + const outerResponse = jsonlEditFn(jsonp, innerResponse); + if ( outerResponse !== innerResponse ) { + safe.uboLog(logPrefix, 'Pruned'); + } + return (xhrDetails.response = outerResponse); + } + get responseText() { + const response = this.response; + return typeof response !== 'string' + ? super.responseText + : response; + } + }; +} +registerScriptlet(jsonlEditXhrResponseFn, { + name: 'jsonl-edit-xhr-response.fn', + dependencies: [ + JSONPath, + jsonlEditFn, + matchObjectPropertiesFn, + parsePropertiesToMatchFn, + safeSelf, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet jsonl-edit-xhr-response.js + * + * @description + * Edit the objects found in a JSONL resource fetched through a XHR instance. + * Properties can only be removed. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * @param [propsToMatch, value] + * An optional vararg detailing the arguments to match when xhr.open() is + * called. + * + * */ + +function jsonlEditXhrResponse(jsonq = '', ...args) { + jsonlEditXhrResponseFn(false, jsonq, ...args); +} +registerScriptlet(jsonlEditXhrResponse, { + name: 'jsonl-edit-xhr-response.js', + dependencies: [ + jsonlEditXhrResponseFn, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet trusted-jsonl-edit-xhr-response.js + * + * @description + * Edit the objects found in a JSONL resource fetched through a XHR instance. + * Properties can be assigned new values. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * @param [propsToMatch, value] + * An optional vararg detailing the arguments to match when xhr.open() is + * called. + * + * */ + +function trustedJsonlEditXhrResponse(jsonq = '', ...args) { + jsonlEditXhrResponseFn(true, jsonq, ...args); +} +registerScriptlet(trustedJsonlEditXhrResponse, { + name: 'trusted-jsonl-edit-xhr-response.js', + requiresTrust: true, + dependencies: [ + jsonlEditXhrResponseFn, + ], +}); + +/******************************************************************************/ +/******************************************************************************/ + +function jsonlEditFetchResponseFn(trusted, jsonq = '') { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix( + `${trusted ? 'trusted-' : ''}jsonl-edit-fetch-response`, + jsonq + ); + const jsonp = JSONPath.create(jsonq); + if ( jsonp.valid === false || jsonp.value !== undefined && trusted !== true ) { + return safe.uboLog(logPrefix, 'Bad JSONPath query'); + } + const extraArgs = safe.getExtraArgs(Array.from(arguments), 2); + const propNeedles = parsePropertiesToMatchFn(extraArgs.propsToMatch, 'url'); + const logall = jsonq === ''; + proxyApplyFn('fetch', function(context) { + const args = context.callArgs; + const fetchPromise = context.reflect(); + if ( propNeedles.size !== 0 ) { + const objs = [ args[0] instanceof Object ? args[0] : { url: args[0] } ]; + if ( objs[0] instanceof Request ) { + try { + objs[0] = safe.Request_clone.call(objs[0]); + } catch(ex) { + safe.uboErr(logPrefix, 'Error:', ex); + } + } + if ( args[1] instanceof Object ) { + objs.push(args[1]); + } + const matched = matchObjectPropertiesFn(propNeedles, ...objs); + if ( matched === undefined ) { return fetchPromise; } + if ( safe.logLevel > 1 ) { + safe.uboLog(logPrefix, `Matched "propsToMatch":\n\t${matched.join('\n\t')}`); + } + } + return fetchPromise.then(responseBefore => { + const response = responseBefore.clone(); + return response.text().then(textBefore => { + if ( typeof textBefore !== 'string' ) { return textBefore; } + if ( logall ) { + safe.uboLog(logPrefix, textBefore); + return responseBefore; + } + const textAfter = jsonlEditFn(jsonp, textBefore); + if ( textAfter === textBefore ) { return responseBefore; } + safe.uboLog(logPrefix, 'Pruned'); + const responseAfter = new Response(textAfter, { + status: responseBefore.status, + statusText: responseBefore.statusText, + headers: responseBefore.headers, + }); + Object.defineProperties(responseAfter, { + ok: { value: responseBefore.ok }, + redirected: { value: responseBefore.redirected }, + type: { value: responseBefore.type }, + url: { value: responseBefore.url }, + }); + return responseAfter; + }).catch(reason => { + safe.uboErr(logPrefix, 'Error:', reason); + return responseBefore; + }); + }).catch(reason => { + safe.uboErr(logPrefix, 'Error:', reason); + return fetchPromise; + }); + }); +} +registerScriptlet(jsonlEditFetchResponseFn, { + name: 'jsonl-edit-fetch-response.fn', + dependencies: [ + JSONPath, + jsonlEditFn, + matchObjectPropertiesFn, + parsePropertiesToMatchFn, + proxyApplyFn, + safeSelf, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet jsonl-edit-fetch-response.js + * + * @description + * Edit the objects found in a JSONL resource fetched through the fetch API. + * Properties can only be removed. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * @param [propsToMatch, value] + * An optional vararg detailing the arguments to match when xhr.open() is + * called. + * + * */ + +function jsonlEditFetchResponse(jsonq = '', ...args) { + jsonlEditFetchResponseFn(false, jsonq, ...args); +} +registerScriptlet(jsonlEditFetchResponse, { + name: 'jsonl-edit-fetch-response.js', + dependencies: [ + jsonlEditFetchResponseFn, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet trusted-jsonl-edit-fetch-response.js + * + * @description + * Edit the objects found in a JSONL resource fetched through the fetch API. + * Properties can be assigned new values. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * @param [propsToMatch, value] + * An optional vararg detailing the arguments to match when xhr.open() is + * called. + * + * */ + +function trustedJsonlEditFetchResponse(jsonq = '', ...args) { + jsonlEditFetchResponseFn(true, jsonq, ...args); +} +registerScriptlet(trustedJsonlEditFetchResponse, { + name: 'trusted-jsonl-edit-fetch-response.js', + requiresTrust: true, + dependencies: [ + jsonlEditFetchResponseFn, + ], +}); + +/******************************************************************************/ diff --git a/src/js/resources/jsonl-prune.js b/src/js/resources/jsonl-prune.js deleted file mode 100644 index be1f82437..000000000 --- a/src/js/resources/jsonl-prune.js +++ /dev/null @@ -1,249 +0,0 @@ -/******************************************************************************* - - uBlock Origin - a comprehensive, efficient content blocker - Copyright (C) 2019-present Raymond Hill - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see {http://www.gnu.org/licenses/}. - - Home: https://github.com/gorhill/uBlock - -*/ - -import { - matchObjectPropertiesFn, - parsePropertiesToMatchFn, -} from './utils.js'; - -import { JSONPath } from './shared.js'; -import { objectPruneFn } from './object-prune.js'; -import { registerScriptlet } from './base.js'; -import { safeSelf } from './safe-self.js'; - -/******************************************************************************/ - -function jsonlPruneFn( - jsonp, - text = '' -) { - const safe = safeSelf(); - const linesBefore = text.split(/\n+/); - const linesAfter = []; - for ( const lineBefore of linesBefore ) { - let obj; - try { - obj = safe.JSON_parse(lineBefore); - } catch { - } - if ( typeof obj !== 'object' || obj === null ) { - linesAfter.push(lineBefore); - continue; - } - const paths = jsonp.evaluate(obj); - if ( paths.length === 0 ) { - linesAfter.push(lineBefore); - continue; - } - for ( const path of paths ) { - const { obj, key } = jsonp.resolvePath(path); - delete obj[key]; - } - linesAfter.push(safe.JSON_stringify(obj).replace(/\//g, '\\/')); - } - return linesAfter.join('\n'); -} -registerScriptlet(jsonlPruneFn, { - name: 'jsonl-prune.fn', - dependencies: [ - safeSelf, - ], -}); - -/******************************************************************************/ - -/** - * @scriptlet jsonl-prune-xhr-response.js - * - * @description - * Prune the objects found in a JSONL resource fetched through a XHR instance. - * - * @param jsonq - * A uBO-flavored JSONPath query. - * - * @param [propsToMatch, value] - * An optional vararg detailing the arguments to match when xhr.open() is - * called. - * - * */ - -function jsonlPruneXhrResponse( - jsonq = '', -) { - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('jsonl-prune-xhr-response', jsonq); - const xhrInstances = new WeakMap(); - const jsonp = JSONPath.create(jsonq); - const extraArgs = safe.getExtraArgs(Array.from(arguments), 1); - const propNeedles = parsePropertiesToMatchFn(extraArgs.propsToMatch, 'url'); - self.XMLHttpRequest = class extends self.XMLHttpRequest { - open(method, url, ...args) { - const xhrDetails = { method, url }; - const matched = propNeedles.size === 0 || - matchObjectPropertiesFn(propNeedles, xhrDetails); - if ( matched ) { - if ( safe.logLevel > 1 && Array.isArray(matched) ) { - safe.uboLog(logPrefix, `Matched "propsToMatch":\n\t${matched.join('\n\t')}`); - } - xhrInstances.set(this, xhrDetails); - } - return super.open(method, url, ...args); - } - get response() { - const innerResponse = super.response; - const xhrDetails = xhrInstances.get(this); - if ( xhrDetails === undefined ) { - return innerResponse; - } - const responseLength = typeof innerResponse === 'string' - ? innerResponse.length - : undefined; - if ( xhrDetails.lastResponseLength !== responseLength ) { - xhrDetails.response = undefined; - xhrDetails.lastResponseLength = responseLength; - } - if ( xhrDetails.response !== undefined ) { - return xhrDetails.response; - } - if ( typeof innerResponse !== 'string' ) { - return (xhrDetails.response = innerResponse); - } - const outerResponse = jsonlPruneFn(jsonp, innerResponse); - if ( outerResponse !== innerResponse ) { - safe.uboLog(logPrefix, 'Pruned'); - } - return (xhrDetails.response = outerResponse); - } - get responseText() { - const response = this.response; - return typeof response !== 'string' - ? super.responseText - : response; - } - }; -} -registerScriptlet(jsonlPruneXhrResponse, { - name: 'jsonl-prune-xhr-response.js', - dependencies: [ - JSONPath, - jsonlPruneFn, - matchObjectPropertiesFn, - parsePropertiesToMatchFn, - safeSelf, - ], -}); - -/******************************************************************************/ - -/** - * @scriptlet jsonl-prune-fetch-response.js - * - * @description - * Prune the objects found in a JSONL resource fetched through the fetch API. - * Once the pruning is performed. - * - * @param jsonq - * A uBO-flavored JSONPath query. - * - * @param [propsToMatch, value] - * An optional vararg detailing the arguments to match when xhr.open() is - * called. - * - * */ - -function jsonlPruneFetchResponse( - jsonq = '' -) { - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('jsonl-prune-fetch-response', jsonq); - const jsonp = JSONPath.create(jsonq); - const extraArgs = safe.getExtraArgs(Array.from(arguments), 2); - const propNeedles = parsePropertiesToMatchFn(extraArgs.propsToMatch, 'url'); - const logall = jsonq === ''; - const applyHandler = function(target, thisArg, args) { - const fetchPromise = Reflect.apply(target, thisArg, args); - if ( propNeedles.size !== 0 ) { - const objs = [ args[0] instanceof Object ? args[0] : { url: args[0] } ]; - if ( objs[0] instanceof Request ) { - try { - objs[0] = safe.Request_clone.call(objs[0]); - } catch(ex) { - safe.uboErr(logPrefix, 'Error:', ex); - } - } - if ( args[1] instanceof Object ) { - objs.push(args[1]); - } - const matched = matchObjectPropertiesFn(propNeedles, ...objs); - if ( matched === undefined ) { return fetchPromise; } - if ( safe.logLevel > 1 ) { - safe.uboLog(logPrefix, `Matched "propsToMatch":\n\t${matched.join('\n\t')}`); - } - } - return fetchPromise.then(responseBefore => { - const response = responseBefore.clone(); - return response.text().then(textBefore => { - if ( typeof textBefore !== 'string' ) { return textBefore; } - if ( logall ) { - safe.uboLog(logPrefix, textBefore); - return responseBefore; - } - const textAfter = jsonlPruneFn(jsonp, textBefore); - if ( textAfter === textBefore ) { return responseBefore; } - safe.uboLog(logPrefix, 'Pruned'); - const responseAfter = new Response(textAfter, { - status: responseBefore.status, - statusText: responseBefore.statusText, - headers: responseBefore.headers, - }); - Object.defineProperties(responseAfter, { - ok: { value: responseBefore.ok }, - redirected: { value: responseBefore.redirected }, - type: { value: responseBefore.type }, - url: { value: responseBefore.url }, - }); - return responseAfter; - }).catch(reason => { - safe.uboErr(logPrefix, 'Error:', reason); - return responseBefore; - }); - }).catch(reason => { - safe.uboErr(logPrefix, 'Error:', reason); - return fetchPromise; - }); - }; - self.fetch = new Proxy(self.fetch, { - apply: applyHandler - }); -} -registerScriptlet(jsonlPruneFetchResponse, { - name: 'jsonl-prune-fetch-response.js', - dependencies: [ - JSONPath, - jsonlPruneFn, - matchObjectPropertiesFn, - parsePropertiesToMatchFn, - safeSelf, - ], -}); - -/******************************************************************************/ diff --git a/src/js/resources/scriptlets.js b/src/js/resources/scriptlets.js index e9ec67171..0526d022a 100755 --- a/src/js/resources/scriptlets.js +++ b/src/js/resources/scriptlets.js @@ -22,8 +22,8 @@ import './attribute.js'; import './href-sanitizer.js'; +import './json-edit.js'; import './json-prune.js'; -import './jsonl-prune.js'; import './noeval.js'; import './object-prune.js'; import './prevent-innerHTML.js'; diff --git a/src/js/static-filtering-parser.js b/src/js/static-filtering-parser.js index 4b649d7d9..716e4bc41 100644 --- a/src/js/static-filtering-parser.js +++ b/src/js/static-filtering-parser.js @@ -21,6 +21,7 @@ import * as cssTree from '../lib/csstree/css-tree.js'; import { ArglistParser } from './arglist-parser.js'; +import { JSONPath } from './jsonpath.js'; import Regex from '../lib/regexanalyzer/regex.js'; /******************************************************************************* @@ -1472,7 +1473,7 @@ export class AstFilterParser { break; } const value = this.getNetOptionValue(NODE_TYPE_NET_OPTION_NAME_URLTRANSFORM); - if ( value !== '' && parseReplaceValue(value) === undefined ) { + if ( value !== '' && parseReplacebyRegexValue(value) === undefined ) { this.astError = AST_ERROR_OPTION_BADVALUE; realBad = true; } @@ -3028,7 +3029,7 @@ export function parseHeaderValue(arg) { // https://adguard.com/kb/general/ad-filtering/create-own-filters/#replace-modifier -export function parseReplaceValue(s) { +export function parseReplacebyRegexValue(s) { if ( s.charCodeAt(0) !== 0x2F /* / */ ) { return; } const parser = new ArglistParser('/'); parser.nextArg(s, 1); @@ -3054,6 +3055,23 @@ export function parseReplaceValue(s) { } } +export function parseReplaceValue(s) { + if ( s.startsWith('/') ) { + const r = parseReplacebyRegexValue(s); + if ( r ) { r.type = 'text'; } + return r; + } + const pos = s.indexOf(':'); + if ( pos === -1 ) { return; } + const type = s.slice(0, pos); + if ( type === 'json' || type === 'jsonl' ) { + const query = s.slice(pos+1); + const jsonp = JSONPath.create(query); + if ( jsonp.valid === false ) { return; } + return { type, jsonp }; + } +} + /******************************************************************************/ export const netOptionTokenDescriptors = new Map([ diff --git a/src/js/static-net-filtering.js b/src/js/static-net-filtering.js index c6b4f00c6..24961d5f7 100644 --- a/src/js/static-net-filtering.js +++ b/src/js/static-net-filtering.js @@ -5412,7 +5412,7 @@ StaticNetFilteringEngine.prototype.transformRequest = function(fctxt, out = []) continue; } if ( directive.cache === null ) { - directive.cache = sfp.parseReplaceValue(directive.value); + directive.cache = sfp.parseReplaceByRegexValue(directive.value); } const cache = directive.cache; if ( cache === undefined ) { continue; } diff --git a/src/js/traffic.js b/src/js/traffic.js index 6e4b064b2..827c94d51 100644 --- a/src/js/traffic.js +++ b/src/js/traffic.js @@ -625,19 +625,60 @@ function textResponseFilterer(session, directives) { continue; } const { refs } = directive; + if ( refs.$cache !== null ) { + const { jsonp } = refs.$cache; + if ( jsonp && jsonp.apply === undefined ) { + refs.$cache = null; + } + } if ( refs.$cache === null ) { refs.$cache = sfp.parseReplaceValue(refs.value); } const cache = refs.$cache; if ( cache === undefined ) { continue; } - cache.re.lastIndex = 0; - if ( cache.re.test(session.getString()) !== true ) { continue; } - cache.re.lastIndex = 0; - session.setString(session.getString().replace( - cache.re, - cache.replacement - )); - applied.push(directive); + switch ( cache.type ) { + case 'json': { + const json = session.getString(); + let obj; + try { obj = JSON.parse(json); } catch { break; } + if ( cache.jsonp.apply(obj) === 0 ) { break; } + session.setString(cache.jsonp.toJSON(obj)); + applied.push(directive); + break; + } + case 'jsonl': { + const linesBefore = session.getString().split(/\n+/); + const linesAfter = []; + for ( const lineBefore of linesBefore ) { + let obj; + try { obj = JSON.parse(lineBefore); } catch { } + if ( typeof obj !== 'object' || obj === null ) { + linesAfter.push(lineBefore); + continue; + } + if ( cache.jsonp.apply(obj) === 0 ) { + linesAfter.push(lineBefore); + continue; + } + linesAfter.push(cache.jsonp.toJSON(obj)); + } + session.setString(linesAfter.join('\n')); + break; + } + case 'text': { + cache.re.lastIndex = 0; + if ( cache.re.test(session.getString()) !== true ) { break; } + cache.re.lastIndex = 0; + session.setString(session.getString().replace( + cache.re, + cache.replacement + )); + applied.push(directive); + break; + } + default: + break; + } } if ( applied.length === 0 ) { return; } if ( logger.enabled !== true ) { return; }