Add trusted-prevent-fetch scriptlet

Related feedback:
https://github.com/uBlockOrigin/uBlock-discussions/discussions/915#discussioncomment-12077068
This commit is contained in:
Raymond Hill 2025-04-03 11:59:00 -04:00
parent 2501eae25f
commit 4ce26b63ff
No known key found for this signature in database
GPG key ID: 25E1490B761470C2
3 changed files with 285 additions and 186 deletions

View file

@ -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,
],
});
/******************************************************************************/

View file

@ -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: [

View file

@ -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,
],
});
/******************************************************************************/