diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c2ec069..ae1026ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,10 @@ concurrency: permissions: contents: read +env: + # cargo-generate-rpm 0.21.0 has prebuilts for both x86_64 and aarch64. + PACKAGING_TOOLS: cargo-deb@3.8.0,cargo-generate-rpm@0.21.0 + jobs: clippy_check: name: Run clippy check @@ -34,15 +38,10 @@ jobs: components: clippy - name: Cache cargo + target - uses: actions/cache@v4 + uses: Swatinem/rust-cache@v2 with: - key: clippy-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - clippy-${{ runner.os }}- - path: | - ~/.cargo/registry - ~/.cargo/git - ./target + workspaces: . -> target + key: clippy # Frontend dist/ must exist for the server's include_dir! to compile. # Reuses the artifact produced by the `build_frontend` job. @@ -58,6 +57,7 @@ jobs: rustfmt: name: Check style + needs: [image_meta] runs-on: ubuntu-latest steps: - name: Checkout @@ -69,17 +69,37 @@ jobs: components: rustfmt - name: Run cargo fmt - uses: actions-rs/cargo@v1 - with: - command: fmt - args: --all -- --check + run: cargo fmt --all -- --check image_meta: - name: Compute image tag + name: Validate version and compute image tag runs-on: ubuntu-latest outputs: image_tag: ${{ steps.vars.outputs.image_tag }} image_name: ${{ steps.vars.outputs.image_name }} + package_version: ${{ steps.version.outputs.package_version }} steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Validate release version + id: version + run: | + python3 - <<'PY' + import os + import tomllib + + with open("privaxy/Cargo.toml", "rb") as manifest: + version = tomllib.load(manifest)["package"]["version"] + ref = os.environ["GITHUB_REF"] + if ref.startswith("refs/tags/v"): + tag_version = ref.removeprefix("refs/tags/v") + if tag_version != version: + print(f"::error::Release tag v{tag_version} does not match privaxy/Cargo.toml version {version}") + raise SystemExit(1) + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + print(f"package_version={version}", file=output) + PY + - name: Get image tag id: vars run: | @@ -135,6 +155,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Log in to GitHub Container Registry + if: github.event_name == 'push' uses: docker/login-action@v3 with: registry: ghcr.io @@ -147,7 +168,7 @@ jobs: with: images: ${{ needs.image_meta.outputs.image_name }} - - name: Build and push by digest + - name: Build image id: build uses: docker/build-push-action@v6 with: @@ -161,15 +182,18 @@ jobs: annotations: ${{ steps.meta.outputs.annotations }} cache-from: type=gha,scope=${{ matrix.cache_scope }} cache-to: type=gha,mode=max,scope=${{ matrix.cache_scope }} - outputs: type=image,name=${{ needs.image_meta.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true + # PRs validate the image build without publishing to the registry. + outputs: ${{ github.event_name == 'push' && format('type=image,name={0},push-by-digest=true,name-canonical=true,push=true', needs.image_meta.outputs.image_name) || 'type=cacheonly' }} - name: Export digest + if: github.event_name == 'push' run: | mkdir -p /tmp/digests digest="${{ steps.build.outputs.digest }}" touch "/tmp/digests/${digest#sha256:}" - name: Upload digest + if: github.event_name == 'push' uses: actions/upload-artifact@v4 with: name: image-digest-${{ matrix.cache_scope }} @@ -180,6 +204,7 @@ jobs: image_merge: name: Merge multi-arch manifest needs: [image_meta, image_build] + if: github.event_name == 'push' runs-on: ubuntu-latest permissions: contents: read @@ -237,7 +262,7 @@ jobs: - name: Cache cargo + wasm target uses: Swatinem/rust-cache@v2 with: - workspaces: web_frontend -> target + workspaces: . -> target shared-key: build-frontend - name: Install node @@ -315,19 +340,17 @@ jobs: - name: Checkout uses: actions/checkout@v6 - - name: Cache build artifacts - uses: actions/cache@v3 - with: - key: ${{ matrix.os }}-${{ matrix.target }}-artifacts - path: | - ./target - ~/.cargo - - name: Install rust uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} + - name: Cache build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: . -> target + key: native-${{ matrix.target }} + - name: Install cross build dependencies if: matrix.target == 'aarch64-unknown-linux-gnu' run: sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu libc6-dev-arm64-cross @@ -352,14 +375,21 @@ jobs: CC_x86_64_unknown_linux_musl: musl-gcc CC_aarch64_unknown_linux_musl: musl-gcc run: cargo build --release --target ${{ matrix.target }} --bin privaxy --target-dir target + + - name: Install packaging tools + if: ${{ !endsWith(matrix.target, '-musl') }} + uses: taiki-e/install-action@v2 + with: + tool: ${{ env.PACKAGING_TOOLS }} + - name: Build packages if: ${{ !endsWith(matrix.target, '-musl') }} run: | # target/ is cached; discard packages from previous versions. rm -f target/${{ matrix.target }}/release/privaxy_*.deb \ target/${{ matrix.target }}/release/privaxy-*.rpm - cargo install cargo-deb && cargo deb -p privaxy --target ${{ matrix.target }} -o target/${{ matrix.target }}/release - cargo install cargo-generate-rpm && cargo generate-rpm -p privaxy --target ${{ matrix.target }} -o target/${{ matrix.target }}/release + cargo deb --no-build -p privaxy --target ${{ matrix.target }} -o target/${{ matrix.target }}/release + cargo generate-rpm -p privaxy --target ${{ matrix.target }} -o target/${{ matrix.target }}/release - uses: actions/upload-artifact@v4 if: ${{ !endsWith(matrix.target, '-musl') }} with: @@ -401,17 +431,15 @@ jobs: - name: Checkout uses: actions/checkout@v6 - - name: Cache build artifacts - uses: actions/cache@v3 - with: - key: ${{ matrix.os }}-${{ matrix.target }}-artifacts - path: | - ./target - ~/.cargo - - name: Install rust uses: dtolnay/rust-toolchain@nightly + - name: Cache build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: . -> target + key: mips-${{ matrix.target }}-${{ hashFiles('Cross.toml') }} + - name: Install cross uses: taiki-e/install-action@v2 with: @@ -432,6 +460,12 @@ jobs: - name: Build server run: cross build --release --target ${{ matrix.target }} --bin privaxy --target-dir target + - name: Install packaging tools + if: ${{ endsWith(matrix.target, '-linux-gnu') }} + uses: taiki-e/install-action@v2 + with: + tool: ${{ env.PACKAGING_TOOLS }} + - name: Build packages if: ${{ endsWith(matrix.target, '-linux-gnu') }} run: | @@ -439,8 +473,8 @@ jobs: rm -f target/${{ matrix.target }}/release/privaxy_*.deb \ target/${{ matrix.target }}/release/privaxy-*.rpm variant=$(echo "${{ matrix.target }}" | cut -d- -f1) - cargo install cargo-deb && cargo deb --no-build --no-strip --variant "$variant" -p privaxy --target ${{ matrix.target }} -o target/${{ matrix.target }}/release - cargo install cargo-generate-rpm && cargo generate-rpm -p privaxy --target ${{ matrix.target }} -o target/${{ matrix.target }}/release + cargo deb --no-build --no-strip --variant "$variant" -p privaxy --target ${{ matrix.target }} -o target/${{ matrix.target }}/release + cargo generate-rpm -p privaxy --target ${{ matrix.target }} -o target/${{ matrix.target }}/release - uses: actions/upload-artifact@v4 if: ${{ endsWith(matrix.target, '-linux-gnu') }} @@ -462,7 +496,7 @@ jobs: release: name: Create GitHub Release - needs: [ci, ci_mips, image_merge] + needs: [image_meta, ci, ci_mips, image_merge] runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/v') permissions: @@ -477,10 +511,9 @@ jobs: path: ./artifacts pattern: privaxy-* merge-multiple: false - - name: Set package version from tag - run: | - echo "PKG_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV - name: Flatten artifacts for release + env: + PKG_VERSION: ${{ needs.image_meta.outputs.package_version }} run: | mkdir ./release-assets for dir in ./artifacts/privaxy-*/; do diff --git a/.gitignore b/.gitignore index 8ca84374..1da54055 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,7 @@ filters/__pycache__ node_modules .vscode **/target/* -testing/ \ No newline at end of file +testing/ + +# Local opt-in request history can contain browsing metadata. +statistics.redb diff --git a/CHANGELOG.md b/CHANGELOG.md index c09ce61c..48a1a690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,115 @@ # 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 + 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. + Update checks are authenticated and time-limited, with successful checks cached + 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. 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 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 + errors, and shutdown flushing keep storage failures off the proxy request path. + Existing installations retain memory-only statistics until explicitly enabled. +- Show saved statistics directly on the Dashboard when persistence is enabled. + With persistence off or Anonymous mode selected, keep the original live, + per-process dashboard. Remove the separate History tab and redirect old + `/history` bookmarks to `/`. Both layouts retain the blocking toggle and CA + certificate download. +- Live dashboard top blocked entries now use domains and select the top 50 after + sorting; client tracking is bounded. +- Count each modified HTML response once when both head scripts and cosmetic + styles are injected, instead of counting the two injection stages separately. +- Use TLS ClientHello SNI for IP-address CONNECT requests when choosing + interception certificates, applying hostname inclusions and exclusions, and + verifying upstream TLS. HTTP requests and WebSocket upgrades still connect to + the original CONNECT IP and port. Named CONNECT requests retain their existing + identity; DoH redirects and userscript fetches keep their own routing. +- Tunnel CONNECT traffic to port 853 without interception, preserving end-to-end + DNS-over-TLS. The DoH policy continues to apply only to HTTP traffic. Explicit + CONNECT-host/IP exclusions bypass protocol probing. +- Bound CONNECT protocol detection, ClientHello reading, and intercepted TLS + handshakes to 10 seconds each, with a 64 KiB ClientHello limit. Reject malformed, + truncated, oversized, or stalled handshakes while preserving fragmented TLS + records and buffered bytes, including when SNI selects a bypass tunnel. +- Increase the upstream HTTP/2 response header limit from 16 KiB to 64 KiB and + allow 64 KiB of headers on intercepted HTTP/2 requests. This fixes 502 stream + protocol errors from responses with large CSP or Set-Cookie headers. +- Advertise HTTP/2 and HTTP/1.1 through ALPN on intercepted TLS connections so + browsers can multiplex page resources. Enable adaptive HTTP/2 flow control on + those connections and TCP_NODELAY on accepted proxy sockets, tunnel sockets, + and upstream upgrade connections to reduce delays for small writes. +- Preserve known-empty request bodies so the upstream client can retry HTTP/2 + REFUSED_STREAM and graceful GOAWAY failures. Nonempty or unknown-length bodies + continue to stream without buffering or replay. +- Include underlying DNS, TLS, socket, and HTTP/2 failure causes in proxy 502 + pages and logs instead of only reporting "error sending request". +- Improve live statistics, Requests, and log streams with visible connection + status, automatic reconnection, and handling for malformed or unexpected + frames. Leaving a live view cancels its connection and reconnect attempts. +- Show retryable loading errors in settings, filter catalogs, API-key loading, + and userscript management. A failed sign-in status check now offers a retry + instead of incorrectly showing the login form. +- Fix settings save feedback: acknowledge only successfully submitted values, + preserve text drafts after failures and edits made during a save, and disable + unchanged Save buttons and duplicate submissions. Ignore stale responses from + previously visited text-settings pages, and preserve pending exclusion edits + without restoring previously removed hosts. +- Keep blocking controls at their confirmed state after failed updates, and + restore the confirmed Debug settings after failed automatic saves. Lock + Network and PAC fields while saving so later edits cannot be marked saved by + an earlier response. +- Refresh installed filter lists after additions and removals while preserving + pending selections. Reset catalog pagination when searching, bound Next to + matching results, and keep failed removals visible. Built-in lists cannot be + removed through the catalog. +- Make navigation, settings forms, and dialogs fit phone screens. Improve form + labels and error/status announcements, and wrap notification text instead of + truncating it. +- Include [RELEASE.md](RELEASE.md) in generated GitHub release notes and discard cached + Debian/RPM packages before packaging to avoid publishing stale versions. +- Keep Docker builds on pull requests while restricting image and manifest + publishing to pushes. Validate release tags against the server package version + before building, and use that version in release artifact names. +- Correct the frontend cache path and key Rust caches by toolchain, dependencies, + and target. Use pinned prebuilt packaging tools and reuse compiled binaries + when creating Debian packages. ## 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 0ffb544c..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", @@ -2176,6 +2176,7 @@ dependencies = [ "futures", "futures-util", "getrandom 0.3.4", + "h2", "hex", "hmac", "http 1.4.2", @@ -2192,9 +2193,11 @@ dependencies = [ "once_cell", "openssl", "portable-atomic", + "redb", "regex", "reqwest", "rustls", + "semver", "serde", "serde-tuple-vec-map", "serde_json", @@ -2208,6 +2211,7 @@ dependencies = [ "tokio-stream", "tokio-util", "toml", + "tower-service", "uluru", "url", "warp", @@ -2393,6 +2397,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "redb" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de6c3b63e007e90ce536ec2ae4690826136a20ec8dbbbb400daef1bb999d2e36" +dependencies = [ + "libc", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3657,6 +3670,7 @@ dependencies = [ "gloo-net 0.6.0", "gloo-timers 0.4.0", "gloo-utils 0.3.0", + "js-sys", "log", "num-format", "readonly", diff --git a/Cross.toml b/Cross.toml index e01eb019..cbfed87c 100644 --- a/Cross.toml +++ b/Cross.toml @@ -6,10 +6,6 @@ build-std = true [target.mipsel-unknown-linux-musl] build-std = true -[target.mipsel-unknown-linux-musl.env] -RUSTFLAGS = "-C target-feature=+crt-static" [target.mips-unknown-linux-musl] build-std = true -[target.mips-unknown-linux-musl.env] -RUSTFLAGS = "-C target-feature=+crt-static" diff --git a/README.md b/README.md index 8b1315b3..c8f5ada4 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ features, dependency updates, an improved UI, and server-friendly configuration - [Cert-pinned hosts (exclusions)](#4-cert-pinned-hosts-exclusions) - [Filter only selected hosts (inclusions)](#5-filter-only-selected-hosts-inclusions) - [HTTP CONNECT and proxy chaining](#6-http-connect-and-proxy-chaining) + - [Persistent statistics and privacy](#7-persistent-statistics-and-privacy) - [Screenshots](#screenshots) - [Acknowledgements](#acknowledgements) @@ -73,6 +74,7 @@ blocker more than it strictly replaces one. - Suppport for [Adblock Plus filters](https://adblockplus.org/filter-cheatsheet), such as [easylist](https://easylist.to/). - Web graphical user interface with a statistics display as well as a live request explorer. +- Opt-in persistent statistics and request history, with privacy levels, retention limits, and domain exclusions. - Support for uBlock origin's `js` syntax. - Support for uBlock origin's `redirect` syntax. - Support for uBlock origin's scriptlets. @@ -89,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. @@ -285,18 +315,43 @@ exclusions used by ordinary proxy clients. No additional Privaxy setting is needed. This feature supports chains that feed into Privaxy; configuring an upstream proxy for Privaxy's own outbound traffic is outside its scope. -The preceding proxy should preserve the destination hostname in CONNECT, which -Privaxy uses for certificates and domain matching. An IP-only CONNECT for a -named HTTPS site does not recover the hostname from TLS SNI and can fail -certificate validation. Clients must trust Privaxy's CA; certificate-pinned -services still need exclusions. Excluded or unselected hosts are tunneled -without protocol probing. +For an IP CONNECT (including resolved destinations from sing-box/TUN clients), +Privaxy reads TLS ClientHello SNI before choosing an interception certificate +or applying hostname inclusions and exclusions. A DNS SNI hostname supplies +the certificate's DNS SAN, certificate cache identity, HTTP filtering identity, +and upstream TLS SNI/verification name. The upstream connection still goes to +the original CONNECT IP and port. No sing-box DNS or LAN routing changes are +needed. Named CONNECT requests retain their existing behavior. + +Without usable DNS SNI, Privaxy retains the CONNECT identity: an intercepted +literal IP receives an IP SAN, which cannot authenticate a hidden DNS hostname. +Encrypted ClientHello (ECH) does not reveal its inner hostname to Privaxy. +Malformed, truncated, oversized (over 64 KiB), or stalled ClientHellos fail +closed; protocol detection, ClientHello reading, and the intercepted TLS +handshake each have a 10-second timeout. Fragmented ClientHellos and buffered +bytes are preserved, including when SNI selects a raw tunnel. + +Clients must still trust the existing Privaxy CA; certificate-pinned services +need exclusions. Explicit CONNECT-host/IP exclusions bypass protocol probing. +For other IP CONNECTs, hostname rules (including include-only mode) are decided +after reading SNI. Exclusions take precedence over inclusions. + +CONNECT to port **853** is an opaque TCP tunnel for DNS-over-TLS, including +`9.9.9.9:853` with SNI `dns.quad9.net`. TLS stays end-to-end and decrypted DNS +never enters the HTTP parser. Privaxy's DoH policy applies only to HTTP traffic; +it does not filter DoT. Non-HTTP TLS on other ports requires an exclusion. Filtering covers TCP HTTP/HTTPS. Handle UDP/QUIC and non-HTTP protocols in your routing policy; HTTP/3 traffic allowed directly will bypass filtering. Privaxy's client statistics identify the connecting proxy's address, so devices sharing that address appear together. +Local TLS regression tests can be run with +`cargo test -p privaxy --lib proxy::mitm::connect_tests`. They create temporary +trusted test CAs in memory and use loopback fixtures, with no production config +or external services. The DoT exchange uses an unprivileged ephemeral port; +port 853's automatic tunnel selection is tested separately. + #### Example: tun2proxy [tun2proxy](https://github.com/tun2proxy/tun2proxy) is one adapter that can send @@ -319,6 +374,114 @@ for those steps. > `password_hash` value from the config file and restart. The web UI will > force the setup flow again. +### 7. Persistent statistics and privacy + +Enable collection under **Settings → Statistics** to show saved totals, +hourly/daily trends, top domains/clients, and a paginated request log directly +on the **Dashboard**. With persistence off or Anonymous mode selected, the +Dashboard keeps its original layout with the current process's live counters. +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, 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, 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 | + +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. + +**Exclude domains from statistics and request logs** skips matching new traffic +in session counters, modified-response counts, saved history, and the live +Requests feed, even with persistence disabled. Filtering and interception still +apply. Use one case-insensitive hostname or wildcard pattern per line: +`example.com` matches exactly; `*.example.com` matches its subdomains. Do not +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 +decrease as older entries expire. Modified-response totals are separate hourly +aggregates retained by age. Time windows include the current hour and preceding +hours. **Clear saved history** removes the database and starts fresh if enabled. +Turning collection off stops recording and closes the database; it retains the +existing file until cleared or collection is re-enabled and retention runs. + +The equivalent configuration section is: + +```toml +[statistics] +enabled = false +privacy = "hide_domains_and_clients" # full, hide_domains, hide_domains_and_clients, anonymous +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 +in `statistics.redb` under `PRIVAXY_BASE_PATH` (`/etc/privaxy` by default, `/conf` +in Docker). Keep `/conf` on a persistent Docker volume for history to survive +container replacement. No new ports or external database service are required. +On Unix, the database is restricted to its owner (`0600`). Storage is not +encrypted, and redaction/deletion is not secure erasure of filesystem snapshots, +backups, or recoverable disk pages. + +[redb](https://github.com/cberner/redb) transactions run on a dedicated worker, +with a bounded queue and batches flushed every five seconds or 512 events. +SIGTERM/SIGINT also flush accepted events; an abrupt kill can lose the pending +batch. Expired rows are pruned every minute and free space is compacted hourly; +the row limit bounds retained data, not an exact file size. Disk/write errors +pause recording and appear in Statistics settings without stopping the proxy. +Free space and save settings again to retry. Queue overflow drops statistics +events and reports their count instead of delaying proxy requests. + +The authenticated APIs are `GET/PUT /api/settings/statistics` and +`GET/DELETE /api/history`. History accepts `hours`, `limit` (1–200), `before` +(the previous response's `next_before` cursor), and `blocked=true|false`. +Pagination and the blocked filter affect request rows, not the window's totals. + ## Screenshots
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 4fae88e2..7158d144 100644 --- a/privaxy/Cargo.toml +++ b/privaxy/Cargo.toml @@ -1,8 +1,9 @@ [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 = [ "Pierre Barre ", "Josh McDaniel <80354972+joshrmcdaniel@users.noreply.github.com>" @@ -64,10 +65,12 @@ hyper-util = { version = "0.1", features = [ http-body-util = "0.1" tokio = { version = "1.22.0", features = ["full"] } serde_json = "1.0.89" +semver = "1" toml = "1" serde = { version = "1.0.148", features = ["derive"] } tokio-util = { version = "0.7.4", features = ["full"] } tokio-stream = "0.1" +tower-service = "0.3" bytes = "1" socket2 = "0.5" # default-features = false only to drop `single-thread`: the multi-thread @@ -137,6 +140,13 @@ tera = { version = "1", default-features = false } argon2 = { version = "0.5", features = ["std"] } hmac = "0.12" filterlists-api = { path = "../filterlists-api", features = ["reqwest"] } +# redb 4.3+ unconditionally imports libc::flock64 on GNU/Linux, but libc +# does not expose that type on MIPS. Keep the last compatible release until +# upstream fixes the range-lock backend; 4.2 retains the v3 database format. +redb = "=4.2.0" + +[dev-dependencies] +h2 = "0.4" # getrandom 0.3 (pulled in via warp -> tokio-tungstenite -> rand) selects a # backend that calls the libc `getrandom()` wrapper on mips{,el}-unknown-linux-gnu, 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/cert.rs b/privaxy/src/server/cert.rs index 0acc1409..afb857bf 100644 --- a/privaxy/src/server/cert.rs +++ b/privaxy/src/server/cert.rs @@ -49,10 +49,13 @@ impl SignedWithCaCert { // (ring) is installed once as the process default at startup. let private_key = PrivateKeyDer::try_from(private_key.private_key_to_der().unwrap()).unwrap(); - let server_configuration = ServerConfig::builder() + let mut server_configuration = ServerConfig::builder() .with_no_client_auth() .with_single_cert(certs, private_key) .unwrap(); + // The CONNECT service supports both versions. Advertise HTTP/2 so + // browsers can multiplex page resources on one intercepted session. + server_configuration.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; Self { authority, @@ -218,6 +221,9 @@ impl CertCache { cache.insert(certificate); } + /// `authority` is the TLS identity (DNS SNI for an IP CONNECT), not + /// necessarily the socket destination. Cache and SAN generation must use + /// that same identity so virtual hosts sharing an IP cannot share a leaf. pub async fn get(&self, authority: Authority) -> SignedWithCaCert { let mut cache = self.cache.lock().await; 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 1cac41ae..412f4159 100644 --- a/privaxy/src/server/configuration/mod.rs +++ b/privaxy/src/server/configuration/mod.rs @@ -7,6 +7,7 @@ mod ca; mod filter; mod filter_failures; mod network; +mod statistics; mod updater; mod userscript; pub use auth::*; @@ -15,6 +16,7 @@ pub use filter::*; pub use filter_failures::*; use futures::future::join_all; pub use network::*; +pub use statistics::*; use std::env; use std::path::{Path, PathBuf}; pub use updater::*; @@ -85,6 +87,8 @@ pub struct Configuration { pub auth: Auth, #[serde(default)] pub debug: DebugConfig, + #[serde(default)] + pub statistics: StatisticsConfig, /// Userscripts and the master switch governing them. Absent from /// configuration files written before the feature existed, hence the serde /// default. @@ -144,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) @@ -588,6 +593,7 @@ impl Configuration { custom_filters: Vec::new(), auth: Auth::new_initialized(), debug: DebugConfig::default(), + statistics: StatisticsConfig::default(), userscripts: UserScriptsConfig::default(), }) } diff --git a/privaxy/src/server/configuration/statistics.rs b/privaxy/src/server/configuration/statistics.rs new file mode 100644 index 00000000..cee23ecb --- /dev/null +++ b/privaxy/src/server/configuration/statistics.rs @@ -0,0 +1,82 @@ +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)] +#[serde(rename_all = "snake_case")] +pub enum StatisticsPrivacy { + Full, + HideDomains, + #[default] + HideDomainsAndClients, + Anonymous, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(default, deny_unknown_fields)] +pub struct StatisticsConfig { + pub enabled: bool, + pub privacy: StatisticsPrivacy, + pub retention_days: u16, + pub max_entries: u32, + pub excluded_domains: Vec, + pub excluded_clients: Vec, + pub client_names: BTreeMap, +} + +impl Default for StatisticsConfig { + fn default() -> Self { + Self { + enabled: false, + privacy: StatisticsPrivacy::default(), + 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(); + pattern.is_empty() + || pattern.len() > 253 + || !pattern + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b".-*?".contains(&b)) + }) + { + return Err("Use at most 1,000 domain patterns, without schemes, ports, or paths (for example example.com or *.example.com)".into()); + } + if !(1..=365).contains(&self.retention_days) { + return Err("Statistics retention must be between 1 and 365 days".into()); + } + if !(100..=1_000_000).contains(&self.max_entries) { + return Err("Request history limit must be between 100 and 1,000,000 entries".into()); + } + Ok(()) + } + + pub fn persists(&self) -> bool { + self.enabled && self.privacy != StatisticsPrivacy::Anonymous + } +} 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 7e329554..fb614cd7 100644 --- a/privaxy/src/server/lib.rs +++ b/privaxy/src/server/lib.rs @@ -3,17 +3,15 @@ use crate::configuration::{FilterFailureStore, NetworkConfig}; use crate::proxy::exclusions::LocalExclusionStore; use crate::web_gui::events::Event; use hyper::service::service_fn; -use hyper_util::client::legacy::Client; use hyper_util::rt::{TokioExecutor, TokioIo}; use hyper_util::server::conn::auto; use hyper_util::server::graceful::GracefulShutdown; use include_dir::{include_dir, Dir}; use proxy::exclusions; use proxy::gm::storage::GmStorageStore; -use proxy::serve::UpgradeClient; use proxy::tls_failures::TlsFailureStore; +use proxy::upstream::{http_client_builder, UpstreamClients}; use proxy::userscripts::{PrivateNetworkAccess, UserScriptContext, UserScriptStore}; -use reqwest::redirect::Policy; use std::env; use std::net::IpAddr; use std::net::SocketAddr; @@ -30,6 +28,7 @@ mod blocker_utils; mod ca; mod cert; pub mod configuration; +mod filter_list; pub mod logging; mod proxy; pub mod statistics; @@ -104,7 +103,7 @@ pub(crate) fn parse_ip_address(ip_str: &str) -> IpAddr { IpAddr::from_str(ip_str).unwrap() } -async fn handle_signals() -> (Arc, Arc) { +async fn handle_signals(statistics: statistics::Statistics) -> (Arc, Arc) { let notify_shutdown = Arc::new(Notify::new()); let notify_reload = Arc::new(Notify::new()); let notify_shutdown_clone = notify_shutdown.clone(); @@ -115,6 +114,8 @@ async fn handle_signals() -> (Arc, Arc) { signal(SignalKind::hangup()).expect("failed to set up SIGHUP signal handler"); let mut term_signal = signal(SignalKind::terminate()).expect("failed to set up SIGTERM signal handler"); + let mut int_signal = + signal(SignalKind::interrupt()).expect("failed to set up SIGINT signal handler"); loop { tokio::select! { @@ -122,9 +123,16 @@ async fn handle_signals() -> (Arc, Arc) { log::info!("Received SIGHUP signal, restarting child processes..."); notify_reload_clone.notify_waiters(); } - _ = term_signal.recv() => { - log::info!("Received SIGTERM signal, shutting down gracefully..."); + _ = async { tokio::select! { _ = term_signal.recv() => {}, _ = int_signal.recv() => {} } } => { + log::info!("Shutting down; flushing persistent statistics..."); notify_shutdown_clone.notify_waiters(); + if let Some(history) = &statistics.history { + match tokio::time::timeout(Duration::from_secs(8), history.shutdown()).await { + Ok(Ok(())) => {}, + Ok(Err(error)) => log::error!("Could not flush statistics: {error}"), + Err(_) => log::error!("Timed out flushing statistics"), + } + } std::process::exit(0); } } @@ -149,38 +157,7 @@ pub async fn start_privaxy() -> PrivaxyServer { // We use reqwest instead of hyper's client to perform most of the proxying as it's more convenient // to handle compression as well as offers a more convenient interface. - let client = reqwest::Client::builder() - .use_rustls_tls() - .redirect(Policy::none()) - .no_proxy() - .gzip(true) - .brotli(true) - .deflate(true) - // Without these, a proxied request can hang indefinitely on a pooled - // keep-alive connection the remote has silently dropped: reqwest reuses - // the dead connection and waits on a peer that will never answer. - // Retiring idle connections quickly (well under typical server keep-alive - // windows) plus OS-level keepalive probes bounds that. `connect_timeout` - // additionally fails fast on unreachable hosts. - .connect_timeout(Duration::from_secs(10)) - .pool_idle_timeout(Duration::from_secs(30)) - // Bounds a stalled read mid-response: a peer that stops sending without - // closing would otherwise hang the proxied request forever. Generous - // enough (5 minutes between reads) not to kill long-polls or quiet SSE - // streams, which routinely idle for a minute or two between events. - .read_timeout(Duration::from_secs(300)) - // h2-heavy origins multiplex every subresource over a - // single connection whose flow-control window defaults to a small, - // shared 64 KB. When we drain one stream's body slowly (the browser - // reads slowly, or the HTML rewriter backpressures), that window fills - // and stalls *every other stream* on the connection — head-of-line - // stutter across the whole site. Adaptive flow control grows the - // stream/connection windows based on the bandwidth-delay product, - // relieving the stall while keeping multiplexing. (This overrides any - // manual http2_initial_*_window_size, which is why none are set.) - .http2_adaptive_window(true) - .build() - .unwrap(); + let client = http_client_builder().build().unwrap(); let configuration = match configuration::Configuration::read_from_home().await { Ok(configuration) => configuration, @@ -220,7 +197,7 @@ pub async fn start_privaxy() -> PrivaxyServer { } }; - let statistics = statistics::Statistics::new(); + let statistics = statistics::Statistics::with_history(configuration.statistics.clone()).await; let statistics_clone = statistics.clone(); let tls_failure_store = TlsFailureStore::new(configuration.ignored_tls_failures.clone()); @@ -272,7 +249,7 @@ pub async fn start_privaxy() -> PrivaxyServer { let configuration_save_lock = Arc::new(tokio::sync::Mutex::new(())); - let (_notify_shutdown, notify_reload) = handle_signals().await; + let (_notify_shutdown, notify_reload) = handle_signals(statistics.clone()).await; let block_disable_ref = blocking_disabled_store.clone(); let local_exclusion_store_ref = local_exclusion_store.clone(); @@ -361,6 +338,19 @@ pub async fn start_privaxy() -> PrivaxyServer { // The UI updates this store live; SIGHUP must also pick up file // edits to the mode and both inclusion/exclusion lists. local_exclusion_store.replace_configuration(&cfg); + { + // Serialize this re-read/apply with the settings API. A reload + // must not restore a stale privacy policy over a concurrent save. + let _guard = cfg_lock_backend.lock().await; + match configuration::Configuration::read_from_home().await { + Ok(current) => { + if let Err(error) = statistics.configure_history(current.statistics).await { + log::error!("Could not apply statistics settings: {error}"); + } + } + Err(error) => log::error!("Could not reload statistics settings: {error}"), + } + } // Same for the TLS-failure ignore set: pick up hand-edited // `ignored_tls_failures` entries on SIGHUP reload. tls_failure_store.set_ignored(cfg.ignored_tls_failures.clone()); @@ -742,21 +732,7 @@ async fn privaxy_backend( configuration_save_lock: Arc>, notify_reload: Arc, ) { - // Mirror the reqwest client's connection hardening (see above): without a - // connect timeout and OS-level keepalive, an upgrade can hang on a pooled - // keep-alive connection the remote has silently dropped, surfacing as - // "upgrade expected but not completed". - let mut http_connector = hyper_util::client::legacy::connect::HttpConnector::new(); - http_connector.enforce_http(false); - http_connector.set_connect_timeout(Some(Duration::from_secs(10))); - http_connector.set_keepalive(Some(Duration::from_secs(60))); - - let https_connector = hyper_rustls::HttpsConnectorBuilder::new() - .with_native_roots() - .expect("failed to load native root certificates") - .https_or_http() - .enable_http1() - .wrap_connector(http_connector); + let clients = UpstreamClients::new(client).expect("failed to load native root certificates"); let config = match read_configuration(&configuration_save_lock).await { Ok(config) => config, Err(err) => { @@ -785,17 +761,6 @@ async fn privaxy_backend( allow_private_network_requests: private_network_access, }; - // The hyper client is only used to perform upgrades. We don't need to - // handle compression. - // Hyper's client don't follow redirects, which is what we want, nothing to - // disable here. - // An upgraded connection is consumed by the tunnel anyway, so idle pooling - // buys nothing and only risks reusing a stale connection under a long-lived - // WebSocket — disable it. - let hyper_client: UpgradeClient = Client::builder(TokioExecutor::new()) - .pool_max_idle_per_host(0) - .build(https_connector); - let ip = env_or_config_ip(network_config).await; let proxy_server_addr = SocketAddr::from((ip, network_config.proxy_port)); @@ -835,6 +800,9 @@ async fn privaxy_backend( } }; + // Flush small TLS records and streamed response chunks promptly; + // Nagle plus delayed ACKs otherwise introduces avoidable stalls. + let _ = stream.set_nodelay(true); // Reproduce the old `Server::tcp_keepalive(600s)`: enable // OS-level keepalive on each accepted socket. let _ = socket2::SockRef::from(&stream).set_tcp_keepalive( @@ -842,8 +810,7 @@ async fn privaxy_backend( ); let client_ip_address = peer_addr.ip(); - let client = client.clone(); - let hyper_client = hyper_client.clone(); + let clients = clients.clone(); let cert_cache = cert_cache.clone(); let blocker_requester = blocker_requester.clone(); let broadcast_tx = broadcast_tx.clone(); @@ -861,8 +828,7 @@ async fn privaxy_backend( let service = service_fn(move |req| { proxy::serve_mitm_session( blocker_requester.clone(), - hyper_client.clone(), - client.clone(), + clients.clone(), req, cert_cache.clone(), broadcast_tx.clone(), diff --git a/privaxy/src/server/proxy/client_hello.rs b/privaxy/src/server/proxy/client_hello.rs new file mode 100644 index 00000000..d8fc582a --- /dev/null +++ b/privaxy/src/server/proxy/client_hello.rs @@ -0,0 +1,47 @@ +//! Read the ClientHello before choosing an interception policy or certificate. +use std::io; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader}; +use tokio_rustls::StartHandshake; + +// Bound both the TLS parser's input and the copy retained for a possible tunnel. +const MAX_CLIENT_HELLO_BYTES: usize = 64 * 1024; +pub(super) const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +pub(super) async fn read_client_hello( + mut stream: BufReader, +) -> io::Result<(StartHandshake>, Vec)> +where + IO: AsyncRead + AsyncWrite + Unpin, +{ + let mut acceptor = rustls::server::Acceptor::default(); + let mut prefix = Vec::new(); + loop { + let remaining = MAX_CLIENT_HELLO_BYTES - prefix.len(); + if remaining == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "ClientHello too large", + )); + } + let buffered = stream.fill_buf().await?; + let available = &buffered[..buffered.len().min(remaining)]; + let consumed = acceptor.read_tls(&mut &available[..])?; + if consumed == 0 { + return Err(io::ErrorKind::UnexpectedEof.into()); + } + prefix.extend_from_slice(&available[..consumed]); + stream.consume(consumed); + match acceptor.accept() { + Ok(Some(accepted)) => { + // rustls retains the parsed handshake for interception. For a + // bypass, replay exactly the consumed TLS bytes, then drain + // BufReader (including hyper's CONNECT read-ahead) normally. + return Ok((StartHandshake::from_parts(accepted, stream), prefix)); + } + Ok(None) => {} // TCP and TLS-record fragmentation are both valid. + Err((error, _alert)) => { + return Err(io::Error::new(io::ErrorKind::InvalidData, error)); + } + } + } +} diff --git a/privaxy/src/server/proxy/connect_tests.rs b/privaxy/src/server/proxy/connect_tests.rs new file mode 100644 index 00000000..cda8fdf0 --- /dev/null +++ b/privaxy/src/server/proxy/connect_tests.rs @@ -0,0 +1,1164 @@ +//! Local CONNECT/TLS regressions. All keys and trusted roots are ephemeral; +//! these fixtures never load configuration, production CAs or public services. +use super::*; +use crate::blocker::BlockingDisabledStore; +use crate::proxy::{gm::storage::GmStorageStore, userscripts::UserScriptStore}; +use http_body_util::BodyExt; +use openssl::{ + asn1::Asn1Time, + bn::{BigNum, MsbOption}, + hash::MessageDigest, + pkey::{PKey, Private}, + rsa::Rsa, + x509::{ + extension::{BasicConstraints, ExtendedKeyUsage, SubjectAlternativeName}, + X509NameBuilder, X509, + }, +}; +use rustls::{ + pki_types::{CertificateDer, PrivateKeyDer, ServerName}, + ClientConfig, ServerConfig, +}; +use std::{ + collections::BTreeMap, + io, + sync::{OnceLock, RwLock}, + time::Duration, +}; +use tokio::{ + net::TcpListener, + sync::mpsc, + task::{JoinHandle, JoinSet}, + time::timeout, +}; +use tokio_rustls::{client::TlsStream, LazyConfigAcceptor, TlsConnector}; + +const TEST_TIMEOUT: Duration = Duration::from_secs(15); + +struct Pki { + proxy: (X509, PKey), + upstream: (X509, PKey), + untrusted: (X509, PKey), + leaf_key: PKey, +} + +fn pki() -> &'static Pki { + static PKI: OnceLock = OnceLock::new(); + PKI.get_or_init(|| { + let _ = rustls::crypto::ring::default_provider().install_default(); + Pki { + proxy: crate::ca::make_ca_certificate(), + upstream: crate::ca::make_ca_certificate(), + untrusted: crate::ca::make_ca_certificate(), + leaf_key: PKey::from_rsa(Rsa::generate(2048).unwrap()).unwrap(), + } + }) +} + +fn der(cert: &X509) -> CertificateDer<'static> { + cert.to_der().unwrap().into() +} + +// Independent origin certificates: don't use Privaxy's leaf generator for +// both sides of a hostname-verification regression. +fn origin_config(host: &str, trusted: bool, expired: bool) -> Arc { + let pki = pki(); + let (ca, key) = if trusted { + &pki.upstream + } else { + &pki.untrusted + }; + let mut cert = X509::builder().unwrap(); + cert.set_version(2).unwrap(); + let mut serial = BigNum::new().unwrap(); + serial.rand(128, MsbOption::MAYBE_ZERO, false).unwrap(); + cert.set_serial_number(&serial.to_asn1_integer().unwrap()) + .unwrap(); + let mut subject = X509NameBuilder::new().unwrap(); + subject.append_entry_by_text("CN", host).unwrap(); + cert.set_subject_name(&subject.build()).unwrap(); + cert.set_issuer_name(ca.subject_name()).unwrap(); + cert.set_pubkey(&pki.leaf_key).unwrap(); + let (start, end) = if expired { + ( + Asn1Time::from_unix(1).unwrap(), + Asn1Time::from_unix(2).unwrap(), + ) + } else { + ( + Asn1Time::days_from_now(0).unwrap(), + Asn1Time::days_from_now(1).unwrap(), + ) + }; + cert.set_not_before(&start).unwrap(); + cert.set_not_after(&end).unwrap(); + cert.append_extension(BasicConstraints::new().build().unwrap()) + .unwrap(); + cert.append_extension(ExtendedKeyUsage::new().server_auth().build().unwrap()) + .unwrap(); + let mut san = SubjectAlternativeName::new(); + if host.parse::().is_ok() { + san.ip(host); + } else { + san.dns(host); + } + cert.append_extension(san.build(&cert.x509v3_context(Some(ca), None)).unwrap()) + .unwrap(); + cert.sign(key, MessageDigest::sha256()).unwrap(); + let config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert( + vec![der(&cert.build()), der(ca)], + PrivateKeyDer::try_from(pki.leaf_key.private_key_to_der().unwrap()).unwrap(), + ) + .unwrap(); + Arc::new(config) +} + +fn client_config(proxy: bool, sni: bool) -> Arc { + let mut roots = rustls::RootCertStore::empty(); + roots + .add(der(if proxy { + &pki().proxy.0 + } else { + &pki().upstream.0 + })) + .unwrap(); + let mut config = ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + // Disabling SNI does not disable certificate hostname/IP verification. + config.enable_sni = sni; + Arc::new(config) +} + +struct Task(JoinHandle<()>); +impl Drop for Task { + fn drop(&mut self) { + self.0.abort(); + } +} + +struct Proxy { + addr: SocketAddr, + _task: Task, +} + +impl Proxy { + async fn start(policy: LocalExclusionStore) -> Self { + Self::with_doh(policy, DohConfig::default()).await + } + + async fn with_doh(policy: LocalExclusionStore, doh_config: DohConfig) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let clients = UpstreamClients::with_test_roots(&[der(&pki().upstream.0)]); + let cache = CertCache::new(pki().proxy.0.clone(), pki().proxy.1.clone()); + let blocker = AdblockRequester::new(BlockingDisabledStore(Arc::new(RwLock::new(false)))); + let (events, _) = broadcast::channel(4); + let task = tokio::spawn(async move { + let mut sessions = JoinSet::new(); + while let Ok((stream, peer)) = listener.accept().await { + let clients = clients.clone(); + let cache = cache.clone(); + let blocker = blocker.clone(); + let events = events.clone(); + let policy = policy.clone(); + let doh_config = doh_config.clone(); + sessions.spawn(async move { + let service = service_fn(move |request| { + serve_mitm_session( + blocker.clone(), + clients.clone(), + request, + cache.clone(), + events.clone(), + Statistics::new(), + peer.ip(), + policy.clone(), + doh_config.clone(), + false, + TlsFailureStore::new(Default::default()), + None, + UserScriptContext { + store: UserScriptStore::default(), + gm_storage: GmStorageStore::in_memory(), + endpoint_signing_key: "fixture-only".into(), + allow_private_network_requests: Default::default(), + }, + ) + }); + let _ = auto::Builder::new(TokioExecutor::new()) + .serve_connection_with_upgrades(TokioIo::new(stream), service) + .await; + }); + } + }); + Self { + addr, + _task: Task(task), + } + } + + async fn connect( + &self, + destination: &str, + config: Arc, + name: &str, + ) -> TlsStream { + let mut tcp = TcpStream::connect(self.addr).await.unwrap(); + tcp.write_all( + format!("CONNECT {destination} HTTP/1.1\r\nHost: {destination}\r\n\r\n").as_bytes(), + ) + .await + .unwrap(); + assert!(read_headers(&mut tcp) + .await + .unwrap() + .starts_with(b"HTTP/1.1 200")); + timeout( + TEST_TIMEOUT, + TlsConnector::from(config).connect(ServerName::try_from(name.to_owned()).unwrap(), tcp), + ) + .await + .unwrap() + .unwrap() + } +} + +// Read exactly through the header terminator, retaining TLS/DNS bytes that +// might arrive in the same TCP packet. +async fn read_headers(stream: &mut (impl AsyncRead + Unpin)) -> io::Result> { + let mut bytes = Vec::new(); + while !bytes.ends_with(b"\r\n\r\n") { + bytes.push(stream.read_u8().await?); + assert!(bytes.len() < 32 * 1024); + } + Ok(bytes) +} + +#[derive(Clone, Copy)] +enum Protocol { + Http, + HttpKeepAlive, + HttpWaitForPeer, + Http2RefuseOnce, + Http2ResponseHeaders(usize), + Dns, + Upgrade, +} + +struct Origin { + addr: SocketAddr, + requests: mpsc::UnboundedReceiver<(Option, Vec)>, + _task: Task, +} + +impl Origin { + async fn start(configs: Vec<(&str, Arc)>, protocol: Protocol) -> Self { + Self::bind("127.0.0.1:0", configs, protocol).await + } + + async fn bind( + address: &str, + configs: Vec<(&str, Arc)>, + protocol: Protocol, + ) -> Self { + let listener = TcpListener::bind(address).await.unwrap(); + let addr = listener.local_addr().unwrap(); + let fallback = configs[0].1.clone(); + let configs: BTreeMap<_, _> = configs + .into_iter() + .map(|(name, config)| (name.to_owned(), config)) + .collect(); + let (tx, requests) = mpsc::unbounded_channel(); + let response_gate = Arc::new(tokio::sync::Notify::new()); + let task = tokio::spawn(async move { + let mut sessions = JoinSet::new(); + while let Ok((tcp, _)) = listener.accept().await { + let configs = configs.clone(); + let fallback = fallback.clone(); + let tx = tx.clone(); + let response_gate = response_gate.clone(); + sessions.spawn(async move { + let Ok(hello) = LazyConfigAcceptor::new(Default::default(), tcp).await else { return; }; + let name = hello.client_hello().server_name().map(str::to_owned); + let config = name.as_ref().and_then(|name| configs.get(name)).unwrap_or(&fallback).clone(); + let Ok(mut tls) = hello.into_stream(config).await else { return; }; + match protocol { + Protocol::Http | Protocol::HttpKeepAlive | Protocol::HttpWaitForPeer => { + let keep_alive = matches!(protocol, Protocol::HttpKeepAlive); + loop { + let Ok(request) = read_headers(&mut tls).await else { return; }; + let slow = request.starts_with(b"GET /slow "); + let fast = request.starts_with(b"GET /fast "); + tx.send((name.clone(), request)).unwrap(); + if matches!(protocol, Protocol::HttpWaitForPeer) { + if slow { + response_gate.notified().await; + } else if fast { + response_gate.notify_one(); + } + } + let body = format!("upstream:{}", name.as_deref().unwrap_or("no-sni")); + let connection = if keep_alive { "keep-alive" } else { "close" }; + tls.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: {connection}\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + if !keep_alive { break; } + } + } + Protocol::Dns => { + // Two actual DNS-over-TCP frames, including their + // length prefixes and binary DNS response headers. + for _ in 0..2 { + let length = tls.read_u16().await.unwrap(); + let mut query = vec![0; length as usize]; + tls.read_exact(&mut query).await.unwrap(); + tx.send((name.clone(), query.clone())).unwrap(); + query[2..4].copy_from_slice(&[0x81, 0x83]); // NXDOMAIN + tls.write_u16(length).await.unwrap(); + tls.write_all(&query).await.unwrap(); + } + } + Protocol::Http2RefuseOnce => { + let mut connection = h2::server::handshake(tls).await.unwrap(); + let mut refused = false; + while let Some(request) = connection.accept().await { + let (request, mut respond) = request.unwrap(); + tx.send((name.clone(), request.method().as_str().as_bytes().to_vec())).unwrap(); + if !refused { + // The origin explicitly guarantees it did + // not process this request. A bodyless GET + // should be replayed by reqwest's policy. + respond.send_reset(h2::Reason::REFUSED_STREAM); + refused = true; + } else { + let response = http::Response::builder() + .header("content-type", "text/plain") + .body(()) + .unwrap(); + respond.send_response(response, false).unwrap() + .send_data(bytes::Bytes::from_static(b"retried"), true).unwrap(); + } + } + return; + } + Protocol::Http2ResponseHeaders(size) => { + let mut connection = h2::server::handshake(tls).await.unwrap(); + while let Some(request) = connection.accept().await { + let (request, mut respond) = request.unwrap(); + tx.send((name.clone(), request.method().as_str().as_bytes().to_vec())).unwrap(); + let response = http::Response::builder() + .header("content-type", "text/plain") + .header("content-security-policy", format!("script-src https://{}.test", "a".repeat(size))) + .body(()) + .unwrap(); + respond.send_response(response, false).unwrap() + .send_data(bytes::Bytes::from_static(b"large headers"), true).unwrap(); + } + return; + } + Protocol::Upgrade => { + let request = read_headers(&mut tls).await.unwrap(); + tx.send((name, request)).unwrap(); + tls.write_all(b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n").await.unwrap(); + let mut payload = [0; 4]; + tls.read_exact(&mut payload).await.unwrap(); + tls.write_all(&payload).await.unwrap(); + } + } + let _ = tls.shutdown().await; + }); + } + }); + Self { + addr, + requests, + _task: Task(task), + } + } + + async fn request(&mut self) -> (Option, Vec) { + timeout(TEST_TIMEOUT, self.requests.recv()) + .await + .unwrap() + .unwrap() + } +} + +async fn http_get( + stream: impl AsyncRead + AsyncWrite + Unpin + Send + 'static, +) -> (http::StatusCode, String) { + http_request(stream, "https://unrelated.invalid:1234/test", "text/plain").await +} + +async fn http_request( + stream: impl AsyncRead + AsyncWrite + Unpin + Send + 'static, + uri: &str, + accept: &str, +) -> (http::StatusCode, String) { + // Neither an inner Host nor an absolute URL may change the selected peer. + let request = Request::builder() + .uri(uri) + .header("Host", "unrelated.invalid:1234") + .header("Accept", accept) + .body(empty_body()) + .unwrap(); + send_http_request(stream, request).await +} + +async fn send_http_request( + stream: impl AsyncRead + AsyncWrite + Unpin + Send + 'static, + request: Request, +) -> (http::StatusCode, String) { + let (mut sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(stream)) + .await + .unwrap(); + let _task = Task(tokio::spawn(async { + let _ = connection.await; + })); + let response = timeout(TEST_TIMEOUT, sender.send_request(request)) + .await + .unwrap() + .unwrap(); + let status = response.status(); + let bytes = timeout(TEST_TIMEOUT, response.into_body().collect()) + .await + .unwrap() + .unwrap() + .to_bytes(); + (status, String::from_utf8(bytes.to_vec()).unwrap()) +} + +fn peer_certificate(tls: &TlsStream) -> X509 { + X509::from_der(tls.get_ref().1.peer_certificates().unwrap()[0].as_ref()).unwrap() +} + +fn assert_dns_identity(cert: &X509, host: &str) { + let names = cert.subject_alt_names().unwrap(); + assert_eq!(names.len(), 1); + assert_eq!(names[0].dnsname(), Some(host)); + assert!(cert.verify(&pki().proxy.0.public_key().unwrap()).unwrap()); +} + +#[tokio::test] +async fn ip_connect_uses_sni_for_certificates_upstream_tls_and_cache() { + let mut origin = Origin::start( + vec![ + ("one.test", origin_config("one.test", true, false)), + ("two.test", origin_config("two.test", true, false)), + ], + Protocol::Http, + ) + .await; + let proxy = Proxy::start(LocalExclusionStore::new(vec![])).await; + let mut first_cert = None; + for host in ["one.test", "two.test", "one.test"] { + let tls = proxy + .connect(&origin.addr.to_string(), client_config(true, true), host) + .await; + let cert = peer_certificate(&tls); + assert_dns_identity(&cert, host); + if host == "one.test" { + if let Some(first) = &first_cert { + assert_eq!(&cert.to_der().unwrap(), first); + } else { + first_cert = Some(cert.to_der().unwrap()); + } + } + let (status, body) = http_get(tls).await; + assert_eq!(status, http::StatusCode::OK, "{body}"); + assert_eq!(body, format!("upstream:{host}")); + let (sni, request) = origin.request().await; + assert_eq!(sni.as_deref(), Some(host)); + let request = String::from_utf8(request).unwrap().to_ascii_lowercase(); + assert!(request.starts_with("get /test http/1.1")); + assert!(request.contains(&format!("host: {host}:{}\r\n", origin.addr.port()))); + } +} + +#[tokio::test] +async fn same_sni_on_different_connect_destinations_does_not_reuse_the_wrong_pool() { + let mut first = Origin::start( + vec![("shared.test", origin_config("shared.test", true, false))], + Protocol::HttpKeepAlive, + ) + .await; + let address = format!("127.0.0.2:{}", first.addr.port()); + let mut second = Origin::bind( + &address, + vec![("shared.test", origin_config("shared.test", true, false))], + Protocol::HttpKeepAlive, + ) + .await; + let proxy = Proxy::start(LocalExclusionStore::new(vec![])).await; + for index in 0..3 { + let origin = if index == 1 { &mut second } else { &mut first }; + let tls = proxy + .connect( + &origin.addr.to_string(), + client_config(true, true), + "shared.test", + ) + .await; + assert_eq!(http_get(tls).await.0, http::StatusCode::OK); + assert_eq!(origin.request().await.0.as_deref(), Some("shared.test")); + } +} + +#[tokio::test] +async fn hostname_and_literal_ip_connect_keep_verification_and_missing_sni_behavior() { + for (connect_host, identity, send_sni) in [ + ("localhost", "localhost", true), + ("localhost", "localhost", false), + ("127.0.0.1", "127.0.0.1", false), + ("[::1]", "::1", false), + ("[::1]", "one.test", true), + ] { + let bind = if connect_host == "[::1]" { + "[::1]:0" + } else { + "127.0.0.1:0" + }; + let mut origin = Origin::bind( + bind, + vec![(identity, origin_config(identity, true, false))], + Protocol::Http, + ) + .await; + let proxy = Proxy::start(LocalExclusionStore::new(vec![])).await; + let authority = format!("{connect_host}:{}", origin.addr.port()); + let tls = proxy + .connect(&authority, client_config(true, send_sni), identity) + .await; + let cert = peer_certificate(&tls); + if identity.parse::().is_err() { + assert_dns_identity(&cert, identity); + } else { + assert!(cert.subject_alt_names().unwrap()[0].ipaddress().is_some()); + } + let (status, body) = http_get(tls).await; + assert_eq!(status, http::StatusCode::OK, "{body}"); + let upstream_sni = origin.request().await.0; + assert_eq!( + upstream_sni.as_deref(), + if identity.parse::().is_err() { + Some(identity) + } else { + None + } + ); + } +} + +#[tokio::test] +async fn ip_connect_without_sni_does_not_guess_a_dns_identity() { + let origin = Origin::start( + vec![("one.test", origin_config("one.test", true, false))], + Protocol::Http, + ) + .await; + let proxy = Proxy::start(LocalExclusionStore::new(vec![])).await; + let mut tcp = TcpStream::connect(proxy.addr).await.unwrap(); + tcp.write_all(format!("CONNECT {} HTTP/1.1\r\n\r\n", origin.addr).as_bytes()) + .await + .unwrap(); + read_headers(&mut tcp).await.unwrap(); + let result = timeout( + TEST_TIMEOUT, + TlsConnector::from(client_config(true, false)) + .connect(ServerName::try_from("one.test").unwrap(), tcp), + ) + .await + .unwrap(); + assert!( + result.is_err(), + "a DNS verifier must not accept an IP-only certificate" + ); +} + +#[tokio::test] +async fn sni_exclusions_and_ip_exclusions_bypass_interception() { + for (host, exclusions) in [ + ("excluded.test", vec!["*.test".into()]), + ("www.apple.com", vec![]), // built-in exclusion from the reported failure + ("one.test", vec!["127.0.0.1".into()]), + ] { + let mut origin = Origin::start( + vec![(host, origin_config(host, true, false))], + Protocol::Http, + ) + .await; + let proxy = Proxy::start(LocalExclusionStore::new(exclusions)).await; + // Trust only the origin CA: a minted interception cert must fail here. + let tls = proxy + .connect(&origin.addr.to_string(), client_config(false, true), host) + .await; + assert!(peer_certificate(&tls) + .verify(&pki().upstream.0.public_key().unwrap()) + .unwrap()); + assert_eq!(http_get(tls).await.0, http::StatusCode::OK); + assert_eq!(origin.request().await.0.as_deref(), Some(host)); + } +} + +#[tokio::test] +async fn include_only_rules_are_evaluated_after_ip_sni_and_exclusions_win() { + let origin = Origin::start( + vec![("included.test", origin_config("included.test", true, false))], + Protocol::Http, + ) + .await; + for (exclusions, intercept) in [ + (vec![], true), + (vec!["included.test".into()], false), + (vec!["127.0.0.1".into()], false), + ] { + let policy = + LocalExclusionStore::with_rules(true, vec!["included.test".into()], exclusions); + let proxy = Proxy::start(policy).await; + let tls = proxy + .connect( + &origin.addr.to_string(), + client_config(intercept, true), + "included.test", + ) + .await; + if intercept { + assert_dns_identity(&peer_certificate(&tls), "included.test"); + } + assert_eq!(http_get(tls).await.0, http::StatusCode::OK); + } +} + +#[tokio::test] +async fn browser_alpn_enables_parallel_http2_requests_and_keeps_http1() { + let host = "multiplex.test"; + let mut origin = Origin::start( + vec![(host, origin_config(host, true, false))], + Protocol::HttpWaitForPeer, + ) + .await; + let proxy = Proxy::start(LocalExclusionStore::new(vec![])).await; + let mut config = (*client_config(true, true)).clone(); + config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + let tls = proxy + .connect(&origin.addr.to_string(), Arc::new(config), host) + .await; + assert_eq!(tls.get_ref().1.alpn_protocol(), Some(b"h2".as_slice())); + let (mut sender, connection) = + hyper::client::conn::http2::handshake(TokioExecutor::new(), TokioIo::new(tls)) + .await + .unwrap(); + let _task = Task(tokio::spawn(async { + let _ = connection.await; + })); + + // The first origin response is held until the second request arrives. + // Both requests must share this one browser-side TLS connection. + let slow = tokio::spawn( + sender.send_request( + Request::builder() + .uri("https://unrelated.invalid/slow") + .body(empty_body()) + .unwrap(), + ), + ); + let (sni, request) = origin.request().await; + assert_eq!(sni.as_deref(), Some(host)); + assert!(request.starts_with(b"GET /slow ")); + let response = timeout( + TEST_TIMEOUT, + sender.send_request( + Request::builder() + .uri("https://unrelated.invalid/fast") + // Cookie-heavy browsers must not hit hyper's 16 KiB default + // when they start negotiating HTTP/2 with the proxy. + .header("cookie", format!("padding={}", "a".repeat(24 * 1024))) + .body(empty_body()) + .unwrap(), + ), + ) + .await + .unwrap() + .unwrap(); + let slow_response = timeout(TEST_TIMEOUT, slow).await.unwrap().unwrap().unwrap(); + for response in [response, slow_response] { + assert_eq!(response.status(), http::StatusCode::OK); + let body = timeout(TEST_TIMEOUT, response.into_body().collect()) + .await + .unwrap() + .unwrap() + .to_bytes(); + assert_eq!(body, format!("upstream:{host}")); + } + let (sni, request) = origin.request().await; + assert_eq!(sni.as_deref(), Some(host)); + assert!(request.starts_with(b"GET /fast ")); + assert!(String::from_utf8(request) + .unwrap() + .contains(&format!("host: {host}:{}", origin.addr.port()))); + + let mut config = (*client_config(true, true)).clone(); + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + let tls = proxy + .connect(&origin.addr.to_string(), Arc::new(config), host) + .await; + assert_eq!( + tls.get_ref().1.alpn_protocol(), + Some(b"http/1.1".as_slice()) + ); + assert_eq!(http_get(tls).await.0, http::StatusCode::OK); +} + +#[tokio::test] +async fn empty_get_retries_a_refused_http2_stream() { + let mut config = (*origin_config("retry.test", true, false)).clone(); + config.alpn_protocols = vec![b"h2".to_vec()]; + let mut origin = Origin::start( + vec![("retry.test", Arc::new(config))], + Protocol::Http2RefuseOnce, + ) + .await; + let proxy = Proxy::start(LocalExclusionStore::new(vec![])).await; + let tls = proxy + .connect( + &origin.addr.to_string(), + client_config(true, true), + "retry.test", + ) + .await; + + let (status, body) = http_get(tls).await; + assert_eq!(status, http::StatusCode::OK, "{body}"); + assert_eq!(body, "retried"); + for _ in 0..2 { + let (sni, method) = origin.request().await; + assert_eq!(sni.as_deref(), Some("retry.test")); + assert_eq!(method, b"GET"); + } + assert!(origin.requests.try_recv().is_err()); +} + +#[tokio::test] +async fn nonempty_requests_are_not_replayed_after_http2_refusal() { + for method in [Method::GET, Method::POST] { + let mut config = (*origin_config("retry.test", true, false)).clone(); + config.alpn_protocols = vec![b"h2".to_vec()]; + let mut origin = Origin::start( + vec![("retry.test", Arc::new(config))], + Protocol::Http2RefuseOnce, + ) + .await; + let proxy = Proxy::start(LocalExclusionStore::new(vec![])).await; + let tls = proxy + .connect( + &origin.addr.to_string(), + client_config(true, true), + "retry.test", + ) + .await; + let request = Request::builder() + .method(method.clone()) + .uri("/upload") + .body(crate::proxy::full_body("must not be replayed")) + .unwrap(); + let (status, _) = send_http_request(tls, request).await; + assert_eq!(status, http::StatusCode::BAD_GATEWAY); + assert_eq!(origin.request().await.1, method.as_str().as_bytes()); + assert!(origin.requests.try_recv().is_err()); + } +} + +#[tokio::test] +async fn large_http2_response_headers_are_accepted_with_a_bounded_limit() { + // Exercise the shared client (literal IP) and an SNI-pinned client. The + // decoded size, including HPACK's per-field overhead, matters here, not + // the compressed bytes on the wire. + for host in ["127.0.0.1", "headers.test"] { + for (size, expected) in [ + (24 * 1024, http::StatusCode::OK), + (128 * 1024, http::StatusCode::BAD_GATEWAY), + ] { + let mut config = (*origin_config(host, true, false)).clone(); + config.alpn_protocols = vec![b"h2".to_vec()]; + let origin = Origin::start( + vec![(host, Arc::new(config))], + Protocol::Http2ResponseHeaders(size), + ) + .await; + let proxy = Proxy::start(LocalExclusionStore::new(vec![])).await; + let tls = proxy + .connect(&origin.addr.to_string(), client_config(true, true), host) + .await; + let (status, body) = http_get(tls).await; + let reason = body + .lines() + .find(|line| line.contains("error sending request")) + .unwrap_or(&body); + assert_eq!(status, expected, "host={host}, size={size}: {reason}"); + if expected == http::StatusCode::OK { + assert_eq!(body, "large headers"); + } + } + } +} + +#[tokio::test] +async fn invalid_upstream_certificates_are_rejected() { + for (cert_host, trusted, expired) in [ + ("one.test", false, false), + ("wrong.test", true, false), + ("one.test", true, true), + ] { + let mut origin = Origin::start( + vec![("one.test", origin_config(cert_host, trusted, expired))], + Protocol::Http, + ) + .await; + let proxy = Proxy::start(LocalExclusionStore::new(vec![])).await; + let tls = proxy + .connect( + &origin.addr.to_string(), + client_config(true, true), + "one.test", + ) + .await; + let (status, body) = http_get(tls).await; + assert_eq!( + status, + http::StatusCode::BAD_GATEWAY, + "cert={cert_host}, trusted={trusted}, expired={expired}" + ); + assert!(body.contains("invalid peer certificate"), "{body}"); + assert!( + origin.requests.try_recv().is_err(), + "HTTP must never reach an unverified peer" + ); + // The separate hyper client used for upgrades must reject it too. + let mut tls = proxy + .connect( + &origin.addr.to_string(), + client_config(true, true), + "one.test", + ) + .await; + tls.write_all(b"GET /socket HTTP/1.1\r\nHost: one.test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n").await.unwrap(); + let response = timeout(TEST_TIMEOUT, read_headers(&mut tls)) + .await + .unwrap() + .unwrap(); + assert!(response.starts_with(b"HTTP/1.1 502")); + assert!(origin.requests.try_recv().is_err()); + } +} + +#[tokio::test] +async fn ip_connect_protocol_upgrades_keep_the_destination_and_verify_sni() { + let mut origin = Origin::start( + vec![("upgrade.test", origin_config("upgrade.test", true, false))], + Protocol::Upgrade, + ) + .await; + let proxy = Proxy::start(LocalExclusionStore::new(vec![])).await; + let mut tls = proxy + .connect( + &origin.addr.to_string(), + client_config(true, true), + "upgrade.test", + ) + .await; + tls.write_all(b"GET /socket HTTP/1.1\r\nHost: unrelated.invalid\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n").await.unwrap(); + let response = timeout(TEST_TIMEOUT, read_headers(&mut tls)) + .await + .unwrap() + .unwrap(); + assert!(response.starts_with(b"HTTP/1.1 101")); + tls.write_all(b"ping").await.unwrap(); + let mut echo = [0; 4]; + timeout(TEST_TIMEOUT, tls.read_exact(&mut echo)) + .await + .unwrap() + .unwrap(); + assert_eq!(&echo, b"ping"); + let (sni, request) = origin.request().await; + assert_eq!(sni.as_deref(), Some("upgrade.test")); + assert!(String::from_utf8(request) + .unwrap() + .contains(&format!("host: upgrade.test:{}", origin.addr.port()))); +} + +#[tokio::test] +async fn dns_over_tls_passes_binary_queries_and_responses_unchanged() { + let mut origin = Origin::start( + vec![("dns.quad9.net", origin_config("dns.quad9.net", true, false))], + Protocol::Dns, + ) + .await; + // An ephemeral unprivileged port keeps this test runnable without root. + // Standard port 853's automatic bypass is covered separately below. + let proxy = Proxy::start(LocalExclusionStore::new(vec!["dns.quad9.net".into()])).await; + let mut tls = proxy + .connect( + &origin.addr.to_string(), + client_config(false, true), + "dns.quad9.net", + ) + .await; + for id in [0x12, 0x34] { + let query = [ + id, 0x56, 0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0, 7, b'e', b'x', b'a', b'm', b'p', b'l', + b'e', 3, b'c', b'o', b'm', 0, 0, 1, 0, 1, + ]; + tls.write_u16(query.len() as u16).await.unwrap(); + tls.write_all(&query).await.unwrap(); + let length = timeout(TEST_TIMEOUT, tls.read_u16()) + .await + .unwrap() + .unwrap(); + let mut response = vec![0; length as usize]; + timeout(TEST_TIMEOUT, tls.read_exact(&mut response)) + .await + .unwrap() + .unwrap(); + let mut expected = query; + expected[2..4].copy_from_slice(&[0x81, 0x83]); + assert_eq!(response, expected); + let (sni, received) = origin.request().await; + assert_eq!(sni.as_deref(), Some("dns.quad9.net")); + assert_eq!(received, query); + } +} + +#[test] +fn standard_dot_port_bypasses_without_consuming_tls_or_entering_http() { + let policy = LocalExclusionStore::new(vec![]); + for destination in ["9.9.9.9:853", "[2620:fe::fe]:853", "dns.quad9.net:853"] { + let authority: Authority = destination.parse().unwrap(); + assert!(bypass_before_client_hello(&policy, &authority, &authority)); + } + let https: Authority = "9.9.9.9:443".parse().unwrap(); + assert!(!bypass_before_client_hello(&policy, &https, &https)); +} + +fn initial_client_hello( + config: Arc, + host: &str, +) -> (rustls::ClientConnection, Vec) { + let mut client = + rustls::ClientConnection::new(config, ServerName::try_from(host.to_owned()).unwrap()) + .unwrap(); + let mut bytes = Vec::new(); + client.write_tls(&mut bytes).unwrap(); + (client, bytes) +} + +/// Split the handshake itself across TLS records as well as TCP writes. Just +/// fragmenting TCP would miss implementations that only parse the first record. +fn fragment_records(wire: &[u8]) -> Vec { + let mut fragmented = Vec::new(); + let mut offset = 0; + while offset < wire.len() { + let header = &wire[offset..offset + 5]; + let length = u16::from_be_bytes([header[3], header[4]]) as usize; + for chunk in wire[offset + 5..offset + 5 + length].chunks(17) { + fragmented.extend_from_slice(&header[..3]); + fragmented.extend_from_slice(&(chunk.len() as u16).to_be_bytes()); + fragmented.extend_from_slice(chunk); + } + offset += 5 + length; + } + fragmented +} + +async fn flush_tls(client: &mut rustls::ClientConnection, tcp: &mut TcpStream) { + while client.wants_write() { + let mut bytes = Vec::new(); + client.write_tls(&mut bytes).unwrap(); + tcp.write_all(&bytes).await.unwrap(); + } +} + +async fn receive_tls(client: &mut rustls::ClientConnection, tcp: &mut TcpStream) { + let mut bytes = [0; 16 * 1024]; + let length = tcp.read(&mut bytes).await.unwrap(); + assert_ne!(length, 0, "TLS peer closed unexpectedly"); + let mut reader = &bytes[..length]; + while !reader.is_empty() { + assert_ne!(client.read_tls(&mut reader).unwrap(), 0); + client.process_new_packets().unwrap(); + } +} + +#[tokio::test] +async fn fragmented_client_hello_and_connect_read_ahead_work_for_mitm_and_bypass() { + for intercept in [true, false] { + let mut origin = Origin::start( + vec![("fragment.test", origin_config("fragment.test", true, false))], + Protocol::Http, + ) + .await; + let policy = LocalExclusionStore::new(if intercept { + vec![] + } else { + vec!["fragment.test".into()] + }); + let proxy = Proxy::start(policy).await; + timeout(TEST_TIMEOUT, async { + let (mut client, hello) = + initial_client_hello(client_config(intercept, true), "fragment.test"); + let hello = fragment_records(&hello); + let mut tcp = TcpStream::connect(proxy.addr).await.unwrap(); + tcp.set_nodelay(true).unwrap(); + let mut request = format!( + "CONNECT {} HTTP/1.1\r\nHost: {}\r\n\r\n", + origin.addr, origin.addr + ) + .into_bytes(); + // Exercise hyper's upgrade read-ahead with a partial TLS header. + request.extend_from_slice(&hello[..3]); + tcp.write_all(&request).await.unwrap(); + assert!(read_headers(&mut tcp) + .await + .unwrap() + .starts_with(b"HTTP/1.1 200")); + for chunk in hello[3..].chunks(3) { + tcp.write_all(chunk).await.unwrap(); + tokio::task::yield_now().await; + } + while client.is_handshaking() { + flush_tls(&mut client, &mut tcp).await; + receive_tls(&mut client, &mut tcp).await; + } + if intercept { + let cert = X509::from_der(client.peer_certificates().unwrap()[0].as_ref()).unwrap(); + assert_dns_identity(&cert, "fragment.test"); + } + std::io::Write::write_all( + &mut client.writer(), + b"GET /fragment HTTP/1.1\r\nHost: fragment.test\r\nConnection: close\r\n\r\n", + ) + .unwrap(); + flush_tls(&mut client, &mut tcp).await; + let mut response = Vec::new(); + loop { + receive_tls(&mut client, &mut tcp).await; + match std::io::Read::read_to_end(&mut client.reader(), &mut response) { + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::WouldBlock => {} + Err(error) => panic!("TLS read: {error}"), + } + if response.ends_with(b"upstream:fragment.test") { + break; + } + } + assert!(response.starts_with(b"HTTP/1.1 200")); + assert_eq!(origin.request().await.0.as_deref(), Some("fragment.test")); + }) + .await + .unwrap(); + } +} + +#[tokio::test] +async fn client_hello_parser_retains_every_byte_at_all_record_and_buffer_boundaries() { + let (_, hello) = initial_client_hello(client_config(true, true), "fragment.test"); + let mut wire = fragment_records(&hello); + // Bytes after the ClientHello may already be buffered, including TLS 1.3 + // compatibility CCS or early data. A tunnel must preserve them verbatim. + wire.extend_from_slice(b"\x14\x03\x03\x00\x01\x01buffered-after-hello"); + for capacity in [1, 2, 5, 19, 8192] { + let (mut writer, reader) = tokio::io::duplex(wire.len()); + writer.write_all(&wire).await.unwrap(); + writer.shutdown().await.unwrap(); + let (mut hello, mut replay) = read_client_hello(BufReader::with_capacity(capacity, reader)) + .await + .unwrap(); + assert_eq!(hello.client_hello().server_name(), Some("fragment.test")); + hello.io.read_to_end(&mut replay).await.unwrap(); + assert_eq!(replay, wire, "buffer capacity {capacity}"); + } +} + +#[tokio::test] +async fn malformed_truncated_and_oversized_client_hellos_fail_closed() { + let mut oversized = vec![0x16, 3, 1, 0, 4, 1, 1, 0, 1]; + for _ in 0..5 { + oversized.extend_from_slice(&[0x16, 3, 1, 0x40, 0]); + oversized.extend_from_slice(&[0; 16 * 1024]); + } + for bytes in [ + b"\x16\x03\x01\x00\x20\x01".to_vec(), // truncated handshake + b"\x16\x03\x01\x00\x05\x02\x00\x00\x01\x00".to_vec(), // not ClientHello + oversized, + ] { + let (mut writer, reader) = tokio::io::duplex(bytes.len()); + writer.write_all(&bytes).await.unwrap(); + writer.shutdown().await.unwrap(); + assert!(read_client_hello(BufReader::new(reader)).await.is_err()); + } +} + +#[test] +fn tls_identity_only_uses_dns_sni_for_ip_connect_and_keeps_the_port() { + for (connect, sni, expected) in [ + ( + "192.0.2.1:4443", + Some("WWW.Example.test"), + "www.example.test:4443", + ), + ("[2001:db8::1]:443", Some("one.test"), "one.test:443"), + ("192.0.2.1:443", None, "192.0.2.1:443"), + ("[2001:db8::1]:443", Some("192.0.2.2"), "[2001:db8::1]:443"), + ("192.0.2.1:443", Some("invalid/host"), "192.0.2.1:443"), + ( + "original.test:443", + Some("different.test"), + "original.test:443", + ), + ] { + assert_eq!( + tls_identity(&connect.parse().unwrap(), sni).as_str(), + expected + ); + } +} + +#[tokio::test] +async fn connect_ip_pin_does_not_override_configured_doh_redirect_routing() { + let mut resolver = Origin::start( + vec![("localhost", origin_config("localhost", true, false))], + Protocol::Http, + ) + .await; + let mut destination = Origin::bind( + &format!("127.0.0.2:{}", resolver.addr.port()), + vec![("localhost", origin_config("localhost", true, false))], + Protocol::Http, + ) + .await; + let doh = DohConfig { + mode: crate::configuration::DohMode::Redirect, + upstream: Some(format!( + "https://localhost:{}/dns-query", + resolver.addr.port() + )), + extra_hosts: vec![], + }; + let proxy = Proxy::with_doh(LocalExclusionStore::new(vec![]), doh).await; + let tls = proxy + .connect( + &destination.addr.to_string(), + client_config(true, true), + "localhost", + ) + .await; + let (status, body) = http_request(tls, "/dns-query?dns=AA", "application/dns-message").await; + assert_eq!(status, http::StatusCode::OK, "{body}"); + let (sni, request) = resolver.request().await; + assert_eq!(sni.as_deref(), Some("localhost")); + assert!(request.starts_with(b"GET /dns-query?dns=AA HTTP/1.1")); + assert!( + destination.requests.try_recv().is_err(), + "redirect must resolve its own destination" + ); +} diff --git a/privaxy/src/server/proxy/exclusions.rs b/privaxy/src/server/proxy/exclusions.rs index c6aeb10b..3d2056f2 100644 --- a/privaxy/src/server/proxy/exclusions.rs +++ b/privaxy/src/server/proxy/exclusions.rs @@ -298,12 +298,30 @@ impl From<&crate::configuration::Configuration> for InterceptionRules { pub struct LocalExclusionStore(Arc>); impl LocalExclusionStore { + /// Unlike an include-only miss, an explicit exclusion can be honored + /// before looking for SNI on an IP CONNECT (including server-first flows). + pub fn is_excluded(&self, host: &str, raw_host: &str) -> bool { + let rules = self.0.read().unwrap(); + [host, raw_host] + .iter() + .any(|host| DEFAULT_EXCLUSIONS.is_match(host) || rules.exclusions.is_match(host)) + } + #[cfg(test)] pub fn new(exclusions: Vec) -> Self { + Self::with_rules(false, Vec::new(), exclusions) + } + + #[cfg(test)] + pub(super) fn with_rules( + include_only: bool, + inclusions: Vec, + exclusions: Vec, + ) -> Self { Self(Arc::new(RwLock::new(InterceptionRules { exclusions: WildMatchCollection::new(exclusions), - inclusions: WildMatchCollection::new(Vec::new()), - include_only: false, + inclusions: WildMatchCollection::new(inclusions), + include_only, }))) } diff --git a/privaxy/src/server/proxy/html_rewriter.rs b/privaxy/src/server/proxy/html_rewriter.rs index d3cfd65a..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 { @@ -427,6 +430,10 @@ impl Rewriter { endpoint_tokens, } = self; + // Head scriptlets and the trailing cosmetic stylesheet belong to one + // response. Their separate tasks share a single counting guard. + let statistics = statistics.for_response(); + let CosmeticBlockerResult { hidden_selectors, style_selectors, @@ -676,6 +683,56 @@ impl Rewriter { #[cfg(test)] mod tests { use super::*; + + #[tokio::test] + async fn head_and_body_injections_count_each_response_only_once() { + use crate::blocker::BlockingDisabledStore; + use http_body_util::BodyExt; + use std::sync::RwLock; + + let statistics = Statistics::new(); + for expected in 1..=2 { + let requester = + AdblockRequester::new(BlockingDisabledStore(Arc::new(RwLock::new(false)))); + let mut cosmetics = requester + .get_cosmetic_response("https://example.test/".into()) + .await; + cosmetics.hidden_selectors.push(".ad-test".into()); + let (input, receiver) = mpsc::channel(2); + let (sender, mut body) = body_channel(); + let rewriter = Rewriter::new( + requester, + receiver, + sender, + statistics.clone(), + "fixture".into(), + cosmetics, + false, + Vec::new(), + GmStorageStore::in_memory(), + HashMap::new(), + ); + input + .send(Ok(Bytes::from_static( + b"
ad
", + ))) + .await + .unwrap(); + drop(input); + let task = tokio::task::spawn_blocking(move || rewriter.rewrite()); + let mut output = Vec::new(); + while let Some(frame) = body.frame().await { + if let Ok(bytes) = frame.unwrap().into_data() { + output.extend_from_slice(&bytes); + } + } + task.await.unwrap(); + let html = String::from_utf8(output).unwrap(); + assert!(html.contains(", cert_cache: CertCache, broadcast_tx: broadcast::Sender, @@ -65,8 +69,9 @@ pub(crate) async fn serve_mitm_session( if Method::CONNECT == req.method() { // tun2proxy uses CONNECT for every TCP flow, including plain HTTP. // Reply before reading its payload; clients wait for the 200 first. - let intercept = - should_intercept_authority(&local_exclusion_store, &authority, &raw_authority); + let destination = authority.clone(); + let destination_ip = authority_ip(&destination); + let bypass = bypass_before_client_hello(&local_exclusion_store, &authority, &raw_authority); tokio::task::spawn(async move { let upgraded = match hyper::upgrade::on(req).await { @@ -77,34 +82,99 @@ pub(crate) async fn serve_mitm_session( } }; let mut upgraded = TokioIo::new(upgraded); - if !intercept { + if bypass { // Bypass before looking at any payload. This also preserves - // server-first protocols and avoids unnecessary cert minting. - let _ = tunnel(&mut upgraded, &authority, &raw_authority).await; + // server-first protocols. Port 853 is DNS-over-TLS, never HTTP: + // keep its end-to-end TLS and DNS framing completely opaque. + let _ = tunnel(&mut upgraded, &destination, &raw_authority, &[]).await; return; } // Keep both hyper's upgrade read-ahead and our protocol peek. The // first HTTP bytes or TLS ClientHello must reach the parser intact. let mut upgraded = BufReader::new(upgraded); - let protocol = match peek_connect_protocol(&mut upgraded).await { - Ok(Some(protocol)) => protocol, - Ok(None) => return, - Err(error) => { - log::debug!("Unable to read CONNECT payload for {raw_authority}: {error}"); - return; - } - }; + let protocol = + match tokio::time::timeout(HANDSHAKE_TIMEOUT, peek_connect_protocol(&mut upgraded)) + .await + .unwrap_or_else(|_| Err(std::io::ErrorKind::TimedOut.into())) + { + Ok(Some(protocol)) => protocol, + Ok(None) => return, + Err(error) => { + log::debug!("Unable to read CONNECT payload for {raw_authority}: {error}"); + return; + } + }; + let mut authority = authority; + let mut clients = clients; let (stream, scheme) = match protocol { - ConnectProtocol::Http => (Either::Left(upgraded), Scheme::HTTP), + ConnectProtocol::Http => { + if !should_intercept_authority( + &local_exclusion_store, + &authority, + &raw_authority, + ) { + let _ = tunnel(&mut upgraded, &destination, &raw_authority, &[]).await; + return; + } + (Either::Left(upgraded), Scheme::HTTP) + } ConnectProtocol::Tls => { - // Hostnames come from CONNECT (tun2proxy's virtual DNS - // supplies them). Mint only once we know this is TLS. + let (mut hello, prefix) = + match tokio::time::timeout(HANDSHAKE_TIMEOUT, read_client_hello(upgraded)) + .await + .unwrap_or_else(|_| Err(std::io::ErrorKind::TimedOut.into())) + { + Ok(hello) => hello, + Err(error) => { + tls_failure_store.record( + authority.host(), + &error.to_string(), + false, + ); + log::debug!( + "Unable to read ClientHello for {raw_authority}: {error}" + ); + return; + } + }; + authority = tls_identity(&destination, hello.client_hello().server_name()); + log::debug!("CONNECT {raw_authority}: TLS identity {authority}"); + if !should_intercept_authority( + &local_exclusion_store, + &authority, + &raw_authority, + ) { + let _ = tunnel(&mut hello.io, &destination, &raw_authority, &prefix).await; + return; + } + if authority != destination { + // Only IP CONNECTs can acquire a different TLS identity. + // Pin both HTTP clients to that IP and original port; + // resolving SNI could select a different server entirely. + let address = SocketAddr::new( + destination_ip.unwrap(), + destination.port_u16().unwrap_or(443), + ); + clients = match clients.for_destination(&authority, address) { + Ok(clients) => clients, + Err(error) => { + log::warn!( + "Unable to build upstream client for {raw_authority}: {error}" + ); + return; + } + }; + } + // The cache key is the effective TLS identity, not the IP. let server_configuration = Arc::new(cert_cache.get(authority.clone()).await.server_configuration); - match TlsAcceptor::from(server_configuration) - .accept(upgraded) - .await + match tokio::time::timeout( + HANDSHAKE_TIMEOUT, + hello.into_stream(server_configuration), + ) + .await + .unwrap_or_else(|_| Err(std::io::ErrorKind::TimedOut.into())) { Ok(tls_stream) => (Either::Right(tls_stream), Scheme::HTTPS), Err(error) => { @@ -131,18 +201,22 @@ pub(crate) async fn serve_mitm_session( }; // Both transports use the same filtering, rewriting and upgrade - // path. Bind requests to the CONNECT destination, not an inner - // Host header or absolute URI that could name a different host. + // path. Bind requests to the selected identity and the CONNECT + // destination; an inner Host header/URI cannot redirect the socket. let session_authority = raw_authority.clone(); - let result = auto::Builder::new(TokioExecutor::new()) + let mut builder = auto::Builder::new(TokioExecutor::new()); + builder + .http2() + .adaptive_window(true) + .max_header_list_size(MAX_HTTP2_HEADER_LIST_SIZE); + let result = builder .serve_connection_with_upgrades( TokioIo::new(stream), service_fn(move |req| { serve( adblock_requester.clone(), req, - hyper_client.clone(), - client.clone(), + clients.clone(), authority.clone(), scheme.clone(), broadcast_tx.clone(), @@ -202,8 +276,7 @@ pub(crate) async fn serve_mitm_session( serve( adblock_requester, req, - hyper_client.clone(), - client.clone(), + clients, authority, Scheme::HTTP, broadcast_tx, @@ -331,6 +404,7 @@ async fn bridge_http_upgrade( // `TokioIo` bridges hyper 1.0's `Upgraded` to tokio's IO traits. let mut client = TokioIo::new(upgraded); let mut upstream = TcpStream::connect((host, port)).await?; + let _ = upstream.set_nodelay(true); upstream.write_all(head.as_bytes()).await?; let leftover = read_past_response_headers(&mut upstream).await?; @@ -373,11 +447,17 @@ async fn read_past_response_headers(stream: &mut TcpStream) -> std::io::Result, + upgraded: &mut (impl AsyncRead + AsyncWrite + Unpin), authority: &Authority, raw_authority: &Authority, + prefix: &[u8], ) -> std::io::Result<()> { - let mut server = match TcpStream::connect(authority.to_string()).await { + let mut server = match TcpStream::connect(( + authority.host().trim_matches(['[', ']']), + authority.port_u16().unwrap_or(443), + )) + .await + { Ok(server) => server, Err(error) => { if authority == raw_authority { @@ -394,7 +474,9 @@ async fn tunnel( } }; + let _ = server.set_nodelay(true); log::debug!("Started tunneling host: {}", authority); + server.write_all(prefix).await?; // Byte counts and lifetime make tunnel health diagnosable from logs: a // tunnel that closes quickly having received 0 bytes from upstream means @@ -424,6 +506,41 @@ async fn tunnel( } } +fn authority_ip(authority: &Authority) -> Option { + authority.host().trim_matches(['[', ']']).parse().ok() +} + +fn bypass_before_client_hello( + exclusions: &LocalExclusionStore, + destination: &Authority, + raw_authority: &Authority, +) -> bool { + destination.port_u16() == Some(853) + || exclusions.is_excluded(destination.host(), raw_authority.host()) + || (authority_ip(destination).is_none() + && !should_intercept_authority(exclusions, destination, raw_authority)) +} + +/// SNI is an identity hint only for IP CONNECTs. Keep named CONNECT behavior, +/// port selection and literal-IP/no-SNI certificate verification unchanged. +fn tls_identity(destination: &Authority, sni: Option<&str>) -> Authority { + if authority_ip(destination).is_some() { + if let Some(name) = sni { + if matches!( + rustls::pki_types::ServerName::try_from(name), + Ok(rustls::pki_types::ServerName::DnsName(_)) + ) { + let name = name.to_ascii_lowercase(); + let candidate = format!("{}:{}", name, destination.port_u16().unwrap_or(443)); + if let Ok(identity) = candidate.parse() { + return identity; + } + } + } + } + destination.clone() +} + /// Strip a trailing parenthesized service selector from an authority's host. /// /// Apple's RCS client tunnels to Google's Jibe backend using CONNECT @@ -472,6 +589,10 @@ where Ok(()) } +#[cfg(test)] +#[path = "connect_tests.rs"] +mod connect_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/privaxy/src/server/proxy/mod.rs b/privaxy/src/server/proxy/mod.rs index 0c162b07..3e90f7ff 100644 --- a/privaxy/src/server/proxy/mod.rs +++ b/privaxy/src/server/proxy/mod.rs @@ -1,3 +1,4 @@ +mod client_hello; pub(crate) mod doh; pub(crate) mod mitm; pub(crate) mod serve; @@ -6,6 +7,7 @@ pub(crate) mod exclusions; pub(crate) mod gm; pub(crate) mod html_rewriter; pub(crate) mod tls_failures; +pub(crate) mod upstream; pub(crate) mod userscripts; use bytes::Bytes; @@ -13,6 +15,10 @@ use http_body_util::{combinators::BoxBody, BodyExt, Empty, Full, StreamBody}; use hyper::body::Frame; use tokio_stream::wrappers::ReceiverStream; +/// Bound decoded HTTP/2 headers on both sides of intercepted connections. +/// Allow cookie-heavy requests and large CSP/Set-Cookie responses. +pub(crate) const MAX_HTTP2_HEADER_LIST_SIZE: u32 = 64 * 1024; + /// Response body type used throughout the proxy. hyper 1.0 removed the built-in /// `hyper::Body`, so we standardize on a boxed body of `Bytes` with an /// `io::Error` error type (the channel/stream bodies below can surface IO diff --git a/privaxy/src/server/proxy/serve.rs b/privaxy/src/server/proxy/serve.rs index 17e481ce..3e169f76 100644 --- a/privaxy/src/server/proxy/serve.rs +++ b/privaxy/src/server/proxy/serve.rs @@ -1,6 +1,7 @@ use super::doh::{self, DohAction}; use super::gm::endpoint as gm_endpoint; use super::html_rewriter::Rewriter; +use super::upstream::{UpgradeClient, UpstreamClients}; use super::userscripts::UserScriptContext; use super::{body_channel, boxed_incoming, empty_body, full_body, BodySender, ProxyBody}; use crate::blocker::AdblockRequester; @@ -13,23 +14,23 @@ use futures::TryStreamExt; use http::uri::{Authority, Scheme}; use http::{HeaderMap, HeaderValue, Request, Response, StatusCode, Uri}; use http_body_util::{BodyExt, BodyStream, Limited}; -use hyper::body::{Frame, Incoming}; -use hyper_rustls::HttpsConnector; -use hyper_util::client::legacy::connect::HttpConnector; -use hyper_util::client::legacy::Client as HyperClient; +use hyper::body::{Body, Frame, Incoming}; use hyper_util::rt::TokioIo; use std::net::IpAddr; use tokio::sync::broadcast; use url::Url; -/// Type of the hyper client used for upgrade tunneling (websockets, etc.). -pub(crate) type UpgradeClient = HyperClient, ProxyBody>; - /// Adapt an incoming request body into a `reqwest::Body`, preserving streaming /// (hyper 1.0 + reqwest 0.13 no longer share a body type, so we bridge via a /// `Bytes` stream). Trailer frames are dropped — proxied request bodies don't /// carry meaningful trailers. fn incoming_to_reqwest_body(incoming: Incoming) -> reqwest::Body { + // Even an empty wrap_stream body cannot be cloned. Preserve a known-empty + // body so reqwest can retry an HTTP/2 REFUSED_STREAM or graceful GOAWAY. + // Nonempty/unknown bodies still stream without buffering or replaying them. + if incoming.is_end_stream() { + return reqwest::Body::default(); + } let stream = BodyStream::new(incoming).try_filter_map(|frame| async move { Ok(frame.into_data().ok()) }); reqwest::Body::wrap_stream(stream) @@ -289,8 +290,7 @@ fn url_for_matching(uri: &Uri) -> String { pub(crate) async fn serve( adblock_requester: AdblockRequester, request: Request, - hyper_client: UpgradeClient, - client: reqwest::Client, + clients: UpstreamClients, authority: Authority, scheme: Scheme, broadcast_sender: broadcast::Sender, @@ -302,8 +302,7 @@ pub(crate) async fn serve( user_scripts: UserScriptContext, intercept: bool, ) -> Result, hyper::Error> { - let scheme_string = scheme.to_string(); - + let (client, hyper_client) = clients.proxy_clients(); let uri = match http::uri::Builder::new() .scheme(scheme) .authority(authority) @@ -338,9 +337,14 @@ pub(crate) async fn serve( .map(|collected| collected.to_bytes()) .unwrap_or_default(); - return Ok( - gm_endpoint::handle(&uri, &parts.method, &collected, &user_scripts, &client).await, - ); + return Ok(gm_endpoint::handle( + &uri, + &parts.method, + &collected, + &user_scripts, + &clients.http, + ) + .await); } let (mut parts, body) = request.into_parts(); @@ -349,14 +353,13 @@ pub(crate) async fn serve( let req = Request::from_parts(parts, body); log::debug!("{} {}", req.method(), req.uri()); - - statistics.increment_top_clients(client_ip_address); + let statistics = statistics.for_request(uri.host(), client_ip_address); if !intercept { let mut req = req; req.headers_mut().remove(http::header::PROXY_AUTHORIZATION); req.headers_mut().remove("proxy-connection"); - if broadcast_sender.receiver_count() > 0 { + if statistics.should_collect() && broadcast_sender.receiver_count() > 0 { let _ = broadcast_sender.send(Event { now: chrono::Utc::now(), method: req.method().to_string(), @@ -367,7 +370,7 @@ pub(crate) async fn serve( // Forward with hyper so bodies, compression and CSP remain untouched. // This path skips adblock, DoH policy and all HTML/userscript injection. let response = if req.headers().contains_key(http::header::UPGRADE) { - perform_two_ends_upgrade(req, uri, hyper_client).await + perform_two_ends_upgrade(req, uri.clone(), hyper_client).await } else { let (mut parts, body) = req.into_parts(); parts.headers.remove(http::header::HOST); @@ -385,7 +388,7 @@ pub(crate) async fn serve( } } }; - statistics.increment_proxied_requests(); + statistics.record_request(&uri, client_ip_address, false); return Ok(response); } @@ -403,7 +406,7 @@ pub(crate) async fn serve( log::debug!("Refusing DoH request: {}", uri); // Events only feed the live requests view; when nobody is subscribed, // building one (two Strings and a timestamp) is wasted work. - if broadcast_sender.receiver_count() > 0 { + if statistics.should_collect() && broadcast_sender.receiver_count() > 0 { let _result = broadcast_sender.send(Event { now: chrono::Utc::now(), method: req.method().to_string(), @@ -411,7 +414,7 @@ pub(crate) async fn serve( is_request_blocked: true, }); } - statistics.increment_blocked_requests(); + statistics.record_request(&uri, client_ip_address, true); return Ok(get_empty_response(StatusCode::BAD_GATEWAY)); } @@ -426,7 +429,7 @@ pub(crate) async fn serve( request_type, ); - if broadcast_sender.receiver_count() > 0 { + if statistics.should_collect() && broadcast_sender.receiver_count() > 0 { let _result = broadcast_sender.send(Event { now: chrono::Utc::now(), method: req.method().to_string(), @@ -436,13 +439,7 @@ pub(crate) async fn serve( } if is_request_blocked { - statistics.increment_blocked_requests(); - statistics.increment_top_blocked_paths(format!( - "{}://{}{}", - scheme_string, - uri.host().unwrap(), - uri.path() - )); + 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 @@ -471,12 +468,19 @@ pub(crate) async fn serve( } _ => req.uri().to_string(), }; + // A configured resolver is a separate destination and must use its usual + // DNS/TLS checks rather than inheriting this CONNECT session's IP pin. + let (client, hyper_client) = if matches!(doh_action, DohAction::Redirect(_)) { + (clients.http, clients.upgrade) + } else { + (client, hyper_client) + }; if req.headers().contains_key(http::header::UPGRADE) { let Ok(outbound_uri) = outbound_url.parse() else { return Ok(get_empty_response(StatusCode::BAD_GATEWAY)); }; let response = perform_two_ends_upgrade(req, outbound_uri, hyper_client).await; - statistics.increment_proxied_requests(); + statistics.record_request(&uri, client_ip_address, false); return Ok(response); } @@ -519,20 +523,21 @@ pub(crate) async fn serve( { Ok(response) => response, Err(err) => { - log::error!("Failed to send request: {}", err); + let reason = format_error_chain(&err); + log::error!("Failed to send request: {reason}"); // `authority` was moved into the uri builder above; the host of // the rebuilt `uri` is the same portless hostname. let failing_host = uri.host(); let exclude_url = build_exclude_url(failing_host, gui_base_url.as_deref()); return Ok(get_informative_error_response( - &err.to_string(), + &reason, failing_host, exclude_url.as_deref(), )); } }; - statistics.increment_proxied_requests(); + statistics.record_request(&uri, client_ip_address, false); *new_response.headers_mut() = response.headers().clone(); @@ -673,6 +678,19 @@ pub(crate) async fn serve( Ok(new_response) } +/// reqwest's Display only says "error sending request"; its sources contain +/// the actual DNS, TLS, socket or HTTP/2 failure needed to diagnose a 502. +fn format_error_chain(error: &(dyn std::error::Error + 'static)) -> String { + let mut reason = error.to_string(); + let mut source = error.source(); + while let Some(error) = source { + reason.push_str(": "); + reason.push_str(&error.to_string()); + source = error.source(); + } + reason +} + /// Minimal HTML escaping for text interpolated into the proxy's error page. /// The failure reason comes from reqwest and can embed attacker-influenced /// URLs; the failing host is client-controlled. diff --git a/privaxy/src/server/proxy/upstream.rs b/privaxy/src/server/proxy/upstream.rs new file mode 100644 index 00000000..64c52c6f --- /dev/null +++ b/privaxy/src/server/proxy/upstream.rs @@ -0,0 +1,177 @@ +//! Keep the HTTP/TLS identity separate from an IP CONNECT's TCP destination. +use super::{ProxyBody, MAX_HTTP2_HEADER_LIST_SIZE}; +use http::{uri::Authority, Uri}; +use hyper_rustls::{ConfigBuilderExt, HttpsConnector}; +use hyper_util::{ + client::legacy::{connect::HttpConnector, Client}, + rt::TokioExecutor, +}; +use std::{ + net::SocketAddr, + task::{Context, Poll}, + time::Duration, +}; +use tower_service::Service; + +/// HTTP/1 client for protocol upgrades and unchanged, unfiltered responses. +pub(crate) type UpgradeClient = Client, ProxyBody>; + +/// Shared defaults for ordinary requests and clients scoped to an IP CONNECT. +/// Keep redirects disabled: each request must go through proxy policy checks. +pub(crate) fn http_client_builder() -> reqwest::ClientBuilder { + reqwest::Client::builder() + .use_rustls_tls() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .gzip(true) + .brotli(true) + .deflate(true) + // Bound unreachable peers and retire stale idle connections quickly. + .connect_timeout(Duration::from_secs(10)) + .pool_idle_timeout(Duration::from_secs(30)) + // Permit long polls/SSE while bounding a stalled response read. + .read_timeout(Duration::from_secs(300)) + // Avoid a slow response stalling other streams on an HTTP/2 connection. + .http2_adaptive_window(true) + // Large CSP and Set-Cookie responses can exceed hyper's 16 KiB + // default, which h2 reports as a generic stream PROTOCOL_ERROR. Allow + // 64 KiB of decoded headers (including per-field overhead) while + // retaining a finite limit for both shared and IP-pinned clients. + .http2_max_header_list_size(MAX_HTTP2_HEADER_LIST_SIZE) +} + +#[derive(Clone)] +pub(crate) struct UpstreamClients { + // Global clients remain available for policy-directed DoH redirects and + // GM requests, whose destination checks must not inherit a CONNECT pin. + pub http: reqwest::Client, + pub upgrade: UpgradeClient, + pinned: Option<(reqwest::Client, UpgradeClient)>, + upgrade_tls: rustls::ClientConfig, + #[cfg(test)] + test_roots: Option>, +} + +impl UpstreamClients { + pub fn new(http: reqwest::Client) -> std::io::Result { + let tls = rustls::ClientConfig::builder() + .with_native_roots()? + .with_no_client_auth(); + Ok(Self::from_config(http, tls)) + } + + fn from_config(http: reqwest::Client, upgrade_tls: rustls::ClientConfig) -> Self { + Self { + http, + upgrade: upgrade_client(&upgrade_tls, None), + pinned: None, + upgrade_tls, + #[cfg(test)] + test_roots: None, + } + } + + /// A separate pool per CONNECT prevents one IP (or hostname) from reusing + /// another tunnel's connection. The URL remains the TLS identity, so both + /// clients send its SNI and verify its certificate using their usual roots. + pub fn for_destination( + &self, + identity: &Authority, + destination: SocketAddr, + ) -> Result { + // The URI carries the original port. A zero resolver port lets the + // HTTP client use that URI port, including explicit default ports. + let builder = + http_client_builder().resolve(identity.host(), SocketAddr::new(destination.ip(), 0)); + #[cfg(test)] + let builder = match &self.test_roots { + Some(roots) => builder.tls_certs_only(roots.clone()), + None => builder, + }; + Ok(Self { + pinned: Some(( + builder.build()?, + upgrade_client(&self.upgrade_tls, Some((identity.clone(), destination))), + )), + ..self.clone() + }) + } + + pub fn proxy_clients(&self) -> (reqwest::Client, UpgradeClient) { + self.pinned + .clone() + .unwrap_or_else(|| (self.http.clone(), self.upgrade.clone())) + } + + #[cfg(test)] + pub(super) fn with_test_roots(roots: &[rustls::pki_types::CertificateDer<'_>]) -> Self { + let mut store = rustls::RootCertStore::empty(); + for root in roots { + store.add(root.clone().into_owned()).unwrap(); + } + let tls = rustls::ClientConfig::builder() + .with_root_certificates(store) + .with_no_client_auth(); + let roots: Vec<_> = roots + .iter() + .map(|root| reqwest::Certificate::from_der(root).unwrap()) + .collect(); + let http = http_client_builder() + .tls_certs_only(roots.clone()) + .build() + .unwrap(); + let mut clients = Self::from_config(http, tls); + clients.test_roots = Some(roots); + clients + } +} + +fn upgrade_client( + tls: &rustls::ClientConfig, + destination: Option<(Authority, SocketAddr)>, +) -> UpgradeClient { + let mut http = HttpConnector::new(); + http.enforce_http(false); + http.set_nodelay(true); + http.set_connect_timeout(Some(Duration::from_secs(10))); + http.set_keepalive(Some(Duration::from_secs(60))); + let https = hyper_rustls::HttpsConnectorBuilder::new() + .with_tls_config(tls.clone()) + .https_or_http() + .enable_http1() + .wrap_connector(DestinationConnector { http, destination }); + // Upgraded connections are consumed by their tunnels; do not pool them. + Client::builder(TokioExecutor::new()) + .pool_max_idle_per_host(0) + .build(https) +} + +/// hyper-rustls chooses SNI/verification from the original URI before calling +/// this connector. Only TCP dialing sees the CONNECT IP. Other destinations +/// (e.g. a configured DoH redirect) keep their normal routing and verification. +#[derive(Clone)] +pub(crate) struct DestinationConnector { + http: HttpConnector, + destination: Option<(Authority, SocketAddr)>, +} + +impl Service for DestinationConnector { + type Response = >::Response; + type Error = >::Error; + type Future = >::Future; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.http.poll_ready(cx) + } + + fn call(&mut self, mut uri: Uri) -> Self::Future { + if let Some((identity, destination)) = &self.destination { + if uri.authority() == Some(identity) { + let mut parts = uri.into_parts(); + parts.authority = Some(destination.to_string().parse().expect("socket authority")); + uri = Uri::from_parts(parts).expect("valid destination URI"); + } + } + self.http.call(uri) + } +} diff --git a/privaxy/src/server/statistics.rs b/privaxy/src/server/statistics.rs index 133984b0..7309568f 100644 --- a/privaxy/src/server/statistics.rs +++ b/privaxy/src/server/statistics.rs @@ -1,13 +1,16 @@ +use crate::configuration::{StatisticsConfig, StatisticsPrivacy}; use portable_atomic::{AtomicU64, Ordering}; use serde::Serialize; use std::{ - collections::HashMap, + collections::{BTreeMap, HashMap}, net::IpAddr, - sync::{Arc, Mutex}, + sync::{atomic::AtomicBool, Arc, Mutex}, }; use uluru::LRUCache; -const ENTRIES_PER_STATISTICS_TABLE: u8 = 50; +pub mod history; + +const ENTRIES_PER_STATISTICS_TABLE: usize = 50; #[derive(Debug, Serialize)] pub struct SerializableStatistics { @@ -18,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)] @@ -27,6 +31,9 @@ pub struct Statistics { pub modified_responses: Arc, pub top_blocked_paths: Arc>>, pub top_clients: Arc>>, + pub history: Option, + collect: bool, + response_modified: Option>, } impl Default for Statistics { @@ -43,6 +50,96 @@ impl Statistics { modified_responses: Arc::new(AtomicU64::new(0)), top_blocked_paths: Arc::new(Mutex::new(LRUCache::default())), top_clients: Arc::new(Mutex::new(HashMap::new())), + history: None, + collect: true, + response_modified: None, + } + } + + pub async fn with_history(config: StatisticsConfig) -> Self { + let path = crate::configuration::get_config_file().with_file_name("statistics.redb"); + Self { + history: Some(history::HistoryStore::start(path, config).await), + ..Self::new() + } + } + + pub async fn configure_history(&self, config: StatisticsConfig) -> Result<(), String> { + let Some(history) = &self.history else { + return Ok(()); + }; + 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. + if reset_identifiers { + self.top_blocked_paths.lock().unwrap().clear(); + self.top_clients.lock().unwrap().clear(); + } + result + } + + /// Carry a request's collection decision through response rewriting too. + /// Statistics exclusions never change interception or blocking decisions. + 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, client)), + ..self.clone() + } + } + + pub fn should_collect(&self) -> bool { + self.collect + } + + pub(crate) fn for_response(&self) -> Self { + Self { + response_modified: Some(Arc::new(AtomicBool::new(false))), + ..self.clone() + } + } + + 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 + .history + .as_ref() + .map(|h| h.session_privacy()) + .unwrap_or(StatisticsPrivacy::Full); + if privacy < StatisticsPrivacy::HideDomainsAndClients { + self.increment_top_clients(client); + } + if blocked { + self.increment_blocked_requests(); + if privacy == StatisticsPrivacy::Full { + if let Some(domain) = domain { + self.increment_top_blocked_paths(domain.to_ascii_lowercase()); + } + } + } else { + self.increment_proxied_requests(); + } + if let Some(history) = &self.history { + history.request(uri, client, blocked); } } @@ -60,7 +157,10 @@ impl Statistics { } pub fn increment_top_clients(&self, client: IpAddr) { - *self.top_clients.lock().unwrap().entry(client).or_insert(0) += 1; + let mut clients = self.top_clients.lock().unwrap(); + if clients.len() < 1000 || clients.contains_key(&client) { + *clients.entry(client).or_insert(0) += 1; + } } pub fn increment_proxied_requests(&self) -> u64 { @@ -72,46 +172,62 @@ impl Statistics { } pub fn increment_modified_responses(&self) -> u64 { + if !self.collect + || self + .response_modified + .as_ref() + .is_some_and(|modified| modified.swap(true, Ordering::Relaxed)) + { + return self.modified_responses.load(Ordering::Relaxed); + } + if let Some(history) = &self.history { + history.modified(); + } self.modified_responses.fetch_add(1, Ordering::Relaxed) + 1 } pub fn get_serialized(&self) -> SerializableStatistics { - 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), top_blocked_paths: { let top_blocked_paths = self.top_blocked_paths.lock().unwrap(); - let mut top_blocked_paths_iterator = top_blocked_paths.iter(); - - let mut top_blocked_paths = (0..=ENTRIES_PER_STATISTICS_TABLE) - .filter_map(|_| { - let (path, count) = top_blocked_paths_iterator.next()?; - - Some((path.clone(), *count)) - }) - .collect::>(); - - top_blocked_paths.sort_by_key(|entry| std::cmp::Reverse(entry.1)); + let mut top_blocked_paths = top_blocked_paths.iter().cloned().collect::>(); + top_blocked_paths + .sort_unstable_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + top_blocked_paths.truncate(ENTRIES_PER_STATISTICS_TABLE); top_blocked_paths }, top_clients: { let top_clients = self.top_clients.lock().unwrap(); - let mut top_clients_iter = top_clients.iter(); - - let mut top_clients = (0..=ENTRIES_PER_STATISTICS_TABLE) - .filter_map(|_| { - let (ipv4, count) = top_clients_iter.next()?; - - Some((ipv4.to_string(), *count)) - }) + let mut top_clients = top_clients + .iter() + .map(|(ip, count)| (ip.to_string(), *count)) .collect::>(); - - top_clients.sort_by_key(|entry| std::cmp::Reverse(entry.1)); + top_clients.sort_unstable_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + top_clients.truncate(ENTRIES_PER_STATISTICS_TABLE); top_clients }, + }; + if let Some(history) = &self.history { + let privacy = history.session_privacy(); + if privacy >= StatisticsPrivacy::HideDomains { + serialized.top_blocked_paths.clear(); + } + 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 new file mode 100644 index 00000000..b674d523 --- /dev/null +++ b/privaxy/src/server/statistics/history.rs @@ -0,0 +1,807 @@ +//! Opt-in, bounded request history. Only the worker thread touches redb. +//! Request tasks redact identifiers before trying a non-blocking channel send. +use crate::configuration::{StatisticsConfig, StatisticsPrivacy}; +use portable_atomic::{AtomicU64, Ordering}; +use redb::{Database, ReadableDatabase, ReadableTable, ReadableTableMetadata, TableDefinition}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + fs::OpenOptions, + net::IpAddr, + path::PathBuf, + sync::{mpsc, Arc, Mutex, RwLock}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; +use tokio::sync::{oneshot, watch}; + +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; +const HOUR_MS: u64 = 3_600_000; +const DAY_MS: u64 = 24 * HOUR_MS; +type StoreResult = Result>; + +#[derive(Debug, Clone, Default, Serialize)] +pub struct HistoryStatus { + pub active: bool, + pub error: Option, + pub dropped_events: u64, + pub stored_requests: u64, + pub database_bytes: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +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; + } + } +} + +#[derive(Debug, Clone, Copy, Default, Serialize)] +pub struct Counts { + pub proxied_requests: u64, + pub blocked_requests: u64, + pub modified_responses: u64, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct HistoryQuery { + pub hours: Option, + pub before: Option, + pub blocked: Option, + pub limit: Option, +} + +impl HistoryQuery { + pub fn validate(&self) -> Result<(), String> { + if self.hours.is_some_and(|v| v == 0 || v > 365 * 24) { + return Err("History window must be between 1 and 8760 hours".into()); + } + if self.limit.is_some_and(|v| v == 0 || v > 200) { + return Err("History page size must be between 1 and 200".into()); + } + Ok(()) + } +} + +#[derive(Debug, Default, Serialize)] +pub struct HistorySnapshot { + pub status: HistoryStatus, + pub totals: Counts, + pub hourly: Vec<(u64, Counts)>, + 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, +} + +#[derive(Debug, Default)] +struct Policy { + config: StatisticsConfig, + generation: u64, + stopped: bool, + exclusions: Vec, + excluded_clients: HashSet, +} + +enum Event { + Request(RequestRecord), + Modified(u64), +} + +enum Command { + Event(u64, Event), + Configure(StatisticsConfig, u64, oneshot::Sender>), + Query { + query: HistoryQuery, + flush: bool, + reply: oneshot::Sender>, + }, + Clear(u64, oneshot::Sender>), + Shutdown(oneshot::Sender>), +} + +#[derive(Debug, Clone)] +pub struct HistoryStore { + sender: mpsc::SyncSender, + policy: Arc>, + status: Arc>, + dropped: Arc, + operations: Arc>, + updates: watch::Receiver<()>, +} + +impl HistoryStore { + pub async fn start(path: PathBuf, config: StatisticsConfig) -> Self { + let (sender, receiver) = mpsc::sync_channel(QUEUE_CAPACITY); + let (updates, subscriber) = watch::channel(()); + let status = Arc::new(Mutex::new(HistoryStatus::default())); + let dropped = Arc::new(AtomicU64::new(0)); + let store = Self { + sender, + policy: Arc::new(RwLock::new(Policy::default())), + status: status.clone(), + dropped: dropped.clone(), + operations: Arc::new(tokio::sync::Mutex::new(())), + updates: subscriber, + }; + let worker = Worker { + path, + database: None, + config: StatisticsConfig::default(), + generation: 0, + pending: Vec::new(), + status, + dropped, + last_id: 0, + last_flush: Instant::now(), + last_compaction: Instant::now(), + last_prune: Instant::now() - Duration::from_secs(60), + updates, + }; + match std::thread::Builder::new() + .name("statistics".into()) + .spawn(move || worker.run(receiver)) + { + Ok(_) => { + if let Err(error) = store.configure(config).await { + log::error!("Persistent statistics unavailable: {error}"); + store.status.lock().unwrap().error = Some(error); + } + } + Err(error) => store.status.lock().unwrap().error = Some(error.to_string()), + } + store + } + + pub fn config(&self) -> StatisticsConfig { + self.policy.read().unwrap().config.clone() + } + + pub fn session_privacy(&self) -> StatisticsPrivacy { + let policy = self.policy.read().unwrap(); + if policy.config.enabled { + policy.config.privacy + } else { + StatisticsPrivacy::Full + } + } + + pub fn status(&self) -> HistoryStatus { + let mut status = self.status.lock().unwrap().clone(); + status.dropped_events = self.dropped.load(Ordering::Relaxed); + status + } + + pub fn is_excluded(&self, domain: Option<&str>, client: IpAddr) -> bool { + let policy = self.policy.read().unwrap(); + policy.excluded_clients.contains(&client) + || domain.is_some_and(|domain| matches_exclusion(&policy, domain)) + } + + 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; + } + // 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(), + domain: if policy.config.privacy == StatisticsPrivacy::Full { + domain + .filter(|v| v.len() <= 253) + .map(|v| v.trim_end_matches('.').to_ascii_lowercase()) + } else { + None + }, + endpoint: (policy.config.privacy == StatisticsPrivacy::Full) + .then(|| request_endpoint(uri)) + .flatten(), + client: (policy.config.privacy < StatisticsPrivacy::HideDomainsAndClients) + .then(|| client.to_string()), + blocked, + }; + self.enqueue(&policy, Event::Request(record)); + } + + pub fn modified(&self) { + let policy = self.policy.read().unwrap(); + if !policy.stopped && policy.config.persists() { + self.enqueue(&policy, Event::Modified(now_ms())); + } + } + + fn enqueue(&self, policy: &Policy, event: Event) { + if self + .sender + .try_send(Command::Event(policy.generation, event)) + .is_err() + { + self.dropped.fetch_add(1, Ordering::Relaxed); + } + } + + async fn send( + &self, + command: Command, + reply: oneshot::Receiver>, + ) -> Result { + let sender = self.sender.clone(); + tokio::task::spawn_blocking(move || sender.send(command)) + .await + .map_err(|e| e.to_string())? + .map_err(|_| "Statistics worker has stopped".to_string())?; + reply + .await + .map_err(|_| "Statistics worker has stopped".to_string())? + } + + pub async fn configure(&self, config: StatisticsConfig) -> Result<(), String> { + config.validate()?; + let _operation = self.operations.lock().await; + // Pause collection across the command boundary. An old queued event + // cannot reintroduce identifiers after a privacy change or clear. + let generation = { + let mut policy = self.policy.write().unwrap(); + policy.stopped = true; + policy.generation += 1; + policy.generation + }; + let (tx, rx) = oneshot::channel(); + let result = self + .send(Command::Configure(config.clone(), generation, tx), rx) + .await; + let mut policy = self.policy.write().unwrap(); + policy.exclusions = config + .excluded_domains + .iter() + .map(|pattern| { + wildmatch::WildMatch::new( + &pattern.trim().trim_end_matches('.').to_ascii_lowercase(), + ) + }) + .collect(); + policy.excluded_clients = config.excluded_clients.iter().copied().collect(); + policy.config = config; + policy.stopped = result.is_err(); + result + } + + pub async fn query(&self, query: HistoryQuery) -> Result { + self.read(query, true).await + } + + /// Live subscribers read committed batches without forcing extra disk writes. + pub(crate) async fn query_committed( + &self, + query: HistoryQuery, + ) -> Result { + self.read(query, false).await + } + + pub(crate) fn subscribe(&self) -> watch::Receiver<()> { + self.updates.clone() + } + + async fn read(&self, query: HistoryQuery, flush: bool) -> Result { + query.validate()?; + let (tx, rx) = oneshot::channel(); + self.send( + Command::Query { + query, + flush, + reply: tx, + }, + rx, + ) + .await + } + + pub async fn clear(&self) -> Result<(), String> { + let _operation = self.operations.lock().await; + let generation = { + let mut policy = self.policy.write().unwrap(); + policy.stopped = true; + policy.generation += 1; + policy.generation + }; + let (tx, rx) = oneshot::channel(); + let result = self.send(Command::Clear(generation, tx), rx).await; + self.policy.write().unwrap().stopped = result.is_err(); + result + } + + pub async fn shutdown(&self) -> Result<(), String> { + let _operation = self.operations.lock().await; + self.policy.write().unwrap().stopped = true; + let (tx, rx) = oneshot::channel(); + self.send(Command::Shutdown(tx), rx).await + } +} + +struct Worker { + path: PathBuf, + database: Option, + config: StatisticsConfig, + generation: u64, + pending: Vec, + status: Arc>, + dropped: Arc, + last_id: u64, + last_flush: Instant, + last_compaction: Instant, + last_prune: Instant, + updates: watch::Sender<()>, +} + +impl Worker { + fn run(mut self, receiver: mpsc::Receiver) { + loop { + match receiver.recv_timeout(Duration::from_secs(1)) { + Ok(Command::Event(generation, event)) => { + if generation == self.generation + && self.database.is_some() + && self.status.lock().unwrap().error.is_none() + { + self.pending.push(event); + } else { + self.dropped.fetch_add(1, Ordering::Relaxed); + } + } + Ok(Command::Configure(config, generation, reply)) => { + let result = self + .configure(config, generation) + .map_err(|e| e.to_string()); + self.report(&result); + self.updates.send_replace(()); + let _ = reply.send(result); + } + Ok(Command::Query { + query, + flush, + reply, + }) => { + let result = (if flush { self.flush() } else { Ok(()) }) + .and_then(|()| self.query(query)) + .map_err(|e| e.to_string()); + if let Err(ref error) = result { + self.fail(error.clone()); + } + let _ = reply.send(result); + } + Ok(Command::Clear(generation, reply)) => { + self.generation = generation; + self.pending.clear(); + self.database = None; + let result = self.clear().map_err(|e| e.to_string()); + self.report(&result); + self.updates.send_replace(()); + let _ = reply.send(result); + } + Ok(Command::Shutdown(reply)) => { + let result = self.flush().map_err(|e| e.to_string()); + self.database = None; + let _ = reply.send(result); + break; + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + if let Err(error) = self.flush() { + self.fail(error.to_string()); + } + break; + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + if self.pending.len() >= BATCH_SIZE || self.last_flush.elapsed() >= FLUSH_INTERVAL { + if let Err(error) = self.flush() { + self.fail(error.to_string()); + } + self.last_flush = Instant::now(); + } + } + } + + fn fail(&mut self, error: String) { + let mut status = self.status.lock().unwrap(); + let changed = status.active || status.error.as_ref() != Some(&error); + if status.error.as_ref() != Some(&error) { + log::error!("Persistent statistics paused: {error}"); + } + status.active = false; + status.error = Some(error); + self.dropped + .fetch_add(self.pending.len() as u64, Ordering::Relaxed); + self.pending.clear(); + if changed { + self.updates.send_replace(()); + } + } + + fn report(&mut self, result: &Result<(), String>) { + if let Err(error) = result { + self.fail(error.clone()); + } + } + + fn configure(&mut self, config: StatisticsConfig, generation: u64) -> StoreResult<()> { + // Previously accepted events still have the previous privacy policy. + // Flush them before redacting the database with the new policy. + let flush_result = self.flush(); + if let Err(ref error) = flush_result { + self.fail(error.to_string()); + } + self.pending.clear(); + self.database = None; + self.config = config; + self.generation = generation; + *self.status.lock().unwrap() = HistoryStatus::default(); + if !self.config.persists() { + // Disabled stores are never opened. Anonymous mode may redact a + // database from an earlier opt-in, but never creates one or records + // new events. Switching back cannot recover old identifiers. + if self.config.enabled && self.path.exists() { + self.open()?; + self.database = None; + self.status.lock().unwrap().active = false; + } + return flush_result; + } + self.open()?; + self.last_prune = Instant::now() - Duration::from_secs(60); + self.flush()?; + Ok(()) + } + + fn open(&mut self) -> StoreResult<()> { + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(&self.path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + let mut database = Database::builder() + .set_cache_size(8 * 1024 * 1024) + .create_file(file)?; + let tx = database.begin_write()?; + let (version, redact) = { + let mut meta = tx.open_table(META)?; + 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()); + } + let previous = meta.get("privacy")?.map(|v| v.value()).unwrap_or(0); + meta.insert("version", DATABASE_VERSION)?; + meta.insert("privacy", self.config.privacy as u64)?; + (version, (self.config.privacy as u64) > previous) + }; + { + 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(|(id, _)| id.value())) + .collect::, _>>()?; + for id in ids { + 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 || version < DATABASE_VERSION { + database.compact()?; + } + self.database = Some(database); + self.status.lock().unwrap().active = true; + Ok(()) + } + + fn clear(&mut self) -> StoreResult<()> { + match std::fs::remove_file(&self.path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + self.last_id = 0; + self.dropped.store(0, Ordering::Relaxed); + *self.status.lock().unwrap() = HistoryStatus::default(); + if self.config.persists() { + self.open()?; + } + Ok(()) + } + + fn flush(&mut self) -> StoreResult<()> { + if self.status.lock().unwrap().error.is_some() { + return Ok(()); + } + let Some(database) = self.database.as_ref() else { + return Ok(()); + }; + let prune = self.last_prune.elapsed() >= Duration::from_secs(60); + if self.pending.is_empty() && !prune { + return Ok(()); + } + let now = now_ms(); + let cutoff = now.saturating_sub(u64::from(self.config.retention_days) * DAY_MS); + let tx = database.begin_write()?; + let stored; + { + 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) => { + // 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); + requests.insert(self.last_id, dictionaries.intern(record)?)?; + } + Event::Modified(timestamp) => { + let hour = timestamp / HOUR_MS * HOUR_MS; + let count = modified.get(hour)?.map(|v| v.value()).unwrap_or(0) + 1; + modified.insert(hour, count)?; + } + } + } + // Also inspect stored timestamps: an NTP clock adjustment must not + // make old rows immortal merely because their IDs are monotonic. + if prune { + 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(); + let record = requests.remove(first)?.unwrap().value(); + dictionaries.release(record)?; + } + modified.retain(|hour, _| hour + HOUR_MS > cutoff)?; + stored = requests.len()?; + } + tx.commit()?; + self.pending.clear(); + if prune { + self.last_prune = Instant::now(); + } + if self.last_compaction.elapsed() >= Duration::from_secs(3600) { + self.database.as_mut().unwrap().compact()?; + self.last_compaction = Instant::now(); + } + let mut status = self.status.lock().unwrap(); + status.stored_requests = stored; + status.database_bytes = std::fs::metadata(&self.path)?.len(); + self.updates.send_replace(()); + Ok(()) + } + + fn query(&self, query: HistoryQuery) -> StoreResult { + let mut snapshot = HistorySnapshot { + status: self.status.lock().unwrap().clone(), + ..Default::default() + }; + snapshot.status.dropped_events = self.dropped.load(Ordering::Relaxed); + let Some(database) = &self.database else { + return Ok(snapshot); + }; + // A failed privacy transition must never reveal previously collected data. + if snapshot.status.error.is_some() { + return Ok(snapshot); + } + let hours = query + .hours + .unwrap_or(24) + .min(self.config.retention_days * 24); + let cutoff = (now_ms() / HOUR_MS * HOUR_MS).saturating_sub(u64::from(hours - 1) * HOUR_MS); + 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(); + let mut clients = HashMap::new(); + // 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 (id, record) = entry?; + let id = id.value(); + let record = record.value(); + if record.0 < cutoff { + continue; + } + 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 record.1 != 0 { + *domains.entry(record.1).or_insert(0) += 1; + if record.4 { + *blocked_domains.entry(record.1).or_insert(0) += 1; + } + } + if record.3 != 0 { + *clients.entry(record.3).or_insert(0) += 1; + } + 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(dictionaries.record(id, record)?); + } else if snapshot.next_before.is_none() { + snapshot.next_before = snapshot.requests.last().map(|r| r.id); + } + } + } + for entry in tx + .open_table(MODIFIED)? + .range(cutoff / HOUR_MS * HOUR_MS..)? + { + let (hour, value) = entry?; + let count = value.value(); + snapshot.totals.modified_responses += count; + hourly.entry(hour.value()).or_default().modified_responses = count; + } + snapshot.hourly = hourly.into_iter().collect(); + 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 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 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn matches_exclusion(policy: &Policy, domain: &str) -> bool { + if policy.exclusions.is_empty() { + return false; + } + let domain = domain.trim_end_matches('.').to_ascii_lowercase(); + policy + .exclusions + .iter() + .any(|pattern| pattern.matches(&domain)) +} + +#[cfg(test)] +mod tests; 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 new file mode 100644 index 00000000..388a9d57 --- /dev/null +++ b/privaxy/src/server/statistics/history/tests.rs @@ -0,0 +1,1088 @@ +use super::*; +use crate::statistics::Statistics; + +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "privaxy-stats-test-{}", + crate::configuration::generate_random_hex(12) + )); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + fn database(&self) -> PathBuf { + self.0.join("statistics.redb") + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn enabled(privacy: StatisticsPrivacy) -> StatisticsConfig { + StatisticsConfig { + enabled: true, + privacy, + ..Default::default() + } +} +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(&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. + tokio::time::timeout(Duration::from_secs(10), updates.changed()) + .await + .unwrap() + .unwrap(); + let data = store + .query_committed(HistoryQuery::default()) + .await + .unwrap(); + assert_eq!(data.totals.proxied_requests, 1); + assert_eq!(data.totals.blocked_requests, 1); + assert_eq!(data.totals.modified_responses, 1); + assert_eq!(data.requests.len(), 2); + // Reading a snapshot must not trigger another update loop. + assert!(!updates.has_changed().unwrap()); + store.shutdown().await.unwrap(); + assert!(updates.changed().await.is_err()); +} + +#[tokio::test] +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(&request_uri("private.example.test"), client(), true); + snapshot(&store).await; + let mut updates = store.subscribe(); + updates.borrow_and_update(); + + store + .configure(enabled(StatisticsPrivacy::HideDomainsAndClients)) + .await + .unwrap(); + assert!(updates.has_changed().unwrap()); + updates.borrow_and_update(); + let data = store + .query_committed(HistoryQuery::default()) + .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()); + + store.clear().await.unwrap(); + assert!(updates.has_changed().unwrap()); + updates.borrow_and_update(); + let data = store + .query_committed(HistoryQuery::default()) + .await + .unwrap(); + assert_eq!(data.totals.blocked_requests, 0); + assert!(data.requests.is_empty()); + + store.configure(StatisticsConfig::default()).await.unwrap(); + assert!(updates.has_changed().unwrap()); + assert!( + !store + .query_committed(HistoryQuery::default()) + .await + .unwrap() + .status + .active + ); + store.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn disabled_and_anonymous_do_not_create_databases_or_retroactively_collect() { + for config in [ + StatisticsConfig::default(), + enabled(StatisticsPrivacy::Anonymous), + ] { + let fixture = Fixture::new(); + let store = HistoryStore::start(fixture.database(), config).await; + store.request(&request_uri("private.example.test"), client(), true); + store.modified(); + assert!(snapshot(&store).await.requests.is_empty()); + assert!(!fixture.database().exists()); + store + .configure(enabled(StatisticsPrivacy::Full)) + .await + .unwrap(); + assert!(snapshot(&store).await.requests.is_empty()); + store.shutdown().await.unwrap(); + } +} + +#[tokio::test] +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://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; + let data = snapshot(&store).await; + assert_eq!(data.totals.blocked_requests, 1); + 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("example.test:443/private-account")); + assert!(!text.contains("do-not-store")); + assert!(!text.contains("user:password")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(fixture.database()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } +} + +#[tokio::test] +async fn identifiers_are_redacted_before_queueing_and_cannot_be_recovered() { + for privacy in [ + StatisticsPrivacy::HideDomains, + StatisticsPrivacy::HideDomainsAndClients, + ] { + let fixture = Fixture::new(); + let store = HistoryStore::start(fixture.database(), enabled(privacy)).await; + store.request(&request_uri("private.example.test"), client(), true); + store + .configure(enabled(StatisticsPrivacy::Full)) + .await + .unwrap(); + 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 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")); + } + } +} + +#[tokio::test] +async fn increasing_privacy_redacts_old_rows_including_before_anonymous_mode() { + for privacy in [ + StatisticsPrivacy::HideDomains, + StatisticsPrivacy::HideDomainsAndClients, + StatisticsPrivacy::Anonymous, + ] { + let fixture = Fixture::new(); + let store = HistoryStore::start(fixture.database(), enabled(StatisticsPrivacy::Full)).await; + 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 + .configure(enabled(StatisticsPrivacy::Full)) + .await + .unwrap(); + 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)], + ); + } +} + +#[tokio::test] +async fn retention_entry_cap_and_cursor_pagination_are_enforced() { + let fixture = Fixture::new(); + let mut config = enabled(StatisticsPrivacy::Full); + config.max_entries = 100; + let store = HistoryStore::start(fixture.database(), config.clone()).await; + for i in 0..150 { + store.request( + &request_uri(&format!("host-{i}.example.test")), + client(), + i % 2 == 0, + ); + } + let query = || HistoryQuery { + limit: Some(10), + blocked: Some(true), + ..Default::default() + }; + let first = store.query(query()).await.unwrap(); + assert_eq!(first.status.stored_requests, 100); + assert_eq!(first.totals.blocked_requests, 50); + assert_eq!(first.totals.proxied_requests, 50); + assert!(first.requests.iter().all(|r| r.blocked)); + assert_eq!(first.requests.len(), 10); + let second = store + .query(HistoryQuery { + before: first.next_before, + ..query() + }) + .await + .unwrap(); + assert_eq!(second.requests.len(), 10); + assert!(second + .requests + .iter() + .all(|r| r.id < first.requests.last().unwrap().id)); + assert_eq!(second.totals.blocked_requests, 50); + let generation = store.policy.read().unwrap().generation; + store + .sender + .send(Command::Event( + generation, + Event::Request(RequestRecord { + id: 0, + timestamp: now_ms() - 2 * DAY_MS, + domain: Some("old.example.test".into()), + endpoint: None, + client: None, + blocked: true, + }), + )) + .unwrap(); + config.retention_days = 1; + store.configure(config).await.unwrap(); + let data = snapshot(&store).await; + // 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] +async fn clear_and_disable_do_not_replay_queued_records() { + let fixture = Fixture::new(); + let config = enabled(StatisticsPrivacy::Full); + let store = HistoryStore::start(fixture.database(), config.clone()).await; + for _ in 0..100 { + 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(&request_uri("disabled.example.test"), client(), false); + store.clear().await.unwrap(); + assert!(!fixture.database().exists()); + store.configure(config).await.unwrap(); + assert!(snapshot(&store).await.requests.is_empty()); + store.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn corrupt_storage_is_reported_without_truncation_or_breaking_session_counts() { + let fixture = Fixture::new(); + std::fs::write(fixture.database(), b"corrupt-fixture-do-not-truncate").unwrap(); + let history = HistoryStore::start(fixture.database(), enabled(StatisticsPrivacy::Full)).await; + assert!(history.status().error.is_some()); + assert_eq!( + std::fs::read(fixture.database()).unwrap(), + b"corrupt-fixture-do-not-truncate" + ); + let stats = Statistics { + history: Some(history.clone()), + ..Statistics::new() + }; + 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(&request_uri("example.test"), client(), true); + assert_eq!(snapshot(&history).await.totals.blocked_requests, 1); + history.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn excluded_domains_skip_counters_history_and_the_live_log_even_when_disabled() { + for persistent in [false, true] { + let fixture = Fixture::new(); + let config = StatisticsConfig { + enabled: persistent, + privacy: StatisticsPrivacy::Full, + excluded_domains: vec!["EXAMPLE.test".into(), "*.private.test".into()], + ..Default::default() + }; + let history = HistoryStore::start(fixture.database(), config).await; + let stats = Statistics { + history: Some(history.clone()), + ..Statistics::new() + }; + 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_request(uri.host(), client()); + assert!(!request_stats.should_collect()); + request_stats.record_request(&uri, client(), true); + request_stats.increment_modified_responses(); + // The history collector independently enforces the same policy. + history.request(&uri, client(), true); + } + let data = stats.get_serialized(); + assert_eq!(data.blocked_requests, 0); + assert_eq!(data.modified_responses, 0); + 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_request(Some(domain), client()).should_collect()); + } + history.shutdown().await.unwrap(); + } +} + +#[test] +fn rejects_unbounded_settings_and_urls_in_domain_exclusions() { + for pattern in [ + "https://example.test", + "example.test:443", + "example.test/path", + " ", + ] { + let config = StatisticsConfig { + excluded_domains: vec![pattern.into()], + ..Default::default() + }; + assert!(config.validate().is_err()); + } + for days in [0, 366] { + assert!(StatisticsConfig { + retention_days: days, + ..Default::default() + } + .validate() + .is_err()); + } + for limit in [0, 99, 1_000_001] { + assert!(StatisticsConfig { + max_entries: limit, + ..Default::default() + } + .validate() + .is_err()); + } + assert!(HistoryQuery { + limit: Some(201), + ..Default::default() + } + .validate() + .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); + let store = HistoryStore { + sender, + policy: Arc::new(RwLock::new(Policy { + config: enabled(StatisticsPrivacy::HideDomainsAndClients), + ..Default::default() + })), + status: Arc::new(Mutex::new(HistoryStatus::default())), + dropped: Arc::new(AtomicU64::new(0)), + operations: Arc::new(tokio::sync::Mutex::new(())), + updates: watch::channel(()).1, + }; + 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()); +} + +#[derive(Debug)] +struct FailingDisk { + inner: redb::backends::FileBackend, + full: Arc, +} +impl redb::StorageBackend for FailingDisk { + fn len(&self) -> std::io::Result { + self.inner.len() + } + fn read(&self, offset: u64, out: &mut [u8]) -> std::io::Result<()> { + self.inner.read(offset, out) + } + fn set_len(&self, len: u64) -> std::io::Result<()> { + if self.full.load(Ordering::Relaxed) { + return Err(std::io::ErrorKind::StorageFull.into()); + } + self.inner.set_len(len) + } + fn sync_data(&self) -> std::io::Result<()> { + if self.full.load(Ordering::Relaxed) { + return Err(std::io::ErrorKind::StorageFull.into()); + } + self.inner.sync_data() + } + fn write(&self, offset: u64, bytes: &[u8]) -> std::io::Result<()> { + if self.full.load(Ordering::Relaxed) { + return Err(std::io::ErrorKind::StorageFull.into()); + } + self.inner.write(offset, bytes) + } + fn close(&self) -> std::io::Result<()> { + self.inner.close() + } +} + +#[test] +fn disk_full_pauses_recording_and_preserves_committed_history() { + let fixture = Fixture::new(); + let mut worker = Worker { + path: fixture.database(), + database: None, + config: enabled(StatisticsPrivacy::Full), + generation: 0, + pending: Vec::new(), + status: Arc::new(Mutex::new(HistoryStatus::default())), + dropped: Arc::new(AtomicU64::new(0)), + last_id: 0, + last_flush: Instant::now(), + last_compaction: Instant::now(), + last_prune: Instant::now(), + updates: watch::channel(()).0, + }; + worker.open().unwrap(); + let event = || { + Event::Request(RequestRecord { + id: 0, + timestamp: now_ms(), + domain: Some("example.test".into()), + endpoint: Some("".into()), + client: None, + blocked: true, + }) + }; + worker.pending.push(event()); + worker.flush().unwrap(); + worker.database = None; + let full = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let file = OpenOptions::new() + .read(true) + .write(true) + .open(fixture.database()) + .unwrap(); + let backend = FailingDisk { + inner: redb::backends::FileBackend::new(file).unwrap(), + full: full.clone(), + }; + worker.database = Some(Database::builder().create_with_backend(backend).unwrap()); + full.store(true, Ordering::Relaxed); + worker.pending.push(event()); + let error = worker.flush().unwrap_err(); + worker.fail(error.to_string()); + assert!(!worker.status.lock().unwrap().active); + assert!(worker.status.lock().unwrap().error.is_some()); + assert!(worker.pending.is_empty()); + assert_eq!(worker.dropped.load(Ordering::Relaxed), 1); + worker.database = None; + full.store(false, Ordering::Relaxed); + worker + .configure(enabled(StatisticsPrivacy::Full), 1) + .unwrap(); + assert_eq!( + worker + .query(HistoryQuery::default()) + .unwrap() + .totals + .blocked_requests, + 1 + ); +} diff --git a/privaxy/src/server/web_gui/history.rs b/privaxy/src/server/web_gui/history.rs new file mode 100644 index 00000000..1ee8fc56 --- /dev/null +++ b/privaxy/src/server/web_gui/history.rs @@ -0,0 +1,217 @@ +use super::{json_status, with_arc, with_configuration_save_lock}; +use crate::{ + configuration::{Configuration, StatisticsConfig}, + statistics::{ + history::{HistoryQuery, HistorySnapshot, HistoryStore}, + Statistics, + }, +}; +use futures::{SinkExt, StreamExt}; +use http::StatusCode; +use serde::Serialize; +use std::{convert::Infallible, sync::Arc, time::Duration}; +use warp::{ + filters::BoxedFilter, + ws::{Message, WebSocket, Ws}, + Filter, Reply, +}; + +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum HistoryUpdate { + Snapshot { snapshot: Box }, + Error { error: String }, +} + +#[derive(Serialize)] +struct SettingsResponse { + config: StatisticsConfig, + status: crate::statistics::history::HistoryStatus, +} + +fn error(message: &str) -> Box { + json_status(StatusCode::INTERNAL_SERVER_ERROR, message) +} + +async fn get_settings(statistics: Statistics) -> Result, Infallible> { + let Some(history) = statistics.history else { + return Ok(error("Statistics worker unavailable")); + }; + Ok(match Configuration::read_from_home().await { + Ok(configuration) => Box::new(warp::reply::json(&SettingsResponse { + config: configuration.statistics, + status: history.status(), + })), + Err(err) => error(&err.to_string()), + }) +} + +async fn put_settings( + config: StatisticsConfig, + statistics: Statistics, + lock: Arc>, +) -> Result, Infallible> { + if let Err(message) = config.validate() { + return Ok(json_status(StatusCode::UNPROCESSABLE_ENTITY, &message)); + } + let _guard = lock.lock().await; + let mut configuration = match Configuration::read_from_home().await { + Ok(configuration) => configuration, + Err(err) => return Ok(error(&err.to_string())), + }; + configuration.statistics = config; + if let Err(err) = configuration.save().await { + return Ok(error(&err.to_string())); + } + // A saved opt-in and a working database are distinct; report disk errors + // alongside the saved config. Saving again retries without proxy reloads. + if let Err(err) = statistics.configure_history(configuration.statistics).await { + log::error!("Could not apply statistics settings: {err}"); + } + get_settings(statistics).await +} + +async fn get_history( + query: HistoryQuery, + statistics: Statistics, +) -> Result, Infallible> { + if let Err(message) = query.validate() { + return Ok(json_status(StatusCode::UNPROCESSABLE_ENTITY, &message)); + } + let Some(history) = statistics.history else { + return Ok(error("Statistics worker unavailable")); + }; + Ok(match history.query(query).await { + Ok(snapshot) => Box::new(warp::reply::json(&snapshot)), + Err(err) => error(&err), + }) +} + +async fn clear_history( + statistics: Statistics, + lock: Arc>, +) -> Result, Infallible> { + let _guard = lock.lock().await; + let Some(history) = statistics.history else { + return Ok(error("Statistics worker unavailable")); + }; + Ok(match history.clear().await { + Ok(()) => Box::new(warp::reply::with_status("", StatusCode::NO_CONTENT)), + Err(err) => error(&err), + }) +} + +async fn live_history( + ws: Ws, + query: HistoryQuery, + statistics: Statistics, +) -> Result, Infallible> { + if let Err(message) = query.validate() { + return Ok(json_status(StatusCode::UNPROCESSABLE_ENTITY, &message)); + } + let Some(history) = statistics.history else { + return Ok(error("Statistics worker unavailable")); + }; + Ok(Box::new(ws.on_upgrade(move |socket| { + stream_history(socket, history, query) + }))) +} + +async fn stream_history(socket: WebSocket, history: HistoryStore, query: HistoryQuery) { + let (mut tx, mut rx) = socket.split(); + let mut updates = history.subscribe(); + let send = async move { + let mut keepalive = tokio::time::interval(Duration::from_secs(30)); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + keepalive.tick().await; + let mut hour = chrono::Utc::now().timestamp() / 3600; + loop { + updates.borrow_and_update(); + let update = match history.query_committed(query.clone()).await { + Ok(snapshot) => HistoryUpdate::Snapshot { + snapshot: Box::new(snapshot), + }, + Err(error) => HistoryUpdate::Error { error }, + }; + let Ok(json) = serde_json::to_string(&update) else { + break; + }; + if tx.send(Message::text(json)).await.is_err() { + break; + } + // Coalesce bursts of committed batches. A slow client has at most + // one query in flight; network backpressure never blocks the worker. + tokio::time::sleep(Duration::from_secs(1)).await; + loop { + tokio::select! { + changed = updates.changed() => { + if changed.is_err() { return; } + break; + } + _ = keepalive.tick() => { + let current_hour = chrono::Utc::now().timestamp() / 3600; + if current_hour != hour { + hour = current_hour; + break; + } + if tx.send(Message::ping(Vec::new())).await.is_err() { return; } + } + } + } + } + }; + let receive = async move { + // Poll the reader for Ping/Pong and stop even when an idle client leaves. + while let Some(Ok(message)) = rx.next().await { + if message.is_close() { + break; + } + } + }; + tokio::select! { + _ = send => {}, + _ = receive => {}, + } +} + +pub(super) fn settings_routes( + statistics: Statistics, + lock: Arc>, +) -> BoxedFilter<(impl Reply,)> { + let get = warp::get() + .and(warp::path::end()) + .and(with_arc(statistics.clone())) + .and_then(get_settings); + let put = warp::put() + .and(warp::path::end()) + .and(warp::body::content_length_limit(512 * 1024)) + .and(warp::body::json()) + .and(with_arc(statistics)) + .and(with_configuration_save_lock(lock)) + .and_then(put_settings); + get.or(put).boxed() +} + +pub(super) fn routes( + statistics: Statistics, + lock: Arc>, +) -> BoxedFilter<(impl Reply,)> { + // The parent /history route applies require_auth to all of these endpoints. + let live = warp::path("live") + .and(warp::path::end()) + .and(warp::ws()) + .and(warp::query::()) + .and(with_arc(statistics.clone())) + .and_then(live_history); + let get = warp::get() + .and(warp::path::end()) + .and(warp::query::()) + .and(with_arc(statistics.clone())) + .and_then(get_history); + let delete = warp::delete() + .and(warp::path::end()) + .and(with_arc(statistics)) + .and(with_configuration_save_lock(lock)) + .and_then(clear_history); + live.or(get).or(delete).boxed() +} diff --git a/privaxy/src/server/web_gui/mod.rs b/privaxy/src/server/web_gui/mod.rs index 54209ce0..83317746 100644 --- a/privaxy/src/server/web_gui/mod.rs +++ b/privaxy/src/server/web_gui/mod.rs @@ -23,6 +23,7 @@ pub(crate) mod events; pub(crate) mod exclusions; mod filterlists; pub(crate) mod filters; +mod history; mod inclusions; pub(crate) mod logs; mod pac; @@ -30,6 +31,7 @@ pub(crate) mod settings; pub(crate) mod statistics; pub(crate) mod tls_failures; pub(crate) mod userscripts; +mod version; #[derive(Debug, Serialize)] pub(crate) struct ApiError { @@ -147,6 +149,15 @@ fn create_api_routes( let require_auth = auth::require_auth(configuration_save_lock.clone()); + let version_route = warp::path!("version") + .and(warp::get()) + .and(require_auth.clone()) + .map(version::version); + let updates_route = warp::path("updates") + .and(require_auth.clone()) + .and(version::updates(http_client.clone())); + let version_routes = version_route.or(updates_route).boxed(); + let events_route = warp::path("events") .and(require_auth.clone()) .and(warp::ws()) @@ -155,7 +166,21 @@ fn create_api_routes( ws.on_upgrade(move |websocket| events::events(websocket, events_sender)) }); + let history_route = warp::path("history") + .and(require_auth.clone()) + .and(history::routes( + statistics.clone(), + configuration_save_lock.clone(), + )); + let statistics_settings_route = warp::path!("settings" / "statistics" / ..) + .and(require_auth.clone()) + .and(history::settings_routes( + statistics.clone(), + configuration_save_lock.clone(), + )); + let statistics_route = warp::path("statistics") + .and(warp::path::end()) .and(require_auth.clone()) .and(warp::ws()) .map(move |ws: warp::ws::Ws| { @@ -255,8 +280,11 @@ fn create_api_routes( // a request to `/`), turning every non-API request into a JSON 404 // and preventing the static-files branch from serving the SPA. let api_inner = auth_routes + .or(version_routes) .or(events_route) .or(statistics_route) + .or(history_route) + .or(statistics_settings_route) .or(logs_route) .or(filters_route) .or(custom_filters_route) diff --git a/privaxy/src/server/web_gui/version.rs b/privaxy/src/server/web_gui/version.rs new file mode 100644 index 00000000..3362b889 --- /dev/null +++ b/privaxy/src/server/web_gui/version.rs @@ -0,0 +1,230 @@ +use super::{json_status, with_arc}; +use reqwest::Client; +use semver::Version; +use serde::{Deserialize, Serialize}; +use std::{ + convert::Infallible, + sync::Arc, + time::{Duration, Instant}, +}; +use tokio::sync::Mutex; +use warp::{filters::BoxedFilter, http::StatusCode, Filter, Reply}; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); +const PROJECT_URL: &str = env!("CARGO_PKG_REPOSITORY"); +const RELEASE_API: &str = "https://api.github.com/repos/joshrmcdaniel/privaxy/releases/latest"; +const CACHE_DURATION: Duration = Duration::from_secs(3600); +const ERROR_CACHE_DURATION: Duration = Duration::from_secs(60); + +#[derive(Serialize)] +struct VersionInfo { + version: &'static str, + project_url: &'static str, +} + +#[derive(Clone, Serialize)] +struct ReleaseInfo { + version: String, + url: String, +} + +#[derive(Clone, Serialize)] +struct UpdateCheck { + current_version: &'static str, + latest_release: Option, + update_available: bool, + checked_at: i64, +} + +#[derive(Deserialize)] +struct GitHubRelease { + tag_name: String, + draft: bool, + prerelease: bool, +} + +struct CachedCheck { + result: Result, + fetched_at: Instant, +} + +#[derive(Clone)] +struct UpdateChecker { + client: Client, + cache: Arc>>, +} + +impl UpdateChecker { + async fn check(&self) -> Result { + // Share both successful and failed checks across tabs. Holding this + // lock also combines simultaneous requests into one bounded fetch. + let mut cache = self.cache.lock().await; + if let Some(cached) = cache.as_ref() { + let ttl = if cached.result.is_ok() { + CACHE_DURATION + } else { + ERROR_CACHE_DURATION + }; + if cached.fetched_at.elapsed() < ttl { + return cached.result.clone(); + } + } + let result = self.fetch().await; + *cache = Some(CachedCheck { + result: result.clone(), + fetched_at: Instant::now(), + }); + result + } + + async fn fetch(&self) -> Result { + let response = self + .client + .get(RELEASE_API) + .header( + reqwest::header::USER_AGENT, + concat!("Privaxy/", env!("CARGO_PKG_VERSION")), + ) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2026-03-10") + .timeout(Duration::from_secs(10)) + .send() + .await + .map_err(|error| { + if error.is_timeout() { + "Update check timed out. Try again in a minute." + } else { + "Could not reach GitHub. Try again in a minute." + } + .to_string() + })?; + let release = match response.status() { + StatusCode::NOT_FOUND => None, + StatusCode::OK => Some(response.json::().await.map_err(|_| { + "GitHub returned invalid release information. Try again in a minute.".to_string() + })?), + status => return Err(format!("GitHub returned HTTP {status}. Try again later.")), + }; + let (latest_release, update_available) = match release { + Some(release) => { + let (info, newer) = compare_release(VERSION, release)?; + (Some(info), newer) + } + None => (None, false), + }; + Ok(UpdateCheck { + current_version: VERSION, + latest_release, + update_available, + checked_at: chrono::Utc::now().timestamp(), + }) + } +} + +fn compare_release(current: &str, release: GitHubRelease) -> Result<(ReleaseInfo, bool), String> { + let version = Version::parse( + release + .tag_name + .strip_prefix('v') + .unwrap_or(&release.tag_name), + ) + .map_err(|_| "The latest release has an unrecognized version tag.".to_string())?; + if release.draft || release.prerelease || !version.pre.is_empty() { + return Err("GitHub did not return a stable release.".to_string()); + } + let current = Version::parse(current) + .map_err(|_| "The running version could not be compared.".to_string())?; + let newer = version.cmp_precedence(¤t).is_gt(); + // Construct the link from the fixed project URL and validated version tag; + // the remote response cannot supply an arbitrary link to the frontend. + Ok(( + ReleaseInfo { + version: version.to_string(), + url: format!("{PROJECT_URL}/releases/tag/{}", release.tag_name), + }, + newer, + )) +} + +async fn check_update(checker: UpdateChecker) -> Result, Infallible> { + Ok(match checker.check().await { + Ok(result) => Box::new(warp::reply::json(&result)), + Err(error) => json_status(StatusCode::BAD_GATEWAY, &error), + }) +} + +pub(super) fn version() -> impl Reply { + warp::reply::with_header( + warp::reply::json(&VersionInfo { + version: VERSION, + project_url: PROJECT_URL, + }), + "cache-control", + "no-store", + ) +} + +pub(super) fn updates(client: Client) -> BoxedFilter<(impl Reply,)> { + warp::path::end() + .and(warp::get()) + .and(with_arc(UpdateChecker { + client, + cache: Arc::new(Mutex::new(None)), + })) + .and_then(check_update) + .with(warp::reply::with::header("cache-control", "no-store")) + .boxed() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn release(tag: &str) -> GitHubRelease { + GitHubRelease { + tag_name: tag.into(), + draft: false, + prerelease: false, + } + } + + #[test] + fn updates_use_semantic_precedence_and_preserve_release_links() { + for (current, tag, expected) in [ + ("0.8.0", "v0.8.1", true), + ("0.9.0", "v0.10.0", true), + ("0.8.0", "0.8.0", false), + ("0.9.0", "v0.8.0", false), + ("0.8.0-rc.1", "v0.8.0", true), + ("0.8.0+local", "v0.8.0+release", false), + ] { + let (info, newer) = compare_release(current, release(tag)).unwrap(); + assert_eq!(newer, expected, "{current} -> {tag}"); + assert_eq!(info.url, format!("{PROJECT_URL}/releases/tag/{tag}")); + } + } + + #[test] + fn invalid_and_unstable_releases_cannot_be_reported_as_updates() { + for tag in [ + "latest", + "dev", + "v1.0.0-beta.1", + "https://example.test/", + "v1.0.0/extra", + ] { + assert!(compare_release("0.8.0", release(tag)).is_err()); + } + for (draft, prerelease) in [(true, false), (false, true)] { + assert!(compare_release( + "0.8.0", + GitHubRelease { + draft, + prerelease, + ..release("v1.0.0") + } + ) + .is_err()); + } + } +} diff --git a/web_frontend/Cargo.toml b/web_frontend/Cargo.toml index 5b3dca84..2ba4290c 100644 --- a/web_frontend/Cargo.toml +++ b/web_frontend/Cargo.toml @@ -12,13 +12,14 @@ num-format = "0.4.0" serde = { version = "1.0.137", features = ["derive"] } gloo-utils = "0.3" wasm-bindgen = "0.2.125" +js-sys = "0.3" wasm-bindgen-futures = "0.4" futures = "0.3.21" log = "0.4.17" wasm-logger = "0.2.0" gloo-timers = { version = "0.4", features = ["futures"] } serde-tuple-vec-map = "1.0.1" -web-sys = { version = "0.3.77", features = ["InputEvent", "InputEventInit", "HtmlSelectElement", "Window", "Location", "Clipboard", "Navigator", "SubmitEvent", "KeyboardEvent"]} +web-sys = { version = "0.3.77", features = ["InputEvent", "InputEventInit", "HtmlSelectElement", "Window", "Location", "Clipboard", "Navigator", "SubmitEvent", "KeyboardEvent", "DomTokenList"]} gloo-net = "0.6" serde_with = "3.8.1" url = "2.5.0" diff --git a/web_frontend/src/account.rs b/web_frontend/src/account.rs index 280547c6..e0e176a0 100644 --- a/web_frontend/src/account.rs +++ b/web_frontend/src/account.rs @@ -5,7 +5,7 @@ use web_sys::{HtmlInputElement, SubmitEvent}; use yew::prelude::*; use yew::{html, Callback, Component, Context, Html}; -use crate::auth::AuthStatus; +use crate::{api, auth::AuthStatus}; #[derive(Serialize)] struct ChangePasswordPayload { @@ -20,6 +20,7 @@ struct ApiKeyResponse { pub enum Message { StatusLoaded(Option), + LoadApiKey, ApiKeyLoaded(String), UpdateCurrentPassword(String), UpdateNewPassword(String), @@ -62,17 +63,7 @@ impl Component for AccountSettings { } }); - let link = ctx.link().clone(); - spawn_local(async move { - match Request::get("/api/auth/api-key").send().await { - Ok(response) if response.ok() => { - if let Ok(payload) = response.json::().await { - link.send_message(Message::ApiKeyLoaded(payload.api_key)); - } - } - _ => {} - } - }); + ctx.link().send_message(Message::LoadApiKey); Self { username: None, @@ -84,17 +75,34 @@ impl Component for AccountSettings { password_success: false, password_submitting: false, api_key_error: None, - api_key_busy: false, + api_key_busy: true, } } fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { match msg { + Message::LoadApiKey => { + self.api_key_busy = true; + self.api_key_error = None; + let link = ctx.link().clone(); + spawn_local(async move { + link.send_message( + match api::get_json::("/api/auth/api-key").await { + Ok(payload) => Message::ApiKeyLoaded(payload.api_key), + Err(error) => Message::ApiKeyRotateFailed(format!( + "Could not load API key: {error}" + )), + }, + ); + }); + true + } Message::StatusLoaded(username) => { self.username = username; true } Message::ApiKeyLoaded(key) => { + self.api_key_busy = false; self.api_key = Some(key); true } @@ -233,10 +241,14 @@ impl Component for AccountSettings { let on_rotate = link.callback(|_| Message::RotateApiKey); let username_display = self.username.clone().unwrap_or_else(|| "—".to_string()); - let api_key_display = self - .api_key - .clone() - .unwrap_or_else(|| "Loading...".to_string()); + let api_key_display = self.api_key.clone().unwrap_or_else(|| { + if self.api_key_busy { + "Loading..." + } else { + "Unavailable" + } + .to_string() + }); html! { <> @@ -288,14 +300,17 @@ impl Component for AccountSettings { /> { if let Some(msg) = &self.api_key_error { - html! {
{msg.clone()}
} + html! { } } else { html!{} } } + if self.api_key.is_none() && self.api_key_error.is_some() { + + }

{ "Rotating invalidates the previous key immediately." } diff --git a/web_frontend/src/api.rs b/web_frontend/src/api.rs new file mode 100644 index 00000000..76c8c6f0 --- /dev/null +++ b/web_frontend/src/api.rs @@ -0,0 +1,32 @@ +use gloo_net::http::{Request, RequestBuilder, Response}; +use serde::{de::DeserializeOwned, Serialize}; + +pub async fn response_error(response: Response) -> String { + let status = response.status(); + response + .json::() + .await + .map(|error| error.error) + .unwrap_or_else(|_| format!("HTTP {status}")) +} + +pub async fn get_json(url: &str) -> Result { + let response = Request::get(url).send().await.map_err(|e| e.to_string())?; + if !response.ok() { + return Err(response_error(response).await); + } + response.json().await.map_err(|e| e.to_string()) +} + +pub async fn send_json(request: RequestBuilder, body: &T) -> Result<(), String> { + let response = request + .json(body) + .map_err(|e| e.to_string())? + .send() + .await + .map_err(|e| e.to_string())?; + if !response.ok() { + return Err(response_error(response).await); + } + Ok(()) +} diff --git a/web_frontend/src/auth.rs b/web_frontend/src/auth.rs index 21f60c3f..82b25d5f 100644 --- a/web_frontend/src/auth.rs +++ b/web_frontend/src/auth.rs @@ -27,6 +27,7 @@ struct SetupPayload { #[derive(Debug, Clone, PartialEq)] enum GateState { Loading, + Failed, NeedsSetup, NeedsLogin, Authenticated(Option), @@ -86,7 +87,7 @@ impl Component for AuthGate { true } GateMessage::StatusLoadFailed => { - self.state = GateState::NeedsLogin; + self.state = GateState::Failed; true } GateMessage::SetupCompleted(username) => { @@ -107,6 +108,15 @@ impl Component for AuthGate { fn view(&self, ctx: &Context) -> Html { match &self.state { GateState::Loading => render_loading(), + GateState::Failed => render_card( + "Unable to load Privaxy", + html! { + <> +

+ + + }, + ), GateState::NeedsSetup => { let on_setup = ctx .link() diff --git a/web_frontend/src/blocking_enabled.rs b/web_frontend/src/blocking_enabled.rs index 41ef1ac4..def547da 100644 --- a/web_frontend/src/blocking_enabled.rs +++ b/web_frontend/src/blocking_enabled.rs @@ -1,24 +1,19 @@ +use crate::api; +use crate::button::{ButtonColor, ButtonState, PrivaxyButton}; use gloo_net::http::Request; use wasm_bindgen_futures::spawn_local; -use yew::{classes, html, Component, Context, Html}; - -pub enum ButtonState { - Loading, - Ready, -} +use yew::{html, Component, Context, Html}; pub struct BlockingEnabled { - blocking_enabled: bool, - button_state: ButtonState, + enabled: Option, + busy: bool, + error: Option, } -#[derive(Debug)] pub enum Message { - EnableBlocking, - DisableBlocking, - BlockingEnabled, - BlockingDisabled, - SetCurrentBlockingState, + Load, + Toggle, + Completed(Result), } impl Component for BlockingEnabled { @@ -26,138 +21,75 @@ impl Component for BlockingEnabled { type Properties = (); fn create(ctx: &Context) -> Self { - ctx.link().send_message(Message::SetCurrentBlockingState); - + ctx.link().send_message(Message::Load); Self { - blocking_enabled: true, - button_state: ButtonState::Loading, + enabled: None, + busy: true, + error: None, } } - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - let base_request = - Request::put("/api/blocking-enabled").header("Content-Type", "application/json"); - - let message_callback = ctx.link().callback(|message: Message| message); - + fn update(&mut self, ctx: &Context, msg: Message) -> bool { match msg { - Message::EnableBlocking => { - self.button_state = ButtonState::Loading; - - let request = base_request.body("true").unwrap(); - + Message::Load => { + self.busy = true; + self.error = None; + let link = ctx.link().clone(); spawn_local(async move { - match request.send().await { - Ok(response) => { - if response.ok() { - message_callback.emit(Message::BlockingEnabled) - } - } - Err(_) => message_callback.emit(Message::BlockingDisabled), - } + link.send_message(Message::Completed( + api::get_json("/api/blocking-enabled").await, + )); }); } - Message::DisableBlocking => { - self.button_state = ButtonState::Loading; - - let request = base_request.body("false").unwrap(); - + Message::Toggle => { + if self.busy { + return false; + } + let Some(enabled) = self.enabled.map(|enabled| !enabled) else { + return false; + }; + self.busy = true; + self.error = None; + let link = ctx.link().clone(); spawn_local(async move { - if let Ok(response) = request.send().await { - if response.ok() { - message_callback.emit(Message::BlockingDisabled); - } - } + let result = + api::send_json(Request::put("/api/blocking-enabled"), &enabled).await; + link.send_message(Message::Completed(result.map(|()| enabled))); }); } - Message::BlockingEnabled => { - self.button_state = ButtonState::Ready; - self.blocking_enabled = true; - } - Message::BlockingDisabled => { - self.button_state = ButtonState::Ready; - self.blocking_enabled = false; - } - Message::SetCurrentBlockingState => { - let request = Request::get("/api/blocking-enabled"); - - spawn_local(async move { - if let Ok(response) = request.send().await { - if response.ok() { - if let Ok(value) = response.json::().await { - if value { - message_callback.emit(Message::BlockingEnabled) - } else { - message_callback.emit(Message::BlockingDisabled) - } - }; - } + Message::Completed(result) => { + self.busy = false; + match result { + Ok(enabled) => self.enabled = Some(enabled), + Err(error) => { + self.error = Some(format!("Could not update blocking status: {error}")) } - }); + } } } - true } fn view(&self, ctx: &Context) -> Html { - let enable_blocking = ctx.link().callback(|_| Message::EnableBlocking); - let disable_blocking = ctx.link().callback(|_| Message::DisableBlocking); - - let mut button_classes = classes!( - "inline-flex", - "items-center", - "justify-center", - "px-4", - "py-2", - "border", - "transition", - "ease-in-out", - "duration-150", - "border-transparent", - "text-sm", - "text-sm", - "font-medium", - "rounded-md", - "shadow-sm", - "text-white", - "focus:outline-none", - "focus:ring-2", - "focus:ring-offset-2", - "focus:ring-offset-gray-100", - ); - - if let ButtonState::Loading = self.button_state { - button_classes.push("opacity-50"); - button_classes.push("cursor-not-allowed"); - } - - if self.blocking_enabled { - html! { - - } + let state = if self.busy { + ButtonState::Loading } else { - html! { - - } + ButtonState::Enabled + }; + let (color, button_text) = match self.enabled { + Some(true) => (ButtonColor::Red, "Pause blocking"), + Some(false) => (ButtonColor::Green, "Resume blocking"), + None => (ButtonColor::Gray, "Retry blocking status"), + }; + let loaded = self.enabled.is_some(); + html! { +
+ + if let Some(error) = &self.error { + + } +
} } } diff --git a/web_frontend/src/button.rs b/web_frontend/src/button.rs index f1eadbbc..11a79f09 100644 --- a/web_frontend/src/button.rs +++ b/web_frontend/src/button.rs @@ -97,6 +97,7 @@ impl Component for PrivaxyButton { }; html! { + } else { +

{"Loading statistics…"}

+ } + } + } + } +} + +#[derive(Properties, PartialEq)] +pub struct DashboardHeaderProps { + #[prop_or_default] + pub connected: bool, +} + +#[function_component(DashboardHeader)] +pub fn dashboard_header(props: &DashboardHeaderProps) -> Html { + html! { +
+
+

