From 4ce26b63ff5d0560ec3f529e275a8430a0db7327 Mon Sep 17 00:00:00 2001 From: Raymond Hill Date: Thu, 3 Apr 2025 11:59:00 -0400 Subject: [PATCH] Add `trusted-prevent-fetch` scriptlet Related feedback: https://github.com/uBlockOrigin/uBlock-discussions/discussions/915#discussioncomment-12077068 --- src/js/resources/prevent-fetch.js | 208 ++++++++++++++++++++++++++++++ src/js/resources/scriptlets.js | 188 +-------------------------- src/js/resources/utils.js | 75 +++++++++++ 3 files changed, 285 insertions(+), 186 deletions(-) create mode 100644 src/js/resources/prevent-fetch.js diff --git a/src/js/resources/prevent-fetch.js b/src/js/resources/prevent-fetch.js new file mode 100644 index 000000000..1b142a74c --- /dev/null +++ b/src/js/resources/prevent-fetch.js @@ -0,0 +1,208 @@ +/******************************************************************************* + + 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 { generateContentFn } from './utils.js'; +import { proxyApplyFn } from './proxy-apply.js'; +import { registerScriptlet } from './base.js'; +import { safeSelf } from './safe-self.js'; + +/******************************************************************************/ + +function preventFetchFn( + trusted = false, + propsToMatch = '', + responseBody = '', + responseType = '' +) { + const safe = safeSelf(); + const scriptletName = `${trusted ? 'trusted-' : ''}prevent-fetch`; + const logPrefix = safe.makeLogPrefix( + scriptletName, + propsToMatch, + responseBody, + responseType + ); + const needles = []; + for ( const condition of safe.String_split.call(propsToMatch, /\s+/) ) { + if ( condition === '' ) { continue; } + const pos = condition.indexOf(':'); + let key, value; + if ( pos !== -1 ) { + key = condition.slice(0, pos); + value = condition.slice(pos + 1); + } else { + key = 'url'; + value = condition; + } + needles.push({ key, pattern: safe.initPattern(value, { canNegate: true }) }); + } + const validResponseProps = { + ok: [ false, true ], + statusText: [ '', 'Not Found' ], + type: [ 'basic', 'cors', 'default', 'error', 'opaque' ], + }; + const responseProps = { + statusText: { value: 'OK' }, + }; + if ( /^\{.*\}$/.test(responseType) ) { + try { + Object.entries(JSON.parse(responseType)).forEach(([ p, v ]) => { + if ( validResponseProps[p] === undefined ) { return; } + if ( validResponseProps[p].includes(v) === false ) { return; } + responseProps[p] = { value: v }; + }); + } + catch { } + } else if ( responseType !== '' ) { + if ( validResponseProps.type.includes(responseType) ) { + responseProps.type = { value: responseType }; + } + } + proxyApplyFn('fetch', function fetch(context) { + const { callArgs } = context; + const details = callArgs[0] instanceof self.Request + ? callArgs[0] + : Object.assign({ url: callArgs[0] }, callArgs[1]); + let proceed = true; + try { + const props = new Map(); + for ( const prop in details ) { + let v = details[prop]; + if ( typeof v !== 'string' ) { + try { v = safe.JSON_stringify(v); } + catch { } + } + if ( typeof v !== 'string' ) { continue; } + props.set(prop, v); + } + if ( safe.logLevel > 1 || propsToMatch === '' && responseBody === '' ) { + const out = Array.from(props).map(a => `${a[0]}:${a[1]}`); + safe.uboLog(logPrefix, `Called: ${out.join('\n')}`); + } + if ( propsToMatch === '' && responseBody === '' ) { + return context.reflect(); + } + proceed = needles.length === 0; + for ( const { key, pattern } of needles ) { + if ( + pattern.expect && props.has(key) === false || + safe.testPattern(pattern, props.get(key)) === false + ) { + proceed = true; + break; + } + } + } catch { + } + if ( proceed ) { + return context.reflect(); + } + return Promise.resolve(generateContentFn(trusted, responseBody)).then(text => { + safe.uboLog(logPrefix, `Prevented with response "${text}"`); + const response = new Response(text, { + headers: { + 'Content-Length': text.length, + } + }); + const props = Object.assign( + { url: { value: details.url } }, + responseProps + ); + safe.Object_defineProperties(response, props); + return response; + }); + }); +} +registerScriptlet(preventFetchFn, { + name: 'prevent-fetch.fn', + dependencies: [ + generateContentFn, + proxyApplyFn, + safeSelf, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet prevent-fetch + * + * @description + * Prevent a fetch() call from making a network request to a remote server. + * + * @param propsToMatch + * The fetch arguments to match for the prevention to be triggered. The + * untrusted flavor limits the realm of response to return to safe values. + * + * @param [responseBody] + * Optional. The reponse to return when the prevention occurs. + * + * @param [responseType] + * Optional. The response type to use when emitting a dummy response as a + * result of the prevention. + * + * */ + +function preventFetch(...args) { + preventFetchFn(false, ...args); +} +registerScriptlet(preventFetch, { + name: 'prevent-fetch.js', + aliases: [ + 'no-fetch-if.js', + ], + dependencies: [ + preventFetchFn, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet trusted-prevent-fetch + * + * @description + * Prevent a fetch() call from making a network request to a remote server. + * + * @param propsToMatch + * The fetch arguments to match for the prevention to be triggered. + * + * @param [responseBody] + * Optional. The reponse to return when the prevention occurs. The trusted + * flavor allows to return any response. + * + * @param [responseType] + * Optional. The response type to use when emitting a dummy response as a + * result of the prevention. + * + * */ + +function trustedPreventFetch(...args) { + preventFetchFn(true, ...args); +} +registerScriptlet(trustedPreventFetch, { + name: 'trusted-prevent-fetch.js', + requiresTrust: true, + dependencies: [ + preventFetchFn, + ], +}); + +/******************************************************************************/ diff --git a/src/js/resources/scriptlets.js b/src/js/resources/scriptlets.js index 0526d022a..c6ef19ec9 100755 --- a/src/js/resources/scriptlets.js +++ b/src/js/resources/scriptlets.js @@ -26,12 +26,14 @@ import './json-edit.js'; import './json-prune.js'; import './noeval.js'; import './object-prune.js'; +import './prevent-fetch.js'; import './prevent-innerHTML.js'; import './prevent-settimeout.js'; import './replace-argument.js'; import './spoof-css.js'; import { + generateContentFn, getExceptionTokenFn, getRandomTokenFn, matchObjectPropertiesFn, @@ -73,79 +75,6 @@ function shouldDebug(details) { /******************************************************************************/ -// Reference: -// https://github.com/AdguardTeam/Scriptlets/blob/master/wiki/about-scriptlets.md#prevent-xhr -// -// Added `trusted` argument to allow for returning arbitrary text. Can only -// be used through scriptlets requiring trusted source. - -builtinScriptlets.push({ - name: 'generate-content.fn', - fn: generateContentFn, - dependencies: [ - 'safe-self.fn', - ], -}); -function generateContentFn(trusted, directive) { - const safe = safeSelf(); - const randomize = len => { - const chunks = []; - let textSize = 0; - do { - const s = safe.Math_random().toString(36).slice(2); - chunks.push(s); - textSize += s.length; - } - while ( textSize < len ); - return chunks.join(' ').slice(0, len); - }; - if ( directive === 'true' ) { - return randomize(10); - } - if ( directive === 'emptyObj' ) { - return '{}'; - } - if ( directive === 'emptyArr' ) { - return '[]'; - } - if ( directive === 'emptyStr' ) { - return ''; - } - if ( directive.startsWith('length:') ) { - const match = /^length:(\d+)(?:-(\d+))?$/.exec(directive); - if ( match === null ) { return ''; } - const min = parseInt(match[1], 10); - const extent = safe.Math_max(parseInt(match[2], 10) || 0, min) - min; - const len = safe.Math_min(min + extent * safe.Math_random(), 500000); - return randomize(len | 0); - } - if ( directive.startsWith('war:') ) { - if ( scriptletGlobals.warOrigin === undefined ) { return ''; } - return new Promise(resolve => { - const warOrigin = scriptletGlobals.warOrigin; - const warName = directive.slice(4); - const fullpath = [ warOrigin, '/', warName ]; - const warSecret = scriptletGlobals.warSecret; - if ( warSecret !== undefined ) { - fullpath.push('?secret=', warSecret); - } - const warXHR = new safe.XMLHttpRequest(); - warXHR.responseType = 'text'; - warXHR.onloadend = ev => { - resolve(ev.target.responseText || ''); - }; - warXHR.open('GET', fullpath.join('')); - warXHR.send(); - }).catch(( ) => ''); - } - if ( trusted ) { - return directive; - } - return ''; -} - -/******************************************************************************/ - builtinScriptlets.push({ name: 'abort-current-script-core.fn', fn: abortCurrentScriptCore, @@ -981,119 +910,6 @@ function adjustSetTimeout( /******************************************************************************/ -builtinScriptlets.push({ - name: 'prevent-fetch.js', - aliases: [ - 'no-fetch-if.js', - ], - fn: noFetchIf, - dependencies: [ - 'generate-content.fn', - 'proxy-apply.fn', - 'safe-self.fn', - ], -}); -function noFetchIf( - propsToMatch = '', - responseBody = '', - responseType = '' -) { - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('prevent-fetch', propsToMatch, responseBody, responseType); - const needles = []; - for ( const condition of safe.String_split.call(propsToMatch, /\s+/) ) { - if ( condition === '' ) { continue; } - const pos = condition.indexOf(':'); - let key, value; - if ( pos !== -1 ) { - key = condition.slice(0, pos); - value = condition.slice(pos + 1); - } else { - key = 'url'; - value = condition; - } - needles.push({ key, pattern: safe.initPattern(value, { canNegate: true }) }); - } - const validResponseProps = { - ok: [ false, true ], - statusText: [ '', 'Not Found' ], - type: [ 'basic', 'cors', 'default', 'error', 'opaque' ], - }; - const responseProps = { - statusText: { value: 'OK' }, - }; - if ( /^\{.*\}$/.test(responseType) ) { - try { - Object.entries(JSON.parse(responseType)).forEach(([ p, v ]) => { - if ( validResponseProps[p] === undefined ) { return; } - if ( validResponseProps[p].includes(v) === false ) { return; } - responseProps[p] = { value: v }; - }); - } - catch { } - } else if ( responseType !== '' ) { - if ( validResponseProps.type.includes(responseType) ) { - responseProps.type = { value: responseType }; - } - } - proxyApplyFn('fetch', function fetch(context) { - const { callArgs } = context; - const details = callArgs[0] instanceof self.Request - ? callArgs[0] - : Object.assign({ url: callArgs[0] }, callArgs[1]); - let proceed = true; - try { - const props = new Map(); - for ( const prop in details ) { - let v = details[prop]; - if ( typeof v !== 'string' ) { - try { v = safe.JSON_stringify(v); } - catch { } - } - if ( typeof v !== 'string' ) { continue; } - props.set(prop, v); - } - if ( safe.logLevel > 1 || propsToMatch === '' && responseBody === '' ) { - const out = Array.from(props).map(a => `${a[0]}:${a[1]}`); - safe.uboLog(logPrefix, `Called: ${out.join('\n')}`); - } - if ( propsToMatch === '' && responseBody === '' ) { - return context.reflect(); - } - proceed = needles.length === 0; - for ( const { key, pattern } of needles ) { - if ( - pattern.expect && props.has(key) === false || - safe.testPattern(pattern, props.get(key)) === false - ) { - proceed = true; - break; - } - } - } catch { - } - if ( proceed ) { - return context.reflect(); - } - return Promise.resolve(generateContentFn(false, responseBody)).then(text => { - safe.uboLog(logPrefix, `Prevented with response "${text}"`); - const response = new Response(text, { - headers: { - 'Content-Length': text.length, - } - }); - const props = Object.assign( - { url: { value: details.url } }, - responseProps - ); - safe.Object_defineProperties(response, props); - return response; - }); - }); -} - -/******************************************************************************/ - builtinScriptlets.push({ name: 'prevent-refresh.js', aliases: [ diff --git a/src/js/resources/utils.js b/src/js/resources/utils.js index 86e55e991..f44849519 100644 --- a/src/js/resources/utils.js +++ b/src/js/resources/utils.js @@ -23,6 +23,9 @@ import { registerScriptlet } from './base.js'; import { safeSelf } from './safe-self.js'; +// Externally added to the private namespace in which scriptlets execute. +/* global scriptletGlobals */ + /******************************************************************************/ export function getRandomTokenFn() { @@ -115,3 +118,75 @@ registerScriptlet(matchObjectPropertiesFn, { }); /******************************************************************************/ + +// Reference: +// https://github.com/AdguardTeam/Scriptlets/blob/master/wiki/about-scriptlets.md#prevent-xhr +// +// Added `trusted` argument to allow for returning arbitrary text. Can only +// be used through scriptlets requiring trusted source. + +export function generateContentFn(trusted, directive) { + const safe = safeSelf(); + const randomize = len => { + const chunks = []; + let textSize = 0; + do { + const s = safe.Math_random().toString(36).slice(2); + chunks.push(s); + textSize += s.length; + } + while ( textSize < len ); + return chunks.join(' ').slice(0, len); + }; + if ( directive === 'true' ) { + return randomize(10); + } + if ( directive === 'emptyObj' ) { + return '{}'; + } + if ( directive === 'emptyArr' ) { + return '[]'; + } + if ( directive === 'emptyStr' ) { + return ''; + } + if ( directive.startsWith('length:') ) { + const match = /^length:(\d+)(?:-(\d+))?$/.exec(directive); + if ( match === null ) { return ''; } + const min = parseInt(match[1], 10); + const extent = safe.Math_max(parseInt(match[2], 10) || 0, min) - min; + const len = safe.Math_min(min + extent * safe.Math_random(), 500000); + return randomize(len | 0); + } + if ( directive.startsWith('war:') ) { + if ( scriptletGlobals.warOrigin === undefined ) { return ''; } + return new Promise(resolve => { + const warOrigin = scriptletGlobals.warOrigin; + const warName = directive.slice(4); + const fullpath = [ warOrigin, '/', warName ]; + const warSecret = scriptletGlobals.warSecret; + if ( warSecret !== undefined ) { + fullpath.push('?secret=', warSecret); + } + const warXHR = new safe.XMLHttpRequest(); + warXHR.responseType = 'text'; + warXHR.onloadend = ev => { + resolve(ev.target.responseText || ''); + }; + warXHR.open('GET', fullpath.join('')); + warXHR.send(); + }).catch(( ) => ''); + } + if ( trusted ) { + return directive; + } + return ''; +} +registerScriptlet(generateContentFn, { + name: 'generate-content.fn', + dependencies: [ + safeSelf, + ], +}); + +/******************************************************************************/