From cd6bd92365d29c33fb0224b11a0ce1b06748d4e1 Mon Sep 17 00:00:00 2001 From: joshrmcdaniel <80354972+joshrmcdaniel@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:24:32 -0600 Subject: [PATCH 1/3] scriptlet update --- CHANGELOG.md | 12 + README.md | 28 + privaxy/build-scriptlets.mjs | 109 +- privaxy/build.rs | 6 +- privaxy/src/resources/vendor/ublock/README.md | 28 + .../vendor/ublock/SCRIPTLETS-LICENSE.txt | 674 +++ .../resources/vendor/ublock/arglist-parser.js | 116 + .../src/resources/vendor/ublock/jsonpath.js | 632 +++ .../src/resources/vendor/ublock/package.json | 4 + .../abort-current-script.js | 123 + .../ublock/scriptlet-resources/attribute.js | 337 ++ .../vendor/ublock/scriptlet-resources/base.js | 38 + .../ublock/scriptlet-resources/cookie.js | 446 ++ .../ublock/scriptlet-resources/create-html.js | 144 + .../scriptlet-resources/href-sanitizer.js | 172 + .../ublock/scriptlet-resources/json-edit.js | 1672 ++++++ .../ublock/scriptlet-resources/json-prune.js | 269 + .../scriptlet-resources/localstorage.js | 210 + .../ublock/scriptlet-resources/noeval.js | 58 + .../scriptlet-resources/object-prune.js | 274 + .../scriptlet-resources/parse-replace.js | 54 + .../prevent-addeventlistener.js | 160 + .../ublock/scriptlet-resources/prevent-bab.js | 101 + .../prevent-clipboard-write.js | 153 + .../scriptlet-resources/prevent-dialog.js | 73 + .../scriptlet-resources/prevent-fetch.js | 198 + .../scriptlet-resources/prevent-innerHTML.js | 132 + .../scriptlet-resources/prevent-navigation.js | 67 + .../scriptlet-resources/prevent-settimeout.js | 236 + .../ublock/scriptlet-resources/prevent-xhr.js | 329 ++ .../ublock/scriptlet-resources/proxy-apply.js | 169 + .../scriptlet-resources/replace-argument.js | 133 + .../ublock/scriptlet-resources/run-at.js | 96 + .../ublock/scriptlet-resources/safe-self.js | 210 + .../ublock/scriptlet-resources/scriptlets.js | 2103 ++++++++ .../scriptlet-resources/set-constant.js | 288 + .../ublock/scriptlet-resources/shared.js | 40 + .../ublock/scriptlet-resources/spoof-css.js | 163 + .../ublock/scriptlet-resources/stack-trace.js | 149 + .../ublock/scriptlet-resources/utils.js | 445 ++ .../vendor/ublock/scriptlet-resources/vod.js | 175 + .../src/resources/vendor/ublock/scriptlets.js | 4804 +---------------- .../src/resources/vendor/ublock/urlskip.js | 162 + privaxy/src/server/blocker.rs | 144 +- privaxy/src/server/configuration/filter.rs | 200 +- .../server/configuration/filter/download.rs | 401 ++ privaxy/src/server/configuration/mod.rs | 3 +- privaxy/src/server/filter_list.rs | 284 + privaxy/src/server/lib.rs | 1 + privaxy/src/server/proxy/html_rewriter.rs | 5 +- 50 files changed, 11907 insertions(+), 4923 deletions(-) create mode 100644 privaxy/src/resources/vendor/ublock/README.md create mode 100644 privaxy/src/resources/vendor/ublock/SCRIPTLETS-LICENSE.txt create mode 100644 privaxy/src/resources/vendor/ublock/arglist-parser.js create mode 100644 privaxy/src/resources/vendor/ublock/jsonpath.js create mode 100644 privaxy/src/resources/vendor/ublock/package.json create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/abort-current-script.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/attribute.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/base.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/cookie.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/create-html.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/href-sanitizer.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/json-edit.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/json-prune.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/localstorage.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/noeval.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/object-prune.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/parse-replace.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-addeventlistener.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-bab.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-clipboard-write.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-dialog.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-fetch.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-innerHTML.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-navigation.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-settimeout.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-xhr.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/proxy-apply.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/replace-argument.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/run-at.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/safe-self.js create mode 100755 privaxy/src/resources/vendor/ublock/scriptlet-resources/scriptlets.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/set-constant.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/shared.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/spoof-css.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/stack-trace.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/utils.js create mode 100644 privaxy/src/resources/vendor/ublock/scriptlet-resources/vod.js create mode 100644 privaxy/src/resources/vendor/ublock/urlskip.js create mode 100644 privaxy/src/server/configuration/filter/download.rs create mode 100644 privaxy/src/server/filter_list.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f4bbf8f2..25ff8c6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +- Use the combined uBlock Origin CDN lists for Ads, Badware risks, Privacy, Quick + fixes, and Unbreak. Migrate existing subscriptions without changing enabled + states, and retain an existing CDN subscription when an old source duplicates it. +- Update bundled uBO scriptlets to 1.76.2, including JSON-edit and newer video + response hooks. Preserve shared dependencies and all scriptlet arguments, + isolate helpers from page globals, and honor trusted-scriptlet permissions. +- Evaluate nested filter-list conditions before rule parsing using a portable + capability profile. Expand active relative `!#include` sub-lists, including the + uBO-specific rules in EasyList Cookie and Fanboy's Annoyance. Bound downloads, + reject cycles and paths outside the permitted source, and preserve the last + complete cache if a sub-list fails. Report malformed directives and unknown + conditions; log loaded-rule counts and reasons for unsupported or invalid rules. - Add a shared footer with the running server version linked to the project. Check for stable releases automatically when opened and hourly thereafter, showing an "Update available" release link only when a newer version is found. diff --git a/README.md b/README.md index 8910da1b..de59858b 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,34 @@ blocker more than it strictly replaces one. - [filterlists.com](https://filterlists.com) integration - Ability to add custom filters +Privaxy uses the combined uBlock Origin CDN lists for Ads, Badware risks, +Privacy, Quick fixes, and Unbreak. Existing source-list subscriptions migrate +automatically, preserving their enabled state. An already configured CDN +subscription takes precedence over its old source-list duplicate. Separate +mobile and resource-abuse subscriptions are retained for existing choices, +although their rules are also included in the combined Ads and Privacy lists. + +Filter-list `!#if`, `!#else`, and `!#endif` directives use a portable uBO syntax +profile (`ext_ublock` enabled, browser-specific flags and unsupported capabilities +disabled). This selects scriptlet fallbacks for `!cap_html_filtering`; proxy HTML +injection does not implement uBO's HTML-filtering or `$replace` syntax. Unknown +conditions retain both branches, matching uBO, and are reported in the logs. +Active `!#include` directives load relative sub-lists from the same directory or +its descendants, following uBO's subscription format. Includes are expanded +before caching; inactive branches are not downloaded. Failed downloads, +malformed conditions, cycles, and includes outside the permitted source leave +the previous complete cache untouched. Downloads are limited to 64 files, +16 include levels, and 64 MiB per subscription. Custom filters have no source URL +and cannot use includes. + +Bundled scriptlets come from uBlock Origin 1.76.2. Scriptlets marked +`requiresTrust` are available to the official uBO filter repositories and manually +entered Custom filters, but not arbitrary remote subscriptions. Unsupported +filter rules are counted by rejection reason alongside the number loaded. These +warnings do not mean the whole list failed to load. Filter syntax and browser +integration still differ from the uBO extension, so list compatibility does not +guarantee identical results on every video site. + ## Installation You can either utilize the docker image, binary, or the deb avaiable in releases. diff --git a/privaxy/build-scriptlets.mjs b/privaxy/build-scriptlets.mjs index f55b44a7..d7dcc861 100644 --- a/privaxy/build-scriptlets.mjs +++ b/privaxy/build-scriptlets.mjs @@ -1,81 +1,40 @@ -// Compiles uBlock Origin's modern `scriptlets.js` (the -// `builtinScriptlets.push({ name, fn, dependencies, ... })` format) into the -// JSON Resource schema expected by adblock-rust's `Engine::use_resources`. -// -// Strategy lifted from brave/brave-core-crx-packager `lib/adBlockRustUtils.js` -// (MPL-2.0): walk each scriptlet's transitive `dependencies`, inline the -// dependency function source as a prelude, then wrap the scriptlet body in a -// shim that exposes the legacy numbered template args `{{1}}`..`{{9}}`. The -// `as_css` / numbered-arg style is what adblock-rust's templating layer -// understands; the new format's positional `fn(...args)` calling convention -// is bridged inside the shim. -// -// Invoked from build.rs as `node build-scriptlets.mjs `. - -import { promises as fs } from 'fs'; -import os from 'os'; -import path from 'path'; -import { pathToFileURL } from 'url'; +// Convert the pinned uBlock Origin modules into adblock-rust resources. +// Keep functions and dependency helpers separate: the engine deduplicates them +// and calls each scriptlet with its complete, safely quoted argument list. +// Usage: node build-scriptlets.mjs +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; const [, , scriptletsPath, outPath] = process.argv; if (!scriptletsPath || !outPath) { - console.error('usage: node build-scriptlets.mjs '); - process.exit(2); + throw new Error('usage: node build-scriptlets.mjs '); } -const wrap = (fnString, dependencyPrelude) => `{ - const args = ["{{1}}", "{{2}}", "{{3}}", "{{4}}", "{{5}}", "{{6}}", "{{7}}", "{{8}}", "{{9}}"]; - let last_arg_index = 0; - for (const arg_index in args) { - if (args[arg_index] === '{{' + (Number(arg_index) + 1) + '}}') { - break; - } - last_arg_index += 1; - } - ${dependencyPrelude} - (${fnString})(...args.slice(0, last_arg_index)) -}`; - -// scriptlets.js is plain ESM but lives under a `.js` extension in the vendor -// dir. Node only treats `.js` as ESM with a sibling `package.json` declaring -// `"type": "module"`, which we don't want to add. Copy to a `.mjs` in a -// scratch dir so dynamic import resolves it unambiguously as ESM. -const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'privaxy-scriptlets-')); -const tmpFile = path.join(tmpDir, 'scriptlets.mjs'); -try { - await fs.writeFile(tmpFile, await fs.readFile(scriptletsPath, 'utf8')); - const { builtinScriptlets } = await import(pathToFileURL(tmpFile).href); - - const byName = new Map(builtinScriptlets.map((e) => [e.name, e])); - - const resources = builtinScriptlets - // `.fn`-suffixed entries are pure dependency helpers (e.g. `safe-self.fn`), - // not user-invocable scriptlets. - .filter((s) => !s.name.endsWith('.fn')) - .map((s) => { - const deps = [...(s.dependencies ?? [])]; - for (const d of deps) { - for (const rd of byName.get(d)?.dependencies ?? []) { - if (!deps.includes(rd)) deps.push(rd); - } - } - let prelude = ''; - // Reverse so leaf dependencies are declared before their consumers. - for (const d of deps.reverse()) { - const entry = byName.get(d); - if (!entry) throw new Error(`Missing dependency: ${d} (referenced by ${s.name})`); - prelude += entry.fn.toString() + '\n'; - } - const content = Buffer.from(wrap(s.fn.toString(), prelude)).toString('base64'); - return { - name: s.name, - aliases: s.aliases ?? [], - kind: { mime: 'application/javascript' }, - content, - }; - }); - - await fs.writeFile(outPath, JSON.stringify(resources)); -} finally { - await fs.rm(tmpDir, { recursive: true, force: true }); +const { builtinScriptlets } = await import(pathToFileURL(path.resolve(scriptletsPath)).href); +const byName = new Map(builtinScriptlets.map(entry => [entry.name, entry])); +if (byName.size !== builtinScriptlets.length) { + throw new Error('Duplicate scriptlet resource name'); } + +const resources = builtinScriptlets.map(entry => { + const source = entry.fn.toString(); + if (!entry.name.endsWith('.fn') && !/^function\s+\w+\s*\(/.test(source)) { + throw new Error(`Scriptlet ${entry.name} must be a named function`); + } + for (const dependency of entry.dependencies ?? []) { + if (!byName.has(dependency)) { + throw new Error(`Missing dependency ${dependency} for ${entry.name}`); + } + } + return { + name: entry.name, + aliases: entry.aliases ?? [], + kind: { mime: 'application/javascript' }, + content: Buffer.from(source).toString('base64'), + dependencies: entry.dependencies ?? [], + // Must agree with TRUSTED_SCRIPTLETS in server/filter_list.rs. + permission: entry.requiresTrust ? 1 : 0, + }; +}); +await fs.writeFile(outPath, JSON.stringify(resources)); diff --git a/privaxy/build.rs b/privaxy/build.rs index 3043b920..36eafc5b 100644 --- a/privaxy/build.rs +++ b/privaxy/build.rs @@ -132,7 +132,11 @@ fn build_scriptlet_resources(manifest_dir: &Path, out_dir: &Path) { let builder = manifest_dir.join("build-scriptlets.mjs"); let out_path = out_dir.join("scriptlets-resources.json"); - println!("cargo:rerun-if-changed={}", scriptlets_src.display()); + // The entry point imports the pinned modules and shared parser helpers. + println!( + "cargo:rerun-if-changed={}", + manifest_dir.join("src/resources/vendor/ublock").display() + ); println!("cargo:rerun-if-changed={}", builder.display()); // Allow cross-compile environments without Node (e.g. the cross-rs MIPS diff --git a/privaxy/src/resources/vendor/ublock/README.md b/privaxy/src/resources/vendor/ublock/README.md new file mode 100644 index 00000000..a93304e6 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/README.md @@ -0,0 +1,28 @@ +# uBlock Origin resources + +The scriptlets are vendored from **uBlock Origin 1.76.2**, commit +[`e25a1b3b9e0a9c8db345453f1b8a0a9d109668b7`](https://github.com/gorhill/uBlock/tree/e25a1b3b9e0a9c8db345453f1b8a0a9d109668b7). + +- `scriptlet-resources/` is an unmodified copy of upstream `src/js/resources/`. +- `arglist-parser.js`, `jsonpath.js`, and `urlskip.js` are unmodified copies from + upstream `src/js/`, required by those modules. +- `SCRIPTLETS-LICENSE.txt` is upstream's GPL-3.0 license. Individual source files + retain their copyright and license notices. +- `scriptlets.js` is Privaxy's stable entry point; `package.json` enables ES modules + for Node during preprocessing. Neither file is injected into proxied pages. + +To update, copy these modules and dependencies from one pinned upstream commit, +preserve the license, and update this provenance. From `privaxy/`, run: + +```sh +mkdir -p prebuilt +node build-scriptlets.mjs src/resources/vendor/ublock/scriptlets.js prebuilt/scriptlets-resources.json +``` + +The generated resource JSON is a build artifact. Normal Cargo builds generate it +automatically; CI supplies it to cross-build containers without Node. The +converter preserves dependency names and `requiresTrust` metadata and uses +adblock-rust's function calling convention, including all filter arguments. + +The older `redirect-resources.js` and `web_accessible_resources/` remain separate +redirect resources; they are not part of the scriptlet version pin above. diff --git a/privaxy/src/resources/vendor/ublock/SCRIPTLETS-LICENSE.txt b/privaxy/src/resources/vendor/ublock/SCRIPTLETS-LICENSE.txt new file mode 100644 index 00000000..5e1c8e21 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/SCRIPTLETS-LICENSE.txt @@ -0,0 +1,674 @@ +GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. {http://fsf.org/} + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + {one line to give the program's name and a brief idea of what it does.} + Copyright (C) {year} {name of author} + + 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/}. + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + {{project}} Copyright (C) {{year}} {{fullname}} + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +{http://www.gnu.org/licenses/}. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +{http://www.gnu.org/philosophy/why-not-lgpl.html}. diff --git a/privaxy/src/resources/vendor/ublock/arglist-parser.js b/privaxy/src/resources/vendor/ublock/arglist-parser.js new file mode 100644 index 00000000..708029e2 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/arglist-parser.js @@ -0,0 +1,116 @@ +/******************************************************************************* + + uBlock Origin - a comprehensive, efficient content blocker + Copyright (C) 2020-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 +*/ + +/******************************************************************************/ + +export class ArglistParser { + constructor(separatorChar = ',', mustQuote = false) { + this.separatorChar = this.actualSeparatorChar = separatorChar; + this.separatorCode = this.actualSeparatorCode = separatorChar.charCodeAt(0); + this.mustQuote = mustQuote; + this.quoteBeg = 0; this.quoteEnd = 0; + this.argBeg = 0; this.argEnd = 0; + this.separatorBeg = 0; this.separatorEnd = 0; + this.transform = false; + this.failed = false; + this.reWhitespaceStart = /^\s+/; + this.reWhitespaceEnd = /(?:^|\S)(\s+)$/; + this.reOddTrailingEscape = /(?:^|[^\\])(?:\\\\)*\\$/; + this.reTrailingEscapeChars = /\\+$/; + } + nextArg(pattern, beg = 0) { + const len = pattern.length; + this.quoteBeg = beg + this.leftWhitespaceCount(pattern.slice(beg)); + this.failed = false; + const qc = pattern.charCodeAt(this.quoteBeg); + if ( qc === 0x22 /* " */ || qc === 0x27 /* ' */ || qc === 0x60 /* ` */ ) { + this.indexOfNextArgSeparator(pattern, qc); + if ( this.argEnd !== len ) { + this.quoteEnd = this.argEnd + 1; + this.separatorBeg = this.separatorEnd = this.quoteEnd; + this.separatorEnd += this.leftWhitespaceCount(pattern.slice(this.quoteEnd)); + if ( this.separatorEnd === len ) { return this; } + if ( pattern.charCodeAt(this.separatorEnd) === this.separatorCode ) { + this.separatorEnd += 1; + return this; + } + } + } + this.indexOfNextArgSeparator(pattern, this.separatorCode); + this.separatorBeg = this.separatorEnd = this.argEnd; + if ( this.separatorBeg < len ) { + this.separatorEnd += 1; + } + this.argEnd -= this.rightWhitespaceCount(pattern.slice(0, this.separatorBeg)); + this.quoteEnd = this.argEnd; + if ( this.mustQuote ) { + this.failed = true; + } + return this; + } + normalizeArg(s, char = '') { + if ( char === '' ) { char = this.actualSeparatorChar; } + let out = ''; + let pos = 0; + while ( (pos = s.lastIndexOf(char)) !== -1 ) { + out = s.slice(pos) + out; + s = s.slice(0, pos); + const match = this.reTrailingEscapeChars.exec(s); + if ( match === null ) { continue; } + const tail = (match[0].length & 1) !== 0 + ? match[0].slice(0, -1) + : match[0]; + out = tail + out; + s = s.slice(0, -match[0].length); + } + if ( out === '' ) { return s; } + return s + out; + } + leftWhitespaceCount(s) { + const match = this.reWhitespaceStart.exec(s); + return match === null ? 0 : match[0].length; + } + rightWhitespaceCount(s) { + const match = this.reWhitespaceEnd.exec(s); + return match === null ? 0 : match[1].length; + } + indexOfNextArgSeparator(pattern, separatorCode) { + this.argBeg = this.argEnd = separatorCode !== this.separatorCode + ? this.quoteBeg + 1 + : this.quoteBeg; + this.transform = false; + if ( separatorCode !== this.actualSeparatorCode ) { + this.actualSeparatorCode = separatorCode; + this.actualSeparatorChar = String.fromCharCode(separatorCode); + } + while ( this.argEnd < pattern.length ) { + const pos = pattern.indexOf(this.actualSeparatorChar, this.argEnd); + if ( pos === -1 ) { + return (this.argEnd = pattern.length); + } + if ( this.reOddTrailingEscape.test(pattern.slice(0, pos)) === false ) { + return (this.argEnd = pos); + } + this.transform = true; + this.argEnd = pos + 1; + } + } +} diff --git a/privaxy/src/resources/vendor/ublock/jsonpath.js b/privaxy/src/resources/vendor/ublock/jsonpath.js new file mode 100644 index 00000000..bcdb90c5 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/jsonpath.js @@ -0,0 +1,632 @@ +/******************************************************************************* + + uBlock Origin - a comprehensive, efficient content blocker + Copyright (C) 2025-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 + +*/ + +/** + * Implement the parsing of uBO-flavored JSON path queries. + * + * Reference to original JSON path syntax: + * https://www.rfc-editor.org/info/rfc9535/ + * + * uBO-flavored JSON path implementation differs as follow: + * - Array slice operator not supported + * - Selectors can be a `/`-delimited regex, i.e. `$./pattern/` + * - Onle a single filter selector is allowed in filter selector syntax + * + * - uBO-flavored JSON path supports a limited set of filter selectors: + * set of expressions since unlike the official implementation, uBO can't use + * JS eval() to evaluate arbitrary JS expressions. The operand MUST be valid + * JSON. The currently supported expressions are: + * - ==: strict equality + * - !=: strict inequality + * - <: less than + * - <=: less than or equal to + * - >: greater than + * - >=: greater than or equal to + * - ^=: stringified value starts with + * - $=: stringified value ends with + * - *=: stringified value includes + * - =/.../: true if the regular expression matches the stringified value + * + * - uBO-flavored JSON path supports assignement operator: + * - =[any well-formed JSON value] + * - =repl(...): [to be documented] i.e. {"regex":"...","flags":"i","replacement":"..."} + * - =call(...): [to be documented] i.e. ["${obj}","setAttribute","${key}"] + * * + * - Examples (from "JSONPath examples" at reference link) + * - .store.book[*].author + * - ..author + * - .store.* + * - .store..price + * - ..book[2] + * - ..book[?(.isbn)] + * - ..book[?(.price<10)] + * - ..* + * + * uBO-flavored syntax supports assigning a value to a resolved JSON path by + * appending `=[value]` to the JSON path query. The assigned value MUST be + * valid JSON. Examples: + * - .store..price=0 + * - .store.book[*].author="redacted" + * + * A JSONPath instance can be use to compile a JSON path query, and the result + * of the compilation can be applied to different objects. When a JSON path + * query does not assign a value, the resolved property will be removed. + * + * More capabilities can be added in the future as needed. + * + * */ + +export class JSONPath { + static create(query) { + const jsonp = new JSONPath(); + jsonp.compile(query); + return jsonp; + } + static toJSON(obj, stringifier, ...args) { + return (stringifier || JSON.stringify)(obj, ...args) + .replace(/\//g, '\\/'); + } + static keys = Object.keys; + static entries = Object.entries; + static hasOwn = Object.hasOwn; + static Regex = RegExp; + 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 = undefined; + this.v2 = query.startsWith('v2:'); + if ( this.v2 ) { query = query.slice(3); } + const r = this.#compile(query, 0); + if ( r === undefined ) { return; } + if ( r.i !== query.length ) { + let val; + if ( query.startsWith('=', r.i) ) { + const match = this.#reRval.exec(query.slice(r.i)); + if ( match ) { + r.modify = match[1]; + val = match[2]; + } else { + val = query.slice(r.i+1); + } + } else if ( query.startsWith('+=', r.i) ) { + r.modify = '+'; + val = query.slice(r.i+2); + } + try { r.rval = JSON.parse(val); } + catch { return; } + } + r.v2 = this.v2; + this.#compiled = r; + } + evaluate(root) { + if ( this.valid === false ) { return []; } + this.#root = { '$': root }; + const paths = this.#evaluate(this.#compiled.steps, []); + this.#root = null; + return paths; + } + apply(root) { + if ( this.valid === false ) { return; } + const { rval } = this.#compiled; + this.#root = { '$': root }; + const paths = this.#evaluate(this.#compiled.steps, []); + let i = paths.length + if ( i === 0 ) { this.#root = null; return; } + while ( i-- ) { + const { obj, key } = this.#resolvePath(paths[i]); + if ( obj === undefined ) { continue; } + if ( rval !== undefined ) { + this.#modifyVal(obj, key); + } else if ( Array.isArray(obj) && typeof key === 'number' ) { + obj.splice(key, 1); + } else { + delete obj[key]; + } + } + const result = this.#root['$'] ?? null; + this.#root = null; + return result; + } + dump() { + return JSON.stringify(this.#compiled); + } + toJSON(obj, ...args) { + return JSONPath.toJSON(obj, null, ...args) + } + get [Symbol.toStringTag]() { + return 'JSONPath'; + } + #UNDEFINED = 0; + #ROOT = 1; + #CURRENT = 2; + #CHILDREN = 3; + #DESCENDANTS = 4; + #QUANTIFIER = 5; + #reUnquotedIdentifier = /^[A-Za-z_][\w]*|^\*/; + #reExpr = /^\s*([!=^$*]=|[<>]=?)\s*(.+?)\]/; + #reIndice = /^-?\d+/; + #reRval = /^=([a-z]+)\((.+)\)$/; + #reQuantifier = /^\{(\d+|\d+,\d+|\d+,|,\d+)\};\$/; + #root; + #compiled; + #compile(query, i) { + if ( query.length === 0 ) { return; } + const steps = []; + let c = query.charCodeAt(i); + if ( c === 0x24 /* $ */ ) { + steps.push({ mv: this.#ROOT }); + i += 1; + } else if ( c === 0x40 /* @ */ ) { + steps.push({ mv: this.#CURRENT }); + i += 1; + } else { + steps.push({ mv: i === 0 ? this.#ROOT : this.#CURRENT }); + } + let mv = this.#UNDEFINED; + for (;;) { + if ( i === query.length ) { break; } + c = query.charCodeAt(i); + if ( c === 0x20 /* whitespace */ ) { + i += 1; + continue; + } + // Dot accessor syntax + if ( c === 0x2E /* . */ ) { + if ( mv !== this.#UNDEFINED ) { return; } + if ( query.startsWith('..', i) ) { + mv = this.#DESCENDANTS; + i += 2; + } else { + mv = this.#CHILDREN; + i += 1; + } + continue; + } + if ( c === 0x3B /* ; */ ) { + if ( query.startsWith(';$', i) === false ) { return; } + steps.push( + { mv: this.#QUANTIFIER, min: 1, max: 1e6 }, + { mv: this.#ROOT } + ); + i += 2; + mv = this.#UNDEFINED; + continue; + } + if ( c === 0x7B /* { */ ) { + const match = this.#reQuantifier.exec(query.slice(i)); + if ( match === null ) { return; } + const comma = match[1].indexOf(','); + let min, max; + if ( comma === -1 ) { + min = max = parseInt(match[1]); + } else { + min = parseInt(match[1].slice(0, comma)) || 0; + max = parseInt(match[1].slice(comma+1)) || 1e6; + } + steps.push( + { mv: this.#QUANTIFIER, min, max }, + { mv: this.#ROOT } + ); + i += match[0].length; + mv = this.#UNDEFINED; + continue; + } + if ( c !== 0x5B /* [ */ ) { + if ( mv === this.#UNDEFINED ) { + const step = steps.at(-1); + if ( step === undefined ) { return; } + const j = this.#compileExpr(query, step, i); + if ( j ) { i = j; } + break; + } + const r = this.#consumeUnquotedIdentifier(query, i); + if ( r === undefined ) { return; } + steps.push({ mv, k: r.s }); + i = r.i; + mv = this.#UNDEFINED; + continue; + } + // Bracket accessor syntax + if ( mv === this.#CHILDREN ) { return; } + if ( query.startsWith('[?', i) ) { + const not = query.charCodeAt(i+2) === 0x21 /* ! */ ? 1 : 0; + const j = i + 2 + not; + const r = this.#compile(query, j); + if ( r === undefined ) { return; } + if ( query.startsWith(']', r.i) === false ) { return; } + if ( not ) { r.steps.at(-1).not = true; } + steps.push({ mv: mv || this.#CHILDREN, steps: r.steps }); + i = r.i + 1; + mv = this.#UNDEFINED; + continue; + } + if ( query.startsWith('[*]', i) ) { + mv ||= this.#CHILDREN; + steps.push({ mv, k: '*' }); + i += 3; + mv = this.#UNDEFINED; + continue; + } + const r = this.#consumeIdentifier(query, i+1); + if ( r === undefined ) { return; } + mv ||= this.#CHILDREN; + steps.push({ mv, k: r.s }); + i = r.i + 1; + mv = this.#UNDEFINED; + } + if ( steps.length === 0 ) { return; } + if ( mv !== this.#UNDEFINED ) { return; } + return { steps, i }; + } + #evaluate(steps, pathin) { + let resultset = []; + if ( Array.isArray(steps) === false ) { return resultset; } + for ( const step of steps ) { + switch ( step.mv ) { + case this.#ROOT: + resultset = [ [ '$' ] ]; + break; + case this.#CURRENT: + if ( step.op ) { + const { obj, key } = this.#resolvePath(pathin); + if ( obj === undefined ) { return []; } + const outcome = this.#evaluateExpr(step, obj, key); + if ( outcome !== true ) { break; } + } + resultset = [ pathin ]; + break; + case this.#CHILDREN: + case this.#DESCENDANTS: { + if ( resultset.length === 0 ) { break; } + resultset = this.#getMatches(resultset, step); + break; + } + case this.#QUANTIFIER: { + const { length } = resultset; + if ( length < step.min || length > step.max ) { return []; } + resultset = []; + break; + } + default: + break; + } + } + return resultset; + } + #getMatches(listin, step) { + const listout = []; + for ( const pathin of listin ) { + const { value: owner } = this.#resolvePath(pathin); + if ( owner === undefined ) { continue; } + if ( step.steps ) { + this.#getMatchesFromExpr(pathin, step, owner, listout); + continue; + } + const iter = this.#expandKey(owner, step.k); + if ( iter ) { + for ( const k of iter ) { + const outcome = this.#evaluateExpr(step, owner, k); + if ( outcome !== true ) { continue; } + listout.push([ ...pathin, k ]); + } + } + if ( step.mv !== this.#DESCENDANTS ) { continue; } + for ( const { obj, key, path } of this.#getDescendants(owner, true) ) { + const iter = this.#expandKey(obj[key], step.k); + if ( iter === undefined ) { continue; } + for ( const k of iter ) { + const outcome = this.#evaluateExpr(step, obj[key], k); + if ( outcome !== true ) { continue; } + listout.push([ ...pathin, ...path, k ]); + } + } + } + return listout; + } + #expandKey(owner, k) { + if ( typeof owner !== 'object' || owner === null ) { return; } + if ( Array.isArray(k) ) { + const out = []; + for ( const a of k ) { + const iter = this.#expandKey(owner, a); + if ( iter === undefined ) { continue; } + out.push(...iter); + } + return out; + } + if ( typeof k === 'number' ) { + if ( Array.isArray(owner) === false ) { return; } + return [ k >= 0 ? k : owner.length + k ]; + } + if ( k === '*' ) { + if ( Array.isArray(owner) ) { return owner.keys(); } + return JSONPath.keys(owner); + } + if ( k instanceof JSONPath.Regex ) { + const out = []; + for ( const key of JSONPath.keys(owner) ) { + if ( k.test(key) === false ) { continue; } + out.push(key); + } + return out; + } + return [ k ]; + } + #getMatchesFromExpr(pathin, step, owner, out) { + const recursive = step.mv === this.#DESCENDANTS; + const v2 = this.#compiled.v2 || recursive || Array.isArray(owner); + for ( const { path } of this.#getDescendants(owner, recursive) ) { + const q = v2 ? [ ...pathin, ...path ] : pathin; + const r = this.#evaluate(step.steps, q); + if ( Boolean(r?.length) === false ) { continue; } + out.push(q); + if ( v2 === false ) { break; } + } + } + #getDescendants(v, recursive) { + const iterator = { + next() { + const n = this.stack.length; + if ( n === 0 ) { + this.value = undefined; + this.done = true; + return this; + } + const details = this.stack[n-1]; + const entry = details.keys.next(); + if ( entry.done ) { + this.stack.pop(); + this.path.pop(); + return this.next(); + } + this.path[n-1] = entry.value; + this.value = { + obj: details.obj, + key: entry.value, + path: this.path.slice(), + }; + const v = this.value.obj[this.value.key]; + if ( recursive && typeof v === 'object' && v !== null && this.seen.has(v) === false ) { + if ( Array.isArray(v) ) { + this.stack.push({ obj: v, keys: v.keys() }); + } else { + this.stack.push({ obj: v, keys: JSONPath.keys(v).values() }); + } + this.seen.add(v); + } + return this; + }, + path: [], + value: undefined, + done: false, + stack: [], + seen: new WeakSet(), + [Symbol.iterator]() { return this; }, + }; + if ( Array.isArray(v) ) { + iterator.stack.push({ obj: v, keys: v.keys() }); + } else if ( typeof v === 'object' && v !== null ) { + iterator.stack.push({ obj: v, keys: JSONPath.keys(v).values() }); + } + return iterator; + } + #consumeIdentifier(query, i) { + const keys = []; + let needIdentifier = true; + while ( i < query.length ) { + const c0 = query.charCodeAt(i); + if ( c0 === 0x5D /* ] */ ) { break; } + if ( c0 === 0x20 /* SPACE */ ) { + i += 1; + continue; + } + if ( c0 === 0x2C /* , */ ) { + if ( needIdentifier ) { return; } + i += 1; + needIdentifier = true; + continue; + } + if ( c0 === 0x22 /* " */ || c0 === 0x27 /* ' */ ) { + const r = this.#untilChar(query, c0, i+1); + if ( r === undefined ) { return; } + keys.push(r.s); + i = r.i; + needIdentifier = false; + continue; + } + if ( c0 === 0x2D /* - */ || c0 >= 0x30 && c0 <= 0x39 ) { + const match = this.#reIndice.exec(query.slice(i)); + if ( match === null ) { return; } + const indice = parseInt(query.slice(i), 10); + keys.push(indice); + i += match[0].length; + needIdentifier = false; + continue; + } + if ( this.v2 ) { return; } + const r = this.#consumeUnquotedIdentifier(query, i); + if ( r === undefined ) { return; } + keys.push(r.s); + i = r.i; + } + if ( needIdentifier ) { return; } + return { s: keys.length === 1 ? keys[0] : keys, i }; + } + #consumeUnquotedIdentifier(query, i) { + if ( query.charCodeAt(i) === 0x2F /* / */ ) { + const r = this.#untilChar(query, 0x2F, i+1); + if ( r === undefined ) { return; } + let re; + try { re = new JSONPath.Regex(r.s); } catch { return; } + return { s: re, i: r.i }; + } + const match = this.#reUnquotedIdentifier.exec(query.slice(i)); + if ( match === null ) { return; } + return { s: match[0], i: i + match[0].length }; + } + #untilChar(query, targetCharCode, i) { + const len = query.length; + const parts = []; + let beg = i, end = i; + for (;;) { + if ( end === len ) { return; } + const c = query.charCodeAt(end); + if ( c === targetCharCode ) { + parts.push(query.slice(beg, end)); + end += 1; + break; + } + if ( c === 0x5C /* \ */ && (end+1) < len ) { + const d = query.charCodeAt(end+1); + if ( d === targetCharCode ) { + parts.push(query.slice(beg, end)); + end += 1; + beg = end; + } + } + end += 1; + } + return { s: parts.join(''), i: end }; + } + #compileExpr(query, step, i) { + if ( query.startsWith('=/', i) ) { + const r = this.#untilChar(query, 0x2F /* / */, i+2); + if ( r === undefined ) { return i; } + const match = /^[i]/.exec(query.slice(r.i)); + try { + step.rval = new JSONPath.Regex(r.s, match && match[0] || undefined); + } catch { return; } + step.op = 're'; + if ( match ) { r.i += match[0].length; } + return r.i; + } + const match = this.#reExpr.exec(query.slice(i)); + if ( match === null ) { return; } + const op = match[1], rval = match[2]; + if ( rval.charCodeAt(0) === 0x27 /* ' */ ) { + const r = this.#untilChar(rval, 0x27, 1); + if ( r === undefined ) { return; } + step.rval = r.s; + step.op = op; + } else { + try { + step.rval = JSON.parse(rval); + step.op = op; + } catch { return; } + } + return i + match[0].length - 1; + } + #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]]; + if ( obj instanceof Object === false ) { return {}; } + } + return { obj, key, value: obj[key] }; + } + #evaluateExpr(step, owner, k) { + if ( owner === undefined || owner === null ) { return; } + const hasOwn = owner[k] !== undefined || JSONPath.hasOwn(owner, k); + if ( step.op !== undefined && hasOwn === false ) { return; } + const target = step.not !== true; + const v = owner[k]; + switch ( step.op ) { + case '==': return (v === step.rval) === target; + case '!=': return (v !== step.rval) === target; + case '<': return (v < step.rval) === target; + case '<=': return (v <= step.rval) === target; + case '>': return (v > step.rval) === target; + case '>=': return (v >= step.rval) === target; + case '^=': return `${v}`.startsWith(step.rval) === target; + case '$=': return `${v}`.endsWith(step.rval) === target; + case '*=': return `${v}`.includes(step.rval) === target; + case 're': return step.rval.test(`${v}`); + default: break; + } + return hasOwn === target; + } + #modifyVal(obj, key) { + let { modify, rval } = this.#compiled; + if ( typeof rval === 'string' ) { + rval = rval.replace('${now}', `${Date.now()}`); + } + switch ( modify ) { + case undefined: + obj[key] = rval; + break; + case '+': { + if ( rval instanceof Object === false ) { return; } + const lval = obj[key]; + if ( lval instanceof Object === false ) { return; } + if ( Array.isArray(lval) ) { + if ( Array.isArray(rval) ) { + lval.push(...rval); break; + } + lval.push(rval); break; + } + for ( const [ k, v ] of JSONPath.entries(rval) ) { + lval[k] = v; + } + break; + } + case 'call': { + const entries = rval.slice(); + if ( entries.length < 2 ) { break; } + entries.forEach((a, i, aa) => { + if ( a === '${obj}' ) { aa[i] = obj; } + else if ( a === '${key}' ) { aa[i] = key; } + else if ( a === '${val}' ) { aa[i] = obj[key]; } + }); + const instance = entries[0] ?? self; + instance[entries[1]](...entries.slice(2)); + break; + } + case 'repl': { + const lval = obj[key]; + if ( typeof lval !== 'string' ) { return; } + if ( this.#compiled.re === undefined ) { + this.#compiled.re = null; + try { + this.#compiled.re = rval.regex !== undefined + ? new JSONPath.Regex(rval.regex, rval.flags) + : new JSONPath.Regex(rval.pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + } catch { } + } + if ( this.#compiled.re === null ) { return; } + obj[key] = lval.replace(this.#compiled.re, rval.replacement); + break; + } + default: + break; + } + } +} diff --git a/privaxy/src/resources/vendor/ublock/package.json b/privaxy/src/resources/vendor/ublock/package.json new file mode 100644 index 00000000..e986b24b --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/abort-current-script.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/abort-current-script.js new file mode 100644 index 00000000..c25ba072 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/abort-current-script.js @@ -0,0 +1,123 @@ +/******************************************************************************* + + 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 { getExceptionTokenFn, trapPropertyFn } from './utils.js'; +import { registerScriptlet } from './base.js'; +import { runAtHtmlElementFn } from './run-at.js'; +import { safeSelf } from './safe-self.js'; + +/******************************************************************************/ + +// Issues to mind before changing anything: +// https://github.com/uBlockOrigin/uBlock-issues/issues/2154 +function abortCurrentScriptFn( + target = '', + needle = '', + context = '' +) { + if ( typeof target !== 'string' ) { return; } + if ( target === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('abort-current-script', target, needle, context); + const reNeedle = safe.patternToRegex(needle); + const reContext = safe.patternToRegex(context); + const thisScript = document.currentScript; + const exceptionToken = getExceptionTokenFn(); + const scriptTexts = new WeakMap(); + const textContentGetter = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent').get; + const getScriptText = elem => { + let text = textContentGetter.call(elem); + if ( text.trim() !== '' ) { return text; } + if ( scriptTexts.has(elem) ) { return scriptTexts.get(elem); } + const [ , mime, content ] = /^data:([^,]*),(.+)$/.exec(elem.src.trim()) || + [ '', '', '' ]; + try { + switch ( true ) { + case mime.endsWith(';base64'): + text = self.atob(content); + break; + default: + text = self.decodeURIComponent(content); + break; + } + } catch { + } + scriptTexts.set(elem, text); + return text; + }; + const validate = ( ) => { + const e = document.currentScript; + if ( e instanceof HTMLScriptElement === false ) { return; } + if ( e === thisScript ) { return; } + if ( context !== '' && reContext.test(e.src) === false ) { return; } + if ( safe.logLevel > 1 && context !== '' ) { + safe.uboLog(logPrefix, `Matched src\n${e.src}`); + } + const scriptText = getScriptText(e); + if ( reNeedle.test(scriptText) === false ) { return; } + if ( safe.logLevel > 1 ) { + safe.uboLog(logPrefix, `Matched text\n${scriptText}`); + } + safe.uboLog(logPrefix, 'Aborted'); + throw new ReferenceError(exceptionToken); + }; + let currentValue = trapPropertyFn(target, { + get: function() { + validate(); + return currentValue; + }, + set: function(a) { + validate(); + currentValue = a; + } + }, { canThrow: true }); +} +registerScriptlet(abortCurrentScriptFn , { + name: 'abort-current-script.fn', + dependencies: [ + getExceptionTokenFn, + safeSelf, + trapPropertyFn, + ], +}); + +/******************************************************************************/ + +// Issues to mind before changing anything: +// https://github.com/uBlockOrigin/uBlock-issues/issues/2154 +export function abortCurrentScript(...args) { + runAtHtmlElementFn(( ) => { + abortCurrentScriptFn(...args); + }); +} +registerScriptlet(abortCurrentScript , { + name: 'abort-current-script.js', + aliases: [ + 'abort-current-inline-script.js', + 'acis.js', + 'acs.js', + ], + dependencies: [ + abortCurrentScriptFn, + runAtHtmlElementFn, + ], +}); diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/attribute.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/attribute.js new file mode 100644 index 00000000..b7052790 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/attribute.js @@ -0,0 +1,337 @@ +/******************************************************************************* + + 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 { offIdleFn, onIdleFn } from './utils.js'; +import { registerScriptlet } from './base.js'; +import { runAt } from './run-at.js'; +import { safeSelf } from './safe-self.js'; + +/******************************************************************************/ + +export function setAttrFn( + trusted = false, + logPrefix, + selector = '', + attr = '', + value = '', + options = {} +) { + if ( selector === '' ) { return; } + if ( attr === '' ) { return; } + + const safe = safeSelf(); + const copyFrom = trusted === false && /^\[.+\]$/.test(value) + ? value.slice(1, -1) + : ''; + + const extractValue = elem => copyFrom !== '' + ? elem.getAttribute(copyFrom) || '' + : value; + + const applySetAttr = ( ) => { + let elems; + try { + elems = document.querySelectorAll(selector); + } catch { + return false; + } + for ( const elem of elems ) { + const before = elem.getAttribute(attr); + const after = extractValue(elem); + if ( after === before ) { continue; } + if ( after !== '' && /^on/i.test(attr) ) { + if ( attr.toLowerCase() in elem ) { continue; } + } + elem.setAttribute(attr, after); + safe.uboLog(logPrefix, `${attr}="${after}"`); + } + return true; + }; + + let observer, timer; + const onDomChanged = mutations => { + if ( timer !== undefined ) { return; } + let shouldWork = false; + for ( const mutation of mutations ) { + if ( mutation.addedNodes.length === 0 ) { continue; } + for ( const node of mutation.addedNodes ) { + if ( node.nodeType !== 1 ) { continue; } + shouldWork = true; + break; + } + if ( shouldWork ) { break; } + } + if ( shouldWork === false ) { return; } + timer = self.requestAnimationFrame(( ) => { + timer = undefined; + applySetAttr(); + }); + }; + + const start = ( ) => { + if ( applySetAttr() === false ) { return; } + observer = new MutationObserver(onDomChanged); + const root = document.documentElement; + if ( root instanceof self.Node === false ) { return; } + observer.observe(root, { subtree: true, childList: true }); + }; + runAt(( ) => { start(); }, options.runAt || 'idle'); +} +registerScriptlet(setAttrFn, { + name: 'set-attr.fn', + dependencies: [ + runAt, + safeSelf, + ], +}); + +/** + * @scriptlet set-attr + * + * @description + * Sets the specified attribute on the specified elements. This scriptlet runs + * once when the page loads then afterward on DOM mutations. + * + * Reference: https://github.com/AdguardTeam/Scriptlets/blob/master/src/scriptlets/set-attr.js + * + * @param selector + * A CSS selector for the elements to target. + * + * @param attr + * The name of the attribute to modify. + * + * @param value + * The new value of the attribute. Supported values: + * - `''`: empty string (default) + * - `true` + * - `false` + * - positive decimal integer 0 <= value < 32768 + * - `[other]`: copy the value from attribute `other` on the same element + * + * */ + +export function setAttr( + selector = '', + attr = '', + value = '', + ...varargs +) { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('set-attr', selector, attr, value); + const validValues = [ '', 'false', 'true' ]; + if ( validValues.includes(value.toLowerCase()) === false ) { + if ( /^\d+$/.test(value) ) { + const n = parseInt(value, 10); + if ( n >= 32768 ) { return; } + value = `${n}`; + } else if ( /^\[.+\]$/.test(value) === false ) { + return; + } + } + const options = safe.parseVarargs(varargs); + setAttrFn(false, logPrefix, selector, attr, value, options); +} +registerScriptlet(setAttr, { + name: 'set-attr.js', + dependencies: [ + safeSelf, + setAttrFn, + ], + world: 'ISOLATED', +}); + +/** + * @trustedScriptlet trusted-set-attr + * + * @description + * Sets the specified attribute on the specified elements. This scriptlet runs + * once when the page loads then afterward on DOM mutations. + * + * Reference: https://github.com/AdguardTeam/Scriptlets/blob/master/wiki/about-trusted-scriptlets.md#-%EF%B8%8F-trusted-set-attr + * + * @param selector + * A CSS selector for the elements to target. + * + * @param attr + * The name of the attribute to modify. + * + * @param value + * The new value of the attribute. Since the scriptlet requires a trusted + * source, the value can be anything. + * + * */ + +export function trustedSetAttr( + selector = '', + attr = '', + value = '', + ...varargs +) { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('trusted-set-attr', selector, attr, value); + const options = safe.parseVarargs(varargs); + setAttrFn(true, logPrefix, selector, attr, value, options); +} +registerScriptlet(trustedSetAttr, { + name: 'trusted-set-attr.js', + requiresTrust: true, + dependencies: [ + safeSelf, + setAttrFn, + ], + world: 'ISOLATED', +}); + +/** + * @scriptlet remove-attr + * + * @description + * Remove one or more attributes from a set of elements. + * + * @param attribute + * The name of the attribute(s) to remove. This can be a list of space- + * separated attribute names. + * + * @param [selector] + * Optional. A CSS selector for the elements to target. Default to + * `[attribute]`, or `[attribute1],[attribute2],...` if more than one + * attribute name is specified. + * + * @param [behavior] + * Optional. Space-separated tokens which modify the default behavior. + * - `stay`: Keep trying to remove the specified attribute(s) on DOM mutations. + * + * @params [...varargs] + * Optional, any of following pairs of parameters: + * - `quitAfter, sec`: where `sec` is the number of seconds after which the + * scriptlet ceases to be active. This has precedence over `stay` behavior. + * */ + +export function removeAttr( + rawToken = '', + rawSelector = '', + behavior = '', + ...varargs +) { + if ( typeof rawToken !== 'string' ) { return; } + if ( rawToken === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('remove-attr', + rawToken, rawSelector, behavior, ...varargs + ); + const tokens = safe.String_split.call(rawToken, /\s*\|\s*/); + const selector = tokens.map(a => { + const b = CSS.escape(a); + return rawSelector.includes(`[${b}]`) ? rawSelector : `${rawSelector}[${b}]`; + }).join(','); + const lazily = /\basap\b/.test(behavior) === false; + const options = safe.parseVarargs(varargs); + if ( safe.logLevel > 1 ) { + safe.uboLog(logPrefix, `Target selector:\n\t${selector}`); + } + const rmattrFromNode = node => { + for ( const attr of tokens ) { + if ( node.hasAttribute(attr) === false ) { continue; } + node.removeAttribute(attr); + safe.uboLog(logPrefix, `Removed attribute '${attr}'`); + } + }; + const rmattr = nodes => { + for ( const node of nodes ?? document.querySelectorAll(selector) ) { + rmattrFromNode(node); + } + }; + const rmAttrLazily = ( ) => { + if ( rmAttrLazily.timer !== undefined ) { return; } + rmAttrLazily.timer = onIdleFn(( ) => { + rmAttrLazily.timer = undefined; + rmattr(); + }, { timeout: 17 }); + }; + const mutationHandler = mutations => { + for ( const { addedNodes, removedNodes } of mutations ) { + for ( const node of addedNodes ) { + if ( node.nodeType !== 1 ) { continue; } + if ( lazily ) { return rmAttrLazily(); } + if ( node.matches(selector) ) { + rmattrFromNode(node); + } + if ( node.childElementCount ) { + rmattr(node.querySelectorAll(selector)); + } + } + if ( lazily ) { return; } + for ( const node of removedNodes ) { + if ( node.nodeType !== 1 ) { continue; } + if ( node.matches(selector) ) { + rmattrFromNode(node); + } + } + } + }; + const stop = ( ) => { + if ( start.observer ) { + start.observer.disconnect(); + start.observer = undefined; + } + if ( rmAttrLazily.timer ) { + offIdleFn(rmAttrLazily.timer); + rmAttrLazily.timer = undefined; + } + if ( safe.logLevel > 1 ) { + safe.uboLog(logPrefix, 'Quitting'); + } + }; + const start = ( ) => { + rmattr(); + if ( /\bstay\b/.test(behavior) === false ) { + if ( options.quitAfter === undefined ) { return; } + } + start.observer = new MutationObserver(mutationHandler); + start.observer.observe(document, { + attributes: true, + attributeFilter: tokens, + childList: true, + subtree: true, + }); + if ( options.quitAfter ) { + runAt(( ) => { + self.setTimeout(stop, options.quitAfter * 1000); + }, 'load'); + } + }; + runAt(( ) => { start(); }, safe.String_split.call(behavior, /\s+/)); +} +registerScriptlet(removeAttr, { + name: 'remove-attr.js', + aliases: [ + 'ra.js', + ], + dependencies: [ + offIdleFn, + onIdleFn, + runAt, + safeSelf, + ], +}); + +/******************************************************************************/ diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/base.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/base.js new file mode 100644 index 00000000..2c54418a --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/base.js @@ -0,0 +1,38 @@ +/******************************************************************************* + + 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 + +*/ + +export const registeredScriptlets = []; + +export const registerScriptlet = (fn, details) => { + if ( typeof details !== 'object' ) { + throw new ReferenceError('Missing scriptlet details'); + } + details.fn = fn; + fn.details = details; + if ( Array.isArray(details.dependencies) ) { + details.dependencies.forEach((fn, i, array) => { + if ( typeof fn !== 'function' ) { return; } + array[i] = fn.details.name; + }); + } + registeredScriptlets.push(details); +}; diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/cookie.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/cookie.js new file mode 100644 index 00000000..79cda65b --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/cookie.js @@ -0,0 +1,446 @@ +/******************************************************************************* + + 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 { registerScriptlet } from './base.js'; +import { safeSelf } from './safe-self.js'; + +/******************************************************************************/ + +export function getSafeCookieValuesFn() { + return [ + 'accept', 'reject', + 'accepted', 'rejected', 'notaccepted', + 'allow', 'disallow', 'deny', + 'allowed', 'denied', + 'approved', 'disapproved', + 'checked', 'unchecked', + 'dismiss', 'dismissed', + 'enable', 'disable', + 'enabled', 'disabled', + 'essential', 'nonessential', + 'forbidden', 'forever', + 'hide', 'hidden', + 'necessary', 'required', + 'ok', + 'on', 'off', + 'true', 't', 'false', 'f', + 'yes', 'y', 'no', 'n', + 'all', 'none', 'functional', + 'granted', 'done', + 'decline', 'declined', + 'closed', 'next', 'mandatory', + 'disagree', 'agree', + 'set', 'unset', + 'given', + ]; +} +registerScriptlet(getSafeCookieValuesFn, { + name: 'get-safe-cookie-values.fn', +}); + +/******************************************************************************/ + +export function getAllCookiesFn() { + const safe = safeSelf(); + return safe.String_split.call(document.cookie, /\s*;\s*/).map(s => { + const pos = s.indexOf('='); + if ( pos === 0 ) { return; } + if ( pos === -1 ) { return `${s.trim()}=`; } + const key = s.slice(0, pos).trim(); + const value = s.slice(pos+1).trim(); + return { key, value }; + }).filter(s => s !== undefined); +} +registerScriptlet(getAllCookiesFn, { + name: 'get-all-cookies.fn', + dependencies: [ + safeSelf, + ], +}); + +/******************************************************************************/ + +export function getCookieFn( + name = '' +) { + const safe = safeSelf(); + for ( const s of safe.String_split.call(document.cookie, /\s*;\s*/) ) { + const pos = s.indexOf('='); + if ( pos === -1 ) { continue; } + if ( s.slice(0, pos) !== name ) { continue; } + return s.slice(pos+1).trim(); + } +} +registerScriptlet(getCookieFn, { + name: 'get-cookie.fn', + dependencies: [ + safeSelf, + ], +}); + +/******************************************************************************/ + +export function setCookieFn( + trusted = false, + name = '', + value = '', + expires = '', + path = '', + options = {}, +) { + // https://datatracker.ietf.org/doc/html/rfc2616#section-2.2 + // https://github.com/uBlockOrigin/uBlock-issues/issues/2777 + if ( trusted === false && /[^!#$%&'*+\-.0-9A-Z[\]^_`a-z|~]/.test(name) ) { + name = encodeURIComponent(name); + } + // https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1 + // The characters [",] are given a pass from the RFC requirements because + // apparently browsers do not follow the RFC to the letter. + if ( /[^ -:<-[\]-~]/.test(value) ) { + value = encodeURIComponent(value); + } + + const cookieBefore = getCookieFn(name); + if ( cookieBefore !== undefined && options.dontOverwrite ) { return; } + if ( cookieBefore === value && options.reload ) { return; } + + const cookieParts = [ name, '=', value ]; + if ( expires !== '' ) { + cookieParts.push('; expires=', expires); + } + + if ( path === '' ) { path = '/'; } + else if ( path === 'none' ) { path = ''; } + if ( path !== '' && path !== '/' ) { return; } + if ( path === '/' ) { + cookieParts.push('; path=/'); + } + + if ( trusted ) { + if ( options.domain ) { + let domain = options.domain; + if ( /^\/.+\//.test(domain) ) { + const baseURL = new URL(document.baseURI); + const reDomain = new RegExp(domain.slice(1, -1)); + const match = reDomain.exec(baseURL.hostname); + domain = match ? match[0] : undefined; + } + if ( domain ) { + cookieParts.push(`; domain=${domain}`); + } + } + cookieParts.push('; Secure'); + } else if ( /^__(Host|Secure)-/.test(name) ) { + cookieParts.push('; Secure'); + } + + try { + document.cookie = cookieParts.join(''); + } catch { + } + + const done = getCookieFn(name) === value; + if ( done && options.reload ) { + window.location.reload(); + } + + return done; +} +registerScriptlet(setCookieFn, { + name: 'set-cookie.fn', + dependencies: [ + getCookieFn, + ], +}); + +/** + * @scriptlet set-cookie + * + * @description + * Set a cookie to a safe value. + * + * @param name + * The name of the cookie to set. + * + * @param value + * The value of the cookie to set. Must be a safe value. Unsafe values will be + * ignored and no cookie will be set. See getSafeCookieValuesFn() helper above. + * + * @param [path] + * Optional. The path of the cookie to set. Default to `/`. + * + * Reference: + * https://github.com/AdguardTeam/Scriptlets/blob/master/src/scriptlets/set-cookie.js + * */ + +export function setCookie( + name = '', + value = '', + path = '', + ...varargs +) { + if ( name === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('set-cookie', name, value, path); + const normalized = value.toLowerCase(); + const match = /^("?)(.+)\1$/.exec(normalized); + const unquoted = match && match[2] || normalized; + const validValues = getSafeCookieValuesFn(); + if ( validValues.includes(unquoted) === false ) { + if ( /^-?\d+$/.test(unquoted) === false ) { return; } + const n = parseInt(value, 10) || 0; + if ( n < -32767 || n > 32767 ) { return; } + } + + const done = setCookieFn( + false, + name, + value, + '', + path, + safe.parseVarargs(varargs) + ); + + if ( done ) { + safe.uboLog(logPrefix, 'Done'); + } +} +registerScriptlet(setCookie, { + name: 'set-cookie.js', + world: 'ISOLATED', + dependencies: [ + getSafeCookieValuesFn, + safeSelf, + setCookieFn, + ], +}); + +// For compatibility with AdGuard +export function setCookieReload(name, value, path, ...args) { + setCookie(name, value, path, 'reload', '1', ...args); +} +registerScriptlet(setCookieReload, { + name: 'set-cookie-reload.js', + world: 'ISOLATED', + dependencies: [ + setCookie, + ], +}); + +/** + * @trustedScriptlet trusted-set-cookie + * + * @description + * Set a cookie to any value. This scriptlet can be used only from a trusted + * source. + * + * @param name + * The name of the cookie to set. + * + * @param value + * The value of the cookie to set. Must be a safe value. Unsafe values will be + * ignored and no cookie will be set. See getSafeCookieValuesFn() helper above. + * + * @param [offsetExpiresSec] + * Optional. The path of the cookie to set. Default to `/`. + * + * @param [path] + * Optional. The path of the cookie to set. Default to `/`. + * + * Reference: + * https://github.com/AdguardTeam/Scriptlets/blob/master/src/scriptlets/set-cookie.js + * */ + +export function trustedSetCookie( + name = '', + value = '', + offsetExpiresSec = '', + path = '', + ...varargs +) { + if ( name === '' ) { return; } + + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('set-cookie', name, value, path); + const time = new Date(); + + if ( value.includes('$now$') ) { + value = value.replaceAll('$now$', time.getTime()); + } + if ( value.includes('$currentDate$') ) { + value = value.replaceAll('$currentDate$', time.toUTCString()); + } + if ( value.includes('$currentISODate$') ) { + value = value.replaceAll('$currentISODate$', time.toISOString()); + } + + let expires = ''; + if ( offsetExpiresSec !== '' ) { + if ( offsetExpiresSec === '1day' ) { + time.setDate(time.getDate() + 1); + } else if ( offsetExpiresSec === '1year' ) { + time.setFullYear(time.getFullYear() + 1); + } else { + if ( /^\d+$/.test(offsetExpiresSec) === false ) { return; } + time.setSeconds(time.getSeconds() + parseInt(offsetExpiresSec, 10)); + } + expires = time.toUTCString(); + } + + const done = setCookieFn( + true, + name, + value, + expires, + path, + safe.parseVarargs(varargs) + ); + + if ( done ) { + safe.uboLog(logPrefix, 'Done'); + } +} +registerScriptlet(trustedSetCookie, { + name: 'trusted-set-cookie.js', + requiresTrust: true, + world: 'ISOLATED', + dependencies: [ + safeSelf, + setCookieFn, + ], +}); + +// For compatibility with AdGuard +export function trustedSetCookieReload(name, value, offsetExpiresSec, path, ...args) { + trustedSetCookie(name, value, offsetExpiresSec, path, 'reload', '1', ...args); +} +registerScriptlet(trustedSetCookieReload, { + name: 'trusted-set-cookie-reload.js', + requiresTrust: true, + world: 'ISOLATED', + dependencies: [ + trustedSetCookie, + ], +}); + +/** + * @scriptlet remove-cookie + * + * @description + * Removes current site cookies specified by name. The removal operation occurs + * immediately when the scriptlet is injected, then when the page is unloaded. + * + * @param needle + * A string or a regex matching the name of the cookie(s) to remove. + * + * @param ['when', token] + * Vararg, optional. The parameter following 'when' tells when extra removal + * operations should take place. + * - `scroll`: when the page is scrolled + * - `keydown`: when a keyboard touch is pressed + * + * */ + +export function removeCookie( + needle = '', + ...varargs +) { + if ( typeof needle !== 'string' ) { return; } + const safe = safeSelf(); + const reName = safe.patternToRegex(needle); + const extraArgs = safe.parseVarargs(varargs); + const throttle = (fn, ms = 500) => { + if ( throttle.timer !== undefined ) { return; } + throttle.timer = setTimeout(( ) => { + throttle.timer = undefined; + fn(); + }, ms); + }; + const baseURL = new URL(document.baseURI); + let targetDomain = extraArgs.domain; + if ( targetDomain && /^\/.+\//.test(targetDomain) ) { + const reDomain = new RegExp(targetDomain.slice(1, -1)); + const match = reDomain.exec(baseURL.hostname); + targetDomain = match ? match[0] : undefined; + } + const remove = ( ) => { + safe.String_split.call(document.cookie, ';').forEach(cookieStr => { + const pos = cookieStr.indexOf('='); + if ( pos === -1 ) { return; } + const cookieName = cookieStr.slice(0, pos).trim(); + if ( reName.test(cookieName) === false ) { return; } + const part1 = cookieName + '='; + const part2a = `; domain=${baseURL.hostname}`; + const part2b = `; domain=.${baseURL.hostname}`; + let part2c, part2d; + if ( targetDomain ) { + part2c = `; domain=${targetDomain}`; + part2d = `; domain=.${targetDomain}`; + } else if ( document.domain ) { + const domain = document.domain; + if ( domain !== baseURL.hostname ) { + part2c = `; domain=.${domain}`; + } + if ( domain.startsWith('www.') ) { + part2d = `; domain=${domain.replace('www', '')}`; + } + } + const part3 = '; path=/'; + const part4 = '; Max-Age=-1000; expires=Thu, 01 Jan 1970 00:00:00 GMT'; + document.cookie = part1 + part4; + document.cookie = part1 + part2a + part4; + document.cookie = part1 + part2b + part4; + document.cookie = part1 + part3 + part4; + document.cookie = part1 + part2a + part3 + part4; + document.cookie = part1 + part2b + part3 + part4; + if ( part2c !== undefined ) { + document.cookie = part1 + part2c + part3 + part4; + } + if ( part2d !== undefined ) { + document.cookie = part1 + part2d + part3 + part4; + } + }); + }; + remove(); + window.addEventListener('beforeunload', remove); + if ( typeof extraArgs.when !== 'string' ) { return; } + const supportedEventTypes = [ 'scroll', 'keydown' ]; + const eventTypes = safe.String_split.call(extraArgs.when, /\s/); + for ( const type of eventTypes ) { + if ( supportedEventTypes.includes(type) === false ) { continue; } + document.addEventListener(type, ( ) => { + throttle(remove); + }, { passive: true }); + } +} +registerScriptlet(removeCookie, { + name: 'remove-cookie.js', + aliases: [ + 'cookie-remover.js', + ], + world: 'ISOLATED', + dependencies: [ + safeSelf, + ], +}); + +/******************************************************************************/ diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/create-html.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/create-html.js new file mode 100644 index 00000000..468083d3 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/create-html.js @@ -0,0 +1,144 @@ +/******************************************************************************* + + uBlock Origin - a comprehensive, efficient content blocker + Copyright (C) 2025-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 { registerScriptlet } from './base.js'; +import { runAt } from './run-at.js'; +import { safeSelf } from './safe-self.js'; + +/******************************************************************************/ + +/** + * @scriptlet trusted-create-html + * + * @description + * Element(s) from a parsed HTML string are added as child element(s) to a + * specific parent element in the DOM. + * + * @param parent + * A CSS selector identifying the element to which created element(s) will be + * added. + * + * @param html + * An HTML string to be parsed using DOMParser, and which resulting elements + * are to be added as child element(s). + * + * @param duration + * Optional. If specified, the time in ms after which the added elements will + * be removed. No removal will occur if not specified. + * + * */ + +function trustedCreateHTML( + parentSelector, + htmlStr = '', + durationStr = '', + ...varargs +) { + if ( parentSelector === '' ) { return; } + if ( htmlStr === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('trusted-create-html', parentSelector, htmlStr, durationStr); + const extraArgs = safe.parseVarargs(varargs); + // We do not want to recursively create elements + self.trustedCreateHTML = true; + let ancestor = self.frameElement; + while ( ancestor ) { + const doc = ancestor.ownerDocument; + if ( doc === null ) { break; } + const win = doc.defaultView; + if ( win === null ) { break; } + if ( win.trustedCreateHTML ) { return; } + ancestor = ancestor.frameElement; + } + const duration = parseInt(durationStr, 10); + const domParser = new DOMParser(); + const externalDoc = domParser.parseFromString(htmlStr, 'text/html'); + const toAppend = []; + while ( externalDoc.body.firstChild !== null ) { + toAppend.push(document.adoptNode(externalDoc.body.firstChild)); + } + if ( toAppend.length === 0 ) { return; } + const toRemove = []; + const remove = ( ) => { + for ( const node of toRemove ) { + if ( node.parentNode === null ) { continue; } + node.parentNode.removeChild(node); + } + safe.uboLog(logPrefix, 'Node(s) removed'); + }; + const appendOne = (target, nodes) => { + for ( const node of nodes ) { + target.append(node); + if ( isNaN(duration) ) { continue; } + toRemove.push(node); + } + }; + const append = ( ) => { + const targets = document.querySelectorAll(parentSelector); + if ( targets.length === 0 ) { return false; } + const limit = Math.min(targets.length, extraArgs.limit || 1) - 1; + for ( let i = 0; i < limit; i++ ) { + appendOne(targets[i], toAppend.map(a => a.cloneNode(true))); + } + appendOne(targets[limit], toAppend); + safe.uboLog(logPrefix, 'Node(s) appended'); + if ( toRemove.length === 0 ) { return true; } + setTimeout(remove, duration); + return true; + }; + const start = ( ) => { + if ( append() ) { return; } + const observer = new MutationObserver(( ) => { + if ( append() === false ) { return; } + observer.disconnect(); + }); + const observerOptions = { + childList: true, + subtree: true, + }; + if ( /[#.[]/.test(parentSelector) ) { + observerOptions.attributes = true; + if ( parentSelector.includes('[') === false ) { + observerOptions.attributeFilter = []; + if ( parentSelector.includes('#') ) { + observerOptions.attributeFilter.push('id'); + } + if ( parentSelector.includes('.') ) { + observerOptions.attributeFilter.push('class'); + } + } + } + observer.observe(document, observerOptions); + }; + runAt(start, extraArgs.runAt || 'loading'); +} +registerScriptlet(trustedCreateHTML, { + name: 'trusted-create-html.js', + requiresTrust: true, + dependencies: [ + runAt, + safeSelf, + ], + world: 'ISOLATED', +}); + +/******************************************************************************/ diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/href-sanitizer.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/href-sanitizer.js new file mode 100644 index 00000000..3be7066f --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/href-sanitizer.js @@ -0,0 +1,172 @@ +/******************************************************************************* + + 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 { onIdleFn } from './utils.js'; +import { registerScriptlet } from './base.js'; +import { runAt } from './run-at.js'; +import { safeSelf } from './safe-self.js'; +import { urlSkip } from '../urlskip.js'; + +/******************************************************************************/ + +registerScriptlet(urlSkip, { + name: 'urlskip.fn', +}); + +/** + * @scriptlet href-sanitizer + * + * @description + * Set the `href` attribute to a value found in the DOM at, or below the + * targeted `a` element, and optionally with transformation steps. + * + * @param selector + * A plain CSS selector for elements which `href` property must be sanitized. + * + * @param source + * One or more tokens to lookup the source of the `href` property, and + * optionally the transformation steps to perform: + * - `text`: Use the text content of the element as the URL + * - `[name]`: Use the value of the attribute `name` as the URL + * - Transformation steps: see `urlskip` documentation + * + * If `text` or `[name]` is not present, the URL will be the value of `href` + * attribute. + * + * @example + * `example.org##+js(href-sanitizer, a)` + * `example.org##+js(href-sanitizer, a[title], [title])` + * `example.org##+js(href-sanitizer, a[href*="/away.php?to="], ?to)` + * `example.org##+js(href-sanitizer, a[href*="/redirect"], ?url ?url -base64)` + * + * */ + +function hrefSanitizer( + selector = '', + source = '' +) { + if ( typeof selector !== 'string' ) { return; } + if ( selector === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('href-sanitizer', selector, source); + if ( source === '' ) { source = 'text'; } + const sanitizeCopycats = (href, text) => { + let elems = []; + try { + elems = document.querySelectorAll(`a[href="${href}"`); + } + catch { + } + for ( const elem of elems ) { + elem.setAttribute('href', text); + } + return elems.length; + }; + const validateURL = text => { + if ( typeof text !== 'string' ) { return ''; } + if ( text === '' ) { return ''; } + if ( /[\x00-\x20\x7f]/.test(text) ) { return ''; } + try { + const url = new URL(text, document.location); + return url.href; + } catch { + } + return ''; + }; + const extractURL = (elem, source) => { + if ( /^\[.*\]$/.test(source) ) { + return elem.getAttribute(source.slice(1,-1).trim()) || ''; + } + if ( source === 'text' ) { + return elem.textContent + .replace(/^[^\x21-\x7e]+/, '') // remove leading invalid characters + .replace(/[^\x21-\x7e]+$/, ''); // remove trailing invalid characters + } + const steps = source.replace(/(\S)\?/g, '\\1 ?').split(/\s+/); + const url = urlSkip(elem.href, false, steps); + if ( url === undefined ) { return; } + return url.replace(/ /g, '%20'); + }; + const sanitize = ( ) => { + let elems = []; + try { + elems = document.querySelectorAll(selector); + } + catch { + return false; + } + for ( const elem of elems ) { + if ( elem.localName !== 'a' ) { continue; } + if ( elem.hasAttribute('href') === false ) { continue; } + const href = elem.getAttribute('href'); + const text = extractURL(elem, source); + const hrefAfter = validateURL(text); + if ( hrefAfter === '' ) { continue; } + if ( hrefAfter === href ) { continue; } + elem.setAttribute('href', hrefAfter); + const count = sanitizeCopycats(href, hrefAfter); + safe.uboLog(logPrefix, `Sanitized ${count+1} links to\n${hrefAfter}`); + } + return true; + }; + let observer, timer; + const onDomChanged = mutations => { + if ( timer !== undefined ) { return; } + let shouldSanitize = false; + for ( const mutation of mutations ) { + if ( mutation.addedNodes.length === 0 ) { continue; } + for ( const node of mutation.addedNodes ) { + if ( node.nodeType !== 1 ) { continue; } + shouldSanitize = true; + break; + } + if ( shouldSanitize ) { break; } + } + if ( shouldSanitize === false ) { return; } + timer = onIdleFn(( ) => { + timer = undefined; + sanitize(); + }); + }; + const start = ( ) => { + if ( sanitize() === false ) { return; } + observer = new MutationObserver(onDomChanged); + observer.observe(document.body, { + subtree: true, + childList: true, + }); + }; + runAt(( ) => { start(); }, 'interactive'); +} +registerScriptlet(hrefSanitizer, { + name: 'href-sanitizer.js', + world: 'ISOLATED', + aliases: [ + 'urlskip.js', + ], + dependencies: [ + onIdleFn, + runAt, + safeSelf, + urlSkip, + ], +}); diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/json-edit.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/json-edit.js new file mode 100644 index 00000000..66727250 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/json-edit.js @@ -0,0 +1,1672 @@ +/******************************************************************************* + + 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 { + collateFetchArgumentsFn, + lookupElementsFn, + matchObjectPropertiesFn, + parsePropertiesToMatchFn, + sleepFn, + trapPropertyFn, +} 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 editOutboundObjectFn( + trusted = false, + propChain = '', + jsonq = '' +) { + if ( propChain === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix( + `${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(propChain, function(context) { + const obj = context.reflect(); + const objAfter = jsonp.apply(obj); + if ( objAfter === undefined ) { return obj; } + safe.uboLog(logPrefix, 'Edited'); + if ( safe.logLevel > 1 ) { + safe.uboLog(logPrefix, `After edit:\n${safe.JSON_stringify(objAfter, null, 2)}`); + } + return objAfter; + }); +} +registerScriptlet(editOutboundObjectFn, { + name: 'edit-outbound-object.fn', + dependencies: [ + JSONPath, + proxyApplyFn, + safeSelf, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet edit-outbound-object-.js + * + * @description + * Prune properties from an object returned by a specific method. + * Properties can only be removed. + * + * @param propChain + * Property chain of the method to trap. + * + * @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 of an object returned by a specific method. + * Properties can be assigned new values. + * + * @param propChain + * Property chain of the method to trap. + * +* @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, + ], +}); + +/******************************************************************************/ + +function jsonEditFn(trusted = false, jsonq = '', ...varargs) { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix( + `${trusted ? 'trusted-' : ''}json-edit`, + jsonq, + ...varargs + ); + const jsonp = JSONPath.create(jsonq); + if ( jsonp.valid === false || jsonp.value !== undefined && trusted !== true ) { + return safe.uboLog(logPrefix, 'Bad JSONPath query'); + } + const extraArgs = safe.parseVarargs(varargs); + const pattern = extraArgs.matches && safe.initPattern(extraArgs.matches); + proxyApplyFn('JSON.parse', function(context) { + const json = context.callArgs[0]; + const obj = context.reflect(); + if ( pattern && typeof json === 'string' ) { + if ( safe.testPattern(pattern, json) === false ) { return obj; } + } + const objAfter = jsonp.apply(obj); + if ( objAfter === undefined ) { return obj; } + safe.uboLog(logPrefix, 'Edited'); + if ( safe.logLevel > 1 ) { + safe.uboLog(logPrefix, `After edit:\n${safe.JSON_stringify(objAfter, null, 2)}`); + } + return objAfter; + }); +} +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. + * + * @param 'matches', pattern + * Vararg, optional: The JSONPath will be applied if and only if the pattern + * matches the inbound JSON string. The pattern can be a plain string or a + * regex. + * + * */ + +function jsonEdit(jsonq = '', ...varargs) { + jsonEditFn(false, jsonq, ...varargs); +} +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. + * + * @param 'matches', pattern + * Vararg, optional: The JSONPath will be applied if and only if the pattern + * matches the inbound JSON string. The pattern can be a plain string or a + * regex. + * + * */ + +function trustedJsonEdit(jsonq = '', ...varargs) { + jsonEditFn(true, jsonq, ...varargs); +} +registerScriptlet(trustedJsonEdit, { + name: 'trusted-json-edit.js', + requiresTrust: true, + dependencies: [ + jsonEditFn, + ], +}); + +/******************************************************************************/ +/******************************************************************************/ + +function editInboundObjectFn( + trusted = false, + propChain = '', + argPosRaw = '', + jsonq = '', +) { + if ( propChain === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix( + `${trusted ? 'trusted-' : ''}edit-inbound-object`, + propChain, + jsonq + ); + const jsonp = JSONPath.create(jsonq); + if ( jsonp.valid === false || jsonp.value !== undefined && trusted !== true ) { + return safe.uboLog(logPrefix, 'Bad JSONPath query'); + } + const argPos = parseInt(argPosRaw, 10); + if ( isNaN(argPos) ) { return; } + const getArgPos = args => { + if ( Array.isArray(args) === false ) { return; } + if ( argPos >= 0 ) { + if ( args.length <= argPos ) { return; } + return argPos; + } + if ( args.length < -argPos ) { return; } + return args.length + argPos; + }; + const editObj = obj => { + let clone; + try { + clone = safe.structuredClone(obj); + } catch { + } + if ( typeof clone !== 'object' || clone === null ) { return; } + const objAfter = jsonp.apply(clone); + if ( objAfter === undefined ) { return; } + safe.uboLog(logPrefix, 'Edited'); + if ( safe.logLevel > 1 ) { + safe.uboLog(logPrefix, `After edit:\n${safe.JSON_stringify(objAfter, null, 2)}`); + } + return objAfter; + }; + proxyApplyFn(propChain, function(context) { + const i = getArgPos(context.callArgs); + if ( i !== undefined ) { + const objBefore = context.callArgs[i]; + if ( typeof objBefore === 'object' ) { + const objAfter = editObj(objBefore); + if ( objAfter ) { + context.callArgs[i] = objAfter; + } + } + } + return context.reflect(); + }); +} +registerScriptlet(editInboundObjectFn, { + name: 'edit-inbound-object.fn', + dependencies: [ + JSONPath, + proxyApplyFn, + safeSelf, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet edit-inbound-object.js + * + * @description + * Prune properties from an object passed as argument to a specific method. + * Properties can only be removed. + * + * @param propChain + * Property chain of the method to trap. + * + * @param argPos + * 0-based position of the argument. Use negative integer for position relative + * to the end. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * */ + +function editInboundObject(propChain = '', argPos = '', jsonq = '') { + editInboundObjectFn(false, propChain, argPos, jsonq); +} +registerScriptlet(editInboundObject, { + name: 'edit-inbound-object.js', + dependencies: [ + editInboundObjectFn, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet trusted-edit-inbound-object.js + * + * @description + * Edit properties of an object passed as argument to a specific method. + * Properties can be assigned new values. + * + * @param propChain + * Property chain of the method to trap. + * + * @param argPos + * 0-based position of the argument. Use negative integer for position relative + * to the end. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * */ + +function trustedEditInboundObject(propChain = '', argPos = '', jsonq = '') { + editInboundObjectFn(true, propChain, argPos, jsonq); +} +registerScriptlet(trustedEditInboundObject, { + name: 'trusted-edit-inbound-object.js', + requiresTrust: true, + dependencies: [ + editInboundObjectFn, + ], +}); + +/******************************************************************************/ +/******************************************************************************/ + +function editThisObjectFn( + trusted = false, + propChain = '', + jsonq = '', + order = '' +) { + if ( propChain === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix( + `${trusted ? 'trusted-' : ''}edit-this-object`, + propChain, + jsonq + ); + const jsonp = JSONPath.create(jsonq); + if ( jsonp.valid === false || jsonp.value !== undefined && trusted !== true ) { + return safe.uboLog(logPrefix, 'Bad JSONPath query'); + } + const editObj = objBefore => { + const objAfter = jsonp.apply(objBefore); + if ( objAfter === undefined ) { return; } + safe.uboLog(logPrefix, 'Edited'); + if ( safe.logLevel <= 1 ) { return; } + safe.uboLog(logPrefix, `After edit:\n${safe.JSON_stringify(objAfter, null, 2)}`); + }; + proxyApplyFn(propChain, function(context) { + const { thisArg } = context; + let r; + if ( order === 'after' ) { + r = context.reflect(); + } + editObj(thisArg); + if ( order !== 'after' ) { + r = context.reflect(); + } + return r; + }); +} +registerScriptlet(editThisObjectFn, { + name: 'edit-this-object.fn', + dependencies: [ + JSONPath, + proxyApplyFn, + safeSelf, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet edit-this-object.js + * + * @description + * Prune properties from an object through one of the object's own method. + * Properties can only be removed. + * + * @param propChain + * Property chain of the method to trap. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * @param order + * If set to `after`, the `this` object will be edited after the trapped method + * is called. By default the `this` object is edited before calling the trapped + * method. + * + * */ + +function editThisObject(...args) { + editThisObjectFn(false, ...args); +} +registerScriptlet(editThisObject, { + name: 'edit-this-object.js', + dependencies: [ + editThisObjectFn, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet trusted-edit-this-object.js + * + * @description + * Edit properties of an object through one of the object's own method. + * Properties can be assigned new values. + * + * @param propChain + * Property chain of the method to trap. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * @param order + * If set to `after`, the `this` object will be edited after the trapped method + * is called. By default the `this` object is edited before calling the trapped + * method. + * + * */ + +function trustedEditThisObject(...args) { + editThisObjectFn(true, ...args); +} +registerScriptlet(trustedEditThisObject, { + name: 'trusted-edit-this-object.js', + requiresTrust: true, + dependencies: [ + editThisObjectFn, + ], +}); + +/******************************************************************************/ +/******************************************************************************/ + +function editObjectOnGetterFn( + trusted = false, + propChain = '', + jsonq = '', +) { + if ( propChain === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix( + `${trusted ? 'trusted-' : ''}edit-object-on-getter`, + propChain, + jsonq + ); + const jsonp = JSONPath.create(jsonq); + if ( jsonp.valid === false || jsonp.value !== undefined && trusted !== true ) { + return safe.uboLog(logPrefix, 'Bad JSONPath query'); + } + const editObj = objBefore => { + const objAfter = jsonp.apply(objBefore); + if ( objAfter === undefined ) { return; } + safe.uboLog(logPrefix, 'Edited'); + if ( safe.logLevel <= 1 ) { return; } + safe.uboLog(logPrefix, `After edit:\n${safe.JSON_stringify(objAfter, null, 2)}`); + }; + let currentValue = trapPropertyFn(propChain, { + get: function() { + if ( currentValue instanceof Object ) { + editObj(currentValue); + } + return currentValue; + }, + set: function(a) { + currentValue = a; + } + }); +} +registerScriptlet(editObjectOnGetterFn, { + name: 'edit-object-on-getter.fn', + dependencies: [ + JSONPath, + safeSelf, + trapPropertyFn, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet edit-object-on-getter.js + * + * @description + * Prune properties from an object on read access. + * Properties can only be removed. + * + * @param propChain + * Property chain of the property to trap. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * */ + +function editObjectOnGetter(...args) { + editObjectOnGetterFn(false, ...args); +} +registerScriptlet(editObjectOnGetter, { + name: 'edit-object-on-getter.js', + dependencies: [ + editObjectOnGetterFn, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet trusted-edit-object-on-getter.js + * + * @description + * Edit properties of an object on read access. + * Properties can be assigned new values. + * + * @param propChain + * Property chain of the property to trap. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * */ + +function trustedEditObjectOnGetter(...args) { + editObjectOnGetterFn(true, ...args); +} +registerScriptlet(trustedEditObjectOnGetter, { + name: 'trusted-edit-object-on-getter.js', + requiresTrust: true, + dependencies: [ + editObjectOnGetterFn, + ], +}); + +/******************************************************************************/ +/******************************************************************************/ + +function editObjectOnSetterFn( + trusted = false, + propChain = '', + jsonq = '', +) { + if ( propChain === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix( + `${trusted ? 'trusted-' : ''}edit-object-on-setter`, + propChain, + jsonq + ); + const jsonp = JSONPath.create(jsonq); + if ( jsonp.valid === false || jsonp.value !== undefined && trusted !== true ) { + return safe.uboLog(logPrefix, 'Bad JSONPath query'); + } + const editObj = objBefore => { + const objAfter = jsonp.apply(objBefore); + if ( objAfter === undefined ) { return; } + safe.uboLog(logPrefix, 'Edited'); + if ( safe.logLevel <= 1 ) { return; } + safe.uboLog(logPrefix, `After edit:\n${safe.JSON_stringify(objAfter, null, 2)}`); + }; + let currentValue = trapPropertyFn(propChain, { + get: function() { + return currentValue; + }, + set: function(a) { + currentValue = a; + if ( currentValue instanceof Object ) { + editObj(currentValue); + } + } + }); + if ( currentValue instanceof Object ) { + editObj(currentValue); + } +} +registerScriptlet(editObjectOnSetterFn, { + name: 'edit-object-on-setter.fn', + dependencies: [ + JSONPath, + safeSelf, + trapPropertyFn, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet edit-object-on-setter.js + * + * @description + * Prune properties from an object on write access. + * Properties can only be removed. + * + * @param propChain + * Property chain of the property to trap. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * */ + +function editObjectOnSetter(...args) { + editObjectOnSetterFn(false, ...args); +} +registerScriptlet(editObjectOnSetter, { + name: 'edit-object-on-setter.js', + dependencies: [ + editObjectOnSetterFn, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet trusted-edit-object-on-setter.js + * + * @description + * Edit properties of an object on write access. + * Properties can be assigned new values. + * + * @param propChain + * Property chain of the property to trap. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * */ + +function trustedEditObjectOnSetter(...args) { + editObjectOnSetterFn(true, ...args); +} +registerScriptlet(trustedEditObjectOnSetter, { + name: 'trusted-edit-object-on-setter.js', + requiresTrust: true, + dependencies: [ + editObjectOnSetterFn, + ], +}); + +/******************************************************************************/ +/******************************************************************************/ + +async function editElementObjectFn( + trusted = false, + selector = '', + jsonq = '', + timeout = '' +) { + if ( selector === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix( + `${trusted ? 'trusted-' : ''}edit-element-object`, + selector, jsonq, timeout + ); + const jsonp = JSONPath.create(jsonq); + if ( jsonp.valid === false || jsonp.value !== undefined && trusted !== true ) { + return safe.uboLog(logPrefix, 'Bad JSONPath query'); + } + const process = elems => { + for ( const elem of elems ) { + const r = jsonp.apply(elem); + if ( r === undefined ) { continue; } + safe.uboLog(logPrefix, 'Edited'); + } + }; + const until = timeout !== '' ? Date.now() + parseInt(timeout, 10) : 0; + do { + const r = lookupElementsFn(selector, until); + if ( Array.isArray(r) ) { + process(r); + } else if ( r instanceof Promise ) { + const elems = await r; + process(elems); + } + await sleepFn(); + } while ( Date.now() < until ); +} +registerScriptlet(editElementObjectFn, { + name: 'edit-element-object.fn', + dependencies: [ + JSONPath, + lookupElementsFn, + safeSelf, + sleepFn, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet edit-element-object.js + * + * @description + * Prune properties of one or more elements matching a specific selector. + * Properties can only be removed. + * + * @param selector + * The selector used to locate the elements on which the JSONPath query will + * be applied. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * @param timeout + * The maximum time (in milliseconds) to wait for matching elements before + * stopping. Defaults to 0, which executes a single lookup attempt without + * waiting. + * + * */ + +function editElementObject(...args) { + editElementObjectFn(false, ...args); +} +registerScriptlet(editElementObject, { + name: 'edit-element-object.js', + dependencies: [ + editElementObjectFn, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet trusted-edit-element-object.js + * + * @description + * Edit properties of one or more elements matching a specific selector. + * Properties can be assigned new values. + * + * @param selector + * The selector used to locate the elements on which the JSONPath query will + * be applied. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * @param timeout + * The maximum time (in milliseconds) to wait for matching elements before + * stopping. Defaults to 0, which executes a single lookup attempt without + * waiting. + * + * */ + +function trustedEditElementObject(...args) { + editElementObjectFn(true, ...args); +} +registerScriptlet(trustedEditElementObject, { + name: 'trusted-edit-element-object.js', + requiresTrust: true, + dependencies: [ + editElementObjectFn, + ], +}); + +/******************************************************************************/ +/******************************************************************************/ + +function jsonEditXhrResponseFn(trusted, jsonq = '', ...varargs) { + 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.parseVarargs(varargs); + 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 ) { + return (xhrDetails.response = innerResponse); + } + const objAfter = jsonp.apply(obj); + if ( objAfter === undefined ) { + return (xhrDetails.response = innerResponse); + } + safe.uboLog(logPrefix, 'Edited'); + const outerResponse = typeof innerResponse === 'string' + ? JSONPath.toJSON(objAfter, safe.JSON_stringify) + : objAfter; + 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 jsonEditXhrRequestFn(trusted, jsonq = '', ...varargs) { + 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.parseVarargs(varargs); + 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 objAfter = jsonp.apply(data); + if ( objAfter === undefined ) { return; } + body = safe.JSON_stringify(objAfter); + 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 = '', ...varargs) { + 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.parseVarargs(varargs); + const propNeedles = parsePropertiesToMatchFn(extraArgs.propsToMatch, 'url'); + proxyApplyFn('fetch', function(context) { + const args = context.callArgs; + const fetchPromise = context.reflect(); + if ( propNeedles.size !== 0 ) { + const props = collateFetchArgumentsFn(...args); + const matched = matchObjectPropertiesFn(propNeedles, props); + 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; } + const objAfter = jsonp.apply(obj); + if ( objAfter === undefined ) { return responseBefore; } + safe.uboLog(logPrefix, 'Edited'); + const responseAfter = Response.json(objAfter, { + 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: [ + collateFetchArgumentsFn, + 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 jsonEditFetchRequestFn(trusted, jsonq = '', ...varargs) { + 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.parseVarargs(varargs); + 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 objAfter = jsonp.apply(data); + if ( objAfter === undefined ) { return; } + return safe.JSON_stringify(objAfter); + } + 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 props = collateFetchArgumentsFn(resource, options); + const matched = matchObjectPropertiesFn(propNeedles, props); + 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: [ + collateFetchArgumentsFn, + JSONPath, + matchObjectPropertiesFn, + parsePropertiesToMatchFn, + proxyApplyFn, + safeSelf, + ], +}); + +/******************************************************************************/ +/******************************************************************************/ + +async function editInboundElementFn( + trusted = false, + propChain = '', + argPosRaw = '', + selector = '', + jsonq = '' +) { + if ( propChain === '' ) { return; } + if ( selector === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix( + `${trusted ? 'trusted-' : ''}edit-inbound-element`, + propChain, argPosRaw, selector, jsonq + ); + const jsonp = JSONPath.create(jsonq); + if ( jsonp.valid === false || jsonp.value !== undefined && trusted !== true ) { + return safe.uboLog(logPrefix, 'Bad JSONPath query'); + } + const argPos = parseInt(argPosRaw, 10); + const getElem = context => { + if ( argPosRaw === 'this' ) { return context.thisArg; } + const { callArgs } = context; + if ( Array.isArray(callArgs) === false ) { return; } + if ( isNaN(argPos) ) { return; } + if ( argPos >= 0 ) { + if ( callArgs.length <= argPos ) { return; } + return callArgs[argPos]; + } + if ( callArgs.length < -argPos ) { return; } + return callArgs[callArgs.length + argPos]; + }; + const editElem = elem => { + if ( elem instanceof Element === false ) { return; } + if ( elem.matches(selector) === false ) { return; } + elem = jsonp.apply(elem); + if ( elem === undefined ) { return; } + safe.uboLog(logPrefix, 'Edited'); + }; + proxyApplyFn(propChain, function(context) { + const elem = getElem(context); + if ( elem !== undefined ) { + editElem(elem); + } + return context.reflect(); + }); +} +registerScriptlet(editInboundElementFn, { + name: 'edit-inbound-element.fn', + dependencies: [ + JSONPath, + proxyApplyFn, + safeSelf, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet trusted-edit-inbound-element.js + * + * @description + * Edit properties of an element passed as an argument to a method. + * Properties can be assigned new values. + * + * @param propChain + * Property chain of the method to trap. + * + * @param argPos + * 0-based position of the argument. Use negative integer for position relative + * to the end. + * + * @param selector + * The selector the element must match for the JSONPath query to be applied. + * + * @param jsonq + * A uBO-flavored JSONPath query. + * + * */ + +function trustedEditInboundElement(...args) { + editInboundElementFn(true, ...args); +} +registerScriptlet(trustedEditInboundElement, { + name: 'trusted-edit-inbound-element.js', + requiresTrust: true, + dependencies: [ + editInboundElementFn, + ], +}); + +/******************************************************************************/ +/** + * @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 = '', jsonExtract) { + const safe = safeSelf(); + const lineSeparator = /\r?\n/.exec(text)?.[0] || '\n'; + const linesBefore = text.split('\n'); + const linesAfter = []; + for ( const lineBefore of linesBefore ) { + const match = jsonExtract.exec(lineBefore); + if ( match === null ) { + linesAfter.push(lineBefore); + continue; + } + const jsonBefore = match[1]; + let obj; + try { obj = safe.JSON_parse(jsonBefore); } catch { } + if ( typeof obj !== 'object' || obj === null ) { + linesAfter.push(lineBefore); + continue; + } + const objAfter = jsonp.apply(obj); + if ( objAfter === undefined ) { + linesAfter.push(lineBefore); + continue; + } + const jsonAfter = safe.JSON_stringify(objAfter); + const lineAfter = [ + lineBefore.slice(0, match.index), + jsonAfter, + lineBefore.slice(match.index + jsonBefore.length), + ].join(''); + linesAfter.push(lineAfter); + } + return linesAfter.join(lineSeparator); +} +registerScriptlet(jsonlEditFn, { + name: 'jsonl-edit.fn', + dependencies: [ + JSONPath, + safeSelf, + ], +}); + +/******************************************************************************/ + +function jsonlEditXhrResponseFn(trusted, jsonq = '', ...varargs) { + 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.parseVarargs(varargs); + const propNeedles = parsePropertiesToMatchFn(extraArgs.propsToMatch, 'url'); + const jsonExtract = extraArgs.jsonExtract + ? new RegExp(extraArgs.jsonExtract) + : /^(.*)$/; + 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, jsonExtract); + 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 = '', ...varargs) { + 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.parseVarargs(varargs); + const propNeedles = parsePropertiesToMatchFn(extraArgs.propsToMatch, 'url'); + const jsonExtract = extraArgs.jsonExtract + ? new RegExp(extraArgs.jsonExtract) + : /^(.*)$/; + const logall = jsonq === ''; + proxyApplyFn('fetch', function(context) { + const args = context.callArgs; + const fetchPromise = context.reflect(); + if ( propNeedles.size !== 0 ) { + const props = collateFetchArgumentsFn(...args); + const matched = matchObjectPropertiesFn(propNeedles, props); + 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, jsonExtract); + 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: [ + collateFetchArgumentsFn, + 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/privaxy/src/resources/vendor/ublock/scriptlet-resources/json-prune.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/json-prune.js new file mode 100644 index 00000000..22c5dc46 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/json-prune.js @@ -0,0 +1,269 @@ +/******************************************************************************* + + 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 { + collateFetchArgumentsFn, + matchObjectPropertiesFn, + parsePropertiesToMatchFn, +} from './utils.js'; + +import { objectPruneFn } from './object-prune.js'; +import { proxyApplyFn } from './proxy-apply.js'; +import { registerScriptlet } from './base.js'; +import { safeSelf } from './safe-self.js'; + +/******************************************************************************/ + +function jsonPrune( + rawPrunePaths = '', + rawNeedlePaths = '', + stackNeedle = '', + ...varargs +) { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('json-prune', rawPrunePaths, rawNeedlePaths, stackNeedle); + const stackNeedleDetails = safe.initPattern(stackNeedle, { canNegate: true }); + const extraArgs = safe.parseVarargs(varargs); + proxyApplyFn('JSON.parse', function(context) { + const objBefore = context.reflect(); + if ( rawPrunePaths === '' ) { + safe.uboLog(logPrefix, safe.JSON_stringify(objBefore, null, 2)); + } + const objAfter = objectPruneFn( + objBefore, + rawPrunePaths, + rawNeedlePaths, + stackNeedleDetails, + extraArgs + ); + if ( objAfter === undefined ) { return objBefore; } + safe.uboLog(logPrefix, 'Pruned'); + if ( safe.logLevel > 1 ) { + safe.uboLog(logPrefix, `After pruning:\n${safe.JSON_stringify(objAfter, null, 2)}`); + } + return objAfter; + }); +} +registerScriptlet(jsonPrune, { + name: 'json-prune.js', + dependencies: [ + objectPruneFn, + proxyApplyFn, + safeSelf, + ], +}); + +/******************************************************************************/ + +function jsonPruneFetchResponse( + rawPrunePaths = '', + rawNeedlePaths = '', + ...varargs +) { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('json-prune-fetch-response', rawPrunePaths, rawNeedlePaths); + const extraArgs = safe.parseVarargs(varargs); + const propNeedles = parsePropertiesToMatchFn(extraArgs.propsToMatch, 'url'); + const stackNeedle = safe.initPattern(extraArgs.stackToMatch || '', { canNegate: true }); + const logall = rawPrunePaths === ''; + const applyHandler = function(target, thisArg, args) { + const fetchPromise = Reflect.apply(target, thisArg, args); + if ( propNeedles.size !== 0 ) { + const props = collateFetchArgumentsFn(...args); + const matched = matchObjectPropertiesFn(propNeedles, props); + 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(objBefore => { + if ( typeof objBefore !== 'object' ) { return responseBefore; } + if ( logall ) { + safe.uboLog(logPrefix, safe.JSON_stringify(objBefore, null, 2)); + return responseBefore; + } + const objAfter = objectPruneFn( + objBefore, + rawPrunePaths, + rawNeedlePaths, + stackNeedle, + extraArgs + ); + if ( typeof objAfter !== 'object' ) { return responseBefore; } + safe.uboLog(logPrefix, 'Pruned'); + const responseAfter = Response.json(objAfter, { + 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(jsonPruneFetchResponse, { + name: 'json-prune-fetch-response.js', + dependencies: [ + collateFetchArgumentsFn, + matchObjectPropertiesFn, + objectPruneFn, + parsePropertiesToMatchFn, + safeSelf, + ], +}); + +/******************************************************************************/ + +function jsonPruneXhrResponse( + rawPrunePaths = '', + rawNeedlePaths = '', + ...varargs +) { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('json-prune-xhr-response', rawPrunePaths, rawNeedlePaths); + const xhrInstances = new WeakMap(); + const extraArgs = safe.parseVarargs(varargs); + const propNeedles = parsePropertiesToMatchFn(extraArgs.propsToMatch, 'url'); + const stackNeedle = safe.initPattern(extraArgs.stackToMatch || '', { canNegate: true }); + self.XMLHttpRequest = class extends self.XMLHttpRequest { + open(method, url, ...args) { + const xhrDetails = { method, url }; + let outcome = 'match'; + if ( propNeedles.size !== 0 ) { + if ( matchObjectPropertiesFn(propNeedles, xhrDetails) === undefined ) { + outcome = 'nomatch'; + } + } + if ( outcome === 'match' ) { + if ( safe.logLevel > 1 ) { + safe.uboLog(logPrefix, `Matched optional "propsToMatch", "${extraArgs.propsToMatch}"`); + } + 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 objBefore; + if ( typeof innerResponse === 'object' ) { + objBefore = innerResponse; + } else if ( typeof innerResponse === 'string' ) { + try { + objBefore = safe.JSON_parse(innerResponse); + } catch { + } + } + if ( typeof objBefore !== 'object' ) { + return (xhrDetails.response = innerResponse); + } + const objAfter = objectPruneFn( + objBefore, + rawPrunePaths, + rawNeedlePaths, + stackNeedle, + extraArgs + ); + let outerResponse; + if ( typeof objAfter === 'object' ) { + outerResponse = typeof innerResponse === 'string' + ? safe.JSON_stringify(objAfter) + : objAfter; + safe.uboLog(logPrefix, 'Pruned'); + } else { + outerResponse = innerResponse; + } + return (xhrDetails.response = outerResponse); + } + get responseText() { + const response = this.response; + return typeof response !== 'string' + ? super.responseText + : response; + } + }; +} +registerScriptlet(jsonPruneXhrResponse, { + name: 'json-prune-xhr-response.js', + dependencies: [ + matchObjectPropertiesFn, + objectPruneFn, + parsePropertiesToMatchFn, + safeSelf, + ], +}); + +/******************************************************************************/ + +// There is still code out there which uses `eval` in lieu of `JSON.parse`. + +function evaldataPrune( + rawPrunePaths = '', + rawNeedlePaths = '' +) { + proxyApplyFn('eval', function(context) { + const before = context.reflect(); + if ( typeof before !== 'object' ) { return before; } + if ( before === null ) { return null; } + const after = objectPruneFn(before, rawPrunePaths, rawNeedlePaths); + return after || before; + }); +} +registerScriptlet(evaldataPrune, { + name: 'evaldata-prune.js', + dependencies: [ + objectPruneFn, + proxyApplyFn, + ], +}); + +/******************************************************************************/ diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/localstorage.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/localstorage.js new file mode 100644 index 00000000..69c03709 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/localstorage.js @@ -0,0 +1,210 @@ +/******************************************************************************* + + 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 { getSafeCookieValuesFn } from './cookie.js'; +import { registerScriptlet } from './base.js'; +import { safeSelf } from './safe-self.js'; + +/******************************************************************************/ + +export function getAllLocalStorageFn(which = 'localStorage') { + const storage = self[which]; + const out = []; + for ( let i = 0; i < storage.length; i++ ) { + const key = storage.key(i); + const value = storage.getItem(key); + return { key, value }; + } + return out; +} +registerScriptlet(getAllLocalStorageFn, { + name: 'get-all-local-storage.fn', +}); + +/******************************************************************************/ + +export function setLocalStorageItemFn( + which = 'local', + trusted = false, + key = '', + value = '', + options = {} +) { + if ( key === '' ) { return; } + + // For increased compatibility with AdGuard + if ( value === 'emptyArr' ) { + value = '[]'; + } else if ( value === 'emptyObj' ) { + value = '{}'; + } + + const trustedValues = [ + '', + 'undefined', 'null', + '{}', '[]', '""', + '$remove$', + ...getSafeCookieValuesFn(), + ]; + + if ( trusted ) { + if ( value.includes('$now$') ) { + value = value.replaceAll('$now$', Date.now()); + } + if ( value.includes('$currentDate$') ) { + value = value.replaceAll('$currentDate$', `${Date()}`); + } + if ( value.includes('$currentISODate$') ) { + value = value.replaceAll('$currentISODate$', (new Date()).toISOString()); + } + } else { + const normalized = value.toLowerCase(); + const match = /^("?)(.+)\1$/.exec(normalized); + const unquoted = match && match[2] || normalized; + if ( trustedValues.includes(unquoted) === false ) { + if ( /^-?\d+$/.test(unquoted) === false ) { return; } + const n = parseInt(unquoted, 10) || 0; + if ( n < -32767 || n > 32767 ) { return; } + } + } + + let modified = false; + + try { + const storage = self[`${which}Storage`]; + if ( value === '$remove$' ) { + const safe = safeSelf(); + const pattern = safe.patternToRegex(key, undefined, true ); + const toRemove = []; + for ( let i = 0, n = storage.length; i < n; i++ ) { + const key = storage.key(i); + if ( pattern.test(key) ) { toRemove.push(key); } + } + modified = toRemove.length !== 0; + for ( const key of toRemove ) { + storage.removeItem(key); + } + } else { + + const before = storage.getItem(key); + const after = `${value}`; + modified = after !== before; + if ( modified ) { + storage.setItem(key, after); + } + } + } catch { + } + + if ( modified && typeof options.reload === 'number' ) { + setTimeout(( ) => { window.location.reload(); }, options.reload); + } +} +registerScriptlet(setLocalStorageItemFn, { + name: 'set-local-storage-item.fn', + dependencies: [ + getSafeCookieValuesFn, + safeSelf, + ], +}); + +/******************************************************************************* + * + * set-local-storage-item.js + * set-session-storage-item.js + * + * Set a local/session storage entry to a specific, allowed value. + * + * Reference: + * https://github.com/AdguardTeam/Scriptlets/blob/master/src/scriptlets/set-local-storage-item.js + * https://github.com/AdguardTeam/Scriptlets/blob/master/src/scriptlets/set-session-storage-item.js + * + **/ + +export function setLocalStorageItem(key = '', value = '', ...varargs) { + const safe = safeSelf(); + const options = safe.parseVarargs(varargs) + setLocalStorageItemFn('local', false, key, value, options); +} +registerScriptlet(setLocalStorageItem, { + name: 'set-local-storage-item.js', + world: 'ISOLATED', + dependencies: [ + safeSelf, + setLocalStorageItemFn, + ], +}); + +export function setSessionStorageItem(key = '', value = '', ...varargs) { + const safe = safeSelf(); + const options = safe.parseVarargs(varargs) + setLocalStorageItemFn('session', false, key, value, options); +} +registerScriptlet(setSessionStorageItem, { + name: 'set-session-storage-item.js', + world: 'ISOLATED', + dependencies: [ + safeSelf, + setLocalStorageItemFn, + ], +}); + +/******************************************************************************* + * + * trusted-set-local-storage-item.js + * + * Set a local storage entry to an arbitrary value. + * + * Reference: + * https://github.com/AdguardTeam/Scriptlets/blob/master/src/scriptlets/trusted-set-local-storage-item.js + * + **/ + +export function trustedSetLocalStorageItem(key = '', value = '', ...varargs) { + const safe = safeSelf(); + const options = safe.parseVarargs(varargs) + setLocalStorageItemFn('local', true, key, value, options); +} +registerScriptlet(trustedSetLocalStorageItem, { + name: 'trusted-set-local-storage-item.js', + requiresTrust: true, + world: 'ISOLATED', + dependencies: [ + safeSelf, + setLocalStorageItemFn, + ], +}); + +export function trustedSetSessionStorageItem(key = '', value = '', ...varargs) { + const safe = safeSelf(); + const options = safe.parseVarargs(varargs) + setLocalStorageItemFn('session', true, key, value, options); +} +registerScriptlet(trustedSetSessionStorageItem, { + name: 'trusted-set-session-storage-item.js', + requiresTrust: true, + world: 'ISOLATED', + dependencies: [ + safeSelf, + setLocalStorageItemFn, + ], +}); diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/noeval.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/noeval.js new file mode 100644 index 00000000..d66db532 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/noeval.js @@ -0,0 +1,58 @@ +/******************************************************************************* + + 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 { proxyApplyFn } from './proxy-apply.js'; +import { registerScriptlet } from './base.js'; +import { safeSelf } from './safe-self.js'; + +/******************************************************************************/ + +function noEvalIf( + needle = '' +) { + if ( typeof needle !== 'string' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('noeval-if', needle); + const reNeedle = safe.patternToRegex(needle); + proxyApplyFn('eval', function(context) { + const { callArgs } = context; + const a = String(callArgs[0]); + if ( needle !== '' && reNeedle.test(a) ) { + safe.uboLog(logPrefix, 'Prevented:\n', a); + return; + } + if ( needle === '' || safe.logLevel > 1 ) { + safe.uboLog(logPrefix, 'Not prevented:\n', a); + } + return context.reflect(); + }); +} +registerScriptlet(noEvalIf, { + name: 'noeval-if.js', + aliases: [ + 'prevent-eval-if.js', + ], + dependencies: [ + proxyApplyFn, + safeSelf, + ], +}); diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/object-prune.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/object-prune.js new file mode 100644 index 00000000..537e2b20 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/object-prune.js @@ -0,0 +1,274 @@ +/******************************************************************************* + + 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 { matchesStackTraceFn } from './stack-trace.js'; +import { proxyApplyFn } from './proxy-apply.js'; +import { registerScriptlet } from './base.js'; +import { safeSelf } from './safe-self.js'; + +/******************************************************************************/ + +function objectFindOwnerFn( + root, + path, + prune = false +) { + const safe = safeSelf(); + let owner = root; + let chain = path; + for (;;) { + if ( typeof owner !== 'object' || owner === null ) { return false; } + const pos = chain.indexOf('.'); + if ( pos === -1 ) { + if ( prune === false ) { + return safe.Object_hasOwn(owner, chain); + } + let modified = false; + if ( chain === '*' ) { + for ( const key in owner ) { + if ( safe.Object_hasOwn(owner, key) === false ) { continue; } + delete owner[key]; + modified = true; + } + } else if ( safe.Object_hasOwn(owner, chain) ) { + delete owner[chain]; + modified = true; + } + return modified; + } + const prop = chain.slice(0, pos); + const next = chain.slice(pos + 1); + let found = false; + if ( prop === '[-]' && Array.isArray(owner) ) { + let i = owner.length; + while ( i-- ) { + if ( objectFindOwnerFn(owner[i], next) === false ) { continue; } + owner.splice(i, 1); + found = true; + } + return found; + } + if ( prop === '{-}' && owner instanceof Object ) { + for ( const key of Object.keys(owner) ) { + if ( objectFindOwnerFn(owner[key], next) === false ) { continue; } + delete owner[key]; + found = true; + } + return found; + } + if ( + prop === '[]' && Array.isArray(owner) || + prop === '{}' && owner instanceof Object || + prop === '*' && owner instanceof Object + ) { + for ( const key of Object.keys(owner) ) { + if (objectFindOwnerFn(owner[key], next, prune) === false ) { continue; } + found = true; + } + return found; + } + if ( safe.Object_hasOwn(owner, prop) === false ) { return false; } + owner = owner[prop]; + chain = chain.slice(pos + 1); + } +} +registerScriptlet(objectFindOwnerFn, { + name: 'object-find-owner.fn', + dependencies: [ + safeSelf, + ], +}); + +/******************************************************************************/ + +// When no "prune paths" argument is provided, the scriptlet is +// used for logging purpose and the "needle paths" argument is +// used to filter logging output. +// +// https://github.com/uBlockOrigin/uBlock-issues/issues/1545 +// - Add support for "remove everything if needle matches" case + +export function objectPruneFn( + obj, + rawPrunePaths, + rawNeedlePaths, + stackNeedleDetails = { matchAll: true }, + extraArgs = {} +) { + if ( typeof rawPrunePaths !== 'string' ) { return; } + const safe = safeSelf(); + const prunePaths = rawPrunePaths !== '' + ? safe.String_split.call(rawPrunePaths, / +/) + : []; + const needlePaths = prunePaths.length !== 0 && rawNeedlePaths !== '' + ? safe.String_split.call(rawNeedlePaths, / +/) + : []; + if ( stackNeedleDetails.matchAll !== true ) { + if ( matchesStackTraceFn(stackNeedleDetails, extraArgs.logstack) === false ) { + return; + } + } + if ( objectPruneFn.mustProcess === undefined ) { + objectPruneFn.mustProcess = (root, needlePaths) => { + for ( const needlePath of needlePaths ) { + if ( objectFindOwnerFn(root, needlePath) === false ) { + return false; + } + } + return true; + }; + } + if ( prunePaths.length === 0 ) { return; } + let outcome = 'nomatch'; + if ( objectPruneFn.mustProcess(obj, needlePaths) ) { + for ( const path of prunePaths ) { + if ( objectFindOwnerFn(obj, path, true) ) { + outcome = 'match'; + } + } + } + if ( outcome === 'match' ) { return obj; } +} +registerScriptlet(objectPruneFn, { + name: 'object-prune.fn', + dependencies: [ + matchesStackTraceFn, + objectFindOwnerFn, + safeSelf, + ], +}); + +/******************************************************************************/ + +function trustedPruneInboundObject( + entryPoint = '', + argPos = '', + rawPrunePaths = '', + rawNeedlePaths = '', + ...varargs +) { + if ( entryPoint === '' ) { return; } + let context = globalThis; + let prop = entryPoint; + for (;;) { + const pos = prop.indexOf('.'); + if ( pos === -1 ) { break; } + context = context[prop.slice(0, pos)]; + if ( context instanceof Object === false ) { return; } + prop = prop.slice(pos+1); + } + if ( typeof context[prop] !== 'function' ) { return; } + const argIndex = parseInt(argPos); + if ( isNaN(argIndex) ) { return; } + if ( argIndex < 1 ) { return; } + const safe = safeSelf(); + const extraArgs = safe.parseVarargs(varargs); + const needlePaths = []; + if ( rawPrunePaths !== '' ) { + needlePaths.push(...safe.String_split.call(rawPrunePaths, / +/)); + } + if ( rawNeedlePaths !== '' ) { + needlePaths.push(...safe.String_split.call(rawNeedlePaths, / +/)); + } + const stackNeedle = safe.initPattern(extraArgs.stackToMatch || '', { canNegate: true }); + const mustProcess = root => { + for ( const needlePath of needlePaths ) { + if ( objectFindOwnerFn(root, needlePath) === false ) { + return false; + } + } + return true; + }; + context[prop] = new Proxy(context[prop], { + apply: function(target, thisArg, args) { + const targetArg = argIndex <= args.length + ? args[argIndex-1] + : undefined; + if ( targetArg instanceof Object && mustProcess(targetArg) ) { + let objBefore = targetArg; + if ( extraArgs.dontOverwrite ) { + try { + objBefore = safe.JSON_parse(safe.JSON_stringify(targetArg)); + } catch { + objBefore = undefined; + } + } + if ( objBefore !== undefined ) { + const objAfter = objectPruneFn( + objBefore, + rawPrunePaths, + rawNeedlePaths, + stackNeedle, + extraArgs + ); + args[argIndex-1] = objAfter || objBefore; + } + } + return Reflect.apply(target, thisArg, args); + }, + }); +} +registerScriptlet(trustedPruneInboundObject, { + name: 'trusted-prune-inbound-object.js', + requiresTrust: true, + dependencies: [ + objectFindOwnerFn, + objectPruneFn, + safeSelf, + ], +}); + +/******************************************************************************/ + +function trustedPruneOutboundObject( + propChain = '', + rawPrunePaths = '', + rawNeedlePaths = '', + ...varargs +) { + if ( propChain === '' ) { return; } + const safe = safeSelf(); + const extraArgs = safe.parseVarargs(varargs); + proxyApplyFn(propChain, function(context) { + const objBefore = context.reflect(); + if ( objBefore instanceof Object === false ) { return objBefore; } + const objAfter = objectPruneFn( + objBefore, + rawPrunePaths, + rawNeedlePaths, + { matchAll: true }, + extraArgs + ); + return objAfter || objBefore; + }); +} +registerScriptlet(trustedPruneOutboundObject, { + name: 'trusted-prune-outbound-object.js', + requiresTrust: true, + dependencies: [ + objectPruneFn, + proxyApplyFn, + safeSelf, + ], +}); + +/******************************************************************************/ diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/parse-replace.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/parse-replace.js new file mode 100644 index 00000000..604644f4 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/parse-replace.js @@ -0,0 +1,54 @@ +/******************************************************************************* + + 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 { ArglistParser } from './shared.js'; +import { registerScriptlet } from './base.js'; + +/******************************************************************************/ + +export function parseReplaceFn(s) { + if ( s.charCodeAt(0) !== 0x2F /* / */ ) { return; } + const parser = new ArglistParser('/'); + parser.nextArg(s, 1); + let pattern = s.slice(parser.argBeg, parser.argEnd); + if ( parser.transform ) { + pattern = parser.normalizeArg(pattern); + } + if ( pattern === '' ) { return; } + parser.nextArg(s, parser.separatorEnd); + let replacement = s.slice(parser.argBeg, parser.argEnd); + if ( parser.separatorEnd === parser.separatorBeg ) { return; } + if ( parser.transform ) { + replacement = parser.normalizeArg(replacement); + } + const flags = s.slice(parser.separatorEnd); + try { + return { re: new RegExp(pattern, flags), replacement }; + } catch { + } +} +registerScriptlet(parseReplaceFn, { + name: 'parse-replace.fn', + dependencies: [ + ArglistParser, + ], +}); diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-addeventlistener.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-addeventlistener.js new file mode 100644 index 00000000..1321edac --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-addeventlistener.js @@ -0,0 +1,160 @@ +/******************************************************************************* + + 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 { proxyApplyFn } from './proxy-apply.js'; +import { registerScriptlet } from './base.js'; +import { runAt } from './run-at.js'; +import { safeSelf } from './safe-self.js'; + +/******************************************************************************/ + +/** + * @scriptlet prevent-addEventListener + * + * @description + * Conditionally prevent execution of the callback function passed to native + * addEventListener method. With no parameters, all calls to addEventListener + * will be shown in the logger. + * + * @param [type] + * The type of the event to prevent. The pattern can be a plain string, or a + * regex to specify more than one event type. + * + * @param [pattern] + * A pattern to match against the stringified callback. The pattern can be a + * plain string, or a regex. + * + * @param [runat, value] + * An optional vararg which tell the scriptlet when the prevention should + * start. Values correspond to `readyState`: loading, interactive, complete. + * + * @param [protect, 1] + * An optional vararg which tells the scriptlet whether the prevention should + * be protected, i.e. not overwritten by other code. + * + * */ + +function preventAddEventListener( + type = '', + pattern = '', + ...varargs +) { + const safe = safeSelf(); + const extraArgs = safe.parseVarargs(varargs); + const logPrefix = safe.makeLogPrefix('prevent-addEventListener', type, pattern); + const reType = safe.patternToRegex(type, undefined, true); + const rePattern = safe.patternToRegex(pattern); + const targetSelector = extraArgs.elements || undefined; + const elementMatches = elem => { + if ( targetSelector === 'window' ) { return elem === window; } + if ( targetSelector === 'document' ) { return elem === document; } + if ( elem && elem.matches && elem.matches(targetSelector) ) { return true; } + const elems = Array.from(document.querySelectorAll(targetSelector)); + return elems.includes(elem); + }; + const elementDetails = elem => { + if ( elem instanceof Window ) { return 'window'; } + if ( elem instanceof Document ) { return 'document'; } + if ( elem instanceof Element === false ) { return '?'; } + const parts = []; + // https://github.com/uBlockOrigin/uAssets/discussions/17907#discussioncomment-9871079 + const id = String(elem.id); + if ( id !== '' ) { parts.push(`#${CSS.escape(id)}`); } + for ( let i = 0; i < elem.classList.length; i++ ) { + parts.push(`.${CSS.escape(elem.classList.item(i))}`); + } + for ( let i = 0; i < elem.attributes.length; i++ ) { + const attr = elem.attributes.item(i); + if ( attr.name === 'id' ) { continue; } + if ( attr.name === 'class' ) { continue; } + parts.push(`[${CSS.escape(attr.name)}="${attr.value}"]`); + } + return parts.join(''); + }; + const shouldPrevent = (thisArg, type, handler) => { + const matchesType = safe.RegExp_test(reType, type); + const matchesHandler = safe.RegExp_test(rePattern, handler); + const matchesEither = matchesType || matchesHandler; + const matchesBoth = matchesType && matchesHandler; + if ( safe.logLevel > 1 && matchesEither ) { + debugger; // eslint-disable-line no-debugger + } + if ( matchesBoth && targetSelector !== undefined ) { + if ( elementMatches(thisArg) === false ) { return false; } + } + return matchesBoth; + }; + const proxyFn = function(context) { + const { callArgs, thisArg } = context; + let t, h; + try { + t = String(callArgs[0]); + if ( typeof callArgs[1] === 'function' ) { + h = String(safe.Function_toString(callArgs[1])); + } else if ( typeof callArgs[1] === 'object' && callArgs[1] !== null ) { + if ( typeof callArgs[1].handleEvent === 'function' ) { + h = String(safe.Function_toString(callArgs[1].handleEvent)); + } + } else { + h = String(callArgs[1]); + } + } catch { + } + if ( type === '' && pattern === '' ) { + safe.uboLog(logPrefix, `Called: ${t}\n${h}\n${elementDetails(thisArg)}`); + } else if ( shouldPrevent(thisArg, t, h) ) { + return safe.uboLog(logPrefix, `Prevented: ${t}\n${h}\n${elementDetails(thisArg)}`); + } + return context.reflect(); + }; + const protect = owner => { + const { addEventListener } = owner; + Object.defineProperty(owner, 'addEventListener', { + set() { }, + get() { return addEventListener; } + }); + }; + runAt(( ) => { + proxyApplyFn('EventTarget.prototype.addEventListener', proxyFn); + if ( extraArgs.protect ) { protect(EventTarget.prototype); } + if ( Object.hasOwn(document, 'addEventListener') ) { + proxyApplyFn('document.addEventListener', proxyFn); + if ( extraArgs.protect ) { protect(document); } + } + if ( Object.hasOwn(window, 'addEventListener') ) { + proxyApplyFn('window.addEventListener', proxyFn); + if ( extraArgs.protect ) { protect(window); } + } + }, extraArgs.runAt); +} +registerScriptlet(preventAddEventListener , { + name: 'prevent-addEventListener.js', + aliases: [ + 'addEventListener-defuser.js', + 'aeld.js', + ], + dependencies: [ + proxyApplyFn, + runAt, + safeSelf, + ], +}); diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-bab.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-bab.js new file mode 100644 index 00000000..22af8059 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-bab.js @@ -0,0 +1,101 @@ +/******************************************************************************* + + uBlock Origin - a browser extension to block requests. + 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 { proxyApplyFn } from './proxy-apply.js'; +import { registerScriptlet } from './base.js'; +import { safeSelf } from './safe-self.js'; + +/******************************************************************************/ + +function preventBab() { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('prevent-bab'); + const signatures = [ + [ 'blockadblock' ], + [ 'babasbm' ], + [ /getItem\('babn'\)/ ], + [ + 'getElementById', + 'String.fromCharCode', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', + 'charAt', + 'DOMContentLoaded', + 'AdBlock', + 'addEventListener', + 'doScroll', + 'fromCharCode', + '<<2|r>>4', + 'sessionStorage', + 'clientWidth', + 'localStorage', + 'Math', + 'random', + ], + ]; + const check = function(s) { + if ( typeof s !== 'string' ) { return false; } + for ( const tokens of signatures ) { + let match = 0; + for ( const token of tokens ) { + const hit = token instanceof RegExp + ? token.test(s) + : s.includes(token); + if ( hit ) { match += 1; } + } + if ( (match / tokens.length) >= 0.8 ) { return true; } + } + return false; + }; + proxyApplyFn('eval', function(context) { + const a = context.callArgs[0]; + if ( !check(a) ) { + return context.reflect(); + } + safe.uboLog(logPrefix, 'Prevented'); + if ( document.body ) { + document.body.style.removeProperty('visibility'); + } + const el = document.getElementById('babasbmsgx'); + if ( el ) { + el.parentNode.removeChild(el); + } + }); + proxyApplyFn('setTimeout', function(context) { + const { callArgs } = context; + const a = callArgs[0]; + if ( typeof a === 'string' && /\.bab_elementid.$/.test(a) ) { + callArgs[0] = ( ) => { }; + safe.uboLog(logPrefix, 'Prevented'); + } + return context.reflect(); + }); +} +registerScriptlet(preventBab, { + name: 'prevent-bab.js', + aliases: [ + 'bab-defuser.js', + 'nobab.js', + ], + dependencies: [ + proxyApplyFn, + safeSelf, + ], +}); diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-clipboard-write.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-clipboard-write.js new file mode 100644 index 00000000..a3e4b54f --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-clipboard-write.js @@ -0,0 +1,153 @@ +/******************************************************************************* + + uBlock Origin - a browser extension to block requests. + 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 { proxyApplyFn } from './proxy-apply.js'; +import { registerScriptlet } from './base.js'; +import { runAt } from './run-at.js'; +import { safeSelf } from './safe-self.js'; + +/******************************************************************************/ + +/** + * @scriptlet prevent-clipboard-write + * + * @description + * Prevent the clipboard from being overwritten. + * + * @param matches + * A pattern or regex to match against the text for the prevention to occur. + * + * @param excludeMatches + * Optional. A vararg to be used as a pattern or regex to match against the + * text for the prevention to NOT occur. + * + * @param domAlert + * Optional. A vararg to be used to alert the user in case a clipboard write + * operation was prevented. + * + * @example + * ##+js(prevent-clipboard-write, /^bash <<', + '${warning}\n', + '', + '', + ].join(''); + const domAlert = clipboardText => { + const doc = document; + const domAlert = extraArgs.domAlert.replace(/\\n/g, '\n'); + let html; + if ( domAlert.includes('${text}') ) { + const code = doc.createElement('code'); + const styles = [ + 'background-color: #ddc', + 'display: inline-block', + 'font-family: monospace', + 'font-size: 100%', + 'max-height: 8em', + 'overflow: auto', + 'padding: 0.25em', + 'user-select: all', + 'width: 100%;', + 'word-break: break-all' + ]; + if ( Boolean(extraArgs.selectable ?? true) === false ) { + styles.push('user-select: none'); + } + code.style = styles.join(';'); + code.textContent = clipboardText; + html = htmlTemplate.replace('${warning}', + domAlert.replace('${text}', code.outerHTML) + ); + } else { + html = htmlTemplate.replace('${warning}', domAlert); + } + if ( currentAlert ) { currentAlert.remove(); } + const domParser = new DOMParser(); + const fragment = domParser.parseFromString(html, 'text/html'); + currentAlert = fragment.querySelector('div'); + const button = currentAlert.querySelector('button'); + button.addEventListener('click', ( ) => { + if ( currentAlert === null ) { return; } + currentAlert.remove(); + currentAlert = null; + }); + currentAlert.setAttribute('popover', 'manual'); + doc.documentElement.append(currentAlert); + if ( typeof currentAlert.showPopover === 'function' ) { + currentAlert.showPopover(); + } + }; + let currentAlert = null; + const prevent = text => { + if ( typeof text !== 'string' ) { return; } + text = text.trim(); + if ( safe.testPattern(pattern, text) !== true ) { return; } + if ( extraArgs.excludeMatches ) { + if ( safe.testPattern(excludePattern, text) ) { return; } + } + if ( extraArgs.domAlert ) { + domAlert(text); + } + safe.uboLog(logPrefix, 'Prevented:\n\t', text); + return true; + }; + const installTraps = ( ) => { + proxyApplyFn('navigator.clipboard.writeText', async function(context) { + const text = `${context.callArgs[0]}`; + if ( prevent(text) ) { return; } + return context.reflect(); + }, { skipToString: true }); + proxyApplyFn('document.execCommand', function(context) { + const { callArgs } = context; + if ( callArgs[0] === 'copy' || callArgs[0] === 'cut' ) { + const text = document.getSelection()?.toString(); + if ( prevent(text) ) { return true; } + } + return context.reflect(); + }, { skipToString: true }); + }; + runAt(( ) => { + self.document.addEventListener('mousemove', installTraps, { + once: true, + capture: true, + }); + }, 'interactive') +} +registerScriptlet(preventClipboardWrite, { + name: 'prevent-clipboard-write.js', + requiresTrust: true, + dependencies: [ + proxyApplyFn, + runAt, + safeSelf, + ], +}); diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-dialog.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-dialog.js new file mode 100644 index 00000000..fbb86ef9 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-dialog.js @@ -0,0 +1,73 @@ +/******************************************************************************* + + uBlock Origin - a comprehensive, efficient content blocker + Copyright (C) 2025-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 { registerScriptlet } from './base.js'; +import { safeSelf } from './safe-self.js'; + +/** + * @scriptlet prevent-dialog + * + * @description + * Programmatically close `dialog` elements. + * + * @param [selector] + * Optional. The dialog element must matches `dialog{selector}` for the + * prevention to take place. + * + * @usage: + * example.com##+js(prevent-dialog) + * + * */ + +export function preventDialog( + selector = '', +) { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('prevent-dialog', selector); + const prevent = ( ) => { + debouncer = undefined; + const elems = document.querySelectorAll(`dialog${selector}`); + for ( const elem of elems ) { + if ( typeof elem.close !== 'function' ) { continue; } + if ( elem.open === false ) { continue; } + elem.close(); + safe.uboLog(logPrefix, 'Closed'); + } + }; + let debouncer; + const observer = new MutationObserver(( ) => { + if ( debouncer !== undefined ) { return; } + debouncer = requestAnimationFrame(prevent); + }); + observer.observe(document, { + attributes: true, + childList: true, + subtree: true, + }); +} +registerScriptlet(preventDialog, { + name: 'prevent-dialog.js', + dependencies: [ + safeSelf, + ], + world: 'ISOLATED', +}); diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-fetch.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-fetch.js new file mode 100644 index 00000000..d636859b --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-fetch.js @@ -0,0 +1,198 @@ +/******************************************************************************* + + 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 { + collateFetchArgumentsFn, + generateContentFn, + matchObjectPropertiesFn, + parsePropertiesToMatchFn, +} 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 = '', + ...varargs +) { + const safe = safeSelf(); + const setTimeout = self.setTimeout; + const scriptletName = `${trusted ? 'trusted-' : ''}prevent-fetch`; + const logPrefix = safe.makeLogPrefix( + scriptletName, + propsToMatch, + responseBody, + responseType + ); + const extraArgs = safe.parseVarargs(varargs); + const propNeedles = parsePropertiesToMatchFn(propsToMatch, 'url'); + const validResponseProps = { + ok: [ false, true ], + status: [ 403 ], + statusText: [ '', 'Not Found' ], + type: [ 'basic', 'cors', 'default', 'error', 'opaque' ], + }; + const responseProps = { + statusText: { value: 'OK' }, + }; + const responseHeaders = {}; + if ( /^\{.*\}$/.test(responseType) ) { + try { + Object.entries(JSON.parse(responseType)).forEach(([ p, v ]) => { + if ( p === 'headers' && trusted ) { + Object.assign(responseHeaders, v); + return; + } + 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 = collateFetchArgumentsFn(...callArgs); + if ( safe.logLevel > 1 || propsToMatch === '' && responseBody === '' ) { + const out = Array.from(Object.entries(details)).map(a => `${a[0]}:${a[1]}`); + safe.uboLog(logPrefix, `Called: ${out.join('\n')}`); + } + if ( propsToMatch === '' && responseBody === '' ) { + return context.reflect(); + } + const matched = matchObjectPropertiesFn(propNeedles, details); + if ( matched === undefined || matched.length === 0 ) { + return context.reflect(); + } + return Promise.resolve(generateContentFn(trusted, responseBody)).then(text => { + safe.uboLog(logPrefix, `Prevented with response "${text}"`); + const headers = Object.assign({}, responseHeaders); + if ( headers['content-length'] === undefined ) { + headers['content-length'] = text.length; + } + const response = new Response(text, { headers }); + const props = Object.assign( + { url: { value: details.url } }, + responseProps + ); + safe.Object_defineProperties(response, props); + if ( extraArgs.throttle ) { + return new Promise(resolve => { + setTimeout(( ) => { resolve(response); }, extraArgs.throttle); + }); + } + return response; + }); + }); +} +registerScriptlet(preventFetchFn, { + name: 'prevent-fetch.fn', + dependencies: [ + collateFetchArgumentsFn, + generateContentFn, + matchObjectPropertiesFn, + parsePropertiesToMatchFn, + 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. + * + * @param [...varargs] + * ["throttle", n]: the time to wait in ms before returning a result. + * + * */ + +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. + * + * @param [...varargs] + * ["throttle", n]: the time to wait in ms before returning a result. + * + * */ + +function trustedPreventFetch(...args) { + preventFetchFn(true, ...args); +} +registerScriptlet(trustedPreventFetch, { + name: 'trusted-prevent-fetch.js', + requiresTrust: true, + dependencies: [ + preventFetchFn, + ], +}); + +/******************************************************************************/ diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-innerHTML.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-innerHTML.js new file mode 100644 index 00000000..1e14c2ae --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-innerHTML.js @@ -0,0 +1,132 @@ +/******************************************************************************* + + uBlock Origin - a comprehensive, efficient content blocker + Copyright (C) 2025-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 { registerScriptlet } from './base.js'; +import { safeSelf } from './safe-self.js'; + +/** + * @scriptlet freeze-element-property + * + * @description + * Conditionally prevent assignment to an element property. + * + * @param property + * The name of the property to freeze. + * + * @param [selector] + * Optional. The element must match `selector` for the prevention to take + * place. + * + * @param [pattern] + * Optional. A pattern to match against the stringified assigned value. The + * pattern can be a plain string, or a regex. Prepend with `!` to reverse the + * match condition. + * + * */ + +function freezeElementProperty( + property = '', + selector = '', + pattern = '' +) { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('freeze-element-property', property, selector, pattern); + const matcher = safe.initPattern(pattern, { canNegate: true }); + const owner = (( ) => { + if ( Object.hasOwn(HTMLScriptElement.prototype, property) ) { + return HTMLScriptElement.prototype; + } + if ( Object.hasOwn(HTMLElement.prototype, property) ) { + return HTMLElement.prototype; + } + if ( Object.hasOwn(Element.prototype, property) ) { + return Element.prototype; + } + if ( Object.hasOwn(Node.prototype, property) ) { + return Node.prototype; + } + return null; + })(); + if ( owner === null ) { return; } + const current = safe.Object_getOwnPropertyDescriptor(owner, property); + if ( current === undefined ) { return; } + const shouldPreventSet = (elem, a) => { + if ( selector !== '' ) { + if ( typeof elem.matches !== 'function' ) { return false; } + if ( elem.matches(selector) === false ) { return false; } + } + return safe.testPattern(matcher, `${a}`); + }; + Object.defineProperty(owner, property, { + get: function() { + return current.get + ? current.get.call(this) + : current.value; + }, + set: function(a) { + if ( shouldPreventSet(this, a) ) { + safe.uboLog(logPrefix, 'Assignment prevented'); + } else if ( current.set ) { + current.set.call(this, a); + } + if ( safe.logLevel > 1 ) { + safe.uboLog(logPrefix, `Assigned:\n${a}`); + } + current.value = a; + }, + }); +} +registerScriptlet(freezeElementProperty, { + name: 'freeze-element-property.js', + dependencies: [ + safeSelf, + ], +}); + +/** + * @scriptlet prevent-innerHTML + * + * @description + * Conditionally prevent assignment to `innerHTML` property. + * + * @param [selector] + * Optional. The element must match `selector` for the prevention to take + * place. + * + * @param [pattern] + * Optional. A pattern to match against the assigned value. The pattern can be + * a plain string, or a regex. Prepend with `!` to reverse the match condition. + * + * */ + +function preventInnerHTML( + selector = '', + pattern = '' +) { + freezeElementProperty('innerHTML', selector, pattern); +} +registerScriptlet(preventInnerHTML, { + name: 'prevent-innerHTML.js', + dependencies: [ + freezeElementProperty, + ], +}); diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-navigation.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-navigation.js new file mode 100644 index 00000000..81a4b579 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-navigation.js @@ -0,0 +1,67 @@ +/******************************************************************************* + + uBlock Origin - a comprehensive, efficient content blocker + Copyright (C) 2026-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 { registerScriptlet } from './base.js'; +import { safeSelf } from './safe-self.js'; + +/** + * @scriptlet prevent-navigation + * + * @description + * Conditionally abort navigation events. + * + * @param [pattern] + * Optional. A pattern to match against the assigned value. The pattern can be + * a plain string, or a regex. Prepend with `!` to reverse the match condition. + * No pattern + * + * Reference: + * https://github.com/AdguardTeam/Scriptlets/commit/cd2d8eefd5 + * */ + +export function preventNavigation( + pattern = '' +) { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('prevent-navigation', pattern); + const needle = pattern === 'location.href' ? self.location.href : pattern; + const matcher = safe.initPattern(needle, { canNegate: true }); + self.navigation.addEventListener('navigate', ev => { + if ( ev.userInitiated ) { return; } + const { url } = ev.destination; + if ( pattern === '' ) { + safe.uboLog(logPrefix, `Navigation to ${url}`); + return; + } + if ( safe.testPattern(matcher, url) ) { + ev.preventDefault(); + safe.uboLog(logPrefix, `Prevented navigation to ${url}`); + } + }); +} +registerScriptlet(preventNavigation, { + name: 'prevent-navigation.js', + dependencies: [ + safeSelf, + ], + world: 'ISOLATED', +}); diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-settimeout.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-settimeout.js new file mode 100644 index 00000000..289c4465 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-settimeout.js @@ -0,0 +1,236 @@ +/******************************************************************************* + + 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 { proxyApplyFn } from './proxy-apply.js'; +import { registerScriptlet } from './base.js'; +import { safeSelf } from './safe-self.js'; + +/******************************************************************************/ + +class RangeParser { + constructor(s) { + this.not = s.charAt(0) === '!'; + if ( this.not ) { s = s.slice(1); } + if ( s === '' ) { return; } + const pos = s.indexOf('-'); + if ( pos !== 0 ) { + this.min = this.max = parseInt(s, 10) || 0; + } + if ( pos !== -1 ) { + this.max = parseInt(s.slice(pos + 1), 10) || Number.MAX_SAFE_INTEGER; + } + } + unbound() { + return this.min === undefined && this.max === undefined; + } + test(v) { + const n = Math.min(Math.max(Number(v) || 0, 0), Number.MAX_SAFE_INTEGER); + if ( this.min === this.max ) { + return (this.min === undefined || n === this.min) !== this.not; + } + if ( this.min === undefined ) { + return (n <= this.max) !== this.not; + } + if ( this.max === undefined ) { + return (n >= this.min) !== this.not; + } + return (n >= this.min && n <= this.max) !== this.not; + } +} +registerScriptlet(RangeParser, { + name: 'range-parser.fn', +}); + +/** + * @scriptlet prevent-setTimeout + * + * @description + * Conditionally prevent execution of the callback function passed to native + * setTimeout method. With no parameters, all calls to setTimeout will be + * shown in the logger. + * + * @param [needle] + * A pattern to match against the stringified callback. The pattern can be a + * plain string, or a regex. Prepend with `!` to reverse the match condition. + * + * @param [delay] + * A value to match against the delay. Can be a single value for exact match, + * or a range: + * - `min-max`: matches if delay >= min and delay <= max + * - `min-`: matches if delay >= min + * - `-max`: matches if delay <= max + * No delay means to match any delay value. + * Prepend with `!` to reverse the match condition. + * + * */ + +export function preventSetTimeout( + needleRaw = '', + delayRaw = '' +) { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('prevent-setTimeout', needleRaw, delayRaw); + const needleNot = needleRaw.charAt(0) === '!'; + const reNeedle = safe.patternToRegex(needleNot ? needleRaw.slice(1) : needleRaw); + const range = new RangeParser(delayRaw); + proxyApplyFn('setTimeout', function(context) { + const { callArgs } = context; + const a = callArgs[0] instanceof Function + ? safe.String(safe.Function_toString(callArgs[0])) + : safe.String(callArgs[0]); + const b = callArgs[1]; + if ( needleRaw === '' && range.unbound() ) { + safe.uboLog(logPrefix, `Called:\n${a}\n${b}`); + return context.reflect(); + } + if ( reNeedle.test(a) !== needleNot && range.test(b) ) { + callArgs[0] = function(){}; + safe.uboLog(logPrefix, `Prevented:\n${a}\n${b}`); + } + return context.reflect(); + }); +} +registerScriptlet(preventSetTimeout, { + name: 'prevent-setTimeout.js', + aliases: [ + 'no-setTimeout-if.js', + 'nostif.js', + 'setTimeout-defuser.js', + ], + dependencies: [ + proxyApplyFn, + RangeParser, + safeSelf, + ], +}); + +/** + * @scriptlet prevent-setInterval + * + * @description + * Conditionally prevent execution of the callback function passed to native + * setInterval method. With no parameters, all calls to setInterval will be + * shown in the logger. + * + * @param [needle] + * A pattern to match against the stringified callback. The pattern can be a + * plain string, or a regex. Prepend with `!` to reverse the match condition. + * No pattern means to match anything. + * + * @param [delay] + * A value to match against the delay. Can be a single value for exact match, + * or a range: + * - `min-max`: matches if delay >= min and delay <= max + * - `min-`: matches if delay >= min + * - `-max`: matches if delay <= max + * No delay means to match any delay value. + * Prepend with `!` to reverse the match condition. + * + * */ + +export function preventSetInterval( + needleRaw = '', + delayRaw = '' +) { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('prevent-setInterval', needleRaw, delayRaw); + const needleNot = needleRaw.charAt(0) === '!'; + const reNeedle = safe.patternToRegex(needleNot ? needleRaw.slice(1) : needleRaw); + const range = new RangeParser(delayRaw); + proxyApplyFn('setInterval', function(context) { + const { callArgs } = context; + const a = callArgs[0] instanceof Function + ? safe.String(safe.Function_toString(callArgs[0])) + : safe.String(callArgs[0]); + const b = callArgs[1]; + if ( needleRaw === '' && range.unbound() ) { + safe.uboLog(logPrefix, `Called:\n${a}\n${b}`); + return context.reflect(); + } + if ( reNeedle.test(a) !== needleNot && range.test(b) ) { + callArgs[0] = function(){}; + safe.uboLog(logPrefix, `Prevented:\n${a}\n${b}`); + } + return context.reflect(); + }); +} +registerScriptlet(preventSetInterval, { + name: 'prevent-setInterval.js', + aliases: [ + 'no-setInterval-if.js', + 'nosiif.js', + 'setInterval-defuser.js', + ], + dependencies: [ + proxyApplyFn, + RangeParser, + safeSelf, + ], +}); + +/** + * @scriptlet prevent-requestAnimationFrame + * + * @description + * Conditionally prevent execution of the callback function passed to native + * requestAnimationFrame method. With no parameters, all calls to + * requestAnimationFrame will be shown in the logger. + * + * @param [needle] + * A pattern to match against the stringified callback. The pattern can be a + * plain string, or a regex. + * Prepend with `!` to reverse the match condition. + * + * */ + +export function preventRequestAnimationFrame( + needleRaw = '' +) { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('prevent-requestAnimationFrame', needleRaw); + const needleNot = needleRaw.charAt(0) === '!'; + const reNeedle = safe.patternToRegex(needleNot ? needleRaw.slice(1) : needleRaw); + proxyApplyFn('requestAnimationFrame', function(context) { + const { callArgs } = context; + const a = callArgs[0] instanceof Function + ? safe.String(safe.Function_toString(callArgs[0])) + : safe.String(callArgs[0]); + if ( needleRaw === '' ) { + safe.uboLog(logPrefix, `Called:\n${a}`); + } else if ( reNeedle.test(a) !== needleNot ) { + callArgs[0] = function(){}; + safe.uboLog(logPrefix, `Prevented:\n${a}`); + } + return context.reflect(); + }); +} +registerScriptlet(preventRequestAnimationFrame, { + name: 'prevent-requestAnimationFrame.js', + aliases: [ + 'no-requestAnimationFrame-if.js', + 'norafif.js', + ], + dependencies: [ + proxyApplyFn, + safeSelf, + ], +}); diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-xhr.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-xhr.js new file mode 100644 index 00000000..47847087 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/prevent-xhr.js @@ -0,0 +1,329 @@ +/******************************************************************************* + + 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, + matchObjectPropertiesFn, + parsePropertiesToMatchFn, +} from './utils.js'; +import { + proxyApplyFn, + proxyToStringFn, +} from './proxy-apply.js'; +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 modifyXhrResponseFn( + propsToMatch = '', + modifierFn = '' +) { + if ( typeof propsToMatch !== 'string' ) { return; } + const safe = safeSelf(); + if ( modifyXhrResponseFn.xhrInstances === undefined ) { + modifyXhrResponseFn.xhrInstances = new WeakMap(); + } + const propNeedles = parsePropertiesToMatchFn(propsToMatch, 'url'); + const NativeXMLHttpRequest = self.XMLHttpRequest; + const TrappedXMLHttpRequest = class XMLHttpRequest extends NativeXMLHttpRequest { + open(method, url, ...args) { + const haystack = { method, url }; + if ( propsToMatch === '' ) { + safe.uboLog(`modifyXhrResponseFn() / Called: ${safe.JSON_stringify(haystack, null, 2)}`); + } else if ( matchObjectPropertiesFn(propNeedles, haystack) ) { + modifyXhrResponseFn.xhrInstances.set(this, modifierFn); + } + return super.open(method, url, ...args); + } + get response() { + const modifierFn = modifyXhrResponseFn.xhrInstances.get(this); + return modifierFn + ? modifierFn(this, super.response) + : super.response; + } + get responseText() { + const modifierFn = modifyXhrResponseFn.xhrInstances.get(this); + return modifierFn + ? modifierFn(this, super.responseText) + : super.responseText; + } + get responseXML() { + const modifierFn = modifyXhrResponseFn.xhrInstances.get(this); + return modifierFn + ? modifierFn(this, super.responseXML) + : super.responseXML; + } + }; + proxyToStringFn(TrappedXMLHttpRequest.prototype.open, NativeXMLHttpRequest.prototype.open); + proxyToStringFn(TrappedXMLHttpRequest, NativeXMLHttpRequest); + self.XMLHttpRequest = TrappedXMLHttpRequest; +} +registerScriptlet(modifyXhrResponseFn, { + name: 'modify-xhr-response.fn', + dependencies: [ + matchObjectPropertiesFn, + parsePropertiesToMatchFn, + proxyToStringFn, + safeSelf, + ], +}); + +/******************************************************************************/ + +function preventXhrFn( + trusted = false, + propsToMatch = '', + directive = '' +) { + if ( typeof propsToMatch !== 'string' ) { return; } + const safe = safeSelf(); + const scriptletName = trusted ? 'trusted-prevent-xhr' : 'prevent-xhr'; + const logPrefix = safe.makeLogPrefix(scriptletName, propsToMatch, directive); + const xhrInstances = new WeakMap(); + const propNeedles = parsePropertiesToMatchFn(propsToMatch, 'url'); + const warOrigin = scriptletGlobals.warOrigin; + const safeDispatchEvent = (xhr, type) => { + try { + xhr.dispatchEvent(new Event(type)); + } catch { + } + }; + proxyApplyFn('XMLHttpRequest.prototype.open', function(context) { + const { thisArg, callArgs } = context; + xhrInstances.delete(thisArg); + const [ method, url, ...args ] = callArgs; + if ( warOrigin !== undefined && url.startsWith(warOrigin) ) { + return context.reflect(); + } + const haystack = { method, url }; + if ( propsToMatch === '' && directive === '' ) { + safe.uboLog(logPrefix, `Called: ${safe.JSON_stringify(haystack, null, 2)}`); + return context.reflect(); + } + if ( matchObjectPropertiesFn(propNeedles, haystack) ) { + const xhrDetails = Object.assign(haystack, { + xhr: thisArg, + defer: args.length === 0 || !!args[0], + directive, + headers: { + 'date': '', + 'content-type': '', + 'content-length': '', + }, + url: haystack.url, + props: { + response: { value: '' }, + responseText: { value: '' }, + responseXML: { value: null }, + }, + }); + xhrInstances.set(thisArg, xhrDetails); + } + return context.reflect(); + }); + proxyApplyFn('XMLHttpRequest.prototype.send', function(context) { + const { thisArg } = context; + const xhrDetails = xhrInstances.get(thisArg); + if ( xhrDetails === undefined ) { + return context.reflect(); + } + xhrDetails.headers['date'] = (new Date()).toUTCString(); + let xhrText = ''; + switch ( thisArg.responseType ) { + case 'arraybuffer': + xhrDetails.props.response.value = new ArrayBuffer(0); + xhrDetails.headers['content-type'] = 'application/octet-stream'; + break; + case 'blob': + xhrDetails.props.response.value = new Blob([]); + xhrDetails.headers['content-type'] = 'application/octet-stream'; + break; + case 'document': { + const parser = new DOMParser(); + const doc = parser.parseFromString('', 'text/html'); + xhrDetails.props.response.value = doc; + xhrDetails.props.responseXML.value = doc; + xhrDetails.headers['content-type'] = 'text/html'; + break; + } + case 'json': + xhrDetails.props.response.value = {}; + xhrDetails.props.responseText.value = '{}'; + xhrDetails.headers['content-type'] = 'application/json'; + break; + default: { + if ( directive === '' ) { break; } + xhrText = generateContentFn(trusted, xhrDetails.directive); + if ( xhrText instanceof Promise ) { + xhrText = xhrText.then(text => { + xhrDetails.props.response.value = text; + xhrDetails.props.responseText.value = text; + }); + } else { + xhrDetails.props.response.value = xhrText; + xhrDetails.props.responseText.value = xhrText; + } + xhrDetails.headers['content-type'] = 'text/plain'; + break; + } + } + if ( xhrDetails.defer === false ) { + xhrDetails.headers['content-length'] = `${xhrDetails.props.response.value}`.length; + Object.defineProperties(xhrDetails.xhr, { + readyState: { value: 4 }, + responseURL: { value: xhrDetails.url }, + status: { value: 200 }, + statusText: { value: 'OK' }, + }); + Object.defineProperties(xhrDetails.xhr, xhrDetails.props); + return; + } + Promise.resolve(xhrText).then(( ) => xhrDetails).then(details => { + Object.defineProperties(details.xhr, { + readyState: { value: 1, configurable: true }, + responseURL: { value: xhrDetails.url }, + }); + safeDispatchEvent(details.xhr, 'readystatechange'); + return details; + }).then(details => { + xhrDetails.headers['content-length'] = `${details.props.response.value}`.length; + Object.defineProperties(details.xhr, { + readyState: { value: 2, configurable: true }, + status: { value: 200 }, + statusText: { value: 'OK' }, + }); + safeDispatchEvent(details.xhr, 'readystatechange'); + return details; + }).then(details => { + Object.defineProperties(details.xhr, { + readyState: { value: 3, configurable: true }, + }); + Object.defineProperties(details.xhr, details.props); + safeDispatchEvent(details.xhr, 'readystatechange'); + return details; + }).then(details => { + Object.defineProperties(details.xhr, { + readyState: { value: 4 }, + }); + safeDispatchEvent(details.xhr, 'readystatechange'); + safeDispatchEvent(details.xhr, 'load'); + safeDispatchEvent(details.xhr, 'loadend'); + safe.uboLog(logPrefix, `Prevented with response:\n${details.xhr.response}`); + }); + }); + proxyApplyFn('XMLHttpRequest.prototype.getResponseHeader', function(context) { + const { thisArg } = context; + const xhrDetails = xhrInstances.get(thisArg); + if ( xhrDetails === undefined || thisArg.readyState < thisArg.HEADERS_RECEIVED ) { + return context.reflect(); + } + const headerName = `${context.callArgs[0]}`; + const value = xhrDetails.headers[headerName.toLowerCase()]; + if ( value !== undefined && value !== '' ) { return value; } + return null; + }); + proxyApplyFn('XMLHttpRequest.prototype.getAllResponseHeaders', function(context) { + const { thisArg } = context; + const xhrDetails = xhrInstances.get(thisArg); + if ( xhrDetails === undefined || thisArg.readyState < thisArg.HEADERS_RECEIVED ) { + return context.reflect(); + } + const out = []; + for ( const [ name, value ] of Object.entries(xhrDetails.headers) ) { + if ( !value ) { continue; } + out.push(`${name}: ${value}`); + } + if ( out.length !== 0 ) { out.push(''); } + return out.join('\r\n'); + }); +} +registerScriptlet(preventXhrFn, { + name: 'prevent-xhr.fn', + dependencies: [ + generateContentFn, + matchObjectPropertiesFn, + parsePropertiesToMatchFn, + proxyApplyFn, + safeSelf, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet prevent-xhr + * + * @description + * Prevent a XMLHttpRequest-baesed request from being sent 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. The response + * must be a safe constant value. + * + * */ + +function preventXhr(...args) { + preventXhrFn(false, ...args); +} +registerScriptlet(preventXhr, { + name: 'prevent-xhr.js', + aliases: [ + 'no-xhr-if.js', + ], + dependencies: [ + preventXhrFn, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet trusted-prevent-xhr + * + * @description + * Prevent a XMLHttpRequest-based request from being sent 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. The trusted + * version allows arbitrary response. + * + * */ + +function trustedPreventXhr(...args) { + preventXhrFn(true, ...args); +} +registerScriptlet(trustedPreventXhr, { + name: 'trusted-prevent-xhr.js', + dependencies: [ + preventXhrFn, + ], +}); diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/proxy-apply.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/proxy-apply.js new file mode 100644 index 00000000..9f32a643 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/proxy-apply.js @@ -0,0 +1,169 @@ +/******************************************************************************* + + 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 { registerScriptlet } from './base.js'; + +/******************************************************************************/ + +export function proxyToStringFn(proxiedFn, nativeFn) { + if ( proxyToStringFn.proxies === undefined ) { + proxyToStringFn.proxies = new WeakMap(); + proxyToStringFn.nativeToString = Function.prototype.toString; + const proxiedToString = new Proxy(Function.prototype.toString, { + apply(target, thisArg) { + let proxied = thisArg; + for(;;) { + const fn = proxyToStringFn.proxies.get(proxied); + if ( fn === undefined ) { break; } + proxied = fn; + } + return proxyToStringFn.nativeToString.call(proxied); + } + }); + proxyToStringFn.proxies.set(proxiedToString, proxyToStringFn.nativeToString); + Function.prototype.toString = proxiedToString; + } + proxyToStringFn.proxies.set(proxiedFn, nativeFn); +} +registerScriptlet(proxyToStringFn, { + name: 'proxy-tostring.fn', +}); + +/******************************************************************************/ + +export function proxyApplyFn( + target = '', + handler = '', + options = {} +) { + let context = globalThis; + let prop = target; + for (;;) { + const pos = prop.indexOf('.'); + if ( pos === -1 ) { break; } + context = context[prop.slice(0, pos)]; + if ( context instanceof Object === false ) { return; } + prop = prop.slice(pos+1); + } + const fn = context[prop]; + if ( typeof fn !== 'function' ) { return; } + if ( proxyApplyFn.CtorContext === undefined ) { + proxyApplyFn.ctorContexts = []; + proxyApplyFn.CtorContext = class { + constructor(...args) { + this.init(...args); + } + init(callFn, callArgs) { + this.callFn = callFn; + this.callArgs = callArgs; + return this; + } + reflect() { + const r = Reflect.construct(this.callFn, this.callArgs); + this.callFn = this.callArgs = this.private = undefined; + proxyApplyFn.ctorContexts.push(this); + return r; + } + static factory(...args) { + return proxyApplyFn.ctorContexts.length !== 0 + ? proxyApplyFn.ctorContexts.pop().init(...args) + : new proxyApplyFn.CtorContext(...args); + } + }; + proxyApplyFn.applyContexts = []; + proxyApplyFn.ApplyContext = class { + constructor(...args) { + this.init(...args); + } + init(callFn, thisArg, callArgs) { + this.callFn = callFn; + this.thisArg = thisArg; + this.callArgs = callArgs; + return this; + } + reflect() { + const r = Reflect.apply(this.callFn, this.thisArg, this.callArgs); + this.callFn = this.thisArg = this.callArgs = this.private = undefined; + proxyApplyFn.applyContexts.push(this); + return r; + } + static factory(...args) { + return proxyApplyFn.applyContexts.length !== 0 + ? proxyApplyFn.applyContexts.pop().init(...args) + : new proxyApplyFn.ApplyContext(...args); + } + }; + proxyApplyFn.isCtor = new Map(); + proxyApplyFn.proxies = new WeakMap(); + if ( (options.skipToString || proxyApplyFn.skipToString) !== true ) { + proxyApplyFn.nativeToString = Function.prototype.toString; + const proxiedToString = new Proxy(Function.prototype.toString, { + apply(target, thisArg) { + let proxied = thisArg; + for(;;) { + const fn = proxyApplyFn.proxies.get(proxied); + if ( fn === undefined ) { break; } + proxied = fn; + } + return proxyApplyFn.nativeToString.call(proxied); + } + }); + proxyApplyFn.proxies.set(proxiedToString, proxyApplyFn.nativeToString); + Function.prototype.toString = proxiedToString; + } + } + if ( proxyApplyFn.isCtor.has(target) === false ) { + proxyApplyFn.isCtor.set(target, fn.prototype?.constructor === fn); + } + const proxyDetails = { + apply(target, thisArg, args) { + return handler(proxyApplyFn.ApplyContext.factory(target, thisArg, args)); + } + }; + if ( proxyApplyFn.isCtor.get(target) ) { + proxyDetails.construct = function(target, args) { + return handler(proxyApplyFn.CtorContext.factory(target, args)); + }; + } + const proxiedTarget = new Proxy(fn, proxyDetails); + proxyApplyFn.proxies.set(proxiedTarget, fn); + context[prop] = proxiedTarget; +} +registerScriptlet(proxyApplyFn, { + name: 'proxy-apply.fn', +}); + +/******************************************************************************/ + +export function proxyApplyConfig(config = '') { + try { + if ( typeof proxyApplyFn !== 'function' ) { return; } + config = JSON.parse(config); + if ( typeof config !== 'object' ) { return; } + Object.assign(proxyApplyFn, config); + } catch { + } +} +registerScriptlet(proxyApplyConfig , { + name: 'proxy-apply-config.js', + priority: 100, +}); diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/replace-argument.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/replace-argument.js new file mode 100644 index 00000000..0c98217a --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/replace-argument.js @@ -0,0 +1,133 @@ +/******************************************************************************* + + 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 { parseReplaceFn } from './parse-replace.js'; +import { proxyApplyFn } from './proxy-apply.js'; +import { registerScriptlet } from './base.js'; +import { safeSelf } from './safe-self.js'; +import { validateConstantFn } from './set-constant.js'; + +/** + * @scriptlet trusted-replace-argument.js + * + * @description + * Replace an argument passed to a method. Requires a trusted source. + * + * @param propChain + * The property chain to the function which argument must be replaced when + * called. + * + * @param argposRaw + * The zero-based position of the argument in the argument list. Use a negative + * number for a position relative to the last argument. Use literal `this` to + * replace the value used in `prototype`-based methods. + * + * @param argraw + * The replacement value, validated using the same heuristic as with the + * `set-constant.js` scriptlet. + * If the replacement value matches `json:...`, the value will be the + * json-parsed string after `json:`. + * If the replacement value matches `repl:/.../.../`, the target argument will + * be replaced according the regex-replacement directive following `repl:` + * If the replacement value matches `add:number`, number will be added to the + * target argument. + * + * @param [, condition, pattern] + * Optional. The replacement will occur only when pattern matches the target + * argument. + * + * */ + +export function trustedReplaceArgument( + propChain = '', + argposRaw = '', + argraw = '', + ...varargs +) { + if ( propChain === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('trusted-replace-argument', propChain, argposRaw, argraw); + const argoffset = parseInt(argposRaw, 10) || 0; + const extraArgs = safe.parseVarargs(varargs); + let replacer; + if ( argraw.startsWith('repl:/') ) { + const parsed = parseReplaceFn(argraw.slice(5)); + if ( parsed === undefined ) { return; } + replacer = arg => `${arg}`.replace(replacer.re, replacer.replacement); + Object.assign(replacer, parsed); + } else if ( argraw.startsWith('add:') ) { + const delta = parseFloat(argraw.slice(4)); + if ( isNaN(delta) ) { return; } + replacer = arg => Number(arg) + delta; + } else { + const value = validateConstantFn(true, argraw, extraArgs); + replacer = ( ) => value; + } + const reCondition = extraArgs.condition + ? safe.patternToRegex(`${extraArgs.condition}`) + : /^/; + const getArg = context => { + if ( argposRaw === 'this' ) { return context.thisArg; } + const { callArgs } = context; + const argpos = argoffset >= 0 ? argoffset : callArgs.length - argoffset; + if ( argpos < 0 || argpos >= callArgs.length ) { return; } + context.private = { argpos }; + return callArgs[argpos]; + }; + const setArg = (context, value) => { + if ( argposRaw === 'this' ) { + if ( value !== context.thisArg ) { + context.thisArg = value; + } + } else if ( context.private ) { + context.callArgs[context.private.argpos] = value; + } + }; + proxyApplyFn(propChain, function(context) { + if ( argposRaw === '' ) { + safe.uboLog(logPrefix, `Arguments:\n${context.callArgs.join('\n')}`); + return context.reflect(); + } + const argBefore = getArg(context); + if ( extraArgs.condition !== undefined ) { + if ( safe.RegExp_test(reCondition, argBefore) === false ) { + return context.reflect(); + } + } + const argAfter = replacer(argBefore); + if ( argAfter !== argBefore ) { + setArg(context, argAfter); + safe.uboLog(logPrefix, `Replaced argument:\nBefore: ${JSON.stringify(argBefore)}\nAfter: ${argAfter}`); + } + return context.reflect(); + }); +} +registerScriptlet(trustedReplaceArgument, { + name: 'trusted-replace-argument.js', + requiresTrust: true, + dependencies: [ + parseReplaceFn, + proxyApplyFn, + safeSelf, + validateConstantFn, + ], +}); diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/run-at.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/run-at.js new file mode 100644 index 00000000..35036d2b --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/run-at.js @@ -0,0 +1,96 @@ +/******************************************************************************* + + 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 { registerScriptlet } from './base.js'; +import { safeSelf } from './safe-self.js'; + +/* eslint no-prototype-builtins: 0 */ + +/** + * @helperScriptlet run-at.fn + * + * @description + * Execute a function at a specific page-load milestone. + * + * @param fn + * The function to call. + * + * @param when + * An identifier which tells when the function should be executed. + * See . + * + * @example + * `runAt(( ) => { start(); }, 'interactive')` + * + * */ + +export function runAt(fn, when) { + const intFromReadyState = state => { + const targets = { + 'loading': 1, 'asap': 1, + 'interactive': 2, 'end': 2, '2': 2, + 'complete': 3, 'idle': 3, '3': 3, + }; + const tokens = Array.isArray(state) ? state : [ state ]; + for ( const token of tokens ) { + const prop = `${token}`; + if ( Object.hasOwn(targets, prop) === false ) { continue; } + return targets[prop]; + } + return 0; + }; + const runAt = intFromReadyState(when); + if ( intFromReadyState(document.readyState) >= runAt ) { + fn(); return; + } + const onStateChange = ( ) => { + if ( intFromReadyState(document.readyState) < runAt ) { return; } + fn(); + safe.removeEventListener.apply(document, args); + }; + const safe = safeSelf(); + const args = [ 'readystatechange', onStateChange, { capture: true } ]; + safe.addEventListener.apply(document, args); +} +registerScriptlet(runAt, { + name: 'run-at.fn', + dependencies: [ + safeSelf, + ], +}); + +/******************************************************************************/ + +export function runAtHtmlElementFn(fn) { + if ( document.documentElement ) { + fn(); + return; + } + const observer = new MutationObserver(( ) => { + observer.disconnect(); + fn(); + }); + observer.observe(document, { childList: true }); +} +registerScriptlet(runAtHtmlElementFn, { + name: 'run-at-html-element.fn', +}); diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/safe-self.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/safe-self.js new file mode 100644 index 00000000..0e8dc3fd --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/safe-self.js @@ -0,0 +1,210 @@ +/******************************************************************************* + + 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 { registerScriptlet } from './base.js'; + +/******************************************************************************/ + +// Externally added to the private namespace in which scriptlets execute. +/* global scriptletGlobals */ + +export function safeSelf() { + if ( safeSelf.safe ) { + return safeSelf.safe; + } + const self = globalThis; + const safe = { + 'Array_from': Array.from, + 'Error': self.Error, + 'Function_toString': Function.prototype.call.bind(self.Function.prototype.toString), + 'Math_floor': Math.floor, + 'Math_max': Math.max, + 'Math_min': Math.min, + 'Math_random': Math.random, + 'Object': Object, + 'Object_defineProperty': Object.defineProperty.bind(Object), + 'Object_defineProperties': Object.defineProperties.bind(Object), + 'Object_fromEntries': Object.fromEntries.bind(Object), + 'Object_getOwnPropertyDescriptor': Object.getOwnPropertyDescriptor.bind(Object), + 'Object_hasOwn': Object.hasOwn.bind(Object), + 'Object_toString': Object.prototype.toString, + 'RegExp': self.RegExp, + 'RegExp_test': Function.prototype.call.bind(self.RegExp.prototype.test), + 'RegExp_exec': self.RegExp.prototype.exec, + 'Request_clone': self.Request.prototype.clone, + 'String': self.String, + 'String_fromCharCode': String.fromCharCode, + 'String_includes': String.prototype.includes, + 'String_split': String.prototype.split, + 'XMLHttpRequest': self.XMLHttpRequest, + 'XMLHttpRequest_open': Function.prototype.call.bind(self.XMLHttpRequest.prototype.open), + 'XMLHttpRequest_send': Function.prototype.call.bind(self.XMLHttpRequest.prototype.send), + 'addEventListener': self.EventTarget.prototype.addEventListener, + 'removeEventListener': self.EventTarget.prototype.removeEventListener, + 'fetch': self.fetch, + 'JSON': self.JSON, + 'JSON_parse': Function.prototype.call.bind(self.JSON.parse, self.JSON), + 'JSON_stringify': Function.prototype.call.bind(self.JSON.stringify, self.JSON), + 'structuredClone': Function.prototype.call.bind(self.structuredClone, self), + 'log': console.log.bind(console), + // Properties + logLevel: 0, + // Methods + makeLogPrefix(...args) { + return this.sendToLogger && `[${args.join(' \u205D ')}]` || ''; + }, + uboLog(...args) { + if ( this.sendToLogger === undefined ) { return; } + if ( args === undefined || args[0] === '' ) { return; } + return this.sendToLogger('info', ...args); + + }, + uboErr(...args) { + if ( this.sendToLogger === undefined ) { return; } + if ( args === undefined || args[0] === '' ) { return; } + return this.sendToLogger('error', ...args); + }, + escapeRegexChars(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + }, + initPattern(pattern, options = {}) { + if ( pattern === '' ) { + return { matchAll: true, expect: true }; + } + const expect = (options.canNegate !== true || pattern.startsWith('!') === false); + if ( expect === false ) { + pattern = pattern.slice(1); + } + const match = /^\/(.+)\/([gimsu]*)$/.exec(pattern); + if ( match !== null ) { + return { + re: new this.RegExp( + match[1], + match[2] || options.flags + ), + expect, + }; + } + if ( options.flags !== undefined ) { + return { + re: new this.RegExp(this.escapeRegexChars(pattern), + options.flags + ), + expect, + }; + } + return { pattern, expect }; + }, + testPattern(details, haystack) { + if ( details.matchAll ) { return true; } + if ( details.re ) { + return this.RegExp_test(details.re, haystack) === details.expect; + } + return this.String_includes.call(`${haystack}`, details.pattern) === details.expect; + }, + patternToRegex(pattern, flags = undefined, verbatim = false) { + if ( pattern === '' ) { return /^/; } + const match = /^\/(.+)\/([gimsu]*)$/.exec(pattern); + if ( match === null ) { + const reStr = this.escapeRegexChars(pattern); + return new RegExp(verbatim ? `^${reStr}$` : reStr, flags); + } + try { + return new RegExp(match[1], match[2] || undefined); + } + catch { + } + return /^/; + }, + parseVarargs(varargs) { + const entries = varargs.reduce((out, v, i, a) => { + if ( i & 1 ) { return out; } + const rawValue = a[i+1]; + const value = /^\d+$/.test(rawValue) + ? parseInt(rawValue, 10) + : rawValue; + out.push([ a[i], value ]); + return out; + }, []); + return this.Object_fromEntries(entries); + }, + }; + safeSelf.safe = safe; + if ( scriptletGlobals.bcSecret === undefined ) { return safe; } + // This is executed only when the logger is opened + safe.logLevel = scriptletGlobals.logLevel || 1; + let lastLogType = ''; + let lastLogText = ''; + let lastLogTime = 0; + safe.toLogText = (type, ...args) => { + if ( args.length === 0 ) { return; } + const text = `[${document.location.hostname || document.location.href}]${args.join(' ')}`; + if ( text === lastLogText && type === lastLogType ) { + if ( (Date.now() - lastLogTime) < 5000 ) { return; } + } + lastLogType = type; + lastLogText = text; + lastLogTime = Date.now(); + return text; + }; + try { + const bc = new self.BroadcastChannel(scriptletGlobals.bcSecret); + let bcBuffer = []; + safe.sendToLogger = (type, ...args) => { + const text = safe.toLogText(type, ...args); + if ( text === undefined ) { return; } + if ( bcBuffer === undefined ) { + return bc.postMessage({ what: 'messageToLogger', type, text }); + } + bcBuffer.push({ type, text }); + }; + bc.onmessage = ev => { + const msg = ev.data; + switch ( msg ) { + case 'iamready!': + if ( bcBuffer === undefined ) { break; } + bcBuffer.forEach(({ type, text }) => + bc.postMessage({ what: 'messageToLogger', type, text }) + ); + bcBuffer = undefined; + break; + case 'setScriptletLogLevelToOne': + safe.logLevel = 1; + break; + case 'setScriptletLogLevelToTwo': + safe.logLevel = 2; + break; + } + }; + bc.postMessage('areyouready?'); + } catch { + safe.sendToLogger = (type, ...args) => { + const text = safe.toLogText(type, ...args); + if ( text === undefined ) { return; } + safe.log(`uBO ${text}`); + }; + } + return safe; +} +registerScriptlet(safeSelf, { + name: 'safe-self.fn', +}); diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/scriptlets.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/scriptlets.js new file mode 100755 index 00000000..cbb415a1 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/scriptlets.js @@ -0,0 +1,2103 @@ +/******************************************************************************* + + 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 './abort-current-script.js'; +import './attribute.js'; +import './create-html.js'; +import './href-sanitizer.js'; +import './json-edit.js'; +import './json-prune.js'; +import './noeval.js'; +import './object-prune.js'; +import './prevent-addeventlistener.js'; +import './prevent-bab.js'; +import './prevent-clipboard-write.js'; +import './prevent-dialog.js'; +import './prevent-fetch.js'; +import './prevent-innerHTML.js'; +import './prevent-navigation.js'; +import './prevent-settimeout.js'; +import './replace-argument.js'; +import './spoof-css.js'; +import './vod.js'; + +import { + collateFetchArgumentsFn, + getExceptionTokenFn, + getRandomTokenFn, + lookupElementsFn, + matchObjectPropertiesFn, + onIdleFn, + parsePropertiesToMatchFn, +} from './utils.js'; +import { + runAt, + runAtHtmlElementFn, +} from './run-at.js'; + +import { getAllCookiesFn } from './cookie.js'; +import { getAllLocalStorageFn } from './localstorage.js'; +import { matchesStackTraceFn } from './stack-trace.js'; +import { modifyXhrResponseFn } from './prevent-xhr.js'; +import { proxyApplyFn } from './proxy-apply.js'; +import { registeredScriptlets } from './base.js'; +import { safeSelf } from './safe-self.js'; +import { validateConstantFn } from './set-constant.js'; + +/* eslint no-prototype-builtins: 0 */ + +export const builtinScriptlets = registeredScriptlets; + +/******************************************************************************* + + Helper functions + + These are meant to be used as dependencies to injectable scriptlets. + +/******************************************************************************/ + +builtinScriptlets.push({ + name: 'replace-node-text.fn', + fn: replaceNodeTextFn, + dependencies: [ + 'get-random-token.fn', + 'run-at.fn', + 'safe-self.fn', + ], +}); +function replaceNodeTextFn( + nodeName = '', + pattern = '', + replacement = '', + ...varargs +) { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('replace-node-text.fn', ...Array.from(arguments)); + const reNodeName = safe.patternToRegex(nodeName, 'i', true); + const rePattern = safe.patternToRegex(pattern, 'gms'); + const extraArgs = safe.parseVarargs(varargs); + const reIncludes = extraArgs.includes || extraArgs.condition + ? safe.patternToRegex(extraArgs.includes || extraArgs.condition, 'ms') + : null; + const reExcludes = extraArgs.excludes + ? safe.patternToRegex(extraArgs.excludes, 'ms') + : null; + const textContentFactory = (( ) => { + const out = { createScript: s => s }; + const { trustedTypes: tt } = self; + if ( tt instanceof Object ) { + if ( typeof tt.getPropertyType === 'function' ) { + if ( tt.getPropertyType('script', 'textContent') === 'TrustedScript' ) { + return tt.createPolicy(getRandomTokenFn(), out); + } + } + } + return out; + })(); + let sedCount = extraArgs.sedCount ?? Number.MAX_SAFE_INTEGER; + const handleNode = node => { + const before = node.textContent; + if ( reIncludes ) { + reIncludes.lastIndex = 0; + if ( safe.RegExp_test(reIncludes, before) === false ) { return; } + } + if ( reExcludes ) { + reExcludes.lastIndex = 0; + if ( safe.RegExp_test(reExcludes, before) ) { return; } + } + rePattern.lastIndex = 0; + if ( safe.RegExp_test(rePattern, before) === false ) { return; } + rePattern.lastIndex = 0; + const after = pattern !== '' + ? before.replace(rePattern, replacement) + : replacement; + node.textContent = node.nodeName === 'SCRIPT' + ? textContentFactory.createScript(after) + : after; + if ( safe.logLevel > 1 ) { + safe.uboLog(logPrefix, `Text before:\n${before.trim()}`); + } + safe.uboLog(logPrefix, `Text after:\n${after.trim()}`); + sedCount -= 1; + }; + const handleTree = root => { + const treeWalker = document.createTreeWalker(root, + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT + ); + const { currentScript } = document; + let count = 0; + for (;;) { + const node = treeWalker.nextNode(); + if ( node === null ) { break; } + count += 1; + if ( node === currentScript ) { continue; } + if ( reNodeName.test(node.nodeName) ) { + handleNode(node); + } else if ( node.nodeName === 'TEMPLATE' ) { + count += handleTree(node.content); + } else { + continue; + } + if ( sedCount === 0 ) { break; } + } + return count; + }; + if ( document.documentElement ) { + const count = handleTree(document.documentElement); + safe.uboLog(logPrefix, `${count} nodes present before installing mutation observer`); + } + const stay = Boolean(extraArgs.stay); + if ( sedCount === 0 && stay === false ) { return; } + const stop = (takeRecord = true) => { + const mutations = takeRecord ? observer.takeRecords() : []; + observer.disconnect(); + handleMutations(mutations); + if ( safe.logLevel > 1 ) { + safe.uboLog(logPrefix, 'Quitting'); + } + }; + const handleMutations = mutations => { + for ( const mutation of mutations ) { + for ( const node of mutation.addedNodes ) { + if ( reNodeName.test(node.nodeName) ) { + handleNode(node); + } else if ( node.nodeName === 'TEMPLATE' ) { + handleTree(node.content); + } else { + continue; + } + if ( sedCount === 0 ) { return stop(false); } + } + } + }; + const observer = new MutationObserver(handleMutations); + observer.observe(document, { childList: true, subtree: true }); + if ( stay ) { return; } + runAt(( ) => { + const quitAfter = extraArgs.quitAfter ?? 0; + if ( quitAfter === 0 ) { return stop(); } + setTimeout(( ) => { stop(); }, quitAfter); + }, 'interactive'); +} + +/******************************************************************************/ + +builtinScriptlets.push({ + name: 'replace-fetch-response.fn', + fn: replaceFetchResponseFn, + dependencies: [ + 'collate-fetch-arguments.fn', + 'match-object-properties.fn', + 'parse-properties-to-match.fn', + 'safe-self.fn', + ], +}); +function replaceFetchResponseFn( + trusted = false, + pattern = '', + replacement = '', + propsToMatch = '', + ...varargs +) { + if ( trusted !== true ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('replace-fetch-response', pattern, replacement, propsToMatch); + if ( pattern === '*' ) { pattern = '.*'; } + const rePattern = safe.patternToRegex(pattern); + const propNeedles = parsePropertiesToMatchFn(propsToMatch, 'url'); + const extraArgs = safe.parseVarargs(varargs); + const reIncludes = extraArgs.includes ? safe.patternToRegex(extraArgs.includes) : null; + self.fetch = new Proxy(self.fetch, { + apply: function(target, thisArg, args) { + const fetchPromise = Reflect.apply(target, thisArg, args); + if ( pattern === '' ) { return fetchPromise; } + if ( propNeedles.size !== 0 ) { + const props = collateFetchArgumentsFn(...args); + const matched = matchObjectPropertiesFn(propNeedles, props); + 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 ( reIncludes && reIncludes.test(textBefore) === false ) { + return responseBefore; + } + const textAfter = textBefore.replace(rePattern, replacement); + if ( textAfter === textBefore ) { return responseBefore; } + safe.uboLog(logPrefix, 'Replaced'); + 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, reason); + return responseBefore; + }); + }).catch(reason => { + safe.uboErr(logPrefix, reason); + return fetchPromise; + }); + } + }); +} + + + +/******************************************************************************* + + Injectable scriptlets + + These are meant to be used in the MAIN (webpage) execution world. + +/******************************************************************************/ + +builtinScriptlets.push({ + name: 'abort-on-property-read.js', + aliases: [ + 'aopr.js', + ], + fn: abortOnPropertyRead, + dependencies: [ + 'get-exception-token.fn', + 'safe-self.fn', + ], +}); +function abortOnPropertyRead( + chain = '' +) { + if ( typeof chain !== 'string' ) { return; } + if ( chain === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('abort-on-property-read', chain); + const exceptionToken = getExceptionTokenFn(); + const abort = function() { + safe.uboLog(logPrefix, 'Aborted'); + throw new ReferenceError(exceptionToken); + }; + const makeProxy = function(owner, chain) { + const pos = chain.indexOf('.'); + if ( pos === -1 ) { + const desc = Object.getOwnPropertyDescriptor(owner, chain); + if ( !desc || desc.get !== abort ) { + Object.defineProperty(owner, chain, { + get: abort, + set: function(){} + }); + } + return; + } + const prop = chain.slice(0, pos); + let v = owner[prop]; + chain = chain.slice(pos + 1); + if ( v ) { + makeProxy(v, chain); + return; + } + const desc = Object.getOwnPropertyDescriptor(owner, prop); + if ( desc && desc.set !== undefined ) { return; } + Object.defineProperty(owner, prop, { + get: function() { return v; }, + set: function(a) { + v = a; + if ( a instanceof Object ) { + makeProxy(a, chain); + } + } + }); + }; + const owner = window; + makeProxy(owner, chain); +} + +/******************************************************************************/ + +builtinScriptlets.push({ + name: 'abort-on-property-write.js', + aliases: [ + 'aopw.js', + ], + fn: abortOnPropertyWrite, + dependencies: [ + 'get-exception-token.fn', + 'safe-self.fn', + ], +}); +function abortOnPropertyWrite( + prop = '' +) { + if ( typeof prop !== 'string' ) { return; } + if ( prop === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('abort-on-property-write', prop); + const exceptionToken = getExceptionTokenFn(); + let owner = window; + for (;;) { + const pos = prop.indexOf('.'); + if ( pos === -1 ) { break; } + owner = owner[prop.slice(0, pos)]; + if ( owner instanceof Object === false ) { return; } + prop = prop.slice(pos + 1); + } + delete owner[prop]; + Object.defineProperty(owner, prop, { + set: function() { + safe.uboLog(logPrefix, 'Aborted'); + throw new ReferenceError(exceptionToken); + } + }); +} + +/******************************************************************************/ + +builtinScriptlets.push({ + name: 'adjust-setInterval.js', + aliases: [ + 'nano-setInterval-booster.js', + 'nano-sib.js', + ], + fn: adjustSetInterval, + dependencies: [ + 'safe-self.fn', + ], +}); +// Imported from: +// https://github.com/NanoAdblocker/NanoFilters/blob/1f3be7211bb0809c5106996f52564bf10c4525f7/NanoFiltersSource/NanoResources.txt#L126 +// +// Speed up or down setInterval, 3 optional arguments. +// The payload matcher, a string literal or a JavaScript RegExp, defaults +// to match all. +// delayMatcher +// The delay matcher, an integer, defaults to 1000. +// Use `*` to match any delay. +// boostRatio - The delay multiplier when there is a match, 0.5 speeds up by +// 2 times and 2 slows down by 2 times, defaults to 0.05 or speed up +// 20 times. Speed up and down both cap at 50 times. +function adjustSetInterval( + needleArg = '', + delayArg = '', + boostArg = '' +) { + if ( typeof needleArg !== 'string' ) { return; } + const safe = safeSelf(); + const reNeedle = safe.patternToRegex(needleArg); + let delay = delayArg !== '*' ? parseInt(delayArg, 10) : -1; + if ( isNaN(delay) || isFinite(delay) === false ) { delay = 1000; } + let boost = parseFloat(boostArg); + boost = isNaN(boost) === false && isFinite(boost) + ? Math.min(Math.max(boost, 0.001), 50) + : 0.05; + self.setInterval = new Proxy(self.setInterval, { + apply: function(target, thisArg, args) { + const [ a, b ] = args; + if ( + (delay === -1 || b === delay) && + reNeedle.test(a.toString()) + ) { + args[1] = b * boost; + } + return target.apply(thisArg, args); + } + }); +} + +/******************************************************************************/ + +builtinScriptlets.push({ + name: 'adjust-setTimeout.js', + aliases: [ + 'nano-setTimeout-booster.js', + 'nano-stb.js', + ], + fn: adjustSetTimeout, + dependencies: [ + 'safe-self.fn', + ], +}); +// Imported from: +// https://github.com/NanoAdblocker/NanoFilters/blob/1f3be7211bb0809c5106996f52564bf10c4525f7/NanoFiltersSource/NanoResources.txt#L82 +// +// Speed up or down setTimeout, 3 optional arguments. +// funcMatcher +// The payload matcher, a string literal or a JavaScript RegExp, defaults +// to match all. +// delayMatcher +// The delay matcher, an integer, defaults to 1000. +// Use `*` to match any delay. +// boostRatio - The delay multiplier when there is a match, 0.5 speeds up by +// 2 times and 2 slows down by 2 times, defaults to 0.05 or speed up +// 20 times. Speed up and down both cap at 50 times. +function adjustSetTimeout( + needleArg = '', + delayArg = '', + boostArg = '' +) { + if ( typeof needleArg !== 'string' ) { return; } + const safe = safeSelf(); + const reNeedle = safe.patternToRegex(needleArg); + let delay = delayArg !== '*' ? parseInt(delayArg, 10) : -1; + if ( isNaN(delay) || isFinite(delay) === false ) { delay = 1000; } + let boost = parseFloat(boostArg); + boost = isNaN(boost) === false && isFinite(boost) + ? Math.min(Math.max(boost, 0.001), 50) + : 0.05; + self.setTimeout = new Proxy(self.setTimeout, { + apply: function(target, thisArg, args) { + const [ a, b ] = args; + if ( + (delay === -1 || b === delay) && + reNeedle.test(a.toString()) + ) { + args[1] = b * boost; + } + return target.apply(thisArg, args); + } + }); +} + +/******************************************************************************/ + +builtinScriptlets.push({ + name: 'prevent-refresh.js', + aliases: [ + 'refresh-defuser.js', + ], + fn: preventRefresh, + world: 'ISOLATED', + dependencies: [ + 'safe-self.fn', + ], +}); +// https://www.reddit.com/r/uBlockOrigin/comments/q0frv0/while_reading_a_sports_article_i_was_redirected/hf7wo9v/ +function preventRefresh( + delay = '' +) { + if ( typeof delay !== 'string' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('prevent-refresh', delay); + const stop = content => { + window.stop(); + safe.uboLog(logPrefix, `Prevented "${content}"`); + }; + const defuse = ( ) => { + const meta = document.querySelector('meta[http-equiv="refresh" i][content]'); + if ( meta === null ) { return; } + const content = meta.getAttribute('content') || ''; + const ms = delay === '' + ? Math.max(parseFloat(content) || 0, 0) * 500 + : 0; + if ( ms === 0 ) { + stop(content); + } else { + setTimeout(( ) => { stop(content); }, ms); + } + }; + self.addEventListener('load', defuse, { capture: true, once: true }); +} + +/******************************************************************************/ + +builtinScriptlets.push({ + name: 'remove-class.js', + aliases: [ + 'rc.js', + ], + fn: removeClass, + world: 'ISOLATED', + dependencies: [ + 'on-idle.fn', + 'run-at.fn', + 'safe-self.fn', + ], +}); +function removeClass( + rawToken = '', + rawSelector = '', + behavior = '' +) { + if ( typeof rawToken !== 'string' ) { return; } + if ( rawToken === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('remove-class', rawToken, rawSelector, behavior); + const tokens = safe.String_split.call(rawToken, /\s*\|\s*/); + const selector = tokens + .map(a => `${rawSelector}.${CSS.escape(a)}`) + .join(','); + if ( safe.logLevel > 1 ) { + safe.uboLog(logPrefix, `Target selector:\n\t${selector}`); + } + const mustStay = /\bstay\b/.test(behavior); + let timer; + const rmclass = ( ) => { + timer = undefined; + try { + const nodes = document.querySelectorAll(selector); + for ( const node of nodes ) { + node.classList.remove(...tokens); + safe.uboLog(logPrefix, 'Removed class(es)'); + } + } catch { + } + if ( mustStay ) { return; } + if ( document.readyState !== 'complete' ) { return; } + observer.disconnect(); + }; + const mutationHandler = mutations => { + if ( timer !== undefined ) { return; } + let skip = true; + for ( let i = 0; i < mutations.length && skip; i++ ) { + const { type, addedNodes, removedNodes } = mutations[i]; + if ( type === 'attributes' ) { skip = false; } + for ( let j = 0; j < addedNodes.length && skip; j++ ) { + if ( addedNodes[j].nodeType === 1 ) { skip = false; break; } + } + for ( let j = 0; j < removedNodes.length && skip; j++ ) { + if ( removedNodes[j].nodeType === 1 ) { skip = false; break; } + } + } + if ( skip ) { return; } + timer = onIdleFn(rmclass, { timeout: 67 }); + }; + const observer = new MutationObserver(mutationHandler); + const start = ( ) => { + rmclass(); + observer.observe(document, { + attributes: true, + attributeFilter: [ 'class' ], + childList: true, + subtree: true, + }); + }; + runAt(( ) => { + start(); + }, /\bcomplete\b/.test(behavior) ? 'idle' : 'loading'); +} + +/******************************************************************************/ + +builtinScriptlets.push({ + name: 'webrtc-if.js', + fn: webrtcIf, + dependencies: [ + 'safe-self.fn', + ], +}); +function webrtcIf( + good = '' +) { + if ( typeof good !== 'string' ) { return; } + const safe = safeSelf(); + const reGood = safe.patternToRegex(good); + const rtcName = window.RTCPeerConnection + ? 'RTCPeerConnection' + : (window.webkitRTCPeerConnection ? 'webkitRTCPeerConnection' : ''); + if ( rtcName === '' ) { return; } + const log = console.log.bind(console); + const neuteredPeerConnections = new WeakSet(); + const isGoodConfig = function(instance, config) { + if ( neuteredPeerConnections.has(instance) ) { return false; } + if ( config instanceof Object === false ) { return true; } + if ( Array.isArray(config.iceServers) === false ) { return true; } + for ( const server of config.iceServers ) { + const urls = typeof server.urls === 'string' + ? [ server.urls ] + : server.urls; + if ( Array.isArray(urls) ) { + for ( const url of urls ) { + if ( reGood.test(url) ) { return true; } + } + } + if ( typeof server.username === 'string' ) { + if ( reGood.test(server.username) ) { return true; } + } + if ( typeof server.credential === 'string' ) { + if ( reGood.test(server.credential) ) { return true; } + } + } + neuteredPeerConnections.add(instance); + return false; + }; + const peerConnectionCtor = window[rtcName]; + const peerConnectionProto = peerConnectionCtor.prototype; + peerConnectionProto.createDataChannel = + new Proxy(peerConnectionProto.createDataChannel, { + apply: function(target, thisArg, args) { + if ( isGoodConfig(target, args[1]) === false ) { + log('uBO:', args[1]); + return Reflect.apply(target, thisArg, args.slice(0, 1)); + } + return Reflect.apply(target, thisArg, args); + }, + }); + window[rtcName] = + new Proxy(peerConnectionCtor, { + construct: function(target, args) { + if ( isGoodConfig(target, args[0]) === false ) { + log('uBO:', args[0]); + return Reflect.construct(target); + } + return Reflect.construct(target, args); + } + }); +} + +/** + * @scriptlet prevent-window-open + * + * @description + * Prevent a webpage from opening new tabs through `window.open()`. + * + * @param pattern + * A plain string or regex to match against the `url` argument for the + * prevention to be triggered. If not provided, all calls to `window.open()` + * are prevented. + * If set to the special value `debug` *and* the logger is opened, the scriptlet + * will trigger a `debugger` statement and the prevention will not occur. + * + * @param [delay] + * If provided, a decoy will be created or opened, and this parameter states + * the number of seconds to wait for before the decoy is terminated, i.e. + * either removed from the DOM or closed. + * + * @param [decoy] + * A string representing the type of decoy to use: + * - `blank`: replace the `url` parameter with `about:blank` + * - `object`: create and append an `object` element to the DOM, and return + * its `contentWindow` property. + * - `frame`: create and append an `iframe` element to the DOM, and return + * its `contentWindow` property. + * + * @example + * ##+js(prevent-window-open, ads.example.com/) + * + * @example + * ##+js(prevent-window-open, ads.example.com/, 1, iframe) + * + * */ + +builtinScriptlets.push({ + name: 'prevent-window-open.js', + aliases: [ + 'nowoif.js', + 'no-window-open-if.js', + 'window.open-defuser.js', + ], + fn: noWindowOpenIf, + dependencies: [ + 'proxy-apply.fn', + 'safe-self.fn', + ], +}); +function noWindowOpenIf( + pattern = '', + delay = '', + decoy = '' +) { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('no-window-open-if', pattern, delay, decoy); + const targetMatchResult = pattern.startsWith('!') === false; + if ( targetMatchResult === false ) { + pattern = pattern.slice(1); + } + const rePattern = safe.patternToRegex(pattern); + const autoRemoveAfter = (parseFloat(delay) || 0) * 1000; + const setTimeout = self.setTimeout; + const createDecoy = function(tag, urlProp, url) { + const decoyElem = document.createElement(tag); + decoyElem[urlProp] = url; + decoyElem.style.setProperty('height','1px', 'important'); + decoyElem.style.setProperty('position','fixed', 'important'); + decoyElem.style.setProperty('top','-1px', 'important'); + decoyElem.style.setProperty('width','1px', 'important'); + document.body.appendChild(decoyElem); + setTimeout(( ) => { decoyElem.remove(); }, autoRemoveAfter); + return decoyElem; + }; + const noopFunc = function(){}; + proxyApplyFn('open', function open(context) { + if ( pattern === 'debug' && safe.logLevel !== 0 ) { + debugger; // eslint-disable-line no-debugger + return context.reflect(); + } + const { callArgs } = context; + const haystack = callArgs.join(' '); + if ( rePattern.test(haystack) !== targetMatchResult ) { + if ( safe.logLevel > 1 ) { + safe.uboLog(logPrefix, `Allowed (${callArgs.join(', ')})`); + } + return context.reflect(); + } + safe.uboLog(logPrefix, `Prevented (${callArgs.join(', ')})`); + if ( delay === '' ) { return null; } + if ( decoy === 'blank' ) { + callArgs[0] = 'about:blank'; + const r = context.reflect(); + setTimeout(( ) => { r.close(); }, autoRemoveAfter); + return r; + } + const decoyElem = decoy === 'obj' + ? createDecoy('object', 'data', ...callArgs) + : createDecoy('iframe', 'src', ...callArgs); + let popup = decoyElem.contentWindow; + if ( typeof popup === 'object' && popup !== null ) { + Object.defineProperty(popup, 'closed', { value: false }); + } else { + popup = new Proxy(self, { + get: function(target, prop, ...args) { + if ( prop === 'closed' ) { return false; } + const r = Reflect.get(target, prop, ...args); + if ( typeof r === 'function' ) { return noopFunc; } + return r; + }, + set: function(...args) { + return Reflect.set(...args); + }, + }); + } + if ( safe.logLevel !== 0 ) { + popup = new Proxy(popup, { + get: function(target, prop, ...args) { + const r = Reflect.get(target, prop, ...args); + safe.uboLog(logPrefix, `popup / get ${prop} === ${r}`); + if ( typeof r === 'function' ) { + return (...args) => { return r.call(target, ...args); }; + } + return r; + }, + set: function(target, prop, value, ...args) { + safe.uboLog(logPrefix, `popup / set ${prop} = ${value}`); + return Reflect.set(target, prop, value, ...args); + }, + }); + } + return popup; + }); +} + +/******************************************************************************/ + +builtinScriptlets.push({ + name: 'close-window.js', + aliases: [ + 'window-close-if.js', + ], + fn: closeWindow, + world: 'ISOLATED', + dependencies: [ + 'safe-self.fn', + ], +}); +// https://github.com/uBlockOrigin/uAssets/issues/10323#issuecomment-992312847 +// https://github.com/AdguardTeam/Scriptlets/issues/158 +// https://github.com/uBlockOrigin/uBlock-issues/discussions/2270 +function closeWindow( + arg1 = '' +) { + if ( typeof arg1 !== 'string' ) { return; } + const safe = safeSelf(); + let subject = ''; + if ( /^\/.*\/$/.test(arg1) ) { + subject = window.location.href; + } else if ( arg1 !== '' ) { + subject = `${window.location.pathname}${window.location.search}`; + } + try { + const re = safe.patternToRegex(arg1); + if ( re.test(subject) ) { + window.close(); + } + } catch(ex) { + console.log(ex); + } +} + +/******************************************************************************/ + +builtinScriptlets.push({ + name: 'window.name-defuser.js', + fn: windowNameDefuser, +}); +// https://github.com/gorhill/uBlock/issues/1228 +function windowNameDefuser() { + if ( window === window.top ) { + window.name = ''; + } +} + +/******************************************************************************/ + +builtinScriptlets.push({ + name: 'overlay-buster.js', + fn: overlayBuster, +}); +// Experimental: Generic nuisance overlay buster. +// if this works well and proves to be useful, this may end up +// as a stock tool in uBO's popup panel. +function overlayBuster(allFrames) { + if ( allFrames === '' && window !== window.top ) { return; } + var tstart; + var ttl = 30000; + var delay = 0; + var delayStep = 50; + var buster = function() { + var docEl = document.documentElement, + bodyEl = document.body, + vw = Math.min(docEl.clientWidth, window.innerWidth), + vh = Math.min(docEl.clientHeight, window.innerHeight), + tol = Math.min(vw, vh) * 0.05, + el = document.elementFromPoint(vw/2, vh/2), + style, rect; + for (;;) { + if ( el === null || el.parentNode === null || el === bodyEl ) { + break; + } + style = window.getComputedStyle(el); + if ( parseInt(style.zIndex, 10) >= 1000 || style.position === 'fixed' ) { + rect = el.getBoundingClientRect(); + if ( rect.left <= tol && rect.top <= tol && (vw - rect.right) <= tol && (vh - rect.bottom) < tol ) { + el.parentNode.removeChild(el); + tstart = Date.now(); + el = document.elementFromPoint(vw/2, vh/2); + bodyEl.style.setProperty('overflow', 'auto', 'important'); + docEl.style.setProperty('overflow', 'auto', 'important'); + continue; + } + } + el = el.parentNode; + } + if ( (Date.now() - tstart) < ttl ) { + delay = Math.min(delay + delayStep, 1000); + setTimeout(buster, delay); + } + }; + var domReady = function(ev) { + if ( ev ) { + document.removeEventListener(ev.type, domReady); + } + tstart = Date.now(); + setTimeout(buster, delay); + }; + if ( document.readyState === 'loading' ) { + document.addEventListener('DOMContentLoaded', domReady); + } else { + domReady(); + } +} + +/******************************************************************************/ + +builtinScriptlets.push({ + name: 'alert-buster.js', + fn: alertBuster, +}); +// https://github.com/uBlockOrigin/uAssets/issues/8 +function alertBuster() { + window.alert = new Proxy(window.alert, { + apply: function(a) { + console.info(a); + }, + get(target, prop) { + if ( prop === 'toString' ) { + return target.toString.bind(target); + } + return Reflect.get(target, prop); + }, + }); +} + +/******************************************************************************/ + +builtinScriptlets.push({ + name: 'nowebrtc.js', + fn: noWebrtc, +}); +// Prevent web pages from using RTCPeerConnection(), and report attempts in console. +function noWebrtc() { + var rtcName = window.RTCPeerConnection ? 'RTCPeerConnection' : ( + window.webkitRTCPeerConnection ? 'webkitRTCPeerConnection' : '' + ); + if ( rtcName === '' ) { return; } + var log = console.log.bind(console); + var pc = function(cfg) { + log('Document tried to create an RTCPeerConnection: %o', cfg); + }; + const noop = function() { + }; + pc.prototype = { + close: noop, + createDataChannel: noop, + createOffer: noop, + setRemoteDescription: noop, + toString: function() { + return '[object RTCPeerConnection]'; + } + }; + var z = window[rtcName]; + window[rtcName] = pc.bind(window); + if ( z.prototype ) { + z.prototype.createDataChannel = function() { + return { + close: function() {}, + send: function() {} + }; + }.bind(null); + } +} + +/******************************************************************************/ + +builtinScriptlets.push({ + name: 'disable-newtab-links.js', + fn: disableNewtabLinks, +}); +// https://github.com/uBlockOrigin/uAssets/issues/913 +function disableNewtabLinks() { + document.addEventListener('click', ev => { + let target = ev.target; + while ( target !== null ) { + if ( target.localName === 'a' && target.hasAttribute('target') ) { + ev.stopPropagation(); + ev.preventDefault(); + break; + } + target = target.parentNode; + } + }, { capture: true }); +} + +/******************************************************************************/ + +builtinScriptlets.push({ + name: 'xml-prune.js', + fn: xmlPrune, + dependencies: [ + 'modify-xhr-response.fn', + 'safe-self.fn', + ], +}); +function xmlPrune( + selector = '', + selectorCheck = '', + urlPattern = '', + ...varargs +) { + if ( typeof selector !== 'string' ) { return; } + if ( selector === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('xml-prune', selector, selectorCheck, urlPattern); + const reUrl = safe.patternToRegex(urlPattern); + const extraArgs = safe.parseVarargs(varargs); + const queryAll = (xmlDoc, selector) => { + const isXpath = /^xpath\(.+\)$/.test(selector); + if ( isXpath === false ) { + return Array.from(xmlDoc.querySelectorAll(selector)); + } + const xpr = xmlDoc.evaluate( + selector.slice(6, -1), + xmlDoc, + null, + XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, + null + ); + const out = []; + for ( let i = 0; i < xpr.snapshotLength; i++ ) { + const node = xpr.snapshotItem(i); + out.push(node); + } + return out; + }; + const pruneFromDoc = xmlDoc => { + try { + if ( selectorCheck !== '' && xmlDoc.querySelector(selectorCheck) === null ) { + return xmlDoc; + } + if ( extraArgs.logdoc ) { + const serializer = new XMLSerializer(); + safe.uboLog(logPrefix, `Document is\n\t${serializer.serializeToString(xmlDoc)}`); + } + const items = queryAll(xmlDoc, selector); + if ( items.length === 0 ) { return xmlDoc; } + safe.uboLog(logPrefix, `Removing ${items.length} items`); + for ( const item of items ) { + if ( item.nodeType === 1 ) { + item.remove(); + } else if ( item.nodeType === 2 ) { + item.ownerElement.removeAttribute(item.nodeName); + } + safe.uboLog(logPrefix, `${item.constructor.name}.${item.nodeName} removed`); + } + } catch(ex) { + safe.uboErr(logPrefix, `Error: ${ex}`); + } + return xmlDoc; + }; + const pruneFromText = text => { + if ( (/^\s*\s*$/.test(text)) === false ) { + return text; + } + try { + const xmlParser = new DOMParser(); + const xmlDoc = xmlParser.parseFromString(text, 'text/xml'); + pruneFromDoc(xmlDoc); + const serializer = new XMLSerializer(); + text = serializer.serializeToString(xmlDoc); + } catch { + } + return text; + }; + const urlFromArg = arg => { + if ( typeof arg === 'string' ) { return arg; } + if ( arg instanceof Request ) { return arg.url; } + return String(arg); + }; + self.fetch = new Proxy(self.fetch, { + apply: function(target, thisArg, args) { + const fetchPromise = Reflect.apply(target, thisArg, args); + if ( reUrl.test(urlFromArg(args[0])) === false ) { + return fetchPromise; + } + return fetchPromise.then(responseBefore => { + const response = responseBefore.clone(); + return response.text().then(text => { + const responseAfter = new Response(pruneFromText(text), { + 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(( ) => + responseBefore + ); + }); + } + }); + modifyXhrResponseFn(urlPattern, (xhr, before) => { + if ( before instanceof XMLDocument ) { + return pruneFromDoc(before); + } + if ( typeof before === 'string' ) { + return pruneFromText(before); + } + return before; + }); +} + +/******************************************************************************/ + +builtinScriptlets.push({ + name: 'm3u-prune.js', + fn: m3uPrune, + dependencies: [ + 'proxy-apply.fn', + 'safe-self.fn', + ], +}); +// https://en.wikipedia.org/wiki/M3U +function m3uPrune( + m3uPattern = '', + urlPattern = '' +) { + if ( typeof m3uPattern !== 'string' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('m3u-prune', m3uPattern, urlPattern); + const toLog = []; + const regexFromArg = arg => { + if ( arg === '' ) { return /^/; } + const match = /^\/(.+)\/([gms]*)$/.exec(arg); + if ( match !== null ) { + let flags = match[2] || ''; + if ( flags.includes('m') ) { flags += 's'; } + return new RegExp(match[1], flags); + } + return new RegExp( + arg.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*+/g, '.*?') + ); + }; + const reM3u = regexFromArg(m3uPattern); + const reUrl = regexFromArg(urlPattern); + const pruneSpliceoutBlock = (lines, i) => { + if ( lines[i].startsWith('#EXT-X-CUE:TYPE="SpliceOut"') === false ) { + return false; + } + toLog.push(`\t${lines[i]}`); + lines[i] = undefined; i += 1; + if ( lines[i].startsWith('#EXT-X-ASSET:CAID') ) { + toLog.push(`\t${lines[i]}`); + lines[i] = undefined; i += 1; + } + if ( lines[i].startsWith('#EXT-X-SCTE35:') ) { + toLog.push(`\t${lines[i]}`); + lines[i] = undefined; i += 1; + } + if ( lines[i].startsWith('#EXT-X-CUE-IN') ) { + toLog.push(`\t${lines[i]}`); + lines[i] = undefined; i += 1; + } + if ( lines[i].startsWith('#EXT-X-SCTE35:') ) { + toLog.push(`\t${lines[i]}`); + lines[i] = undefined; i += 1; + } + return true; + }; + const pruneInfBlock = (lines, i) => { + if ( lines[i].startsWith('#EXTINF') === false ) { return false; } + if ( reM3u.test(lines[i+1]) === false ) { return false; } + toLog.push('Discarding', `\t${lines[i]}, \t${lines[i+1]}`); + lines[i] = lines[i+1] = undefined; i += 2; + if ( lines[i].startsWith('#EXT-X-DISCONTINUITY') ) { + toLog.push(`\t${lines[i]}`); + lines[i] = undefined; i += 1; + } + return true; + }; + const pruner = text => { + if ( (/^\s*#EXTM3U/.test(text)) === false ) { return text; } + if ( m3uPattern === '' ) { + safe.uboLog(` Content:\n${text}`); + return text; + } + if ( reM3u.multiline ) { + reM3u.lastIndex = 0; + for (;;) { + const match = reM3u.exec(text); + if ( match === null ) { break; } + let discard = match[0]; + let before = text.slice(0, match.index); + if ( + /^[\n\r]+/.test(discard) === false && + /[\n\r]+$/.test(before) === false + ) { + const startOfLine = /[^\n\r]+$/.exec(before); + if ( startOfLine !== null ) { + before = before.slice(0, startOfLine.index); + discard = startOfLine[0] + discard; + } + } + let after = text.slice(match.index + match[0].length); + if ( + /[\n\r]+$/.test(discard) === false && + /^[\n\r]+/.test(after) === false + ) { + const endOfLine = /^[^\n\r]+/.exec(after); + if ( endOfLine !== null ) { + after = after.slice(endOfLine.index); + discard += discard + endOfLine[0]; + } + } + text = before.trim() + '\n' + after.trim(); + reM3u.lastIndex = before.length + 1; + toLog.push('Discarding', ...safe.String_split.call(discard, /\n+/).map(s => `\t${s}`)); + if ( reM3u.global === false ) { break; } + } + return text; + } + const lines = safe.String_split.call(text, /\n\r|\n|\r/); + for ( let i = 0; i < lines.length; i++ ) { + if ( lines[i] === undefined ) { continue; } + if ( pruneSpliceoutBlock(lines, i) ) { continue; } + if ( pruneInfBlock(lines, i) ) { continue; } + } + return lines.filter(l => l !== undefined).join('\n'); + }; + const urlFromArg = arg => { + if ( typeof arg === 'string' ) { return arg; } + if ( arg instanceof Request ) { return arg.url; } + return String(arg); + }; + proxyApplyFn('fetch', async function fetch(context) { + const args = context.callArgs; + const fetchPromise = context.reflect(); + if ( reUrl.test(urlFromArg(args[0])) === false ) { return fetchPromise; } + const responseBefore = await fetchPromise; + const responseClone = responseBefore.clone(); + const textBefore = await responseClone.text(); + const textAfter = pruner(textBefore); + if ( textAfter === textBefore ) { return responseBefore; } + const responseAfter = new Response(textAfter, { + status: responseBefore.status, + statusText: responseBefore.statusText, + headers: responseBefore.headers, + }); + Object.defineProperties(responseAfter, { + url: { value: responseBefore.url }, + type: { value: responseBefore.type }, + }); + if ( toLog.length !== 0 ) { + toLog.unshift(logPrefix); + safe.uboLog(toLog.join('\n')); + } + return responseAfter; + }) + self.XMLHttpRequest.prototype.open = new Proxy(self.XMLHttpRequest.prototype.open, { + apply: async (target, thisArg, args) => { + if ( reUrl.test(urlFromArg(args[1])) === false ) { + return Reflect.apply(target, thisArg, args); + } + thisArg.addEventListener('readystatechange', function() { + if ( thisArg.readyState !== 4 ) { return; } + const type = thisArg.responseType; + if ( type !== '' && type !== 'text' ) { return; } + const textin = thisArg.responseText; + const textout = pruner(textin); + if ( textout === textin ) { return; } + Object.defineProperty(thisArg, 'response', { value: textout }); + Object.defineProperty(thisArg, 'responseText', { value: textout }); + if ( toLog.length !== 0 ) { + toLog.unshift(logPrefix); + safe.uboLog(toLog.join('\n')); + } + }); + return Reflect.apply(target, thisArg, args); + } + }); +} + +/******************************************************************************* + * + * @scriptlet call-nothrow + * + * @description + * Prevent a function call from throwing. The function will be called, however + * should it throw, the scriptlet will silently process the exception and + * returns as if no exception has occurred. + * + * ### Syntax + * + * ```text + * example.org##+js(call-nothrow, propertyChain) + * ``` + * + * - `propertyChain`: a chain of dot-separated properties which leads to the + * function to be trapped. + * + * ### Examples + * + * example.org##+js(call-nothrow, Object.defineProperty) + * + * */ + +builtinScriptlets.push({ + name: 'call-nothrow.js', + fn: callNothrow, + dependencies: [ + 'safe-self.fn', + ], +}); +function callNothrow( + chain = '' +) { + if ( typeof chain !== 'string' ) { return; } + if ( chain === '' ) { return; } + const safe = safeSelf(); + const parts = safe.String_split.call(chain, '.'); + let owner = window, prop; + for (;;) { + prop = parts.shift(); + if ( parts.length === 0 ) { break; } + owner = owner[prop]; + if ( owner instanceof Object === false ) { return; } + } + if ( prop === '' ) { return; } + const fn = owner[prop]; + if ( typeof fn !== 'function' ) { return; } + owner[prop] = new Proxy(fn, { + apply: function(...args) { + let r; + try { + r = Reflect.apply(...args); + } catch { + } + return r; + }, + }); +} + +/******************************************************************************/ + +builtinScriptlets.push({ + name: 'remove-node-text.js', + aliases: [ + 'rmnt.js', + ], + fn: removeNodeText, + world: 'ISOLATED', + dependencies: [ + 'replace-node-text.fn', + ], +}); +function removeNodeText( + nodeName, + includes, + ...extraArgs +) { + replaceNodeTextFn(nodeName, '', '', 'includes', includes || '', ...extraArgs); +} + +/******************************************************************************* + * + * @scriptlet prevent-canvas + * + * @description + * Prevent usage of specific or all (default) canvas APIs. + * + * ### Syntax + * + * ```text + * example.com##+js(prevent-canvas [, contextType]) + * ``` + * + * - `contextType`: A specific type of canvas API to prevent (default to all + * APIs). Can be a string or regex which will be matched against the type + * used in getContext() call. Prepend with `!` to test for no-match. + * + * ### Examples + * + * 1. Prevent `example.com` from accessing all canvas APIs + * + * ```adblock + * example.com##+js(prevent-canvas) + * ``` + * + * 2. Prevent access to any flavor of WebGL API, everywhere + * + * ```adblock + * *##+js(prevent-canvas, /webgl/) + * ``` + * + * 3. Prevent `example.com` from accessing any flavor of canvas API except `2d` + * + * ```adblock + * example.com##+js(prevent-canvas, !2d) + * ``` + * + * ### References + * + * https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/getContext + * + * */ + +builtinScriptlets.push({ + name: 'prevent-canvas.js', + fn: preventCanvas, + dependencies: [ + 'safe-self.fn', + ], +}); +function preventCanvas( + contextType = '' +) { + const safe = safeSelf(); + const pattern = safe.initPattern(contextType, { canNegate: true }); + const proto = globalThis.HTMLCanvasElement.prototype; + proto.getContext = new Proxy(proto.getContext, { + apply(target, thisArg, args) { + if ( safe.testPattern(pattern, args[0]) ) { return null; } + return Reflect.apply(target, thisArg, args); + } + }); +} + +/******************************************************************************/ + +builtinScriptlets.push({ + name: 'multiup.js', + fn: multiup, + world: 'ISOLATED', +}); +function multiup() { + const handler = ev => { + const target = ev.target; + if ( target.matches('button[link]') === false ) { return; } + const ancestor = target.closest('form'); + if ( ancestor === null ) { return; } + if ( ancestor !== target.parentElement ) { return; } + const link = (target.getAttribute('link') || '').trim(); + if ( link === '' ) { return; } + ev.preventDefault(); + ev.stopPropagation(); + document.location.href = link; + }; + document.addEventListener('click', handler, { capture: true }); +} + +/******************************************************************************/ + +builtinScriptlets.push({ + name: 'break-on-call.js', + fn: breakOnCall, + dependencies: [ + 'proxy-apply.fn', + ], +}); +function breakOnCall(target) { + proxyApplyFn(target, function(context) { + debugger; // eslint-disable-line no-debugger + return context.reflect(); + }); +} + + + + + +/******************************************************************************* + * + * Scriplets below this section are only available for filter lists from + * trusted sources. They all have the property `requiresTrust` set to `true`. + * + * Trusted sources are: + * + * - uBO's own filter lists, which name starts with "uBlock filters – ", and + * maintained at: https://github.com/uBlockOrigin/uAssets + * + * - The user's own filters as seen in "My filters" pane in uBO's dashboard. + * + * The trustworthiness of filters using these privileged scriptlets are + * evaluated at filter list compiled time: when a filter using one of the + * privileged scriptlet originates from a non-trusted filter list source, it + * is discarded at compile time, specifically from within: + * + * - Source: ./src/js/scriptlet-filtering.js + * - Method: scriptletFilteringEngine.compile(), via normalizeRawFilter() + * + **/ + +/******************************************************************************* + * + * replace-node-text.js + * + * Replace text instance(s) with another text instance inside specific + * DOM nodes. By default, the scriplet stops and quits at the interactive + * stage of a document. + * + * See commit messages for usage: + * - https://github.com/gorhill/uBlock/commit/99ce027fd702 + * - https://github.com/gorhill/uBlock/commit/41876336db48 + * + **/ + +builtinScriptlets.push({ + name: 'trusted-replace-node-text.js', + requiresTrust: true, + aliases: [ + 'trusted-rpnt.js', + 'replace-node-text.js', + 'rpnt.js', + ], + fn: replaceNodeText, + world: 'ISOLATED', + dependencies: [ + 'replace-node-text.fn', + ], +}); +function replaceNodeText( + nodeName, + pattern, + replacement, + ...extraArgs +) { + replaceNodeTextFn(nodeName, pattern, replacement, ...extraArgs); +} + +/******************************************************************************* + * + * trusted-replace-fetch-response.js + * + * Replaces response text content of fetch requests if all given parameters + * match. + * + * Reference: + * https://github.com/AdguardTeam/Scriptlets/blob/master/src/scriptlets/trusted-replace-fetch-response.js + * + **/ + +builtinScriptlets.push({ + name: 'trusted-replace-fetch-response.js', + requiresTrust: true, + aliases: [ + 'trusted-rpfr.js', + ], + fn: trustedReplaceFetchResponse, + dependencies: [ + 'replace-fetch-response.fn', + ], +}); +function trustedReplaceFetchResponse(...args) { + replaceFetchResponseFn(true, ...args); +} + +/******************************************************************************/ + +builtinScriptlets.push({ + name: 'trusted-replace-xhr-response.js', + requiresTrust: true, + fn: trustedReplaceXhrResponse, + dependencies: [ + 'match-object-properties.fn', + 'parse-properties-to-match.fn', + 'safe-self.fn', + ], +}); +function trustedReplaceXhrResponse( + pattern = '', + replacement = '', + propsToMatch = '', + ...varargs +) { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('trusted-replace-xhr-response', pattern, replacement, propsToMatch); + const xhrInstances = new WeakMap(); + if ( pattern === '*' ) { pattern = '.*'; } + const rePattern = safe.patternToRegex(pattern); + const propNeedles = parsePropertiesToMatchFn(propsToMatch, 'url'); + const extraArgs = safe.parseVarargs(varargs); + const reIncludes = extraArgs.includes ? safe.patternToRegex(extraArgs.includes) : null; + self.XMLHttpRequest = class extends self.XMLHttpRequest { + open(method, url, ...args) { + const outerXhr = this; + const xhrDetails = { method, url }; + let outcome = 'match'; + if ( propNeedles.size !== 0 ) { + if ( matchObjectPropertiesFn(propNeedles, xhrDetails) === undefined ) { + outcome = 'nomatch'; + } + } + if ( outcome === 'match' ) { + if ( safe.logLevel > 1 ) { + safe.uboLog(logPrefix, `Matched "propsToMatch"`); + } + xhrInstances.set(outerXhr, 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); + } + if ( reIncludes && reIncludes.test(innerResponse) === false ) { + return (xhrDetails.response = innerResponse); + } + const textBefore = innerResponse; + const textAfter = textBefore.replace(rePattern, replacement); + if ( textAfter !== textBefore ) { + safe.uboLog(logPrefix, 'Match'); + } + return (xhrDetails.response = textAfter); + } + get responseText() { + const response = this.response; + if ( typeof response !== 'string' ) { + return super.responseText; + } + return response; + } + }; +} + +/******************************************************************************* + * + * @scriptlet trusted-click-element + * + * @description + * Programmatically click on one or more elements. + * + * @param steps + * A comma-separated list of steps to fulfilled: + * - A valid CSS selector matching an element to programmatically click + * - A integer: A delay in milliseconds to wait before the next step is + * processed. If the last step is an integer, it is used as a timeout value + * before the scriptlet bails out (default to 11s) + * If the list of steps starts with `;` or `|`, it will be used as the + * separator character instead of comma. This is convenient when a selector + * contains a comma character. + * A selector must be one of: + * - A valid plain CSS selector + * - An xpath expression: xpath:[expression] + * Addtionally: + * - Use `>>>` between selectors to target an element inside a shadow root + * - Prepend with `when-visible:[selector]` to progrmatically click the element + * only when it is visible + * + **/ + +builtinScriptlets.push({ + name: 'trusted-click-element.js', + requiresTrust: true, + fn: trustedClickElement, + world: 'ISOLATED', + dependencies: [ + 'get-all-cookies.fn', + 'get-all-local-storage.fn', + 'lookup-elements.fn', + 'run-at-html-element.fn', + 'safe-self.fn', + ], +}); +function trustedClickElement( + selectors = '', + extraMatch = '', + delay = '' +) { + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('trusted-click-element', selectors, extraMatch, delay); + + if ( extraMatch !== '' ) { + const assertions = safe.String_split.call(extraMatch, ',').map(s => { + const pos1 = s.indexOf(':'); + const s1 = pos1 !== -1 ? s.slice(0, pos1) : s; + const not = s1.startsWith('!'); + const type = not ? s1.slice(1) : s1; + const s2 = pos1 !== -1 ? s.slice(pos1+1).trim() : ''; + if ( s2 === '' ) { return; } + const out = { not, type }; + const match = /^\/(.+)\/(i?)$/.exec(s2); + if ( match !== null ) { + out.re = new RegExp(match[1], match[2] || undefined); + return out; + } + const pos2 = s2.indexOf('='); + const key = pos2 !== -1 ? s2.slice(0, pos2).trim() : s2; + const value = pos2 !== -1 ? s2.slice(pos2+1).trim() : ''; + out.re = new RegExp(`^${safe.escapeRegexChars(key)}=${safe.escapeRegexChars(value)}`); + return out; + }).filter(details => details !== undefined); + const allCookies = assertions.some(o => o.type === 'cookie') + ? getAllCookiesFn() + : []; + const allStorageItems = assertions.some(o => o.type === 'localStorage') + ? getAllLocalStorageFn() + : []; + const hasNeedle = (haystack, needle) => { + for ( const { key, value } of haystack ) { + if ( needle.test(`${key}=${value}`) ) { return true; } + } + return false; + }; + for ( const { not, type, re } of assertions ) { + switch ( type ) { + case 'cookie': + if ( hasNeedle(allCookies, re) === not ) { return; } + break; + case 'localStorage': + if ( hasNeedle(allStorageItems, re) === not ) { return; } + break; + } + } + } + + const steps = (( ) => { + const steps = /^[;|]/.test(selectors) + ? safe.String_split.call(selectors.slice(1), selectors.charAt(0)) + : safe.String_split.call(selectors, ','); + return steps.map(a => { + a = a.trim(); + if ( /^\d+$/.test(a) ) { return parseInt(a, 10); } + return a; + }); + })(); + if ( steps.length === 0 ) { return; } + const clickDelay = parseInt(delay, 10) || 1; + for ( let i = steps.length-1; i > 0; i-- ) { + if ( typeof steps[i] !== 'string' ) { continue; } + if ( typeof steps[i-1] !== 'string' ) { continue; } + steps.splice(i, 0, clickDelay); + } + if ( steps.length === 1 && delay !== '' ) { + steps.unshift(clickDelay); + } + if ( typeof steps.at(-1) !== 'number' ) { + steps.push(11000); + } + + const timeout = steps.pop(); + + const waitForTime = ms => { + return new Promise(resolve => { + safe.uboLog(logPrefix, `Waiting for ${ms} ms`); + waitForTime.timer = setTimeout(( ) => { + waitForTime.timer = undefined; + resolve(); + }, ms); + }); + }; + + const waitForElement = directive => { + safe.uboLog(logPrefix, `Waiting for ${directive}`); + return lookupElementsFn(directive, Date.now() + timeout).then(elems => { + if ( elems.length === 0 ) { return false; } + elems[0].click(); + safe.uboLog(logPrefix, `Clicked ${directive}`); + return true; + }); + }; + + const process = async ( ) => { + while ( steps.length !== 0 ) { + const step = steps.shift(); + if ( step === undefined ) { break; } + if ( typeof step === 'number' ) { + await waitForTime(step); + if ( step === 1 ) { continue; } + continue; + } + if ( step.startsWith('!') ) { continue; } + const clicked = await waitForElement(step); + if ( clicked ) { continue; } + safe.uboLog(logPrefix, `Timed out waiting on ${step}`); + break; + } + }; + + runAtHtmlElementFn(process); +} + +/******************************************************************************/ + +builtinScriptlets.push({ + name: 'trusted-replace-outbound-text.js', + requiresTrust: true, + fn: trustedReplaceOutboundText, + dependencies: [ + 'proxy-apply.fn', + 'safe-self.fn', + ], +}); +function trustedReplaceOutboundText( + propChain = '', + rawPattern = '', + rawReplacement = '', + ...varargs +) { + if ( propChain === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('trusted-replace-outbound-text', propChain, rawPattern, rawReplacement, ...varargs); + const rePattern = safe.patternToRegex(rawPattern); + const replacement = rawReplacement.startsWith('json:') + ? safe.JSON_parse(rawReplacement.slice(5)) + : rawReplacement; + const extraArgs = safe.parseVarargs(varargs); + const reCondition = safe.patternToRegex(extraArgs.condition || ''); + proxyApplyFn(propChain, function(context) { + const encodedTextBefore = context.reflect(); + let textBefore = encodedTextBefore; + if ( extraArgs.encoding === 'base64' ) { + try { textBefore = self.atob(encodedTextBefore); } + catch { return encodedTextBefore; } + } + if ( rawPattern === '' ) { + safe.uboLog(logPrefix, 'Decoded outbound text:\n', textBefore); + return encodedTextBefore; + } + reCondition.lastIndex = 0; + if ( reCondition.test(textBefore) === false ) { return encodedTextBefore; } + const textAfter = textBefore.replace(rePattern, replacement); + if ( textAfter === textBefore ) { return encodedTextBefore; } + safe.uboLog(logPrefix, 'Matched and replaced'); + if ( safe.logLevel > 1 ) { + safe.uboLog(logPrefix, 'Modified decoded outbound text:\n', textAfter); + } + let encodedTextAfter = textAfter; + if ( extraArgs.encoding === 'base64' ) { + encodedTextAfter = self.btoa(textAfter); + } + return encodedTextAfter; + }); +} + +/******************************************************************************* + * + * Reference: + * https://github.com/AdguardTeam/Scriptlets/blob/5a92d79489/wiki/about-trusted-scriptlets.md#trusted-suppress-native-method + * + * This is a first version with current limitations: + * - Does not support matching arguments which are object or array + * - Does not support `stack` parameter + * + * If `signatureStr` parameter is not declared, the scriptlet will log all calls + * to `methodPath` along with the arguments passed and will not prevent the + * trapped method. + * + * */ + +builtinScriptlets.push({ + name: 'trusted-suppress-native-method.js', + requiresTrust: true, + fn: trustedSuppressNativeMethod, + dependencies: [ + 'matches-stack-trace.fn', + 'proxy-apply.fn', + 'safe-self.fn', + ], +}); +function trustedSuppressNativeMethod( + methodPath = '', + signature = '', + how = '', + stack = '' +) { + if ( methodPath === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('trusted-suppress-native-method', methodPath, signature, how, stack); + const signatureArgs = safe.String_split.call(signature, /\s*\|\s*/).map(v => { + if ( /^".*"$/.test(v) ) { + return { type: 'pattern', re: safe.patternToRegex(v.slice(1, -1)) }; + } + if ( /^\/.+\/$/.test(v) ) { + return { type: 'pattern', re: safe.patternToRegex(v) }; + } + if ( v === 'false' ) { + return { type: 'exact', value: false }; + } + if ( v === 'true' ) { + return { type: 'exact', value: true }; + } + if ( v === 'null' ) { + return { type: 'exact', value: null }; + } + if ( v === 'undefined' ) { + return { type: 'exact', value: undefined }; + } + }); + const stackNeedle = safe.initPattern(stack, { canNegate: true }); + proxyApplyFn(methodPath, function(context) { + const { callArgs } = context; + if ( signature === '' ) { + safe.uboLog(logPrefix, `Arguments:\n${callArgs.join('\n')}`); + return context.reflect(); + } + for ( let i = 0; i < signatureArgs.length; i++ ) { + const signatureArg = signatureArgs[i]; + if ( signatureArg === undefined ) { continue; } + const targetArg = i < callArgs.length ? callArgs[i] : undefined; + if ( signatureArg.type === 'exact' ) { + if ( targetArg !== signatureArg.value ) { + return context.reflect(); + } + } + if ( signatureArg.type === 'pattern' ) { + if ( safe.RegExp_test(signatureArg.re, targetArg) === false ) { + return context.reflect(); + } + } + } + if ( stackNeedle.matchAll !== true ) { + const logLevel = safe.logLevel > 1 ? 'all' : ''; + if ( matchesStackTraceFn(stackNeedle, logLevel) === false ) { + return context.reflect(); + } + } + if ( how === 'debug' ) { + debugger; // eslint-disable-line no-debugger + return context.reflect(); + } + safe.uboLog(logPrefix, `Suppressed:\n${callArgs.join('\n')}`); + if ( how === 'abort' ) { + throw new ReferenceError(); + } + }); +} + +/** + * @trustedScriptlet trusted-prevent-dom-bypass + * + * @description + * Prevent the bypassing of uBO scriptlets through anonymous embedded context. + * + * Ensure that a target method in the embedded context is using the + * corresponding parent context's method (which is assumed to be + * properly patched), or to replace the embedded context with that of the + * parent context. + * + * Root issue: + * https://issues.chromium.org/issues/40202434 + * + * @param methodPath + * The method which calls must be intercepted. The arguments + * of the intercepted calls are assumed to be HTMLElement, anything else will + * be ignored. + * + * @param [targetProps] + * The space-separated methods in the embedded context which should be + * delegated to the parent context. If no method is specified, the embedded + * context becomes the parent one, i.e. all properties of the embedded context + * will be that of the parent context. + * + * @example + * ##+js(trusted-prevent-dom-bypass, Element.prototype.append, open) + * + * @example + * ##+js(trusted-prevent-dom-bypass, Element.prototype.appendChild, XMLHttpRequest JSON.parse) + * + * */ + +builtinScriptlets.push({ + name: 'trusted-prevent-dom-bypass.js', + requiresTrust: true, + fn: trustedPreventDomBypass, + dependencies: [ + 'proxy-apply.fn', + 'safe-self.fn', + ], +}); +function trustedPreventDomBypass( + methodPath = '', + targetProp = '' +) { + if ( methodPath === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('trusted-prevent-dom-bypass', methodPath, targetProp); + proxyApplyFn(methodPath, function(context) { + const elems = new Set(context.callArgs.filter(e => e instanceof HTMLElement)); + const r = context.reflect(); + if ( elems.length === 0 ) { return r; } + for ( const elem of elems ) { + try { + if ( `${elem.contentWindow}` !== '[object Window]' ) { continue; } + if ( elem.contentWindow.location.href !== 'about:blank' ) { + if ( elem.contentWindow.location.href !== self.location.href ) { + continue; + } + } + if ( targetProp !== '' ) { + const chains = targetProp.split(/\s+/); + let me = self, it = elem.contentWindow; + for ( let chain of chains ) { + for (;;) { + const pos = chain.indexOf('.'); + if ( pos === -1 ) { break; } + const prop = chain.slice(0, pos); + me = me[prop]; it = it[prop]; + chain = chain.slice(pos+1); + } + it[chain] = me[chain]; + } + } else { + Object.defineProperty(elem, 'contentWindow', { value: self }); + } + safe.uboLog(logPrefix, 'Bypass prevented'); + } catch { + } + } + return r; + }); +} + +/** + * @trustedScriptlet trusted-override-element-method + * + * @description + * Override the behavior of a method on matching elements. + * + * @param methodPath + * The method which calls must be intercepted. + * + * @param [selector] + * A CSS selector which the target element must match. If not specified, + * the override will occur for all elements. + * + * @param [disposition] + * How the override should be handled. If not specified, the overridden call + * will be equivalent to an empty function. If set to `throw`, an exception + * will be thrown. Any other value will be validated and returned as a + * supported safe constant. + * + * @example + * ##+js(trusted-override-element-method, HTMLAnchorElement.prototype.click, a[target="_blank"][style]) + * + * */ + +builtinScriptlets.push({ + name: 'trusted-override-element-method.js', + requiresTrust: true, + fn: trustedOverrideElementMethod, + dependencies: [ + 'proxy-apply.fn', + 'safe-self.fn', + 'validate-constant.fn', + ], +}); +function trustedOverrideElementMethod( + methodPath = '', + selector = '', + disposition = '', + ...varargs +) { + if ( methodPath === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('trusted-override-element-method', methodPath, selector, disposition); + const extraArgs = safe.parseVarargs(varargs); + proxyApplyFn(methodPath, function(context) { + let override = selector === ''; + if ( override === false ) { + const { thisArg } = context; + try { + override = thisArg.closest(selector) === thisArg; + } catch { + } + } + if ( override === false ) { + return context.reflect(); + } + safe.uboLog(logPrefix, 'Overridden'); + if ( disposition === '' ) { return; } + if ( disposition === 'debug' && safe.logLevel !== 0 ) { + debugger; // eslint-disable-line no-debugger + } + if ( disposition === 'throw' ) { + throw new ReferenceError(); + } + return validateConstantFn(true, disposition, extraArgs); + }); +} + +/******************************************************************************/ diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/set-constant.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/set-constant.js new file mode 100644 index 00000000..ced3adf0 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/set-constant.js @@ -0,0 +1,288 @@ +/******************************************************************************* + + 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 { registerScriptlet } from './base.js'; +import { runAt } from './run-at.js'; +import { safeSelf } from './safe-self.js'; + +/******************************************************************************/ + +export function validateConstantFn(trusted, raw, extraArgs = {}) { + const safe = safeSelf(); + let value; + if ( raw === 'undefined' ) { + value = undefined; + } else if ( raw === 'false' ) { + value = false; + } else if ( raw === 'true' ) { + value = true; + } else if ( raw === 'null' ) { + value = null; + } else if ( raw === "''" || raw === '' ) { + value = ''; + } else if ( raw === '[]' || raw === 'emptyArr' ) { + value = []; + } else if ( raw === '{}' || raw === 'emptyObj' ) { + value = {}; + } else if ( raw === 'noopFunc' ) { + value = function(){}; + } else if ( raw === 'trueFunc' ) { + value = function(){ return true; }; + } else if ( raw === 'falseFunc' ) { + value = function(){ return false; }; + } else if ( raw === 'throwFunc' ) { + value = function(){ throw ''; }; + } else if ( /^-?\d+$/.test(raw) ) { + value = parseInt(raw); + if ( isNaN(raw) ) { return; } + if ( Math.abs(raw) > 0x7FFF ) { return; } + } else if ( trusted ) { + if ( raw.startsWith('json:') ) { + try { value = safe.JSON_parse(raw.slice(5)); } catch { return; } + } else if ( raw.startsWith('{') && raw.endsWith('}') ) { + try { value = safe.JSON_parse(raw).value; } catch { return; } + } + } else { + return; + } + if ( extraArgs.as !== undefined ) { + if ( extraArgs.as === 'function' ) { + return ( ) => value; + } else if ( extraArgs.as === 'callback' ) { + return ( ) => (( ) => value); + } else if ( extraArgs.as === 'resolved' ) { + return Promise.resolve(value); + } else if ( extraArgs.as === 'rejected' ) { + return Promise.reject(value); + } + } + return value; +} +registerScriptlet(validateConstantFn, { + name: 'validate-constant.fn', + dependencies: [ + safeSelf, + ], +}); + +/******************************************************************************/ + +export function setConstantFn( + trusted = false, + chain = '', + rawValue = '', + ...varargs +) { + if ( chain === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('set-constant', chain, rawValue); + const extraArgs = safe.parseVarargs(varargs); + function setConstant(chain, rawValue) { + const trappedProp = (( ) => { + const pos = chain.lastIndexOf('.'); + if ( pos === -1 ) { return chain; } + return chain.slice(pos+1); + })(); + const cloakFunc = fn => { + safe.Object_defineProperty(fn, 'name', { value: trappedProp }); + return new Proxy(fn, { + defineProperty(target, prop) { + if ( prop !== 'toString' ) { + return Reflect.defineProperty(...arguments); + } + return true; + }, + deleteProperty(target, prop) { + if ( prop !== 'toString' ) { + return Reflect.deleteProperty(...arguments); + } + return true; + }, + get(target, prop) { + if ( prop === 'toString' ) { + return function() { + return `function ${trappedProp}() { [native code] }`; + }.bind(null); + } + return Reflect.get(...arguments); + }, + }); + }; + if ( trappedProp === '' ) { return; } + const thisScript = document.currentScript; + let normalValue = validateConstantFn(trusted, rawValue, extraArgs); + if ( rawValue === 'noopFunc' || rawValue === 'trueFunc' || rawValue === 'falseFunc' ) { + normalValue = cloakFunc(normalValue); + } + let aborted = false; + const mustAbort = function(v) { + if ( trusted ) { return false; } + if ( aborted ) { return true; } + aborted = + (v !== undefined && v !== null) && + (normalValue !== undefined && normalValue !== null) && + (typeof v !== typeof normalValue); + if ( aborted ) { + safe.uboLog(logPrefix, `Aborted because value set to ${v}`); + } + return aborted; + }; + // https://github.com/uBlockOrigin/uBlock-issues/issues/156 + // Support multiple trappers for the same property. + const trapProp = function(owner, prop, configurable, handler) { + if ( handler.init(configurable ? owner[prop] : normalValue) === false ) { return; } + const odesc = safe.Object_getOwnPropertyDescriptor(owner, prop); + let prevGetter, prevSetter; + if ( odesc instanceof safe.Object ) { + owner[prop] = normalValue; + if ( odesc.get instanceof Function ) { + prevGetter = odesc.get; + } + if ( odesc.set instanceof Function ) { + prevSetter = odesc.set; + } + } + try { + safe.Object_defineProperty(owner, prop, { + configurable, + get() { + if ( prevGetter !== undefined ) { + prevGetter(); + } + return handler.getter(); + }, + set(a) { + if ( prevSetter !== undefined ) { + prevSetter(a); + } + handler.setter(a); + } + }); + safe.uboLog(logPrefix, 'Trap installed'); + } catch(ex) { + safe.uboErr(logPrefix, ex); + } + }; + const trapChain = function(owner, chain) { + const pos = chain.indexOf('.'); + if ( pos === -1 ) { + trapProp(owner, chain, false, { + v: undefined, + init: function(v) { + if ( mustAbort(v) ) { return false; } + this.v = v; + return true; + }, + getter: function() { + if ( document.currentScript === thisScript ) { + return this.v; + } + safe.uboLog(logPrefix, 'Property read'); + return normalValue; + }, + setter: function(a) { + if ( mustAbort(a) === false ) { return; } + normalValue = a; + } + }); + return; + } + const prop = chain.slice(0, pos); + const v = owner[prop]; + chain = chain.slice(pos + 1); + if ( v instanceof safe.Object || typeof v === 'object' && v !== null ) { + trapChain(v, chain); + return; + } + trapProp(owner, prop, true, { + v: undefined, + init: function(v) { + this.v = v; + return true; + }, + getter: function() { + return this.v; + }, + setter: function(a) { + this.v = a; + if ( a instanceof safe.Object ) { + trapChain(a, chain); + } + } + }); + }; + trapChain(window, chain); + } + runAt(( ) => { + setConstant(chain, rawValue); + }, extraArgs.runAt); +} +registerScriptlet(setConstantFn, { + name: 'set-constant.fn', + dependencies: [ + runAt, + safeSelf, + validateConstantFn, + ], +}); + +/******************************************************************************/ + +export function setConstant( + ...args +) { + setConstantFn(false, ...args); +} +registerScriptlet(setConstant, { + name: 'set-constant.js', + aliases: [ + 'set.js', + ], + dependencies: [ + setConstantFn, + ], +}); + +/******************************************************************************* + * + * trusted-set-constant.js + * + * Set specified property to any value. This is essentially the same as + * set-constant.js, but with no restriction as to which values can be used. + * + **/ + +export function trustedSetConstant( + ...args +) { + setConstantFn(true, ...args); +} +registerScriptlet(trustedSetConstant, { + name: 'trusted-set-constant.js', + requiresTrust: true, + aliases: [ + 'trusted-set.js', + ], + dependencies: [ + setConstantFn, + ], +}); diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/shared.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/shared.js new file mode 100644 index 00000000..01a9de71 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/shared.js @@ -0,0 +1,40 @@ +/******************************************************************************* + + 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 + +*/ + +// Code imported from main code base and exposed as injectable scriptlets +import { ArglistParser as __ArglistParser__ } from '../arglist-parser.js'; +import { JSONPath as __JSONPath__ } from '../jsonpath.js'; +import { registerScriptlet } from './base.js'; + +/******************************************************************************/ + +export const ArglistParser = __ArglistParser__; + +registerScriptlet(ArglistParser, { + name: 'arglist-parser.fn', +}); + +export const JSONPath = __JSONPath__; + +registerScriptlet(JSONPath, { + name: 'jsonpath.fn', +}); diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/spoof-css.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/spoof-css.js new file mode 100644 index 00000000..7cc7b9f9 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/spoof-css.js @@ -0,0 +1,163 @@ +/******************************************************************************* + + 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 { registerScriptlet } from './base.js'; +import { safeSelf } from './safe-self.js'; + +/** + * @scriptlet spoof-css.js + * + * @description + * Spoof the value of CSS properties. + * + * @param selector + * A CSS selector for the element(s) to target. + * + * @param [property, value, ...] + * A list of property-value pairs of the style properties to spoof to the + * specified values. +* + * */ + +export function spoofCSS( + selector, + ...args +) { + if ( typeof selector !== 'string' ) { return; } + if ( selector === '' ) { return; } + const toCamelCase = s => s.replace(/-[a-z]/g, s => s.charAt(1).toUpperCase()); + const propToValueMap = new Map(); + const privatePropToValueMap = new Map(); + for ( let i = 0; i < args.length; i += 2 ) { + const prop = toCamelCase(args[i+0]); + if ( prop === '' ) { break; } + const value = args[i+1]; + if ( typeof value !== 'string' ) { break; } + if ( prop.charCodeAt(0) === 0x5F /* _ */ ) { + privatePropToValueMap.set(prop, value); + } else { + propToValueMap.set(prop, value); + } + } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('spoof-css', selector, ...args); + const instanceProperties = [ 'cssText', 'length', 'parentRule' ]; + const spoofStyle = (prop, real) => { + const normalProp = toCamelCase(prop); + const shouldSpoof = propToValueMap.has(normalProp); + const value = shouldSpoof ? propToValueMap.get(normalProp) : real; + if ( shouldSpoof ) { + safe.uboLog(logPrefix, `Spoofing ${prop} to ${value}`); + } + return value; + }; + const cloackFunc = (fn, thisArg, name) => { + const trap = fn.bind(thisArg); + Object.defineProperty(trap, 'name', { value: name }); + Object.defineProperty(trap, 'toString', { + value: ( ) => `function ${name}() { [native code] }` + }); + return trap; + }; + self.getComputedStyle = new Proxy(self.getComputedStyle, { + apply: function(target, thisArg, args) { + // eslint-disable-next-line no-debugger + if ( privatePropToValueMap.has('_debug') ) { debugger; } + const style = Reflect.apply(target, thisArg, args); + const targetElements = new WeakSet(document.querySelectorAll(selector)); + if ( targetElements.has(args[0]) === false ) { return style; } + const proxiedStyle = new Proxy(style, { + get(target, prop) { + if ( typeof target[prop] === 'function' ) { + if ( prop === 'getPropertyValue' ) { + return cloackFunc(function getPropertyValue(prop) { + return spoofStyle(prop, target[prop]); + }, target, 'getPropertyValue'); + } + return cloackFunc(target[prop], target, prop); + } + if ( instanceProperties.includes(prop) ) { + return Reflect.get(target, prop); + } + return spoofStyle(prop, Reflect.get(target, prop)); + }, + getOwnPropertyDescriptor(target, prop) { + if ( propToValueMap.has(prop) ) { + return { + configurable: true, + enumerable: true, + value: propToValueMap.get(prop), + writable: true, + }; + } + return Reflect.getOwnPropertyDescriptor(target, prop); + }, + }); + return proxiedStyle; + }, + get(target, prop) { + if ( prop === 'toString' ) { + return target.toString.bind(target); + } + return Reflect.get(target, prop); + }, + }); + Element.prototype.getBoundingClientRect = new Proxy(Element.prototype.getBoundingClientRect, { + apply: function(target, thisArg, args) { + // eslint-disable-next-line no-debugger + if ( privatePropToValueMap.has('_debug') ) { debugger; } + const rect = Reflect.apply(target, thisArg, args); + const targetElements = new WeakSet(document.querySelectorAll(selector)); + if ( targetElements.has(thisArg) === false ) { return rect; } + let { x, y, height, width } = rect; + if ( privatePropToValueMap.has('_rectx') ) { + x = parseFloat(privatePropToValueMap.get('_rectx')); + } + if ( privatePropToValueMap.has('_recty') ) { + y = parseFloat(privatePropToValueMap.get('_recty')); + } + if ( privatePropToValueMap.has('_rectw') ) { + width = parseFloat(privatePropToValueMap.get('_rectw')); + } else if ( propToValueMap.has('width') ) { + width = parseFloat(propToValueMap.get('width')); + } + if ( privatePropToValueMap.has('_recth') ) { + height = parseFloat(privatePropToValueMap.get('_recth')); + } else if ( propToValueMap.has('height') ) { + height = parseFloat(propToValueMap.get('height')); + } + return new self.DOMRect(x, y, width, height); + }, + get(target, prop) { + if ( prop === 'toString' ) { + return target.toString.bind(target); + } + return Reflect.get(target, prop); + }, + }); +} +registerScriptlet(spoofCSS, { + name: 'spoof-css.js', + dependencies: [ + safeSelf, + ], +}); diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/stack-trace.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/stack-trace.js new file mode 100644 index 00000000..7d742135 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/stack-trace.js @@ -0,0 +1,149 @@ +/******************************************************************************* + + 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 { getExceptionTokenFn } from './utils.js'; +import { registerScriptlet } from './base.js'; +import { safeSelf } from './safe-self.js'; + +/******************************************************************************/ + +export function matchesStackTraceFn( + needleDetails, + logLevel = '' +) { + const safe = safeSelf(); + const exceptionToken = getExceptionTokenFn(); + const error = new safe.Error(exceptionToken); + const docURL = new URL(self.location.href); + docURL.hash = ''; + // Normalize stack trace + const reLine = /(.*?@)?(\S+)(:\d+):\d+\)?$/; + const lines = []; + for ( let line of safe.String_split.call(error.stack, /[\n\r]+/) ) { + if ( line.includes(exceptionToken) ) { continue; } + line = line.trim(); + const match = safe.RegExp_exec.call(reLine, line); + if ( match === null ) { continue; } + let url = match[2]; + if ( url.startsWith('(') ) { url = url.slice(1); } + if ( url === docURL.href ) { + url = 'inlineScript'; + } else if ( url.startsWith('') ) { + url = 'injectedScript'; + } + let fn = match[1] !== undefined + ? match[1].slice(0, -1) + : line.slice(0, match.index).trim(); + if ( fn.startsWith('at') ) { fn = fn.slice(2).trim(); } + let rowcol = match[3]; + lines.push(' ' + `${fn} ${url}${rowcol}:1`.trim()); + } + lines[0] = `stackDepth:${lines.length-1}`; + const stack = lines.join('\t'); + const r = needleDetails.matchAll !== true && + safe.testPattern(needleDetails, stack); + if ( + logLevel === 'all' || + logLevel === 'match' && r || + logLevel === 'nomatch' && !r + ) { + safe.uboLog(stack.replace(/\t/g, '\n')); + } + return r; +} +registerScriptlet(matchesStackTraceFn, { + name: 'matches-stack-trace.fn', + dependencies: [ + getExceptionTokenFn, + safeSelf, + ], +}); + +/******************************************************************************/ + +function abortOnStackTrace( + chain = '', + needle = '', + ...varargs +) { + if ( typeof chain !== 'string' ) { return; } + const safe = safeSelf(); + const needleDetails = safe.initPattern(needle, { canNegate: true }); + const extraArgs = safe.parseVarargs(varargs); + if ( needle === '' ) { extraArgs.log = 'all'; } + const makeProxy = function(owner, chain) { + const pos = chain.indexOf('.'); + if ( pos === -1 ) { + let v = owner[chain]; + Object.defineProperty(owner, chain, { + get: function() { + const log = safe.logLevel > 1 ? 'all' : 'match'; + if ( matchesStackTraceFn(needleDetails, log) ) { + throw new ReferenceError(getExceptionTokenFn()); + } + return v; + }, + set: function(a) { + const log = safe.logLevel > 1 ? 'all' : 'match'; + if ( matchesStackTraceFn(needleDetails, log) ) { + throw new ReferenceError(getExceptionTokenFn()); + } + v = a; + }, + }); + return; + } + const prop = chain.slice(0, pos); + let v = owner[prop]; + chain = chain.slice(pos + 1); + if ( v ) { + makeProxy(v, chain); + return; + } + const desc = Object.getOwnPropertyDescriptor(owner, prop); + if ( desc && desc.set !== undefined ) { return; } + Object.defineProperty(owner, prop, { + get: function() { return v; }, + set: function(a) { + v = a; + if ( a instanceof Object ) { + makeProxy(a, chain); + } + } + }); + }; + const owner = window; + makeProxy(owner, chain); +} +registerScriptlet(abortOnStackTrace, { + name: 'abort-on-stack-trace.js', + aliases: [ + 'aost.js', + ], + dependencies: [ + getExceptionTokenFn, + matchesStackTraceFn, + safeSelf, + ], +}); + +/******************************************************************************/ diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/utils.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/utils.js new file mode 100644 index 00000000..d492bd5e --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/utils.js @@ -0,0 +1,445 @@ +/******************************************************************************* + + 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 { registerScriptlet } from './base.js'; +import { safeSelf } from './safe-self.js'; + +/******************************************************************************/ + +export function getRandomTokenFn() { + const safe = safeSelf(); + return safe.String_fromCharCode(Date.now() % 26 + 97) + + safe.Math_floor(safe.Math_random() * 982451653 + 982451653).toString(36); +} +registerScriptlet(getRandomTokenFn, { + name: 'get-random-token.fn', + dependencies: [ + safeSelf, + ], +}); + +/******************************************************************************/ + +export function getExceptionTokenFn() { + const token = getRandomTokenFn(); + const oe = self.onerror; + self.onerror = function(msg, ...args) { + if ( typeof msg === 'string' && msg.includes(token) ) { return true; } + if ( oe instanceof Function ) { + return oe.call(this, msg, ...args); + } + }.bind(); + return token; +} +registerScriptlet(getExceptionTokenFn, { + name: 'get-exception-token.fn', + dependencies: [ + getRandomTokenFn, + ], +}); + +/******************************************************************************/ + +export function trapPropertyFn(propChain, handler, options = {}) { + if ( propChain === '' ) { return; } + let owner = self; + let prop = propChain; + for (;;) { + const pos = prop.indexOf('.'); + if ( pos === -1 ) { break; } + owner = owner[prop.slice(0, pos)]; + if ( owner instanceof Object === false ) { return; } + prop = prop.slice(pos + 1); + } + const safe = safeSelf(); + if ( trapPropertyFn.db === undefined ) { + trapPropertyFn.db = new WeakMap(); + trapPropertyFn.entryFromContext = (owner, prop) => { + const handlers = trapPropertyFn.db.get(owner); + return handlers?.get(prop); + }; + trapPropertyFn.getter = (owner, prop) => { + const entry = trapPropertyFn.entryFromContext(owner, prop); + if ( entry === undefined ) { return; } + let r = entry.value; + for ( const desc of entry.stack ) { + try { r = desc.get(); } catch (e) { + if ( entry.canThrow ) { throw e; } + } + } + return r; + }; + trapPropertyFn.setter = (owner, prop, value) => { + const entry = trapPropertyFn.entryFromContext(owner, prop); + if ( entry === undefined ) { return; } + entry.value = value; + for ( const desc of entry.stack ) { + try { desc.set(value); } catch (e) { + if ( entry.canThrow ) { throw e; } + } + } + }; + } + const { db } = trapPropertyFn; + const handlers = db.get(owner) || new Map(); + if ( handlers.size === 0 ) { + db.set(owner, handlers); + } + const entry = handlers.get(prop) || { + value: owner[prop], + stack: [], + }; + entry.stack.push(handler); + if ( entry.stack.length > 1 ) { return entry.value; } + Object.assign(entry, options); + handlers.set(prop, entry); + const desc = safe.Object_getOwnPropertyDescriptor(owner, prop); + if ( desc instanceof safe.Object ) { + if ( desc.get || desc.set ) { + entry.stack.push(desc); + } + } + try { + safe.Object_defineProperty(owner, prop, { + get() { + return trapPropertyFn.getter(owner, prop); + }, + set(value) { + trapPropertyFn.setter(owner, prop, value); + } + }); + } catch { + } + return entry.value; +} +registerScriptlet(trapPropertyFn, { + name: 'trap-property-access.fn', + dependencies: [ + safeSelf, + ], +}); + +/******************************************************************************/ + +export function collateFetchArgumentsFn(resource, options) { + const safe = safeSelf(); + const props = [ + 'body', 'cache', 'credentials', 'duplex', 'headers', + 'integrity', 'keepalive', 'method', 'mode', 'priority', + 'redirect', 'referrer', 'referrerPolicy', 'url' + ]; + const out = {}; + if ( collateFetchArgumentsFn.collateKnownProps === undefined ) { + collateFetchArgumentsFn.collateKnownProps = (src, out) => { + for ( const prop of props ) { + if ( src[prop] === undefined ) { continue; } + out[prop] = src[prop]; + } + }; + } + if ( + typeof resource !== 'object' || + safe.Object_toString.call(resource) !== '[object Request]' + ) { + out.url = `${resource}`; + } else { + let clone; + try { + clone = safe.Request_clone.call(resource); + } catch { + } + collateFetchArgumentsFn.collateKnownProps(clone || resource, out); + } + if ( typeof options === 'object' && options !== null ) { + collateFetchArgumentsFn.collateKnownProps(options, out); + } + return out; +} +registerScriptlet(collateFetchArgumentsFn, { + name: 'collate-fetch-arguments.fn', + dependencies: [ + safeSelf, + ], +}); + +/******************************************************************************/ + +export function parsePropertiesToMatchFn(propsToMatch, implicit = '') { + const safe = safeSelf(); + const needles = new Map(); + if ( propsToMatch === undefined || propsToMatch === '' ) { return needles; } + const options = { canNegate: true }; + for ( const needle of safe.String_split.call(propsToMatch, /\s+/) ) { + let [ prop, pattern ] = safe.String_split.call(needle, ':'); + if ( prop === '' ) { continue; } + if ( pattern !== undefined && /[^$\w -]/.test(prop) ) { + prop = `${prop}:${pattern}`; + pattern = undefined; + } + if ( pattern !== undefined ) { + needles.set(prop, safe.initPattern(pattern, options)); + } else if ( implicit !== '' ) { + needles.set(implicit, safe.initPattern(prop, options)); + } + } + return needles; +} +registerScriptlet(parsePropertiesToMatchFn, { + name: 'parse-properties-to-match.fn', + dependencies: [ + safeSelf, + ], +}); + +/******************************************************************************/ + +export function matchObjectPropertiesFn(propNeedles, ...objs) { + const safe = safeSelf(); + const matched = []; + for ( const obj of objs ) { + if ( obj instanceof Object === false ) { continue; } + for ( const [ prop, details ] of propNeedles ) { + let value = obj[prop]; + if ( value === undefined ) { continue; } + if ( typeof value !== 'string' ) { + try { value = safe.JSON_stringify(value); } + catch { } + if ( typeof value !== 'string' ) { continue; } + } + if ( safe.testPattern(details, value) === false ) { return; } + matched.push(`${prop}: ${value}`); + } + } + return matched; +} +registerScriptlet(matchObjectPropertiesFn, { + name: 'match-object-properties.fn', + dependencies: [ + safeSelf, + ], +}); + +/******************************************************************************/ + +// 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:') ) { + // TODO: resources should be fetched by messaging ISOLATED world + return ''; + } + if ( directive.startsWith('join:') ) { + const parts = directive.slice(7) + .split(directive.slice(5, 7)) + .map(a => generateContentFn(trusted, a)); + return parts.some(a => a instanceof Promise) + ? Promise.all(parts).then(parts => parts.join('')) + : parts.join(''); + } + if ( trusted ) { + return directive; + } + return ''; +} +registerScriptlet(generateContentFn, { + name: 'generate-content.fn', + dependencies: [ + safeSelf, + ], +}); + +/******************************************************************************/ + +export function onIdleFn(fn, options) { + if ( self.requestIdleCallback ) { + return self.requestIdleCallback(fn, options); + } + return self.requestAnimationFrame(fn); +} +registerScriptlet(onIdleFn, { + name: 'on-idle.fn', +}); + +export function offIdleFn(id) { + if ( self.requestIdleCallback ) { + return self.cancelIdleCallback(id); + } + return self.cancelAnimationFrame(id); +} +registerScriptlet(offIdleFn, { + name: 'off-idle.fn', +}); + +/******************************************************************************/ + +export function sleepFn(ms = 0) { + const nap = ( ) => { + return new Promise(resolve => { + self.requestAnimationFrame(resolve); + }); + } + const until = Date.now() + ms; + const sleep = async resolve => { + do { + await nap(); + } while ( Date.now() < until ); + resolve(); + }; + return new Promise(resolve => { sleep(resolve); }); +} +registerScriptlet(sleepFn, { + name: 'sleep.fn', +}); + +/******************************************************************************/ + +export function lookupElementsFn(directive, until = 0) { + if ( lookupElementsFn.querySelectorEx === undefined ) { + lookupElementsFn.getShadowRoot = elem => { + if ( elem.openOrClosedShadowRoot ) { // Firefox + return elem.openOrClosedShadowRoot; + } + if ( self.chrome?.dom?.openOrClosedShadowRoot ) { // Chromium + return self.chrome.dom.openOrClosedShadowRoot(elem); + } + return elem.shadowRoot; + }; + lookupElementsFn.queryOrEvaluateSelector = (selector, context) => { + if ( selector.startsWith('xpath:') === false ) { + return Array.from(context.querySelectorAll(selector)); + } + const result = document.evaluate(selector.slice(6), context, null, 7, null); + const out = []; + if ( result.resultType === 7 ) { + for ( let i = 0; i < result.snapshotLength; i++ ) { + out[i] = result.snapshotItem(i); + } + } + return out; + } + lookupElementsFn.querySelectorEx = (selector, context = document) => { + const pos = selector.indexOf(' >>> '); + if ( pos === -1 ) { + return lookupElementsFn.queryOrEvaluateSelector(selector, context); + } + const outside = selector.slice(0, pos).trim(); + const inside = selector.slice(pos + 5).trim(); + const elems = lookupElementsFn.queryOrEvaluateSelector(outside, context); + const out = []; + for ( let i = 0; i < elems.length; i++ ) { + const shadowRoot = lookupElementsFn.getShadowRoot(elems[i]); + if ( Boolean(shadowRoot) === false ) { continue; } + lookupElementsFn.querySelectorEx(inside, shadowRoot).forEach(a => out.push(a)); + } + return out; + }; + lookupElementsFn.lookup = directive => { + const beVisible = directive.startsWith('when-visible:'); + const selector = beVisible ? directive.slice(13) : directive; + const elems = lookupElementsFn.querySelectorEx(selector); + if ( beVisible !== true ) { return elems; } + return elems.filter(a => a.checkVisibility({ + opacityProperty: true, + visibilityProperty: true, + })); + }; + lookupElementsFn.lookupAsync = details => { + const elems = lookupElementsFn.lookup(details.directive); + if ( elems.length || Date.now() >= details.until ) { + if ( details.observer ) { + details.observer.disconnect(); + details.observer = undefined; + } + if ( details.timer ) { + offIdleFn(details.timer); + details.timer = undefined; + } + return details.resolve(elems); + } + if ( details.observer === undefined ) { + details.observer = new MutationObserver(( ) => { + lookupElementsFn.lookupAsync(details); + }); + details.observer.observe(document, { + attributes: true, + childList: true, + subtree: true, + }); + } + if ( details.timer === undefined ) { + details.timer = onIdleFn(( ) => { + details.timer = undefined; + lookupElementsFn.lookupAsync(details); + }, { timeout: 151 }); + } + }; + } + if ( until === 0 ) { + return lookupElementsFn.lookup(directive); + } + return new Promise(resolve => { + lookupElementsFn.lookupAsync({ directive, until, resolve }); + }); +} +registerScriptlet(lookupElementsFn, { + name: 'lookup-elements.fn', + dependencies: [ + offIdleFn, + onIdleFn, + ], +}); diff --git a/privaxy/src/resources/vendor/ublock/scriptlet-resources/vod.js b/privaxy/src/resources/vendor/ublock/scriptlet-resources/vod.js new file mode 100644 index 00000000..f74d2595 --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/scriptlet-resources/vod.js @@ -0,0 +1,175 @@ +/******************************************************************************* + + uBlock Origin - a comprehensive, efficient content blocker + Copyright (C) 2026-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 { modifyXhrResponseFn } from './prevent-xhr.js'; +import { registerScriptlet } from './base.js'; +import { safeSelf } from './safe-self.js'; + +export function mpegdashPrune( + selector = '', + propsToMatch = '' +) { + if ( typeof selector !== 'string' ) { return; } + if ( selector === '' ) { return; } + const safe = safeSelf(); + const logPrefix = safe.makeLogPrefix('mpegdash-prune', selector, propsToMatch); + const queryAll = (xmlDoc, selector) => { + if ( selector.startsWith('xpath:') === false ) { + return Array.from(xmlDoc.querySelectorAll(selector)); + } + const xpr = xmlDoc.evaluate( + selector.slice(6), + xmlDoc, + null, + XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, + null + ); + const out = []; + for ( let i = 0; i < xpr.snapshotLength; i++ ) { + const node = xpr.snapshotItem(i); + out.push(node); + } + return out; + }; + const rePTparse = /^PT(\d+D)?(\d+H)?(\d+M)?([\d.]+S)?$/; + const secondsPerDay = 24 * 60 * 60; + const secondsPerHour = 60 * 60; + const secondsPerMinute = 60; + const secondsFromPT = pt => { + const match = rePTparse.exec(pt); + if ( match === null ) { return; } + let seconds = 0; + if ( match[1] ) { + const d = parseFloat(match[1].slice(0, -1)); + if ( isNaN(d) ) { return; } + seconds += d * secondsPerDay; + } + if ( match[2] ) { + const h = parseFloat(match[2].slice(0, -1)); + if ( isNaN(h) ) { return; } + seconds += h * secondsPerHour; + } + if ( match[3] ) { + const m = parseFloat(match[3].slice(0, -1)); + if ( isNaN(m) ) { return; } + seconds += m * secondsPerMinute; + } + if ( match[4] ) { + const s = parseFloat(match[4].slice(0, -1)); + if ( isNaN(s) ) { return; } + seconds += s; + } + return seconds; + }; + const ptFromSeconds = seconds => { + const parts = [ 'PT' ]; + const d = Math.floor(seconds / secondsPerDay); + if ( d ) { + parts.push(`${d}D`); + seconds -= d * secondsPerDay; + } + const h = Math.floor(seconds / secondsPerHour); + if ( h ) { + parts.push(`${h}H`); + seconds -= h * secondsPerHour; + } + const m = Math.floor(seconds / secondsPerMinute); + if ( m ) { + parts.push(`${m}M`); + seconds -= m * secondsPerMinute; + } + parts.push(`${seconds}S`); + return parts.join(''); + }; + const fixTimeAttributes = xmlDoc => { + try { + const periods = queryAll(xmlDoc, 'MPD > Period'); + if ( periods.length === 0 ) { return; } + let seconds = 0; + for ( const period of periods ) { + const startAttrBefore = period.getAttribute('start'); + const durAttr = period.getAttribute('duration'); + if ( startAttrBefore === null || durAttr === null ) { continue; } + const startAttrAfter = ptFromSeconds(seconds); + period.setAttribute('start', startAttrAfter); + if ( period.hasAttribute('id') ) { + const idAttr = period.getAttribute('id'); + period.setAttribute('id', idAttr.replace(startAttrBefore, startAttrAfter)); + } + seconds += secondsFromPT(durAttr); + } + const mpds = queryAll(xmlDoc, 'MPD[mediaPresentationDuration]'); + if ( mpds.length !== 1 ) { return; } + mpds[0].setAttribute('mediaPresentationDuration', ptFromSeconds(seconds)); + } catch { + } + }; + const pruneFromDoc = xmlDoc => { + try { + if ( selector === '' ) { + const serializer = new XMLSerializer(); + safe.uboLog(logPrefix, `Document is\n\t${serializer.serializeToString(xmlDoc)}`); + } + const items = queryAll(xmlDoc, selector); + if ( items.length === 0 ) { return xmlDoc; } + safe.uboLog(logPrefix, `Patching ${items.length} items`); + for ( const item of items ) { + if ( item.nodeType !== 1 ) { continue; } + item.setAttribute('duration', 'PT0S'); + } + fixTimeAttributes(xmlDoc); + } catch(ex) { + safe.uboErr(logPrefix, `Error: ${ex}`); + } + return xmlDoc; + }; + const pruneFromText = text => { + if ( (/^\s*\s*$/.test(text)) === false ) { + return text; + } + try { + const xmlParser = new DOMParser(); + const xmlDoc = xmlParser.parseFromString(text, 'text/xml'); + pruneFromDoc(xmlDoc); + const serializer = new XMLSerializer(); + text = serializer.serializeToString(xmlDoc); + } catch { + } + return text; + }; + modifyXhrResponseFn(propsToMatch, (xhr, before) => { + if ( before instanceof XMLDocument ) { + return pruneFromDoc(before); + } + if ( typeof before === 'string' ) { + return pruneFromText(before); + } + return before; + }); +} +registerScriptlet(mpegdashPrune, { + name: 'mpegdash-prune.js', + dependencies: [ + modifyXhrResponseFn, + safeSelf, + ], +}); diff --git a/privaxy/src/resources/vendor/ublock/scriptlets.js b/privaxy/src/resources/vendor/ublock/scriptlets.js index f214a9f8..5d3b579d 100644 --- a/privaxy/src/resources/vendor/ublock/scriptlets.js +++ b/privaxy/src/resources/vendor/ublock/scriptlets.js @@ -1,4802 +1,2 @@ -/******************************************************************************* - - 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 - - The scriptlets below are meant to be injected only into a - web page context. -*/ - -/* eslint no-prototype-builtins: 0 */ - -// Externally added to the private namespace in which scriptlets execute. -/* global scriptletGlobals */ - -export const builtinScriptlets = []; - -/******************************************************************************* - - Helper functions - - These are meant to be used as dependencies to injectable scriptlets. - -*******************************************************************************/ - -builtinScriptlets.push({ - name: 'safe-self.fn', - fn: safeSelf, -}); -function safeSelf() { - if ( scriptletGlobals.safeSelf ) { - return scriptletGlobals.safeSelf; - } - const self = globalThis; - const safe = { - 'Array_from': Array.from, - 'Error': self.Error, - 'Function_toStringFn': self.Function.prototype.toString, - 'Function_toString': thisArg => safe.Function_toStringFn.call(thisArg), - 'Math_floor': Math.floor, - 'Math_max': Math.max, - 'Math_min': Math.min, - 'Math_random': Math.random, - 'Object': Object, - 'Object_defineProperty': Object.defineProperty.bind(Object), - 'Object_fromEntries': Object.fromEntries.bind(Object), - 'Object_getOwnPropertyDescriptor': Object.getOwnPropertyDescriptor.bind(Object), - 'RegExp': self.RegExp, - 'RegExp_test': self.RegExp.prototype.test, - 'RegExp_exec': self.RegExp.prototype.exec, - 'Request_clone': self.Request.prototype.clone, - 'XMLHttpRequest': self.XMLHttpRequest, - 'addEventListener': self.EventTarget.prototype.addEventListener, - 'removeEventListener': self.EventTarget.prototype.removeEventListener, - 'fetch': self.fetch, - 'JSON': self.JSON, - 'JSON_parseFn': self.JSON.parse, - 'JSON_stringifyFn': self.JSON.stringify, - 'JSON_parse': (...args) => safe.JSON_parseFn.call(safe.JSON, ...args), - 'JSON_stringify': (...args) => safe.JSON_stringifyFn.call(safe.JSON, ...args), - 'log': console.log.bind(console), - // Properties - logLevel: 0, - // Methods - makeLogPrefix(...args) { - return this.sendToLogger && `[${args.join(' \u205D ')}]` || ''; - }, - uboLog(...args) { - if ( this.sendToLogger === undefined ) { return; } - if ( args === undefined || args[0] === '' ) { return; } - return this.sendToLogger('info', ...args); - - }, - uboErr(...args) { - if ( this.sendToLogger === undefined ) { return; } - if ( args === undefined || args[0] === '' ) { return; } - return this.sendToLogger('error', ...args); - }, - escapeRegexChars(s) { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - }, - initPattern(pattern, options = {}) { - if ( pattern === '' ) { - return { matchAll: true }; - } - const expect = (options.canNegate !== true || pattern.startsWith('!') === false); - if ( expect === false ) { - pattern = pattern.slice(1); - } - const match = /^\/(.+)\/([gimsu]*)$/.exec(pattern); - if ( match !== null ) { - return { - re: new this.RegExp( - match[1], - match[2] || options.flags - ), - expect, - }; - } - if ( options.flags !== undefined ) { - return { - re: new this.RegExp(this.escapeRegexChars(pattern), - options.flags - ), - expect, - }; - } - return { pattern, expect }; - }, - testPattern(details, haystack) { - if ( details.matchAll ) { return true; } - if ( details.re ) { - return this.RegExp_test.call(details.re, haystack) === details.expect; - } - return haystack.includes(details.pattern) === details.expect; - }, - patternToRegex(pattern, flags = undefined, verbatim = false) { - if ( pattern === '' ) { return /^/; } - const match = /^\/(.+)\/([gimsu]*)$/.exec(pattern); - if ( match === null ) { - const reStr = this.escapeRegexChars(pattern); - return new RegExp(verbatim ? `^${reStr}$` : reStr, flags); - } - try { - return new RegExp(match[1], match[2] || undefined); - } - catch(ex) { - } - return /^/; - }, - getExtraArgs(args, offset = 0) { - const entries = args.slice(offset).reduce((out, v, i, a) => { - if ( (i & 1) === 0 ) { - const rawValue = a[i+1]; - const value = /^\d+$/.test(rawValue) - ? parseInt(rawValue, 10) - : rawValue; - out.push([ a[i], value ]); - } - return out; - }, []); - return this.Object_fromEntries(entries); - }, - onIdle(fn, options) { - if ( self.requestIdleCallback ) { - return self.requestIdleCallback(fn, options); - } - return self.requestAnimationFrame(fn); - }, - }; - scriptletGlobals.safeSelf = safe; - if ( scriptletGlobals.bcSecret === undefined ) { return safe; } - // This is executed only when the logger is opened - const bc = new self.BroadcastChannel(scriptletGlobals.bcSecret); - let bcBuffer = []; - safe.logLevel = scriptletGlobals.logLevel || 1; - safe.sendToLogger = (type, ...args) => { - if ( args.length === 0 ) { return; } - const text = `[${document.location.hostname || document.location.href}]${args.join(' ')}`; - if ( bcBuffer === undefined ) { - return bc.postMessage({ what: 'messageToLogger', type, text }); - } - bcBuffer.push({ type, text }); - }; - bc.onmessage = ev => { - const msg = ev.data; - switch ( msg ) { - case 'iamready!': - if ( bcBuffer === undefined ) { break; } - bcBuffer.forEach(({ type, text }) => - bc.postMessage({ what: 'messageToLogger', type, text }) - ); - bcBuffer = undefined; - break; - case 'setScriptletLogLevelToOne': - safe.logLevel = 1; - break; - case 'setScriptletLogLevelToTwo': - safe.logLevel = 2; - break; - } - }; - bc.postMessage('areyouready?'); - return safe; -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'get-exception-token.fn', - fn: getExceptionToken, - dependencies: [ - 'safe-self.fn', - ], -}); -function getExceptionToken() { - const safe = safeSelf(); - const token = - String.fromCharCode(Date.now() % 26 + 97) + - safe.Math_floor(safe.Math_random() * 982451653 + 982451653).toString(36); - const oe = self.onerror; - self.onerror = function(msg, ...args) { - if ( typeof msg === 'string' && msg.includes(token) ) { return true; } - if ( oe instanceof Function ) { - return oe.call(this, msg, ...args); - } - }.bind(); - return token; -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'should-debug.fn', - fn: shouldDebug, -}); -function shouldDebug(details) { - if ( details instanceof Object === false ) { return false; } - return scriptletGlobals.canDebug && details.debug; -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'run-at.fn', - fn: runAt, - dependencies: [ - 'safe-self.fn', - ], -}); -function runAt(fn, when) { - const intFromReadyState = state => { - const targets = { - 'loading': 1, - 'interactive': 2, 'end': 2, '2': 2, - 'complete': 3, 'idle': 3, '3': 3, - }; - const tokens = Array.isArray(state) ? state : [ state ]; - for ( const token of tokens ) { - const prop = `${token}`; - if ( targets.hasOwnProperty(prop) === false ) { continue; } - return targets[prop]; - } - return 0; - }; - const runAt = intFromReadyState(when); - if ( intFromReadyState(document.readyState) >= runAt ) { - fn(); return; - } - const onStateChange = ( ) => { - if ( intFromReadyState(document.readyState) < runAt ) { return; } - fn(); - safe.removeEventListener.apply(document, args); - }; - const safe = safeSelf(); - const args = [ 'readystatechange', onStateChange, { capture: true } ]; - safe.addEventListener.apply(document, args); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'run-at-html-element.fn', - fn: runAtHtmlElementFn, -}); -function runAtHtmlElementFn(fn) { - if ( document.documentElement ) { - fn(); - return; - } - const observer = new MutationObserver(( ) => { - observer.disconnect(); - fn(); - }); - observer.observe(document, { childList: true }); -} - -/******************************************************************************/ - -// Reference: -// https://github.com/AdguardTeam/Scriptlets/blob/master/wiki/about-scriptlets.md#prevent-xhr - -builtinScriptlets.push({ - name: 'generate-content.fn', - fn: generateContentFn, - dependencies: [ - 'safe-self.fn', - ], -}); -function generateContentFn(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 Promise.resolve(randomize(10)); - } - if ( directive === 'emptyObj' ) { - return Promise.resolve('{}'); - } - if ( directive === 'emptyArr' ) { - return Promise.resolve('[]'); - } - if ( directive === 'emptyStr' ) { - return Promise.resolve(''); - } - if ( directive.startsWith('length:') ) { - const match = /^length:(\d+)(?:-(\d+))?$/.exec(directive); - if ( match ) { - 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 Promise.resolve(randomize(len | 0)); - } - } - if ( directive.startsWith('war:') && scriptletGlobals.warOrigin ) { - 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(); - }); - } - return Promise.resolve(''); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'abort-current-script-core.fn', - fn: abortCurrentScriptCore, - dependencies: [ - 'get-exception-token.fn', - 'safe-self.fn', - 'should-debug.fn', - ], -}); -// Issues to mind before changing anything: -// https://github.com/uBlockOrigin/uBlock-issues/issues/2154 -function abortCurrentScriptCore( - target = '', - needle = '', - context = '' -) { - if ( typeof target !== 'string' ) { return; } - if ( target === '' ) { return; } - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('abort-current-script', target, needle, context); - const reNeedle = safe.patternToRegex(needle); - const reContext = safe.patternToRegex(context); - const extraArgs = safe.getExtraArgs(Array.from(arguments), 3); - const thisScript = document.currentScript; - const chain = target.split('.'); - let owner = window; - let prop; - for (;;) { - prop = chain.shift(); - if ( chain.length === 0 ) { break; } - if ( prop in owner === false ) { break; } - owner = owner[prop]; - if ( owner instanceof Object === false ) { return; } - } - let value; - let desc = Object.getOwnPropertyDescriptor(owner, prop); - if ( - desc instanceof Object === false || - desc.get instanceof Function === false - ) { - value = owner[prop]; - desc = undefined; - } - const debug = shouldDebug(extraArgs); - const exceptionToken = getExceptionToken(); - const scriptTexts = new WeakMap(); - const getScriptText = elem => { - let text = elem.textContent; - if ( text.trim() !== '' ) { return text; } - if ( scriptTexts.has(elem) ) { return scriptTexts.get(elem); } - const [ , mime, content ] = - /^data:([^,]*),(.+)$/.exec(elem.src.trim()) || - [ '', '', '' ]; - try { - switch ( true ) { - case mime.endsWith(';base64'): - text = self.atob(content); - break; - default: - text = self.decodeURIComponent(content); - break; - } - } catch(ex) { - } - scriptTexts.set(elem, text); - return text; - }; - const validate = ( ) => { - const e = document.currentScript; - if ( e instanceof HTMLScriptElement === false ) { return; } - if ( e === thisScript ) { return; } - if ( context !== '' && reContext.test(e.src) === false ) { - // eslint-disable-next-line no-debugger - if ( debug === 'nomatch' || debug === 'all' ) { debugger; } - return; - } - if ( safe.logLevel > 1 && context !== '' ) { - safe.uboLog(logPrefix, `Matched src\n${e.src}`); - } - const scriptText = getScriptText(e); - if ( reNeedle.test(scriptText) === false ) { - // eslint-disable-next-line no-debugger - if ( debug === 'nomatch' || debug === 'all' ) { debugger; } - return; - } - if ( safe.logLevel > 1 ) { - safe.uboLog(logPrefix, `Matched text\n${scriptText}`); - } - // eslint-disable-next-line no-debugger - if ( debug === 'match' || debug === 'all' ) { debugger; } - safe.uboLog(logPrefix, 'Aborted'); - throw new ReferenceError(exceptionToken); - }; - // eslint-disable-next-line no-debugger - if ( debug === 'install' ) { debugger; } - try { - Object.defineProperty(owner, prop, { - get: function() { - validate(); - return desc instanceof Object - ? desc.get.call(owner) - : value; - }, - set: function(a) { - validate(); - if ( desc instanceof Object ) { - desc.set.call(owner, a); - } else { - value = a; - } - } - }); - } catch(ex) { - safe.uboErr(logPrefix, `Error: ${ex}`); - } -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'validate-constant.fn', - fn: validateConstantFn, - dependencies: [ - 'safe-self.fn', - ], -}); -function validateConstantFn(trusted, raw) { - const safe = safeSelf(); - const extraArgs = safe.getExtraArgs(Array.from(arguments), 2); - let value; - if ( raw === 'undefined' ) { - value = undefined; - } else if ( raw === 'false' ) { - value = false; - } else if ( raw === 'true' ) { - value = true; - } else if ( raw === 'null' ) { - value = null; - } else if ( raw === "''" || raw === '' ) { - value = ''; - } else if ( raw === '[]' || raw === 'emptyArr' ) { - value = []; - } else if ( raw === '{}' || raw === 'emptyObj' ) { - value = {}; - } else if ( raw === 'noopFunc' ) { - value = function(){}; - } else if ( raw === 'trueFunc' ) { - value = function(){ return true; }; - } else if ( raw === 'falseFunc' ) { - value = function(){ return false; }; - } else if ( /^-?\d+$/.test(raw) ) { - value = parseInt(raw); - if ( isNaN(raw) ) { return; } - if ( Math.abs(raw) > 0x7FFF ) { return; } - } else if ( trusted ) { - if ( raw.startsWith('{') && raw.endsWith('}') ) { - try { value = safe.JSON_parse(raw).value; } catch(ex) { return; } - } - } else { - return; - } - if ( extraArgs.as !== undefined ) { - if ( extraArgs.as === 'function' ) { - return ( ) => value; - } else if ( extraArgs.as === 'callback' ) { - return ( ) => (( ) => value); - } else if ( extraArgs.as === 'resolved' ) { - return Promise.resolve(value); - } else if ( extraArgs.as === 'rejected' ) { - return Promise.reject(value); - } - } - return value; -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'set-constant.fn', - fn: setConstantFn, - dependencies: [ - 'run-at.fn', - 'safe-self.fn', - 'validate-constant.fn', - ], -}); -function setConstantFn( - trusted = false, - chain = '', - rawValue = '' -) { - if ( chain === '' ) { return; } - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('set-constant', chain, rawValue); - const extraArgs = safe.getExtraArgs(Array.from(arguments), 3); - function setConstant(chain, rawValue) { - const trappedProp = (( ) => { - const pos = chain.lastIndexOf('.'); - if ( pos === -1 ) { return chain; } - return chain.slice(pos+1); - })(); - const cloakFunc = fn => { - safe.Object_defineProperty(fn, 'name', { value: trappedProp }); - return new Proxy(fn, { - defineProperty(target, prop) { - if ( prop !== 'toString' ) { - return Reflect.defineProperty(...arguments); - } - return true; - }, - deleteProperty(target, prop) { - if ( prop !== 'toString' ) { - return Reflect.deleteProperty(...arguments); - } - return true; - }, - get(target, prop) { - if ( prop === 'toString' ) { - return function() { - return `function ${trappedProp}() { [native code] }`; - }.bind(null); - } - return Reflect.get(...arguments); - }, - }); - }; - if ( trappedProp === '' ) { return; } - const thisScript = document.currentScript; - let normalValue = validateConstantFn(trusted, rawValue); - if ( rawValue === 'noopFunc' || rawValue === 'trueFunc' || rawValue === 'falseFunc' ) { - normalValue = cloakFunc(normalValue); - } - let aborted = false; - const mustAbort = function(v) { - if ( trusted ) { return false; } - if ( aborted ) { return true; } - aborted = - (v !== undefined && v !== null) && - (normalValue !== undefined && normalValue !== null) && - (typeof v !== typeof normalValue); - if ( aborted ) { - safe.uboLog(logPrefix, `Aborted because value set to ${v}`); - } - return aborted; - }; - // https://github.com/uBlockOrigin/uBlock-issues/issues/156 - // Support multiple trappers for the same property. - const trapProp = function(owner, prop, configurable, handler) { - if ( handler.init(configurable ? owner[prop] : normalValue) === false ) { return; } - const odesc = safe.Object_getOwnPropertyDescriptor(owner, prop); - let prevGetter, prevSetter; - if ( odesc instanceof safe.Object ) { - owner[prop] = normalValue; - if ( odesc.get instanceof Function ) { - prevGetter = odesc.get; - } - if ( odesc.set instanceof Function ) { - prevSetter = odesc.set; - } - } - try { - safe.Object_defineProperty(owner, prop, { - configurable, - get() { - if ( prevGetter !== undefined ) { - prevGetter(); - } - return handler.getter(); - }, - set(a) { - if ( prevSetter !== undefined ) { - prevSetter(a); - } - handler.setter(a); - } - }); - safe.uboLog(logPrefix, 'Trap installed'); - } catch(ex) { - safe.uboErr(logPrefix, ex); - } - }; - const trapChain = function(owner, chain) { - const pos = chain.indexOf('.'); - if ( pos === -1 ) { - trapProp(owner, chain, false, { - v: undefined, - init: function(v) { - if ( mustAbort(v) ) { return false; } - this.v = v; - return true; - }, - getter: function() { - if ( document.currentScript === thisScript ) { - return this.v; - } - safe.uboLog(logPrefix, 'Property read'); - return normalValue; - }, - setter: function(a) { - if ( mustAbort(a) === false ) { return; } - normalValue = a; - } - }); - return; - } - const prop = chain.slice(0, pos); - const v = owner[prop]; - chain = chain.slice(pos + 1); - if ( v instanceof safe.Object || typeof v === 'object' && v !== null ) { - trapChain(v, chain); - return; - } - trapProp(owner, prop, true, { - v: undefined, - init: function(v) { - this.v = v; - return true; - }, - getter: function() { - return this.v; - }, - setter: function(a) { - this.v = a; - if ( a instanceof safe.Object ) { - trapChain(a, chain); - } - } - }); - }; - trapChain(window, chain); - } - runAt(( ) => { - setConstant(chain, rawValue); - }, extraArgs.runAt); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'replace-node-text.fn', - fn: replaceNodeTextFn, - dependencies: [ - 'run-at.fn', - 'safe-self.fn', - ], -}); -function replaceNodeTextFn( - nodeName = '', - pattern = '', - replacement = '' -) { - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('replace-node-text.fn', ...Array.from(arguments)); - const reNodeName = safe.patternToRegex(nodeName, 'i', true); - const rePattern = safe.patternToRegex(pattern, 'gms'); - const extraArgs = safe.getExtraArgs(Array.from(arguments), 3); - const reCondition = safe.patternToRegex(extraArgs.condition || '', 'ms'); - const stop = (takeRecord = true) => { - if ( takeRecord ) { - handleMutations(observer.takeRecords()); - } - observer.disconnect(); - if ( safe.logLevel > 1 ) { - safe.uboLog(logPrefix, 'Quitting'); - } - }; - let sedCount = extraArgs.sedCount || 0; - const handleNode = node => { - const before = node.textContent; - reCondition.lastIndex = 0; - if ( safe.RegExp_test.call(reCondition, before) === false ) { return true; } - rePattern.lastIndex = 0; - if ( safe.RegExp_test.call(rePattern, before) === false ) { return true; } - rePattern.lastIndex = 0; - const after = pattern !== '' - ? before.replace(rePattern, replacement) - : replacement; - node.textContent = after; - if ( safe.logLevel > 1 ) { - safe.uboLog(logPrefix, `Text before:\n${before.trim()}`); - } - safe.uboLog(logPrefix, `Text after:\n${after.trim()}`); - return sedCount === 0 || (sedCount -= 1) !== 0; - }; - const handleMutations = mutations => { - for ( const mutation of mutations ) { - for ( const node of mutation.addedNodes ) { - if ( reNodeName.test(node.nodeName) === false ) { continue; } - if ( handleNode(node) ) { continue; } - stop(false); return; - } - } - }; - const observer = new MutationObserver(handleMutations); - observer.observe(document, { childList: true, subtree: true }); - if ( document.documentElement ) { - const treeWalker = document.createTreeWalker( - document.documentElement, - NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT - ); - let count = 0; - for (;;) { - const node = treeWalker.nextNode(); - count += 1; - if ( node === null ) { break; } - if ( reNodeName.test(node.nodeName) === false ) { continue; } - if ( handleNode(node) ) { continue; } - stop(); break; - } - safe.uboLog(logPrefix, `${count} nodes present before installing mutation observer`); - } - if ( extraArgs.stay ) { return; } - runAt(( ) => { - const quitAfter = extraArgs.quitAfter || 0; - if ( quitAfter !== 0 ) { - setTimeout(( ) => { stop(); }, quitAfter); - } else { - stop(); - } - }, 'interactive'); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'object-prune.fn', - fn: objectPruneFn, - dependencies: [ - 'matches-stack-trace.fn', - 'object-find-owner.fn', - ], -}); -// When no "prune paths" argument is provided, the scriptlet is -// used for logging purpose and the "needle paths" argument is -// used to filter logging output. -// -// https://github.com/uBlockOrigin/uBlock-issues/issues/1545 -// - Add support for "remove everything if needle matches" case -function objectPruneFn( - obj, - rawPrunePaths, - rawNeedlePaths, - stackNeedleDetails = { matchAll: true }, - extraArgs = {} -) { - if ( typeof rawPrunePaths !== 'string' ) { return; } - const prunePaths = rawPrunePaths !== '' - ? rawPrunePaths.split(/ +/) - : []; - const needlePaths = prunePaths.length !== 0 && rawNeedlePaths !== '' - ? rawNeedlePaths.split(/ +/) - : []; - if ( stackNeedleDetails.matchAll !== true ) { - if ( matchesStackTrace(stackNeedleDetails, extraArgs.logstack) === false ) { - return; - } - } - if ( objectPruneFn.mustProcess === undefined ) { - objectPruneFn.mustProcess = (root, needlePaths) => { - for ( const needlePath of needlePaths ) { - if ( objectFindOwnerFn(root, needlePath) === false ) { - return false; - } - } - return true; - }; - } - if ( prunePaths.length === 0 ) { return; } - let outcome = 'nomatch'; - if ( objectPruneFn.mustProcess(obj, needlePaths) ) { - for ( const path of prunePaths ) { - if ( objectFindOwnerFn(obj, path, true) ) { - outcome = 'match'; - } - } - } - if ( outcome === 'match' ) { return obj; } -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'object-find-owner.fn', - fn: objectFindOwnerFn, -}); -function objectFindOwnerFn( - root, - path, - prune = false -) { - let owner = root; - let chain = path; - for (;;) { - if ( typeof owner !== 'object' || owner === null ) { return false; } - const pos = chain.indexOf('.'); - if ( pos === -1 ) { - if ( prune === false ) { - return owner.hasOwnProperty(chain); - } - let modified = false; - if ( chain === '*' ) { - for ( const key in owner ) { - if ( owner.hasOwnProperty(key) === false ) { continue; } - delete owner[key]; - modified = true; - } - } else if ( owner.hasOwnProperty(chain) ) { - delete owner[chain]; - modified = true; - } - return modified; - } - const prop = chain.slice(0, pos); - const next = chain.slice(pos + 1); - let found = false; - if ( prop === '[-]' && Array.isArray(owner) ) { - let i = owner.length; - while ( i-- ) { - if ( objectFindOwnerFn(owner[i], next) === false ) { continue; } - owner.splice(i, 1); - found = true; - } - return found; - } - if ( prop === '{-}' && owner instanceof Object ) { - for ( const key of Object.keys(owner) ) { - if ( objectFindOwnerFn(owner[key], next) === false ) { continue; } - delete owner[key]; - found = true; - } - return found; - } - if ( - prop === '[]' && Array.isArray(owner) || - prop === '{}' && owner instanceof Object || - prop === '*' && owner instanceof Object - ) { - for ( const key of Object.keys(owner) ) { - if (objectFindOwnerFn(owner[key], next, prune) === false ) { continue; } - found = true; - } - return found; - } - if ( owner.hasOwnProperty(prop) === false ) { return false; } - owner = owner[prop]; - chain = chain.slice(pos + 1); - } -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'get-all-cookies.fn', - fn: getAllCookiesFn, -}); -function getAllCookiesFn() { - return document.cookie.split(/\s*;\s*/).map(s => { - const pos = s.indexOf('='); - if ( pos === 0 ) { return; } - if ( pos === -1 ) { return `${s.trim()}=`; } - const key = s.slice(0, pos).trim(); - const value = s.slice(pos+1).trim(); - return { key, value }; - }).filter(s => s !== undefined); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'get-all-local-storage.fn', - fn: getAllLocalStorageFn, -}); -function getAllLocalStorageFn(which = 'localStorage') { - const storage = self[which]; - const out = []; - for ( let i = 0; i < storage.length; i++ ) { - const key = storage.key(i); - const value = storage.getItem(key); - return { key, value }; - } - return out; -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'get-cookie.fn', - fn: getCookieFn, -}); -function getCookieFn( - name = '' -) { - for ( const s of document.cookie.split(/\s*;\s*/) ) { - const pos = s.indexOf('='); - if ( pos === -1 ) { continue; } - if ( s.slice(0, pos) !== name ) { continue; } - return s.slice(pos+1).trim(); - } -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'set-cookie.fn', - fn: setCookieFn, - dependencies: [ - 'get-cookie.fn', - ], -}); -function setCookieFn( - trusted = false, - name = '', - value = '', - expires = '', - path = '', - options = {}, -) { - // https://datatracker.ietf.org/doc/html/rfc2616#section-2.2 - // https://github.com/uBlockOrigin/uBlock-issues/issues/2777 - if ( trusted === false && /[^!#$%&'*+\-.0-9A-Z[\]^_`a-z|~]/.test(name) ) { - name = encodeURIComponent(name); - } - // https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1 - // The characters [",] are given a pass from the RFC requirements because - // apparently browsers do not follow the RFC to the letter. - if ( /[^ -:<-[\]-~]/.test(value) ) { - value = encodeURIComponent(value); - } - - const cookieBefore = getCookieFn(name); - if ( cookieBefore !== undefined && options.dontOverwrite ) { return; } - if ( cookieBefore === value && options.reload ) { return; } - - const cookieParts = [ name, '=', value ]; - if ( expires !== '' ) { - cookieParts.push('; expires=', expires); - } - - if ( path === '' ) { path = '/'; } - else if ( path === 'none' ) { path = ''; } - if ( path !== '' && path !== '/' ) { return; } - if ( path === '/' ) { - cookieParts.push('; path=/'); - } - - if ( trusted ) { - if ( options.domain ) { - cookieParts.push(`; domain=${options.domain}`); - } - cookieParts.push('; Secure'); - } - - try { - document.cookie = cookieParts.join(''); - } catch(_) { - } - - const done = getCookieFn(name) === value; - if ( done && options.reload ) { - window.location.reload(); - } - - return done; -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'set-local-storage-item.fn', - fn: setLocalStorageItemFn, - dependencies: [ - 'safe-self.fn', - ], -}); -function setLocalStorageItemFn( - which = 'local', - trusted = false, - key = '', - value = '', -) { - if ( key === '' ) { return; } - - // For increased compatibility with AdGuard - if ( value === 'emptyArr' ) { - value = '[]'; - } else if ( value === 'emptyObj' ) { - value = '{}'; - } - - const trustedValues = [ - '', - 'undefined', 'null', - 'false', 'true', - 'on', 'off', - 'yes', 'no', - 'accept', 'reject', - 'accepted', 'rejected', - '{}', '[]', '""', - '$remove$', - ]; - - if ( trusted ) { - if ( value.includes('$now$') ) { - value = value.replaceAll('$now$', Date.now()); - } - if ( value.includes('$currentDate$') ) { - value = value.replaceAll('$currentDate$', `${Date()}`); - } - if ( value.includes('$currentISODate$') ) { - value = value.replaceAll('$currentISODate$', (new Date()).toISOString()); - } - } else { - const normalized = value.toLowerCase(); - const match = /^("?)(.+)\1$/.exec(normalized); - const unquoted = match && match[2] || normalized; - if ( trustedValues.includes(unquoted) === false ) { - if ( /^\d+$/.test(unquoted) === false ) { return; } - const n = parseInt(unquoted, 10); - if ( n > 32767 ) { return; } - } - } - - try { - const storage = self[`${which}Storage`]; - if ( value === '$remove$' ) { - const safe = safeSelf(); - const pattern = safe.patternToRegex(key, undefined, true ); - const toRemove = []; - for ( let i = 0, n = storage.length; i < n; i++ ) { - const key = storage.key(i); - if ( pattern.test(key) ) { toRemove.push(key); } - } - for ( const key of toRemove ) { - storage.removeItem(key); - } - } else { - storage.setItem(key, `${value}`); - } - } catch(ex) { - } -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'matches-stack-trace.fn', - fn: matchesStackTrace, - dependencies: [ - 'get-exception-token.fn', - 'safe-self.fn', - ], -}); -function matchesStackTrace( - needleDetails, - logLevel = '' -) { - const safe = safeSelf(); - const exceptionToken = getExceptionToken(); - const error = new safe.Error(exceptionToken); - const docURL = new URL(self.location.href); - docURL.hash = ''; - // Normalize stack trace - const reLine = /(.*?@)?(\S+)(:\d+):\d+\)?$/; - const lines = []; - for ( let line of error.stack.split(/[\n\r]+/) ) { - if ( line.includes(exceptionToken) ) { continue; } - line = line.trim(); - const match = safe.RegExp_exec.call(reLine, line); - if ( match === null ) { continue; } - let url = match[2]; - if ( url.startsWith('(') ) { url = url.slice(1); } - if ( url === docURL.href ) { - url = 'inlineScript'; - } else if ( url.startsWith('') ) { - url = 'injectedScript'; - } - let fn = match[1] !== undefined - ? match[1].slice(0, -1) - : line.slice(0, match.index).trim(); - if ( fn.startsWith('at') ) { fn = fn.slice(2).trim(); } - let rowcol = match[3]; - lines.push(' ' + `${fn} ${url}${rowcol}:1`.trim()); - } - lines[0] = `stackDepth:${lines.length-1}`; - const stack = lines.join('\t'); - const r = needleDetails.matchAll !== true && - safe.testPattern(needleDetails, stack); - if ( - logLevel === 'all' || - logLevel === 'match' && r || - logLevel === 'nomatch' && !r - ) { - safe.uboLog(stack.replace(/\t/g, '\n')); - } - return r; -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'parse-properties-to-match.fn', - fn: parsePropertiesToMatch, - dependencies: [ - 'safe-self.fn', - ], -}); -function parsePropertiesToMatch(propsToMatch, implicit = '') { - const safe = safeSelf(); - const needles = new Map(); - if ( propsToMatch === undefined || propsToMatch === '' ) { return needles; } - const options = { canNegate: true }; - for ( const needle of propsToMatch.split(/\s+/) ) { - const [ prop, pattern ] = needle.split(':'); - if ( prop === '' ) { continue; } - if ( pattern !== undefined ) { - needles.set(prop, safe.initPattern(pattern, options)); - } else if ( implicit !== '' ) { - needles.set(implicit, safe.initPattern(prop, options)); - } - } - return needles; -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'match-object-properties.fn', - fn: matchObjectProperties, - dependencies: [ - 'safe-self.fn', - ], -}); -function matchObjectProperties(propNeedles, ...objs) { - if ( matchObjectProperties.extractProperties === undefined ) { - matchObjectProperties.extractProperties = (src, des, props) => { - for ( const p of props ) { - const v = src[p]; - if ( v === undefined ) { continue; } - des[p] = src[p]; - } - }; - } - const safe = safeSelf(); - const haystack = {}; - const props = safe.Array_from(propNeedles.keys()); - for ( const obj of objs ) { - if ( obj instanceof Object === false ) { continue; } - matchObjectProperties.extractProperties(obj, haystack, props); - } - for ( const [ prop, details ] of propNeedles ) { - let value = haystack[prop]; - if ( value === undefined ) { continue; } - if ( typeof value !== 'string' ) { - try { value = safe.JSON_stringify(value); } - catch(ex) { } - if ( typeof value !== 'string' ) { continue; } - } - if ( safe.testPattern(details, value) ) { continue; } - return false; - } - return true; -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'json-prune-fetch-response.fn', - fn: jsonPruneFetchResponseFn, - dependencies: [ - 'match-object-properties.fn', - 'object-prune.fn', - 'parse-properties-to-match.fn', - 'safe-self.fn', - ], -}); -function jsonPruneFetchResponseFn( - rawPrunePaths = '', - rawNeedlePaths = '' -) { - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('json-prune-fetch-response', rawPrunePaths, rawNeedlePaths); - const extraArgs = safe.getExtraArgs(Array.from(arguments), 2); - const propNeedles = parsePropertiesToMatch(extraArgs.propsToMatch, 'url'); - const stackNeedle = safe.initPattern(extraArgs.stackToMatch || '', { canNegate: true }); - const logall = rawPrunePaths === ''; - const applyHandler = function(target, thisArg, args) { - const fetchPromise = Reflect.apply(target, thisArg, args); - let outcome = logall ? 'nomatch' : 'match'; - 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]); - } - if ( matchObjectProperties(propNeedles, ...objs) === false ) { - outcome = 'nomatch'; - } - } - if ( logall === false && outcome === 'nomatch' ) { return fetchPromise; } - if ( safe.logLevel > 1 && outcome !== 'nomatch' && propNeedles.size !== 0 ) { - safe.uboLog(logPrefix, `Matched optional "propsToMatch"\n${extraArgs.propsToMatch}`); - } - return fetchPromise.then(responseBefore => { - const response = responseBefore.clone(); - return response.json().then(objBefore => { - if ( typeof objBefore !== 'object' ) { return responseBefore; } - if ( logall ) { - safe.uboLog(logPrefix, safe.JSON_stringify(objBefore, null, 2)); - return responseBefore; - } - const objAfter = objectPruneFn( - objBefore, - rawPrunePaths, - rawNeedlePaths, - stackNeedle, - extraArgs - ); - if ( typeof objAfter !== 'object' ) { return responseBefore; } - safe.uboLog(logPrefix, 'Pruned'); - const responseAfter = Response.json(objAfter, { - 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 - }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'replace-fetch-response.fn', - fn: replaceFetchResponseFn, - dependencies: [ - 'match-object-properties.fn', - 'parse-properties-to-match.fn', - 'safe-self.fn', - ], -}); -function replaceFetchResponseFn( - trusted = false, - pattern = '', - replacement = '', - propsToMatch = '' -) { - if ( trusted !== true ) { return; } - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('replace-fetch-response', pattern, replacement, propsToMatch); - if ( pattern === '*' ) { pattern = '.*'; } - const rePattern = safe.patternToRegex(pattern); - const propNeedles = parsePropertiesToMatch(propsToMatch, 'url'); - self.fetch = new Proxy(self.fetch, { - apply: function(target, thisArg, args) { - const fetchPromise = Reflect.apply(target, thisArg, args); - if ( pattern === '' ) { return fetchPromise; } - let outcome = 'match'; - 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, ex); - } - } - if ( args[1] instanceof Object ) { - objs.push(args[1]); - } - if ( matchObjectProperties(propNeedles, ...objs) === false ) { - outcome = 'nomatch'; - } - } - if ( outcome === 'nomatch' ) { return fetchPromise; } - if ( safe.logLevel > 1 ) { - safe.uboLog(logPrefix, `Matched "propsToMatch"\n${propsToMatch}`); - } - return fetchPromise.then(responseBefore => { - const response = responseBefore.clone(); - return response.text().then(textBefore => { - const textAfter = textBefore.replace(rePattern, replacement); - const outcome = textAfter !== textBefore ? 'match' : 'nomatch'; - if ( outcome === 'nomatch' ) { return responseBefore; } - safe.uboLog(logPrefix, 'Replaced'); - 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, reason); - return responseBefore; - }); - }).catch(reason => { - safe.uboErr(logPrefix, reason); - return fetchPromise; - }); - } - }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'proxy-apply.fn', - fn: proxyApplyFn, - dependencies: [ - 'safe-self.fn', - ], -}); -function proxyApplyFn( - target = '', - handler = '' -) { - let context = globalThis; - let prop = target; - for (;;) { - const pos = prop.indexOf('.'); - if ( pos === -1 ) { break; } - context = context[prop.slice(0, pos)]; - if ( context instanceof Object === false ) { return; } - prop = prop.slice(pos+1); - } - const fn = context[prop]; - if ( typeof fn !== 'function' ) { return; } - if ( fn.prototype && fn.prototype.constructor === fn ) { - context[prop] = new Proxy(fn, { construct: handler }); - return (...args) => { return Reflect.construct(...args); }; - } - context[prop] = new Proxy(fn, { apply: handler }); - return (...args) => { return Reflect.apply(...args); }; -} - -/******************************************************************************* - - Injectable scriptlets - - These are meant to be used in the MAIN (webpage) execution world. - -*******************************************************************************/ - -builtinScriptlets.push({ - name: 'abort-current-script.js', - aliases: [ - 'acs.js', - 'abort-current-inline-script.js', - 'acis.js', - ], - fn: abortCurrentScript, - dependencies: [ - 'abort-current-script-core.fn', - 'run-at-html-element.fn', - ], -}); -// Issues to mind before changing anything: -// https://github.com/uBlockOrigin/uBlock-issues/issues/2154 -function abortCurrentScript(...args) { - runAtHtmlElementFn(( ) => { - abortCurrentScriptCore(...args); - }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'abort-on-property-read.js', - aliases: [ - 'aopr.js', - ], - fn: abortOnPropertyRead, - dependencies: [ - 'get-exception-token.fn', - 'safe-self.fn', - ], -}); -function abortOnPropertyRead( - chain = '' -) { - if ( typeof chain !== 'string' ) { return; } - if ( chain === '' ) { return; } - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('abort-on-property-read', chain); - const exceptionToken = getExceptionToken(); - const abort = function() { - safe.uboLog(logPrefix, 'Aborted'); - throw new ReferenceError(exceptionToken); - }; - const makeProxy = function(owner, chain) { - const pos = chain.indexOf('.'); - if ( pos === -1 ) { - const desc = Object.getOwnPropertyDescriptor(owner, chain); - if ( !desc || desc.get !== abort ) { - Object.defineProperty(owner, chain, { - get: abort, - set: function(){} - }); - } - return; - } - const prop = chain.slice(0, pos); - let v = owner[prop]; - chain = chain.slice(pos + 1); - if ( v ) { - makeProxy(v, chain); - return; - } - const desc = Object.getOwnPropertyDescriptor(owner, prop); - if ( desc && desc.set !== undefined ) { return; } - Object.defineProperty(owner, prop, { - get: function() { return v; }, - set: function(a) { - v = a; - if ( a instanceof Object ) { - makeProxy(a, chain); - } - } - }); - }; - const owner = window; - makeProxy(owner, chain); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'abort-on-property-write.js', - aliases: [ - 'aopw.js', - ], - fn: abortOnPropertyWrite, - dependencies: [ - 'get-exception-token.fn', - 'safe-self.fn', - ], -}); -function abortOnPropertyWrite( - prop = '' -) { - if ( typeof prop !== 'string' ) { return; } - if ( prop === '' ) { return; } - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('abort-on-property-write', prop); - const exceptionToken = getExceptionToken(); - let owner = window; - for (;;) { - const pos = prop.indexOf('.'); - if ( pos === -1 ) { break; } - owner = owner[prop.slice(0, pos)]; - if ( owner instanceof Object === false ) { return; } - prop = prop.slice(pos + 1); - } - delete owner[prop]; - Object.defineProperty(owner, prop, { - set: function() { - safe.uboLog(logPrefix, 'Aborted'); - throw new ReferenceError(exceptionToken); - } - }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'abort-on-stack-trace.js', - aliases: [ - 'aost.js', - ], - fn: abortOnStackTrace, - dependencies: [ - 'get-exception-token.fn', - 'matches-stack-trace.fn', - 'safe-self.fn', - ], -}); -function abortOnStackTrace( - chain = '', - needle = '' -) { - if ( typeof chain !== 'string' ) { return; } - const safe = safeSelf(); - const needleDetails = safe.initPattern(needle, { canNegate: true }); - const extraArgs = safe.getExtraArgs(Array.from(arguments), 2); - if ( needle === '' ) { extraArgs.log = 'all'; } - const makeProxy = function(owner, chain) { - const pos = chain.indexOf('.'); - if ( pos === -1 ) { - let v = owner[chain]; - Object.defineProperty(owner, chain, { - get: function() { - if ( matchesStackTrace(needleDetails, extraArgs.log) ) { - throw new ReferenceError(getExceptionToken()); - } - return v; - }, - set: function(a) { - if ( matchesStackTrace(needleDetails, extraArgs.log) ) { - throw new ReferenceError(getExceptionToken()); - } - v = a; - }, - }); - return; - } - const prop = chain.slice(0, pos); - let v = owner[prop]; - chain = chain.slice(pos + 1); - if ( v ) { - makeProxy(v, chain); - return; - } - const desc = Object.getOwnPropertyDescriptor(owner, prop); - if ( desc && desc.set !== undefined ) { return; } - Object.defineProperty(owner, prop, { - get: function() { return v; }, - set: function(a) { - v = a; - if ( a instanceof Object ) { - makeProxy(a, chain); - } - } - }); - }; - const owner = window; - makeProxy(owner, chain); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'addEventListener-defuser.js', - aliases: [ - 'aeld.js', - 'prevent-addEventListener.js', - ], - fn: addEventListenerDefuser, - dependencies: [ - 'run-at.fn', - 'safe-self.fn', - 'should-debug.fn', - ], -}); -// https://github.com/uBlockOrigin/uAssets/issues/9123#issuecomment-848255120 -function addEventListenerDefuser( - type = '', - pattern = '' -) { - const safe = safeSelf(); - const extraArgs = safe.getExtraArgs(Array.from(arguments), 2); - const logPrefix = safe.makeLogPrefix('prevent-addEventListener', type, pattern); - const reType = safe.patternToRegex(type, undefined, true); - const rePattern = safe.patternToRegex(pattern); - const debug = shouldDebug(extraArgs); - const targetSelector = extraArgs.elements || undefined; - const elementMatches = elem => { - if ( elem && elem.matches && elem.matches(targetSelector) ) { return true; } - const elems = Array.from(document.querySelectorAll(targetSelector)); - return elems.includes(elem); - }; - const elementDetails = elem => { - if ( elem instanceof Window ) { return 'window'; } - if ( elem instanceof Document ) { return 'document'; } - if ( elem instanceof Element === false ) { return '?'; } - const parts = []; - if ( elem.id !== '' ) { parts.push(`#${CSS.escape(elem.id)}`); } - for ( let i = 0; i < elem.classList.length; i++ ) { - parts.push(`.${CSS.escape(elem.classList.item(i))}`); - } - for ( let i = 0; i < elem.attributes.length; i++ ) { - const attr = elem.attributes.item(i); - if ( attr.name === 'id' ) { continue; } - if ( attr.name === 'class' ) { continue; } - parts.push(`[${CSS.escape(attr.name)}="${attr.value}"]`); - } - return parts.join(''); - }; - const shouldPrevent = (thisArg, type, handler) => { - const matchesType = safe.RegExp_test.call(reType, type); - const matchesHandler = safe.RegExp_test.call(rePattern, handler); - const matchesEither = matchesType || matchesHandler; - const matchesBoth = matchesType && matchesHandler; - if ( debug === 1 && matchesBoth || debug === 2 && matchesEither ) { - debugger; // eslint-disable-line no-debugger - } - if ( matchesBoth && targetSelector !== undefined ) { - if ( elementMatches(thisArg) === false ) { return false; } - } - return matchesBoth; - }; - const trapEddEventListeners = ( ) => { - const eventListenerHandler = { - apply: function(target, thisArg, args) { - let t, h; - try { - t = String(args[0]); - if ( typeof args[1] === 'function' ) { - h = String(safe.Function_toString(args[1])); - } else if ( typeof args[1] === 'object' && args[1] !== null ) { - if ( typeof args[1].handleEvent === 'function' ) { - h = String(safe.Function_toString(args[1].handleEvent)); - } - } else { - h = String(args[1]); - } - } catch(ex) { - } - if ( type === '' && pattern === '' ) { - safe.uboLog(logPrefix, `Called: ${t}\n${h}\n${elementDetails(thisArg)}`); - } else if ( shouldPrevent(thisArg, t, h) ) { - return safe.uboLog(logPrefix, `Prevented: ${t}\n${h}\n${elementDetails(thisArg)}`); - } - return Reflect.apply(target, thisArg, args); - }, - get(target, prop, receiver) { - if ( prop === 'toString' ) { - return target.toString.bind(target); - } - return Reflect.get(target, prop, receiver); - }, - }; - self.EventTarget.prototype.addEventListener = new Proxy( - self.EventTarget.prototype.addEventListener, - eventListenerHandler - ); - }; - runAt(( ) => { - trapEddEventListeners(); - }, extraArgs.runAt); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'json-prune.js', - fn: jsonPrune, - dependencies: [ - 'object-prune.fn', - 'safe-self.fn', - ], -}); -function jsonPrune( - rawPrunePaths = '', - rawNeedlePaths = '', - stackNeedle = '' -) { - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('json-prune', rawPrunePaths, rawNeedlePaths, stackNeedle); - const stackNeedleDetails = safe.initPattern(stackNeedle, { canNegate: true }); - const extraArgs = safe.getExtraArgs(Array.from(arguments), 3); - JSON.parse = new Proxy(JSON.parse, { - apply: function(target, thisArg, args) { - const objBefore = Reflect.apply(target, thisArg, args); - if ( rawPrunePaths === '' ) { - safe.uboLog(logPrefix, safe.JSON_stringify(objBefore, null, 2)); - } - const objAfter = objectPruneFn( - objBefore, - rawPrunePaths, - rawNeedlePaths, - stackNeedleDetails, - extraArgs - ); - if ( objAfter === undefined ) { return objBefore; } - safe.uboLog(logPrefix, 'Pruned'); - if ( safe.logLevel > 1 ) { - safe.uboLog(logPrefix, `After pruning:\n${safe.JSON_stringify(objAfter, null, 2)}`); - } - return objAfter; - }, - }); -} - -/******************************************************************************* - * - * json-prune-fetch-response.js - * - * Prune JSON response of fetch requests. - * - **/ - -builtinScriptlets.push({ - name: 'json-prune-fetch-response.js', - fn: jsonPruneFetchResponse, - dependencies: [ - 'json-prune-fetch-response.fn', - ], -}); -function jsonPruneFetchResponse(...args) { - jsonPruneFetchResponseFn(...args); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'json-prune-xhr-response.js', - fn: jsonPruneXhrResponse, - dependencies: [ - 'match-object-properties.fn', - 'object-prune.fn', - 'parse-properties-to-match.fn', - 'safe-self.fn', - ], -}); -function jsonPruneXhrResponse( - rawPrunePaths = '', - rawNeedlePaths = '' -) { - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('json-prune-xhr-response', rawPrunePaths, rawNeedlePaths); - const xhrInstances = new WeakMap(); - const extraArgs = safe.getExtraArgs(Array.from(arguments), 2); - const propNeedles = parsePropertiesToMatch(extraArgs.propsToMatch, 'url'); - const stackNeedle = safe.initPattern(extraArgs.stackToMatch || '', { canNegate: true }); - self.XMLHttpRequest = class extends self.XMLHttpRequest { - open(method, url, ...args) { - const xhrDetails = { method, url }; - let outcome = 'match'; - if ( propNeedles.size !== 0 ) { - if ( matchObjectProperties(propNeedles, xhrDetails) === false ) { - outcome = 'nomatch'; - } - } - if ( outcome === 'match' ) { - if ( safe.logLevel > 1 ) { - safe.uboLog(logPrefix, `Matched optional "propsToMatch", "${extraArgs.propsToMatch}"`); - } - 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 objBefore; - if ( typeof innerResponse === 'object' ) { - objBefore = innerResponse; - } else if ( typeof innerResponse === 'string' ) { - try { - objBefore = safe.JSON_parse(innerResponse); - } catch(ex) { - } - } - if ( typeof objBefore !== 'object' ) { - return (xhrDetails.response = innerResponse); - } - const objAfter = objectPruneFn( - objBefore, - rawPrunePaths, - rawNeedlePaths, - stackNeedle, - extraArgs - ); - let outerResponse; - if ( typeof objAfter === 'object' ) { - outerResponse = typeof innerResponse === 'string' - ? safe.JSON_stringify(objAfter) - : objAfter; - safe.uboLog(logPrefix, 'Pruned'); - } else { - outerResponse = innerResponse; - } - return (xhrDetails.response = outerResponse); - } - get responseText() { - const response = this.response; - return typeof response !== 'string' - ? super.responseText - : response; - } - }; -} - -/******************************************************************************/ - -// There is still code out there which uses `eval` in lieu of `JSON.parse`. - -builtinScriptlets.push({ - name: 'evaldata-prune.js', - fn: evaldataPrune, - dependencies: [ - 'object-prune.fn', - ], -}); -function evaldataPrune( - rawPrunePaths = '', - rawNeedlePaths = '' -) { - self.eval = new Proxy(self.eval, { - apply(target, thisArg, args) { - const before = Reflect.apply(target, thisArg, args); - if ( typeof before === 'object' ) { - const after = objectPruneFn(before, rawPrunePaths, rawNeedlePaths); - return after || before; - } - return before; - } - }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'adjust-setInterval.js', - aliases: [ - 'nano-setInterval-booster.js', - 'nano-sib.js', - ], - fn: adjustSetInterval, - dependencies: [ - 'safe-self.fn', - ], -}); -// Imported from: -// https://github.com/NanoAdblocker/NanoFilters/blob/1f3be7211bb0809c5106996f52564bf10c4525f7/NanoFiltersSource/NanoResources.txt#L126 -// -// Speed up or down setInterval, 3 optional arguments. -// The payload matcher, a string literal or a JavaScript RegExp, defaults -// to match all. -// delayMatcher -// The delay matcher, an integer, defaults to 1000. -// Use `*` to match any delay. -// boostRatio - The delay multiplier when there is a match, 0.5 speeds up by -// 2 times and 2 slows down by 2 times, defaults to 0.05 or speed up -// 20 times. Speed up and down both cap at 50 times. -function adjustSetInterval( - needleArg = '', - delayArg = '', - boostArg = '' -) { - if ( typeof needleArg !== 'string' ) { return; } - const safe = safeSelf(); - const reNeedle = safe.patternToRegex(needleArg); - let delay = delayArg !== '*' ? parseInt(delayArg, 10) : -1; - if ( isNaN(delay) || isFinite(delay) === false ) { delay = 1000; } - let boost = parseFloat(boostArg); - boost = isNaN(boost) === false && isFinite(boost) - ? Math.min(Math.max(boost, 0.001), 50) - : 0.05; - self.setInterval = new Proxy(self.setInterval, { - apply: function(target, thisArg, args) { - const [ a, b ] = args; - if ( - (delay === -1 || b === delay) && - reNeedle.test(a.toString()) - ) { - args[1] = b * boost; - } - return target.apply(thisArg, args); - } - }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'adjust-setTimeout.js', - aliases: [ - 'nano-setTimeout-booster.js', - 'nano-stb.js', - ], - fn: adjustSetTimeout, - dependencies: [ - 'safe-self.fn', - ], -}); -// Imported from: -// https://github.com/NanoAdblocker/NanoFilters/blob/1f3be7211bb0809c5106996f52564bf10c4525f7/NanoFiltersSource/NanoResources.txt#L82 -// -// Speed up or down setTimeout, 3 optional arguments. -// funcMatcher -// The payload matcher, a string literal or a JavaScript RegExp, defaults -// to match all. -// delayMatcher -// The delay matcher, an integer, defaults to 1000. -// Use `*` to match any delay. -// boostRatio - The delay multiplier when there is a match, 0.5 speeds up by -// 2 times and 2 slows down by 2 times, defaults to 0.05 or speed up -// 20 times. Speed up and down both cap at 50 times. -function adjustSetTimeout( - needleArg = '', - delayArg = '', - boostArg = '' -) { - if ( typeof needleArg !== 'string' ) { return; } - const safe = safeSelf(); - const reNeedle = safe.patternToRegex(needleArg); - let delay = delayArg !== '*' ? parseInt(delayArg, 10) : -1; - if ( isNaN(delay) || isFinite(delay) === false ) { delay = 1000; } - let boost = parseFloat(boostArg); - boost = isNaN(boost) === false && isFinite(boost) - ? Math.min(Math.max(boost, 0.001), 50) - : 0.05; - self.setTimeout = new Proxy(self.setTimeout, { - apply: function(target, thisArg, args) { - const [ a, b ] = args; - if ( - (delay === -1 || b === delay) && - reNeedle.test(a.toString()) - ) { - args[1] = b * boost; - } - return target.apply(thisArg, args); - } - }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'noeval-if.js', - aliases: [ - 'prevent-eval-if.js', - ], - fn: noEvalIf, - dependencies: [ - 'safe-self.fn', - ], -}); -function noEvalIf( - needle = '' -) { - if ( typeof needle !== 'string' ) { return; } - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('noeval-if', needle); - const reNeedle = safe.patternToRegex(needle); - window.eval = new Proxy(window.eval, { // jshint ignore: line - apply: function(target, thisArg, args) { - const a = String(args[0]); - if ( needle !== '' && reNeedle.test(a) ) { - safe.uboLog(logPrefix, 'Prevented:\n', a); - return; - } - if ( needle === '' || safe.logLevel > 1 ) { - safe.uboLog(logPrefix, 'Not prevented:\n', a); - } - return Reflect.apply(target, thisArg, args); - } - }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'prevent-fetch.js', - aliases: [ - 'no-fetch-if.js', - ], - fn: noFetchIf, - dependencies: [ - 'generate-content.fn', - 'safe-self.fn', - ], -}); -function noFetchIf( - propsToMatch = '', - responseBody = '' -) { - if ( typeof propsToMatch !== 'string' ) { return; } - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('prevent-fetch', propsToMatch, responseBody); - const needles = []; - for ( const condition of propsToMatch.split(/\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, re: safe.patternToRegex(value) }); - } - self.fetch = new Proxy(self.fetch, { - apply: function(target, thisArg, args) { - const details = args[0] instanceof self.Request - ? args[0] - : Object.assign({ url: args[0] }, args[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(ex) { } - } - if ( typeof v !== 'string' ) { continue; } - props.set(prop, v); - } - if ( propsToMatch === '' && responseBody === '' ) { - const out = Array.from(props).map(a => `${a[0]}:${a[1]}`); - safe.uboLog(logPrefix, `Called: ${out.join('\n')}`); - return Reflect.apply(target, thisArg, args); - } - proceed = needles.length === 0; - for ( const { key, re } of needles ) { - if ( - props.has(key) === false || - re.test(props.get(key)) === false - ) { - proceed = true; - break; - } - } - } catch(ex) { - } - if ( proceed ) { - return Reflect.apply(target, thisArg, args); - } - let responseType = ''; - if ( details.mode === undefined || details.mode === 'cors' ) { - try { - const desURL = new URL(details.url); - responseType = desURL.origin !== document.location.origin - ? 'cors' - : 'basic'; - } catch(ex) { - safe.uboErr(logPrefix, `Error: ${ex}`); - } - } - return generateContentFn(responseBody).then(text => { - safe.uboLog(logPrefix, `Prevented with response "${text}"`); - const response = new Response(text, { - statusText: 'OK', - headers: { - 'Content-Length': text.length, - } - }); - safe.Object_defineProperty(response, 'url', { - value: details.url - }); - if ( responseType !== '' ) { - safe.Object_defineProperty(response, 'type', { - value: responseType - }); - } - return response; - }); - } - }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'prevent-refresh.js', - aliases: [ - 'refresh-defuser.js', - ], - fn: preventRefresh, - world: 'ISOLATED', - dependencies: [ - 'run-at.fn', - 'safe-self.fn', - ], -}); -// https://www.reddit.com/r/uBlockOrigin/comments/q0frv0/while_reading_a_sports_article_i_was_redirected/hf7wo9v/ -function preventRefresh( - arg1 = '' -) { - if ( typeof arg1 !== 'string' ) { return; } - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('prevent-refresh', arg1); - const defuse = ( ) => { - const meta = document.querySelector('meta[http-equiv="refresh" i][content]'); - if ( meta === null ) { return; } - safe.uboLog(logPrefix, `Prevented "${meta.textContent}"`); - const s = arg1 === '' - ? meta.getAttribute('content') - : arg1; - const ms = Math.max(parseFloat(s) || 0, 0) * 1000; - setTimeout(( ) => { window.stop(); }, ms); - }; - runAt(( ) => { - defuse(); - }, 'interactive'); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'remove-attr.js', - aliases: [ - 'ra.js', - ], - fn: removeAttr, - dependencies: [ - 'run-at.fn', - 'safe-self.fn', - ], -}); -function removeAttr( - rawToken = '', - rawSelector = '', - behavior = '' -) { - if ( typeof rawToken !== 'string' ) { return; } - if ( rawToken === '' ) { return; } - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('remove-attr', rawToken, rawSelector, behavior); - const tokens = rawToken.split(/\s*\|\s*/); - const selector = tokens - .map(a => `${rawSelector}[${CSS.escape(a)}]`) - .join(','); - if ( safe.logLevel > 1 ) { - safe.uboLog(logPrefix, `Target selector:\n\t${selector}`); - } - let timer; - const rmattr = ( ) => { - timer = undefined; - try { - const nodes = document.querySelectorAll(selector); - for ( const node of nodes ) { - for ( const attr of tokens ) { - if ( node.hasAttribute(attr) === false ) { continue; } - node.removeAttribute(attr); - safe.uboLog(logPrefix, `Removed attribute '${attr}'`); - } - } - } catch(ex) { - } - }; - const mutationHandler = mutations => { - if ( timer !== undefined ) { return; } - let skip = true; - for ( let i = 0; i < mutations.length && skip; i++ ) { - const { type, addedNodes, removedNodes } = mutations[i]; - if ( type === 'attributes' ) { skip = false; } - for ( let j = 0; j < addedNodes.length && skip; j++ ) { - if ( addedNodes[j].nodeType === 1 ) { skip = false; break; } - } - for ( let j = 0; j < removedNodes.length && skip; j++ ) { - if ( removedNodes[j].nodeType === 1 ) { skip = false; break; } - } - } - if ( skip ) { return; } - timer = safe.onIdle(rmattr, { timeout: 67 }); - }; - const start = ( ) => { - rmattr(); - if ( /\bstay\b/.test(behavior) === false ) { return; } - const observer = new MutationObserver(mutationHandler); - observer.observe(document, { - attributes: true, - attributeFilter: tokens, - childList: true, - subtree: true, - }); - }; - runAt(( ) => { - start(); - }, /\bcomplete\b/.test(behavior) ? 'idle' : 'interactive'); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'remove-class.js', - aliases: [ - 'rc.js', - ], - fn: removeClass, - world: 'ISOLATED', - dependencies: [ - 'run-at.fn', - 'safe-self.fn', - ], -}); -function removeClass( - rawToken = '', - rawSelector = '', - behavior = '' -) { - if ( typeof rawToken !== 'string' ) { return; } - if ( rawToken === '' ) { return; } - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('remove-class', rawToken, rawSelector, behavior); - const tokens = rawToken.split(/\s*\|\s*/); - const selector = tokens - .map(a => `${rawSelector}.${CSS.escape(a)}`) - .join(','); - if ( safe.logLevel > 1 ) { - safe.uboLog(logPrefix, `Target selector:\n\t${selector}`); - } - const mustStay = /\bstay\b/.test(behavior); - let timer; - const rmclass = ( ) => { - timer = undefined; - try { - const nodes = document.querySelectorAll(selector); - for ( const node of nodes ) { - node.classList.remove(...tokens); - safe.uboLog(logPrefix, 'Removed class(es)'); - } - } catch(ex) { - } - if ( mustStay ) { return; } - if ( document.readyState !== 'complete' ) { return; } - observer.disconnect(); - }; - const mutationHandler = mutations => { - if ( timer !== undefined ) { return; } - let skip = true; - for ( let i = 0; i < mutations.length && skip; i++ ) { - const { type, addedNodes, removedNodes } = mutations[i]; - if ( type === 'attributes' ) { skip = false; } - for ( let j = 0; j < addedNodes.length && skip; j++ ) { - if ( addedNodes[j].nodeType === 1 ) { skip = false; break; } - } - for ( let j = 0; j < removedNodes.length && skip; j++ ) { - if ( removedNodes[j].nodeType === 1 ) { skip = false; break; } - } - } - if ( skip ) { return; } - timer = safe.onIdle(rmclass, { timeout: 67 }); - }; - const observer = new MutationObserver(mutationHandler); - const start = ( ) => { - rmclass(); - observer.observe(document, { - attributes: true, - attributeFilter: [ 'class' ], - childList: true, - subtree: true, - }); - }; - runAt(( ) => { - start(); - }, /\bcomplete\b/.test(behavior) ? 'idle' : 'loading'); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'no-requestAnimationFrame-if.js', - aliases: [ - 'norafif.js', - 'prevent-requestAnimationFrame.js', - ], - fn: noRequestAnimationFrameIf, - dependencies: [ - 'safe-self.fn', - ], -}); -function noRequestAnimationFrameIf( - needle = '' -) { - if ( typeof needle !== 'string' ) { return; } - const safe = safeSelf(); - const needleNot = needle.charAt(0) === '!'; - if ( needleNot ) { needle = needle.slice(1); } - const log = needleNot === false && needle === '' ? console.log : undefined; - const reNeedle = safe.patternToRegex(needle); - window.requestAnimationFrame = new Proxy(window.requestAnimationFrame, { - apply: function(target, thisArg, args) { - const a = args[0] instanceof Function - ? String(safe.Function_toString(args[0])) - : String(args[0]); - let defuse = false; - if ( log !== undefined ) { - log('uBO: requestAnimationFrame("%s")', a); - } else { - defuse = reNeedle.test(a) !== needleNot; - } - if ( defuse ) { - args[0] = function(){}; - } - return target.apply(thisArg, args); - } - }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'set-constant.js', - aliases: [ - 'set.js', - ], - fn: setConstant, - dependencies: [ - 'set-constant.fn' - ], -}); -function setConstant( - ...args -) { - setConstantFn(false, ...args); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'no-setInterval-if.js', - aliases: [ - 'nosiif.js', - 'prevent-setInterval.js', - 'setInterval-defuser.js', - ], - fn: noSetIntervalIf, - dependencies: [ - 'safe-self.fn', - ], -}); -function noSetIntervalIf( - needle = '', - delay = '' -) { - if ( typeof needle !== 'string' ) { return; } - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('prevent-setInterval', needle, delay); - const needleNot = needle.charAt(0) === '!'; - if ( needleNot ) { needle = needle.slice(1); } - if ( delay === '' ) { delay = undefined; } - let delayNot = false; - if ( delay !== undefined ) { - delayNot = delay.charAt(0) === '!'; - if ( delayNot ) { delay = delay.slice(1); } - delay = parseInt(delay, 10); - } - const reNeedle = safe.patternToRegex(needle); - self.setInterval = new Proxy(self.setInterval, { - apply: function(target, thisArg, args) { - const a = args[0] instanceof Function - ? String(safe.Function_toString(args[0])) - : String(args[0]); - const b = args[1]; - if ( needle === '' && delay === undefined ) { - safe.uboLog(logPrefix, `Called:\n${a}\n${b}`); - return Reflect.apply(target, thisArg, args); - } - let defuse; - if ( needle !== '' ) { - defuse = reNeedle.test(a) !== needleNot; - } - if ( defuse !== false && delay !== undefined ) { - defuse = (b === delay || isNaN(b) && isNaN(delay) ) !== delayNot; - } - if ( defuse ) { - args[0] = function(){}; - safe.uboLog(logPrefix, `Prevented:\n${a}\n${b}`); - } - return Reflect.apply(target, thisArg, args); - }, - get(target, prop, receiver) { - if ( prop === 'toString' ) { - return target.toString.bind(target); - } - return Reflect.get(target, prop, receiver); - }, - }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'no-setTimeout-if.js', - aliases: [ - 'nostif.js', - 'prevent-setTimeout.js', - 'setTimeout-defuser.js', - ], - fn: noSetTimeoutIf, - dependencies: [ - 'safe-self.fn', - ], -}); -function noSetTimeoutIf( - needle = '', - delay = '' -) { - if ( typeof needle !== 'string' ) { return; } - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('prevent-setTimeout', needle, delay); - const needleNot = needle.charAt(0) === '!'; - if ( needleNot ) { needle = needle.slice(1); } - if ( delay === '' ) { delay = undefined; } - let delayNot = false; - if ( delay !== undefined ) { - delayNot = delay.charAt(0) === '!'; - if ( delayNot ) { delay = delay.slice(1); } - delay = parseInt(delay, 10); - } - const reNeedle = safe.patternToRegex(needle); - self.setTimeout = new Proxy(self.setTimeout, { - apply: function(target, thisArg, args) { - const a = args[0] instanceof Function - ? String(safe.Function_toString(args[0])) - : String(args[0]); - const b = args[1]; - if ( needle === '' && delay === undefined ) { - safe.uboLog(logPrefix, `Called:\n${a}\n${b}`); - return Reflect.apply(target, thisArg, args); - } - let defuse; - if ( needle !== '' ) { - defuse = reNeedle.test(a) !== needleNot; - } - if ( defuse !== false && delay !== undefined ) { - defuse = (b === delay || isNaN(b) && isNaN(delay) ) !== delayNot; - } - if ( defuse ) { - args[0] = function(){}; - safe.uboLog(logPrefix, `Prevented:\n${a}\n${b}`); - } - return Reflect.apply(target, thisArg, args); - }, - get(target, prop, receiver) { - if ( prop === 'toString' ) { - return target.toString.bind(target); - } - return Reflect.get(target, prop, receiver); - }, - }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'webrtc-if.js', - fn: webrtcIf, - dependencies: [ - 'safe-self.fn', - ], -}); -function webrtcIf( - good = '' -) { - if ( typeof good !== 'string' ) { return; } - const safe = safeSelf(); - const reGood = safe.patternToRegex(good); - const rtcName = window.RTCPeerConnection - ? 'RTCPeerConnection' - : (window.webkitRTCPeerConnection ? 'webkitRTCPeerConnection' : ''); - if ( rtcName === '' ) { return; } - const log = console.log.bind(console); - const neuteredPeerConnections = new WeakSet(); - const isGoodConfig = function(instance, config) { - if ( neuteredPeerConnections.has(instance) ) { return false; } - if ( config instanceof Object === false ) { return true; } - if ( Array.isArray(config.iceServers) === false ) { return true; } - for ( const server of config.iceServers ) { - const urls = typeof server.urls === 'string' - ? [ server.urls ] - : server.urls; - if ( Array.isArray(urls) ) { - for ( const url of urls ) { - if ( reGood.test(url) ) { return true; } - } - } - if ( typeof server.username === 'string' ) { - if ( reGood.test(server.username) ) { return true; } - } - if ( typeof server.credential === 'string' ) { - if ( reGood.test(server.credential) ) { return true; } - } - } - neuteredPeerConnections.add(instance); - return false; - }; - const peerConnectionCtor = window[rtcName]; - const peerConnectionProto = peerConnectionCtor.prototype; - peerConnectionProto.createDataChannel = - new Proxy(peerConnectionProto.createDataChannel, { - apply: function(target, thisArg, args) { - if ( isGoodConfig(target, args[1]) === false ) { - log('uBO:', args[1]); - return Reflect.apply(target, thisArg, args.slice(0, 1)); - } - return Reflect.apply(target, thisArg, args); - }, - }); - window[rtcName] = - new Proxy(peerConnectionCtor, { - construct: function(target, args) { - if ( isGoodConfig(target, args[0]) === false ) { - log('uBO:', args[0]); - return Reflect.construct(target); - } - return Reflect.construct(target, args); - } - }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'no-xhr-if.js', - aliases: [ - 'prevent-xhr.js', - ], - fn: noXhrIf, - dependencies: [ - 'generate-content.fn', - 'match-object-properties.fn', - 'parse-properties-to-match.fn', - 'safe-self.fn', - ], -}); -function noXhrIf( - propsToMatch = '', - directive = '' -) { - if ( typeof propsToMatch !== 'string' ) { return; } - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('prevent-xhr', propsToMatch, directive); - const xhrInstances = new WeakMap(); - const propNeedles = parsePropertiesToMatch(propsToMatch, 'url'); - const warOrigin = scriptletGlobals.warOrigin; - const headers = { - 'date': '', - 'content-type': '', - 'content-length': '', - }; - self.XMLHttpRequest = class extends self.XMLHttpRequest { - open(method, url, ...args) { - xhrInstances.delete(this); - if ( warOrigin !== undefined && url.startsWith(warOrigin) ) { - return super.open(method, url, ...args); - } - const haystack = { method, url }; - if ( propsToMatch === '' && directive === '' ) { - safe.uboLog(logPrefix, `Called: ${safe.JSON_stringify(haystack, null, 2)}`); - return super.open(method, url, ...args); - } - if ( matchObjectProperties(propNeedles, haystack) ) { - xhrInstances.set(this, haystack); - } - haystack.headers = Object.assign({}, headers); - return super.open(method, url, ...args); - } - send(...args) { - const haystack = xhrInstances.get(this); - if ( haystack === undefined ) { - return super.send(...args); - } - haystack.headers['date'] = (new Date()).toUTCString(); - let promise = Promise.resolve({ - xhr: this, - directive, - props: { - readyState: { value: 4 }, - response: { value: '' }, - responseText: { value: '' }, - responseXML: { value: null }, - responseURL: { value: haystack.url }, - status: { value: 200 }, - statusText: { value: 'OK' }, - }, - }); - switch ( this.responseType ) { - case 'arraybuffer': - promise = promise.then(details => { - details.props.response.value = new ArrayBuffer(0); - return details; - }); - haystack.headers['content-type'] = 'application/octet-stream'; - break; - case 'blob': - promise = promise.then(details => { - details.props.response.value = new Blob([]); - return details; - }); - haystack.headers['content-type'] = 'application/octet-stream'; - break; - case 'document': { - promise = promise.then(details => { - const parser = new DOMParser(); - const doc = parser.parseFromString('', 'text/html'); - details.props.response.value = doc; - details.props.responseXML.value = doc; - return details; - }); - haystack.headers['content-type'] = 'text/html'; - break; - } - case 'json': - promise = promise.then(details => { - details.props.response.value = {}; - details.props.responseText.value = '{}'; - return details; - }); - haystack.headers['content-type'] = 'application/json'; - break; - default: - if ( directive === '' ) { break; } - promise = promise.then(details => { - return generateContentFn(details.directive).then(text => { - details.props.response.value = text; - details.props.responseText.value = text; - return details; - }); - }); - haystack.headers['content-type'] = 'text/plain'; - break; - } - promise.then(details => { - haystack.headers['content-length'] = `${details.props.response.value}`.length; - Object.defineProperties(details.xhr, details.props); - details.xhr.dispatchEvent(new Event('readystatechange')); - details.xhr.dispatchEvent(new Event('load')); - details.xhr.dispatchEvent(new Event('loadend')); - safe.uboLog(logPrefix, `Prevented with response:\n${details.xhr.response}`); - }); - } - getResponseHeader(headerName) { - const haystack = xhrInstances.get(this); - if ( haystack === undefined || this.readyState < this.HEADERS_RECEIVED ) { - return super.getResponseHeader(headerName); - } - const value = haystack.headers[headerName.toLowerCase()]; - if ( value !== undefined && value !== '' ) { return value; } - return null; - } - getAllResponseHeaders() { - const haystack = xhrInstances.get(this); - if ( haystack === undefined || this.readyState < this.HEADERS_RECEIVED ) { - return super.getAllResponseHeaders(); - } - const out = []; - for ( const [ name, value ] of Object.entries(haystack.headers) ) { - if ( !value ) { continue; } - out.push(`${name}: ${value}`); - } - if ( out.length !== 0 ) { out.push(''); } - return out.join('\r\n'); - } - }; -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'no-window-open-if.js', - aliases: [ - 'nowoif.js', - 'prevent-window-open.js', - 'window.open-defuser.js', - ], - fn: noWindowOpenIf, - dependencies: [ - 'safe-self.fn', - ], -}); -function noWindowOpenIf( - pattern = '', - delay = '', - decoy = '' -) { - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('no-window-open-if', pattern, delay, decoy); - const targetMatchResult = pattern.startsWith('!') === false; - if ( targetMatchResult === false ) { - pattern = pattern.slice(1); - } - const rePattern = safe.patternToRegex(pattern); - let autoRemoveAfter = parseInt(delay); - if ( isNaN(autoRemoveAfter) ) { - autoRemoveAfter = -1; - } - const createDecoy = function(tag, urlProp, url) { - const decoyElem = document.createElement(tag); - decoyElem[urlProp] = url; - decoyElem.style.setProperty('height','1px', 'important'); - decoyElem.style.setProperty('position','fixed', 'important'); - decoyElem.style.setProperty('top','-1px', 'important'); - decoyElem.style.setProperty('width','1px', 'important'); - document.body.appendChild(decoyElem); - setTimeout(( ) => { decoyElem.remove(); }, autoRemoveAfter * 1000); - return decoyElem; - }; - window.open = new Proxy(window.open, { - apply: function(target, thisArg, args) { - const haystack = args.join(' '); - if ( rePattern.test(haystack) !== targetMatchResult ) { - if ( safe.logLevel > 1 ) { - safe.uboLog(logPrefix, `Allowed (${args.join(', ')})`); - } - return Reflect.apply(target, thisArg, args); - } - safe.uboLog(logPrefix, `Prevented (${args.join(', ')})`); - if ( autoRemoveAfter < 0 ) { return null; } - const decoyElem = decoy === 'obj' - ? createDecoy('object', 'data', ...args) - : createDecoy('iframe', 'src', ...args); - let popup = decoyElem.contentWindow; - if ( typeof popup === 'object' && popup !== null ) { - Object.defineProperty(popup, 'closed', { value: false }); - } else { - const noopFunc = (function(){}).bind(self); - popup = new Proxy(self, { - get: function(target, prop) { - if ( prop === 'closed' ) { return false; } - const r = Reflect.get(...arguments); - if ( typeof r === 'function' ) { return noopFunc; } - return target[prop]; - }, - set: function() { - return Reflect.set(...arguments); - }, - }); - } - if ( safe.logLevel !== 0 ) { - popup = new Proxy(popup, { - get: function(target, prop) { - safe.uboLog(logPrefix, 'window.open / get', prop, '===', target[prop]); - return Reflect.get(...arguments); - }, - set: function(target, prop, value) { - safe.uboLog(logPrefix, 'window.open / set', prop, '=', value); - return Reflect.set(...arguments); - }, - }); - } - return popup; - } - }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'close-window.js', - aliases: [ - 'window-close-if.js', - ], - fn: closeWindow, - world: 'ISOLATED', - dependencies: [ - 'safe-self.fn', - ], -}); -// https://github.com/uBlockOrigin/uAssets/issues/10323#issuecomment-992312847 -// https://github.com/AdguardTeam/Scriptlets/issues/158 -// https://github.com/uBlockOrigin/uBlock-issues/discussions/2270 -function closeWindow( - arg1 = '' -) { - if ( typeof arg1 !== 'string' ) { return; } - const safe = safeSelf(); - let subject = ''; - if ( /^\/.*\/$/.test(arg1) ) { - subject = window.location.href; - } else if ( arg1 !== '' ) { - subject = `${window.location.pathname}${window.location.search}`; - } - try { - const re = safe.patternToRegex(arg1); - if ( re.test(subject) ) { - window.close(); - } - } catch(ex) { - console.log(ex); - } -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'window.name-defuser.js', - fn: windowNameDefuser, -}); -// https://github.com/gorhill/uBlock/issues/1228 -function windowNameDefuser() { - if ( window === window.top ) { - window.name = ''; - } -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'overlay-buster.js', - fn: overlayBuster, -}); -// Experimental: Generic nuisance overlay buster. -// if this works well and proves to be useful, this may end up -// as a stock tool in uBO's popup panel. -function overlayBuster() { - if ( window !== window.top ) { return; } - var tstart; - var ttl = 30000; - var delay = 0; - var delayStep = 50; - var buster = function() { - var docEl = document.documentElement, - bodyEl = document.body, - vw = Math.min(docEl.clientWidth, window.innerWidth), - vh = Math.min(docEl.clientHeight, window.innerHeight), - tol = Math.min(vw, vh) * 0.05, - el = document.elementFromPoint(vw/2, vh/2), - style, rect; - for (;;) { - if ( el === null || el.parentNode === null || el === bodyEl ) { - break; - } - style = window.getComputedStyle(el); - if ( parseInt(style.zIndex, 10) >= 1000 || style.position === 'fixed' ) { - rect = el.getBoundingClientRect(); - if ( rect.left <= tol && rect.top <= tol && (vw - rect.right) <= tol && (vh - rect.bottom) < tol ) { - el.parentNode.removeChild(el); - tstart = Date.now(); - el = document.elementFromPoint(vw/2, vh/2); - bodyEl.style.setProperty('overflow', 'auto', 'important'); - docEl.style.setProperty('overflow', 'auto', 'important'); - continue; - } - } - el = el.parentNode; - } - if ( (Date.now() - tstart) < ttl ) { - delay = Math.min(delay + delayStep, 1000); - setTimeout(buster, delay); - } - }; - var domReady = function(ev) { - if ( ev ) { - document.removeEventListener(ev.type, domReady); - } - tstart = Date.now(); - setTimeout(buster, delay); - }; - if ( document.readyState === 'loading' ) { - document.addEventListener('DOMContentLoaded', domReady); - } else { - domReady(); - } -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'alert-buster.js', - fn: alertBuster, -}); -// https://github.com/uBlockOrigin/uAssets/issues/8 -function alertBuster() { - window.alert = new Proxy(window.alert, { - apply: function(a) { - console.info(a); - }, - get(target, prop, receiver) { - if ( prop === 'toString' ) { - return target.toString.bind(target); - } - return Reflect.get(target, prop, receiver); - }, - }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'nowebrtc.js', - fn: noWebrtc, -}); -// Prevent web pages from using RTCPeerConnection(), and report attempts in console. -function noWebrtc() { - var rtcName = window.RTCPeerConnection ? 'RTCPeerConnection' : ( - window.webkitRTCPeerConnection ? 'webkitRTCPeerConnection' : '' - ); - if ( rtcName === '' ) { return; } - var log = console.log.bind(console); - var pc = function(cfg) { - log('Document tried to create an RTCPeerConnection: %o', cfg); - }; - const noop = function() { - }; - pc.prototype = { - close: noop, - createDataChannel: noop, - createOffer: noop, - setRemoteDescription: noop, - toString: function() { - return '[object RTCPeerConnection]'; - } - }; - var z = window[rtcName]; - window[rtcName] = pc.bind(window); - if ( z.prototype ) { - z.prototype.createDataChannel = function() { - return { - close: function() {}, - send: function() {} - }; - }.bind(null); - } -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'disable-newtab-links.js', - fn: disableNewtabLinks, -}); -// https://github.com/uBlockOrigin/uAssets/issues/913 -function disableNewtabLinks() { - document.addEventListener('click', function(ev) { - var target = ev.target; - while ( target !== null ) { - if ( target.localName === 'a' && target.hasAttribute('target') ) { - ev.stopPropagation(); - ev.preventDefault(); - break; - } - target = target.parentNode; - } - }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'remove-cookie.js', - aliases: [ - 'cookie-remover.js', - ], - fn: cookieRemover, - world: 'ISOLATED', - dependencies: [ - 'safe-self.fn', - ], -}); -// https://github.com/NanoAdblocker/NanoFilters/issues/149 -function cookieRemover( - needle = '' -) { - if ( typeof needle !== 'string' ) { return; } - const safe = safeSelf(); - const reName = safe.patternToRegex(needle); - const extraArgs = safe.getExtraArgs(Array.from(arguments), 1); - const throttle = (fn, ms = 500) => { - if ( throttle.timer !== undefined ) { return; } - throttle.timer = setTimeout(( ) => { - throttle.timer = undefined; - fn(); - }, ms); - }; - const removeCookie = ( ) => { - document.cookie.split(';').forEach(cookieStr => { - const pos = cookieStr.indexOf('='); - if ( pos === -1 ) { return; } - const cookieName = cookieStr.slice(0, pos).trim(); - if ( reName.test(cookieName) === false ) { return; } - const part1 = cookieName + '='; - const part2a = '; domain=' + document.location.hostname; - const part2b = '; domain=.' + document.location.hostname; - let part2c, part2d; - const domain = document.domain; - if ( domain ) { - if ( domain !== document.location.hostname ) { - part2c = '; domain=.' + domain; - } - if ( domain.startsWith('www.') ) { - part2d = '; domain=' + domain.replace('www', ''); - } - } - const part3 = '; path=/'; - const part4 = '; Max-Age=-1000; expires=Thu, 01 Jan 1970 00:00:00 GMT'; - document.cookie = part1 + part4; - document.cookie = part1 + part2a + part4; - document.cookie = part1 + part2b + part4; - document.cookie = part1 + part3 + part4; - document.cookie = part1 + part2a + part3 + part4; - document.cookie = part1 + part2b + part3 + part4; - if ( part2c !== undefined ) { - document.cookie = part1 + part2c + part3 + part4; - } - if ( part2d !== undefined ) { - document.cookie = part1 + part2d + part3 + part4; - } - }); - }; - removeCookie(); - window.addEventListener('beforeunload', removeCookie); - if ( typeof extraArgs.when !== 'string' ) { return; } - const supportedEventTypes = [ 'scroll', 'keydown' ]; - const eventTypes = extraArgs.when.split(/\s/); - for ( const type of eventTypes ) { - if ( supportedEventTypes.includes(type) === false ) { continue; } - document.addEventListener(type, ( ) => { - throttle(removeCookie); - }, { passive: true }); - } -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'xml-prune.js', - fn: xmlPrune, - dependencies: [ - 'safe-self.fn', - ], -}); -function xmlPrune( - selector = '', - selectorCheck = '', - urlPattern = '' -) { - if ( typeof selector !== 'string' ) { return; } - if ( selector === '' ) { return; } - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('xml-prune', selector, selectorCheck, urlPattern); - const reUrl = safe.patternToRegex(urlPattern); - const extraArgs = safe.getExtraArgs(Array.from(arguments), 3); - const queryAll = (xmlDoc, selector) => { - const isXpath = /^xpath\(.+\)$/.test(selector); - if ( isXpath === false ) { - return Array.from(xmlDoc.querySelectorAll(selector)); - } - const xpr = xmlDoc.evaluate( - selector.slice(6, -1), - xmlDoc, - null, - XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, - null - ); - const out = []; - for ( let i = 0; i < xpr.snapshotLength; i++ ) { - const node = xpr.snapshotItem(i); - out.push(node); - } - return out; - }; - const pruneFromDoc = xmlDoc => { - try { - if ( selectorCheck !== '' && xmlDoc.querySelector(selectorCheck) === null ) { - return xmlDoc; - } - if ( extraArgs.logdoc ) { - const serializer = new XMLSerializer(); - safe.uboLog(logPrefix, `Document is\n\t${serializer.serializeToString(xmlDoc)}`); - } - const items = queryAll(xmlDoc, selector); - if ( items.length === 0 ) { return xmlDoc; } - safe.uboLog(logPrefix, `Removing ${items.length} items`); - for ( const item of items ) { - if ( item.nodeType === 1 ) { - item.remove(); - } else if ( item.nodeType === 2 ) { - item.ownerElement.removeAttribute(item.nodeName); - } - safe.uboLog(logPrefix, `${item.constructor.name}.${item.nodeName} removed`); - } - } catch(ex) { - safe.uboErr(logPrefix, `Error: ${ex}`); - } - return xmlDoc; - }; - const pruneFromText = text => { - if ( (/^\s*\s*$/.test(text)) === false ) { - return text; - } - try { - const xmlParser = new DOMParser(); - const xmlDoc = xmlParser.parseFromString(text, 'text/xml'); - pruneFromDoc(xmlDoc); - const serializer = new XMLSerializer(); - text = serializer.serializeToString(xmlDoc); - } catch(ex) { - } - return text; - }; - const urlFromArg = arg => { - if ( typeof arg === 'string' ) { return arg; } - if ( arg instanceof Request ) { return arg.url; } - return String(arg); - }; - self.fetch = new Proxy(self.fetch, { - apply: function(target, thisArg, args) { - const fetchPromise = Reflect.apply(target, thisArg, args); - if ( reUrl.test(urlFromArg(args[0])) === false ) { - return fetchPromise; - } - return fetchPromise.then(responseBefore => { - const response = responseBefore.clone(); - return response.text().then(text => { - const responseAfter = new Response(pruneFromText(text), { - 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(( ) => - responseBefore - ); - }); - } - }); - self.XMLHttpRequest.prototype.open = new Proxy(self.XMLHttpRequest.prototype.open, { - apply: async (target, thisArg, args) => { - if ( reUrl.test(urlFromArg(args[1])) === false ) { - return Reflect.apply(target, thisArg, args); - } - thisArg.addEventListener('readystatechange', function() { - if ( thisArg.readyState !== 4 ) { return; } - const type = thisArg.responseType; - if ( - type === 'document' || - type === '' && thisArg.responseXML instanceof XMLDocument - ) { - pruneFromDoc(thisArg.responseXML); - const serializer = new XMLSerializer(); - const textout = serializer.serializeToString(thisArg.responseXML); - Object.defineProperty(thisArg, 'responseText', { value: textout }); - return; - } - if ( - type === 'text' || - type === '' && typeof thisArg.responseText === 'string' - ) { - const textin = thisArg.responseText; - const textout = pruneFromText(textin); - if ( textout === textin ) { return; } - Object.defineProperty(thisArg, 'response', { value: textout }); - Object.defineProperty(thisArg, 'responseText', { value: textout }); - return; - } - }); - return Reflect.apply(target, thisArg, args); - } - }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'm3u-prune.js', - fn: m3uPrune, - dependencies: [ - 'safe-self.fn', - ], -}); -// https://en.wikipedia.org/wiki/M3U -function m3uPrune( - m3uPattern = '', - urlPattern = '' -) { - if ( typeof m3uPattern !== 'string' ) { return; } - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('m3u-prune', m3uPattern, urlPattern); - const toLog = []; - const regexFromArg = arg => { - if ( arg === '' ) { return /^/; } - const match = /^\/(.+)\/([gms]*)$/.exec(arg); - if ( match !== null ) { - let flags = match[2] || ''; - if ( flags.includes('m') ) { flags += 's'; } - return new RegExp(match[1], flags); - } - return new RegExp( - arg.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*+/g, '.*?') - ); - }; - const reM3u = regexFromArg(m3uPattern); - const reUrl = regexFromArg(urlPattern); - const pruneSpliceoutBlock = (lines, i) => { - if ( lines[i].startsWith('#EXT-X-CUE:TYPE="SpliceOut"') === false ) { - return false; - } - toLog.push(`\t${lines[i]}`); - lines[i] = undefined; i += 1; - if ( lines[i].startsWith('#EXT-X-ASSET:CAID') ) { - toLog.push(`\t${lines[i]}`); - lines[i] = undefined; i += 1; - } - if ( lines[i].startsWith('#EXT-X-SCTE35:') ) { - toLog.push(`\t${lines[i]}`); - lines[i] = undefined; i += 1; - } - if ( lines[i].startsWith('#EXT-X-CUE-IN') ) { - toLog.push(`\t${lines[i]}`); - lines[i] = undefined; i += 1; - } - if ( lines[i].startsWith('#EXT-X-SCTE35:') ) { - toLog.push(`\t${lines[i]}`); - lines[i] = undefined; i += 1; - } - return true; - }; - const pruneInfBlock = (lines, i) => { - if ( lines[i].startsWith('#EXTINF') === false ) { return false; } - if ( reM3u.test(lines[i+1]) === false ) { return false; } - toLog.push('Discarding', `\t${lines[i]}, \t${lines[i+1]}`); - lines[i] = lines[i+1] = undefined; i += 2; - if ( lines[i].startsWith('#EXT-X-DISCONTINUITY') ) { - toLog.push(`\t${lines[i]}`); - lines[i] = undefined; i += 1; - } - return true; - }; - const pruner = text => { - if ( (/^\s*#EXTM3U/.test(text)) === false ) { return text; } - if ( m3uPattern === '' ) { - safe.uboLog(` Content:\n${text}`); - return text; - } - if ( reM3u.multiline ) { - reM3u.lastIndex = 0; - for (;;) { - const match = reM3u.exec(text); - if ( match === null ) { break; } - let discard = match[0]; - let before = text.slice(0, match.index); - if ( - /^[\n\r]+/.test(discard) === false && - /[\n\r]+$/.test(before) === false - ) { - const startOfLine = /[^\n\r]+$/.exec(before); - if ( startOfLine !== null ) { - before = before.slice(0, startOfLine.index); - discard = startOfLine[0] + discard; - } - } - let after = text.slice(match.index + match[0].length); - if ( - /[\n\r]+$/.test(discard) === false && - /^[\n\r]+/.test(after) === false - ) { - const endOfLine = /^[^\n\r]+/.exec(after); - if ( endOfLine !== null ) { - after = after.slice(endOfLine.index); - discard += discard + endOfLine[0]; - } - } - text = before.trim() + '\n' + after.trim(); - reM3u.lastIndex = before.length + 1; - toLog.push('Discarding', ...discard.split(/\n+/).map(s => `\t${s}`)); - if ( reM3u.global === false ) { break; } - } - return text; - } - const lines = text.split(/\n\r|\n|\r/); - for ( let i = 0; i < lines.length; i++ ) { - if ( lines[i] === undefined ) { continue; } - if ( pruneSpliceoutBlock(lines, i) ) { continue; } - if ( pruneInfBlock(lines, i) ) { continue; } - } - return lines.filter(l => l !== undefined).join('\n'); - }; - const urlFromArg = arg => { - if ( typeof arg === 'string' ) { return arg; } - if ( arg instanceof Request ) { return arg.url; } - return String(arg); - }; - const realFetch = self.fetch; - self.fetch = new Proxy(self.fetch, { - apply: function(target, thisArg, args) { - if ( reUrl.test(urlFromArg(args[0])) === false ) { - return Reflect.apply(target, thisArg, args); - } - return realFetch(...args).then(realResponse => - realResponse.text().then(text => { - const response = new Response(pruner(text), { - status: realResponse.status, - statusText: realResponse.statusText, - headers: realResponse.headers, - }); - if ( toLog.length !== 0 ) { - toLog.unshift(logPrefix); - safe.uboLog(toLog.join('\n')); - } - return response; - }) - ); - } - }); - self.XMLHttpRequest.prototype.open = new Proxy(self.XMLHttpRequest.prototype.open, { - apply: async (target, thisArg, args) => { - if ( reUrl.test(urlFromArg(args[1])) === false ) { - return Reflect.apply(target, thisArg, args); - } - thisArg.addEventListener('readystatechange', function() { - if ( thisArg.readyState !== 4 ) { return; } - const type = thisArg.responseType; - if ( type !== '' && type !== 'text' ) { return; } - const textin = thisArg.responseText; - const textout = pruner(textin); - if ( textout === textin ) { return; } - Object.defineProperty(thisArg, 'response', { value: textout }); - Object.defineProperty(thisArg, 'responseText', { value: textout }); - if ( toLog.length !== 0 ) { - toLog.unshift(logPrefix); - safe.uboLog(toLog.join('\n')); - } - }); - return Reflect.apply(target, thisArg, args); - } - }); -} - -/******************************************************************************* - * - * @scriptlet href-sanitizer - * - * @description - * Set the `href` attribute to a value found in the DOM at, or below the - * targeted `a` element. - * - * ### Syntax - * - * ```text - * example.org##+js(href-sanitizer, selector [, source]) - * ``` - * - * - `selector`: required, CSS selector, specifies `a` elements for which the - * `href` attribute must be overridden. - * - `source`: optional, default to `text`, specifies from where to get the - * value which will override the `href` attribute. - * - `text`: the value will be the first valid URL found in the text - * content of the targeted `a` element. - * - `[attr]`: the value will be the attribute _attr_ of the targeted `a` - * element. - * - `?param`: the value will be the query parameter _param_ of the URL - * found in the `href` attribute of the targeted `a` element. - * - * ### Examples - * - * example.org##+js(href-sanitizer, a) - * example.org##+js(href-sanitizer, a[title], [title]) - * example.org##+js(href-sanitizer, a[href*="/away.php?to="], ?to) - * - * */ - -builtinScriptlets.push({ - name: 'href-sanitizer.js', - fn: hrefSanitizer, - world: 'ISOLATED', - dependencies: [ - 'run-at.fn', - 'safe-self.fn', - ], -}); -function hrefSanitizer( - selector = '', - source = '' -) { - if ( typeof selector !== 'string' ) { return; } - if ( selector === '' ) { return; } - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('href-sanitizer', selector, source); - if ( source === '' ) { source = 'text'; } - const sanitizeCopycats = (href, text) => { - let elems = []; - try { - elems = document.querySelectorAll(`a[href="${href}"`); - } - catch(ex) { - } - for ( const elem of elems ) { - elem.setAttribute('href', text); - } - return elems.length; - }; - const validateURL = text => { - if ( text === '' ) { return ''; } - if ( /[^\x21-\x7e]/.test(text) ) { return ''; } - try { - const url = new URL(text, document.location); - return url.href; - } catch(ex) { - } - return ''; - }; - const extractText = (elem, source) => { - if ( /^\[.*\]$/.test(source) ) { - return elem.getAttribute(source.slice(1,-1).trim()) || ''; - } - if ( source.startsWith('?') ) { - try { - const url = new URL(elem.href, document.location); - return url.searchParams.get(source.slice(1)) || ''; - } catch(x) { - } - return ''; - } - if ( source === 'text' ) { - return elem.textContent - .replace(/^[^\x21-\x7e]+/, '') // remove leading invalid characters - .replace(/[^\x21-\x7e]+$/, '') // remove trailing invalid characters - ; - } - return ''; - }; - const sanitize = ( ) => { - let elems = []; - try { - elems = document.querySelectorAll(selector); - } - catch(ex) { - return false; - } - for ( const elem of elems ) { - if ( elem.localName !== 'a' ) { continue; } - if ( elem.hasAttribute('href') === false ) { continue; } - const href = elem.getAttribute('href'); - const text = extractText(elem, source); - const hrefAfter = validateURL(text); - if ( hrefAfter === '' ) { continue; } - if ( hrefAfter === href ) { continue; } - elem.setAttribute('href', hrefAfter); - const count = sanitizeCopycats(href, hrefAfter); - safe.uboLog(logPrefix, `Sanitized ${count+1} links to\n${hrefAfter}`); - } - return true; - }; - let observer, timer; - const onDomChanged = mutations => { - if ( timer !== undefined ) { return; } - let shouldSanitize = false; - for ( const mutation of mutations ) { - if ( mutation.addedNodes.length === 0 ) { continue; } - for ( const node of mutation.addedNodes ) { - if ( node.nodeType !== 1 ) { continue; } - shouldSanitize = true; - break; - } - if ( shouldSanitize ) { break; } - } - if ( shouldSanitize === false ) { return; } - timer = safe.onIdle(( ) => { - timer = undefined; - sanitize(); - }); - }; - const start = ( ) => { - if ( sanitize() === false ) { return; } - observer = new MutationObserver(onDomChanged); - observer.observe(document.body, { - subtree: true, - childList: true, - }); - }; - runAt(( ) => { start(); }, 'interactive'); -} - -/******************************************************************************* - * - * @scriptlet call-nothrow - * - * @description - * Prevent a function call from throwing. The function will be called, however - * should it throw, the scriptlet will silently process the exception and - * returns as if no exception has occurred. - * - * ### Syntax - * - * ```text - * example.org##+js(call-nothrow, propertyChain) - * ``` - * - * - `propertyChain`: a chain of dot-separated properties which leads to the - * function to be trapped. - * - * ### Examples - * - * example.org##+js(call-nothrow, Object.defineProperty) - * - * */ - -builtinScriptlets.push({ - name: 'call-nothrow.js', - fn: callNothrow, -}); -function callNothrow( - chain = '' -) { - if ( typeof chain !== 'string' ) { return; } - if ( chain === '' ) { return; } - const parts = chain.split('.'); - let owner = window, prop; - for (;;) { - prop = parts.shift(); - if ( parts.length === 0 ) { break; } - owner = owner[prop]; - if ( owner instanceof Object === false ) { return; } - } - if ( prop === '' ) { return; } - const fn = owner[prop]; - if ( typeof fn !== 'function' ) { return; } - owner[prop] = new Proxy(fn, { - apply: function(...args) { - let r; - try { - r = Reflect.apply(...args); - } catch(ex) { - } - return r; - }, - }); -} - - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'spoof-css.js', - fn: spoofCSS, - dependencies: [ - 'safe-self.fn', - ], -}); -function spoofCSS( - selector, - ...args -) { - if ( typeof selector !== 'string' ) { return; } - if ( selector === '' ) { return; } - const toCamelCase = s => s.replace(/-[a-z]/g, s => s.charAt(1).toUpperCase()); - const propToValueMap = new Map(); - for ( let i = 0; i < args.length; i += 2 ) { - if ( typeof args[i+0] !== 'string' ) { break; } - if ( args[i+0] === '' ) { break; } - if ( typeof args[i+1] !== 'string' ) { break; } - propToValueMap.set(toCamelCase(args[i+0]), args[i+1]); - } - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('spoof-css', selector, ...args); - const canDebug = scriptletGlobals.canDebug; - const shouldDebug = canDebug && propToValueMap.get('debug') || 0; - const instanceProperties = [ 'cssText', 'length', 'parentRule' ]; - const spoofStyle = (prop, real) => { - const normalProp = toCamelCase(prop); - const shouldSpoof = propToValueMap.has(normalProp); - const value = shouldSpoof ? propToValueMap.get(normalProp) : real; - if ( shouldSpoof ) { - safe.uboLog(logPrefix, `Spoofing ${prop} to ${value}`); - } - return value; - }; - const cloackFunc = (fn, thisArg, name) => { - const trap = fn.bind(thisArg); - Object.defineProperty(trap, 'name', { value: name }); - Object.defineProperty(trap, 'toString', { - value: ( ) => `function ${name}() { [native code] }` - }); - return trap; - }; - self.getComputedStyle = new Proxy(self.getComputedStyle, { - apply: function(target, thisArg, args) { - // eslint-disable-next-line no-debugger - if ( shouldDebug !== 0 ) { debugger; } - const style = Reflect.apply(target, thisArg, args); - const targetElements = new WeakSet(document.querySelectorAll(selector)); - if ( targetElements.has(args[0]) === false ) { return style; } - const proxiedStyle = new Proxy(style, { - get(target, prop, receiver) { - if ( typeof target[prop] === 'function' ) { - if ( prop === 'getPropertyValue' ) { - return cloackFunc(function getPropertyValue(prop) { - return spoofStyle(prop, target[prop]); - }, target, 'getPropertyValue'); - } - return cloackFunc(target[prop], target, prop); - } - if ( instanceProperties.includes(prop) ) { - return Reflect.get(target, prop); - } - return spoofStyle(prop, Reflect.get(target, prop, receiver)); - }, - getOwnPropertyDescriptor(target, prop) { - if ( propToValueMap.has(prop) ) { - return { - configurable: true, - enumerable: true, - value: propToValueMap.get(prop), - writable: true, - }; - } - return Reflect.getOwnPropertyDescriptor(target, prop); - }, - }); - return proxiedStyle; - }, - get(target, prop, receiver) { - if ( prop === 'toString' ) { - return target.toString.bind(target); - } - return Reflect.get(target, prop, receiver); - }, - }); - Element.prototype.getBoundingClientRect = new Proxy(Element.prototype.getBoundingClientRect, { - apply: function(target, thisArg, args) { - // eslint-disable-next-line no-debugger - if ( shouldDebug !== 0 ) { debugger; } - const rect = Reflect.apply(target, thisArg, args); - const targetElements = new WeakSet(document.querySelectorAll(selector)); - if ( targetElements.has(thisArg) === false ) { return rect; } - let { height, width } = rect; - if ( propToValueMap.has('width') ) { - width = parseFloat(propToValueMap.get('width')); - } - if ( propToValueMap.has('height') ) { - height = parseFloat(propToValueMap.get('height')); - } - return new self.DOMRect(rect.x, rect.y, width, height); - }, - get(target, prop, receiver) { - if ( prop === 'toString' ) { - return target.toString.bind(target); - } - return Reflect.get(target, prop, receiver); - }, - }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'remove-node-text.js', - aliases: [ - 'rmnt.js', - ], - fn: removeNodeText, - world: 'ISOLATED', - dependencies: [ - 'replace-node-text.fn', - ], -}); -function removeNodeText( - nodeName, - condition, - ...extraArgs -) { - replaceNodeTextFn(nodeName, '', '', 'condition', condition || '', ...extraArgs); -} - -/******************************************************************************* - * - * set-cookie.js - * - * Set specified cookie to a specific value. - * - * Reference: - * https://github.com/AdguardTeam/Scriptlets/blob/master/src/scriptlets/set-cookie.js - * - **/ - -builtinScriptlets.push({ - name: 'set-cookie.js', - fn: setCookie, - world: 'ISOLATED', - dependencies: [ - 'safe-self.fn', - 'set-cookie.fn', - ], -}); -function setCookie( - name = '', - value = '', - path = '' -) { - if ( name === '' ) { return; } - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('set-cookie', name, value, path); - - const validValues = [ - 'accept', 'reject', - 'accepted', 'rejected', 'notaccepted', - 'allow', 'deny', - 'allowed', 'disallow', - 'enable', 'disable', - 'enabled', 'disabled', - 'ok', - 'on', 'off', - 'true', 't', 'false', 'f', - 'yes', 'y', 'no', 'n', - 'necessary', 'required', - 'approved', 'disapproved', - ]; - const normalized = value.toLowerCase(); - const match = /^("?)(.+)\1$/.exec(normalized); - const unquoted = match && match[2] || normalized; - if ( validValues.includes(unquoted) === false ) { - if ( /^\d+$/.test(unquoted) === false ) { return; } - const n = parseInt(value, 10); - if ( n > 32767 ) { return; } - } - - const done = setCookieFn( - false, - name, - value, - '', - path, - safe.getExtraArgs(Array.from(arguments), 3) - ); - - if ( done ) { - safe.uboLog(logPrefix, 'Done'); - } -} - -// For compatibility with AdGuard -builtinScriptlets.push({ - name: 'set-cookie-reload.js', - fn: setCookieReload, - world: 'ISOLATED', - dependencies: [ - 'set-cookie.js', - ], -}); -function setCookieReload(name, value, path, ...args) { - setCookie(name, value, path, 'reload', '1', ...args); -} - -/******************************************************************************* - * - * set-local-storage-item.js - * set-session-storage-item.js - * - * Set a local/session storage entry to a specific, allowed value. - * - * Reference: - * https://github.com/AdguardTeam/Scriptlets/blob/master/src/scriptlets/set-local-storage-item.js - * https://github.com/AdguardTeam/Scriptlets/blob/master/src/scriptlets/set-session-storage-item.js - * - **/ - -builtinScriptlets.push({ - name: 'set-local-storage-item.js', - fn: setLocalStorageItem, - world: 'ISOLATED', - dependencies: [ - 'set-local-storage-item.fn', - ], -}); -function setLocalStorageItem(key = '', value = '') { - setLocalStorageItemFn('local', false, key, value); -} - -builtinScriptlets.push({ - name: 'set-session-storage-item.js', - fn: setSessionStorageItem, - world: 'ISOLATED', - dependencies: [ - 'set-local-storage-item.fn', - ], -}); -function setSessionStorageItem(key = '', value = '') { - setLocalStorageItemFn('session', false, key, value); -} - -/******************************************************************************* - * - * @scriptlet set-attr - * - * @description - * Sets the specified attribute on the specified elements. This scriptlet runs - * once when the page loads then afterward on DOM mutations. - - * Reference: https://github.com/AdguardTeam/Scriptlets/blob/master/src/scriptlets/set-attr.js - * - * ### Syntax - * - * ```text - * example.org##+js(set-attr, selector, attr [, value]) - * ``` - * - * - `selector`: CSS selector of DOM elements for which the attribute `attr` - * must be modified. - * - `attr`: the name of the attribute to modify - * - `value`: the value to assign to the target attribute. Possible values: - * - `''`: empty string (default) - * - `true` - * - `false` - * - positive decimal integer 0 <= value < 32768 - * - `[other]`: copy the value from attribute `other` on the same element - * */ - -builtinScriptlets.push({ - name: 'set-attr.js', - fn: setAttr, - world: 'ISOLATED', - dependencies: [ - 'run-at.fn', - 'safe-self.fn', - ], -}); -function setAttr( - selector = '', - attr = '', - value = '' -) { - if ( selector === '' ) { return; } - if ( attr === '' ) { return; } - - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('set-attr', attr, value); - const validValues = [ '', 'false', 'true' ]; - let copyFrom = ''; - - if ( validValues.includes(value.toLowerCase()) === false ) { - if ( /^\d+$/.test(value) ) { - const n = parseInt(value, 10); - if ( n >= 32768 ) { return; } - value = `${n}`; - } else if ( /^\[.+\]$/.test(value) ) { - copyFrom = value.slice(1, -1); - } else { - return; - } - } - - const extractValue = elem => { - if ( copyFrom !== '' ) { - return elem.getAttribute(copyFrom) || ''; - } - return value; - }; - - const applySetAttr = ( ) => { - const elems = []; - try { - elems.push(...document.querySelectorAll(selector)); - } - catch(ex) { - return false; - } - for ( const elem of elems ) { - const before = elem.getAttribute(attr); - const after = extractValue(elem); - if ( after === before ) { continue; } - if ( after !== '' && /^on/i.test(attr) ) { - if ( attr.toLowerCase() in elem ) { continue; } - } - elem.setAttribute(attr, after); - safe.uboLog(logPrefix, `${attr}="${after}"`); - } - return true; - }; - let observer, timer; - const onDomChanged = mutations => { - if ( timer !== undefined ) { return; } - let shouldWork = false; - for ( const mutation of mutations ) { - if ( mutation.addedNodes.length === 0 ) { continue; } - for ( const node of mutation.addedNodes ) { - if ( node.nodeType !== 1 ) { continue; } - shouldWork = true; - break; - } - if ( shouldWork ) { break; } - } - if ( shouldWork === false ) { return; } - timer = self.requestAnimationFrame(( ) => { - timer = undefined; - applySetAttr(); - }); - }; - const start = ( ) => { - if ( applySetAttr() === false ) { return; } - observer = new MutationObserver(onDomChanged); - observer.observe(document.body, { - subtree: true, - childList: true, - }); - }; - runAt(( ) => { start(); }, 'idle'); -} - -/******************************************************************************* - * - * @scriptlet prevent-canvas - * - * @description - * Prevent usage of specific or all (default) canvas APIs. - * - * ### Syntax - * - * ```text - * example.com##+js(prevent-canvas [, contextType]) - * ``` - * - * - `contextType`: A specific type of canvas API to prevent (default to all - * APIs). Can be a string or regex which will be matched against the type - * used in getContext() call. Prepend with `!` to test for no-match. - * - * ### Examples - * - * 1. Prevent `example.com` from accessing all canvas APIs - * - * ```adblock - * example.com##+js(prevent-canvas) - * ``` - * - * 2. Prevent access to any flavor of WebGL API, everywhere - * - * ```adblock - * *##+js(prevent-canvas, /webgl/) - * ``` - * - * 3. Prevent `example.com` from accessing any flavor of canvas API except `2d` - * - * ```adblock - * example.com##+js(prevent-canvas, !2d) - * ``` - * - * ### References - * - * https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/getContext - * - * */ - -builtinScriptlets.push({ - name: 'prevent-canvas.js', - fn: preventCanvas, - dependencies: [ - 'safe-self.fn', - ], -}); -function preventCanvas( - contextType = '' -) { - const safe = safeSelf(); - const pattern = safe.initPattern(contextType, { canNegate: true }); - const proto = globalThis.HTMLCanvasElement.prototype; - proto.getContext = new Proxy(proto.getContext, { - apply(target, thisArg, args) { - if ( safe.testPattern(pattern, args[0]) ) { return null; } - return Reflect.apply(target, thisArg, args); - } - }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'multiup.js', - fn: multiup, - world: 'ISOLATED', -}); -function multiup() { - const handler = ev => { - const target = ev.target; - if ( target.matches('button[link]') === false ) { return; } - const ancestor = target.closest('form'); - if ( ancestor === null ) { return; } - if ( ancestor !== target.parentElement ) { return; } - const link = (target.getAttribute('link') || '').trim(); - if ( link === '' ) { return; } - ev.preventDefault(); - ev.stopPropagation(); - document.location.href = link; - }; - document.addEventListener('click', handler, { capture: true }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'remove-cache-storage-item.js', - fn: removeCacheStorageItem, - world: 'ISOLATED', - dependencies: [ - 'safe-self.fn', - ], -}); -function removeCacheStorageItem( - cacheNamePattern = '', - requestPattern = '' -) { - if ( cacheNamePattern === '' ) { return; } - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('remove-cache-storage-item', cacheNamePattern, requestPattern); - const cacheStorage = self.caches; - if ( cacheStorage instanceof Object === false ) { return; } - const reCache = safe.patternToRegex(cacheNamePattern, undefined, true); - const reRequest = safe.patternToRegex(requestPattern, undefined, true); - cacheStorage.keys().then(cacheNames => { - for ( const cacheName of cacheNames ) { - if ( reCache.test(cacheName) === false ) { continue; } - if ( requestPattern === '' ) { - cacheStorage.delete(cacheName).then(result => { - if ( safe.logLevel > 1 ) { - safe.uboLog(logPrefix, `Deleting ${cacheName}`); - } - if ( result !== true ) { return; } - safe.uboLog(logPrefix, `Deleted ${cacheName}: ${result}`); - }); - continue; - } - cacheStorage.open(cacheName).then(cache => { - cache.keys().then(requests => { - for ( const request of requests ) { - if ( reRequest.test(request.url) === false ) { continue; } - if ( safe.logLevel > 1 ) { - safe.uboLog(logPrefix, `Deleting ${cacheName}/${request.url}`); - } - cache.delete(request).then(result => { - if ( result !== true ) { return; } - safe.uboLog(logPrefix, `Deleted ${cacheName}/${request.url}: ${result}`); - }); - } - }); - }); - } - }); -} - - -/******************************************************************************* - * - * Scriplets below this section are only available for filter lists from - * trusted sources. They all have the property `requiresTrust` set to `true`. - * - * Trusted sources are: - * - * - uBO's own filter lists, which name starts with "uBlock filters – ", and - * maintained at: https://github.com/uBlockOrigin/uAssets - * - * - The user's own filters as seen in "My filters" pane in uBO's dashboard. - * - * The trustworthiness of filters using these privileged scriptlets are - * evaluated at filter list compiled time: when a filter using one of the - * privileged scriptlet originates from a non-trusted filter list source, it - * is discarded at compile time, specifically from within: - * - * - Source: ./src/js/scriptlet-filtering.js - * - Method: scriptletFilteringEngine.compile(), via normalizeRawFilter() - * - **/ - -/******************************************************************************* - * - * replace-node-text.js - * - * Replace text instance(s) with another text instance inside specific - * DOM nodes. By default, the scriplet stops and quits at the interactive - * stage of a document. - * - * See commit messages for usage: - * - https://github.com/gorhill/uBlock/commit/99ce027fd702 - * - https://github.com/gorhill/uBlock/commit/41876336db48 - * - **/ - -builtinScriptlets.push({ - name: 'trusted-replace-node-text.js', - requiresTrust: true, - aliases: [ - 'trusted-rpnt.js', - 'replace-node-text.js', - 'rpnt.js', - ], - fn: replaceNodeText, - world: 'ISOLATED', - dependencies: [ - 'replace-node-text.fn', - ], -}); -function replaceNodeText( - nodeName, - pattern, - replacement, - ...extraArgs -) { - replaceNodeTextFn(nodeName, pattern, replacement, ...extraArgs); -} - -/******************************************************************************* - * - * trusted-set-constant.js - * - * Set specified property to any value. This is essentially the same as - * set-constant.js, but with no restriction as to which values can be used. - * - **/ - -builtinScriptlets.push({ - name: 'trusted-set-constant.js', - requiresTrust: true, - aliases: [ - 'trusted-set.js', - ], - fn: trustedSetConstant, - dependencies: [ - 'set-constant.fn' - ], -}); -function trustedSetConstant( - ...args -) { - setConstantFn(true, ...args); -} - -/******************************************************************************* - * - * trusted-set-cookie.js - * - * Set specified cookie to an arbitrary value. - * - * Reference: - * https://github.com/AdguardTeam/Scriptlets/blob/master/src/scriptlets/trusted-set-cookie.js#L23 - * - **/ - -builtinScriptlets.push({ - name: 'trusted-set-cookie.js', - requiresTrust: true, - fn: trustedSetCookie, - world: 'ISOLATED', - dependencies: [ - 'safe-self.fn', - 'set-cookie.fn', - ], -}); -function trustedSetCookie( - name = '', - value = '', - offsetExpiresSec = '', - path = '' -) { - if ( name === '' ) { return; } - - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('set-cookie', name, value, path); - const time = new Date(); - - if ( value.includes('$now$') ) { - value = value.replaceAll('$now$', time.getTime()); - } - if ( value.includes('$currentDate$') ) { - value = value.replaceAll('$currentDate$', time.toUTCString()); - } - - let expires = ''; - if ( offsetExpiresSec !== '' ) { - if ( offsetExpiresSec === '1day' ) { - time.setDate(time.getDate() + 1); - } else if ( offsetExpiresSec === '1year' ) { - time.setFullYear(time.getFullYear() + 1); - } else { - if ( /^\d+$/.test(offsetExpiresSec) === false ) { return; } - time.setSeconds(time.getSeconds() + parseInt(offsetExpiresSec, 10)); - } - expires = time.toUTCString(); - } - - const done = setCookieFn( - true, - name, - value, - expires, - path, - safeSelf().getExtraArgs(Array.from(arguments), 4) - ); - - if ( done ) { - safe.uboLog(logPrefix, 'Done'); - } -} - -// For compatibility with AdGuard -builtinScriptlets.push({ - name: 'trusted-set-cookie-reload.js', - requiresTrust: true, - fn: trustedSetCookieReload, - world: 'ISOLATED', - dependencies: [ - 'trusted-set-cookie.js', - ], -}); -function trustedSetCookieReload(name, value, offsetExpiresSec, path, ...args) { - trustedSetCookie(name, value, offsetExpiresSec, path, 'reload', '1', ...args); -} - -/******************************************************************************* - * - * trusted-set-local-storage-item.js - * - * Set a local storage entry to an arbitrary value. - * - * Reference: - * https://github.com/AdguardTeam/Scriptlets/blob/master/src/scriptlets/trusted-set-local-storage-item.js - * - **/ - -builtinScriptlets.push({ - name: 'trusted-set-local-storage-item.js', - requiresTrust: true, - fn: trustedSetLocalStorageItem, - world: 'ISOLATED', - dependencies: [ - 'set-local-storage-item.fn', - ], -}); -function trustedSetLocalStorageItem(key = '', value = '') { - setLocalStorageItemFn('local', true, key, value); -} - -builtinScriptlets.push({ - name: 'trusted-set-session-storage-item.js', - requiresTrust: true, - fn: trustedSetSessionStorageItem, - world: 'ISOLATED', - dependencies: [ - 'set-local-storage-item.fn', - ], -}); -function trustedSetSessionStorageItem(key = '', value = '') { - setLocalStorageItemFn('session', true, key, value); -} - -/******************************************************************************* - * - * trusted-replace-fetch-response.js - * - * Replaces response text content of fetch requests if all given parameters - * match. - * - * Reference: - * https://github.com/AdguardTeam/Scriptlets/blob/master/src/scriptlets/trusted-replace-fetch-response.js - * - **/ - -builtinScriptlets.push({ - name: 'trusted-replace-fetch-response.js', - requiresTrust: true, - aliases: [ - 'trusted-rpfr.js', - ], - fn: trustedReplaceFetchResponse, - dependencies: [ - 'replace-fetch-response.fn', - ], -}); -function trustedReplaceFetchResponse(...args) { - replaceFetchResponseFn(true, ...args); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'trusted-replace-xhr-response.js', - requiresTrust: true, - fn: trustedReplaceXhrResponse, - dependencies: [ - 'match-object-properties.fn', - 'parse-properties-to-match.fn', - 'safe-self.fn', - ], -}); -function trustedReplaceXhrResponse( - pattern = '', - replacement = '', - propsToMatch = '' -) { - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('trusted-replace-xhr-response', pattern, replacement, propsToMatch); - const xhrInstances = new WeakMap(); - if ( pattern === '*' ) { pattern = '.*'; } - const rePattern = safe.patternToRegex(pattern); - const propNeedles = parsePropertiesToMatch(propsToMatch, 'url'); - self.XMLHttpRequest = class extends self.XMLHttpRequest { - open(method, url, ...args) { - const outerXhr = this; - const xhrDetails = { method, url }; - let outcome = 'match'; - if ( propNeedles.size !== 0 ) { - if ( matchObjectProperties(propNeedles, xhrDetails) === false ) { - outcome = 'nomatch'; - } - } - if ( outcome === 'match' ) { - if ( safe.logLevel > 1 ) { - safe.uboLog(logPrefix, `Matched "propsToMatch"`); - } - xhrInstances.set(outerXhr, 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 textBefore = innerResponse; - const textAfter = textBefore.replace(rePattern, replacement); - if ( textAfter !== textBefore ) { - safe.uboLog(logPrefix, 'Match'); - } - return (xhrDetails.response = textAfter); - } - get responseText() { - const response = this.response; - if ( typeof response !== 'string' ) { - return super.responseText; - } - return response; - } - }; -} - -/******************************************************************************* - * - * trusted-click-element.js - * - * Reference API: - * https://github.com/AdguardTeam/Scriptlets/blob/master/src/scriptlets/trusted-click-element.js - * - **/ - -builtinScriptlets.push({ - name: 'trusted-click-element.js', - requiresTrust: true, - fn: trustedClickElement, - world: 'ISOLATED', - dependencies: [ - 'get-all-cookies.fn', - 'get-all-local-storage.fn', - 'run-at-html-element.fn', - 'safe-self.fn', - ], -}); -function trustedClickElement( - selectors = '', - extraMatch = '', - delay = '' -) { - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('trusted-click-element', selectors, extraMatch, delay); - - if ( extraMatch !== '' ) { - const assertions = extraMatch.split(',').map(s => { - const pos1 = s.indexOf(':'); - const s1 = pos1 !== -1 ? s.slice(0, pos1) : s; - const not = s1.startsWith('!'); - const type = not ? s1.slice(1) : s1; - const s2 = pos1 !== -1 ? s.slice(pos1+1).trim() : ''; - if ( s2 === '' ) { return; } - const out = { not, type }; - const match = /^\/(.+)\/(i?)$/.exec(s2); - if ( match !== null ) { - out.re = new RegExp(match[1], match[2] || undefined); - return out; - } - const pos2 = s2.indexOf('='); - const key = pos2 !== -1 ? s2.slice(0, pos2).trim() : s2; - const value = pos2 !== -1 ? s2.slice(pos2+1).trim() : ''; - out.re = new RegExp(`^${this.escapeRegexChars(key)}=${this.escapeRegexChars(value)}`); - return out; - }).filter(details => details !== undefined); - const allCookies = assertions.some(o => o.type === 'cookie') - ? getAllCookiesFn() - : []; - const allStorageItems = assertions.some(o => o.type === 'localStorage') - ? getAllLocalStorageFn() - : []; - const hasNeedle = (haystack, needle) => { - for ( const { key, value } of haystack ) { - if ( needle.test(`${key}=${value}`) ) { return true; } - } - return false; - }; - for ( const { not, type, re } of assertions ) { - switch ( type ) { - case 'cookie': - if ( hasNeedle(allCookies, re) === not ) { return; } - break; - case 'localStorage': - if ( hasNeedle(allStorageItems, re) === not ) { return; } - break; - } - } - } - - const getShadowRoot = elem => { - // Firefox - if ( elem.openOrClosedShadowRoot ) { - return elem.openOrClosedShadowRoot; - } - // Chromium - if ( typeof chrome === 'object' ) { - if ( chrome.dom && chrome.dom.openOrClosedShadowRoot ) { - return chrome.dom.openOrClosedShadowRoot(elem); - } - } - return null; - }; - - const querySelectorEx = (selector, context = document) => { - const pos = selector.indexOf(' >>> '); - if ( pos === -1 ) { return context.querySelector(selector); } - const outside = selector.slice(0, pos).trim(); - const inside = selector.slice(pos + 5).trim(); - const elem = context.querySelector(outside); - if ( elem === null ) { return null; } - const shadowRoot = getShadowRoot(elem); - return shadowRoot && querySelectorEx(inside, shadowRoot); - }; - - const selectorList = selectors.split(/\s*,\s*/) - .filter(s => { - try { - void querySelectorEx(s); - } catch(_) { - return false; - } - return true; - }); - if ( selectorList.length === 0 ) { return; } - - const clickDelay = parseInt(delay, 10) || 1; - const t0 = Date.now(); - const tbye = t0 + 10000; - let tnext = selectorList.length !== 1 ? t0 : t0 + clickDelay; - - const terminate = ( ) => { - selectorList.length = 0; - next.stop(); - observe.stop(); - }; - - const next = notFound => { - if ( selectorList.length === 0 ) { - safe.uboLog(logPrefix, 'Completed'); - return terminate(); - } - const tnow = Date.now(); - if ( tnow >= tbye ) { - safe.uboLog(logPrefix, 'Timed out'); - return terminate(); - } - if ( notFound ) { observe(); } - const delay = Math.max(notFound ? tbye - tnow : tnext - tnow, 1); - next.timer = setTimeout(( ) => { - next.timer = undefined; - process(); - }, delay); - safe.uboLog(logPrefix, `Waiting for ${selectorList[0]}...`); - }; - next.stop = ( ) => { - if ( next.timer === undefined ) { return; } - clearTimeout(next.timer); - next.timer = undefined; - }; - - const observe = ( ) => { - if ( observe.observer !== undefined ) { return; } - observe.observer = new MutationObserver(( ) => { - if ( observe.timer !== undefined ) { return; } - observe.timer = setTimeout(( ) => { - observe.timer = undefined; - process(); - }, 20); - }); - observe.observer.observe(document, { - attributes: true, - childList: true, - subtree: true, - }); - }; - observe.stop = ( ) => { - if ( observe.timer !== undefined ) { - clearTimeout(observe.timer); - observe.timer = undefined; - } - if ( observe.observer ) { - observe.observer.disconnect(); - observe.observer = undefined; - } - }; - - const process = ( ) => { - next.stop(); - if ( Date.now() < tnext ) { return next(); } - const selector = selectorList.shift(); - if ( selector === undefined ) { return terminate(); } - const elem = querySelectorEx(selector); - if ( elem === null ) { - selectorList.unshift(selector); - return next(true); - } - safe.uboLog(logPrefix, `Clicked ${selector}`); - elem.click(); - tnext += clickDelay; - next(); - }; - - runAtHtmlElementFn(process); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'trusted-prune-inbound-object.js', - requiresTrust: true, - fn: trustedPruneInboundObject, - dependencies: [ - 'object-find-owner.fn', - 'object-prune.fn', - 'safe-self.fn', - ], -}); -function trustedPruneInboundObject( - entryPoint = '', - argPos = '', - rawPrunePaths = '', - rawNeedlePaths = '' -) { - if ( entryPoint === '' ) { return; } - let context = globalThis; - let prop = entryPoint; - for (;;) { - const pos = prop.indexOf('.'); - if ( pos === -1 ) { break; } - context = context[prop.slice(0, pos)]; - if ( context instanceof Object === false ) { return; } - prop = prop.slice(pos+1); - } - if ( typeof context[prop] !== 'function' ) { return; } - const argIndex = parseInt(argPos); - if ( isNaN(argIndex) ) { return; } - if ( argIndex < 1 ) { return; } - const safe = safeSelf(); - const extraArgs = safe.getExtraArgs(Array.from(arguments), 4); - const needlePaths = []; - if ( rawPrunePaths !== '' ) { - needlePaths.push(...rawPrunePaths.split(/ +/)); - } - if ( rawNeedlePaths !== '' ) { - needlePaths.push(...rawNeedlePaths.split(/ +/)); - } - const stackNeedle = safe.initPattern(extraArgs.stackToMatch || '', { canNegate: true }); - const mustProcess = root => { - for ( const needlePath of needlePaths ) { - if ( objectFindOwnerFn(root, needlePath) === false ) { - return false; - } - } - return true; - }; - context[prop] = new Proxy(context[prop], { - apply: function(target, thisArg, args) { - const targetArg = argIndex <= args.length - ? args[argIndex-1] - : undefined; - if ( targetArg instanceof Object && mustProcess(targetArg) ) { - let objBefore = targetArg; - if ( extraArgs.dontOverwrite ) { - try { - objBefore = safe.JSON_parse(safe.JSON_stringify(targetArg)); - } catch(_) { - objBefore = undefined; - } - } - if ( objBefore !== undefined ) { - const objAfter = objectPruneFn( - objBefore, - rawPrunePaths, - rawNeedlePaths, - stackNeedle, - extraArgs - ); - args[argIndex-1] = objAfter || objBefore; - } - } - return Reflect.apply(target, thisArg, args); - }, - }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'trusted-prune-outbound-object.js', - requiresTrust: true, - fn: trustedPruneOutboundObject, - dependencies: [ - 'object-prune.fn', - 'proxy-apply.fn', - 'safe-self.fn', - ], -}); -function trustedPruneOutboundObject( - propChain = '', - rawPrunePaths = '', - rawNeedlePaths = '' -) { - if ( propChain === '' ) { return; } - const safe = safeSelf(); - const extraArgs = safe.getExtraArgs(Array.from(arguments), 3); - const reflector = proxyApplyFn(propChain, function(...args) { - const objBefore = reflector(...args); - if ( objBefore instanceof Object === false ) { return objBefore; } - const objAfter = objectPruneFn( - objBefore, - rawPrunePaths, - rawNeedlePaths, - { matchAll: true }, - extraArgs - ); - return objAfter || objBefore; - }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'trusted-replace-argument.js', - requiresTrust: true, - fn: trustedReplaceArgument, - dependencies: [ - 'proxy-apply.fn', - 'safe-self.fn', - 'validate-constant.fn', - ], -}); -function trustedReplaceArgument( - propChain = '', - argpos = '', - argraw = '' -) { - if ( propChain === '' ) { return; } - if ( argpos === '' ) { return; } - if ( argraw === '' ) { return; } - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('trusted-replace-argument', propChain, argpos, argraw); - const extraArgs = safe.getExtraArgs(Array.from(arguments), 3); - const normalValue = validateConstantFn(true, argraw); - const reCondition = extraArgs.condition - ? safe.patternToRegex(extraArgs.condition) - : /^/; - const reflector = proxyApplyFn(propChain, function(...args) { - const arglist = args[args.length-1]; - if ( Array.isArray(arglist) === false ) { return reflector(...args); } - const argBefore = arglist[argpos]; - if ( reCondition.test(argBefore) === false ) { return reflector(...args); } - arglist[argpos] = normalValue; - safe.uboLog(logPrefix, `Replaced argument:\nBefore: ${JSON.stringify(argBefore)}\nAfter: ${normalValue}`); - return reflector(...args); - }); -} - -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'trusted-replace-outbound-text.js', - requiresTrust: true, - fn: trustedReplaceOutboundText, - dependencies: [ - 'proxy-apply.fn', - 'safe-self.fn', - ], -}); -function trustedReplaceOutboundText( - propChain = '', - pattern = '', - replacement = '', - ...args -) { - if ( propChain === '' ) { return; } - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('trusted-replace-outbound-text', propChain, pattern, replacement, ...args); - const rePattern = safe.patternToRegex(pattern); - const extraArgs = safe.getExtraArgs(args); - const reCondition = safe.patternToRegex(extraArgs.condition || ''); - const reflector = proxyApplyFn(propChain, function(...args) { - const textBefore = reflector(...args); - if ( pattern === '' ) { - safe.uboLog(logPrefix, 'Outbound text:\n', textBefore); - return textBefore; - } - reCondition.lastIndex = 0; - if ( reCondition.test(textBefore) === false ) { return textBefore; } - const textAfter = textBefore.replace(rePattern, replacement); - if ( textAfter === textBefore ) { return textBefore; } - safe.uboLog(logPrefix, 'Matched and replaced'); - if ( safe.logLevel > 1 ) { - safe.uboLog(logPrefix, 'Modified outbound text:\n', textAfter); - } - return textAfter; - }); -} - -/******************************************************************************/ \ No newline at end of file +// Stable entry point used by build.rs and CI. See README.md for provenance. +export { builtinScriptlets } from './scriptlet-resources/scriptlets.js'; diff --git a/privaxy/src/resources/vendor/ublock/urlskip.js b/privaxy/src/resources/vendor/ublock/urlskip.js new file mode 100644 index 00000000..7ca8644a --- /dev/null +++ b/privaxy/src/resources/vendor/ublock/urlskip.js @@ -0,0 +1,162 @@ +/******************************************************************************* + + uBlock Origin - a comprehensive, efficient content blocker + Copyright (C) 2022-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 +*/ + +/** + * @trustedOption urlskip + * + * @description + * Extract a URL from another URL according to one or more transformation steps, + * thereby skipping over intermediate network request(s) to remote servers. + * Requires a trusted source. + * + * @param steps + * A serie of space-separated directives representing the transformation steps + * to perform to extract the final URL to which a network request should be + * redirected. + * + * Supported directives: + * + * `?name`: extract the value of parameter `name` as the current string. + * + * `&i`: extract the name of the parameter at position `i` as the current + * string. The position is 1-based. + * + * `#`: extract the hash as the current string. + * + * `/.../`: extract the first capture group of a regex as the current string. + * + * `+https`: prepend the current string with `https://`. + * + * `-base64`: decode the current string as a base64-encoded string. + * + * `-safebase64`: decode the current string as a safe base64-encoded string. + * + * `-uricomponent`: decode the current string as a URI encoded string. + * + * `-blocked`: allow the redirection of blocked requests. By default, blocked + * requests can't by urlskip'ed. + * + * At any given step, the currently extracted string may not necessarily be + * a valid URL, and more transformation steps may be needed to obtain a valid + * URL once all the steps are applied. + * + * An unsupported step or a failed step will abort the transformation and no + * redirection will be performed. + * + * The final step is expected to yield a valid URL. If the result is not a + * valid URL, no redirection will be performed. + * + * @example + * ||example.com/path/to/tracker$urlskip=?url + * ||example.com/path/to/tracker$urlskip=?url ?to + * ||pixiv.net/jump.php?$urlskip=&1 + * ||podtrac.com/pts/redirect.mp3/$urlskip=/\/redirect\.mp3\/(.*?\.mp3\b)/ +https + * + * */ + +export function urlSkip(url, blocked, steps) { + try { + let redirectBlocked = false; + let urlout = url; + for ( const step of steps ) { + const urlin = urlout; + const c0 = step.charCodeAt(0); + // Extract from hash + if ( c0 === 0x23 && step === '#' ) { // # + const pos = urlin.indexOf('#'); + urlout = pos !== -1 ? urlin.slice(pos+1) : ''; + continue; + } + // Extract from URL parameter name at position i + if ( c0 === 0x26 ) { // & + const i = (parseInt(step.slice(1)) || 0) - 1; + if ( i < 0 ) { return; } + const url = new URL(urlin); + if ( i >= url.searchParams.size ) { return; } + const params = Array.from(url.searchParams.keys()); + urlout = decodeURIComponent(params[i]); + continue; + } + // Enforce https + if ( c0 === 0x2B && step === '+https' ) { // + + const s = urlin.replace(/^https?:\/\//, ''); + if ( /^[\w-]:\/\//.test(s) ) { return; } + urlout = `https://${s}`; + continue; + } + // Decode + if ( c0 === 0x2D ) { // - + // Base64 + if ( step === '-base64' ) { + urlout = self.atob(urlin); + continue; + } + // Safe Base64 + if ( step === '-safebase64' ) { + if ( urlSkip.safeBase64Replacer === undefined ) { + urlSkip.safeBase64Map = { '-': '+', '_': '/' }; + urlSkip.safeBase64Replacer = s => urlSkip.safeBase64Map[s]; + } + urlout = urlin.replace(/[-_]/g, urlSkip.safeBase64Replacer); + urlout = self.atob(urlout); + continue; + } + // URI component + if ( step === '-uricomponent' ) { + urlout = decodeURIComponent(urlin); + continue; + } + // Enable skip of blocked requests + if ( step === '-blocked' ) { + redirectBlocked = true; + continue; + } + } + // Regex extraction from first capture group + if ( c0 === 0x2F ) { // / + const re = new RegExp(step.slice(1, -1)); + const match = re.exec(urlin); + if ( match === null ) { return; } + if ( match.length <= 1 ) { return; } + urlout = match[1]; + continue; + } + // Extract from URL parameter + if ( c0 === 0x3F ) { // ? + urlout = (new URL(urlin)).searchParams.get(step.slice(1)); + if ( urlout === null ) { return; } + if ( urlout.includes(' ') ) { + urlout = urlout.replace(/ /g, '%20'); + } + continue; + } + // Unknown directive + return; + } + const urlfinal = new URL(urlout); + if ( urlfinal.protocol !== 'https:' ) { + if ( urlfinal.protocol !== 'http:' ) { return; } + } + if ( blocked && redirectBlocked !== true ) { return; } + return urlout; + } catch { + } +} diff --git a/privaxy/src/server/blocker.rs b/privaxy/src/server/blocker.rs index ab21b30c..ae53047a 100644 --- a/privaxy/src/server/blocker.rs +++ b/privaxy/src/server/blocker.rs @@ -1,11 +1,12 @@ use crate::blocker_utils::{build_resource_from_file_contents, read_redirectable_resource_mapping}; +use crate::filter_list::{preprocess, FilterList, TRUSTED_SCRIPTLETS}; use adblock::lists::FilterSet; use adblock::request::Request; use adblock::resources::Resource; use adblock::Engine; use include_dir::{include_dir, Dir}; use lazy_static::lazy_static; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, RwLock}; #[derive(Debug, Clone)] @@ -101,13 +102,74 @@ fn unmatched_result() -> adblock::blocker::BlockerResult { /// Build an engine from raw filter lists. CPU-heavy — seconds for full-size /// lists — so callers must not run it on an async task directly. -fn build_engine(filters: Vec) -> Engine { +fn build_engine(filters: Vec) -> Engine { log::debug!("Configuring blocking engine."); let mut filter_set = FilterSet::new(true); for filter in filters { - filter_set.add_filter_list(&filter, adblock::lists::ParseOptions::default()); + let parsed = match preprocess(&filter.contents) { + Ok(parsed) => parsed, + Err(error) => { + log::warn!("Skipping filter list '{}': {error}", filter.name); + continue; + } + }; + if parsed.unknown_conditions > 0 { + log::warn!( + "Filter list '{}': {} unknown conditions left unfiltered", + filter.name, + parsed.unknown_conditions + ); + } + let options = adblock::lists::ParseOptions { + permissions: if filter.trusted { + TRUSTED_SCRIPTLETS + } else { + Default::default() + }, + ..Default::default() + }; + let mut supported = 0; + let mut unsupported = 0; + let mut rejection_reasons = BTreeMap::::new(); + for line in parsed.lines { + if line.is_empty() + || line.starts_with('!') + || line.starts_with("[Adblock") + || line + .strip_prefix('#') + .is_some_and(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace)) + { + continue; + } + match filter_set.add_filter(line, options) { + Ok(()) => supported += 1, + Err(error) => { + unsupported += 1; + // Regex errors may contain the full rule. Keep diagnostics + // useful without logging custom URLs or query parameters. + let reason = match error { + adblock::lists::FilterParseError::Network( + adblock::filters::network::NetworkFilterError::RegexParsingError(_), + ) => "network filter error: invalid regular expression".to_owned(), + error => error.to_string(), + }; + *rejection_reasons.entry(reason).or_default() += 1; + } + } + } + if unsupported > 0 { + let reasons = rejection_reasons + .iter() + .map(|(reason, count)| format!("{count} {reason}")) + .collect::>() + .join("; "); + log::warn!( + "Filter list '{}': loaded {supported} rules; skipped {unsupported} unsupported or invalid rules ({reasons})", + filter.name + ); + } } let mut adblock_engine = Engine::from_filter_set(filter_set, true); @@ -198,7 +260,7 @@ impl AdblockRequester { self.engine.read().unwrap().clone() } - pub(crate) async fn replace_engine(&self, filters: Vec) { + pub(crate) async fn replace_engine(&self, filters: Vec) { let new_engine = tokio::task::spawn_blocking(move || build_engine(filters)) .await .expect("engine build task panicked"); @@ -271,6 +333,72 @@ impl AdblockRequester { mod tests { use super::*; + fn list(contents: &str) -> FilterList { + FilterList { + name: "Test list".to_owned(), + contents: contents.to_owned(), + trusted: false, + } + } + + #[test] + fn conditional_blocks_affect_matching_and_do_not_leak_between_lists() { + let engine = build_engine(vec![ + list("!#if cap_html_filtering\n||html.example^\n!#else\n||fallback.example^\n!#endif"), + list("||other.example^"), + ]); + for (host, expected) in [ + ("html.example", false), + ("fallback.example", true), + ("other.example", true), + ] { + let request = Request::new( + &format!("https://{host}/ad.js"), + "https://site.example/", + "script", + ) + .unwrap(); + assert_eq!(engine.check_network_request(&request).matched, expected); + } + } + + #[test] + fn modern_json_edit_scriptlets_include_their_shared_dependencies() { + let engine = build_engine(vec![list("m.youtube.com##+js(json-edit-fetch-response, ..itemSectionRenderer.contents.*[?.adSlotRenderer], propsToMatch, /get_watch)")]); + let result = cosmetic_response(&engine, "https://m.youtube.com/"); + let script = result.injected_script.unwrap(); + assert!(script.contains("function jsonEditFetchResponse(")); + assert!(script.contains("class JSONPath")); + assert!(script.contains("function safeSelf(")); + assert!(script.contains("jsonEditFetchResponse(\"..itemSectionRenderer.contents.*[?.adSlotRenderer]\", \"propsToMatch\", \"/get_watch\")")); + } + + #[test] + fn trusted_scriptlets_require_trusted_list_permissions() { + let rules = + "example.com##+js(trusted-replace-fetch-response, adPlacements, no_ads, player?)"; + let engine = build_engine(vec![list(rules)]); + assert!(cosmetic_response(&engine, "https://example.com/") + .injected_script + .is_none()); + let mut trusted = list(rules); + trusted.trusted = true; + let engine = build_engine(vec![trusted]); + assert!(cosmetic_response(&engine, "https://example.com/") + .injected_script + .is_some()); + } + + #[test] + fn scriptlets_receive_more_than_nine_arguments_and_no_literal_placeholders() { + let engine = build_engine(vec![list("example.com##+js(json-prune-fetch-response, ads, , propsToMatch, player, log, 1, stackToMatch, test, extra, tenth, eleventh)")]); + let script = cosmetic_response(&engine, "https://example.com/") + .injected_script + .unwrap(); + assert!(script.contains("\"tenth\", \"eleventh\")")); + assert!(!script.contains("{{1}}")); + } + fn requester() -> AdblockRequester { AdblockRequester::new(BlockingDisabledStore(Arc::new(RwLock::new(false)))) } @@ -291,7 +419,7 @@ mod tests { async fn replace_engine_applies_network_filters() { let requester = requester(); requester - .replace_engine(vec![String::from("||ads.example.com^")]) + .replace_engine(vec![list("||ads.example.com^")]) .await; let (blocked, result) = requester.is_network_url_blocked( @@ -316,7 +444,7 @@ mod tests { async fn disabling_blocking_bypasses_matching() { let requester = requester(); requester - .replace_engine(vec![String::from("||ads.example.com^")]) + .replace_engine(vec![list("||ads.example.com^")]) .await; requester.blocking_disabled.set(false); @@ -338,9 +466,7 @@ mod tests { async fn cosmetic_lookup_splits_url_scoped_and_generic_selectors() { let requester = requester(); requester - .replace_engine(vec![String::from( - "example.com###site-ad\n##.generic-ad-class", - )]) + .replace_engine(vec![list("example.com###site-ad\n##.generic-ad-class")]) .await; // The URL-scoped lookup carries the specific selector but not the diff --git a/privaxy/src/server/configuration/filter.rs b/privaxy/src/server/configuration/filter.rs index cde79aeb..c61773e1 100644 --- a/privaxy/src/server/configuration/filter.rs +++ b/privaxy/src/server/configuration/filter.rs @@ -6,6 +6,10 @@ use std::path::{Path, PathBuf}; use tokio::fs; use url::Url; +use crate::filter_list::{is_trusted_source, preprocess, FilterList}; + +mod download; + use serde_with::{serde_as, DisplayFromStr}; pub(crate) const FILTERS_DIRECTORY_NAME: &str = "filters"; @@ -121,13 +125,13 @@ impl DefaultFilters { fn get_default_filters() -> Vec { vec![ - ("https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt", "uBlock filters", FilterGroup::Default, true), + ("https://raw.githubusercontent.com/uBlockOrigin/uAssetsCDN/main/filters/filters.min.txt", "uBlock Origin filters - Ads", FilterGroup::Default, true), ("https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters-mobile.txt", "uBlock mobile filters", FilterGroup::Default, true), - ("https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/badware.txt", "uBlock filters - Badware risks", FilterGroup::Default, true), - ("https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/privacy.txt", "uBlock filters - Privacy", FilterGroup::Default, true), + ("https://raw.githubusercontent.com/uBlockOrigin/uAssetsCDN/main/filters/badware.min.txt", "uBlock Origin filters - Badware risks", FilterGroup::Default, true), + ("https://raw.githubusercontent.com/uBlockOrigin/uAssetsCDN/main/filters/privacy.min.txt", "uBlock Origin filters - Privacy", FilterGroup::Default, true), ("https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/resource-abuse.txt", "uBlock filters - Resource abuse", FilterGroup::Default, true), - ("https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/unbreak.txt", "uBlock filters - Unbreak", FilterGroup::Default, true), - ("https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/quick-fixes.txt", "uBlock filters - Quick Fixes", FilterGroup::Default, true), + ("https://raw.githubusercontent.com/uBlockOrigin/uAssetsCDN/main/filters/unbreak.min.txt", "uBlock Origin filters - Unbreak", FilterGroup::Default, true), + ("https://raw.githubusercontent.com/uBlockOrigin/uAssetsCDN/main/filters/quick-fixes.min.txt", "uBlock Origin filters - Quick fixes", FilterGroup::Default, true), ] .into_iter() .filter_map(|(url, title, group, enabled_by_default)| Self::parse_filter(url, title, group, enabled_by_default)) @@ -259,6 +263,58 @@ pub(crate) fn calculate_sha256_hex(input: &str) -> String { hex::encode(hasher.finalize()) } +/// Upgrade only the five known source subscriptions. Existing CDN entries take +/// precedence, including their enabled state; unrelated lists are untouched. +/// A new filename forces a download of the complete bundle instead of reusing +/// the old source fragment. Old cache files remain available for rollback. +pub(crate) fn migrate_ublock_sources(filters: &mut Vec) -> bool { + let mut changed = false; + let defaults = DefaultFilters::new(); + for name in ["filters", "badware", "privacy", "quick-fixes", "unbreak"] { + let legacy = format!( + "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/{name}.txt" + ); + let legacy_ref = format!("https://raw.githubusercontent.com/uBlockOrigin/uAssets/refs/heads/master/filters/{name}.txt"); + let canonical = format!( + "https://raw.githubusercontent.com/uBlockOrigin/uAssetsCDN/main/filters/{name}.min.txt" + ); + let is_legacy = + |filter: &Filter| filter.url.as_str() == legacy || filter.url.as_str() == legacy_ref; + if !filters.iter().any(is_legacy) { + continue; + } + if filters + .iter() + .any(|filter| filter.url.as_str() == canonical) + { + filters.retain(|filter| !is_legacy(filter)); + } else { + let replacement = defaults + .0 + .iter() + .find(|filter| filter.url.as_str() == canonical) + .unwrap(); + // Both spellings may be present. Keep the first entry's settings. + let mut migrated = false; + filters.retain_mut(|filter| { + if !is_legacy(filter) { + return true; + } + if migrated { + return false; + } + filter.url = replacement.url.clone(); + filter.file_name = replacement.file_name.clone(); + filter.title = replacement.title.clone(); + migrated = true; + true + }); + } + changed = true; + } + changed +} + pub(crate) fn calc_filter_filename(filename: &str) -> String { format!("{}.txt", calculate_sha256_hex(filename)) } @@ -460,6 +516,9 @@ fn validate_filter_content_type( /// Verifies at least one rule is present. fn validate_filter_rules(filter: &Filter, contents: &str) -> super::ConfigurationResult<()> { + preprocess(contents).map_err(|error| { + super::ConfigurationError::FilterValidationError(format!("{}: {error}", filter.title)) + })?; // Validation also runs on cache hits. Stop at the first usable rule rather // than parsing every list twice before building the blocking engine. let has_rule = contents.lines().any(|line| { @@ -479,24 +538,7 @@ pub(crate) async fn get_filter( filter: &mut Filter, http_client: &reqwest::Client, ) -> super::ConfigurationResult { - let response = http_client.get(filter.url.as_str()).send().await?; - if response.status().is_success() { - validate_filter_content_type(filter, &response)?; - let content = response.text().await?; - validate_filter_rules(filter, &content)?; - Ok(content) - } else { - log::error!( - "Failed to fetch filter content for {}: {}", - filter.title, - response.status() - ); - Err(super::ConfigurationError::FilterError(format!( - "Failed to fetch filter content for {}: {}", - filter.title, - response.status() - ))) - } + download::get_filter(filter, http_client).await } fn get_filter_directory() -> PathBuf { @@ -512,7 +554,7 @@ pub(crate) async fn get_filters_content( configuration: &mut super::Configuration, http_client: &reqwest::Client, filter_failure_store: &super::FilterFailureStore, -) -> Vec { +) -> Vec { let mut filters = Vec::new(); let enabled_count = configuration .filters @@ -538,7 +580,11 @@ pub(crate) async fn get_filters_content( filter.title, filter_content.len() ); - filters.push(filter_content); + filters.push(FilterList { + name: filter.title.clone(), + contents: filter_content, + trusted: is_trusted_source(&filter.url), + }); } // A fetch failure here includes a filter whose URL has stopped serving a // `text/plain` list (see `validate_filter_content_type`); we record it for the @@ -561,10 +607,15 @@ pub(crate) async fn get_filters_content( log::error!("No enabled filter lists could be loaded; only custom rules are available"); } - filters.extend(configuration.custom_filters.iter().cloned()); - filters.sort_unstable(); - // Filter out duplicate lines, if present - filters.dedup(); + // Custom rules are explicitly supplied by the operator. Keep their order + // and scope together so conditional blocks can span multiple lines. + if !configuration.custom_filters.is_empty() { + filters.push(FilterList { + name: "Custom filters".to_owned(), + contents: configuration.custom_filters.join("\n"), + trusted: true, + }); + } filters } @@ -572,10 +623,91 @@ pub(crate) async fn get_filters_content( mod tests { use super::*; - struct TestCache(PathBuf); + pub(super) fn test_client() -> reqwest::Client { + let _ = rustls::crypto::ring::default_provider().install_default(); + reqwest::Client::builder().no_proxy().build().unwrap() + } + + pub(super) fn subscription(url: &str, enabled: bool) -> Filter { + Filter { + enabled, + title: "Existing subscription".to_owned(), + group: FilterGroup::Default, + file_name: calc_filter_filename(url), + url: Url::parse(url).unwrap(), + } + } + + #[test] + fn source_migration_preserves_settings_and_uses_new_cache_names() { + let mut filters: Vec<_> = ["filters", "badware", "privacy", "quick-fixes", "unbreak"] + .iter().enumerate().map(|(index, name)| subscription( + &format!("https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/{name}.txt"), + index % 2 == 0, + )).collect(); + let unchanged = subscription("https://example.test/custom.txt", false); + let mobile = subscription("https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters-mobile.txt", false); + filters.extend([unchanged.clone(), mobile.clone()]); + let old = filters.clone(); + assert!(migrate_ublock_sources(&mut filters)); + assert_eq!(filters.len(), old.len()); + for (before, after) in old[..5].iter().zip(&filters[..5]) { + assert_eq!(before.enabled, after.enabled); + assert_eq!(before.group, after.group); + assert_ne!(before.file_name, after.file_name); + assert_eq!(after.file_name, calc_filter_filename(after.url.as_str())); + assert!(DefaultFilters::new() + .file_names() + .contains(&after.file_name)); + } + assert_eq!(filters[5..], [unchanged, mobile]); + let migrated = filters.clone(); + assert!(!migrate_ublock_sources(&mut filters)); + assert_eq!(filters, migrated); + } + + #[test] + fn existing_cdn_subscription_wins_over_legacy_duplicates() { + let cdn = "https://raw.githubusercontent.com/uBlockOrigin/uAssetsCDN/main/filters/filters.min.txt"; + for enabled in [true, false] { + let mut canonical = subscription(cdn, enabled); + canonical.title = "My CDN subscription".to_owned(); + let mut filters = vec![ + subscription("https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt", !enabled), + canonical.clone(), + subscription("https://raw.githubusercontent.com/uBlockOrigin/uAssets/refs/heads/master/filters/filters.txt", !enabled), + ]; + assert!(migrate_ublock_sources(&mut filters)); + assert_eq!(filters, [canonical]); + } + } + + #[test] + fn migration_recognizes_explicit_branch_refs_without_adding_absent_lists() { + let mut filters = vec![subscription("https://raw.githubusercontent.com/uBlockOrigin/uAssets/refs/heads/master/filters/privacy.txt", false)]; + assert!(migrate_ublock_sources(&mut filters)); + assert_eq!(filters.len(), 1); + assert!(!filters[0].enabled); + assert!(filters[0] + .url + .as_str() + .ends_with("uAssetsCDN/main/filters/privacy.min.txt")); + let mut empty = Vec::new(); + assert!(!migrate_ublock_sources(&mut empty)); + assert!(empty.is_empty()); + } + + #[test] + fn malformed_conditional_lists_are_not_accepted_into_the_cache() { + let filter = cached_filter(); + assert!(validate_filter_rules(&filter, "!#if false\n||ads.example.com^").is_err()); + assert!(validate_filter_rules(&filter, "!#if false\n||ads.example.com^\n!#endif").is_ok()); + } + + pub(super) struct TestCache(pub(super) PathBuf); impl TestCache { - fn new() -> Self { + pub(super) fn new() -> Self { let directory = std::env::temp_dir().join(format!( "privaxy-filter-cache-{}", super::super::generate_random_hex(8) @@ -612,7 +744,7 @@ mod tests { let alternate = path.with_extension(""); let rules = "||ads.example.com^\nexample.com##.banner\n"; fs::write(&alternate, rules).await.unwrap(); - let client = reqwest::Client::new(); + let client = test_client(); for invalid in [ None, @@ -646,7 +778,7 @@ mod tests { .unwrap(); assert_eq!( filter - .get_contents_in_directory(&reqwest::Client::new(), &cache.0) + .get_contents_in_directory(&test_client(), &cache.0) .await .unwrap(), rules @@ -663,7 +795,7 @@ mod tests { .await .unwrap(); let error = filter - .get_contents_in_directory(&reqwest::Client::new(), &cache.0) + .get_contents_in_directory(&test_client(), &cache.0) .await .unwrap_err(); assert!(matches!( diff --git a/privaxy/src/server/configuration/filter/download.rs b/privaxy/src/server/configuration/filter/download.rs new file mode 100644 index 00000000..a85b6202 --- /dev/null +++ b/privaxy/src/server/configuration/filter/download.rs @@ -0,0 +1,401 @@ +//! Expand uBO includes before a subscription replaces its on-disk cache. +use std::time::Duration; + +use url::Url; + +use super::super::{ConfigurationError, ConfigurationResult}; +use super::{validate_filter_content_type, validate_filter_rules, Filter}; +use crate::filter_list::{is_trusted_source, preprocess_for_download}; + +const MAX_FILES: usize = 64; +const MAX_DEPTH: usize = 16; +const MAX_BYTES: usize = 64 * 1024 * 1024; + +/// uBO includes must stay in the parent's directory or one of its descendants. +/// Reject URL/encoding tricks before joining, including encoded path traversal. +fn include_url(parent: &Url, path: &str) -> Result { + if path.is_empty() + || path.starts_with('/') + || path.contains("..") + || path.contains(['\\', ':', '%', '?', '#']) + || path.chars().any(char::is_whitespace) + { + return Err("!#include must name a relative file in the same directory or a subdirectory"); + } + let child = parent.join(path).map_err(|_| "invalid !#include path")?; + if !within_directory(parent, &child) { + return Err("!#include cannot leave its parent's directory or origin"); + } + Ok(child) +} + +fn within_directory(parent: &Url, child: &Url) -> bool { + let directory_end = parent.path().rfind('/').map_or(0, |index| index + 1); + matches!(parent.scheme(), "http" | "https") + && parent.origin() == child.origin() + && child.path().starts_with(&parent.path()[..directory_end]) +} + +struct Download<'a> { + filter: &'a Filter, + client: &'a reqwest::Client, + ancestors: Vec, + files: usize, + bytes: usize, +} + +impl Download<'_> { + fn invalid(&self, message: impl std::fmt::Display) -> ConfigurationError { + ConfigurationError::FilterValidationError(format!("{}: {message}", self.filter.title)) + } + + async fn load(&mut self, url: &Url) -> ConfigurationResult { + if self.ancestors.contains(url) { + return Err(self.invalid("cyclic !#include")); + } + if self.ancestors.len() > MAX_DEPTH { + return Err(self.invalid("!#include nesting exceeds 16 levels")); + } + self.files += 1; + if self.files > MAX_FILES { + return Err(self.invalid("subscription exceeds 64 files")); + } + if is_trusted_source(&self.filter.url) && !is_trusted_source(url) { + return Err(self.invalid("!#include cannot leave the trusted filter source")); + } + + let mut response = self + .client + .get(url.clone()) + .timeout(Duration::from_secs(60)) + .send() + .await?; + if !response.status().is_success() { + return Err(ConfigurationError::FilterError(format!( + "Failed to fetch filter content for {}: {}", + self.filter.title, + response.status() + ))); + } + // The shared client disables redirects. Also check the final URL when + // called with another client, so fetched content cannot gain trust by + // redirecting out of the permitted source. + if (is_trusted_source(&self.filter.url) && !is_trusted_source(response.url())) + || self + .ancestors + .last() + .is_some_and(|parent| !within_directory(parent, response.url())) + { + return Err(self.invalid("filter redirect leaves the permitted source")); + } + validate_filter_content_type(self.filter, &response)?; + let headers = response.headers().clone(); + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await? { + self.bytes = self.bytes.saturating_add(chunk.len()); + if self.bytes > MAX_BYTES { + return Err(self.invalid("combined subscription exceeds 64 MiB")); + } + bytes.extend_from_slice(&chunk); + } + // Preserve reqwest's charset decoding after reading a bounded body. + let mut response = reqwest::Response::from(http::Response::new(bytes)); + *response.headers_mut() = headers; + let content = response.text().await?; + let parsed = preprocess_for_download(&content).map_err(|error| self.invalid(error))?; + if parsed.includes.is_empty() { + return Ok(content); + } + + self.ancestors.push(url.clone()); + let mut expanded = String::new(); + let mut includes = parsed.includes.into_iter().peekable(); + for (index, line) in content.lines().enumerate() { + if let Some(&(include_index, path)) = includes.peek() { + if index == include_index { + includes.next(); + let child = include_url(url, path).map_err(|error| self.invalid(error))?; + let fragment = Box::pin(self.load(&child)) + .await + .map_err(|error| self.invalid(format!("!#include {path}: {error}")))?; + self.append(&mut expanded, &format!("! Included: {path}\n"))?; + self.append(&mut expanded, &fragment)?; + self.append(&mut expanded, "\n! End include\n")?; + continue; + } + } + self.append(&mut expanded, line)?; + self.append(&mut expanded, "\n")?; + } + self.ancestors.pop(); + Ok(expanded) + } + + fn append(&self, output: &mut String, text: &str) -> ConfigurationResult<()> { + if output.len().saturating_add(text.len()) > MAX_BYTES { + return Err(self.invalid("expanded subscription exceeds 64 MiB")); + } + output.push_str(text); + Ok(()) + } +} + +pub(super) async fn get_filter( + filter: &Filter, + client: &reqwest::Client, +) -> ConfigurationResult { + let mut download = Download { + filter, + client, + ancestors: Vec::new(), + files: 0, + bytes: 0, + }; + let content = download.load(&filter.url).await?; + validate_filter_rules(filter, &content)?; + Ok(content) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + use tokio::task::JoinHandle; + + use super::*; + use crate::configuration::filter::tests::{subscription, test_client, TestCache}; + use crate::filter_list::preprocess; + + struct Server { + url: Url, + requests: Arc>>, + task: JoinHandle<()>, + } + + impl Server { + async fn new(routes: &[(&str, &str)]) -> Self { + let routes: HashMap<_, _> = routes + .iter() + .map(|(path, body)| (path.to_string(), body.to_string())) + .collect(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let recorded = Arc::clone(&requests); + let task = tokio::spawn(async move { + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut chunk = [0; 1024]; + while !request.ends_with(b"\r\n\r\n") { + let len = stream.read(&mut chunk).await.unwrap(); + if len == 0 { + break; + } + request.extend_from_slice(&chunk[..len]); + } + let request = String::from_utf8(request).unwrap(); + let path = request.split_whitespace().nth(1).unwrap(); + recorded.lock().unwrap().push(path.to_owned()); + let (status, body) = match routes.get(path) { + Some(body) => ("200 OK", body.as_str()), + None => ("404 Not Found", "missing"), + }; + let mime = if body.starts_with("") { + "text/html" + } else { + "text/plain; charset=utf-8" + }; + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + } + }); + Self { + url, + requests, + task, + } + } + + fn filter(&self) -> Filter { + subscription(self.url.join("lists/main.txt").unwrap().as_str(), true) + } + } + + impl Drop for Server { + fn drop(&mut self) { + self.task.abort(); + } + } + + #[test] + fn include_paths_cannot_leave_the_subscription_directory() { + let base = Url::parse("https://example.com/lists/main.txt").unwrap(); + for path in ["child.txt", "nested/child.txt", "./child.txt"] { + assert!(include_url(&base, path).is_ok(), "{path}"); + } + for path in [ + "", + "../child.txt", + "/child.txt", + "//other.example/child.txt", + "https://other.example/child.txt", + "file:///etc/passwd", + "sub/../../child.txt", + "%2e%2e/child.txt", + "sub%2fchild.txt", + "sub\\child.txt", + "child.txt?url=other", + "child.txt#fragment", + ] { + assert!(include_url(&base, path).is_err(), "{path}"); + } + assert!(!within_directory( + &base, + &Url::parse("https://example.com/lists-other/child.txt").unwrap() + )); + } + + #[tokio::test] + async fn nested_includes_upgrade_old_caches_and_survive_offline_reload() { + let main = "!#if ext_ublock\n!#include child.txt\n!#else\n!#include missing.txt\n!#endif\n||after.example^"; + let server = Server::new(&[ + ("/lists/main.txt", main), + ( + "/lists/child.txt", + "||child.example^\n!#include nested/leaf.txt", + ), + ( + "/lists/nested/leaf.txt", + "!#if cap_html_filtering\n||unused.example^\n!#else\n||leaf.example^\n!#endif", + ), + ]) + .await; + let cache = TestCache::new(); + let mut filter = server.filter(); + let path = cache.0.join(&filter.file_name); + // Old versions cached unresolved include directives as comments. + tokio::fs::write(&path, main).await.unwrap(); + let client = test_client(); + let expanded = filter + .get_contents_in_directory(&client, &cache.0) + .await + .unwrap(); + let active = preprocess(&expanded).unwrap(); + let rules: Vec<_> = active + .lines + .into_iter() + .filter(|line| line.starts_with("||")) + .collect(); + assert_eq!( + rules, + ["||child.example^", "||leaf.example^", "||after.example^"] + ); + assert_eq!( + *server.requests.lock().unwrap(), + [ + "/lists/main.txt", + "/lists/child.txt", + "/lists/nested/leaf.txt" + ] + ); + assert_eq!(tokio::fs::read_to_string(path).await.unwrap(), expanded); + drop(server); + assert_eq!( + filter + .get_contents_in_directory(&client, &cache.0) + .await + .unwrap(), + expanded + ); + } + + #[tokio::test] + async fn failed_includes_preserve_the_last_complete_cache() { + for (fragment, expected_error) in [ + (None, "404"), + (Some("not a list"), "text/html"), + (Some("!#endif\n||bad.example^"), "unmatched !#endif"), + (Some("!#include main.txt"), "cyclic !#include"), + (Some("!#include ../outside.txt"), "relative file"), + ] { + let mut routes = vec![("/lists/main.txt", "||new.example^\n!#include child.txt")]; + if let Some(fragment) = fragment { + routes.push(("/lists/child.txt", fragment)); + } + let server = Server::new(&routes).await; + let mut filter = server.filter(); + let client = test_client(); + let cache = TestCache::new(); + let path = cache.0.join(&filter.file_name); + let previous = "||previous.example^\n"; + tokio::fs::write(&path, previous).await.unwrap(); + let error = filter + .update_in_directory(&client, &cache.0) + .await + .unwrap_err() + .to_string(); + assert!(error.contains(expected_error), "{error}"); + assert_eq!(tokio::fs::read_to_string(path).await.unwrap(), previous); + assert_eq!( + filter + .get_contents_in_directory(&client, &cache.0) + .await + .unwrap(), + previous + ); + } + } + + #[tokio::test] + async fn subscriptions_can_consist_only_of_includes() { + let server = Server::new(&[ + ("/lists/main.txt", "!#include child.txt"), + ("/lists/child.txt", "||ads.example^"), + ]) + .await; + let result = get_filter(&server.filter(), &test_client()).await.unwrap(); + assert!(result.contains("||ads.example^")); + } + + #[tokio::test] + async fn download_limits_and_trust_are_enforced() { + let server = Server::new(&[("/lists/main.txt", "||ads.example^")]).await; + let filter = server.filter(); + let client = test_client(); + let mut download = Download { + filter: &filter, + client: &client, + ancestors: Vec::new(), + files: MAX_FILES, + bytes: 0, + }; + let error = download.load(&filter.url).await.unwrap_err().to_string(); + assert!(error.contains("64 files")); + download.files = 0; + download.ancestors = vec![server.url.clone(); MAX_DEPTH + 1]; + let error = download.load(&filter.url).await.unwrap_err().to_string(); + assert!(error.contains("nesting exceeds")); + download.ancestors.clear(); + download.bytes = MAX_BYTES; + let error = download.load(&filter.url).await.unwrap_err().to_string(); + assert!(error.contains("64 MiB")); + assert_eq!(server.requests.lock().unwrap().len(), 1); + + let trusted = subscription( + "https://raw.githubusercontent.com/uBlockOrigin/uAssetsCDN/main/filters/filters.min.txt", + true, + ); + download.filter = &trusted; + download.bytes = 0; + let error = download.load(&filter.url).await.unwrap_err().to_string(); + assert!(error.contains("trusted filter source")); + assert_eq!(server.requests.lock().unwrap().len(), 1); + } +} diff --git a/privaxy/src/server/configuration/mod.rs b/privaxy/src/server/configuration/mod.rs index faeb4220..412f4159 100644 --- a/privaxy/src/server/configuration/mod.rs +++ b/privaxy/src/server/configuration/mod.rs @@ -148,7 +148,8 @@ impl Configuration { Ok(bytes) => { let mut configuration: Configuration = toml::from_str(std::str::from_utf8(&bytes)?)?; - if configuration.ensure_auth_keys() { + let migrated_filters = migrate_ublock_sources(&mut configuration.filters); + if configuration.ensure_auth_keys() || migrated_filters { configuration.save().await?; } Ok(configuration) diff --git a/privaxy/src/server/filter_list.rs b/privaxy/src/server/filter_list.rs new file mode 100644 index 00000000..2b9392c2 --- /dev/null +++ b/privaxy/src/server/filter_list.rs @@ -0,0 +1,284 @@ +//! uBO conditional directives and source permissions, before adblock-rust parsing. +use adblock::resources::PermissionMask; +use url::Url; + +/// Also used by build-scriptlets.mjs for upstream `requiresTrust` resources. +pub(crate) const TRUSTED_SCRIPTLETS: PermissionMask = PermissionMask::from_bits(1); + +pub(crate) struct FilterList { + pub name: String, + pub contents: String, + pub trusted: bool, +} + +/// Only the official uBO filter repositories have automatic scriptlet trust. +/// In particular, neither a lookalike host nor arbitrary GitHub lists qualify. +pub(crate) fn is_trusted_source(url: &Url) -> bool { + url.scheme() == "https" + && url.host_str() == Some("raw.githubusercontent.com") + && url.port().is_none() + && url.username().is_empty() + && url.password().is_none() + && [ + "/uBlockOrigin/uAssets/master/filters/", + "/uBlockOrigin/uAssets/refs/heads/master/filters/", + "/uBlockOrigin/uAssetsCDN/main/filters/", + ] + .iter() + .any(|prefix| { + url.path().strip_prefix(prefix).is_some_and(|name| { + name.ends_with(".txt") + && name.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') + }) + }) + }) +} + +/// A portable uBO syntax profile. A shared proxy cannot assume its clients use +/// Firefox, Chromium, or a mobile browser. HTML rewriting for injection does not +/// implement uBO HTML filtering/$replace or $ipaddress, so those capabilities +/// must remain false and lists can select their scriptlet fallbacks instead. +fn token_value(token: &str) -> Option { + let (negated, token) = match token.strip_prefix('!') { + Some(token) => (true, token), + None => (false, token), + }; + let value = match token { + "ext_ublock" => true, + "false" + | "ext_ubol" + | "ext_devbuild" + | "ext_abp" + | "adguard" + | "env_brave" + | "env_chromium" + | "env_edge" + | "env_firefox" + | "env_legacy" + | "env_mobile" + | "env_mv3" + | "env_safari" + | "adguard_app_android" + | "adguard_app_cli" + | "adguard_app_ios" + | "adguard_app_mac" + | "adguard_app_windows" + | "adguard_ext_android_cb" + | "adguard_ext_chromium" + | "adguard_ext_chromium_mv3" + | "adguard_ext_edge" + | "adguard_ext_firefox" + | "adguard_ext_opera" + | "adguard_ext_safari" => false, + token if token.starts_with("cap_") => false, + _ => return None, + }; + Some(value != negated) +} + +// uBO evaluates a chain from left to right, with optional outer parentheses. +// Unrecognized expressions remain unknown, as in uBO: neither branch is removed. +fn evaluate_condition(expression: &str) -> Option { + let expression = expression.trim(); + let expression = expression + .strip_prefix('(') + .and_then(|inner| inner.strip_suffix(')')) + .unwrap_or(expression); + let mut tokens = expression.split_whitespace(); + let mut result = token_value(tokens.next()?)?; + while let Some(operator) = tokens.next() { + let rhs = token_value(tokens.next()?)?; + result = match operator { + "&&" => result && rhs, + "||" => result || rhs, + _ => return None, + }; + } + Some(result) +} + +struct Conditional { + parent_active: bool, + condition: Option, + in_else: bool, +} + +pub(crate) struct Preprocessed<'a> { + pub lines: Vec<&'a str>, + pub unknown_conditions: usize, + /// Zero-based source line and relative path, in source order. + pub includes: Vec<(usize, &'a str)>, +} + +/// Keep directive scope local to each list. Reject malformed structure instead +/// of letting an unmatched directive accidentally disable the rest of a list. +pub(crate) fn preprocess(contents: &str) -> Result, String> { + preprocess_inner(contents, false) +} + +/// Find active includes without losing the original conditional blocks. Each +/// downloaded fragment is checked separately so it cannot close a parent's if. +pub(crate) fn preprocess_for_download(contents: &str) -> Result, String> { + preprocess_inner(contents, true) +} + +fn preprocess_inner(contents: &str, allow_includes: bool) -> Result, String> { + let mut result = Preprocessed { + lines: Vec::new(), + unknown_conditions: 0, + includes: Vec::new(), + }; + let mut stack: Vec = Vec::new(); + let mut active = true; + for (index, line) in contents.lines().enumerate() { + let line = line.trim().trim_start_matches('\u{feff}'); + let (directive, argument) = line.split_once(char::is_whitespace).unwrap_or((line, "")); + let error = |message| format!("line {}: {message}", index + 1); + match directive { + "!#if" => { + if argument.trim().is_empty() { + return Err(error("missing condition")); + } + if stack.len() >= 128 { + return Err(error("conditional nesting exceeds 128 levels")); + } + let condition = evaluate_condition(argument); + if condition.is_none() { + result.unknown_conditions += 1; + } + stack.push(Conditional { + parent_active: active, + condition, + in_else: false, + }); + active &= condition.unwrap_or(true); + } + "!#else" => { + let frame = stack.last_mut().ok_or_else(|| error("unmatched !#else"))?; + if frame.in_else { + return Err(error("duplicate !#else")); + } + frame.in_else = true; + active = frame.parent_active && frame.condition.map(|value| !value).unwrap_or(true); + } + "!#endif" => { + let frame = stack.pop().ok_or_else(|| error("unmatched !#endif"))?; + active = frame.parent_active; + } + "!#include" if active => { + if !allow_includes { + return Err(error( + "unresolved !#include; refresh the subscription to download its sub-lists", + )); + } + let path = argument + .split_whitespace() + .next() + .ok_or_else(|| error("missing !#include path"))?; + result.includes.push((index, path)); + } + _ if active => result.lines.push(line), + _ => {} + } + } + if !stack.is_empty() { + return Err("unterminated !#if".to_owned()); + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nested_conditions_select_portable_scriptlet_fallbacks() { + let input = "!#if cap_html_filtering\nreplace-rule\n!#else\nscriptlet-rule\n!#if env_firefox\nfirefox-only\n!#else\nportable\n!#endif\n!#endif\nafter"; + assert_eq!( + preprocess(input).unwrap().lines, + ["scriptlet-rule", "portable", "after"] + ); + let input = + "!#if false\n!#if !false\nhidden\n!#else\nalso-hidden\n!#endif\n!#endif\nvisible"; + assert_eq!(preprocess(input).unwrap().lines, ["visible"]); + } + + #[test] + fn conditions_follow_ubo_left_to_right_boolean_chains() { + assert_eq!( + evaluate_condition("(!ext_ubol && !cap_html_filtering)"), + Some(true) + ); + assert_eq!(evaluate_condition("!false || false && false"), Some(false)); + assert_eq!(evaluate_condition("cap_future_feature"), Some(false)); + assert_eq!(evaluate_condition("adguard_future_platform"), None); + assert_eq!(evaluate_condition("!cap_future_feature"), Some(true)); + assert_eq!( + evaluate_condition("env_firefox || env_chromium"), + Some(false) + ); + } + + #[test] + fn unknown_conditions_preserve_both_branches_like_ubo() { + let parsed = preprocess("!#if future_token\na\n!#else\nb\n!#endif").unwrap(); + assert_eq!(parsed.lines, ["a", "b"]); + assert_eq!(parsed.unknown_conditions, 1); + } + + #[test] + fn only_active_includes_are_downloaded_and_keep_source_positions() { + let input = "!#if false\n!#include ignored.txt\n!#else\n!#include first.txt comment\n!#endif\n!#if future_token\n!#include second.txt\n!#else\n!#include third.txt\n!#endif"; + let parsed = preprocess_for_download(input).unwrap(); + assert_eq!( + parsed.includes, + [(3, "first.txt"), (6, "second.txt"), (8, "third.txt")] + ); + assert_eq!(parsed.unknown_conditions, 1); + assert!(preprocess_for_download("!#include").is_err()); + assert!(preprocess_for_download("!#if false\n!#include\n!#endif").is_ok()); + } + + #[test] + fn malformed_directives_and_active_includes_fail() { + for input in [ + "!#else", + "!#endif", + "!#if", + "!#if false", + "!#if false\n!#else\n!#else\n!#endif", + "!#include more.txt", + ] { + assert!(preprocess(input).is_err(), "{input}"); + } + assert!(preprocess("!#if false\n!#include unused.txt\n!#endif").is_ok()); + assert!(preprocess(&"!#if !false\n".repeat(129)).is_err()); + assert_eq!( + preprocess("\u{feff}!#if !false\r\na\r\n!#endif\r\n") + .unwrap() + .lines, + ["a"] + ); + } + + #[test] + fn scriptlet_trust_is_limited_to_official_sources() { + for url in [ + "https://raw.githubusercontent.com/uBlockOrigin/uAssetsCDN/main/filters/filters.min.txt", + "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters-mobile.txt", + ] { + assert!(is_trusted_source(&Url::parse(url).unwrap())); + } + for url in [ + "http://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt", + "https://raw.githubusercontent.com.evil.test/uBlockOrigin/uAssets/master/filters/filters.txt", + "https://raw.githubusercontent.com/another/repo/main/filters/filters.txt", + "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/../other.txt", + "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/..%2F..%2Fother.txt", + "https://raw.githubusercontent.com:8443/uBlockOrigin/uAssets/master/filters/filters.txt", + ] { + assert!(!is_trusted_source(&Url::parse(url).unwrap())); + } + } +} diff --git a/privaxy/src/server/lib.rs b/privaxy/src/server/lib.rs index 747df5b0..fb614cd7 100644 --- a/privaxy/src/server/lib.rs +++ b/privaxy/src/server/lib.rs @@ -28,6 +28,7 @@ mod blocker_utils; mod ca; mod cert; pub mod configuration; +mod filter_list; pub mod logging; mod proxy; pub mod statistics; diff --git a/privaxy/src/server/proxy/html_rewriter.rs b/privaxy/src/server/proxy/html_rewriter.rs index 7d3ae4c2..8173f775 100644 --- a/privaxy/src/server/proxy/html_rewriter.rs +++ b/privaxy/src/server/proxy/html_rewriter.rs @@ -216,8 +216,11 @@ impl Rewriter { // silently no-op. Defining it once at the top of the payload (the // scriptlet bodies use dot-access, so it must be an object) // so the scriptlets actually work. - payload.push_str("const scriptletGlobals = {};\n"); + // Function-style resources share helpers with one another, but must + // not publish/overwrite names such as safeSelf or JSONPath on the page. + payload.push_str("(() => {\nconst scriptletGlobals = {};\n"); payload.push_str(&script); + payload.push_str("\n})();\n"); } if shim_needed { From be1a6e607563d81557b9db8e9ab765dc0a93a754 Mon Sep 17 00:00:00 2001 From: joshrmcdaniel <80354972+joshrmcdaniel@users.noreply.github.com> Date: Sat, 14 Nov 2026 13:16:10 -0600 Subject: [PATCH 2/3] reduce db size --- CHANGELOG.md | 23 +- Cargo.lock | 2 +- README.md | 43 +- RELEASE.md | 124 ++-- privaxy/Cargo.toml | 2 +- .../src/server/configuration/statistics.rs | 20 + privaxy/src/server/proxy/serve.rs | 12 +- privaxy/src/server/statistics.rs | 45 +- privaxy/src/server/statistics/history.rs | 211 ++++-- .../src/server/statistics/history/storage.rs | 216 +++++++ .../src/server/statistics/history/tests.rs | 610 +++++++++++++++++- privaxy/src/server/web_gui/history.rs | 6 +- web_frontend/src/dashboard.rs | 16 +- web_frontend/src/history.rs | 48 +- web_frontend/src/statistics_settings.rs | 132 +++- 15 files changed, 1309 insertions(+), 201 deletions(-) create mode 100644 privaxy/src/server/statistics/history/storage.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 25ff8c6c..48a1a690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## v0.8.1 - Use the combined uBlock Origin CDN lists for Ads, Badware risks, Privacy, Quick fixes, and Unbreak. Migrate existing subscriptions without changing enabled @@ -21,10 +21,22 @@ for one hour and failures for one minute. - Added opt-in persistent statistics and individual request history using redb. History includes time ranges, trends, top domains/clients, and pagination, with - privacy levels that remove identifiers before collection. URL paths, query - strings, headers, and bodies are never saved in statistics. + privacy levels that remove identifiers before collection. Query strings, URL + credentials, headers, and bodies are never saved in statistics. +- Add an Endpoint column to Dashboard request history with the path separate + from the domain, visual truncation for long values, and the full path on hover. + Store paths without the first slash and restore it for display. Migrate existing + endpoint values to compact paths when upgrading the statistics database. + Endpoints follow domain privacy settings; older saved records remain readable. +- Deduplicate domains, endpoints, and clients in shared lookup tables, with + compact ID references in each request. Upgrade existing history atomically to + storage version 3 and compact it. Remove unused values when requests expire; + privacy changes remove the corresponding values and lookup indexes. +- Add client names in Statistics settings, shown in both dashboard layouts and + request history with the IP available on hover. Renaming preserves counts and + updates existing history; client privacy also hides names. - Added Statistics settings for retention, a request-count limit, clearing saved - history, and excluding domain patterns from both statistics and the live + history, and excluding domain patterns or client IPs from statistics and the live Requests log. Exclusions do not change filtering behavior and work even when persistence is disabled. - Bounded statistics buffering, batched transactional writes, visible storage @@ -96,7 +108,8 @@ ## v0.8.0 - 2026-10-02 -Release highlights and upgrade notes are in [RELEASE.md](RELEASE.md). +Release highlights and upgrade notes are in the +[v0.8.0 release notes](https://github.com/joshrmcdaniel/privaxy/blob/v0.8.0/RELEASE.md). - Support both plain HTTP and HTTPS inside CONNECT tunnels, allowing compatible HTTP proxies and adapters to chain into Privaxy. Detect HTTP versus TLS on any diff --git a/Cargo.lock b/Cargo.lock index 97948af5..27ae3b84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2162,7 +2162,7 @@ dependencies = [ [[package]] name = "privaxy" -version = "0.8.0" +version = "0.8.1" dependencies = [ "adblock", "argon2", diff --git a/README.md b/README.md index de59858b..c8f5ada4 100644 --- a/README.md +++ b/README.md @@ -384,23 +384,35 @@ Old `/history` bookmarks redirect to the Dashboard. Persistence is **off by default**; enabling it starts collecting new activity, without importing the live Requests feed. -History records timestamps, destination hostnames, client IP addresses, and -whether a request was blocked or successfully forwarded. It never stores URL -paths, query strings, headers, cookies, or request/response bodies. Raw CONNECT -tunnels and requests that fail before being forwarded are not counted. Clients -behind another proxy share that proxy's client address. +History records timestamps, destination hostnames, endpoint paths, client IP +addresses, and whether a request was blocked or successfully forwarded, according +to the selected privacy level. The Endpoint column shows only the path, such as +`/204`, beside the separate domain. The database saves `204`, without duplicating +the domain or the first `/`; that separator is restored for display. Query +strings, URL credentials, headers, cookies, and request/response bodies are never +stored. Long paths are shortened visually; hover over one to see its full value. +Older records without an endpoint show "Not recorded". Domains, endpoints, and +clients each have a shared lookup table: repeated values are saved once and +request rows reference their IDs. Unused values are removed when their last +request expires. Existing history migrates automatically to storage version 3 +and is compacted, converting older hostname-prefixed endpoints to paths too. +Earlier builds that only support versions 1 or 2 cannot read the upgraded +history database. +Raw CONNECT tunnels and requests that fail before being forwarded are not +counted. Clients behind another proxy share that proxy's client address. The privacy levels follow [Pi-hole's approach](https://docs.pi-hole.net/ftldns/privacylevels/): | Level | Saved identifiers | | --- | --- | -| Show domains and clients | Hostname and client IP | +| Show domains and clients | Hostname, endpoint path, and client IP | | Hide domains | Client IP only | | Hide domains and clients (default) | Neither; request times and outcomes remain | | Anonymous | No new history; session counters only | -Hidden identifiers are removed **before queueing or storage**, without retaining -a reversible mapping or a hash. While collection is enabled, raising privacy +Hiding domains also hides endpoints. Hidden identifiers are removed **before +queueing or storage**, without retaining a reversible mapping or a hash. While +collection is enabled, raising privacy also redacts identifiers in existing history, including when entering anonymous mode. Lowering privacy cannot restore them. These controls apply to statistics; the separate live Requests feed and diagnostic logs can still contain full URLs. @@ -413,6 +425,17 @@ apply. Use one case-insensitive hostname or wildcard pattern per line: include schemes, ports, or paths. Existing history and diagnostic logs are unaffected. +**Exclude clients from statistics and request logs** applies the same exclusions +to one exact IPv4 or IPv6 address per line. This works with persistence off too; +it does not bypass ad blocking or delete previously saved requests. + +**Client names** assigns friendly names to IP addresses. Names appear in both +dashboard layouts and saved history wherever client addresses are visible; +hover over a name to see its IP. Renaming updates existing history without +changing counts or merging different clients. Names are saved in configuration, +so clearing history keeps them. Privacy settings that hide clients also hide +their names on the Dashboard. + Defaults are **7 days** and **100,000 saved requests**. The oldest records expire when either limit is reached; valid limits are 1–365 days and 100–1,000,000 requests. History totals and top lists describe retained requests, so they can @@ -431,6 +454,10 @@ privacy = "hide_domains_and_clients" # full, hide_domains, hide_domains_and_clie retention_days = 7 max_entries = 100000 excluded_domains = ["example.com", "*.example.com"] +excluded_clients = ["192.0.2.10", "2001:db8::10"] + +[statistics.client_names] +"192.0.2.20" = "Living room TV" ``` UI changes apply immediately; file edits also apply on SIGHUP. Data stays local diff --git a/RELEASE.md b/RELEASE.md index 785cd29e..48a6eaf4 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,75 +1,73 @@ -# Privaxy v0.8.0 +# v0.8.1 -Privaxy v0.8.0 adds userscripts, filtering for selected hosts, and broader HTTP -CONNECT support. It also improves streaming performance, filter-list recovery, -and the tools for diagnosing sites that break under interception. +Privaxy v0.8.1 improves uBlock Origin filter-list compatibility, adds optional +persistent statistics, and fixes HTTP/2 failures and page-loading delays. ## Highlights -- **Userscripts managed from the web UI.** Install by URL or paste, edit source, - enable or disable individual scripts, and check for updates. Supports common - Greasemonkey/Tampermonkey metadata and APIs, persistent storage, resources, - menu commands, and server-side requests controlled by each script's - `@connect` rules. URL-installed scripts also check for updates automatically. -- **Filter only selected hosts.** Inclusion mode intercepts the hosts you - select and tunnels everything else. Exclusions always take precedence, and - an empty inclusion list bypasses all hosts. The same policy applies to - HTTP, HTTPS, and PAC files; existing configurations keep their previous mode. -- **HTTP CONNECT and incoming proxy chaining.** Compatible HTTP proxies and - adapters can send both plain HTTP and HTTPS through CONNECT to Privaxy, - including on nonstandard ports. Traffic uses the existing filtering, - injection, and inclusion/exclusion policies. The README includes tun2proxy - as one example, with routing configured by the user. -- **Better filter-list management.** Refresh all enabled lists on demand, see - download failures and their causes, and edit or remove user-added lists. - Empty or invalid caches are recovered or downloaded again, and atomic - replacement prevents failed writes from truncating a working list. -- **Easier interception troubleshooting.** Proxy error pages link to an - exclusion confirmation page. The UI also lists recent TLS interception - failures, with actions to exclude or ignore a host. A configurable GUI URL - keeps these links usable behind reverse proxies and Docker NAT. +- **Updated uBlock Origin filtering.** Use the combined official CDN lists for + Ads, Badware risks, Privacy, Quick fixes, and Unbreak. Bundled scriptlets are + updated to uBO 1.76.2, including newer JSON-edit and video-response hooks, + shared helpers, and trusted-scriptlet permissions. +- **Conditional rules and sub-lists.** Evaluate nested filter-list conditions + and download active relative `!#include` files. This fixes updates for EasyList + Cookie and Fanboy's Annoyance and loads their uBO-specific rules. Failed + sub-list downloads preserve the previous complete cache. Logs now show how + many rules loaded and why individual rules were rejected. +- **Persistent statistics on the Dashboard.** Optional history provides time + ranges, trends, top domains and clients, and paginated requests. Settings + control privacy, retention, storage limits, exclusions, and clearing history. + With persistence off or Anonymous mode selected, the Dashboard retains its + original live layout. Old `/history` bookmarks redirect to the Dashboard. + Request history shows paths separately from domains, truncating long values + with the full path available on hover. Paths are stored without the first `/`, + which is restored in the UI. Domains, endpoints, and clients are stored once + and shared across request records to reduce repeated data. Assign friendly + client names or exclude client IPs from statistics and the live Requests log + in Statistics settings; excluded clients still receive normal filtering. +- **More reliable live views and settings.** Dashboard statistics, Requests, + and logs show connection status and reconnect automatically. Settings retain + unsaved edits after errors, report save results accurately, and fit smaller + screens more comfortably. +- **Version and update status.** The footer links the running Privaxy version + to the project and automatically shows an update link when a newer stable + release is detected. ## Reliability and performance -- HTML responses stream as they are rewritten, allowing browsers to start - rendering before the complete document arrives. Bounded buffering limits - memory use when downstream clients are slow. -- Larger WebSocket buffers, a shared adblock engine, and fewer repeated - cosmetic lookups reduce overhead. Filter updates replace the engine without - pausing requests for the full rebuild. -- The proxy waits for initial filter loading before accepting traffic. The - web UI remains available during loading and reports list failures. -- Invalid configuration edits retain the last working configuration. Reloads - no longer race for the web UI listener, and bind failures retry. -- Fixes cover filtering on upgraded requests, WebSocket/MMTLS handling, - truncated upstream responses, replacement-resource decoding, IPv6 - certificates, and CSP compatibility for injected content. -- PAC files are also served at `/wpad.dat`, and CIDR bypass rules now render - valid subnet masks. -- The HTTP/TLS stack and frontend dependencies have been updated. Docker - examples include log rotation to limit disk usage. +- Larger HTTP/2 header limits fix 502 protocol errors on responses with large + CSP or Set-Cookie headers. Intercepted TLS connections advertise HTTP/2 so + browsers can multiplex requests; adaptive flow control and TCP_NODELAY reduce + delays. Known-empty requests can recover from retryable HTTP/2 failures. +- IP-address CONNECT requests use TLS ClientHello SNI for certificates, + hostname policy, and upstream TLS verification while retaining the original + destination IP. Protocol detection and TLS handshakes have bounded timeouts. + CONNECT traffic to port 853 tunnels DNS-over-TLS without interception. +- Proxy error pages and logs include underlying DNS, TLS, socket, and HTTP/2 + failure causes. HTML modification counts no longer double-count a response + that receives both scripts and cosmetic styles. +- CI validates release tags against the package version, avoids stale package + assets, improves build caches, and keeps pull-request Docker checks separate + from image publishing. ## Upgrading and compatibility -- Existing configuration files remain supported. For v0.7.1 configurations, - inclusion mode defaults off and no userscripts are installed automatically. -- If upgrading from a development build with userscripts, **reload open pages** - after upgrading. Userscript endpoints now use tokens bound to each script - and matched page; disabling a script or changing its match rules revokes - access. -- Installed userscripts run on matching pages for every client behind the - proxy, in the page's main JavaScript world. This is a subset of browser - userscript-manager functionality; see the changelog for supported APIs and - known limitations. Requests to private networks remain disabled by default. -- For incoming proxy chains, preserve destination hostnames in CONNECT so - domain filtering and HTTPS certificates work correctly. Clients still need - to trust Privaxy's CA. This release does not add configuration for routing - Privaxy's outbound traffic through another upstream proxy. TUN setup, - routing, and UDP/QUIC handling remain the user's responsibility. -- To pin this release in Docker, use `ghcr.io/joshrmcdaniel/privaxy:0.8.0`. - Existing containers need to be recreated to adopt the log-rotation options - shown in the README. +- Existing configurations remain supported. Persistent statistics stay off + until enabled in Settings. Endpoints follow domain privacy settings; query + strings, URL credentials, headers, and bodies are never saved in statistics. + Existing history migrates to statistics storage version 3 when opened; + earlier builds that only support versions 1 or 2 cannot read the upgraded + database. Client names stay in configuration when saved history is cleared. +- Legacy subscriptions for the five updated uBO lists migrate automatically + while preserving enabled states. An existing CDN subscription takes + precedence over its old source-list duplicate. Refresh enabled lists and + reload open pages after upgrading to use the latest rules and scriptlets. +- Filter syntax and browser integration still differ from the uBO extension. + Unsupported-rule warnings describe individual skipped rules, not failed + subscription downloads, and this update does not guarantee identical video + ad blocking on every site. +- To pin this release in Docker, use `ghcr.io/joshrmcdaniel/privaxy:0.8.1`. -See the [full changelog](https://github.com/joshrmcdaniel/privaxy/blob/v0.8.0/CHANGELOG.md) -and [proxy-chaining setup](https://github.com/joshrmcdaniel/privaxy/blob/v0.8.0/README.md#6-http-connect-and-proxy-chaining) +See the [full changelog](https://github.com/joshrmcdaniel/privaxy/blob/v0.8.1/CHANGELOG.md) +and [setup documentation](https://github.com/joshrmcdaniel/privaxy/blob/v0.8.1/README.md#setup) for details. diff --git a/privaxy/Cargo.toml b/privaxy/Cargo.toml index 56a9b79a..7158d144 100644 --- a/privaxy/Cargo.toml +++ b/privaxy/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "privaxy" description = "Next generation tracker and advertisement blocker" -version = "0.8.0" +version = "0.8.1" edition = "2021" repository = "https://github.com/joshrmcdaniel/privaxy" authors = [ diff --git a/privaxy/src/server/configuration/statistics.rs b/privaxy/src/server/configuration/statistics.rs index 9f93c394..cee23ecb 100644 --- a/privaxy/src/server/configuration/statistics.rs +++ b/privaxy/src/server/configuration/statistics.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, net::IpAddr}; /// These levels describe collected data, not just what the UI displays. #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] @@ -19,6 +20,8 @@ pub struct StatisticsConfig { pub retention_days: u16, pub max_entries: u32, pub excluded_domains: Vec, + pub excluded_clients: Vec, + pub client_names: BTreeMap, } impl Default for StatisticsConfig { @@ -29,12 +32,29 @@ impl Default for StatisticsConfig { retention_days: 7, max_entries: 100_000, excluded_domains: Vec::new(), + excluded_clients: Vec::new(), + client_names: BTreeMap::new(), } } } impl StatisticsConfig { pub fn validate(&self) -> Result<(), String> { + if self.excluded_clients.len() > 1000 { + return Err("Use at most 1,000 client IP exclusions".into()); + } + if self.client_names.len() > 1000 + || self.client_names.values().any(|name| { + name.trim().is_empty() + || name.chars().count() > 64 + || name.chars().any(char::is_control) + }) + { + return Err( + "Use at most 1,000 client names, each 1–64 characters without control characters" + .into(), + ); + } if self.excluded_domains.len() > 1000 || self.excluded_domains.iter().any(|pattern| { let pattern = pattern.trim(); diff --git a/privaxy/src/server/proxy/serve.rs b/privaxy/src/server/proxy/serve.rs index 697018a4..3e169f76 100644 --- a/privaxy/src/server/proxy/serve.rs +++ b/privaxy/src/server/proxy/serve.rs @@ -353,7 +353,7 @@ pub(crate) async fn serve( let req = Request::from_parts(parts, body); log::debug!("{} {}", req.method(), req.uri()); - let statistics = statistics.for_domain(uri.host()); + let statistics = statistics.for_request(uri.host(), client_ip_address); if !intercept { let mut req = req; @@ -388,7 +388,7 @@ pub(crate) async fn serve( } } }; - statistics.record_request(uri.host(), client_ip_address, false); + statistics.record_request(&uri, client_ip_address, false); return Ok(response); } @@ -414,7 +414,7 @@ pub(crate) async fn serve( is_request_blocked: true, }); } - statistics.record_request(uri.host(), client_ip_address, true); + statistics.record_request(&uri, client_ip_address, true); return Ok(get_empty_response(StatusCode::BAD_GATEWAY)); } @@ -439,7 +439,7 @@ pub(crate) async fn serve( } if is_request_blocked { - statistics.record_request(uri.host(), client_ip_address, true); + statistics.record_request(&uri, client_ip_address, true); // adblock-rust fuses many same-option patterns into one filter and // reports the union as the matched filter, which can be enormous; cap @@ -480,7 +480,7 @@ pub(crate) async fn serve( return Ok(get_empty_response(StatusCode::BAD_GATEWAY)); }; let response = perform_two_ends_upgrade(req, outbound_uri, hyper_client).await; - statistics.record_request(uri.host(), client_ip_address, false); + statistics.record_request(&uri, client_ip_address, false); return Ok(response); } @@ -537,7 +537,7 @@ pub(crate) async fn serve( } }; - statistics.record_request(uri.host(), client_ip_address, false); + statistics.record_request(&uri, client_ip_address, false); *new_response.headers_mut() = response.headers().clone(); diff --git a/privaxy/src/server/statistics.rs b/privaxy/src/server/statistics.rs index ec0c7c03..7309568f 100644 --- a/privaxy/src/server/statistics.rs +++ b/privaxy/src/server/statistics.rs @@ -2,7 +2,7 @@ use crate::configuration::{StatisticsConfig, StatisticsPrivacy}; use portable_atomic::{AtomicU64, Ordering}; use serde::Serialize; use std::{ - collections::HashMap, + collections::{BTreeMap, HashMap}, net::IpAddr, sync::{atomic::AtomicBool, Arc, Mutex}, }; @@ -21,6 +21,7 @@ pub struct SerializableStatistics { pub top_blocked_paths: Vec<(String, u64)>, #[serde(with = "tuple_vec_map")] pub top_clients: Vec<(String, u64)>, + pub client_names: BTreeMap, } #[derive(Debug, Clone)] @@ -67,22 +68,33 @@ impl Statistics { let Some(history) = &self.history else { return Ok(()); }; - if config == history.config() && history.status().error.is_none() { + let previous = history.config(); + if config == previous && history.status().error.is_none() { return Ok(()); } + let reset_identifiers = config.enabled != previous.enabled + || config.privacy != previous.privacy + || config.excluded_domains != previous.excluded_domains + || config.excluded_clients != previous.excluded_clients; let result = history.configure(config).await; // Discard session identifiers too. Lowering privacy later must not // resurrect identifiers from before the change. - self.top_blocked_paths.lock().unwrap().clear(); - self.top_clients.lock().unwrap().clear(); + if reset_identifiers { + self.top_blocked_paths.lock().unwrap().clear(); + self.top_clients.lock().unwrap().clear(); + } result } - /// Carry one domain's collection decision through response rewriting too. + /// Carry a request's collection decision through response rewriting too. /// Statistics exclusions never change interception or blocking decisions. - pub fn for_domain(&self, domain: Option<&str>) -> Self { + pub fn for_request(&self, domain: Option<&str>, client: IpAddr) -> Self { Self { - collect: self.collect && !self.history.as_ref().is_some_and(|h| h.is_excluded(domain)), + collect: self.collect + && !self + .history + .as_ref() + .is_some_and(|h| h.is_excluded(domain, client)), ..self.clone() } } @@ -98,8 +110,14 @@ impl Statistics { } } - pub fn record_request(&self, domain: Option<&str>, client: IpAddr, blocked: bool) { - if !self.collect || self.history.as_ref().is_some_and(|h| h.is_excluded(domain)) { + pub fn record_request(&self, uri: &http::Uri, client: IpAddr, blocked: bool) { + let domain = uri.host(); + if !self.collect + || self + .history + .as_ref() + .is_some_and(|h| h.is_excluded(domain, client)) + { return; } let privacy = self @@ -121,7 +139,7 @@ impl Statistics { self.increment_proxied_requests(); } if let Some(history) = &self.history { - history.request(domain, client, blocked); + history.request(uri, client, blocked); } } @@ -170,6 +188,7 @@ impl Statistics { pub fn get_serialized(&self) -> SerializableStatistics { let mut serialized = SerializableStatistics { + client_names: BTreeMap::new(), proxied_requests: self.proxied_requests.load(Ordering::Relaxed), blocked_requests: self.blocked_requests.load(Ordering::Relaxed), modified_responses: self.modified_responses.load(Ordering::Relaxed), @@ -202,6 +221,12 @@ impl Statistics { if privacy >= StatisticsPrivacy::HideDomainsAndClients { serialized.top_clients.clear(); } + serialized.client_names = history.client_names( + serialized + .top_clients + .iter() + .map(|(client, _)| client.as_str()), + ); } serialized } diff --git a/privaxy/src/server/statistics/history.rs b/privaxy/src/server/statistics/history.rs index 8e29b41a..b674d523 100644 --- a/privaxy/src/server/statistics/history.rs +++ b/privaxy/src/server/statistics/history.rs @@ -5,7 +5,7 @@ use portable_atomic::{AtomicU64, Ordering}; use redb::{Database, ReadableDatabase, ReadableTable, ReadableTableMetadata, TableDefinition}; use serde::{Deserialize, Serialize}; use std::{ - collections::{BTreeMap, HashMap}, + collections::{BTreeMap, HashMap, HashSet}, fs::OpenOptions, net::IpAddr, path::PathBuf, @@ -14,9 +14,12 @@ use std::{ }; use tokio::sync::{oneshot, watch}; -const REQUESTS: TableDefinition = TableDefinition::new("requests_v1"); +mod storage; +use storage::{Dictionaries, DictionaryReader, LEGACY_REQUESTS, REQUESTS}; const MODIFIED: TableDefinition = TableDefinition::new("modified_hours_v1"); const META: TableDefinition<&str, u64> = TableDefinition::new("metadata"); +// v1: JSON with full endpoints; v2: JSON with compact paths; v3: ID references. +const DATABASE_VERSION: u64 = 3; const FLUSH_INTERVAL: Duration = Duration::from_secs(5); const QUEUE_CAPACITY: usize = 4096; const BATCH_SIZE: usize = 512; @@ -38,14 +41,30 @@ pub struct RequestRecord { pub id: u64, pub timestamp: u64, pub domain: Option, + /// Path without its first slash; absent in older saved records. + #[serde(default)] + pub endpoint: Option, pub client: Option, pub blocked: bool, } impl RequestRecord { + fn migrate_endpoint(&mut self) -> bool { + let Some(endpoint) = &mut self.endpoint else { + return false; + }; + *endpoint = endpoint + .split_once('/') + .map(|(_, path)| path) + .unwrap_or_default() + .to_owned(); + true + } + fn redact(&mut self, privacy: StatisticsPrivacy) { if privacy >= StatisticsPrivacy::HideDomains { self.domain = None; + self.endpoint = None; } if privacy >= StatisticsPrivacy::HideDomainsAndClients { self.client = None; @@ -89,6 +108,7 @@ pub struct HistorySnapshot { pub top_domains: Vec<(String, u64)>, pub top_blocked_domains: Vec<(String, u64)>, pub top_clients: Vec<(String, u64)>, + pub client_names: BTreeMap, pub requests: Vec, pub next_before: Option, } @@ -99,6 +119,7 @@ struct Policy { generation: u64, stopped: bool, exclusions: Vec, + excluded_clients: HashSet, } enum Event { @@ -190,21 +211,32 @@ impl HistoryStore { status } - pub fn is_excluded(&self, domain: Option<&str>) -> bool { + pub fn is_excluded(&self, domain: Option<&str>, client: IpAddr) -> bool { let policy = self.policy.read().unwrap(); - domain.is_some_and(|domain| matches_exclusion(&policy, domain)) + policy.excluded_clients.contains(&client) + || domain.is_some_and(|domain| matches_exclusion(&policy, domain)) } - pub fn request(&self, domain: Option<&str>, client: IpAddr, blocked: bool) { + pub(crate) fn client_names<'a>( + &self, + clients: impl Iterator, + ) -> BTreeMap { + client_names(&self.policy.read().unwrap().config, clients) + } + + pub fn request(&self, uri: &http::Uri, client: IpAddr, blocked: bool) { + let domain = uri.host(); let policy = self.policy.read().unwrap(); if policy.stopped || !policy.config.persists() + || policy.excluded_clients.contains(&client) || domain.is_some_and(|domain| matches_exclusion(&policy, domain)) { return; } - // No URL, path, query string, headers, request body, or client/domain hash - // is ever queued. Hidden values cannot be restored by changing settings. + // Strip query strings and credentials before queueing. Endpoint paths + // follow domain privacy; hidden values cannot be restored by changing + // settings. Headers and bodies never enter the statistics queue. let record = RequestRecord { id: 0, timestamp: now_ms(), @@ -215,6 +247,9 @@ impl HistoryStore { } else { None }, + endpoint: (policy.config.privacy == StatisticsPrivacy::Full) + .then(|| request_endpoint(uri)) + .flatten(), client: (policy.config.privacy < StatisticsPrivacy::HideDomainsAndClients) .then(|| client.to_string()), blocked, @@ -279,6 +314,7 @@ impl HistoryStore { ) }) .collect(); + policy.excluded_clients = config.excluded_clients.iter().copied().collect(); policy.config = config; policy.stopped = result.is_err(); result @@ -487,36 +523,60 @@ impl Worker { .set_cache_size(8 * 1024 * 1024) .create_file(file)?; let tx = database.begin_write()?; - let redact; - { + let (version, redact) = { let mut meta = tx.open_table(META)?; - if meta.get("version")?.is_some_and(|v| v.value() != 1) { + let version = meta + .get("version")? + .map(|v| v.value()) + .unwrap_or(DATABASE_VERSION); + if !(1..=DATABASE_VERSION).contains(&version) { return Err("Unsupported statistics database version".into()); } - meta.insert("version", 1)?; let previous = meta.get("privacy")?.map(|v| v.value()).unwrap_or(0); - redact = (self.config.privacy as u64) > previous; + meta.insert("version", DATABASE_VERSION)?; meta.insert("privacy", self.config.privacy as u64)?; - } + (version, (self.config.privacy as u64) > previous) + }; { - let mut table = tx.open_table(REQUESTS)?; - self.last_id = table.last()?.map(|(id, _)| id.value()).unwrap_or(0); - if redact { - let ids = table + let mut dictionaries = Dictionaries::open(&tx)?; + let mut requests = tx.open_table(REQUESTS)?; + if version < DATABASE_VERSION { + let legacy = tx.open_table(LEGACY_REQUESTS)?; + for entry in legacy.iter()? { + let (id, bytes) = entry?; + let mut record: RequestRecord = serde_json::from_slice(bytes.value())?; + if version == 1 { + record.migrate_endpoint(); + } + record.redact(self.config.privacy); + requests.insert(id.value(), dictionaries.intern(&record)?)?; + } + } else if redact { + let ids = requests .iter()? - .map(|r| r.map(|(k, _)| k.value())) + .map(|r| r.map(|(id, _)| id.value())) .collect::, _>>()?; for id in ids { - let mut record: RequestRecord = - serde_json::from_slice(table.get(id)?.unwrap().value())?; - record.redact(self.config.privacy); - table.insert(id, serde_json::to_vec(&record)?.as_slice())?; + let mut record = requests.get(id)?.unwrap().value(); + if self.config.privacy >= StatisticsPrivacy::HideDomains { + record.1 = 0; + record.2 = 0; + } + if self.config.privacy >= StatisticsPrivacy::HideDomainsAndClients { + record.3 = 0; + } + requests.insert(id, record)?; } + dictionaries.redact(self.config.privacy)?; } + self.last_id = requests.last()?.map(|(id, _)| id.value()).unwrap_or(0); + } + if version < DATABASE_VERSION { + tx.delete_table(LEGACY_REQUESTS)?; } tx.open_table(MODIFIED)?; tx.commit()?; - if redact { + if redact || version < DATABASE_VERSION { database.compact()?; } self.database = Some(database); @@ -557,15 +617,14 @@ impl Worker { { let mut requests = tx.open_table(REQUESTS)?; let mut modified = tx.open_table(MODIFIED)?; + let mut dictionaries = Dictionaries::open(&tx)?; for event in &self.pending { match event { Event::Request(record) => { - let mut record = record.clone(); // Microsecond-sized IDs remain ordered and fit exactly in // JavaScript numbers for centuries. The clock may step back. self.last_id = (record.timestamp * 1000).max(self.last_id + 1); - record.id = self.last_id; - requests.insert(record.id, serde_json::to_vec(&record)?.as_slice())?; + requests.insert(self.last_id, dictionaries.intern(record)?)?; } Event::Modified(timestamp) => { let hour = timestamp / HOUR_MS * HOUR_MS; @@ -577,23 +636,15 @@ impl Worker { // Also inspect stored timestamps: an NTP clock adjustment must not // make old rows immortal merely because their IDs are monotonic. if prune { - let mut invalid_row = false; - requests.retain(|_, bytes| { - match serde_json::from_slice::(bytes) { - Ok(record) => record.timestamp >= cutoff, - Err(_) => { - invalid_row = true; - true - } - } - })?; - if invalid_row { - return Err("Invalid record in statistics database; recording paused".into()); + for entry in requests.extract_if(|_, record| record.0 < cutoff)? { + let (_, record) = entry?; + dictionaries.release(record.value())?; } } while requests.len()? > u64::from(self.config.max_entries) { let first = requests.first()?.unwrap().0.value(); - requests.remove(first)?; + let record = requests.remove(first)?.unwrap().value(); + dictionaries.release(record)?; } modified.retain(|hour, _| hour + HOUR_MS > cutoff)?; stored = requests.len()?; @@ -635,6 +686,7 @@ impl Worker { let limit = query.limit.unwrap_or(50); let tx = database.begin_read()?; let table = tx.open_table(REQUESTS)?; + let dictionaries = DictionaryReader::open(&tx)?; let mut hourly: BTreeMap = BTreeMap::new(); let mut domains = HashMap::new(); let mut blocked_domains = HashMap::new(); @@ -642,35 +694,34 @@ impl Worker { // IDs are at least timestamp * 1000, even after a clock correction, // so this range skips older pages without losing in-window records. for entry in table.range(cutoff.saturating_mul(1000)..)?.rev() { - let (_, bytes) = entry?; - let record: RequestRecord = serde_json::from_slice(bytes.value())?; - if record.timestamp < cutoff { + let (id, record) = entry?; + let id = id.value(); + let record = record.value(); + if record.0 < cutoff { continue; } - let counts = hourly - .entry(record.timestamp / HOUR_MS * HOUR_MS) - .or_default(); - if record.blocked { + let counts = hourly.entry(record.0 / HOUR_MS * HOUR_MS).or_default(); + if record.4 { snapshot.totals.blocked_requests += 1; counts.blocked_requests += 1; } else { snapshot.totals.proxied_requests += 1; counts.proxied_requests += 1; } - if let Some(ref domain) = record.domain { - *domains.entry(domain.clone()).or_insert(0) += 1; - if record.blocked { - *blocked_domains.entry(domain.clone()).or_insert(0) += 1; + if record.1 != 0 { + *domains.entry(record.1).or_insert(0) += 1; + if record.4 { + *blocked_domains.entry(record.1).or_insert(0) += 1; } } - if let Some(ref client) = record.client { - *clients.entry(client.clone()).or_insert(0) += 1; + if record.3 != 0 { + *clients.entry(record.3).or_insert(0) += 1; } - if query.before.is_none_or(|id| record.id < id) - && query.blocked.is_none_or(|v| record.blocked == v) + if query.before.is_none_or(|before| id < before) + && query.blocked.is_none_or(|v| record.4 == v) { if snapshot.requests.len() < limit { - snapshot.requests.push(record); + snapshot.requests.push(dictionaries.record(id, record)?); } else if snapshot.next_before.is_none() { snapshot.next_before = snapshot.requests.last().map(|r| r.id); } @@ -686,18 +737,52 @@ impl Worker { hourly.entry(hour.value()).or_default().modified_responses = count; } snapshot.hourly = hourly.into_iter().collect(); - snapshot.top_domains = top_entries(domains); - snapshot.top_blocked_domains = top_entries(blocked_domains); - snapshot.top_clients = top_entries(clients); + snapshot.top_domains = storage::top_entries(&dictionaries.domains, domains)?; + snapshot.top_blocked_domains = + storage::top_entries(&dictionaries.domains, blocked_domains)?; + snapshot.top_clients = storage::top_entries(&dictionaries.clients, clients)?; + snapshot.client_names = client_names( + &self.config, + snapshot + .top_clients + .iter() + .map(|(client, _)| client.as_str()) + .chain( + snapshot + .requests + .iter() + .filter_map(|record| record.client.as_deref()), + ), + ); Ok(snapshot) } } -fn top_entries(entries: HashMap) -> Vec<(String, u64)> { - let mut entries: Vec<_> = entries.into_iter().collect(); - entries.sort_unstable_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); - entries.truncate(50); - entries +fn client_names<'a>( + config: &StatisticsConfig, + clients: impl Iterator, +) -> BTreeMap { + clients + .filter_map(|client| { + let address = client.parse::().ok()?; + config + .client_names + .get(&address) + .map(|name| (client.to_owned(), name.trim().to_owned())) + }) + .collect() +} + +fn request_endpoint(uri: &http::Uri) -> Option { + uri.authority()?; + // Remove exactly one separator so //path still renders as //path. Query + // strings, credentials, and the authority never enter this field. + Some( + uri.path() + .strip_prefix('/') + .unwrap_or(uri.path()) + .to_owned(), + ) } fn now_ms() -> u64 { diff --git a/privaxy/src/server/statistics/history/storage.rs b/privaxy/src/server/statistics/history/storage.rs new file mode 100644 index 00000000..5f0945f0 --- /dev/null +++ b/privaxy/src/server/statistics/history/storage.rs @@ -0,0 +1,216 @@ +//! Compact request rows and reference-counted identifier dictionaries. +use super::{RequestRecord, StoreResult}; +use redb::{ + ReadOnlyTable, ReadTransaction, ReadableTable, Table, TableDefinition, WriteTransaction, +}; +use sha2::{Digest, Sha256}; +use std::cmp::Reverse; +use std::collections::{BinaryHeap, HashMap}; + +// Timestamp, domain ID, endpoint ID, client ID, blocked. Zero means absent; +// the empty root path is a real dictionary value with a nonzero ID. +pub(super) type StoredRequest = (u64, u64, u64, u64, bool); +pub(super) const REQUESTS: TableDefinition = + TableDefinition::new("requests_v3"); +pub(super) const LEGACY_REQUESTS: TableDefinition = TableDefinition::new("requests_v1"); +pub(super) const DOMAINS: TableDefinition = TableDefinition::new("domains_v3"); +pub(super) const ENDPOINTS: TableDefinition = + TableDefinition::new("endpoints_v3"); +pub(super) const CLIENTS: TableDefinition = TableDefinition::new("clients_v3"); +const DOMAIN_INDEX: TableDefinition<&[u8], u64> = TableDefinition::new("domain_index_v3"); +const ENDPOINT_INDEX: TableDefinition<&[u8], u64> = TableDefinition::new("endpoint_index_v3"); +const CLIENT_INDEX: TableDefinition<&[u8], u64> = TableDefinition::new("client_index_v3"); + +struct Dictionary<'a> { + values: Table<'a, u64, (u64, &'static str)>, + index: Table<'a, &'static [u8], u64>, +} + +impl Dictionary<'_> { + fn intern(&mut self, value: Option<&str>) -> StoreResult { + let Some(value) = value else { return Ok(0) }; + // A fixed-size index avoids storing each string twice, especially for + // long paths. Verify the string too: collisions must never alias values. + let hash = Sha256::digest(value.as_bytes()); + let existing = self.index.get(hash.as_slice())?.map(|v| v.value()); + if let Some(id) = existing { + let references = { + let saved = self + .values + .get(id)? + .ok_or("Missing statistics identifier")?; + let (references, saved) = saved.value(); + if saved != value { + return Err("Statistics identifier hash collision".into()); + } + references + .checked_add(1) + .ok_or("Statistics reference count overflow")? + }; + self.values.insert(id, (references, value))?; + return Ok(id); + } + let id = self + .values + .last()? + .map(|(id, _)| id.value()) + .unwrap_or(0) + .checked_add(1) + .ok_or("Statistics identifier overflow")?; + self.values.insert(id, (1, value))?; + self.index.insert(hash.as_slice(), id)?; + Ok(id) + } + + fn release(&mut self, id: u64) -> StoreResult<()> { + if id == 0 { + return Ok(()); + } + let (references, value) = { + let saved = self + .values + .get(id)? + .ok_or("Missing statistics identifier")?; + let (references, value) = saved.value(); + (references, value.to_owned()) + }; + if references == 1 { + self.values.remove(id)?; + self.index + .remove(Sha256::digest(value.as_bytes()).as_slice())?; + } else { + let references = references + .checked_sub(1) + .ok_or("Invalid statistics reference count")?; + self.values.insert(id, (references, value.as_str()))?; + } + Ok(()) + } + + fn clear(&mut self) -> StoreResult<()> { + self.values.retain(|_, _| false)?; + self.index.retain(|_, _| false)?; + Ok(()) + } +} + +pub(super) struct Dictionaries<'a> { + domains: Dictionary<'a>, + endpoints: Dictionary<'a>, + clients: Dictionary<'a>, +} + +impl<'a> Dictionaries<'a> { + pub(super) fn open(tx: &'a WriteTransaction) -> StoreResult { + Ok(Self { + domains: Dictionary { + values: tx.open_table(DOMAINS)?, + index: tx.open_table(DOMAIN_INDEX)?, + }, + endpoints: Dictionary { + values: tx.open_table(ENDPOINTS)?, + index: tx.open_table(ENDPOINT_INDEX)?, + }, + clients: Dictionary { + values: tx.open_table(CLIENTS)?, + index: tx.open_table(CLIENT_INDEX)?, + }, + }) + } + + pub(super) fn intern(&mut self, record: &RequestRecord) -> StoreResult { + Ok(( + record.timestamp, + self.domains.intern(record.domain.as_deref())?, + self.endpoints.intern(record.endpoint.as_deref())?, + self.clients.intern(record.client.as_deref())?, + record.blocked, + )) + } + + pub(super) fn release(&mut self, record: StoredRequest) -> StoreResult<()> { + self.domains.release(record.1)?; + self.endpoints.release(record.2)?; + self.clients.release(record.3)?; + Ok(()) + } + + pub(super) fn redact( + &mut self, + privacy: crate::configuration::StatisticsPrivacy, + ) -> StoreResult<()> { + use crate::configuration::StatisticsPrivacy; + if privacy >= StatisticsPrivacy::HideDomains { + self.domains.clear()?; + self.endpoints.clear()?; + } + if privacy >= StatisticsPrivacy::HideDomainsAndClients { + self.clients.clear()?; + } + Ok(()) + } +} + +pub(super) struct DictionaryReader { + pub domains: ReadOnlyTable, + endpoints: ReadOnlyTable, + pub clients: ReadOnlyTable, +} + +impl DictionaryReader { + pub(super) fn open(tx: &ReadTransaction) -> StoreResult { + Ok(Self { + domains: tx.open_table(DOMAINS)?, + endpoints: tx.open_table(ENDPOINTS)?, + clients: tx.open_table(CLIENTS)?, + }) + } + + pub(super) fn record(&self, id: u64, record: StoredRequest) -> StoreResult { + Ok(RequestRecord { + id, + timestamp: record.0, + domain: value(&self.domains, record.1)?, + endpoint: value(&self.endpoints, record.2)?, + client: value(&self.clients, record.3)?, + blocked: record.4, + }) + } +} + +fn value(table: &ReadOnlyTable, id: u64) -> StoreResult> { + if id == 0 { + return Ok(None); + } + Ok(Some( + table + .get(id)? + .ok_or("Missing statistics identifier")? + .value() + .1 + .to_owned(), + )) +} + +pub(super) fn top_entries( + table: &ReadOnlyTable, + counts: HashMap, +) -> StoreResult> { + // Keep at most 50 strings while resolving IDs, with the same alphabetical + // tie-breaking as the original dashboard. + let mut best = BinaryHeap::new(); + for (id, count) in counts { + if let Some(name) = value(table, id)? { + best.push((Reverse(count), name)); + if best.len() > 50 { + best.pop(); + } + } + } + let mut entries: Vec<_> = best + .into_iter() + .map(|(Reverse(count), name)| (name, count)) + .collect(); + entries.sort_unstable_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + Ok(entries) +} diff --git a/privaxy/src/server/statistics/history/tests.rs b/privaxy/src/server/statistics/history/tests.rs index 797ddfe6..388a9d57 100644 --- a/privaxy/src/server/statistics/history/tests.rs +++ b/privaxy/src/server/statistics/history/tests.rs @@ -31,18 +31,280 @@ fn enabled(privacy: StatisticsPrivacy) -> StatisticsConfig { fn client() -> IpAddr { "192.0.2.10".parse().unwrap() } +fn request_uri(domain: &str) -> http::Uri { + format!("https://{domain}/private-path?secret=do-not-store") + .parse() + .unwrap() +} async fn snapshot(store: &HistoryStore) -> HistorySnapshot { store.query(HistoryQuery::default()).await.unwrap() } +fn assert_dictionaries(tx: &redb::ReadTransaction, sizes: [u64; 3]) { + use sha2::{Digest, Sha256}; + let rows = tx.open_table(REQUESTS).unwrap(); + for (column, (definition, index_name)) in [ + (storage::DOMAINS, "domain_index_v3"), + (storage::ENDPOINTS, "endpoint_index_v3"), + (storage::CLIENTS, "client_index_v3"), + ] + .into_iter() + .enumerate() + { + let mut counts = HashMap::new(); + for entry in rows.iter().unwrap() { + let row = entry.unwrap().1.value(); + let id = [row.1, row.2, row.3][column]; + if id != 0 { + *counts.entry(id).or_insert(0_u64) += 1; + } + } + let values = tx.open_table(definition).unwrap(); + let index = tx + .open_table(TableDefinition::<&[u8], u64>::new(index_name)) + .unwrap(); + assert_eq!(values.len().unwrap(), sizes[column]); + assert_eq!(index.len().unwrap(), sizes[column]); + assert_eq!(counts.len() as u64, sizes[column]); + for entry in values.iter().unwrap() { + let (id, value) = entry.unwrap(); + let (references, value) = value.value(); + assert_eq!(counts[&id.value()], references); + assert_eq!( + index + .get(Sha256::digest(value.as_bytes()).as_slice()) + .unwrap() + .unwrap() + .value(), + id.value() + ); + } + } +} + +#[tokio::test] +async fn repeated_identifiers_share_ids_including_paths_on_different_domains() { + let fixture = Fixture::new(); + let store = HistoryStore::start(fixture.database(), enabled(StatisticsPrivacy::Full)).await; + for (uri, ip) in [ + ("https://one.test/204?first=1", "192.0.2.10"), + ("https://one.test/204?second=2", "192.0.2.10"), + ("https://two.test/204", "2001:db8::1"), + ("https://one.test/", "192.0.2.10"), + ("https://two.test/", "2001:db8::1"), + ] { + store.request(&uri.parse().unwrap(), ip.parse().unwrap(), false); + } + assert_eq!(snapshot(&store).await.totals.proxied_requests, 5); + store.shutdown().await.unwrap(); + let database = Database::open(fixture.database()).unwrap(); + let tx = database.begin_read().unwrap(); + assert_dictionaries(&tx, [2, 2, 2]); + let rows: Vec<_> = tx + .open_table(REQUESTS) + .unwrap() + .iter() + .unwrap() + .map(|entry| entry.unwrap().1.value()) + .collect(); + assert_eq!(rows[0].1, rows[1].1); + assert_eq!(rows[0].2, rows[2].2); + assert_eq!(rows[0].3, rows[1].3); + assert_ne!(rows[0].1, rows[2].1); + assert_ne!(rows[0].3, rows[2].3); + assert_eq!(rows[3].2, rows[4].2); + assert_ne!(rows[3].2, 0, "Root paths must not become missing endpoints"); +} + +#[test] +fn endpoints_store_only_paths_without_the_first_slash() { + for (input, expected) in [ + ( + "https://username:password@EXAMPLE.test:8443/Some%20Path?q=secret", + Some("Some%20Path"), + ), + ( + "http://[2001:db8::1]:8080/path%3Fpart?token=secret", + Some("path%3Fpart"), + ), + ("https://example.test?token=secret", Some("")), + ("https://example.test//kept///path", Some("/kept///path")), + ("/relative/path?token=secret", None), + ] { + let uri: http::Uri = input.parse().unwrap(); + assert_eq!(request_endpoint(&uri).as_deref(), expected, "{input}"); + } + let path = format!("/{}", "segment/".repeat(500)); + let uri = format!("https://example.test{path}?token=secret") + .parse() + .unwrap(); + assert_eq!( + request_endpoint(&uri).unwrap(), + path.strip_prefix('/').unwrap() + ); +} + +#[tokio::test] +async fn existing_history_without_endpoints_still_loads_and_accepts_new_requests() { + let fixture = Fixture::new(); + let timestamp = now_ms(); + let id = timestamp * 1000; + // Match the old on-disk JSON schema: no endpoint key, not even a null value. + let old_record = serde_json::to_vec(&serde_json::json!({ + "id": id, + "timestamp": timestamp, + "domain": "old.example.test", + "client": "192.0.2.10", + "blocked": true, + })) + .unwrap(); + { + let database = Database::create(fixture.database()).unwrap(); + let tx = database.begin_write().unwrap(); + tx.open_table(META).unwrap().insert("version", 1).unwrap(); + tx.open_table(META).unwrap().insert("privacy", 0).unwrap(); + tx.open_table(LEGACY_REQUESTS) + .unwrap() + .insert(id, old_record.as_slice()) + .unwrap(); + tx.commit().unwrap(); + } + let store = HistoryStore::start(fixture.database(), enabled(StatisticsPrivacy::Full)).await; + let data = snapshot(&store).await; + assert_eq!(data.requests.len(), 1); + assert_eq!(data.requests[0].domain.as_deref(), Some("old.example.test")); + assert!(data.requests[0].endpoint.is_none()); + store.request(&request_uri("new.example.test"), client(), false); + let data = snapshot(&store).await; + assert_eq!(data.requests.len(), 2); + assert_eq!(data.requests[0].endpoint.as_deref(), Some("private-path")); + assert_eq!(data.totals.blocked_requests, 1); + assert_eq!(data.totals.proxied_requests, 1); + store.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn legacy_endpoints_migrate_once_without_losing_paths_or_privacy() { + for (version, privacy) in [1, 2].into_iter().flat_map(|version| { + [StatisticsPrivacy::Full, StatisticsPrivacy::HideDomains].map(|privacy| (version, privacy)) + }) { + let fixture = Fixture::new(); + let timestamp = now_ms(); + let first_id = timestamp * 1000; + let examples = [ + ("google.com", "google.com/204", "204"), + ("google.com", "google.com/", ""), + ("google.com", "google.com//kept///path", "/kept///path"), + ("google.com", "google.com/google.com/204", "google.com/204"), + ("[2001:db8::1]", "[2001:db8::1]:8443/a%2Fb", "a%2Fb"), + ]; + { + let database = Database::create(fixture.database()).unwrap(); + let tx = database.begin_write().unwrap(); + tx.open_table(META) + .unwrap() + .insert("version", version) + .unwrap(); + tx.open_table(META).unwrap().insert("privacy", 0).unwrap(); + for (index, (domain, endpoint, path)) in examples.iter().enumerate() { + let id = first_id + index as u64; + let record = serde_json::to_vec(&serde_json::json!({ + "id": id, + "timestamp": timestamp, + "domain": domain, + "endpoint": if version == 1 { endpoint } else { path }, + "client": "192.0.2.10", + "blocked": index % 2 == 0, + })) + .unwrap(); + tx.open_table(LEGACY_REQUESTS) + .unwrap() + .insert(id, record.as_slice()) + .unwrap(); + } + tx.commit().unwrap(); + } + let config = enabled(privacy); + let store = HistoryStore::start(fixture.database(), config.clone()).await; + assert!(store.status().error.is_none()); + let data = snapshot(&store).await; + assert_eq!(data.totals.blocked_requests, 3); + assert_eq!(data.totals.proxied_requests, 2); + for (index, (domain, _, path)) in examples.iter().enumerate() { + let record = data + .requests + .iter() + .find(|record| record.id == first_id + index as u64) + .unwrap(); + assert_eq!(record.timestamp, timestamp); + assert_eq!( + record.domain.as_deref(), + (privacy == StatisticsPrivacy::Full).then_some(*domain) + ); + assert_eq!( + record.endpoint.as_deref(), + (privacy == StatisticsPrivacy::Full).then_some(*path) + ); + } + let new_uri = "https://google.com/google.com/new".parse().unwrap(); + store.request(&new_uri, client(), false); + store.shutdown().await.unwrap(); + { + let database = Database::open(fixture.database()).unwrap(); + let tx = database.begin_read().unwrap(); + assert!(matches!( + tx.open_table(LEGACY_REQUESTS), + Err(redb::TableError::TableDoesNotExist(_)) + )); + assert_eq!( + tx.open_table(META) + .unwrap() + .get("version") + .unwrap() + .unwrap() + .value(), + DATABASE_VERSION + ); + let table = tx.open_table(REQUESTS).unwrap(); + let stored = table.get(first_id).unwrap().unwrap().value(); + let record = DictionaryReader::open(&tx) + .unwrap() + .record(first_id, stored) + .unwrap(); + assert_eq!( + record.endpoint.as_deref(), + (privacy == StatisticsPrivacy::Full).then_some("204") + ); + } + // A valid path can itself start with a hostname. Reopening must not + // mistake it for another legacy authority and strip it a second time. + let store = HistoryStore::start(fixture.database(), config).await; + let data = snapshot(&store).await; + assert_eq!( + data.requests[0].endpoint.as_deref(), + (privacy == StatisticsPrivacy::Full).then_some("google.com/new") + ); + let record = data + .requests + .iter() + .find(|record| record.id == first_id + 3) + .unwrap(); + assert_eq!( + record.endpoint.as_deref(), + (privacy == StatisticsPrivacy::Full).then_some("google.com/204") + ); + store.shutdown().await.unwrap(); + } +} + #[tokio::test] async fn live_subscribers_receive_committed_batches_without_polling() { let fixture = Fixture::new(); let store = HistoryStore::start(fixture.database(), enabled(StatisticsPrivacy::Full)).await; let mut updates = store.subscribe(); updates.borrow_and_update(); - store.request(Some("allowed.example.test"), client(), false); - store.request(Some("blocked.example.test"), client(), true); + store.request(&request_uri("allowed.example.test"), client(), false); + store.request(&request_uri("blocked.example.test"), client(), true); store.modified(); // No query is used to force a flush: the normal batching timer wakes viewers. @@ -68,7 +330,7 @@ async fn live_subscribers_receive_committed_batches_without_polling() { async fn live_subscribers_see_privacy_changes_clears_and_disabled_history() { let fixture = Fixture::new(); let store = HistoryStore::start(fixture.database(), enabled(StatisticsPrivacy::Full)).await; - store.request(Some("private.example.test"), client(), true); + store.request(&request_uri("private.example.test"), client(), true); snapshot(&store).await; let mut updates = store.subscribe(); updates.borrow_and_update(); @@ -84,6 +346,7 @@ async fn live_subscribers_see_privacy_changes_clears_and_disabled_history() { .await .unwrap(); assert!(data.requests[0].domain.is_none()); + assert!(data.requests[0].endpoint.is_none()); assert!(data.requests[0].client.is_none()); assert!(data.top_domains.is_empty()); assert!(data.top_clients.is_empty()); @@ -119,7 +382,7 @@ async fn disabled_and_anonymous_do_not_create_databases_or_retroactively_collect ] { let fixture = Fixture::new(); let store = HistoryStore::start(fixture.database(), config).await; - store.request(Some("private.example.test"), client(), true); + store.request(&request_uri("private.example.test"), client(), true); store.modified(); assert!(snapshot(&store).await.requests.is_empty()); assert!(!fixture.database().exists()); @@ -133,15 +396,16 @@ async fn disabled_and_anonymous_do_not_create_databases_or_retroactively_collect } #[tokio::test] -async fn requests_and_modified_counts_survive_restart_without_url_details() { +async fn requests_survive_restart_with_endpoints_but_without_queries_or_credentials() { let fixture = Fixture::new(); let config = enabled(StatisticsPrivacy::Full); let store = HistoryStore::start(fixture.database(), config.clone()).await; - let uri: http::Uri = "https://EXAMPLE.test:443/private-account?secret=do-not-store" - .parse() - .unwrap(); - store.request(uri.host(), client(), true); - store.request(Some("allowed.example.test"), client(), false); + let uri: http::Uri = + "https://user:password@EXAMPLE.test:443/private-account?secret=do-not-store" + .parse() + .unwrap(); + store.request(&uri, client(), true); + store.request(&request_uri("allowed.example.test"), client(), false); store.modified(); store.shutdown().await.unwrap(); let store = HistoryStore::start(fixture.database(), config).await; @@ -150,14 +414,19 @@ async fn requests_and_modified_counts_survive_restart_without_url_details() { assert_eq!(data.totals.proxied_requests, 1); assert_eq!(data.totals.modified_responses, 1); assert_eq!(data.requests[1].domain.as_deref(), Some("example.test")); + assert_eq!( + data.requests[1].endpoint.as_deref(), + Some("private-account") + ); assert_eq!(data.requests[1].client.as_deref(), Some("192.0.2.10")); assert_eq!(data.top_blocked_domains, vec![("example.test".into(), 1)]); store.shutdown().await.unwrap(); let bytes = std::fs::read(fixture.database()).unwrap(); let text = String::from_utf8_lossy(&bytes); - assert!(!text.contains("private-account")); + assert!(text.contains("private-account")); + assert!(!text.contains("example.test:443/private-account")); assert!(!text.contains("do-not-store")); - assert!(!text.contains(":443")); + assert!(!text.contains("user:password")); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -180,7 +449,7 @@ async fn identifiers_are_redacted_before_queueing_and_cannot_be_recovered() { ] { let fixture = Fixture::new(); let store = HistoryStore::start(fixture.database(), enabled(privacy)).await; - store.request(Some("private.example.test"), client(), true); + store.request(&request_uri("private.example.test"), client(), true); store .configure(enabled(StatisticsPrivacy::Full)) .await @@ -188,6 +457,7 @@ async fn identifiers_are_redacted_before_queueing_and_cannot_be_recovered() { let data = snapshot(&store).await; assert_eq!(data.requests.len(), 1); assert!(data.requests[0].domain.is_none()); + assert!(data.requests[0].endpoint.is_none()); assert_eq!( data.requests[0].client.is_none(), privacy == StatisticsPrivacy::HideDomainsAndClients @@ -196,6 +466,8 @@ async fn identifiers_are_redacted_before_queueing_and_cannot_be_recovered() { let bytes = std::fs::read(fixture.database()).unwrap(); let text = String::from_utf8_lossy(&bytes); assert!(!text.contains("private.example.test")); + assert!(!text.contains("private-path")); + assert!(!text.contains("do-not-store")); if privacy == StatisticsPrivacy::HideDomainsAndClients { assert!(!text.contains("192.0.2.10")); } @@ -211,7 +483,7 @@ async fn increasing_privacy_redacts_old_rows_including_before_anonymous_mode() { ] { let fixture = Fixture::new(); let store = HistoryStore::start(fixture.database(), enabled(StatisticsPrivacy::Full)).await; - store.request(Some("private.example.test"), client(), true); + store.request(&request_uri("private.example.test"), client(), true); // The pending record must also be covered by this transition. store.configure(enabled(privacy)).await.unwrap(); store @@ -221,11 +493,18 @@ async fn increasing_privacy_redacts_old_rows_including_before_anonymous_mode() { let data = snapshot(&store).await; assert_eq!(data.requests.len(), 1); assert!(data.requests[0].domain.is_none()); + assert!(data.requests[0].endpoint.is_none()); assert_eq!( data.requests[0].client.is_none(), privacy >= StatisticsPrivacy::HideDomainsAndClients ); store.shutdown().await.unwrap(); + let database = Database::open(fixture.database()).unwrap(); + let tx = database.begin_read().unwrap(); + assert_dictionaries( + &tx, + [0, 0, u64::from(privacy == StatisticsPrivacy::HideDomains)], + ); } } @@ -237,7 +516,7 @@ async fn retention_entry_cap_and_cursor_pagination_are_enforced() { let store = HistoryStore::start(fixture.database(), config.clone()).await; for i in 0..150 { store.request( - Some(&format!("host-{i}.example.test")), + &request_uri(&format!("host-{i}.example.test")), client(), i % 2 == 0, ); @@ -275,6 +554,7 @@ async fn retention_entry_cap_and_cursor_pagination_are_enforced() { id: 0, timestamp: now_ms() - 2 * DAY_MS, domain: Some("old.example.test".into()), + endpoint: None, client: None, blocked: true, }), @@ -286,6 +566,93 @@ async fn retention_entry_cap_and_cursor_pagination_are_enforced() { // Old timestamp with a newer ID is pruned too (clock corrections). assert_eq!(data.status.stored_requests, 99); store.shutdown().await.unwrap(); + let database = Database::open(fixture.database()).unwrap(); + let tx = database.begin_read().unwrap(); + assert_dictionaries(&tx, [99, 1, 1]); +} + +#[tokio::test] +async fn expiring_last_references_removes_values_and_indexes() { + let fixture = Fixture::new(); + let mut config = enabled(StatisticsPrivacy::Full); + config.retention_days = 3; + let store = HistoryStore::start(fixture.database(), config.clone()).await; + let generation = store.policy.read().unwrap().generation; + for domain in ["first.test", "second.test"] { + store + .sender + .send(Command::Event( + generation, + Event::Request(RequestRecord { + id: 0, + timestamp: now_ms() - 2 * DAY_MS, + domain: Some(domain.into()), + endpoint: Some("same-path".into()), + client: Some(client().to_string()), + blocked: false, + }), + )) + .unwrap(); + } + assert_eq!(snapshot(&store).await.status.stored_requests, 2); + config.retention_days = 1; + store.configure(config.clone()).await.unwrap(); + assert_eq!(snapshot(&store).await.status.stored_requests, 0); + store.shutdown().await.unwrap(); + { + let database = Database::open(fixture.database()).unwrap(); + assert_dictionaries(&database.begin_read().unwrap(), [0, 0, 0]); + } + // Reusing identifiers after they expire must create valid references again. + let store = HistoryStore::start(fixture.database(), config).await; + let uri = "https://first.test/same-path".parse().unwrap(); + store.request(&uri, client(), true); + assert_eq!(snapshot(&store).await.totals.blocked_requests, 1); + store.shutdown().await.unwrap(); + let database = Database::open(fixture.database()).unwrap(); + assert_dictionaries(&database.begin_read().unwrap(), [1, 1, 1]); +} + +#[tokio::test] +async fn invalid_legacy_rows_leave_the_original_database_version_and_records_intact() { + let fixture = Fixture::new(); + { + let database = Database::create(fixture.database()).unwrap(); + let tx = database.begin_write().unwrap(); + tx.open_table(META).unwrap().insert("version", 1).unwrap(); + tx.open_table(LEGACY_REQUESTS) + .unwrap() + .insert(1, b"invalid JSON".as_slice()) + .unwrap(); + tx.commit().unwrap(); + } + let store = HistoryStore::start(fixture.database(), enabled(StatisticsPrivacy::Full)).await; + assert!(store.status().error.is_some()); + store.shutdown().await.unwrap(); + let database = Database::open(fixture.database()).unwrap(); + let tx = database.begin_read().unwrap(); + assert_eq!( + tx.open_table(META) + .unwrap() + .get("version") + .unwrap() + .unwrap() + .value(), + 1 + ); + assert_eq!( + tx.open_table(LEGACY_REQUESTS) + .unwrap() + .get(1) + .unwrap() + .unwrap() + .value(), + b"invalid JSON" + ); + assert!(matches!( + tx.open_table(REQUESTS), + Err(redb::TableError::TableDoesNotExist(_)) + )); } #[tokio::test] @@ -294,12 +661,12 @@ async fn clear_and_disable_do_not_replay_queued_records() { let config = enabled(StatisticsPrivacy::Full); let store = HistoryStore::start(fixture.database(), config.clone()).await; for _ in 0..100 { - store.request(Some("old.example.test"), client(), true); + store.request(&request_uri("old.example.test"), client(), true); } store.clear().await.unwrap(); assert!(snapshot(&store).await.requests.is_empty()); store.configure(StatisticsConfig::default()).await.unwrap(); - store.request(Some("disabled.example.test"), client(), false); + store.request(&request_uri("disabled.example.test"), client(), false); store.clear().await.unwrap(); assert!(!fixture.database().exists()); store.configure(config).await.unwrap(); @@ -321,11 +688,11 @@ async fn corrupt_storage_is_reported_without_truncation_or_breaking_session_coun history: Some(history.clone()), ..Statistics::new() }; - stats.record_request(Some("example.test"), client(), true); + stats.record_request(&request_uri("example.test"), client(), true); assert_eq!(stats.get_serialized().blocked_requests, 1); history.clear().await.unwrap(); assert!(history.status().error.is_none()); - stats.record_request(Some("example.test"), client(), true); + stats.record_request(&request_uri("example.test"), client(), true); assert_eq!(snapshot(&history).await.totals.blocked_requests, 1); history.shutdown().await.unwrap(); } @@ -347,12 +714,12 @@ async fn excluded_domains_skip_counters_history_and_the_live_log_even_when_disab }; for authority in ["example.test:443", "Sub.Private.Test.:8443"] { let uri: http::Uri = format!("https://{authority}/secret").parse().unwrap(); - let request_stats = stats.for_domain(uri.host()); + let request_stats = stats.for_request(uri.host(), client()); assert!(!request_stats.should_collect()); - request_stats.record_request(uri.host(), client(), true); + request_stats.record_request(&uri, client(), true); request_stats.increment_modified_responses(); // The history collector independently enforces the same policy. - history.request(uri.host(), client(), true); + history.request(&uri, client(), true); } let data = stats.get_serialized(); assert_eq!(data.blocked_requests, 0); @@ -360,7 +727,7 @@ async fn excluded_domains_skip_counters_history_and_the_live_log_even_when_disab assert!(data.top_clients.is_empty()); assert!(snapshot(&history).await.requests.is_empty()); for domain in ["private.test", "sub.example.test", "example.test.evil.test"] { - assert!(stats.for_domain(Some(domain)).should_collect()); + assert!(stats.for_request(Some(domain), client()).should_collect()); } history.shutdown().await.unwrap(); } @@ -404,6 +771,197 @@ fn rejects_unbounded_settings_and_urls_in_domain_exclusions() { .is_err()); } +#[tokio::test] +async fn excluded_clients_skip_all_collection_with_or_without_persistence() { + let ipv6: IpAddr = "2001:db8::10".parse().unwrap(); + for persistent in [false, true] { + let fixture = Fixture::new(); + let config = StatisticsConfig { + enabled: persistent, + privacy: StatisticsPrivacy::Full, + excluded_clients: vec![client(), ipv6], + ..Default::default() + }; + let history = HistoryStore::start(fixture.database(), config).await; + let stats = Statistics { + history: Some(history.clone()), + ..Statistics::new() + }; + let uri = request_uri("example.test"); + for address in [client(), ipv6] { + let request_stats = stats.for_request(uri.host(), address); + assert!(!request_stats.should_collect()); + request_stats.record_request(&uri, address, true); + request_stats.increment_modified_responses(); + // Also enforce exclusions when callers use the collectors directly. + stats.record_request(&uri, address, false); + history.request(&uri, address, true); + } + let data = stats.get_serialized(); + assert_eq!(data.blocked_requests, 0); + assert_eq!(data.proxied_requests, 0); + assert_eq!(data.modified_responses, 0); + assert!(data.top_clients.is_empty()); + assert!(snapshot(&history).await.requests.is_empty()); + let allowed: IpAddr = "192.0.2.11".parse().unwrap(); + let request_stats = stats.for_request(uri.host(), allowed); + assert!(request_stats.should_collect()); + request_stats.record_request(&uri, allowed, true); + request_stats.increment_modified_responses(); + assert_eq!(stats.get_serialized().blocked_requests, 1); + assert_eq!(stats.get_serialized().modified_responses, 1); + assert_eq!( + snapshot(&history).await.requests.len(), + usize::from(persistent) + ); + history.shutdown().await.unwrap(); + } +} + +#[tokio::test] +async fn client_names_update_existing_history_and_live_counts_without_merging_clients() { + let other: IpAddr = "2001:db8::11".parse().unwrap(); + for persistent in [false, true] { + let fixture = Fixture::new(); + let mut config = StatisticsConfig { + enabled: persistent, + privacy: StatisticsPrivacy::Full, + client_names: BTreeMap::from([(client(), " Living room ".into())]), + ..Default::default() + }; + let history = HistoryStore::start(fixture.database(), config.clone()).await; + let stats = Statistics { + history: Some(history.clone()), + ..Statistics::new() + }; + stats.record_request(&request_uri("example.test"), client(), true); + stats.record_request(&request_uri("example.test"), other, false); + assert_eq!( + stats.get_serialized().client_names[&client().to_string()], + "Living room" + ); + config.client_names.insert(client(), "TV".into()); + config.client_names.insert(other, "TV".into()); + stats.configure_history(config.clone()).await.unwrap(); + let live = stats.get_serialized(); + assert_eq!(live.proxied_requests, 1); + assert_eq!(live.blocked_requests, 1); + assert_eq!(live.top_clients.len(), 2); + assert_eq!(live.client_names.len(), 2); + assert!(live.client_names.values().all(|name| name == "TV")); + if persistent { + let data = snapshot(&history).await; + assert_eq!(data.requests.len(), 2); + assert_eq!(data.client_names, live.client_names); + assert_eq!(data.top_clients.len(), 2); + config.privacy = StatisticsPrivacy::HideDomainsAndClients; + stats.configure_history(config.clone()).await.unwrap(); + assert!(stats.get_serialized().client_names.is_empty()); + assert!(snapshot(&history).await.client_names.is_empty()); + config.privacy = StatisticsPrivacy::Full; + stats.configure_history(config.clone()).await.unwrap(); + assert!(snapshot(&history).await.client_names.is_empty()); + assert!(stats.get_serialized().client_names.is_empty()); + } + history.clear().await.unwrap(); + assert_eq!(history.config().client_names, config.client_names); + stats.record_request(&request_uri("new.test"), client(), false); + assert_eq!( + stats.get_serialized().client_names[&client().to_string()], + "TV" + ); + history.shutdown().await.unwrap(); + } +} + +#[test] +fn client_settings_round_trip_and_reject_invalid_addresses_or_names() { + let config = StatisticsConfig { + excluded_clients: vec![client(), "2001:db8::10".parse().unwrap()], + client_names: BTreeMap::from([(client(), "Living room TV".into())]), + ..Default::default() + }; + config.validate().unwrap(); + let text = toml::to_string(&config).unwrap(); + assert_eq!(toml::from_str::(&text).unwrap(), config); + let json = serde_json::to_string(&config).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + config + ); + for json in [ + r#"{"excluded_clients":["not-an-ip"]}"#, + r#"{"excluded_clients":["192.0.2.0/24"]}"#, + r#"{"client_names":{"example.com":"TV"}}"#, + ] { + assert!(serde_json::from_str::(json).is_err()); + } + for name in [" ".into(), "a".repeat(65), "TV\nsecond line".into()] { + assert!(StatisticsConfig { + client_names: BTreeMap::from([(client(), name)]), + ..Default::default() + } + .validate() + .is_err()); + } + assert!(StatisticsConfig { + excluded_clients: vec![client(); 1001], + ..Default::default() + } + .validate() + .is_err()); +} + +#[tokio::test] +async fn migration_reduces_storage_for_repeated_identifiers() { + let fixture = Fixture::new(); + let timestamp = now_ms(); + let count = 10_000_u64; + let original_bytes; + { + let mut database = Database::create(fixture.database()).unwrap(); + let tx = database.begin_write().unwrap(); + tx.open_table(META).unwrap().insert("version", 2).unwrap(); + { + let mut requests = tx.open_table(LEGACY_REQUESTS).unwrap(); + for offset in 0..count { + let id = timestamp * 1000 + offset; + let record = RequestRecord { + id, + timestamp, + domain: Some(format!("host-{}.example.test", offset % 100)), + endpoint: Some(format!("api/v1/resources/{}/status", offset % 20)), + client: Some(format!("192.0.2.{}", offset % 10 + 1)), + blocked: offset % 2 == 0, + }; + requests + .insert(id, serde_json::to_vec(&record).unwrap().as_slice()) + .unwrap(); + } + } + tx.commit().unwrap(); + database.compact().unwrap(); + original_bytes = std::fs::metadata(fixture.database()).unwrap().len(); + } + let store = HistoryStore::start(fixture.database(), enabled(StatisticsPrivacy::Full)).await; + let data = snapshot(&store).await; + assert_eq!(data.totals.blocked_requests, count / 2); + assert_eq!(data.totals.proxied_requests, count / 2); + let mut expected: Vec<_> = (0..100) + .map(|i| (format!("host-{i}.example.test"), 100)) + .collect(); + expected.sort_unstable(); + expected.truncate(50); + assert_eq!(data.top_domains, expected); + store.shutdown().await.unwrap(); + let mut database = Database::open(fixture.database()).unwrap(); + database.compact().unwrap(); + assert_dictionaries(&database.begin_read().unwrap(), [100, 20, 10]); + let compact_bytes = std::fs::metadata(fixture.database()).unwrap().len(); + eprintln!("10,000 requests: JSON {original_bytes} bytes; dictionaries {compact_bytes} bytes"); + assert!(compact_bytes < original_bytes); +} + #[test] fn a_full_queue_drops_events_without_blocking_requests() { let (sender, receiver) = mpsc::sync_channel(1); @@ -418,13 +976,14 @@ fn a_full_queue_drops_events_without_blocking_requests() { operations: Arc::new(tokio::sync::Mutex::new(())), updates: watch::channel(()).1, }; - store.request(Some("private.example.test"), client(), true); - store.request(Some("private.example.test"), client(), true); + store.request(&request_uri("private.example.test"), client(), true); + store.request(&request_uri("private.example.test"), client(), true); assert_eq!(store.status().dropped_events, 1); let Command::Event(_, Event::Request(record)) = receiver.try_recv().unwrap() else { panic!("expected a request"); }; assert!(record.domain.is_none()); + assert!(record.endpoint.is_none()); assert!(record.client.is_none()); } @@ -486,6 +1045,7 @@ fn disk_full_pauses_recording_and_preserves_committed_history() { id: 0, timestamp: now_ms(), domain: Some("example.test".into()), + endpoint: Some("".into()), client: None, blocked: true, }) diff --git a/privaxy/src/server/web_gui/history.rs b/privaxy/src/server/web_gui/history.rs index 2b92b475..1ee8fc56 100644 --- a/privaxy/src/server/web_gui/history.rs +++ b/privaxy/src/server/web_gui/history.rs @@ -19,7 +19,7 @@ use warp::{ #[derive(Serialize)] #[serde(tag = "type", rename_all = "snake_case")] enum HistoryUpdate { - Snapshot { snapshot: HistorySnapshot }, + Snapshot { snapshot: Box }, Error { error: String }, } @@ -128,7 +128,9 @@ async fn stream_history(socket: WebSocket, history: HistoryStore, query: History loop { updates.borrow_and_update(); let update = match history.query_committed(query.clone()).await { - Ok(snapshot) => HistoryUpdate::Snapshot { snapshot }, + Ok(snapshot) => HistoryUpdate::Snapshot { + snapshot: Box::new(snapshot), + }, Err(error) => HistoryUpdate::Error { error }, }; let Ok(json) = serde_json::to_string(&update) else { diff --git a/web_frontend/src/dashboard.rs b/web_frontend/src/dashboard.rs index f2939d34..9e137f23 100644 --- a/web_frontend/src/dashboard.rs +++ b/web_frontend/src/dashboard.rs @@ -4,6 +4,7 @@ use crate::{api, history::HistoryPage, statistics_settings::StatisticsSettings}; use futures::future::{AbortHandle, Abortable}; use num_format::{Locale, ToFormattedString}; use serde::Deserialize; +use std::collections::BTreeMap; use wasm_bindgen_futures::spawn_local; use yew::prelude::*; @@ -91,6 +92,8 @@ pub struct Statistics { top_blocked_paths: Vec<(String, u64)>, #[serde(with = "tuple_vec_map")] top_clients: Vec<(String, u64)>, + #[serde(default)] + client_names: BTreeMap, } pub enum Message { @@ -124,6 +127,7 @@ impl Component for LiveDashboard { modified_responses: None, top_blocked_paths: Vec::new(), top_clients: Vec::new(), + client_names: BTreeMap::new(), }, } } @@ -150,13 +154,17 @@ impl Component for LiveDashboard { } } - fn render_list_element(key: &str, count: u64) -> Html { + fn render_list_element( + key: &str, + count: u64, + names: Option<&BTreeMap>, + ) -> Html { html! {
  • -

    { key }

    +

    {if let Some(names) = names {crate::history::client_label(Some(key), names)} else {html!{{key}}}}

    { count.to_formatted_string(&Locale::en) }
    @@ -215,7 +223,7 @@ impl Component for LiveDashboard {
      { for self.message.top_blocked_paths.iter().map(|(path, - count)|render_list_element(path, *count)) } + count)|render_list_element(path, *count, None)) }
    @@ -227,7 +235,7 @@ impl Component for LiveDashboard {
      { for self.message.top_clients.iter().map(|(client, - count)|render_list_element(client, *count)) } + count)|render_list_element(client, *count, Some(&self.message.client_names))) }
    diff --git a/web_frontend/src/history.rs b/web_frontend/src/history.rs index 1805ef2c..71ebe080 100644 --- a/web_frontend/src/history.rs +++ b/web_frontend/src/history.rs @@ -20,6 +20,8 @@ struct Record { id: u64, timestamp: u64, domain: Option, + #[serde(default)] + endpoint: Option, client: Option, blocked: bool, } @@ -32,6 +34,8 @@ pub struct Snapshot { top_domains: Vec<(String, u64)>, top_blocked_domains: Vec<(String, u64)>, top_clients: Vec<(String, u64)>, + #[serde(default)] + client_names: BTreeMap, requests: Vec, next_before: Option, } @@ -176,9 +180,9 @@ impl Component for HistoryPage { {self.chart(snapshot)}
    - {top_list("Top domains", &snapshot.top_domains)} - {top_list("Top blocked domains", &snapshot.top_blocked_domains)} - {top_list("Top clients", &snapshot.top_clients)} + {top_list("Top domains", &snapshot.top_domains, None)} + {top_list("Top blocked domains", &snapshot.top_blocked_domains, None)} + {top_list("Top clients", &snapshot.top_clients, Some(&snapshot.client_names))}
    @@ -194,16 +198,19 @@ impl Component for HistoryPage { } if snapshot.requests.is_empty() {

    {"No saved requests in this window."}

    }
    - - - {for snapshot.requests.iter().map(|record| html! { +
    {"Time (UTC)"}{"Domain"}{"Client"}{"Result"}
    + + {for snapshot.requests.iter().map(|record| { + let endpoint = record.endpoint.as_ref().map(|path| format!("/{path}")); + html! { - - + + + - })} + }})}
    {"Time (UTC)"}{"Domain"}{"Endpoint"}{"Client"}{"Result"}
    {timestamp(record.timestamp)}{record.domain.as_deref().unwrap_or("Hidden")}{record.client.as_deref().unwrap_or("Hidden")}{record.domain.as_deref().unwrap_or("Hidden")}{endpoint.as_deref().unwrap_or(if record.domain.is_some() {"Not recorded"} else {"Hidden"})}{client_label(record.client.as_deref(), &snapshot.client_names)} {if record.blocked {"Blocked"} else {"Proxied"}}
    @@ -298,16 +305,35 @@ impl HistoryPage { } } -fn top_list(title: &str, entries: &[(String, u64)]) -> Html { +fn top_list( + title: &str, + entries: &[(String, u64)], + names: Option<&BTreeMap>, +) -> Html { html! {

    {title}

    if entries.is_empty() {

    {"No visible data at this privacy level or in this window."}

    }
      {for entries.iter().take(10).map(|(label, count)| html! { -
    1. {label}{count.to_formatted_string(&Locale::en)}
    2. +
    3. {if let Some(names) = names {client_label(Some(label), names)} else {html!{{label}}}}{count.to_formatted_string(&Locale::en)}
    4. })}
    } } +pub(crate) fn client_label(address: Option<&str>, names: &BTreeMap) -> Html { + match address { + Some(address) => { + let name = names.get(address).map(String::as_str).unwrap_or(address); + let title = if name == address { + address.to_owned() + } else { + format!("{name} ({address})") + }; + html! {{name}} + } + None => html! {{"Hidden"}}, + } +} + fn timestamp(value: u64) -> String { let iso = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(value as f64)) .to_iso_string() diff --git a/web_frontend/src/statistics_settings.rs b/web_frontend/src/statistics_settings.rs index a4e31a67..d9d9d939 100644 --- a/web_frontend/src/statistics_settings.rs +++ b/web_frontend/src/statistics_settings.rs @@ -1,6 +1,7 @@ use crate::api; use gloo_net::http::Request; use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, net::IpAddr}; use wasm_bindgen_futures::spawn_local; use web_sys::{HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement}; use yew::prelude::*; @@ -13,6 +14,10 @@ pub struct StatisticsConfig { pub max_entries: u32, #[serde(default)] pub excluded_domains: Vec, + #[serde(default)] + pub excluded_clients: Vec, + #[serde(default)] + pub client_names: BTreeMap, } #[derive(Clone, Debug, Default, Deserialize, PartialEq)] @@ -38,6 +43,11 @@ pub enum Message { Days(String), Limit(String), Exclusions(String), + ClientExclusions(String), + ClientAddress(usize, String), + ClientName(usize, String), + AddClient, + RemoveClient(usize), Save, Saved(Result), ConfirmClear(bool), @@ -50,6 +60,8 @@ pub struct StatisticsSettingsPage { days: String, limit: String, exclusions: String, + client_exclusions: String, + client_names: Vec<(String, String)>, busy: bool, confirm_clear: bool, error: Option, @@ -67,6 +79,8 @@ impl Component for StatisticsSettingsPage { days: String::new(), limit: String::new(), exclusions: String::new(), + client_exclusions: String::new(), + client_names: Vec::new(), busy: false, confirm_clear: false, error: None, @@ -93,6 +107,13 @@ impl Component for StatisticsSettingsPage { self.days = settings.config.retention_days.to_string(); self.limit = settings.config.max_entries.to_string(); self.exclusions = settings.config.excluded_domains.join("\n"); + self.client_exclusions = settings.config.excluded_clients.join("\n"); + self.client_names = settings + .config + .client_names + .iter() + .map(|(ip, name)| (ip.clone(), name.clone())) + .collect(); self.error = settings.status.error.clone(); self.settings = Some(settings); } @@ -141,6 +162,47 @@ impl Component for StatisticsSettingsPage { self.exclusions = value; self.notice = None; } + Message::ClientExclusions(value) => { + if self.busy { + return false; + } + self.client_exclusions = value; + self.notice = None; + } + Message::ClientAddress(index, value) => { + if self.busy { + return false; + } + if let Some(client) = self.client_names.get_mut(index) { + client.0 = value; + } + self.notice = None; + } + Message::ClientName(index, value) => { + if self.busy { + return false; + } + if let Some(client) = self.client_names.get_mut(index) { + client.1 = value; + } + self.notice = None; + } + Message::AddClient => { + if self.busy || self.client_names.len() >= 1000 { + return false; + } + self.client_names.push((String::new(), String::new())); + self.notice = None; + } + Message::RemoveClient(index) => { + if self.busy { + return false; + } + if index < self.client_names.len() { + self.client_names.remove(index); + } + self.notice = None; + } Message::Save => { if self.busy { return false; @@ -164,6 +226,48 @@ impl Component for StatisticsSettingsPage { .filter(|s| !s.is_empty()) .map(|s| s.trim_end_matches('.').to_ascii_lowercase()) .collect(); + let clients: Result, _> = self + .client_exclusions + .lines() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|address| address.parse::().map(|ip| ip.to_string())) + .collect(); + let Ok(clients) = clients else { + self.error = Some( + "Client exclusions must contain one IPv4 or IPv6 address per line.".into(), + ); + return true; + }; + config.excluded_clients = clients; + config.client_names.clear(); + for (index, (address, name)) in self.client_names.iter().enumerate() { + let name = name.trim(); + if address.trim().is_empty() && name.is_empty() { + continue; + } + let address = address.trim().parse::(); + if address.is_err() + || name.is_empty() + || name.chars().count() > 64 + || name.chars().any(char::is_control) + { + self.error = Some(format!( + "Client {} needs a valid IP address and a name of 1–64 characters.", + index + 1 + )); + return true; + } + let address = address.unwrap().to_string(); + if config + .client_names + .insert(address.clone(), name.to_owned()) + .is_some() + { + self.error = Some(format!("Client {address} has more than one name. Keep one entry per IP address.")); + return true; + } + } self.busy = true; self.error = None; self.notice = None; @@ -265,9 +369,12 @@ impl Component for StatisticsSettingsPage { let on_exclusions = ctx.link().callback(|e: InputEvent| { Message::Exclusions(e.target_unchecked_into::().value()) }); + let on_client_exclusions = ctx.link().callback(|e: InputEvent| { + Message::ClientExclusions(e.target_unchecked_into::().value()) + }); html! { <>

    {"Statistics"}

    -

    {"History is off by default. When enabled, the Dashboard shows saved totals, trends, and request history. With persistence off or Anonymous mode selected, it shows live session statistics. Saved history stays on this server and survives restarts. It records request times, hostnames, client IPs, and blocking decisions according to your privacy level. Full URLs, query strings, headers, and bodies are never saved in statistics."}

    +

    {"History is off by default. When enabled, the Dashboard shows saved totals, trends, and request history. With persistence off or Anonymous mode selected, it shows live session statistics. Saved history stays on this server and survives restarts. It records request times, hostnames, endpoint paths, client IPs, and blocking decisions according to your privacy level. The Endpoint column shows the path separately from the domain. Query strings, URL credentials, headers, and bodies are never saved in statistics."}

    if let Some(error) = &self.error { }
    @@ -284,7 +391,7 @@ impl Component for StatisticsSettingsPage { {for [("full", "Show domains and clients"), ("hide_domains", "Hide domains"), ("hide_domains_and_clients", "Hide domains and clients"), ("anonymous", "Anonymous — no saved history")].into_iter().map(|(value, label)| html! {})}
    -

    {"Hidden identifiers are removed before collection. While enabled, increasing privacy also removes those identifiers from saved history. They cannot be recovered by lowering privacy. Anonymous mode keeps only session counters and does not record history."}

    +

    {"Hiding domains also hides endpoints. Hidden identifiers are removed before collection. While enabled, increasing privacy also removes those identifiers from saved history. They cannot be recovered by lowering privacy. Anonymous mode keeps only session counters and does not record history."}

    @@ -299,6 +406,27 @@ impl Component for StatisticsSettingsPage {