{ "Dashboard" } if props.connected {
}

+
+ +
+ } +} #[derive(Debug, Deserialize, PartialEq, Eq)] -pub struct Message { +pub struct Statistics { proxied_requests: Option, blocked_requests: Option, modified_responses: Option, @@ -18,101 +92,58 @@ pub struct Message { top_blocked_paths: Vec<(String, u64)>, #[serde(with = "tuple_vec_map")] top_clients: Vec<(String, u64)>, + #[serde(default)] + client_names: BTreeMap, } -pub struct Dashboard { - message: Message, +pub enum Message { + Received(Statistics), + Connection(bool), +} + +pub struct LiveDashboard { + message: Statistics, + connected: bool, ws_abort_handle: AbortHandle, } -impl Component for Dashboard { +impl Component for LiveDashboard { type Message = Message; type Properties = (); fn create(ctx: &Context) -> Self { - let message_callback = ctx.link().callback(|message: Message| message); - - let (abort_handle, abort_registration) = AbortHandle::new_pair(); - let future = Abortable::new( - async move { - loop { - let ws = match WebSocket::open("/api/statistics") { - Ok(ws) => ws, - Err(_err) => { - log::warn!("Unable to connect to websocket, trying again."); - - TimeoutFuture::new(1_000).await; - - continue; - } - }; - - let (_write, mut read) = ws.split(); - - while let Some(result) = read.next().await { - match result { - Ok(msg) => { - let message = match msg { - gloo_net::websocket::Message::Text(s) => { - let cursor = Cursor::new(s.as_bytes()); - let mut deserializer = - serde_json::Deserializer::from_reader(cursor) - .into_iter::(); - - match deserializer.next() { - Some(Ok(message)) => message, - Some(Err(e)) => { - log::error!( - "Failed to deserialize message: {:?}", - e - ); - continue; - } - None => { - log::warn!("No message received"); - continue; - } - } - } - gloo_net::websocket::Message::Bytes(_) => unreachable!(), - }; - message_callback.emit(message); - } - Err(e) => { - log::warn!("WebSocket error: {:?}", e); - break; - } - } - } - log::warn!("Lost connection to websocket, trying again."); - - TimeoutFuture::new(1_000).await; - } - }, - abort_registration, + let abort_handle = live_stream::subscribe( + "/api/statistics", + ctx.link().callback(Message::Received), + ctx.link().callback(Message::Connection), ); - spawn_local(async { - let _result = future.await; - }); - Self { ws_abort_handle: abort_handle, - message: Message { + connected: false, + message: Statistics { proxied_requests: None, blocked_requests: None, modified_responses: None, top_blocked_paths: Vec::new(), top_clients: Vec::new(), + client_names: BTreeMap::new(), }, } } fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { - let update = self.message != msg; - - self.message = msg; - update + match msg { + Message::Received(message) => { + let changed = self.message != message; + self.message = message; + changed + } + Message::Connection(connected) => { + self.connected = connected; + true + } + } } fn view(&self, _ctx: &Context) -> Html { @@ -123,13 +154,17 @@ impl Component for Dashboard { } } - 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) }
    @@ -139,27 +174,12 @@ impl Component for Dashboard { html! { <> -
    -
    -

    { "Dashboard" }
    -

    -
    - -
    + + if !self.connected { +

    {"Connecting to statistics… Retrying automatically. Displayed counts may be out of date."}

    + } +

    {"Live statistics since this process started. "}{"Statistics settings"}

    @@ -198,12 +218,12 @@ impl Component for Dashboard {
    -

    {"Top blocked paths"}

    +

    {"Top blocked domains"}

      { for self.message.top_blocked_paths.iter().map(|(path, - count)|render_list_element(path, *count)) } + count)|render_list_element(path, *count, None)) }
    @@ -215,7 +235,7 @@ impl Component for Dashboard {
      { 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/debug.rs b/web_frontend/src/debug.rs index 438c5526..faf39dc6 100644 --- a/web_frontend/src/debug.rs +++ b/web_frontend/src/debug.rs @@ -1,4 +1,4 @@ -use crate::logs::LogStream; +use crate::{api, logs::LogStream}; use gloo_net::http::Request; use serde::{Deserialize, Serialize}; use wasm_bindgen_futures::spawn_local; @@ -33,6 +33,7 @@ impl Default for DebugConfig { } pub enum Message { + Load, Loaded(DebugConfig), ToggleScriptletLogging(bool), SetLogLevel(String), @@ -42,6 +43,7 @@ pub enum Message { pub struct DebugSettingsPage { config: DebugConfig, + remote: DebugConfig, loaded: bool, saving: bool, saved: bool, @@ -53,20 +55,11 @@ impl Component for DebugSettingsPage { type Properties = (); fn create(ctx: &Context) -> Self { - let link = ctx.link().clone(); - spawn_local(async move { - match Request::get("/api/settings/debug").send().await { - Ok(response) if response.ok() => { - if let Ok(config) = response.json::().await { - link.send_message(Message::Loaded(config)); - } - } - _ => log::error!("Failed to load debug settings"), - } - }); + ctx.link().send_message(Message::Load); Self { config: DebugConfig::default(), + remote: DebugConfig::default(), loaded: false, saving: false, saved: false, @@ -76,12 +69,29 @@ impl Component for DebugSettingsPage { fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { match msg { + Message::Load => { + self.error = None; + let link = ctx.link().clone(); + spawn_local(async move { + link.send_message(match api::get_json("/api/settings/debug").await { + Ok(config) => Message::Loaded(config), + Err(error) => { + Message::SaveFailed(format!("Could not load debug settings: {error}")) + } + }); + }); + true + } Message::Loaded(config) => { + self.remote = config.clone(); self.config = config; self.loaded = true; true } Message::ToggleScriptletLogging(value) => { + if self.saving || !self.loaded { + return false; + } // Optimistically reflect the new value, then persist. The PUT // triggers a backend reload, so it takes effect on newly served // pages. @@ -90,6 +100,9 @@ impl Component for DebugSettingsPage { true } Message::SetLogLevel(level) => { + if self.saving || !self.loaded { + return false; + } // The backend applies the level live (no reload needed) on the // PUT; we persist it so it survives restarts. self.config.log_level = level; @@ -97,6 +110,7 @@ impl Component for DebugSettingsPage { true } Message::SaveSucceeded(config) => { + self.remote = config.clone(); self.config = config; self.saving = false; self.saved = true; @@ -104,6 +118,7 @@ impl Component for DebugSettingsPage { true } Message::SaveFailed(message) => { + self.config = self.remote.clone(); self.saving = false; self.saved = false; self.error = Some(message); @@ -114,7 +129,12 @@ impl Component for DebugSettingsPage { fn view(&self, ctx: &Context) -> Html { if !self.loaded { - return html! {
    {"Loading..."}
    }; + return html! { + if let Some(error) = &self.error { + + + } else {

    {"Loading..."}

    } + }; } let on_toggle = ctx.link().callback(|e: MouseEvent| { @@ -142,17 +162,18 @@ impl Component for DebugSettingsPage {

    { "Debug" }

    + if let Some(error) = &self.error { }
    { "Scriptlet diagnostics" }
    -
    -
    -
    +
    +
    +
    { "Log scriptlet errors to console" }
    -
    +
    -
    +

    { "Surface errors thrown by injected uBO scriptlets in the page's developer console as " } { "[privaxy scriptlet]" } { " entries, instead of silently swallowing them. Noisy and reveals that Privaxy is intercepting the page \u{2014} leave off unless troubleshooting." }

    - if let Some(error) = &self.error { -

    { error.clone() }

    - }
    @@ -178,14 +196,14 @@ impl Component for DebugSettingsPage {
    { "Logging" }
    -
    -
    -
    +
    +
    +
    { "Log level" }
    -
    +
    ().expect("event target should be a select element"); let value = select.value(); @@ -289,7 +305,7 @@ impl Component for AddFilterComponent {
    ().expect("event target should be an input element"); @@ -303,7 +319,7 @@ impl Component for AddFilterComponent {
    ().expect("event target should be an input element"); @@ -315,7 +331,7 @@ impl Component for AddFilterComponent {
    -
    +
    } @@ -386,9 +402,10 @@ pub struct FilterRefreshResult { pub enum Message { Load, Display(FilterConfiguration), + Failed(String), UpdateFilterSelection((String, bool)), Save, - ChangesSaved, + ChangesSaved(FilterConfiguration), AckChanges, OpenEdit(Filter), CloseEdit, @@ -399,6 +416,9 @@ pub enum Message { pub struct Filters { filter_configuration: Option, + loading: bool, + saving: bool, + error: Option, filter_configuration_before_changes: Option, changes_saved: bool, editing_filter: Option, @@ -435,6 +455,9 @@ impl Component for Filters { Self { filter_configuration: None, + loading: true, + saving: false, + error: None, filter_configuration_before_changes: None, changes_saved: false, editing_filter: None, @@ -446,69 +469,77 @@ impl Component for Filters { fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { match msg { - Message::Display(filter_configuration) => { - log::debug!("Displaying"); - self.filter_configuration = Some(filter_configuration.clone()); - self.filter_configuration_before_changes = Some(filter_configuration); - } - Message::Load => { - log::debug!("Retrieving filters.."); - let request = Request::get("/api/filters"); - log::debug!("Request: {:?}", request); - let message_callback = ctx.link().callback(|message: Message| message); - log::debug!("Message callback: {:?}", message_callback); - - spawn_local(async move { - if let Ok(response) = request.send().await { - log::debug!("Response: {:?}", response); - if response.ok() { - log::debug!("Response OK"); - if let Ok(body) = response.text().await { - let cursor = Cursor::new(body); - let stream = StreamDeserializer::new(IoRead::new(cursor)); - for result in stream { - match result { - Ok(filter_configuration) => message_callback - .emit(Message::Display(filter_configuration)), - Err(e) => log::error!("Failed to parse chunk: {:?}", e), - } - } + Message::Display(remote) => { + let mut draft = remote.clone(); + // Adding, removing or editing a list must not discard pending + // checkbox changes elsewhere on this page. + if let (Some(current), Some(saved)) = ( + &self.filter_configuration, + &self.filter_configuration_before_changes, + ) { + for filter in &mut draft { + if let (Some(current), Some(saved)) = ( + current.iter().find(|f| f.file_name == filter.file_name), + saved.iter().find(|f| f.file_name == filter.file_name), + ) { + if current.enabled != saved.enabled { + filter.enabled = current.enabled; } } } + } + self.filter_configuration = Some(draft); + self.filter_configuration_before_changes = Some(remote); + self.loading = false; + } + Message::Failed(error) => { + self.error = Some(error); + self.loading = false; + self.saving = false; + } + Message::Load => { + self.loading = true; + self.error = None; + let link = ctx.link().clone(); + spawn_local(async move { + link.send_message(match api::get_json("/api/filters").await { + Ok(filters) => Message::Display(filters), + Err(error) => Message::Failed(format!("Could not load filters: {error}")), + }); }); } Message::Save => { - if !self.configuration_has_changed() { + if self.loading + || self.saving + || self.refreshing + || !self.configuration_has_changed() + { return false; } - let request_body = self - .filter_configuration - .as_ref() - .unwrap() + let Some(submitted) = self.filter_configuration.clone() else { + return false; + }; + self.saving = true; + self.error = None; + self.changes_saved = false; + let request_body = submitted .iter() .map(|filter| FilterStatusChangeRequest { enabled: filter.enabled, file_name: filter.file_name.clone(), }) .collect::>(); - - let request = Request::put("/api/filters") - .header("Content-Type", "application/json") - .body(serde_json::to_string(&request_body).unwrap()) - .unwrap(); - - let callback = ctx.link().callback(|message: Message| message); - + let link = ctx.link().clone(); spawn_local(async move { - if let Ok(response) = request.send().await { - if response.ok() { - callback.emit(Message::ChangesSaved); - } - } + link.send_message( + match api::send_json(Request::put("/api/filters"), &request_body).await { + Ok(()) => Message::ChangesSaved(submitted), + Err(error) => { + Message::Failed(format!("Could not save filters: {error}")) + } + }, + ); }); - - log::info!("Save") } Message::UpdateFilterSelection((filter_name, enabled)) => { self.changes_saved = false; @@ -523,9 +554,10 @@ impl Component for Filters { filter.enabled = enabled; } } - Message::ChangesSaved => { - self.changes_saved = true; - self.filter_configuration_before_changes = self.filter_configuration.clone(); + Message::ChangesSaved(submitted) => { + self.saving = false; + self.filter_configuration_before_changes = Some(submitted); + self.changes_saved = !self.configuration_has_changed(); } Message::AckChanges => self.changes_saved = false, Message::OpenEdit(filter) => self.editing_filter = Some(filter), @@ -535,7 +567,11 @@ impl Component for Filters { ctx.link().send_message(Message::Load); } Message::RefreshLists => { - if self.refreshing || self.configuration_has_changed() { + if self.loading + || self.saving + || self.refreshing + || self.configuration_has_changed() + { return false; } self.refreshing = true; @@ -572,7 +608,9 @@ impl Component for Filters { fn view(&self, ctx: &Context) -> Html { log::debug!("At view."); - let save_button_state = if !self.configuration_has_changed() { + let save_button_state = if self.saving { + ButtonState::Loading + } else if self.loading || self.refreshing || !self.configuration_has_changed() { ButtonState::Disabled } else { ButtonState::Enabled @@ -605,6 +643,7 @@ impl Component for Filters { html! {
    @@ -683,7 +722,9 @@ impl Component for Filters { let refresh_state = if self.refreshing { ButtonState::Loading - } else if self.configuration_has_changed() + } else if self.saving + || self.loading + || self.configuration_has_changed() || !self .filter_configuration .as_ref() @@ -722,6 +763,14 @@ impl Component for Filters { None => html! {}, }; + let error_banner = self.error.as_ref().map(|error| html! { +
    + + if self.filter_configuration.is_none() { + + } +
    + }).unwrap_or_default(); match &self.filter_configuration { Some(filter_configuration) => { html! { @@ -730,10 +779,11 @@ impl Component for Filters { {success_banner} + {error_banner} { edit_modal }
    - - + + {save_button!(save_callback, save_button_state)}
    } } - // This realistically loads way too fast for a loader to be useful. Adding one would just add - // unwanted flickering. - None => html! {{ title }}, + None => { + html! { <>{title}{error_banner} if self.loading {

    {"Loading filters…"}

    } } + } } } } diff --git a/web_frontend/src/footer.rs b/web_frontend/src/footer.rs new file mode 100644 index 00000000..9edbd5f0 --- /dev/null +++ b/web_frontend/src/footer.rs @@ -0,0 +1,186 @@ +use crate::api; +use futures::future::{AbortHandle, Abortable}; +use gloo_timers::callback::Interval; +use serde::{de::DeserializeOwned, Deserialize}; +use wasm_bindgen_futures::spawn_local; +use yew::prelude::*; + +const UPDATE_CHECK_INTERVAL_MS: u32 = 60 * 60 * 1000; + +#[derive(Deserialize)] +pub struct VersionInfo { + version: String, + project_url: String, +} + +#[derive(Deserialize)] +struct ReleaseInfo { + version: String, + url: String, +} + +#[derive(Deserialize)] +pub struct UpdateCheck { + current_version: String, + latest_release: Option, + update_available: bool, + checked_at: i64, +} + +pub enum Message { + LoadVersion, + VersionLoaded(Result), + Check, + Checked(Result), +} + +pub struct Footer { + version: Option, + version_error: Option, + update: Option, + version_request: Option, + update_request: Option, + _update_interval: Interval, +} + +impl Component for Footer { + type Message = Message; + type Properties = (); + + fn create(ctx: &Context) -> Self { + ctx.link().send_message(Message::LoadVersion); + let link = ctx.link().clone(); + Self { + version: None, + version_error: None, + update: None, + version_request: None, + update_request: None, + _update_interval: Interval::new(UPDATE_CHECK_INTERVAL_MS, move || { + link.send_message(Message::Check); + }), + } + } + + fn update(&mut self, ctx: &Context, message: Message) -> bool { + match message { + Message::LoadVersion => { + if self.version_request.is_some() { + return false; + } + self.version_error = None; + self.version_request = Some(fetch( + "/api/version", + ctx.link().callback(Message::VersionLoaded), + )); + } + Message::VersionLoaded(result) => { + self.version_request = None; + match result { + Ok(version) => { + self.version = Some(version); + ctx.link().send_message(Message::Check); + } + Err(error) => self.version_error = Some(error), + } + } + Message::Check => { + if self.version.is_none() || self.update_request.is_some() { + return false; + } + self.update_request = + Some(fetch("/api/updates", ctx.link().callback(Message::Checked))); + return false; + } + Message::Checked(result) => { + self.update_request = None; + let update = match result { + Ok(update) => update, + Err(error) => { + log::warn!("Automatic update check failed: {error}"); + return false; + } + }; + if let Some(version) = &mut self.version { + version.version.clone_from(&update.current_version); + } + self.update = Some(update); + } + } + true + } + + fn destroy(&mut self, _ctx: &Context) { + for request in [&mut self.version_request, &mut self.update_request] { + if let Some(request) = request.take() { + request.abort(); + } + } + } + + fn view(&self, ctx: &Context) -> Html { + html! { +
    +
    +
    + if let Some(version) = &self.version { + {format!("Privaxy v{}", version.version)} + } else { + {"Privaxy"} + if let Some(error) = &self.version_error { + {"Version unavailable"} + + } else { + {"Loading version…"} + } + } +
    + {self.update_status()} +
    +
    + } + } +} + +impl Footer { + fn update_status(&self) -> Html { + let Some(update) = self + .update + .as_ref() + .filter(|update| update.update_available) + else { + return Html::default(); + }; + let Some(release) = &update.latest_release else { + return Html::default(); + }; + let checked = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64( + update.checked_at as f64 * 1000.0, + )) + .to_iso_string() + .as_string() + .unwrap_or_default(); + let title = format!( + "Privaxy v{} is available. Last checked: {checked}", + release.version + ); + html! { + + } + } +} + +fn fetch( + url: &'static str, + callback: Callback>, +) -> AbortHandle { + let (handle, registration) = AbortHandle::new_pair(); + spawn_local(async move { + if let Ok(result) = Abortable::new(api::get_json(url), registration).await { + callback.emit(result); + } + }); + handle +} diff --git a/web_frontend/src/general.rs b/web_frontend/src/general.rs index 7c0f3ee9..f6190376 100644 --- a/web_frontend/src/general.rs +++ b/web_frontend/src/general.rs @@ -2,8 +2,7 @@ use crate::button::ButtonState; use crate::button::{get_css, ButtonColor}; use crate::failure_banner; use crate::success_banner; -use crate::{save_button, ApiError}; -use gloo_net::http::Request; +use crate::{api, save_button, ApiError}; use gloo_utils::format::JsValueSerdeExt; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -24,6 +23,7 @@ pub enum Message { Load, Save, NetworkLoadSuccess(NetworkConfig), + LoadFailed(String), UpdateProxyPort(String), UpdateBindAddr(String), UpdateWebPort(String), @@ -223,7 +223,9 @@ impl NetworkSettings { Ok(()) } else { log::error!("Failed to save network config"); - return Err(resp.json::().await.unwrap()); + Err(ApiError { + error: api::response_error(resp).await, + }) } } Err(err) => { @@ -249,6 +251,8 @@ pub(crate) struct GeneralSettings { network_settings: Option, ca_config: CaConfig, loading: bool, + saving: bool, + load_error: Option, #[allow(dead_code)] save_callback: Callback<()>, show_error: bool, @@ -305,6 +309,8 @@ impl Component for GeneralSettings { }, network_settings: None, loading: true, + saving: false, + load_error: None, save_callback: Callback::noop(), show_success: false, show_error: false, @@ -315,31 +321,27 @@ impl Component for GeneralSettings { fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { match msg { Message::Load => { + self.loading = true; + self.load_error = None; let link = ctx.link().clone(); spawn_local(async move { - let request = Request::get("/api/settings/network"); - match request.send().await { - Ok(response) => { - if response.ok() { - if let Ok(network_config) = response.json::().await { - link.send_message(Message::NetworkLoadSuccess(network_config)); - } - } else { - log::error!( - "Failed to load network config: {:?}", - response.status() - ); - } - } - Err(err) => { - log::error!("Request error: {:?}", err); - } - } + link.send_message(match api::get_json("/api/settings/network").await { + Ok(config) => Message::NetworkLoadSuccess(config), + Err(error) => Message::LoadFailed(error), + }); }); + } + Message::LoadFailed(error) => { self.loading = false; - self.changes_saved = true; + self.load_error = Some(error); } Message::Save => { + if self.loading || self.saving || !self.config_has_changed() || !self.validate() { + return false; + } + self.saving = true; + self.show_error = false; + self.show_success = false; let link = ctx.link().clone(); let network_settings = self.network_settings.clone(); let ca_config = self.ca_config.clone(); @@ -383,6 +385,7 @@ impl Component for GeneralSettings { }); } Message::SaveFailed(err) => { + self.saving = false; let error_msg = err.clone().error.to_string(); self.changes_saved = false; self.show_success = false; @@ -390,6 +393,7 @@ impl Component for GeneralSettings { self.err_msg = error_msg; } Message::SaveSuccess => { + self.saving = false; self.changes_saved = true; self.show_success = true; self.show_error = false; @@ -579,14 +583,14 @@ impl Component for GeneralSettings { error: Option<&String>, description: &str| { html! { -
    -
    -
    { setting_name }
    -
    - +
    +
    +
    { setting_name }
    +
    +
    -
    +

    {description}

    if let Some(error_msg) = error {

    {error_msg}

    @@ -601,14 +605,14 @@ impl Component for GeneralSettings { oninput: Callback, description: &str| { html! { -
    -
    -
    { setting_name }
    -
    - +
    +
    +
    { setting_name }
    +
    +
    -
    +

    {description}

    @@ -748,7 +752,9 @@ impl Component for GeneralSettings {
    } }; - let save_button_state = if self.config_has_changed() && self.validate() { + let save_button_state = if self.saving { + ButtonState::Loading + } else if !self.loading && self.config_has_changed() && self.validate() { ButtonState::Enabled } else { ButtonState::Disabled @@ -779,12 +785,18 @@ impl Component for GeneralSettings { { title } { success_banner_html } { failure_banner_html } + if let Some(error) = &self.load_error { + + + } +
    if let Some(network_settings) = &self.network_settings { {render_category("Network", SettingCategories::Network(network_settings.clone()))} - } else { -
    {"Loading..."}
    + } else if self.loading { +
    {"Loading..."}
    } {render_category("Certificate", SettingCategories::Certificate(self.ca_config.clone()))} +
    {save_button!(save_callback, save_button_state)} @@ -802,23 +814,23 @@ fn render_certificate_setting( ) -> Html { let input_id = setting_name.to_string().to_lowercase().replace(" ", "_"); html! { -
    -
    -
    { setting_name }
    -
    +
    +
    +
    { setting_name }
    +