diff --git a/src/js/jsonpath.js b/src/js/jsonpath.js index e0edb1e4c..4f4bf1a84 100644 --- a/src/js/jsonpath.js +++ b/src/js/jsonpath.js @@ -99,6 +99,10 @@ export class JSONPath { const r = this.#compile(query, 0); if ( r === undefined ) { return; } if ( r.i !== query.length ) { + if ( query.startsWith('+=', r.i) ) { + r.modify = '+'; + r.i += 1; + } if ( query.startsWith('=', r.i) === false ) { return; } try { r.rval = JSON.parse(query.slice(r.i+1)); } catch { return; } @@ -114,7 +118,7 @@ export class JSONPath { } apply(root) { if ( this.valid === false ) { return 0; } - const { rval } = this.#compiled; + const { modify, rval } = this.#compiled; this.#root = root; const paths = this.#evaluate(this.#compiled.steps, []); const n = paths.length; @@ -122,7 +126,11 @@ export class JSONPath { while ( i-- ) { const { obj, key } = this.#resolvePath(paths[i]); if ( rval !== undefined ) { - obj[key] = rval; + if ( modify === '+' ) { + this.#modifyVal(obj, key, rval); + } else { + obj[key] = rval; + } } else if ( Array.isArray(obj) && typeof key === 'number' ) { obj.splice(key, 1); } else { @@ -450,4 +458,13 @@ export class JSONPath { } if ( outcome ) { return k; } } + #modifyVal(obj, key, rval) { + const lval = obj[key]; + if ( rval instanceof Object === false ) { return; } + if ( lval instanceof Object === false ) { return; } + if ( Array.isArray(lval) ) { return; } + for ( const [ k, v ] of Object.entries(rval) ) { + lval[k] = v; + } + } } diff --git a/src/js/resources/json-edit.js b/src/js/resources/json-edit.js index 39dec68c2..7622afd37 100644 --- a/src/js/resources/json-edit.js +++ b/src/js/resources/json-edit.js @@ -30,21 +30,28 @@ import { proxyApplyFn } from './proxy-apply.js'; import { registerScriptlet } from './base.js'; import { safeSelf } from './safe-self.js'; +/******************************************************************************/ /******************************************************************************/ -function jsonEditFn(trusted, jsonq = '') { +function editOutboundObjectFn( + trusted = false, + propChain = '', + jsonq = '', +) { + if ( propChain === '' ) { return; } const safe = safeSelf(); const logPrefix = safe.makeLogPrefix( - `${trusted ? 'trusted-' : ''}json-edit`, + `${trusted ? 'trusted-' : ''}edit-outbound-object`, + propChain, 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) { + proxyApplyFn(propChain, function(context) { const obj = context.reflect(); - if ( jsonp.apply(obj) !== 0 ) { return obj; } + 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)}`); @@ -52,8 +59,8 @@ function jsonEditFn(trusted, jsonq = '') { return obj; }); } -registerScriptlet(jsonEditFn, { - name: 'json-edit.fn', +registerScriptlet(editOutboundObjectFn, { + name: 'edit-outbound-object.fn', dependencies: [ JSONPath, proxyApplyFn, @@ -61,6 +68,54 @@ registerScriptlet(jsonEditFn, { ], }); +/******************************************************************************/ +/** + * @scriptlet edit-outbound-object-.js + * + * @description + * Prune properties from an object returned by a specific method. + * Properties can only be removed. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * */ + +function editOutboundObject(propChain = '', jsonq = '') { + editOutboundObjectFn(false, propChain, jsonq); +} +registerScriptlet(editOutboundObject, { + name: 'edit-outbound-object.js', + dependencies: [ + editOutboundObjectFn, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet trusted-edit-outbound-object.js + * + * @description + * Edit properties from an object returned by a specific method. + * Properties can be assigned new values. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * */ + +function trustedEditOutboundObject(propChain = '', jsonq = '') { + editOutboundObjectFn(true, propChain, jsonq); +} +registerScriptlet(trustedEditOutboundObject, { + name: 'trusted-edit-outbound-object.js', + requiresTrust: true, + dependencies: [ + editOutboundObjectFn, + ], +}); + +/******************************************************************************/ /******************************************************************************/ /** * @scriptlet json-edit.js @@ -75,12 +130,12 @@ registerScriptlet(jsonEditFn, { * */ function jsonEdit(jsonq = '') { - jsonEditFn(false, jsonq); + editOutboundObjectFn(false, 'JSON.parse', jsonq); } registerScriptlet(jsonEdit, { name: 'json-edit.js', dependencies: [ - jsonEditFn, + editOutboundObjectFn, ], }); @@ -98,13 +153,13 @@ registerScriptlet(jsonEdit, { * */ function trustedJsonEdit(jsonq = '') { - jsonEditFn(true, jsonq); + editOutboundObjectFn(true, 'JSON.parse', jsonq); } registerScriptlet(trustedJsonEdit, { name: 'trusted-json-edit.js', requiresTrust: true, dependencies: [ - jsonEditFn, + editOutboundObjectFn, ], }); @@ -242,6 +297,124 @@ registerScriptlet(trustedJsonEditXhrResponse, { /******************************************************************************/ /******************************************************************************/ +function jsonEditXhrRequestFn(trusted, jsonq = '') { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix( + `${trusted ? 'trusted-' : ''}json-edit-xhr-request`, + 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); + } + send(body) { + const xhrDetails = xhrInstances.get(this); + if ( xhrDetails ) { + body = this.#filterBody(body) || body; + } + super.send(body); + } + #filterBody(body) { + if ( typeof body !== 'string' ) { return; } + let data; + try { data = safe.JSON_parse(body); } + catch { } + if ( data instanceof Object === false ) { return; } + const n = jsonp.apply(data); + if ( n === 0 ) { return; } + body = safe.JSON_stringify(data); + safe.uboLog(logPrefix, 'Edited'); + if ( safe.logLevel > 1 ) { + safe.uboLog(logPrefix, `After edit:\n${body}`); + } + return body; + } + }; +} +registerScriptlet(jsonEditXhrRequestFn, { + name: 'json-edit-xhr-request.fn', + dependencies: [ + JSONPath, + matchObjectPropertiesFn, + parsePropertiesToMatchFn, + safeSelf, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet json-edit-xhr-request.js + * + * @description + * Edit the object sent as the body in 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 jsonEditXhrRequest(jsonq = '', ...args) { + jsonEditXhrRequestFn(false, jsonq, ...args); +} +registerScriptlet(jsonEditXhrRequest, { + name: 'json-edit-xhr-request.js', + dependencies: [ + jsonEditXhrRequestFn, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet trusted-json-edit-xhr-request.js + * + * @description + * Edit the object sent as the body in 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 trustedJsonEditXhrRequest(jsonq = '', ...args) { + jsonEditXhrRequestFn(true, jsonq, ...args); +} +registerScriptlet(trustedJsonEditXhrRequest, { + name: 'trusted-json-edit-xhr-request.js', + requiresTrust: true, + dependencies: [ + jsonEditXhrRequestFn, + ], +}); + +/******************************************************************************/ +/******************************************************************************/ + function jsonEditFetchResponseFn(trusted, jsonq = '') { const safe = safeSelf(); const logPrefix = safe.makeLogPrefix( @@ -372,6 +545,131 @@ registerScriptlet(trustedJsonEditFetchResponse, { /******************************************************************************/ /******************************************************************************/ +function jsonEditFetchRequestFn(trusted, jsonq = '') { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix( + `${trusted ? 'trusted-' : ''}json-edit-fetch-request`, + 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 filterBody = body => { + if ( typeof body !== 'string' ) { return; } + let data; + try { data = safe.JSON_parse(body); } + catch { } + if ( data instanceof Object === false ) { return; } + const n = jsonp.apply(data); + if ( n === 0 ) { return; } + return safe.JSON_stringify(data); + } + const proxyHandler = context => { + const args = context.callArgs; + const [ resource, options ] = args; + const bodyBefore = options?.body; + if ( Boolean(bodyBefore) === false ) { return context.reflect(); } + const bodyAfter = filterBody(bodyBefore); + if ( bodyAfter === undefined || bodyAfter === bodyBefore ) { + return context.reflect(); + } + if ( propNeedles.size !== 0 ) { + const objs = [ + resource instanceof Object ? resource : { url: `${resource}` } + ]; + if ( objs[0] instanceof Request ) { + try { + objs[0] = safe.Request_clone.call(objs[0]); + } catch(ex) { + safe.uboErr(logPrefix, 'Error:', ex); + } + } + const matched = matchObjectPropertiesFn(propNeedles, ...objs); + if ( matched === undefined ) { return context.reflect(); } + if ( safe.logLevel > 1 ) { + safe.uboLog(logPrefix, `Matched "propsToMatch":\n\t${matched.join('\n\t')}`); + } + } + safe.uboLog(logPrefix, 'Edited'); + if ( safe.logLevel > 1 ) { + safe.uboLog(logPrefix, `After edit:\n${bodyAfter}`); + } + options.body = bodyAfter; + return context.reflect(); + }; + proxyApplyFn('fetch', proxyHandler); + proxyApplyFn('Request', proxyHandler); +} +registerScriptlet(jsonEditFetchRequestFn, { + name: 'json-edit-fetch-request.fn', + dependencies: [ + JSONPath, + matchObjectPropertiesFn, + parsePropertiesToMatchFn, + proxyApplyFn, + safeSelf, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet json-edit-fetch-request.js + * + * @description + * Edit the request body sent 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 fetch() is called. + * + * */ + +function jsonEditFetchRequest(jsonq = '', ...args) { + jsonEditFetchRequestFn(false, jsonq, ...args); +} +registerScriptlet(jsonEditFetchRequest, { + name: 'json-edit-fetch-request.js', + dependencies: [ + jsonEditFetchRequestFn, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet trusted-json-edit-fetch-request.js + * + * @description + * Edit the request body sent 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 fetch() is called. + * + * */ + +function trustedJsonEditFetchRequest(jsonq = '', ...args) { + jsonEditFetchRequestFn(true, jsonq, ...args); +} +registerScriptlet(trustedJsonEditFetchRequest, { + name: 'trusted-json-edit-fetch-request.js', + requiresTrust: true, + dependencies: [ + jsonEditFetchRequestFn, + ], +}); + +/******************************************************************************/ +/******************************************************************************/ + function jsonlEditFn(jsonp, text = '') { const safe = safeSelf(); const lineSeparator = /\r?\n/.exec(text)?.[0] || '\n';