From 172cc31f29ad537f59c92847131342c26ba33f54 Mon Sep 17 00:00:00 2001 From: kayurachann <272149049+kayurachann@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:24:10 +0700 Subject: [PATCH 1/2] Free the DNR regex pool and make strict blocking exact (roadmap step 2) Strict blocking no longer spends a regex rule per filter: - Strict-block rules are plain extensionPath redirects to strictblock.html; only filters that are themselves regexes need a regex rule. With the default lists, 108 shared regex slots are used instead of 1,000, and the 278 strict-block rules dropped for lack of room are installed again. - The blocked address is recovered from rule-match, webRequest (when granted) or navigation events and carried in the page's own history entry, so it is exact after server redirects, Back/Forward, duplicated tabs and reloads. When only the navigation start is known the page says the address is approximate; on the low-memory profile the matched-rule listener that keeps the worker awake is not used. - $doc filters in imported lists and My filters get the warning page with Proceed when strict blocking is on and site access is granted. - Stock regex rules move into each list's static ruleset (a separate 1,000 pool), validated at build time with Chrome's isRegexSupported (Chrome 153: 473 of 607 valid); an offline RE2 check guards the load. Filters Chrome cannot run are counted as not converted. - Imported lists and My filters get the freed regex capacity (regex-capacity.js accounts usage per owner); only imported regex rules are skipped when the pool is full. - Diagnostics shows regex capacity, skipped built-in regex rules ("Check now") and how the warning page learns the address. Shared foundations for later steps: dnr-namespaces.js (rule-ID ranges, priority bands, one DNR write queue), strictblock-rules.js, an exclusions provider that no longer erases exclusions when strict mode or site access is turned off, and sender checks for extension pages and user-script messages. Tests: unit tests for every module, a package validator for static regex, and real-Chrome tests (test-strictblock-chrome, test-static-regex-chrome) run in CI. Compiled-cache revision 5. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/mv3-chromium.yml | 31 +- .github/workflows/release.yml | 22 +- CHANGELOG.md | 58 + README.md | 2 +- docs/ARCHITECTURE.md | 21 +- docs/FEATURE-MATRIX.md | 20 +- docs/MV3-LIMITS-RESEARCH-2026-09-24.md | 114 +- docs/POWER-RUNTIME.md | 39 +- docs/PRIVACY.md | 20 +- docs/README.vi.md | 2 +- docs/THREAT-MODEL.md | 27 +- .../mv3/extension/_locales/de/messages.json | 108 ++ .../mv3/extension/_locales/en/messages.json | 108 ++ .../mv3/extension/_locales/es/messages.json | 108 ++ .../mv3/extension/_locales/fr/messages.json | 108 ++ .../mv3/extension/_locales/ja/messages.json | 108 ++ .../mv3/extension/_locales/ko/messages.json | 108 ++ .../mv3/extension/_locales/ru/messages.json | 108 ++ .../mv3/extension/_locales/vi/messages.json | 110 +- .../extension/_locales/zh_CN/messages.json | 108 ++ .../extension/_locales/zh_TW/messages.json | 108 ++ platform/mv3/extension/css/power-settings.css | 27 +- platform/mv3/extension/css/strictblock.css | 9 + platform/mv3/extension/dashboard.html | 11 + platform/mv3/extension/js/background.js | 236 +++- platform/mv3/extension/js/compiled-cache.js | 3 +- platform/mv3/extension/js/compiled-storage.js | 4 + platform/mv3/extension/js/dnr-namespaces.js | 193 +++ platform/mv3/extension/js/filter-lists.js | 5 +- platform/mv3/extension/js/filter-store.js | 26 +- .../extension/js/offscreen/compile-filters.js | 14 + platform/mv3/extension/js/power-settings.js | 204 ++- platform/mv3/extension/js/regex-capacity.js | 266 ++++ platform/mv3/extension/js/ruleset-manager.js | 967 ++++++++++---- .../extension/js/runtime-capabilities-core.js | 39 + .../mv3/extension/js/runtime-capabilities.js | 7 +- .../mv3/extension/js/strictblock-rules.js | 572 +++++++++ .../mv3/extension/js/strictblock-tracker.js | 700 ++++++++++ platform/mv3/extension/js/strictblock.js | 333 +++-- platform/mv3/extension/js/troubleshooting.js | 16 + platform/mv3/extension/strictblock.html | 1 + platform/mv3/make-rulesets.js | 328 +++-- platform/mv3/re2-portable.js | 256 ++++ platform/mv3/regex-verdicts.mjs | 446 +++++++ platform/mv3/salvage-ruleids.mjs | 22 +- platform/mv3/stock-regex.js | 252 ++++ src/js/static-dnr-filtering.js | 6 + tools/chrome-cdp-harness.mjs | 362 ++++++ tools/make-mv3.ps1 | 50 + tools/make-mv3.sh | 19 +- tools/stock-ruleset-harness.mjs | 88 ++ tools/test-a-startup-resilience.mjs | 162 ++- tools/test-antiadblock-chrome.mjs | 75 +- tools/test-dnr-namespaces.mjs | 205 +++ tools/test-mv3-source.mjs | 59 + tools/test-native-ruleset-recovery.mjs | 22 +- tools/test-power-locales.mjs | 42 +- tools/test-re2-portable.mjs | 192 +++ tools/test-regex-capacity-ui.mjs | 567 ++++++++ tools/test-regex-capacity.mjs | 951 ++++++++++++++ tools/test-runtime-capabilities.mjs | 50 + tools/test-runtime-durability.mjs | 81 +- tools/test-sender-trust.mjs | 352 +++++ tools/test-static-regex-chrome.mjs | 271 ++++ tools/test-stock-badfilter.mjs | 33 + tools/test-stock-compiler-honesty.mjs | 187 ++- tools/test-stock-regex-placement.mjs | 447 +++++++ tools/test-strictblock-chrome.mjs | 992 ++++++++++++++ tools/test-strictblock-rules.mjs | 729 +++++++++++ tools/test-strictblock-tracker.mjs | 1137 +++++++++++++++++ tools/test-user-dnr-drafts.mjs | 5 +- tools/test-validate-mv3-regex.mjs | 339 +++++ tools/validate-mv3.mjs | 319 ++++- 73 files changed, 13512 insertions(+), 605 deletions(-) create mode 100644 platform/mv3/extension/js/dnr-namespaces.js create mode 100644 platform/mv3/extension/js/regex-capacity.js create mode 100644 platform/mv3/extension/js/strictblock-rules.js create mode 100644 platform/mv3/extension/js/strictblock-tracker.js create mode 100644 platform/mv3/re2-portable.js create mode 100644 platform/mv3/regex-verdicts.mjs create mode 100644 platform/mv3/stock-regex.js create mode 100644 tools/chrome-cdp-harness.mjs create mode 100644 tools/stock-ruleset-harness.mjs create mode 100644 tools/test-dnr-namespaces.mjs create mode 100644 tools/test-re2-portable.mjs create mode 100644 tools/test-regex-capacity-ui.mjs create mode 100644 tools/test-regex-capacity.mjs create mode 100644 tools/test-sender-trust.mjs create mode 100644 tools/test-static-regex-chrome.mjs create mode 100644 tools/test-stock-regex-placement.mjs create mode 100644 tools/test-strictblock-chrome.mjs create mode 100644 tools/test-strictblock-rules.mjs create mode 100644 tools/test-strictblock-tracker.mjs create mode 100644 tools/test-validate-mv3-regex.mjs diff --git a/.github/workflows/mv3-chromium.yml b/.github/workflows/mv3-chromium.yml index 8652bce89be33..e2d08deca4448 100644 --- a/.github/workflows/mv3-chromium.yml +++ b/.github/workflows/mv3-chromium.yml @@ -16,7 +16,9 @@ jobs: build: name: Lint and build on Windows runs-on: windows-latest - timeout-minutes: 45 + # The builds check every stock regex with Chrome, and the packages are + # tested in Chrome. + timeout-minutes: 60 steps: - name: Clone repository and submodules @@ -54,6 +56,16 @@ jobs: - name: Validate MV3 Chromium artifact run: node tools/validate-mv3.mjs dist/build/uBlockPlus.chromium --release + # windows-latest ships Google Chrome. Each test uses a fresh temporary + # profile, closes the Chrome it started and has its own hard timeout. + - name: Test static regex rules in Chrome + timeout-minutes: 10 + run: node tools/test-static-regex-chrome.mjs --extension dist/build/uBlockPlus.chromium --output dist/chrome-tests/static-regex-chrome.json + + - name: Test strict blocking in Chrome + timeout-minutes: 15 + run: node tools/test-strictblock-chrome.mjs --extension dist/build/uBlockPlus.chromium --output dist/chrome-tests/strictblock-chrome.json + - name: Upload unpacked extension and zip uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -71,6 +83,23 @@ jobs: - name: Validate experimental WebRequest artifact run: node tools/validate-mv3.mjs dist/build/uBlockPlus.experimental.chromium --release --experimental-webrequest + - name: Test experimental static regex rules in Chrome + timeout-minutes: 10 + run: node tools/test-static-regex-chrome.mjs --extension dist/build/uBlockPlus.experimental.chromium --output dist/chrome-tests/static-regex-chrome.experimental.json + + # The experimental package learns blocked addresses from webRequest. + - name: Test experimental strict blocking in Chrome + timeout-minutes: 10 + run: node tools/test-strictblock-chrome.mjs --extension dist/build/uBlockPlus.experimental.chromium --measure-seconds 0 --output dist/chrome-tests/strictblock-chrome.experimental.json + + - name: Upload Chrome test reports + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: chrome-test-reports-${{ github.sha }} + if-no-files-found: ignore + path: dist/chrome-tests/ + - name: Upload experimental extension and launcher uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e1ddc949f587..69954b15af60f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,9 @@ jobs: build: name: Build and verify runs-on: windows-latest - timeout-minutes: 60 + # The builds check every stock regex with Chrome, and the packages are + # tested in Chrome. + timeout-minutes: 75 permissions: contents: read env: @@ -86,6 +88,16 @@ jobs: - name: Validate standard package run: node tools/validate-mv3.mjs dist/build/uBlockPlus.chromium --release + # windows-latest ships Google Chrome. Each test uses a fresh temporary + # profile, closes the Chrome it started and has its own hard timeout. + - name: Test standard package static regex rules in Chrome + timeout-minutes: 10 + run: node tools/test-static-regex-chrome.mjs --extension dist/build/uBlockPlus.chromium + + - name: Test standard package strict blocking in Chrome + timeout-minutes: 10 + run: node tools/test-strictblock-chrome.mjs --extension dist/build/uBlockPlus.chromium --measure-seconds 0 + - name: Build experimental WebRequest package shell: pwsh run: ./tools/make-mv3.ps1 -Platform chromium -Version '${{ steps.version.outputs.version }}' -ExperimentalWebRequest @@ -93,6 +105,14 @@ jobs: - name: Validate experimental package run: node tools/validate-mv3.mjs dist/build/uBlockPlus.experimental.chromium --release --experimental-webrequest + - name: Test experimental package static regex rules in Chrome + timeout-minutes: 10 + run: node tools/test-static-regex-chrome.mjs --extension dist/build/uBlockPlus.experimental.chromium + + - name: Test experimental package strict blocking in Chrome + timeout-minutes: 10 + run: node tools/test-strictblock-chrome.mjs --extension dist/build/uBlockPlus.experimental.chromium --measure-seconds 0 + - name: Upload packages uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 3da76ea34574a..28ef76c8e64ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,62 @@ Releases of this community fork. The upstream uBlock Origin changelog follows fu ## Unreleased +### Strict blocking and regex capacity + +Strict blocking (Chromium): +- Strict-block rules no longer use one regex rule each. They are plain redirects to the warning page that keep the filter's own address condition, so only filters that are regexes need a regex rule: 111 of 1,106 rules with the default lists, instead of 1,132. +- Every valid stock strict-block rule is now installed: 1,106 with the default lists, up from 826. The 278 that did not fit before are back, so some sites that loaded before now show the warning page. +- The warning page shows the exact blocked address. The browser reports it when the page is redirected: through the optional network-observation (`webRequest`) permission, or, on unpacked installations, through Chrome's matched-rule events. The page then keeps the address in its own URL, so Back, Forward, reload and duplicated tabs show it too. Tested in Chrome 153: + - a new tab, a typed address, a link and a `window.open` popup; + - after a server redirect, Back, Forward, reload and in a duplicated tab; + - with the service worker stopped, and for two quick navigations; + - for a stock list and for My filters, including after a server redirect; + - with both sources. After a server redirect Chrome reports the step to the warning page only as a new request, which the network-observation source now follows; the page shows the address within about 100–140 ms instead of after a 1.5 s wait. +- When only the start of the navigation is known (packed installations without the optional permission, or the low-memory profile), the page says that the address is approximate and does not offer **Don't warn me again about this site**: after a server redirect that address belongs to the site which redirected, so **Proceed** may lead back to the warning. When no address is known, it says so and disables **Proceed** and **Don't warn me again about this site**. It never opens an address other than `http:` or `https:`. +- The service worker forgets a blocked address as soon as the warning page has read it. +- The page names what blocked the site: the stock list, "Imported lists" or "My filters". Before, the stock list was found by testing the address against the list's regexes. +- `$doc`, `$document` and `$all` filters in My filters and imported lists show the warning page, with **Proceed** and **Don't warn me again about this site**, when strict blocking is on, the extension has access to all sites and the exact address is available. Otherwise they block as before, with the browser's error page. Hostname-only filters (`||example.com^`) in My filters and imported lists are not strict-blocked yet. Firefox and Safari are unchanged. +- Chrome lets a page load when a redirect matches without host access. Strict-block redirects are therefore removed as soon as access to all sites is withdrawn, and checked again at every service-worker start. +- Turning strict blocking off, or losing access to all sites, no longer erases the **Don't warn me again about this site** list. +- **Dashboard → Diagnostics** states how the warning page learns the address: exact, approximate or not available. It offers a button to grant the optional network-observation permission, the cheapest exact source. + - Without that permission, unpacked installations listen to Chrome's matched-rule events. These arrive for every blocked request, so the service worker stays awake while you browse. + - The low-memory profile does not use these events, so that the service worker can stop: the address is then approximate unless the permission is granted, and `$doc` filters in My filters and imported lists block with the browser's error page. + - Measured on one machine: pages with 300 blocked images loaded 12–26 ms slower, and the worker did not stop while a page requested a blocked image every 2 seconds. + +Regex capacity: +- Stock regex rules are part of each list's packaged ruleset and work as soon as the list is enabled; they are no longer installed as dynamic rules at runtime. +- This frees Chrome's shared limit of 1,000 regex rules for dynamic and session rules, which the default lists used to fill completely. My filters and imported lists can now use about 889 regex rules with the default lists (877 with every list). +- When that limit is full, only regex rules of imported lists that are not exceptions are skipped, from the end, with a warning. Before, the whole activation failed. +- The Filter Store's regex estimate uses the capacity actually left instead of assuming 1,000. +- New **Regex rule capacity** panel in **Dashboard → Diagnostics**: + - built-in regex rules in use out of 1,000, and the Chrome version that checked them when the package was built; + - built-in regex filters the browser cannot run; + - **Check now**, which counts the built-in regex rules this browser skips; + - the shared limit: rules used by My filters and imported lists and by strict blocking, free slots, and rules not installed because the limit is full; + - selected lists the browser did not enable. +- Troubleshooting reports include the regex rule usage and the address source, never an address. + +Honest counts: +- Regex filters that Chrome's RE2 engine cannot run are now counted in each list's tooltip as filters that could not be converted, with the reason codes `unsupported-regex-memory` (160) and `unsupported-regex-syntax` (5). + - The number grows by 108 in the default lists and by 165 across all lists, to 1,378 filters in 36 of the 55 lists. + - Nothing blocks less: Chrome already skipped these filters before, silently, at runtime. +- The 28 strict-block regex rules in the default lists (31 across all lists) that RE2 rejects are counted in `strictblockRejected` in `ruleset-details.json`. As before, those pages get no warning page. For 24 of them (27 across all lists) the filter blocks only documents, so that rule was all there was of it: those pages are not blocked at all, as before, and the filters are counted among those that could not be converted. + +Security: +- On Firefox, which reports no sender origin, a message counts as coming from an extension page only when its extension ID and URL match. Before, the missing origin was enough, so content scripts could send those messages. +- User scripts can send only an allowlisted set of messages. +- Only the warning page, in the top frame of a tab, can ask which address its tab was redirected from. Only extension pages can read the regex capacity. + +Build and validation: +- Chromium and Edge builds check every static regex twice before packaging it: against the portable RE2 subset, then with Chrome's own `isRegexSupported`. Chrome refuses to load an unpacked extension when any static regex fails to parse. + - The check runs Chrome headless, in a new temporary profile that is deleted afterwards. + - Release builds (`-Version`) need Google Chrome, found in the standard install folders or through `CHROME_PATH`. +- New `rulesets/regex-details.json` (count, checking Chrome version, digest). +- `tools/validate-mv3.mjs` also checks the static regex rules, their total against Chrome's limit of 1,000, and the shape of the strict-block rules. +- The CI and release workflows load both packages in a real Chrome (`tools/test-static-regex-chrome.mjs`, `tools/test-strictblock-chrome.mjs`). Their job timeouts were raised. +- Imported lists are recompiled once after the update (compiled-cache revision 5) to add their strict-block redirects. +- Rule-ID salvage (`make-mv3.ps1 -Before`, `make-mv3.sh BEFORE=`) keeps each list's static regex rules after its other rules, where the package validator expects them. + ### Honest filter compilation Stock lists (build time): @@ -51,6 +107,8 @@ Not yet done: - Remote fonts and `report-uri` CSP reports can be blocked by DNR; they are missing features, not browser limits. - The `chrome.debugger` route misses responses built by a page's service worker and the first document of a new tab, prompts for Local Network Access on local iframes, and needs auto-attach for subresources of cross-site iframes. - New tests: `tools/test-stock-compiler-honesty.mjs` and `tools/test-runtime-filter-options.mjs`. +- The research marks roadmap step 2 as done. It records the measured strict-block options, the exact-address sources, the fail-open finding without host access and the cost of Chrome's matched-rule events. The feature matrix, runtime, architecture, threat model and privacy documents describe the two regex limits, the build-time RE2 check and the headless Chrome it starts, and the blocked addresses kept per tab. +- New tests for step 2: `test-strictblock-rules`, `test-dnr-namespaces`, `test-strictblock-tracker`, `test-regex-capacity`, `test-regex-capacity-ui`, `test-re2-portable`, `test-stock-regex-placement`, `test-validate-mv3-regex` and `test-sender-trust`, plus the two real-Chrome tests above (all in `tools/`). ## 1.2.0 diff --git a/README.md b/README.md index f870b243db721..ef55bb261740c 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,7 @@ Userscripts can extend DOM and page-level JavaScript filtering, but do not grant | Response-body rewriting, DNS/CNAME inspection, exact response-size blocking | No equivalent implementation through this build's normal public MV3 APIs. | | Managed settings and additional engines | Supported administrator settings are available; a managed blocking adapter and a filtering native companion remain research items. The only native component is the optional Windows updater, which runs only after you install it yourself; nothing native is installed silently. | -Chrome can reject an allow-exception regex because its compiled RE2 program exceeds the browser's limit. Some packaged lists contain such expressions, so a list-selection change can be rejected even when its rule count fits the quota. The extension restores the previous configuration and rules instead of dropping the exception. See the [native regex limit and recovery details](docs/MV3-PARITY-IMPLEMENTATION-2026-09-06.md). +Chrome cannot run a regex which its RE2 engine rejects, for example because the compiled program exceeds the browser's memory limit. On Chromium, the build checks every packaged regex rule with Chrome: the rules Chrome accepts are packaged in the lists' own static rulesets, and the others, exceptions included, are left out and counted among the filters the list could not convert. Choosing lists is therefore no longer rejected for this reason. Regex rules of imported lists and My filters share a separate limit of 1,000 with strict blocking and are checked when they are installed: when that limit is full, imported regex block rules are left out from the end, with a warning, while an exception or a My filters rule which cannot be installed keeps the previous configuration and rules. Firefox builds keep the previous behaviour for packaged lists. Diagnostics shows both limits. See the [native regex limit and recovery details](docs/MV3-PARITY-IMPLEMENTATION-2026-09-06.md) and [Power Runtime](docs/POWER-RUNTIME.md). **Fail-open is deliberate.** If a popup decision requires context that is missing or an exception cannot be represented safely, the relevant decision defers instead of approximating a block. When the matching work budget runs out, it defers only while an exception could still apply; otherwise the unevaluated block rules are skipped and the Smart/Strict policy decides. Deferred allow conditions can force deferral; they cannot manufacture an approximate allow/block decision. Unsupported restrictive rules are not broadened by stripping their conditions. This reduces false positives and also means some unwanted popups can pass. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 076fe658e0bd7..bca52d6693df1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -30,11 +30,15 @@ Capability probe và phạm vi các browser flag/policy được định nghĩa 1. Lấy source/filter từ nguồn được pin hoặc khai báo trong build input. `make-rulesets.js` giữ list đã tải trong `dist/build/mv3-data` để thử lại offline, nhưng chỉ dùng lại bản tải chưa quá 7 ngày; bản cũ hơn được tải lại để bản phát hành build muộn không âm thầm chứa list cũ. Đặt `UBLOCK_PLUS_LIST_CACHE_MAX_AGE_DAYS` (hoặc tham số `listCacheMaxAgeDays=`) để đổi giới hạn; `Infinity` dùng lại bộ input đã lưu khi cần build lại đúng input cũ. `log.txt` ghi thời điểm tải của mỗi list dùng lại. Checkout sạch (CI) không có cache nên không bị ảnh hưởng. 2. Parser chuẩn hóa filter, loại trùng và phân loại capability. -3. Compiler tạo static/dynamic DNR, cosmetic data và tham chiếu tới packaged scriptlet. +3. Compiler tạo static/dynamic DNR, cosmetic data và tham chiếu tới packaged scriptlet. Với Chromium và Edge (bước 2 của [lộ trình](MV3-LIMITS-RESEARCH-2026-09-24.md#lộ-trình-11-bước)): + - **Cổng RE2:** mọi regex sẽ nằm trong static ruleset phải qua `re2-portable.js` (tập con RE2 an toàn cho mọi Chrome từ 130, bắt buộc) rồi `isRegexSupported` của Chrome thật. `regex-verdicts.mjs` mở Chrome headless qua pipe, trong profile tạm mới và với một extension thử tạm, rồi chỉ đóng cây process do chính nó mở và xóa cả hai thư mục tạm. Build phát hành (`-Version`) dừng nếu không có Chrome (`regexVerdicts=required`); build development không có Chrome thì dùng verdict đã cache; regex không có verdict chỉ qua tập con portable, và log ghi rõ gói chưa được Chrome kiểm tra. Regex Chrome không chạy được bị đếm là `unsupported-regex-memory`/`unsupported-regex-syntax`. + - Regex hợp lệ được nối vào cuối `main/.json` của list theo thứ tự `rulesets.json`, miễn tổng của mọi static ruleset không vượt 1.000; phần vượt ở lại `rulesets/regex/.json` (đường dynamic). `rulesets/regex-details.json` ghi số regex static, phiên bản Chrome đã kiểm và digest. Hiện là 174 regex static với list mặc định, 473 với mọi list. + - `rulesets/strictblock/.json` là redirect `extensionPath` tới `/strictblock.html` ở priority 29, chỉ `main_frame`, giữ nguyên `requestDomains`, `urlFilter` hoặc `regexFilter` của filter. Chỉ filter vốn là regex mới cần regex rule (111 trong 1.106 rule ở list mặc định). + - Firefox và Safari giữ regex stock dynamic và strict-block `regexSubstitution`. 4. Build stock tạo corpus typed bất biến `rulesets/popup/.json`; compiler sandbox/imported tạo route generation-scoped. Condition subset hỗ trợ dùng `popup-observer-runtime`, condition chưa hỗ trợ giữ `popup-compiler-required` cùng reason cụ thể. Stock DNR export hiện chỉ bảo toàn kind `$popup`; `$popunder` được ghi `omitted`, không đổi nhãn. -5. Validator kiểm tra schema, ID range, quota, regex, resource reference, permission, route/condition consistency và remote-code policy. +5. Validator kiểm tra schema, ID range, quota, regex, resource reference, permission, route/condition consistency và remote-code policy. Với regex static: nằm sau mọi rule thường, qua tập con portable, tổng không quá 1.000, khớp `regex-details.json` (build phát hành phải có phiên bản Chrome đã kiểm). Với strict-block: đúng hình dạng redirect ở trên và `/strictblock.html` phải web-accessible. 6. Build report công bố số filter đầu vào, số rule đầu ra, route runtime/deferred, rule bị loại và lý do. -7. CI tạo artifact, checksum và provenance; phát hành chỉ từ commit/tag đã review. Workflow `release.yml` chạy khi push tag `v` (tạo pre-release) hoặc khi maintainer chạy tay cho một tag có sẵn, và có hai job. Job build (read-only, không secret) chạy `npm ci --ignore-scripts`, test, lint, build và validate hai gói; mỗi gói có `updater/` và `updater/package-files.json`. Job publish không cài dependency: nó kiểm tra artifact, ký gói khi đã công bố khóa, kiểm tra khóa của release trước chấp nhận gói mới, attest build provenance và đăng asset với tên cố định mà [trình cập nhật](AUTO-UPDATE.md) sử dụng. Workflow không bao giờ thay asset: asset đã có trên release phải giống hệt từng byte với bản vừa build, nếu không workflow dừng, nên chạy tay lại cho một release đã đăng sẽ thất bại (ZIP build lại không giống từng byte); phát hành dở thì dùng **Re-run failed jobs**. Cờ pre-release chỉ được đặt khi workflow tạo release và không bao giờ bị đổi trên release đã có: muốn release thành stable, bỏ cờ trên GitHub (`gh release edit v --prerelease=false`). +7. CI tạo artifact, checksum và provenance; phát hành chỉ từ commit/tag đã review. Workflow `release.yml` chạy khi push tag `v` (tạo pre-release) hoặc khi maintainer chạy tay cho một tag có sẵn, và có hai job. Job build (read-only, không secret) chạy `npm ci --ignore-scripts`, test, lint, build và validate hai gói, rồi nạp từng gói trong Chrome thật có sẵn trên runner (`tools/test-static-regex-chrome.mjs`, `tools/test-strictblock-chrome.mjs`, mỗi test một profile tạm và giới hạn thời gian riêng); mỗi gói có `updater/` và `updater/package-files.json`. Workflow `mv3-chromium.yml` chạy cùng hai test Chrome cho mỗi push và pull request. Job publish không cài dependency: nó kiểm tra artifact, ký gói khi đã công bố khóa, kiểm tra khóa của release trước chấp nhận gói mới, attest build provenance và đăng asset với tên cố định mà [trình cập nhật](AUTO-UPDATE.md) sử dụng. Workflow không bao giờ thay asset: asset đã có trên release phải giống hệt từng byte với bản vừa build, nếu không workflow dừng, nên chạy tay lại cho một release đã đăng sẽ thất bại (ZIP build lại không giống từng byte); phát hành dở thì dùng **Re-run failed jobs**. Cờ pre-release chỉ được đặt khi workflow tạo release và không bao giờ bị đổi trên release đã có: muốn release thành stable, bỏ cờ trên GitHub (`gh release edit v --prerelease=false`). ## Tối ưu và ghép rule @@ -45,6 +49,7 @@ Rule optimizer được phép: - shard ruleset theo nhóm ngôn ngữ/chủ đề để chỉ bật phần người dùng cần; - ưu tiên static ruleset cho dữ liệu đóng gói, dynamic rules cho lựa chọn/import của người dùng; - budget quota theo [Chrome DNR](https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest): static tách khỏi runtime; số rule dynamic/session có giới hạn riêng nhưng regex dynamic + session dùng chung pool tối đa 1.000, còn enabled static rulesets có aggregate pool 1.000 riêng, theo [Chromium CL 4903267](https://chromium.googlesource.com/chromium/src/+/eab7fc99e59b69e929d02e43bdcf8bbd75333869%5E%21/); +- đặt regex stock vào pool static thay vì pool dùng chung. Với list mặc định, pool static dùng 174/1.000 (473 với mọi list); pool dùng chung chỉ còn 111 regex strict-block của list stock (123), để khoảng 889 (877) cho Bộ lọc của tôi, list nhập thêm và rule DNR developer. `regex-capacity.js` đếm pool dùng chung theo chủ sở hữu, dựa trên dải ID trong `dnr-namespaces.js`; - hỏi quota khả dụng tại runtime và đưa ra lỗi có thể hành động thay vì cắt rule im lặng; - cập nhật rule theo namespace ID và giao dịch remove/add có thể phục hồi. @@ -59,6 +64,13 @@ Optimizer không được đổi thứ tự allow/block, làm rộng domain, b - Với `"incognito": "split"`, worker ẩn danh dùng chung `storage.local` nhưng không chung hàng đợi mutation trong bộ nhớ. Chỉ worker thường khôi phục journal, dọn generation staging/mồ côi và retry compiled filter; worker ẩn danh dùng chế độ lọc đã commit mà không rollback giao dịch chế độ lọc có thể đang chạy. Khi alarm `deferredJobs` đến, worker ẩn danh không lease, không đổi lịch và không chạy job lọc trong danh sách job dùng chung (cập nhật imported list, retry compiled filter); các job đó luôn do worker thường chạy. Worker ẩn danh chỉ dọn cache CSS của chính nó khi job `pruneCSSCache` đến hạn. Giới hạn còn lại: mutation do chính worker ẩn danh khởi tạo từ thao tác của người dùng (ví dụ trang extension mở trong cửa sổ ẩn danh) vẫn không được tuần tự hóa với worker thường, và việc ghi danh sách job từ hai worker vẫn là read-modify-write không nguyên tử (tệ nhất là một job chạy lặp lại). - Mọi lần cập nhật DNR của người dùng đều áp bản nháp DNR developer đã lưu nếu cài được. Chỉ thao tác lưu tường minh (editor, hoặc khôi phục backup qua `updateUserDnrRules`) mới từ chối bản nháp không cài được; khi đó rule cũ được giữ. Các cập nhật khác (compiled activation, rollback, recovery, khởi động) chỉ khi bản nháp không cài được mới dùng lại rule developer đã ghi trong `userDnrRules.applied` (rule đã cài ở lần thành công gần nhất) kèm cảnh báo, thay vì thất bại. Khi chưa có bản ghi này (profile nâng cấp từ bản cũ đang giữ bản nháp lỗi), lỗi vẫn được trả về để không đoán sai và làm mất exception. - Profile chưa từng đổi chế độ lọc được ghi chế độ mặc định vào `storage.local` một lần để content script đọc được; chế độ hiệu lực không đổi. Khi mất quyền host, site Optimal/Complete nằm dưới site cha đã tắt lọc được đưa về mức kế thừa thay vì ném lỗi guard dành cho thao tác người dùng. +- **Strict blocking:** + - Session rule ID 1–999.999 thuộc kế hoạch strict-block (`strictblock-rules.js`). Kế hoạch gồm redirect của list stock đang bật (priority 29), redirect cho `$doc`/`$document`/`$all` của Bộ lọc của tôi và list nhập thêm, và allow cho site được loại trừ. Redirect của người dùng nằm ở P+1 trên block dynamic của chính filter (P); block đó không bao giờ bị sửa và là dự phòng khi redirect không được cài. + - Kế hoạch được dựng lại trong hàng đợi DNR chung, bằng một lần `updateSessionRules`: sau mỗi lần cập nhật rule người dùng, khi đổi site loại trừ hay chế độ strict, khi quyền thay đổi, và khi worker khởi động mà kế hoạch đã lưu (`storage.session['strictBlock.plan']`) không còn khớp. Lần cập nhật thất bại giữ kế hoạch cũ. + - Không có quyền truy cập mọi trang web thì không có redirect nào, vì redirect thiếu quyền host che block bên dưới và để trang tải. Redirect của người dùng còn cần một nguồn URL chính xác, và chỉ có trên Chromium. + - Hết ngân sách session hoặc regex thì rule strict-block stock bị bỏ trước và được báo. + - `strictblock-tracker.js` ghi địa chỉ bị chặn của mỗi tab từ `webRequest` (`onBeforeRedirect`, và `onBeforeRequest` cho bước tới `strictblock.html` sau server redirect) hoặc `onRuleMatchedDebug` (không dùng ở low-memory profile), và lúc bắt đầu điều hướng từ `webNavigation.onBeforeNavigate` làm nguồn gần đúng. Tối đa 64 tab, trong bộ nhớ và `storage.session`, xóa khi trang đã đọc hoặc khi đóng tab. Listener được đăng ký đồng bộ ở top level; chi phí của chúng nằm trong [POWER-RUNTIME.md](POWER-RUNTIME.md#chi-phí-của-strict-block-cho-service-worker). + - `strictblock.html` hỏi địa chỉ bằng `getStrictBlockDetails`, message mà worker chỉ trả lời cho chính trang đó trong frame trên cùng của một tab. Trang rồi giữ địa chỉ trong fragment và `history.state` của entry lịch sử, và chỉ mở URL `http(s)`. - Content script ở `document_start` xử lý cosmetic filtering và gọi scriptlet đã đóng gói. - Offscreen document chỉ sống trong phiên compile cần DOM API; hoàn tất phải đóng. - Imported list được compile cục bộ thành DNR/cosmetic data; không `eval`, không import JavaScript từ URL. List hết hạn (`Expires`) được tải lại trong lần compile kế tiếp, và bản compile tốt gần nhất chỉ bị thay khi bản mới đã tải và compile xong. Tải/compile thất bại thì giữ bản cũ, không làm hỏng các lần compile khác, và lần thử sau lùi dần từ 1 giờ tới tối đa 1 ngày. Bản mới compile được nhưng làm generation không kích hoạt được (ví dụ vượt quota DNR) chỉ được dùng lại trong 1 giờ; sau đó bản đã kích hoạt gần nhất (giữ trong sidecar metadata cho tới khi bản mới commit) được khôi phục để filter cá nhân kích hoạt lại, và lần tải sau cũng lùi dần. Nguyên nhân lỗi gần nhất được lưu trong `refreshError` của list (đọc qua `getImportedLists`) cho tới khi cập nhật thành công. Nguồn pinned bằng SHA-256 là bất biến nên không tải lại theo thời hạn. @@ -116,7 +128,10 @@ Message API hiện có: `getMemoryProfile`, `setMemoryProfile`, `getMemoryTeleme Packaged catalog/filter data ──> compiler ──> static DNR + cosmetic cache User-imported HTTPS list ──────> validator/compiler ──> dynamic DNR └───────> typed popup data +Build machine ──stock regex──> Chrome headless (profile tạm, isRegexSupported) ──> verdict Page request ───────────────────────────────> Chrome DNR +Blocked document ──> Chrome DNR redirect ──> strictblock.html <── URL bị chặn ── tracker trong service worker + (webRequest / onRuleMatchedDebug / webNavigation) Popup/tab events ──> service worker observer ───────> stock + generation typed popup matcher DOM document ───────────────────────────────> packaged content scripts Service worker ──HTTPS GET release list──────> api.github.com (chỉ đọc metadata công khai) diff --git a/docs/FEATURE-MATRIX.md b/docs/FEATURE-MATRIX.md index e5fb28cf55a8f..7f09225e53ffe 100644 --- a/docs/FEATURE-MATRIX.md +++ b/docs/FEATURE-MATRIX.md @@ -10,10 +10,19 @@ Giới hạn nào gỡ được, bằng cách nào và giá bao nhiêu được Theo [Chrome DNR API](https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest), static ruleset được đóng gói và có ngân sách riêng với dynamic/session. Từ Chrome 120/121, giới hạn **số rule** dynamic và session được tách, nhưng [Chromium CL ngày 2023-10-18](https://chromium.googlesource.com/chromium/src/+/eab7fc99e59b69e929d02e43bdcf8bbd75333869%5E%21/) xác nhận quota **regex dynamic + session vẫn dùng chung một pool tối đa 1.000**; enabled static rulesets có aggregate pool tối đa 1.000 regex riêng. Con số cụ thể thay đổi theo browser/version, nên đây là mô hình budget chứ không phải bảo đảm mọi máy có cùng capacity. +Từ [bước 2 của lộ trình](MV3-LIMITS-RESEARCH-2026-09-24.md#pool-regex-sau-bước-2), bản Chromium dùng hai pool như sau (build 1.2.0, Chrome 153): + +| Pool | List mặc định | Mọi list | Ai dùng | +| --- | ---: | ---: | --- | +| Static, 1.000 | 174 | 473 | Regex stock, nằm trong static ruleset của từng list | +| Dynamic + session dùng chung, 1.000 | 111 đã dùng, **khoảng 889 trống** | 123 đã dùng, khoảng 877 trống | Strict-block của list stock (chỉ filter vốn là regex); phần trống dành cho Bộ lọc của tôi, list nhập thêm, rule DNR developer và strict-block của chúng | + +Trước bước 2, 174 regex stock nằm ở pool dùng chung cạnh 826 regex strict-block, nên pool đầy và list nhập thêm không còn chỗ cho regex. **Cổng RE2 lúc build:** mọi regex static phải qua tập con RE2 portable và `isRegexSupported` của Chrome thật (headless, profile tạm) trước khi được đóng gói, vì một regex sai cú pháp trong bất kỳ static ruleset nào làm Chrome từ chối nạp cả gói unpacked. Filter regex mà Chrome không chạy được (108 ở list mặc định, 165 ở mọi list) được đếm là không hỗ trợ. Build giữ tổng regex của mọi static ruleset không quá 1.000, validator kiểm lại, và CI nạp gói trong Chrome thật. Bảng **Dung lượng quy tắc regex** (Regex rule capacity) trong mục **Chẩn đoán** (Diagnostics) của dashboard hiện số thật của trình duyệt đang chạy; **Kiểm tra ngay** đếm regex static mà trình duyệt này bỏ qua (giới hạn bộ nhớ RE2 thay đổi theo phiên bản). Firefox giữ regex stock dạng dynamic. + | Capability | uBO MV2 | Power Edition MV3 | Tầng tùy chọn tương lai | Ghi chú trung thực | | --- | --- | --- | --- | --- | -| Static network blocking | Có | **Có**, packaged static DNR | Không cần | Chrome hiện cho khai báo tối đa 100 static ruleset, bật 50 và bảo đảm tối thiểu 30.000 static rules trên tập đang bật; phần vượt mức phụ thuộc `getAvailableStaticRuleCount()`. Filter stock không chuyển được sang DNR được đếm theo mã lý do trong `log.txt` và `ruleset-details.json` (`rules.rejected`, `rules.rejectedReasons`), và validator đối chiếu các số đó với gói. Bản chụp list ngày 23/9/2026 (build ngày 24/9): 1.213 filter trong 32/55 list, nhiều nhất là `$redirect-rule` (530) và entity/regex domain (337). Khoảng 1.155 trong số đó là filter DNR không biểu diễn được; 58 là filter không hợp lệ mà uBO cổ điển cũng từ chối (51 là cosmetic/scriptlet). Trước đó build ghi `Unsupported: 0` cho mọi list. | -| Custom/imported network lists | Có | **Có**, dynamic DNR trong subset hỗ trợ | Có thể bổ sung compiler native | Dynamic/session có rule-count budget riêng nhưng chia sẻ regex pool; filter không biểu diễn được sẽ được báo kèm mã lý do và số dòng, không cắt im lặng. Compiler runtime từ chối `$requestheader`, `@@…$uritransform` và ngoại lệ `@@…$redirect`/`$empty`/`$mp4` thay vì sinh rule rộng hơn; `$empty`/`$mp4` và bare `$removeparam` biên dịch như stock. Runtime vẫn từ chối nhiều filter hơn stock (hai compiler khác nhau). | +| Static network blocking | Có | **Có**, packaged static DNR | Không cần | Chrome hiện cho khai báo tối đa 100 static ruleset, bật 50 và bảo đảm tối thiểu 30.000 static rules trên tập đang bật; phần vượt mức phụ thuộc `getAvailableStaticRuleCount()`. Filter stock không chuyển được sang DNR được đếm theo mã lý do trong `log.txt` và `ruleset-details.json` (`rules.rejected`, `rules.rejectedReasons`), và validator đối chiếu các số đó với gói. Bản chụp list ngày 23/9/2026 (build ngày 24/9): 1.213 filter trong 32/55 list, nhiều nhất là `$redirect-rule` (530) và entity/regex domain (337). Khoảng 1.155 trong số đó là filter DNR không biểu diễn được; 58 là filter không hợp lệ mà uBO cổ điển cũng từ chối (51 là cosmetic/scriptlet). Trước đó build ghi `Unsupported: 0` cho mọi list. Từ bước 2, filter regex mà RE2 của Chrome không chạy được cũng được đếm (`unsupported-regex-memory` 160, `unsupported-regex-syntax` 5), kể cả 27 filter chỉ chặn document mà rule duy nhất là strict-block rule bị RE2 từ chối, nên tổng là 1.378 trong 36/55 list; regex hợp lệ nằm trong static ruleset của list, có hiệu lực ngay khi list được bật. | +| Custom/imported network lists | Có | **Có**, dynamic DNR trong subset hỗ trợ | Có thể bổ sung compiler native | Dynamic/session có rule-count budget riêng nhưng chia sẻ regex pool; filter không biểu diễn được sẽ được báo kèm mã lý do và số dòng, không cắt im lặng. Compiler runtime từ chối `$requestheader`, `@@…$uritransform` và ngoại lệ `@@…$redirect`/`$empty`/`$mp4` thay vì sinh rule rộng hơn; `$empty`/`$mp4` và bare `$removeparam` biên dịch như stock. Runtime vẫn từ chối nhiều filter hơn stock (hai compiler khác nhau). Regex dùng phần trống của pool dùng chung (khoảng 889 với list mặc định); khi pool đầy, chỉ regex rule không phải ngoại lệ của list nhập thêm bị bỏ, từ cuối, kèm cảnh báo, thay vì cả lần kích hoạt thất bại. Filter `$doc`/`$document`/`$all` có trang strict-block, xem dòng Strict blocking bên dưới. | | Per-site filtering mode | Có | **Có** | Không cần | Persist setting và sinh rule theo namespace. | | Dynamic firewall matrix | Có, quyết định runtime | **Một phần**, native network cells với block/allow/noop, bản tạm/lâu dài và [thử bản nháp](GITHUB-UPGRADES-2026-09-06.md) | Managed adapter có thể mở rộng | Chrome 145+ dùng topDomains; party theo trang cấp cao nhất và PSL. Trang mới có thể cần cập nhật bất đồng bộ. Chưa có full popup matrix, inline-script hoặc main-frame firewall. [Chi tiết](MV3-PARITY-IMPLEMENTATION-2026-09-06.md). | | Cosmetic filtering | Có | **Có** | Không cần | CSS/content script đăng ký theo site/ruleset. | @@ -21,6 +30,7 @@ Theo [Chrome DNR API](https://developer.chrome.com/docs/extensions/reference/api | Scriptlets | Có | **Một phần** | Không tải scriptlet qua companion | Chỉ scriptlet đóng gói/allowlist; cấm remote executable code. | | Element picker/zapper | Có | **Có** | Không cần | Filter tạo ra được lưu cục bộ. | | Strict/popup blocking | Có | **Có/Một phần** | Có thể bổ sung policy | Smart policy vẫn xử lý opener/target/gesture. Observer thực thi corpus stock `$popup` đóng gói và subset `$popup`/`$popunder` của sandbox/imported: URL/regex đã kiểm tra cùng include/exclude request, initiator và top domains. Stock DNR export chưa giữ kind `$popunder`, nên metadata ghi `omitted` thay vì giả lập. Condition như `domainType`, method, resource type hoặc response header được giữ ở typed route `popup-compiler-required` với status `deferred`; deferred allow còn tạo guard superset chỉ có quyền buộc fail-open, không được tự allow/block. Thiếu context hoặc hết work budget cũng phải fail open với compiled filter; khi mọi ngoại lệ đã bị loại trừ, Smart/Strict vẫn quyết định. | +| Strict blocking (trang cảnh báo khi document bị chặn) | Có | **Có** với list stock; **Một phần** với list nhập thêm và Bộ lọc của tôi | Không cần | Rule chặn document thành session redirect `extensionPath` tới `strictblock.html`: 1.106 rule với list mặc định, chỉ 111 trong đó là regex. Trang hỏi service worker địa chỉ bị chặn: chính xác qua `webRequest` (`onBeforeRedirect`/`onBeforeRequest`; quyền tùy chọn, luôn có trong Experimental) hoặc `onRuleMatchedDebug` (chỉ bản cài unpacked, không dùng ở low-memory profile), gần đúng qua lúc bắt đầu điều hướng (khi đó không có **Đừng cảnh báo tôi lần nữa**); không có nguồn nào thì trang nói vậy và khóa **Tiếp tục**. Địa chỉ được giữ trong fragment của trang cho Back/Forward, reload và tab nhân bản. Trang chỉ mở URL `http(s)` và nêu nguồn chặn. `$doc`/`$document`/`$all` của list nhập thêm và Bộ lọc của tôi chỉ có trang này khi strict blocking bật, extension có quyền truy cập mọi trang web và biết địa chỉ chính xác; nếu không, chúng chặn bằng trang lỗi của trình duyệt như trước. Filter chỉ có hostname (`\|\|host^`) của người dùng chưa được strict-block. Mất quyền truy cập mọi trang web thì mọi redirect bị gỡ (khi quyền đổi và mỗi lần worker khởi động), vì một redirect thiếu quyền host che block bên dưới và để trang tải (fail-open, đã đo). Firefox giữ redirect `regexSubstitution`; Safari không có strict blocking. | | Redirect resource | Có | **Một phần** | Không cần | Chỉ redirect tới resource đóng gói/được manifest cho phép. Mọi resource có sẵn của uBO đều được đóng gói và web-accessible, trừ `click2load.html` (cần trang click-to-load của uBO); cả hai compiler từ chối resource đó. Không có `$redirect-rule`: DNR không biểu diễn được “chỉ redirect khi request vốn bị chặn”. | | Request/response header rules | Có | **Một phần** | Managed mode có thể mở rộng | DNR `modifyHeaders` không tương đương mọi thao tác `webRequestBlocking`. `##^responseheader()` stock áp cho response của chính hostname trong filter, mọi loại tài nguyên, và tôn trọng ngoại lệ; ngoại lệ chỉ có hiệu lực trong cùng list. List nhập thêm và bộ lọc cá nhân chưa hỗ trợ `##^responseheader()`. | | Full live request logger | Có | **Một phần**, logger hợp nhất opt-in | **R&D** qua managed/native diagnostics | Quan sát network với quyền tùy chọn, native DNR, CSS/DOM và chẩn đoán scriptlet; 512 bản ghi, có search/export. Không thấy mọi request browser hoặc bảo đảm đầy đủ hiệu ứng MAIN world. | @@ -35,7 +45,7 @@ Theo [Chrome DNR API](https://developer.chrome.com/docs/extensions/reference/api ## Những điều sideload không thay đổi -- quota static/dynamic/session/regex do Chrome áp đặt; static không thể được “mượn” để tăng pool regex dynamic + session; +- quota static/dynamic/session/regex do Chrome áp đặt; static không thể được “mượn” để tăng pool regex dynamic + session. Plus+ chỉ có thể chuyển regex stock sang pool static (bước 2) để phần dùng chung trống cho filter của người dùng; - extension service worker thường bị dừng sau idle và có thể bị chấm dứt ngoài dự kiến; state quan trọng không được chỉ giữ trong global variables; - API DNR chỉ biểu diễn được một tập con semantics của engine MV2; - extension MV3 bình thường không được dùng blocking `webRequest`; Chrome hỗ trợ chính thức quyền này cho policy-installed. Gói thử nghiệm kiểm chứng tham số allowlist trên Chrome 152 và lại trên Chrome 153. Chromium coi tham số này là công cụ test, hiện thanh cảnh báo cờ không được hỗ trợ và có thể gỡ nó ở bản bất kỳ. Quyền đó không làm bản cài thành policy-installed: phản hồi Promise vẫn bị bỏ qua; @@ -68,10 +78,10 @@ Một ô “Có” chỉ được đánh dấu shipped trong release note khi c 4. permission và threat-model review; 5. tài liệu fallback khi quota/API không đủ. -Điều kiện 2 được kiểm tra tự động cho list stock. `tools/validate-mv3.mjs` từ chối gói khi số rule báo cáo khác số rule đóng gói, khi các số theo lý do không cộng đúng tổng filter bị từ chối, khi dòng `Unsupported` trong `log.txt` không khớp, hoặc, với build development, khi tổng đó khác số filter lỗi trong output debug của compiler. Còn ngoài phạm vi đếm, nên điều kiện 2 chưa đạt: +Điều kiện 2 được kiểm tra tự động cho list stock. `tools/validate-mv3.mjs` từ chối gói khi số rule báo cáo khác số rule đóng gói, khi các số theo lý do không cộng đúng tổng filter bị từ chối, khi dòng `Unsupported` trong `log.txt` không khớp, hoặc, với build development, khi tổng đó khác số filter lỗi trong output debug của compiler. Từ bước 2, validator còn từ chối gói khi một regex static nằm ngoài tập con RE2 portable, khi tổng regex static vượt 1.000, khi `regex-details.json` không khớp regex đã đóng gói (với `--release`: khi thiếu phiên bản Chrome đã kiểm), hoặc khi một rule strict-block không phải redirect `extensionPath` tới `/strictblock.html` ở priority 29 chỉ cho `main_frame`. Strict-block regex bị RE2 từ chối được đếm riêng trong `strictblockRejected`. Còn ngoài phạm vi đếm, nên điều kiện 2 chưa đạt: - hostname entity/regex bị phủ định trong `domain=`, `to=`, `denyallow=` (ví dụ `domain=~example.*`) bị bỏ im lặng, không cảnh báo, làm rule áp cả cho site lẽ ra được loại trừ (187 filter trong bản chụp ngày 23/9/2026); - hostname entity bị bỏ khi filter chỉ được chuyển một phần (cảnh báo trong `log.txt` chỉ có cho rule tĩnh không regex); - hai trường hợp `$urlskip`. -Tooltip của mỗi list stock trên dashboard hiện số filter đã chuyển và số filter bị từ chối. Số sau gồm cả filter không hợp lệ mà uBO cổ điển cũng từ chối, dù chuỗi hiển thị gọi đó là network filter không được hỗ trợ. Chưa có trang xem chi tiết theo lý do. +Tooltip của mỗi list stock trên dashboard hiện số filter đã chuyển và số filter bị từ chối. Số sau gồm cả filter không hợp lệ mà uBO cổ điển cũng từ chối, dù chuỗi hiển thị gọi đó là network filter không được hỗ trợ, và từ bước 2 gồm cả filter regex mà RE2 của Chrome không chạy được (+108 ở list mặc định, +165 ở mọi list). Số này tăng vì báo cáo trung thực hơn, không phải vì chặn ít hơn: các regex đó trước kia cũng bị bỏ, chỉ là bỏ im lặng lúc chạy. Chưa có trang xem chi tiết theo lý do. diff --git a/docs/MV3-LIMITS-RESEARCH-2026-09-24.md b/docs/MV3-LIMITS-RESEARCH-2026-09-24.md index 8e5db69d8ff1b..a8518781adeba 100644 --- a/docs/MV3-LIMITS-RESEARCH-2026-09-24.md +++ b/docs/MV3-LIMITS-RESEARCH-2026-09-24.md @@ -9,7 +9,8 @@ Câu hỏi: MV3 hạn chế nhiều tính năng của uBlock Origin gốc. Giớ - Trong Google Chrome **không thể gỡ mọi giới hạn**. MV2 đã bị tắt vô điều kiện. Blocking `webRequest` cần tham số khởi động hoặc cài bằng policy. Quyết định chặn bất đồng bộ (Promise) cần cài bằng policy. Chrome không có API lọc body dạng stream hay trả chuỗi CNAME cho extension. - Phần lớn khoảng trống thực tế **vẫn thu hẹp được**. Nhiều mục chỉ là lỗi compiler hoặc tính năng chưa làm, không phải giới hạn trình duyệt: báo cáo filter bị bỏ, `$empty`/`$mp4`, lỗi biên dịch `$requestheader` thành rule chặn vô điều kiện (bản thân option vẫn cần engine Experimental), `##^responseheader()`, remote font, CSP report, media lớn, strict-block không tốn regex. - Thứ tự đề xuất: sửa trong bản **Standard** trước (bước 1–5), rồi đưa engine mạng cổ điển vào bản **Experimental** (bước 6–7). Ai muốn gỡ hết giới hạn thì con đường trung thực là **bản Firefox** (bước 8). Cài bằng policy, companion CNAME và bản debugger chỉ là tầng tùy chọn, có ghi rõ rủi ro, không bao giờ âm thầm thay đổi hệ thống. -- **Bước 1 đã hoàn tất trong thay đổi này:** compiler báo phần lớn filter không chuyển được và các lỗi runtime/stock đã sửa. Những gì còn bỏ im lặng được liệt kê cuối phần [lộ trình](#lộ-trình-11-bước). +- **Bước 1 đã hoàn tất:** compiler báo phần lớn filter không chuyển được và các lỗi runtime/stock đã sửa. Những gì còn bỏ im lặng được liệt kê cuối phần [lộ trình](#lộ-trình-11-bước). +- **Bước 2 đã hoàn tất** (nhánh `feature/mv3-parity-upgrade`): strict-block dùng redirect `extensionPath` không cần regex và lấy đúng URL bị chặn từ sự kiện của trình duyệt; regex stock đã được Chrome kiểm tra lúc build nằm trong static ruleset. Với sáu list mặc định, pool regex dùng chung của dynamic + session còn **889/1.000** chỗ trống (trước đó 0) cho list nhập thêm và bộ lọc cá nhân. Xem [số đo](#pool-regex-sau-bước-2) và phần [đã giao](#lộ-trình-11-bước). ## Phương pháp @@ -18,6 +19,7 @@ Câu hỏi: MV3 hạn chế nhiều tính năng của uBlock Origin gốc. Giớ - **Node 24.15.0:** biên dịch 55 list stock đã cache (443.276 filter, trong đó 244.330 network filter), khôi phục engine `static-net-filtering.js` từ snapshot và đo so khớp trên 15.301 URL request thật thu từ 46 website công khai (1.326 host). - **Chạy lại cả hai compiler** (stock lúc build và runtime `ubo-parser.js`) trên cùng các list cache để đếm filter bị bỏ. - **Kiểm chứng độc lập:** năm lượt kiểm chứng chạy lại các kết luận quan trọng nhất. Khi số liệu khác nhau, tài liệu dùng số đã kiểm chứng; các chỗ sửa được ghi rõ. +- **Bước 2:** probe thiết kế trong `tmp/design/step2-regex-pool/` (đếm từng phương án strict-block, 13 trường hợp URL, priority giữa các namespace, quyền host bị giữ lại, chi phí `onRuleMatchedDebug`); build 1.2.0 có regex được Chrome 153 kiểm tra; hai test chạy gói đã build trong Chrome thật (`tools/test-static-regex-chrome.mjs`, `tools/test-strictblock-chrome.mjs`), nay chạy trong CI. - Máy đo: AMD Ryzen 5 5600H, 8 GB RAM. Hồ sơ thô nằm trong `tmp/research/` (không đưa vào Git): `plan.json` (bảng giới hạn, lộ trình), `step1-claims.json` (bằng chứng theo file:dòng), các thư mục probe `gap-inventory`, `platform-facts`, `engine`, `bodies`, `synth` và `verify-0` … `verify-4`. ## Kết luận @@ -108,10 +110,63 @@ Theo [declarative_net_request.webidl](https://github.com/chromium/chromium/blob/ | Static rule bị tắt bằng `updateStaticRules` | 5.000 | | Kích thước regex sau biên dịch | 2 KB; không lookaround, không backreference | -Với sáu list mặc định: Chrome từ chối **80/254** regex rule stock (79 `memoryLimitExceeded`, 1 `syntaxError`). 174 regex còn lại cài thành dynamic rule, cộng 826 session rule của strict-block là đúng **1.000**. 278 strict-block rule hợp lệ bị bỏ, và filter nhập thêm hay cá nhân không còn chỗ cho regex. +Trước bước 2, với sáu list mặc định: Chrome từ chối **80/254** regex rule stock (79 `memoryLimitExceeded`, 1 `syntaxError`). 174 regex còn lại cài thành dynamic rule, cộng 826 session rule của strict-block là đúng **1.000**. 278 strict-block rule hợp lệ bị bỏ, và filter nhập thêm hay cá nhân không còn chỗ cho regex. Bước 2 giải phóng pool này: xem [pool regex sau bước 2](#pool-regex-sau-bước-2). Probe pool regex: sau khi bật các static ruleset có tổng 902 regex, vẫn thêm được 1.000 dynamic regex. Với bản cài unpacked (cách Plus+ được phân phối), một regex sai cú pháp trong bất kỳ static ruleset nào, kể cả ruleset không bật, làm Chrome từ chối nạp cả extension. Ruleset làm tổng regex vượt 1.000 thì không được bật, và regex quá lớn bị bỏ qua mà không có cảnh báo. +### Pool regex sau bước 2 + +“Mặc định” là sáu list mặc định, “mọi list” là 55 list. Chrome 153.0.8010.53, profile tạm mới, server cục bộ. + +**Regex rule mà strict-block cần, theo từng phương án** (pool dùng chung 1.000): + +| Phương án | Mặc định / mọi list | Kết quả | +| --- | ---: | --- | +| A. `regexSubstitution` tới `/strictblock.html#\0` cho mọi rule (trước bước 2) | 1.132 / 1.622 | 1.104 regex hợp lệ với RE2 ở mặc định, nhưng chỉ 826 vừa chỗ cạnh 174 regex stock dynamic: 278 bị bỏ, pool hết chỗ | +| B. Redirect `extensionPath` giữ nguyên `requestDomains`, `urlFilter` hoặc `regexFilter` của filter | 139 / 154, trong đó **111 / 123** hợp lệ | **Được chọn.** Chỉ filter vốn là regex mới cần regex rule | +| B, lấy URL từ `webNavigation.onBeforeNavigate` | như B | URL sai sau server redirect, sau Back/Forward và trong tab nhân bản (lượt kiểm chứng `verify-3`) | +| C. Nhóm hostname giữ `regexSubstitution` | 145 / 203 | Không chính xác hơn B mà tốn thêm regex | +| D. Gói pattern `urlFilter` vào regex alternation, không dùng sự kiện | khoảng 708 ở mặc định | Loại: giới hạn 2 KB của RE2 chỉ cho trung vị 2, tối đa 4 pattern mỗi regex; 987 pattern mặc định cần 591 regex | + +**Kết quả sau bước 2** (build 1.2.0, regex được Chrome 153 kiểm tra): + +| Đại lượng | Mặc định | Mọi list | +| --- | ---: | ---: | +| Regex stock trong static ruleset (pool static 1.000) | **174** | **473** | +| Filter regex stock mà RE2 của Chrome từ chối, nay được đếm (`unsupported-regex-memory` / `unsupported-regex-syntax`) | 108 (106 / 2) | 165 (160 / 5) | +| Strict-block rule (redirect `extensionPath`, priority 29, chỉ `main_frame`) | 1.106 (trước đó 826) | 1.592 | +| … trong đó regex rule (pool dùng chung) | **111** | **123** | +| Strict-block regex mà RE2 từ chối (`strictblockRejected`) | 28 | 31 | +| Pool dùng chung còn trống cho list nhập thêm và bộ lọc cá nhân | **889** | **877** | + +Probe đếm 80/254 regex **rule** stock bị từ chối ở mặc định (132 và 2 trên 607 ở mọi list); build đếm theo **filter nguồn**, như mọi lý do khác. Build giữ tổng regex của mọi static ruleset đã khai báo không quá 1.000, nên mọi tổ hợp list bật đều vừa pool static; nếu list stock sau này vượt mức, regex của các list xếp sau trong `rulesets.json` quay lại đường dynamic. Test Chrome nạp gói unpacked trong khoảng 1 s với sáu list mặc định, Chrome chấp nhận cả 473 regex static qua `isRegexSupported`, bật được 50 list có nhiều regex nhất, và khi 111 regex strict-block của list mặc định đã cài, vẫn nhận thêm 889 regex dynamic. + +**URL chính xác.** Redirect `extensionPath` không mang URL, nên trang `strictblock.html` hỏi service worker, theo thứ tự ưu tiên: + +1. `webRequest.onBeforeRedirect` và `webRequest.onBeforeRequest`, chỉ `main_frame`, khi quyền `webRequest` tùy chọn đã được cấp (luôn có trong Experimental). Chính xác, rẻ nhất. Sau server redirect, Chrome không phát `onBeforeRedirect` cho bước redirect DNR tới `strictblock.html`, chỉ phát `onBeforeRequest` cho trang đó với cùng `requestId`; địa chỉ bị chặn là địa chỉ web trước đó của request. +2. `declarativeNetRequest.onRuleMatchedDebug`: Chrome chỉ gửi cho bản cài unpacked có `declarativeNetRequestFeedback`, đúng cách Plus+ được phân phối. Chính xác. Không dùng ở low-memory profile, vì nó giữ worker thức. +3. `webNavigation.onBeforeNavigate` của frame trên cùng: gần đúng, vì không thấy server redirect sau lúc bắt đầu điều hướng. Trang ghi rõ đây là địa chỉ gần đúng và không cho chọn **Đừng cảnh báo tôi lần nữa** trên host đoán ra. +4. Không có nguồn nào: trang nói không xác định được địa chỉ và khóa nút **Tiếp tục** (Proceed). + +Trang rồi giữ địa chỉ trong fragment và `history.state` của chính entry lịch sử (`history.replaceState`), nên Back/Forward, reload và tab nhân bản không cần sự kiện nào nữa. Probe thiết kế cho **13/13** trường hợp đúng với `onRuleMatchedDebug`: tab mới, gõ địa chỉ, liên kết, sau server redirect, Back, Forward, reload, tab nhân bản, worker đã dừng, popup `window.open`, hai điều hướng liên tiếp, bộ lọc cá nhân và bộ lọc cá nhân sau server redirect. Trong mọi trường hợp, kể cả worker đã dừng, sự kiện đã tới worker trước khi trang hỏi (chờ 3–8 ms); sự kiện đến 2–70 ms sau `performance.timeOrigin` của trang. `onBeforeRedirect` cho cùng URL; `redirectUrl` của nó dùng host GUID của `use_dynamic_url`, còn trang được commit với ID tĩnh của extension, nên popup vẫn nhận ra trang strict-block. `tools/test-strictblock-chrome.mjs` lặp lại 13 trường hợp trên gói đã build (rule stock của `ublock-badware` và filter `$doc` của Bộ lọc của tôi), cùng **Tiếp tục** và **Đừng cảnh báo tôi lần nữa về trang web này** (Don’t warn me again about this site) cho cả hai, với gói Standard (nguồn `onRuleMatchedDebug`) và gói Experimental (nguồn `webRequest`); trên Standard nó cấp thêm quyền `webRequest` tùy chọn và thử lại trực tiếp và sau server redirect, rồi thử low-memory profile. Sau server redirect, trang phải hiện địa chỉ trong vòng 1 s kể từ lúc bắt đầu điều hướng: đo được 94–135 ms với cả hai nguồn. + +**Priority giữa các namespace:** session redirect ở P+1 thắng dynamic block ở P; dynamic allow ở 30 thắng redirect; redirect bị loại bằng `excludedRequestDomains` cộng session allow ở P cho trang tải; redirect cùng priority với block thì thua. `excludedRequestDomains` thắng `requestDomains` ngay cả với cùng host, nên biểu diễn được **Tiếp tục**. + +**Quyền host bị giữ lại (fail-open):** khi quyền truy cập site đặt là “Khi nhấp” (On click), một redirect khớp mà không có quyền host sẽ che block có priority thấp hơn, và trang tải bình thường. Vì vậy redirect strict-block chỉ được cài khi có quyền truy cập mọi trang web; bộ redirect được dựng lại khi quyền thay đổi (`permissions.onAdded`/`onRemoved`) và mỗi lần worker khởi động. Block dynamic của filter `$doc` cá nhân không bao giờ bị sửa, nên vẫn chặn khi redirect bị gỡ. Còn lại: một khoảng vài mili giây sau khi quyền đổi, và policy doanh nghiệp `runtime_blocked_hosts` có thể giữ quyền trên một số host trong khi `` vẫn hiện là đã cấp (chưa xử lý). + +**Chi phí `onRuleMatchedDebug`** (listener nhận mọi rule khớp, không chỉ strict-block): + +| Phép đo | Không có listener | Có listener | +| --- | ---: | ---: | +| Probe thiết kế: tải trang có 300 ảnh bị chặn, n=5 xen kẽ | 72,4 ms | 87,0 ms (khoảng 49 µs mỗi rule khớp); khoảng 2,5 ms CPU worker mỗi trang | +| Test Chrome, gói đã build, hai lượt: tải cùng trang (trung bình 3 trang sau trang đầu, worker gắn profiler) | 125 / 127 ms | 151 / 139 ms | +| … thời gian JS của worker bận mỗi trang (lấy mẫu 100 µs) | 1,32 / 1,21 ms | 1,82 / 1,50 ms | +| Thời gian worker sống khi trang yêu cầu một ảnh bị chặn mỗi 2 s | dừng sau 30,1 s | sống suốt 40 s / 60 s | + +Listener giữ worker thức trong lúc duyệt web vì mỗi rule khớp là một sự kiện. Do đó listener chỉ được đăng ký khi có ít nhất một redirect strict-block và không có `webRequest`; khi quyền `webRequest` được cấp, listener `main_frame` rẻ hơn thay thế nó. Mục **Chẩn đoán** (Diagnostics) của dashboard có nút cấp quyền này. + +**Cổng RE2 lúc build.** Regex static sai cú pháp làm Chrome từ chối nạp cả gói unpacked, nên mọi regex static phải qua hai lớp: `re2-portable.js` (tập con RE2 an toàn, bắt buộc kể cả khi không có Chrome; từ chối cả 7 regex mà Chrome 153 báo `syntaxError` đã thử và không từ chối regex nào trong 596 regex Chrome chấp nhận), rồi `isRegexSupported` của Chrome thật, chạy headless trong profile tạm (915 lần kiểm tra mất 220 ms). Regex vượt giới hạn bộ nhớ không làm hỏng việc nạp gói, Chrome chỉ bỏ qua im lặng; giới hạn đó phụ thuộc phiên bản, nên chỉ nút **Kiểm tra ngay** (Check now) trong mục Chẩn đoán đếm được các regex mà trình duyệt đang chạy bỏ qua. + ### Báo cáo compiler stock sau bước 1 Trước thay đổi này, `log.txt` ghi `Unsupported: 0` cho cả 55 list, còn `ruleset-details.json` ghi `rejected: 0`. Chạy lại cùng compiler trên cùng list cho thấy 1.310 **rule entry** có `_error` bị bỏ im lặng. Nay đơn vị đếm là **filter nguồn**: một filter sinh nhiều entry chỉ được đếm một lần, theo lỗi đầu tiên của nó. @@ -138,9 +193,11 @@ Tổng này gồm hai loại. Khoảng **1.155** filter hợp lệ nhưng DNR kh Build đạt validator: 55 ruleset, 70.310 static DNR rule (cộng 607 regex rule đóng gói trong `rulesets/regex`, cài thành dynamic rule lúc chạy khi list được bật). Rule được sinh ra giữ nguyên, trừ các chỗ sửa có chủ ý: hai rule removeparam phủ định trong `adguard-spyware-url` bị bỏ, rule `##^responseheader()` trong `ublock-filters`, `irn-0` và `rus-0` được dựng lại, và hai rule removeparam `main_frame` quá rộng trong `ublock-filters` bị bỏ. +**Sau bước 2**, build còn đếm filter regex mà RE2 của Chrome không chạy được: `unsupported-regex-memory` 160 và `unsupported-regex-syntax` 5, nên tổng thành **1.378** filter trong 36/55 list (list mặc định: +108). Trong đó có 27 filter chỉ chặn document (24 ở list mặc định): rule duy nhất của chúng là strict-block rule mà RE2 từ chối, nên chúng không được áp dụng ở đâu cả. Trước đó các regex này bị runtime bỏ im lặng. Gói có 70.310 rule thường cộng 473 regex rule nằm ngay cuối `main/.json` (70.783 static rule); `rulesets/regex/` chỉ còn cho regex phải đi đường dynamic, và không có trong build hiện tại. + ## Bảng 41 giới hạn -Cột **Kết luận** giữ nguyên phán quyết của nghiên cứu: **Standard** (gỡ được trong bản DNR), **Experimental** (cần bản có blocking `webRequest`), **Một phần**, **Companion** (cần process native), **Trình duyệt khác**. “Bước 1” đánh dấu các dòng đã sửa trong thay đổi này. +Cột **Kết luận** giữ nguyên phán quyết của nghiên cứu: **Standard** (gỡ được trong bản DNR), **Experimental** (cần bản có blocking `webRequest`), **Một phần**, **Companion** (cần process native), **Trình duyệt khác**. “Bước 1” và “Bước 2” đánh dấu các dòng đã sửa ở bước đó của lộ trình. | # | Giới hạn | Hiện trạng | Kết luận | Cách gỡ và lưu ý | | ---: | --- | --- | --- | --- | @@ -148,8 +205,8 @@ Cột **Kết luận** giữ nguyên phán quyết của nghiên cứu: **Standa | 2 | Không có blocking `webRequest` | Standard: không. Experimental: có qua launcher, chỉ dùng cho firewall `block` | Experimental | Đặt engine cổ điển lên đường launcher (bước 6). Đã xác minh cancel, redirect tới `data:` và resource web-accessible, redirect `main_frame`, thêm/xóa response header, thêm request header | | 3 | Không có background page thường trực; không giữ được request | Standard: DNR luôn chạy. Experimental: Promise bị bỏ qua, worker ngủ sau khoảng 30 s | Một phần | Đăng ký listener đồng bộ; khôi phục engine từ IndexedDB (60–95 ms) hoặc dựng đồng bộ ở top level (Chrome giữ request, trễ khoảng 100–115 ms với list mặc định); luôn giữ DNR nền. Tránh top-level `await` (worker không khởi động) và import JSON module (khởi động lại liên tục) | | 4 | Hạn mức rule DNR | Xem [hằng số](#hằng-số-dnr-trên-chrome-153) | Experimental | Engine cổ điển không có hạn mức (đã đo 203.598 filter); thêm 9–16 MiB bộ nhớ worker. Standard không nâng được | -| 5 | Pool 1.000 regex dynamic + session đã đầy với list mặc định | 174 + 826 = 1.000; 278 strict-block rule bị bỏ | Standard | (a) Strict-block bằng redirect `extensionPath` tới `/strictblock.html`, lấy URL gốc từ `webNavigation.onBeforeNavigate`: không cần regex. (b) Chuyển regex stock hợp lệ sang static ruleset (pool riêng). Build phải kiểm cú pháp regex; runtime phải báo ruleset/regex không được bật. Trang strict-block chỉ hiện đúng URL với điều hướng `main_frame` trực tiếp. Sau server redirect nó hiện URL ban đầu của điều hướng (trang chuyển hướng) thay vì URL bị chặn; sau back/forward có thể hiện URL của một điều hướng khác; tab nhân bản (Duplicate) không có URL và hiện `null` | -| 6 | RE2: không lookaround/backreference, tối đa 2 KB | 80/254 regex mặc định bị từ chối | Experimental | Engine cổ điển dùng `RegExp` của JavaScript; chỉ có hiệu lực sau khi engine sẵn sàng | +| 5 | Pool 1.000 regex dynamic + session đã đầy với list mặc định | **Đã sửa (bước 2):** trước đó 174 + 826 = 1.000 và 278 strict-block rule bị bỏ. Nay regex stock nằm trong static ruleset (174/1.000 ở mặc định, 473 ở mọi list), strict-block chỉ dùng 111 regex (123), pool dùng chung còn 889 chỗ trống (877) | Standard | Theo [bảng phương án](#pool-regex-sau-bước-2): A (`regexSubstitution` mọi rule) cần 1.132 regex, B (redirect `extensionPath` giữ nguyên điều kiện URL) chỉ 111 regex hợp lệ, D (gói pattern vào regex) khoảng 708. Chọn B. URL bị chặn lấy chính xác từ `webRequest` (`onBeforeRedirect`, và `onBeforeRequest` sau server redirect) hoặc `onRuleMatchedDebug` rồi được giữ trong fragment của trang: đúng 13/13 trường hợp với mỗi nguồn, gồm server redirect, Back/Forward, tab nhân bản, reload và worker đã dừng. Đính chính: cách lấy URL từ `webNavigation.onBeforeNavigate` mà bản trước của dòng này đề xuất chỉ đúng với điều hướng trực tiếp (sai sau server redirect, sau Back/Forward, và tab nhân bản không có URL); nay nó chỉ là nguồn dự phòng, được ghi là gần đúng. Build kiểm mọi regex static bằng RE2 của Chrome; mục Chẩn đoán báo ruleset không được bật và regex mà trình duyệt bỏ qua. Redirect chỉ được cài khi có quyền truy cập mọi trang web ([fail-open khi thiếu quyền host](#pool-regex-sau-bước-2)) | +| 6 | RE2: không lookaround/backreference, tối đa 2 KB | 80/254 regex rule mặc định bị từ chối. **Bước 2:** được đếm và báo là `unsupported-regex-memory`/`unsupported-regex-syntax` (108 filter ở mặc định, 165 ở mọi list) thay vì bị runtime bỏ im lặng | Experimental | Engine cổ điển dùng `RegExp` của JavaScript; chỉ có hiệu lực sau khi engine sẵn sàng | | 7 | `$redirect-rule` (530 filter) | Không hỗ trợ; nay được đếm và báo | Experimental | Engine chỉ redirect khi request vốn bị chặn. Rule DNR block trùng request phải bị loại khỏi bản này, vì DNR block thắng redirect của `webRequest` | | 8 | Resource `$redirect=` cho filter nhập thêm/cá nhân (10/47 chưa đóng gói) | **Đã sửa (bước 1):** đóng gói và khai báo web-accessible 46 resource cùng alias. `click2load.html` bị cả hai compiler từ chối và ghi trong `rulesets/redirect-resources.json` | Standard | Build dừng nếu thiếu file resource. Filter `redirect=` trỏ resource không dùng được bị bỏ (uBO cổ điển vẫn chặn request đó) | | 9 | `$empty`, `$mp4` ở runtime | **Đã sửa (bước 1):** redirect tới `empty` và `noop-1s.mp4` như stock thay vì chặn mọi loại request | Standard | Ngoại lệ `@@…$empty/$mp4/$redirect` bị từ chối (`unsupported-redirect-exception`) | @@ -157,7 +214,7 @@ Cột **Kết luận** giữ nguyên phán quyết của nghiên cứu: **Standa | 11 | `$removeparam` regex, phủ định, không giá trị | **Bước 1:** bare `$removeparam` xóa cả query (`query: ''`); stock báo giá trị phủ định thay vì sinh rule không bao giờ khớp. Regex vẫn không hỗ trợ | Experimental | Engine xóa tham số bằng redirect; tránh vòng lặp redirect nếu DNR cũng có rule đó | | 12 | Ngoại lệ `$csp`/`$permissions` quá rộng | Ngoại lệ thành allow priority 1, hủy cả rule header/removeparam khác | Experimental | Standard (một phần): trừ domain của ngoại lệ lúc biên dịch. Experimental: chèn CSP chính xác ở `onHeadersReceived`, bỏ rule csp/permissions khỏi DNR | | 13 | `$header=` regex/phức tạp | Chỉ giá trị chính xác hoặc glob | Experimental | So khớp ở `onHeadersReceived` rồi hủy | -| 14 | `$uritransform` | Bị từ chối; `@@…$uritransform` ở runtime nay bị từ chối (bước 1) thay vì thành redirect | Experimental | Redirect trong `onBeforeRequest`, chỉ cho nguồn tin cậy. Standard có thể dùng `regexSubstitution` khi pool regex được giải phóng | +| 14 | `$uritransform` | Bị từ chối; `@@…$uritransform` ở runtime nay bị từ chối (bước 1) thay vì thành redirect | Experimental | Redirect trong `onBeforeRequest`, chỉ cho nguồn tin cậy. Standard có thể dùng `regexSubstitution`: bước 2 đã giải phóng pool regex, nhưng việc này cần chính sách nguồn tin cậy và thuộc bước 4 | | 15 | `$urlskip` | Chỉ list stock, dạng liên kết thủ công trên trang strict-block | Experimental | Tự chuyển hướng `main_frame` bằng `src/js/urlskip.js`, có kiểm tra độ tin cậy của nguồn | | 16 | `$strict1p`/`$strict3p` | Không hỗ trợ (DNR không có điều kiện hostname bằng nhau) | Experimental | Engine cổ điển | | 17 | Entity (`example.*`) và regex trong `domain=`, `to=`, `denyallow=` | 337 filter stock bị bỏ, có filter khác được “salvage” bằng cách bỏ phần không hỗ trợ. Hostname entity/regex bị phủ định (`domain=~example.*`) bị bỏ im lặng, không cảnh báo, không đếm, làm rule rộng hơn: 187 filter trong bản chụp này, ví dụ `/data/banner.json?$domain=~nintendo.*` (`kor-1`) thành rule chặn trên mọi site, kể cả site bị loại trừ | Experimental | Engine khớp chính xác. Standard (chưa thử): mở rộng `example.*` theo public suffix lúc build | @@ -166,8 +223,8 @@ Cột **Kết luận** giữ nguyên phán quyết của nghiên cứu: **Standa | 20 | `$inline-script`, `$inline-font`, ô firewall inline-script | Bị từ chối/bỏ | Standard | Chèn CSP bằng DNR `modifyHeaders` trên `main_frame`/`sub_frame`, giới hạn bằng `topDomains`; đã xác minh | | 21 | `$ghide`/`$ehide`/`$shide` ở runtime | Runtime từ chối (1.743 trong corpus) | Standard | Sinh generic-cosmetic exclusion như stock (bước 4) | | 22 | `$badfilter` nhắm rule stock | Chỉ dựng lại nhóm hostname chặn; phần khác hoãn | Standard | Áp mọi `$badfilter` stock-stock lúc build; `disableRuleIds` cho filter người dùng (tối đa 5.000) | -| 23 | Build stock bỏ rule im lặng | **Đã sửa (bước 1):** đếm theo lý do trong `log.txt`, `ruleset-details.json`, tổng lúc build; validator đối chiếu; tooltip của dashboard hiện số filter không chuyển được thành quy tắc Chrome | Standard | Vẫn chưa được đếm: hostname entity bị bỏ khỏi filter chuyển một phần, hostname entity/regex bị phủ định (dòng 17, bỏ im lặng) và hai trường hợp `$urlskip` | -| 24 | Strict-block cho `$doc` nhập thêm/cá nhân | Chặn DNR thường, không có nút tiếp tục | Standard | Như dòng 5, cộng allow session tạm để tiếp tục | +| 23 | Build stock bỏ rule im lặng | **Đã sửa (bước 1):** đếm theo lý do trong `log.txt`, `ruleset-details.json`, tổng lúc build; validator đối chiếu; tooltip của dashboard hiện số filter không chuyển được thành quy tắc Chrome. **Bước 2** thêm hai lý do RE2 (`unsupported-regex-memory`, `unsupported-regex-syntax`) và đếm strict-block regex bị từ chối trong `strictblockRejected` | Standard | Vẫn chưa được đếm: hostname entity bị bỏ khỏi filter chuyển một phần, hostname entity/regex bị phủ định (dòng 17, bỏ im lặng) và hai trường hợp `$urlskip` | +| 24 | Strict-block cho `$doc` nhập thêm/cá nhân | **Đã sửa (bước 2):** khi strict blocking bật, extension có quyền truy cập mọi trang web và biết URL chính xác, filter `$doc`/`$document`/`$all` của list nhập thêm và Bộ lọc của tôi hiện trang strict-block với **Tiếp tục** và **Đừng cảnh báo tôi lần nữa về trang web này** (Don’t warn me again about this site); trang nêu nguồn chặn. Ngoài các điều kiện đó (hoặc trên Firefox, Safari) chúng vẫn chặn như cũ, với trang lỗi của trình duyệt | Standard | Như dòng 5 (phương án B): mỗi filter thành session redirect `extensionPath` ở P+1 trên chính block của nó (P); block dynamic giữ nguyên làm dự phòng, allow cùng list vẫn thắng. Site được loại trừ nhận `excludedRequestDomains` trên redirect cộng một allow ở P để thắng block dự phòng. Chỉ filter vốn là regex mới tốn regex. Bản cài packed hoặc policy không có `webRequest` chỉ có URL gần đúng, nên filter của người dùng vẫn là block thường. Filter chỉ có hostname (`\|\|host^` không `$doc`) chưa được strict-block (bước 4) | | 25 | Generic cosmetic, ngoại lệ generic, hostname chỉ phủ định, `*##` trong list nhập thêm | Runtime bỏ | Standard | Dùng lại pipeline generic-cosmetic của stock, chèn qua `userScripts` | | 26 | Cosmetic/scriptlet runtime cần công tắc Allow User Scripts | Extension không tự bật được | Một phần | Phát hiện và hướng dẫn; đường stock vẫn chạy khi tắt. Trên Chrome 153 API xuất hiện trong worker đang chạy mà không cần reload. [userScripts](https://developer.chrome.com/docs/extensions/reference/api/userScripts) | | 27 | Scriptlet `trusted-*` từ list nhập thêm | Không bao giờ tin | Standard | Tin list có URL khớp đúng tiền tố tin cậy của build, như uBO | @@ -190,8 +247,8 @@ Cột **Kết luận** giữ nguyên phán quyết của nghiên cứu: **Standa | Bước | Nội dung | Công sức ước tính | Trạng thái | | ---: | --- | --- | --- | -| 1 | Báo cáo trung thực và sửa lỗi compiler (cả hai bản) | 3–5 ngày | **Đã xong trong thay đổi này**, xem bên dưới | -| 2 | Giải phóng pool regex ở Standard: strict-block không dùng regex, regex stock sang static ruleset | 5–8 ngày | Cần kiểm cú pháp regex lúc build và job CI nạp gói trong Chrome thật | +| 1 | Báo cáo trung thực và sửa lỗi compiler (cả hai bản) | 3–5 ngày | **Đã xong**, xem bên dưới | +| 2 | Giải phóng pool regex ở Standard: strict-block không dùng regex, regex stock sang static ruleset | 5–8 ngày | **Đã xong**, xem bên dưới: pool dùng chung còn 889/1.000 chỗ trống với list mặc định (877 với mọi list); regex static 174/1.000 (473); build kiểm regex bằng Chrome, CI nạp gói trong Chrome thật | | 3 | Công tắc theo site và option dựa trên CSP ở Standard: fonts, CSP report, scripting, media lớn, `$inline-script`/`$inline-font` | 6–10 ngày | Cần Chrome 145+ (`topDomains`) | | 4 | Một compiler cho stock, list nhập thêm và bộ lọc cá nhân | 10–15 ngày | Cần ghim phiên bản engine `src/js` và test đối chiếu hai đường | | 5 | Logger, picker và popup ngang uBO; `$ipaddress` cho `main_frame` | 7–10 ngày | Sau bước 4 | @@ -222,12 +279,43 @@ Cột **Kết luận** giữ nguyên phán quyết của nghiên cứu: **Standa - Ngoại lệ `@@…$redirect=` ở stock thành block priority 21, chặn cả khi không có filter nào khác chặn. - Tooltip chỉ hiện số tổng cho list stock, và số đó gồm cả filter không hợp lệ dù chuỗi hiển thị gọi là network filter không được hỗ trợ; chưa có trang xem theo lý do hay dòng nguồn. +**Bước 2 đã giao** (số liệu ở [pool regex sau bước 2](#pool-regex-sau-bước-2)): + +- **Build (Chromium, Edge):** + - Regex stock nằm cuối `main/.json` của mỗi list, trong giới hạn 1.000 cho mọi static ruleset cộng lại. Mọi regex static phải qua `re2-portable.js`, rồi `isRegexSupported` của Chrome thật do `regex-verdicts.mjs` chạy headless trong profile tạm. Build phát hành (`-Version`) bắt buộc có Chrome (`regexVerdicts=required`, tìm trong thư mục cài chuẩn hoặc `CHROME_PATH`); build development dùng Chrome nếu có, nếu không thì dùng verdict đã cache, rồi chỉ tập con portable. + - Regex Chrome không chạy được bị đếm như filter không hỗ trợ khác. + - `rulesets/regex-details.json` ghi số regex static, phiên bản Chrome đã kiểm và digest. + - `rulesets/strictblock/.json` là redirect `extensionPath` tới `/strictblock.html` ở priority 29, chỉ `main_frame`. + - `tools/validate-mv3.mjs` kiểm các điều trên, và CI nạp gói trong Chrome thật. +- **Runtime:** + - Kế hoạch session strict-block dùng ID 1–999.999, ngân sách session và regex còn lại sau các chủ sở hữu khác, và chỉ một lần `updateSessionRules`. + - Redirect cho `$doc` của list nhập thêm và Bộ lọc của tôi. + - Khi pool dùng chung đầy, chỉ regex rule không phải ngoại lệ của list nhập thêm bị bỏ, từ cuối, kèm cảnh báo; trước đó cả lần kích hoạt thất bại. + - Bộ redirect được đối chiếu lại khi quyền thay đổi và mỗi lần worker khởi động. + - Tắt strict blocking hay mất quyền host chỉ gỡ redirect, không xóa danh sách site đã chọn **Đừng cảnh báo tôi lần nữa về trang web này**. +- **Trang strict-block:** URL chính xác, gần đúng hoặc không có (khi đó nút **Tiếp tục** bị khóa); chỉ điều hướng tới URL `http(s)`; nêu nguồn chặn (tên list stock, “Danh sách đã nhập” hoặc “Bộ lọc của tôi”). Trang chỉ nhận chi tiết URL khi chính nó hỏi từ frame trên cùng của một tab. +- **Mục Chẩn đoán (Diagnostics):** bảng “Dung lượng quy tắc regex” (Regex rule capacity) với nút **Kiểm tra ngay**; dòng capability nêu cách trang strict-block biết địa chỉ; nút cấp quyền `webRequest` tùy chọn. Ước lượng regex của Filter Store dùng dung lượng còn lại thật thay vì 1.000. +- Compiled cache lên revision 5, nên list nhập thêm được biên dịch lại một lần để có redirect strict-block. +- Test: `test-strictblock-rules`, `test-dnr-namespaces`, `test-strictblock-tracker`, `test-regex-capacity`, `test-regex-capacity-ui`, `test-re2-portable`, `test-stock-regex-placement`, `test-validate-mv3-regex`, `test-sender-trust`, cùng hai test Chrome thật `test-static-regex-chrome` và `test-strictblock-chrome` (trong `tools/`). + +**Còn mở sau bước 2:** + +- `onRuleMatchedDebug` là API gỡ lỗi: Chrome chỉ gửi cho bản cài unpacked, có thể giới hạn hoặc gỡ nó, và nó giữ worker thức trong lúc duyệt web ([chi phí](#pool-regex-sau-bước-2)). Bản cài packed, policy, hoặc low-memory profile không có `webRequest` chỉ có URL gần đúng, và filter `$doc` của người dùng vẫn là block thường. Với URL gần đúng sau server redirect, **Tiếp tục** mở lại trang redirect và có thể quay về trang cảnh báo. +- Ngưỡng thời gian (sự kiện được coi là mới nếu đến không quá 100 ms trước `timeOrigin` của trang, chờ tối đa 1,5 s) đo trên một máy; máy chậm hơn có thể hiện ghi chú “gần đúng” hoặc “không có” thường hơn. +- Khoảng vài mili giây sau khi quyền host đổi, và `runtime_blocked_hosts` của policy doanh nghiệp, vẫn có thể để redirect che block (fail-open). +- 28 strict-block regex ở list mặc định (31 ở mọi list) mà RE2 từ chối không được cài; document khớp các filter đó không có trang strict-block, như trước khi runtime bỏ chúng. Với 24 filter (27 ở mọi list) chỉ chặn document, đó là rule duy nhất, nên các trang đó không bị chặn chút nào (như trước) và filter được đếm là không chuyển được. 160 filter regex stock vượt giới hạn bộ nhớ chưa được tách hay đơn giản hóa. +- Giới hạn bộ nhớ RE2 phụ thuộc phiên bản: regex static mà Chrome build chấp nhận có thể bị Chrome khác bỏ qua im lặng, chỉ thấy qua **Kiểm tra ngay**. Chrome tối thiểu (130) có thể phân tích cú pháp khác; named group bị loại vì lý do này. +- Allow loại trừ của Bộ lọc của tôi ở priority khoảng 1.000.010 cũng hủy các rule cấp trang có priority thấp hơn (chèn CSP, removeparam, xóa response header) trên document của chính site đó. Allow loại trừ `main_frame` ở 29 cho list stock vẫn được giữ, để **Tiếp tục** không kết thúc ở trang lỗi khi site còn khớp block `main_frame` thường (11 rule ở list mặc định, 18 ở mọi list); nó có cùng loại tác dụng phụ như trước. +- Filter chỉ có hostname (`||host^` không `$doc`) trong list nhập thêm và Bộ lọc của tôi chưa được strict-block như list stock (bước 4). `$uritransform` qua `regexSubstitution` cũng thuộc bước 4. +- Firefox và Safari không đổi: Firefox giữ strict-block `regexSubstitution` và regex stock dynamic, Safari không có strict blocking. +- Lúc trình duyệt khởi động, trước khi session rule được cài, strict-block stock vẫn chưa có hiệu lực (không đổi). + ## Lưu ý trung thực - **Tham số `--allowlisted-extension-id` là công cụ test.** Chromium kiểm tra nó bằng [`IsAllowlistedForTest`](https://github.com/chromium/chromium/blob/153.0.8010.53/extensions/common/features/simple_feature.cc#L735-L743); tham số nằm trong [danh sách cờ cảnh báo](https://github.com/chromium/chromium/blob/153.0.8010.53/chrome/browser/ui/startup/bad_flags_prompt.cc#L119-L121), nên Chrome hiện thanh cảnh báo mỗi phiên, và có thể bị gỡ ở bất kỳ bản phát hành nào. - Tham số mở **mọi** tính năng bị chặn theo allowlist hoặc vị trí cài cho ID đó, không chỉ `webRequestBlocking`: probe khai báo `dns` trong manifest cũng nhận được `chrome.dns`. Tham số không cấp quyền mà manifest không khai báo; gói Experimental không khai báo `dns`. Nó không biến bản cài thành policy-installed: `installType` vẫn là `development` và phản hồi Promise vẫn bị bỏ qua. - Chưa thử mở lại cùng một profile với tham số này qua nhiều lần khởi động, và chưa đo lần khởi động nguội của cả trình duyệt. Launcher hiện chỉ có cho Windows. -- Mọi số đo là của một bản Chrome và một máy, phần lớn với server cục bộ; không phải khảo sát website thực tế. Các kết luận Standard về strict-block, pool regex, `topDomains` và `Content-Length` được xác minh hai lần (probe `synth` và lượt kiểm chứng `verify-3`), trên cùng một máy. +- Mọi số đo là của một bản Chrome và một máy, phần lớn với server cục bộ; không phải khảo sát website thực tế. Các kết luận Standard về strict-block, pool regex, `topDomains` và `Content-Length` được xác minh hai lần (probe `synth` và lượt kiểm chứng `verify-3`), trên cùng một máy. Số đo của bước 2 (13 trường hợp URL, chi phí `onRuleMatchedDebug`, thời gian worker sống) cũng chỉ từ máy đó; hai lượt đo chi phí của test Chrome chênh nhau khá nhiều (+26 ms và +12 ms thời gian tải trang), nên chỉ nên đọc như độ lớn. - Công sức là ước tính của nhóm nghiên cứu, không phải cam kết. Bước 8 chưa được khảo sát chi tiết. - Số filter bị từ chối phụ thuộc bản chụp list ngày 23/9/2026 (build ngày 24/9, dùng lại list đã tải) và sẽ thay đổi khi list cập nhật. @@ -237,5 +325,7 @@ Cột **Kết luận** giữ nguyên phán quyết của nghiên cứu: **Standa - **no-large-media:** DNR biểu diễn được bằng hai rule `Content-Length`, không cần debugger hay proxy. Giới hạn: kích thước đã nén, response chunked không được xét, và ngưỡng đã thử là 50.000 byte chứ không phải đúng 51.200 byte của uBO. - **Remote font và CSP report qua `report-uri`:** là khoảng trống sản phẩm mà DNR chặn được, không phải giới hạn trình duyệt. Upload qua Reporting API (`report-to`) không hiện với DNR lẫn `webRequest` trong probe; chặn được hay không vẫn chưa rõ (ví dụ bằng cách xóa header `Reporting-Endpoints`, chưa thử). - **Đường debugger:** bổ sung các lưu ý Local Network Access, service worker của trang, tab mới và auto-attach ở [trên](#lưu-ý-về-đường-debugger). -- **Regex:** tài liệu ngày 6/9 nêu sáu ngoại lệ regex bị Chrome 152 từ chối; với list mặc định, Chrome 153 từ chối 80/254 regex rule stock. +- **Regex:** tài liệu ngày 6/9 nêu sáu ngoại lệ regex bị Chrome 152 từ chối; với list mặc định, Chrome 153 từ chối 80/254 regex rule stock. Từ bước 2, build không đóng gói các regex đó mà đếm chúng theo filter nguồn (108 ở list mặc định). Bản trước của mục này ghi 84: nó bỏ sót 24 filter chỉ chặn document mà rule strict-block duy nhất bị RE2 từ chối. +- **Nguồn `webRequest` của strict-block:** bản đầu của bước 2 chỉ nghe `webRequest.onBeforeRedirect`, và ghi là đúng 13/13 với gói Experimental. Thực tế sau server redirect Chrome không phát sự kiện đó cho bước redirect DNR: trang chờ 1,5 s rồi hiện địa chỉ bắt đầu điều hướng (trang redirect) như địa chỉ gần đúng. Nguồn `onRuleMatchedDebug` cũng chờ 1,5 s trong trường hợp đó, vì `onBeforeNavigate` của trang redirect tới sau và bị coi là điều hướng mới hơn. Cả hai đã được sửa (`onBeforeRequest` cùng `requestId`, và thời điểm lấy từ `timeStamp` của sự kiện) và test Chrome nay kiểm cả thời gian hiện trang. +- **Strict-block (dòng 5):** bản đầu của tài liệu này đề xuất lấy URL bị chặn từ `webNavigation.onBeforeNavigate`. Cách đó sai sau server redirect, sau Back/Forward và trong tab nhân bản; bước 2 dùng `webRequest`/`onRuleMatchedDebug` cộng fragment, còn `onBeforeNavigate` chỉ là nguồn gần đúng. Dòng 5 và 24 đã được sửa tại chỗ. - **Báo cáo compiler:** bảng tương thích từng coi “không có silent drop” là điều kiện phát hành trong khi build stock vẫn ghi `Unsupported: 0`. Nay số filter bị từ chối được báo và kiểm tra, nhưng điều kiện đó vẫn chưa đạt: hostname entity/regex bị phủ định vẫn bị bỏ im lặng (xem [việc còn mở](#lộ-trình-11-bước)). diff --git a/docs/POWER-RUNTIME.md b/docs/POWER-RUNTIME.md index 4752ea5a3be2e..f4b2990506260 100644 --- a/docs/POWER-RUNTIME.md +++ b/docs/POWER-RUNTIME.md @@ -26,6 +26,20 @@ Managed, native và custom browser là ba ranh giới quyền khác nhau. Một Do constant và implementation có thể khác giữa Chrome version hoặc Chromium fork, UI chỉ công bố giá trị runtime probe được; tài liệu này không hứa một tổng rule cố định trên mọi browser. +### Hai pool regex từ bước 2 + +| Pool | Chủ sở hữu | List mặc định | Mọi list | +| --- | --- | ---: | ---: | +| Static, 1.000 cho mọi static ruleset | Regex stock đóng gói trong `main/.json` | 174 | 473 | +| Dynamic + session dùng chung, 1.000 | Strict-block của list stock | 111 | 123 | +| | Bộ lọc của tôi, list nhập thêm, rule DNR developer, strict-block của chúng, và chủ sở hữu sau này khai báo trong `dnr-namespaces.js` | khoảng 889 còn trống | khoảng 877 còn trống | + +Số trên là của build 1.2.0 trên Chrome 153 ([số đo](MV3-LIMITS-RESEARCH-2026-09-24.md#pool-regex-sau-bước-2)). + +- **Build:** regex stock chỉ được đóng gói static sau khi qua tập con RE2 portable và `isRegexSupported` của Chrome thật. Build phát hành cần Chrome, chạy headless trong profile tạm (xem [threat model](THREAT-MODEL.md#chrome-headless-lúc-build)). Tổng regex của mọi static ruleset đã khai báo không vượt 1.000, nên mọi tổ hợp list bật đều vừa; phần vượt (hiện không có) quay lại đường dynamic. +- **Runtime:** `regex-capacity.js` đếm pool dùng chung theo chủ sở hữu, dựa trên dải rule ID trong `dnr-namespaces.js`. Kế hoạch session strict-block chỉ dùng phần mà dynamic rule và các chủ session khác để lại. Khi một lần cập nhật rule người dùng không vừa, chỉ regex rule không phải ngoại lệ của list nhập thêm bị bỏ, từ cuối, kèm cảnh báo. Nếu vẫn không vừa thì lần cập nhật thất bại và rule cũ được giữ. +- **Báo cáo:** message `getRegexCapacity` (chỉ trang của extension) trả số liệu cho bảng **Dung lượng quy tắc regex** (Regex rule capacity) trong mục **Chẩn đoán** (Diagnostics) của dashboard và cho ước lượng regex của Filter Store. Giá trị không biết được hiện là “chưa kiểm tra”, không bao giờ là 0. **Kiểm tra ngay** gọi `isRegexSupported` cho mọi regex static của list đang bật, vì Chrome bỏ qua im lặng regex static vượt giới hạn bộ nhớ của phiên bản đang chạy. + ## Service worker không phải background page Theo [Chrome extension service-worker lifecycle](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle), worker thường bị dừng sau khoảng 30 giây idle; một event/API call kéo dài khoảng 5 phút hoặc `fetch()` response quá khoảng 30 giây cũng có thể bị chấm dứt. Event mới có thể đánh thức worker, nhưng global variables cũ đã mất. @@ -38,6 +52,28 @@ Vì vậy Power Runtime: - làm update/migration idempotent, có rollback/cleanup khi worker bị dừng; - fail open nếu popup/popunder cần initiator hoặc original-opener context mà checkpoint không thể khôi phục đầy đủ. +### Chi phí của strict-block cho service worker + +Trang strict-block hỏi worker địa chỉ bị chặn (`strictblock-tracker.js`). Listener của tracker được đăng ký đồng bộ ở top level, nên sự kiện đánh thức được worker: + +- `webRequest.onBeforeRedirect` và `webRequest.onBeforeRequest` cho `main_frame`, khi quyền `webRequest` tùy chọn đã được cấp (luôn có trong Experimental). Chúng chỉ nhận request của document cấp cao nhất, nên là nguồn chính xác rẻ nhất. Sau server redirect, Chrome không phát `onBeforeRedirect` cho bước redirect tới `strictblock.html`, chỉ phát `onBeforeRequest` cho trang đó với cùng `requestId`; tracker nhớ địa chỉ web gần nhất của mỗi request (trong bộ nhớ, tối đa 64) để lấy địa chỉ bị chặn. Mục Chẩn đoán có nút cấp quyền này. +- `declarativeNetRequest.onRuleMatchedDebug`, chỉ khi không có `webRequest`, có ít nhất một redirect strict-block và không ở low-memory profile. Chrome gửi sự kiện này cho **mọi** rule khớp của bản cài unpacked, nên worker thức trong suốt lúc duyệt web. Strict blocking bật mặc định, nên đây là trường hợp thường gặp của bản Standard unpacked chưa cấp `webRequest` ở profile balanced. +- `webNavigation.onBeforeNavigate` (đã có sẵn cho popup blocker): nguồn gần đúng. + +Số đo trên Chrome 153, gói 1.2.0 unpacked (`tools/test-strictblock-chrome.mjs`, hai lượt; [chi tiết](MV3-LIMITS-RESEARCH-2026-09-24.md#pool-regex-sau-bước-2)): + +| Đại lượng | Không có `onRuleMatchedDebug` | Có | +| --- | ---: | ---: | +| Tải trang có 300 ảnh bị chặn | 125 / 127 ms | 151 / 139 ms | +| Thời gian JS của worker bận cho trang đó | 1,32 / 1,21 ms | 1,82 / 1,50 ms | +| Worker khi trang yêu cầu một ảnh bị chặn mỗi 2 s | dừng sau 30,1 s | không dừng trong suốt 40 s / 60 s đo | + +Probe thiết kế trước đó đo 72,4 và 87,0 ms cho cùng loại trang (khoảng 49 µs mỗi rule khớp, khoảng 2,5 ms CPU worker mỗi trang). Tracker chỉ giữ bản ghi cho tối đa 64 tab, trong bộ nhớ và `storage.session`, và xóa bản ghi ngay khi trang strict-block đã đọc nó. Nó gỡ listener `onRuleMatchedDebug` khi không còn redirect nào (strict blocking tắt hoặc không có quyền truy cập mọi trang web). + +Ở **low-memory profile** tracker không dùng `onRuleMatchedDebug`, để worker được nghỉ khi người dùng duyệt web: listener đăng ký ở top level (worker chưa biết profile lúc đó) bị gỡ ngay khi `start()` đọc được profile, và mỗi lần đổi profile. Nguồn khi ấy là lúc bắt đầu điều hướng (gần đúng): trang strict-block ghi rõ, không cho chọn **Đừng cảnh báo tôi lần nữa** trên host đoán ra, và filter `$doc` của Bộ lọc của tôi và list nhập thêm lại là block thường (trang lỗi của trình duyệt) vì kế hoạch session được dựng lại theo nguồn mới. Cấp quyền `webRequest` trong mục Chẩn đoán cho lại địa chỉ chính xác với chi phí chỉ một sự kiện mỗi request `main_frame`. + +Thời điểm: bản ghi được đánh dấu bằng `timeStamp` của chính sự kiện (`webRequest`, `webNavigation`); chỉ `onRuleMatchedDebug` không có nên dùng thời điểm worker nhận. Sau server redirect, `onBeforeNavigate` của địa chỉ bắt đầu tới worker sau sự kiện của địa chỉ bị chặn; nó bắt đầu trước nên không làm bản ghi cũ đi, và trang hiện địa chỉ sau khoảng 100–130 ms thay vì chờ hết 1,5 s (test Chrome kiểm dưới 1 s cho cả hai nguồn chính xác). + ## Compiled popup observer Compiler phân loại typed `$popup`/`$popunder` data trước khi lưu: @@ -67,7 +103,8 @@ Module `runtime-capabilities.js` thu thập: - `installType` thực tế (`development`, `admin`, `normal`, `sideload` hoặc `other`); - permission được khai báo **và** được browser cấp; - sự tồn tại của DNR, `webRequest`, `webNavigation`, tabs và offscreen; riêng `userScripts` được probe bằng lời gọi API thật để không báo nhầm khi người dùng tắt **Allow User Scripts**; -- quota DNR mà browser hiện tại công bố, gồm cả `getAvailableStaticRuleCount()`. +- quota DNR mà browser hiện tại công bố, gồm cả `getAvailableStaticRuleCount()`; +- `strictBlockUrlSource`: cách trang strict-block biết địa chỉ bị chặn, là `webrequest` hoặc `rule-match` (chính xác, do trình duyệt báo), `regex-substitution` (chính xác, nằm trong địa chỉ của trang; bản Firefox), `navigation-start` (gần đúng) hoặc `unavailable`. Worker báo nguồn mà nó thật sự đã đăng ký; mỗi nguồn chỉ được giữ khi trình duyệt vẫn cấp những gì nó cần, nếu không thì hạ xuống nguồn kế tiếp. `strictBlockUrlExact` chỉ đúng với ba nguồn chính xác. Mục Chẩn đoán hiện kết quả này trong dòng “Địa chỉ hiển thị trên trang chặn nghiêm ngặt” (Address shown on the strict-blocking page). Kết quả luôn giữ `activeNetworkEngine: "dnr"` cho tới khi một managed adapter hoàn chỉnh được đăng ký. Việc thấy `managed-webrequest` trong `eligibleNetworkEngines` chỉ có nghĩa môi trường đủ điều kiện; nó không âm thầm bật code chưa được kiểm thử. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index f86e066570e31..ff0622ce2551c 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -14,6 +14,18 @@ Logger capture is off by default. After the user selects a tab and starts captur Export is explicit and strips URL credentials, queries, fragments and diagnostic free text. URL paths can still contain personal identifiers, so review exported JSON before sharing. No export is uploaded automatically. +### Strict blocking + +When strict blocking sends a blocked page to the extension's warning page (Chromium builds), the page asks the extension's service worker which address was blocked, so that it can show it and offer **Proceed**. The service worker keeps: + +- the full address of the last blocked page of each tab, with the time it was blocked and the list that blocked it, until the warning page has read it. These records are kept in memory and in the browser's session storage (`storage.session`), so that a restarted service worker can still answer, for at most 64 tabs. A record is deleted as soon as the warning page has read it, or when its tab closes, and all of them are gone when the browser exits or the extension is reloaded or updated. The extension opens its session storage to its own content scripts, so a compromised web page process could read these addresses while they wait there; a stored record dated in the future is ignored; +- with the `webRequest` permission, the address of each tab's recent top-level requests, in memory only, for at most 64 requests: a redirect by the server hides the blocked address from the redirect event, so the service worker takes it from the request's previous address. An entry is deleted when its request reaches the warning page, when the permission is removed, or when newer requests replace it, and is lost when the service worker stops; +- the address where the last top-level navigation of each tab started, for at most 64 tabs, in memory only. It is used only when no exact source is available, and the page then says that the address is approximate and does not offer **Don't warn me again about this site**, since that address may belong to a site which redirected to the blocked one. It is deleted when the tab closes and is lost when the service worker stops. + +The warning page itself keeps the address in its own URL fragment and history state, as the page already did with the address in its URL before. The browser stores it with the tab's history like any page address. Hostnames for which you select **Proceed** are kept in session storage until the browser exits; hostnames for which you select **Don't warn me again about this site** are kept in local storage. Neither is included in backups. + +None of this is sent anywhere. Troubleshooting reports include only which address source is in use (for example `rule-match`) and regex-rule counts, never an address. + ## Network access Bundled filter lists are downloaded during source builds. The packaged Filter Store catalog is updated only through a reviewed uBlock Plus+ release. Users may add custom HTTPS catalog URLs; those catalogs and filter sources are fetched directly from the provider when the Filter Store is opened or an entry is enabled. Users should import only trusted lists and should treat `community` entries as unendorsed. @@ -79,15 +91,15 @@ To remove the updater's data for one extension folder, run `updater\install-upda ## Permissions - `declarativeNetRequest`: blocks, allows, redirects, or modifies requests using Chrome's declarative engine. -- `declarativeNetRequestFeedback`: native matched-rule diagnostics when Chrome and the installation type support it. Matches are observed only while the opt-in logger captures a tab, within its 512-record lifecycle; Developer mode keeps no separate matched-rule buffer. Nothing is transmitted by the project. -- Optional `webRequest` in the standard edition: requested only when the user clicks **Start capture** in the logger. It observes requests for explicitly captured tabs; it does not grant synchronous request blocking. Refusing permission leaves the other available diagnostic sources usable. Observers are removed when capture stops. -- Required `webRequest` and `webRequestBlocking` in the separate [Experimental WebRequest edition](EXPERIMENTAL-WEBREQUEST.md): evaluate saved firewall block rules synchronously when Chrome grants access. The supplementary listener keeps bounded tab context in memory, respects Off and skips uncertain contexts. Request logging remains opt-in. The launcher uses a separate local Chrome profile and does not edit enterprise policy, registry entries or the existing personal profile. It installs no native service and sends no telemetry. The stable manifest public key identifies the variant; it is not a signing secret. +- `declarativeNetRequestFeedback`: native matched-rule diagnostics when Chrome and the installation type support it. For the logger, matches are observed only while the opt-in logger captures a tab, within its 512-record lifecycle; Developer mode keeps no separate matched-rule buffer. Strict blocking also uses it (Chromium, unpacked installations only): while a strict-blocking redirect is installed, the optional `webRequest` permission is not granted and the low-memory profile is not in effect, the service worker listens to Chrome's matched-rule events to learn the exact blocked address. Chrome delivers an event for every matched rule; the extension keeps only top-level strict-blocking matches, as described in [Strict blocking](#strict-blocking). Nothing is transmitted by the project. +- Optional `webRequest` in the standard edition: requested only when the user clicks **Start capture** in the logger, or **Grant network-observation permission** in **Dashboard → Diagnostics**. The logger observes requests only for explicitly captured tabs, and its observers are removed when capture stops. While the permission is granted, strict blocking also listens to top-level requests and redirects and keeps only the addresses that end on its own warning page (see [Strict blocking](#strict-blocking)); this replaces the matched-rule events above and costs less. It does not grant synchronous request blocking. Refusing permission leaves the other available diagnostic sources usable, and the warning page then shows an exact address only on unpacked installations. +- Required `webRequest` and `webRequestBlocking` in the separate [Experimental WebRequest edition](EXPERIMENTAL-WEBREQUEST.md): evaluate saved firewall block rules synchronously when Chrome grants access. The supplementary listener keeps bounded tab context in memory, respects Off and skips uncertain contexts. Strict blocking uses the same top-level redirect listener as the standard edition does once the optional permission is granted. Request logging remains opt-in. The launcher uses a separate local Chrome profile and does not edit enterprise policy, registry entries or the existing personal profile. It installs no native service and sends no telemetry. The stable manifest public key identifies the variant; it is not a signing secret. - `scripting`, `activeTab`, and host access: apply cosmetic filtering, packaged scriptlets and user-invoked element tools. - `storage` and `unlimitedStorage`: persist settings and compiled filter data. - `alarms`: schedule filter-list maintenance and update checks without a permanent background page. - `offscreen`: compile supported imported/custom filter data outside the service worker when required. - `userScripts`: register packaged stock and supported user/imported scriptlets with shared exception data. Chrome requires the user to enable this capability separately. Filter sources remain data; no remotely hosted executable code is used. -- `webNavigation`: correlate newly created navigation targets and enumerate opener frames for the Smart Popup Blocker. Candidate URLs and trusted-gesture targets exist in memory for at most 30 seconds and 5 seconds respectively; service-worker recovery checkpoints retain only origin-level URLs, while the bounded session diagnostic log retains at most 100 hostname-only decisions. Paths, queries, fragments and credentials are not persisted. +- `webNavigation`: correlate newly created navigation targets and enumerate opener frames for the Smart Popup Blocker. Candidate URLs and trusted-gesture targets exist in memory for at most 30 seconds and 5 seconds respectively; service-worker recovery checkpoints retain only origin-level URLs, while the bounded session diagnostic log retains at most 100 hostname-only decisions. Paths, queries, fragments and credentials are not persisted. Strict blocking also keeps the start address of each tab's last top-level navigation in memory, as its approximate fallback ([Strict blocking](#strict-blocking)); it is not persisted either. - Optional `nativeMessaging`: only requested after the user clicks **Allow the updater** in **Settings → Updates**. It lets the extension ask the separately installed Windows updater to download, verify and install a newer release, or to restore the previous one. Only the protocol version, a command, a request ID and, for downloads and installs, a version number are sent (see [Update checks and the Windows updater](#update-checks-and-the-windows-updater)). Revoking it stops automatic installation; update checks continue unless turned off. - Optional `privacy`: only requested after the user clicks **Enable privacy controls…**. It can disable hyperlink auditing, network prediction, non-proxied WebRTC UDP and Privacy Sandbox advertising APIs. Disabling a control calls Chrome's `clear()` operation so the setting returns to Chrome/user policy. diff --git a/docs/README.vi.md b/docs/README.vi.md index fc36b670c5daf..4fea673c92eaa 100644 --- a/docs/README.vi.md +++ b/docs/README.vi.md @@ -322,7 +322,7 @@ Userscript có thể mở rộng xử lý DOM và JavaScript trong trang, nhưng | Viết lại nội dung phản hồi, kiểm tra DNS/CNAME, chặn chính xác theo kích thước phản hồi | Không có cách triển khai tương đương bằng các API MV3 công khai thông thường mà bản build này sử dụng. | | Cài đặt managed và engine bổ sung | Đã hỗ trợ một số cài đặt do quản trị viên cung cấp; adapter chặn managed và native companion phục vụ lọc vẫn thuộc nghiên cứu. Thành phần native duy nhất là trình cập nhật Windows tùy chọn, chỉ chạy sau khi bạn tự cài; không có thành phần native nào được cài âm thầm. | -Chrome có thể từ chối một regex ngoại lệ cho phép khi chương trình RE2 sau biên dịch vượt giới hạn của trình duyệt. Một số danh sách đóng gói có những biểu thức này, nên thay đổi lựa chọn danh sách có thể bị từ chối ngay cả khi số rule vẫn nằm trong quota. Tiện ích khôi phục cấu hình và quy tắc trước đó thay vì bỏ qua ngoại lệ. Xem [giới hạn regex native và cơ chế khôi phục](MV3-PARITY-IMPLEMENTATION-2026-09-06.md). +Chrome không chạy được regex mà engine RE2 của nó từ chối, ví dụ khi chương trình sau biên dịch vượt giới hạn bộ nhớ của trình duyệt. Trên Chromium, quá trình build kiểm tra mọi rule regex đóng gói bằng chính Chrome: rule nào Chrome chấp nhận được đóng gói vào ruleset tĩnh của từng danh sách, còn các rule khác, kể cả ngoại lệ, bị bỏ ra và được tính vào số bộ lọc danh sách không chuyển đổi được. Vì vậy thay đổi lựa chọn danh sách không còn bị từ chối vì lý do này. Rule regex của danh sách nhập vào và Bộ lọc của tôi dùng chung một giới hạn riêng 1.000 rule với chặn nghiêm ngặt và được kiểm tra khi cài: khi giới hạn đó đầy, rule regex chặn của danh sách nhập vào bị bỏ từ cuối danh sách kèm cảnh báo, còn một ngoại lệ hoặc một rule của Bộ lọc của tôi không cài được thì tiện ích giữ nguyên cấu hình và quy tắc trước đó. Bản Firefox giữ cách xử lý cũ cho danh sách đóng gói. Mục Chẩn đoán hiển thị cả hai giới hạn. Xem [giới hạn regex native và cơ chế khôi phục](MV3-PARITY-IMPLEMENTATION-2026-09-06.md) và [Power Runtime](POWER-RUNTIME.md). **Fail-open là lựa chọn có chủ đích.** Nếu quyết định xử lý popup cần ngữ cảnh còn thiếu, không thể biểu diễn ngoại lệ an toàn hoặc đã dùng hết hạn mức đối chiếu, quyết định liên quan sẽ được hoãn thay vì chặn gần đúng. Điều kiện cho phép chưa thể xử lý có thể buộc hoãn quyết định; chúng không được tạo ra quyết định cho phép/chặn gần đúng. Quy tắc hạn chế chưa được hỗ trợ không bị mở rộng bằng cách bỏ bớt điều kiện. Cách xử lý này giảm chặn nhầm, đồng thời có nghĩa là một số popup không mong muốn vẫn có thể lọt qua. diff --git a/docs/THREAT-MODEL.md b/docs/THREAT-MODEL.md index d8d3b70490eb0..c788056ac130b 100644 --- a/docs/THREAT-MODEL.md +++ b/docs/THREAT-MODEL.md @@ -25,6 +25,8 @@ Tài liệu này dùng cho Power Edition sideload-first và ba tầng tùy chọ 9. **Trình cập nhật ↔ thư mục extension và người dùng khác trên máy:** updater chạy với quyền của user hiện tại và chỉ thay thư mục đã đăng ký. Thư mục đó phải không bị người dùng khác sửa, và mọi file bị xóa phải thuộc gói đã cài hoặc backup. 10. **Browser popup/tab events ↔ compiled popup matcher:** event có thể đến thiếu frame/opener context hoặc sau service-worker restart; typed cache vẫn phải được validate trước khi match. 11. **Extension artifact ↔ custom browser build:** extension không được tin một API/patch chỉ vì browser tự xưng tương thích; build identity và capability phải probe/audit riêng. +12. **Build ↔ Chrome trên máy build:** từ bước 2, build Chromium mở Google Chrome headless để hỏi `isRegexSupported` cho mọi regex stock sẽ đóng gói static. Mô tả ở [bên dưới](#chrome-headless-lúc-build). +13. **Trang web ↔ trang strict-block:** URL bị chặn là dữ liệu của trang web (có thể chứa path, query, token). Trang strict-block chèn nó vào DOM dưới dạng text, không phải HTML, và chỉ tạo liên kết hay điều hướng tới URL `http(s)`. ## Adversaries và tình huống lỗi @@ -51,11 +53,14 @@ Local malware hoặc browser/OS đã bị chiếm hoàn toàn nằm ngoài khả | Catalog/source takeover | HTTPS, provenance/ownership review, release-pinned catalog, diff anomaly gate, quarantine/rollback | Nội dung hợp lệ về cú pháp nhưng gây false positive/allow tracking. | | Parser/regex/resource bomb | Size/time/count limit, streaming/chunked parse, abort, DNR validation; popup regex unbounded không neo đầu bị từ chối, glob/regex dùng cache và aggregate 8.000.000-step/4.096-filter/16-realm budget; regex hostname của scriptlet trong imported list được ước lượng tĩnh số bước khớp tối đa trên một hostname 253 ký tự (mọi quantifier và alternation, kể cả `?`/`{0,15}`, đều nhân số nhánh; điểm bắt đầu của regex không neo cũng vậy; lookbehind tính theo chiều ngược). Regex có thể backtrack theo hàm mũ (nhóm lặp mơ hồ, backreference), dài quá 256 ký tự hoặc vượt 2^18 bước, cùng các regex vượt ngân sách chung 256 regex/2^20 bước của mọi imported list, không bao giờ tới trang: scope dương bị bỏ, exception được nới thành regex literal bắt buộc hoặc `*` (fail-open, không mất exception) và được báo trong chẩn đoán scriptlet | List lớn hợp lệ vẫn có thể làm hết budget của một event: khi một ngoại lệ còn có thể khớp thì `defer`, còn không thì block compiled chưa xét bị bỏ qua và chỉ Smart/Strict quyết định. | | Broad allow/priority abuse | Risk class, semantic diff, two-reviewer gate cho allow/header/scriptlet, precedence tests | Sai sót logic reviewer. | -| DNR quota exhaustion | Static budget riêng; dynamic/session rule-count riêng nhưng regex pool dùng chung; dedupe/semantic-safe merge, quota preflight, atomic update và báo deferred/rejected | Quota khác nhau giữa browser/version; browser khác có thể từ chối regex đã qua preflight. | -| Service-worker eviction | Listener top-level, immutable generation, persist checkpoint/state, idempotent startup/migration, không giữ truth chỉ trong memory | Task đang chạy có thể bị ngắt và phải resume; checkpoint cố ý không giữ full browsing URL lâu dài. | +| DNR quota exhaustion | Static budget riêng; dynamic/session rule-count riêng nhưng regex pool dùng chung; dedupe/semantic-safe merge, quota preflight, atomic update và báo deferred/rejected. Từ bước 2: regex stock ở pool static (174/1.000 với list mặc định); pool dùng chung được đếm theo chủ sở hữu (`dnr-namespaces.js`, `regex-capacity.js`); khi đầy chỉ regex không phải ngoại lệ của list nhập thêm bị bỏ, từ cuối, kèm cảnh báo, rồi mới tới lỗi fatal giữ rule cũ; kế hoạch strict-block chỉ dùng phần còn lại và bỏ rule stock trước; mục **Chẩn đoán** (Diagnostics) của dashboard hiện số thật | Quota khác nhau giữa browser/version; browser khác có thể từ chối regex đã qua preflight. Strict-block tăng từ 826 lên khoảng 1.106 session rule với list mặc định, nên còn ít chỗ hơn trong 5.000 session rule cho firewall. | +| Static regex làm Chrome từ chối nạp gói unpacked | Một regex sai cú pháp trong bất kỳ static ruleset nào, kể cả ruleset không bật, làm cả extension không nạp được. Bốn lớp: tập con RE2 portable (`re2-portable.js`, bắt buộc; loại named group, backreference, lookaround, escape lạ và lặp đếm quá lớn); `isRegexSupported` của Chrome thật lúc build (bắt buộc với build phát hành); `tools/validate-mv3.mjs` kiểm lại tập con portable, tổng không quá 1.000 và digest của `regex-details.json`; CI nạp gói trong Chrome thật và bật 50 list có nhiều regex nhất | Chrome cũ hơn (tối thiểu 130) có thể phân tích khác Chrome dùng lúc build. Giới hạn bộ nhớ 2 KB phụ thuộc phiên bản: regex vượt giới hạn không làm hỏng việc nạp gói nhưng bị bỏ qua im lặng, chỉ thấy qua **Kiểm tra ngay** trong mục Chẩn đoán. Build development không có Chrome chỉ có verdict đã cache và tập con portable. | +| Redirect che block (fail-open khi thiếu quyền host) | Đo trên Chrome 153: khi quyền truy cập site là “Khi nhấp” (On click), một session redirect khớp mà không có quyền host sẽ che block có priority thấp hơn, và trang tải. Redirect strict-block chỉ được cài khi có quyền truy cập mọi trang web; bộ redirect được dựng lại khi `permissions.onAdded`/`onRemoved` và đối chiếu lại mỗi lần worker khởi động. Block dynamic của filter `$doc` người dùng không bao giờ bị sửa, nên vẫn chặn khi redirect bị gỡ. Redirect người dùng ở P+1 để allow cùng list vẫn thắng | Vài mili giây sau khi quyền đổi, trước khi bộ rule được dựng lại. Policy doanh nghiệp `runtime_blocked_hosts` có thể giữ quyền trên một số host trong khi `` vẫn hiện là đã cấp: chưa xử lý. Allow loại trừ của Bộ lọc của tôi (priority khoảng 1.000.010) cũng hủy rule cấp trang có priority thấp hơn (chèn CSP, removeparam, xóa response header) trên document của site được loại trừ; allow `main_frame` ở 29 của list stock có cùng loại tác dụng phụ như trước. | +| Phụ thuộc API gỡ lỗi `onRuleMatchedDebug` | Chrome chỉ gửi sự kiện này cho bản cài unpacked có `declarativeNetRequestFeedback`, và gửi cho **mọi** rule khớp. Listener chỉ được đăng ký khi có redirect strict-block, không có `webRequest` và không ở low-memory profile (ở profile đó worker phải được nghỉ; nguồn khi ấy là lúc bắt đầu điều hướng, gần đúng); khi quyền `webRequest` tùy chọn được cấp (nút trong mục Chẩn đoán, luôn có trong Experimental), listener `main_frame` rẻ hơn thay thế nó. Sau server redirect Chrome không phát `onBeforeRedirect` cho bước redirect DNR, chỉ phát `onBeforeRequest` cho `strictblock.html` với cùng `requestId`, nên listener nhớ địa chỉ web trước đó của mỗi request `main_frame` (chỉ trong bộ nhớ, tối đa 64). Không có nguồn chính xác thì trang ghi địa chỉ là gần đúng hoặc không có, và filter `$doc` của người dùng vẫn là block thường. Capability chỉ báo nguồn thật sự đã đăng ký | Chrome có thể giới hạn hoặc gỡ API này. Chi phí đo trên một máy (Chrome 153, trang có 300 ảnh bị chặn): probe thiết kế +14,6 ms tải trang (khoảng 49 µs mỗi rule khớp, khoảng 2,5 ms CPU worker); test Chrome +12 đến +26 ms tải trang và +0,3 đến +0,5 ms thời gian JS worker mỗi trang. Worker không dừng trong suốt 40 s / 60 s khi trang yêu cầu một ảnh bị chặn mỗi 2 s, so với dừng sau 30,1 s khi không có listener. Chi tiết: [POWER-RUNTIME.md](POWER-RUNTIME.md#chi-phí-của-strict-block-cho-service-worker). | +| Service-worker eviction | Listener top-level, immutable generation, persist checkpoint/state, idempotent startup/migration, không giữ truth chỉ trong memory | Task đang chạy có thể bị ngắt và phải resume; checkpoint cố ý không giữ full browsing URL lâu dài. Ngoại lệ có giới hạn: tracker strict-block giữ URL đầy đủ của document bị chặn gần nhất mỗi tab trong `storage.session` để worker khởi động lại vẫn trả lời được trang strict-block, cho tới khi trang đã đọc nó (tối đa 64 tab, xóa ngay khi trang đọc xong hoặc khi đóng tab, mất khi thoát trình duyệt; xem [PRIVACY.md](PRIVACY.md#strict-blocking)). `storage.session` mở cho content script (`TRUSTED_AND_UNTRUSTED_CONTEXTS`), nên một renderer bị chiếm quyền có thể đọc hoặc ghi bản ghi đang chờ; bản ghi có thời điểm trong tương lai bị bỏ qua, và trang chỉ mở URL `http(s)`. Địa chỉ gần đúng (lúc bắt đầu điều hướng) có thể là trang redirect đứng trước site bị chặn: trang không cho chọn “Đừng cảnh báo tôi lần nữa” trên host đoán ra đó. | | Popup false positive do thiếu context | Classifier giới hạn condition; initiator condition cần completeness bit; original-opener không khôi phục đủ thì popunder fail open; allow/block precedence và transition có regression test | Fail-open có thể bỏ sót popup độc hại, nhưng tránh broad block sai. | | Corrupt/stale compiled popup cache | Schema version, route code, condition classifier và generation pointer phải khớp; chỉ `popup-observer-runtime` được ra quyết định exact. `popup-compiler-required` block chỉ để chẩn đoán; allow được giữ tối thiểu như guard superset chỉ có thể trả `defer` | Logic validator/compiler cùng có bug hoặc storage bị browser làm hỏng. | -| Extension-page XSS/message spoof | Không dùng HTML không sanitize; schema validate; kiểm tra `sender.id`, frame/origin và action capability | Browser bug hoặc logic thiếu case. | +| Extension-page XSS/message spoof | Không dùng HTML không sanitize; schema validate; kiểm tra `sender.id`, frame/origin và action capability. Message được chia tầng người gửi (`tools/test-sender-trust.mjs`): trang của extension được nhận theo ID và tiền tố URL (trên Firefox, nơi `sender.origin` không có, cũng vậy, và content script không bao giờ được tin); `getRegexCapacity` chỉ cho trang của extension; `getStrictBlockDetails` chỉ cho `strictblock.html` trong frame trên cùng của một tab; user script chỉ gọi được các message trong allowlist. Trang strict-block chèn URL bị chặn dưới dạng text và chỉ tạo liên kết hay điều hướng tới URL `http(s)` | Browser bug hoặc logic thiếu case. | | Compromised dependency/CI | Lockfile + `npm ci`, pin action theo immutable commit, minimal permissions, dependency review, SBOM/provenance | Maintainer chủ động merge dependency độc hại. | | Artifact substitution | Reproducible build target; SHA-256 kèm tên asset; từ 1.2.0 workflow Release build, attest provenance và phát hành; workflow không bao giờ thay asset đã phát hành, asset đã có mà khác bản build mới thì workflow dừng; chữ ký release khi đã công bố khóa; hai người duyệt release | Lộ khóa ký: `retire` chỉ sửa file khóa trong repo, updater đã cài vẫn tin khóa lộ cho tới khi áp dụng một release đã ký mang bộ khóa generation cao hơn không còn khóa đó (xoay khóa ngắn qua hai release: thêm khóa mới và phát hành bản ký bằng cả hai khóa, rồi `retire` khóa lộ và phát hành tiếp), hoặc người dùng chạy lại `updater\install-updater.cmd` từ gói mới hơn; updater bỏ lỡ các release đó vẫn tin khóa lộ (xem [Trình cập nhật Windows](#trình-cập-nhật-windows) và [AUTO-UPDATE.md](AUTO-UPDATE.md#release-signing-keys-for-maintainers)); release trước 1.2.0 không có attestation; khi chưa công bố khóa, updater chỉ kiểm checksum, còn attestation provenance chỉ kiểm tra thủ công được (`gh attestation verify`). | | Enterprise overreach | Build riêng, admin docs, capability probe, audit log cục bộ, least privilege | Tổ chức quản lý có quyền cao theo policy của họ. | @@ -88,7 +93,21 @@ Release nên đáp ứng các gate sau trước khi coi là stable: 6. release credential chỉ có trong protected release environment, không có ở PR từ fork; 7. diễn tập rollback và giữ artifact last-known-good. -Hiện trạng từ 1.2.0: workflow `release.yml` build từ tag trong checkout sạch với action pin theo commit, chạy test, lint và validate trong job read-only, tạo SHA-256, ký khi đã công bố khóa, attest build provenance rồi mới phát hành (gate 3–5 một phần). Workflow chưa tạo SBOM, chưa chạy memory benchmark hay browser smoke test, và job publish chưa dùng GitHub environment riêng: secret ký chỉ được đọc trong job đó. Repository chưa có file CODEOWNERS; protected branch và review bắt buộc là cấu hình trên GitHub, không kiểm tra được từ mã nguồn. +Hiện trạng từ 1.2.0: workflow `release.yml` build từ tag trong checkout sạch với action pin theo commit, chạy test, lint và validate trong job read-only, tạo SHA-256, ký khi đã công bố khóa, attest build provenance rồi mới phát hành (gate 3–5 một phần). Từ bước 2, job build (và workflow `mv3-chromium.yml` cho mỗi push và pull request) còn nạp cả hai gói trong Chrome thật có sẵn trên runner Windows: kiểm regex static và strict-block (`tools/test-static-regex-chrome.mjs`, `tools/test-strictblock-chrome.mjs`). Workflow chưa tạo SBOM, chưa chạy memory benchmark, và job publish chưa dùng GitHub environment riêng: secret ký chỉ được đọc trong job đó. Repository chưa có file CODEOWNERS; protected branch và review bắt buộc là cấu hình trên GitHub, không kiểm tra được từ mã nguồn. + +### Chrome headless lúc build + +Từ bước 2, `make-rulesets.js` (qua `platform/mv3/regex-verdicts.mjs`) mở Google Chrome trên máy build để hỏi `declarativeNetRequest.isRegexSupported` cho mọi regex stock sẽ được đóng gói static, vì một regex sai cú pháp trong static ruleset làm Chrome từ chối nạp cả gói unpacked. Hai test Chrome của CI dùng cùng cách chạy (`tools/chrome-cdp-harness.mjs`). + +Kiểm soát: + +- Chrome luôn chạy headless với `--user-data-dir` là một thư mục tạm mới tạo dưới thư mục tạm của hệ điều hành, không bao giờ với profile của người dùng. Trên Windows, ngay cả `chrome.exe --version` cũng mở profile mặc định, nên không có lời gọi nào thiếu tham số này. Harness của test từ chối chạy nếu thư mục profile không phải thư mục rỗng nó vừa tạo. +- Điều khiển qua `--remote-debugging-pipe`, không mở cổng mạng. Extension thử là một thư mục tạm chỉ xin `declarativeNetRequest`. +- Chỉ đóng cây process do chính build mở (đóng qua CDP, rồi buộc dừng đúng PID đó sau thời gian chờ), xóa cả hai thư mục tạm, kể cả khi build dừng sớm. Mỗi lệnh gửi Chrome có giới hạn thời gian, và mỗi test Chrome có giới hạn thời gian cứng cho cả lần chạy. +- Không sửa registry, policy hay cài đặt hệ thống; không tải file thực thi. Chrome được tìm trong thư mục cài chuẩn hoặc qua `CHROME_PATH`. +- Verdict được cache trong `dist/build/mv3-data/regex-verdicts.json` và chỉ dùng khi không có Chrome (build development). Build phát hành bắt buộc hỏi Chrome thật (`regexVerdicts=required`). Dù có hay không có verdict, tập con RE2 portable vẫn bắt buộc, và `tools/validate-mv3.mjs` kiểm lại gói. + +Rủi ro còn lại: build tin binary Chrome có trên máy build (hoặc runner CI); một Chrome bị thay có thể trả verdict sai, nhưng tập con portable và validator vẫn chặn regex sai cú pháp đã biết. Một Chrome treo hoặc crash làm build thất bại sau thời gian chờ. Build phát hành nay cần Chrome trên máy build. ## Các tier tùy chọn diff --git a/platform/mv3/extension/_locales/de/messages.json b/platform/mv3/extension/_locales/de/messages.json index 0b61a9077f32a..749ba703a588d 100644 --- a/platform/mv3/extension/_locales/de/messages.json +++ b/platform/mv3/extension/_locales/de/messages.json @@ -575,6 +575,14 @@ "message": "Fortfahren", "description": "A button to navigate to the blocked page" }, + "strictblockPlusUrlUnavailable": { + "message": "uBlock Plus+ konnte nicht feststellen, welche Adresse blockiert wurde. Diese Seite kann sie daher nicht öffnen.", + "description": "Strict-block page: shown instead of the blocked address when the browser did not report it; Proceed and \"Don't warn\" are disabled" + }, + "strictblockPlusUrlApproximate": { + "message": "Dies ist die Adresse, bei der die Navigation begann. Die blockierte Seite kann eine Weiterleitung davon sein. In diesem Fall kann „Fortfahren“ wieder hierher führen, daher lassen sich die Warnungen für diese Seite nicht dauerhaft abschalten.", + "description": "Strict-block page: the shown address is where the navigation started, which may differ from the blocked page after a server redirect. Proceed may then lead back to the warning, and the permanent \"don't warn\" choice is disabled" + }, "zapperTipEnter": { "message": "Ein Element entfernen", "description": "Tooltip for the button used to enter zapper mode" @@ -919,6 +927,106 @@ "diagnosticsUnavailable": { "message": "Nicht verfügbar" }, + "diagnosticsStrictBlockAddress": { + "message": "Auf der Seite für striktes Blockieren angezeigte Adresse", + "description": "Dashboard > Diagnostics > Runtime capabilities: label of the row saying how the strict-blocking page learns the blocked address" + }, + "diagnosticsStrictBlockAddressExact": { + "message": "Genau, vom Browser gemeldet", + "description": "Dashboard > Diagnostics: the browser reports the exact blocked address (webRequest or rule-match events)" + }, + "diagnosticsStrictBlockAddressFragment": { + "message": "Genau, in der Seitenadresse mitgeführt", + "description": "Dashboard > Diagnostics: the redirect itself carries the exact blocked address in the strict-blocking page's own address (Firefox)" + }, + "diagnosticsStrictBlockAddressApproximate": { + "message": "Ungefähr: wo die Navigation begann", + "description": "Dashboard > Diagnostics: only the address where the navigation started is known" + }, + "diagnosticsStrictBlockAddressUnavailable": { + "message": "Nicht verfügbar", + "description": "Dashboard > Diagnostics: the strict-blocking page cannot learn the blocked address" + }, + "diagnosticsStrictBlockAddressGrantNote": { + "message": "Optional: Mit der Berechtigung zur Netzwerkbeobachtung meldet der Browser die genaue blockierte Adresse, wenn er eine Seite auf die Seite für striktes Blockieren umleitet. Das ist die sparsamste genaue Quelle. Nur diese Weiterleitungen werden aufbewahrt. Das Protokoll nutzt dieselbe Berechtigung und zeichnet erst auf, wenn Sie es starten.", + "description": "Dashboard > Diagnostics: explains the optional network-observation (webRequest) permission offered for the strict-blocking page" + }, + "diagnosticsStrictBlockAddressGrant": { + "message": "Berechtigung zur Netzwerkbeobachtung erteilen", + "description": "Dashboard > Diagnostics: button which asks the browser for the optional network-observation (webRequest) permission" + }, + "diagnosticsStrictBlockAddressGranted": { + "message": "Berechtigung zur Netzwerkbeobachtung erteilt.", + "description": "Dashboard > Diagnostics: status after the browser granted the optional network-observation permission" + }, + "diagnosticsRegexTitle": { + "message": "Kapazität für Regex-Regeln", + "description": "Dashboard > Diagnostics: title of the panel about the browser's regex rule limits" + }, + "diagnosticsRegexUsage": { + "message": "$1 von $2", + "description": "Dashboard > Diagnostics > Regex rule capacity: $1 = rules in use, $2 = limit" + }, + "diagnosticsRegexStatic": { + "message": "Integrierte Listen: verwendete Regex-Regeln", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules packaged in the enabled built-in lists, out of the browser's limit for packaged rulesets" + }, + "diagnosticsRegexVerifiedWith": { + "message": "Regex-Regeln beim Erstellen dieser Version geprüft mit", + "description": "Dashboard > Diagnostics > Regex rule capacity: label followed by a browser version such as \"Chrome/153.0.8010.53\"" + }, + "diagnosticsRegexNotVerified": { + "message": "Beim Erstellen dieser Version nicht geprüft", + "description": "Dashboard > Diagnostics > Regex rule capacity: this version was built without checking the regex rules in a browser" + }, + "diagnosticsRegexRejectedAtBuild": { + "message": "Integrierte Regex-Filter, die der Browser nicht ausführen kann", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex filters of the enabled built-in lists left out because the browser cannot run them" + }, + "diagnosticsRegexSkipped": { + "message": "Integrierte Regex-Regeln, die dieser Browser überspringt", + "description": "Dashboard > Diagnostics > Regex rule capacity: packaged regex rules this browser version silently skips, counted by \"Check now\"" + }, + "diagnosticsRegexNotChecked": { + "message": "Noch nicht geprüft", + "description": "Dashboard > Diagnostics > Regex rule capacity: \"Check now\" was not run for this version and browser" + }, + "diagnosticsRegexVerify": { + "message": "Jetzt prüfen", + "description": "Dashboard > Diagnostics > Regex rule capacity: button which checks every packaged regex rule of the enabled lists with this browser" + }, + "diagnosticsRegexShared": { + "message": "Gemeinsame Obergrenze (Ihre Filter, importierte Listen, striktes Blockieren): verwendete Regex-Regeln", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules in the browser's limit shared by dynamic and session rules" + }, + "diagnosticsRegexUser": { + "message": "Von „Meine Filter“ und importierten Listen verwendet", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by My filters and imported lists" + }, + "diagnosticsRegexStrictBlock": { + "message": "Vom strikten Blockieren verwendet", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by the strict-blocking page redirects" + }, + "diagnosticsRegexStockFallback": { + "message": "Von integrierten Listen verwendet (dynamische Regeln)", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by built-in lists installed as dynamic rules (shown only when not zero)" + }, + "diagnosticsRegexOther": { + "message": "Von anderen Funktionen verwendet", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by other features (shown only when not zero)" + }, + "diagnosticsRegexFree": { + "message": "Noch für Ihre Filter verfügbar", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules still free" + }, + "diagnosticsRegexDropped": { + "message": "Nicht installiert, weil die Obergrenze erreicht ist", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules which were not installed because the shared limit is full" + }, + "diagnosticsRulesetsNotEnabled": { + "message": "Ausgewählte Listen, die der Browser nicht aktiviert hat", + "description": "Dashboard > Diagnostics > Regex rule capacity: selected built-in lists which the browser did not enable" + }, "performanceDetailsTitle": { "message": "Wirksames Leistungsbudget" }, diff --git a/platform/mv3/extension/_locales/en/messages.json b/platform/mv3/extension/_locales/en/messages.json index 8892507cff766..eeaf620c36915 100644 --- a/platform/mv3/extension/_locales/en/messages.json +++ b/platform/mv3/extension/_locales/en/messages.json @@ -641,6 +641,14 @@ "message": "Proceed", "description": "A button to navigate to the blocked page" }, + "strictblockPlusUrlUnavailable": { + "message": "uBlock Plus+ could not determine which address was blocked, so this page cannot open it.", + "description": "Strict-block page: shown instead of the blocked address when the browser did not report it; Proceed and \"Don't warn\" are disabled" + }, + "strictblockPlusUrlApproximate": { + "message": "This is the address where the navigation started. The page that was blocked may be a redirect from it. If so, Proceed may bring you back here, so warnings for this site cannot be turned off permanently.", + "description": "Strict-block page: the shown address is where the navigation started, which may differ from the blocked page after a server redirect. Proceed may then lead back to the warning, and the permanent \"don't warn\" choice is disabled" + }, "zapperTipEnter": { "message": "Remove an element", "description": "Tooltip for the button used to enter zapper mode" @@ -985,6 +993,106 @@ "diagnosticsUnavailable": { "message": "Unavailable" }, + "diagnosticsStrictBlockAddress": { + "message": "Address shown on the strict-blocking page", + "description": "Dashboard > Diagnostics > Runtime capabilities: label of the row saying how the strict-blocking page learns the blocked address" + }, + "diagnosticsStrictBlockAddressExact": { + "message": "Exact, reported by the browser", + "description": "Dashboard > Diagnostics: the browser reports the exact blocked address (webRequest or rule-match events)" + }, + "diagnosticsStrictBlockAddressFragment": { + "message": "Exact, carried in the page address", + "description": "Dashboard > Diagnostics: the redirect itself carries the exact blocked address in the strict-blocking page's own address (Firefox)" + }, + "diagnosticsStrictBlockAddressApproximate": { + "message": "Approximate: where the navigation started", + "description": "Dashboard > Diagnostics: only the address where the navigation started is known" + }, + "diagnosticsStrictBlockAddressUnavailable": { + "message": "Not available", + "description": "Dashboard > Diagnostics: the strict-blocking page cannot learn the blocked address" + }, + "diagnosticsStrictBlockAddressGrantNote": { + "message": "Optional: with the network-observation permission, the browser reports the exact blocked address when it redirects a page to the strict-blocking page. This is the least costly exact source. Only those redirects are kept. The Logger uses the same permission and records only after you start it.", + "description": "Dashboard > Diagnostics: explains the optional network-observation (webRequest) permission offered for the strict-blocking page" + }, + "diagnosticsStrictBlockAddressGrant": { + "message": "Grant network-observation permission", + "description": "Dashboard > Diagnostics: button which asks the browser for the optional network-observation (webRequest) permission" + }, + "diagnosticsStrictBlockAddressGranted": { + "message": "Network-observation permission granted.", + "description": "Dashboard > Diagnostics: status after the browser granted the optional network-observation permission" + }, + "diagnosticsRegexTitle": { + "message": "Regex rule capacity", + "description": "Dashboard > Diagnostics: title of the panel about the browser's regex rule limits" + }, + "diagnosticsRegexUsage": { + "message": "$1 of $2", + "description": "Dashboard > Diagnostics > Regex rule capacity: $1 = rules in use, $2 = limit" + }, + "diagnosticsRegexStatic": { + "message": "Built-in lists: regex rules in use", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules packaged in the enabled built-in lists, out of the browser's limit for packaged rulesets" + }, + "diagnosticsRegexVerifiedWith": { + "message": "Regex rules checked when this version was built with", + "description": "Dashboard > Diagnostics > Regex rule capacity: label followed by a browser version such as \"Chrome/153.0.8010.53\"" + }, + "diagnosticsRegexNotVerified": { + "message": "Not checked when this version was built", + "description": "Dashboard > Diagnostics > Regex rule capacity: this version was built without checking the regex rules in a browser" + }, + "diagnosticsRegexRejectedAtBuild": { + "message": "Built-in regex filters the browser cannot run", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex filters of the enabled built-in lists left out because the browser cannot run them" + }, + "diagnosticsRegexSkipped": { + "message": "Built-in regex rules this browser skips", + "description": "Dashboard > Diagnostics > Regex rule capacity: packaged regex rules this browser version silently skips, counted by \"Check now\"" + }, + "diagnosticsRegexNotChecked": { + "message": "Not checked yet", + "description": "Dashboard > Diagnostics > Regex rule capacity: \"Check now\" was not run for this version and browser" + }, + "diagnosticsRegexVerify": { + "message": "Check now", + "description": "Dashboard > Diagnostics > Regex rule capacity: button which checks every packaged regex rule of the enabled lists with this browser" + }, + "diagnosticsRegexShared": { + "message": "Shared limit (your filters, imported lists, strict blocking): regex rules in use", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules in the browser's limit shared by dynamic and session rules" + }, + "diagnosticsRegexUser": { + "message": "Used by My filters and imported lists", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by My filters and imported lists" + }, + "diagnosticsRegexStrictBlock": { + "message": "Used by strict blocking", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by the strict-blocking page redirects" + }, + "diagnosticsRegexStockFallback": { + "message": "Used by built-in lists (dynamic rules)", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by built-in lists installed as dynamic rules (shown only when not zero)" + }, + "diagnosticsRegexOther": { + "message": "Used by other features", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by other features (shown only when not zero)" + }, + "diagnosticsRegexFree": { + "message": "Still available for your filters", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules still free" + }, + "diagnosticsRegexDropped": { + "message": "Not installed because the limit is full", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules which were not installed because the shared limit is full" + }, + "diagnosticsRulesetsNotEnabled": { + "message": "Selected lists the browser did not enable", + "description": "Dashboard > Diagnostics > Regex rule capacity: selected built-in lists which the browser did not enable" + }, "performanceDetailsTitle": { "message": "Effective performance budget" }, diff --git a/platform/mv3/extension/_locales/es/messages.json b/platform/mv3/extension/_locales/es/messages.json index 610dea0a8e6d8..a6a6f6cdb470e 100644 --- a/platform/mv3/extension/_locales/es/messages.json +++ b/platform/mv3/extension/_locales/es/messages.json @@ -575,6 +575,14 @@ "message": "Continuar", "description": "A button to navigate to the blocked page" }, + "strictblockPlusUrlUnavailable": { + "message": "uBlock Plus+ no pudo determinar qué dirección se bloqueó, así que esta página no puede abrirla.", + "description": "Strict-block page: shown instead of the blocked address when the browser did not report it; Proceed and \"Don't warn\" are disabled" + }, + "strictblockPlusUrlApproximate": { + "message": "Esta es la dirección donde empezó la navegación. La página bloqueada puede ser una redirección desde ella. En ese caso, Continuar puede traerte de vuelta aquí, por lo que las advertencias sobre este sitio no se pueden desactivar de forma permanente.", + "description": "Strict-block page: the shown address is where the navigation started, which may differ from the blocked page after a server redirect. Proceed may then lead back to the warning, and the permanent \"don't warn\" choice is disabled" + }, "zapperTipEnter": { "message": "Eliminar un elemento", "description": "Tooltip for the button used to enter zapper mode" @@ -919,6 +927,106 @@ "diagnosticsUnavailable": { "message": "No disponible" }, + "diagnosticsStrictBlockAddress": { + "message": "Dirección mostrada en la página de bloqueo estricto", + "description": "Dashboard > Diagnostics > Runtime capabilities: label of the row saying how the strict-blocking page learns the blocked address" + }, + "diagnosticsStrictBlockAddressExact": { + "message": "Exacta, informada por el navegador", + "description": "Dashboard > Diagnostics: the browser reports the exact blocked address (webRequest or rule-match events)" + }, + "diagnosticsStrictBlockAddressFragment": { + "message": "Exacta, conservada en la dirección de la página", + "description": "Dashboard > Diagnostics: the redirect itself carries the exact blocked address in the strict-blocking page's own address (Firefox)" + }, + "diagnosticsStrictBlockAddressApproximate": { + "message": "Aproximada: donde empezó la navegación", + "description": "Dashboard > Diagnostics: only the address where the navigation started is known" + }, + "diagnosticsStrictBlockAddressUnavailable": { + "message": "No disponible", + "description": "Dashboard > Diagnostics: the strict-blocking page cannot learn the blocked address" + }, + "diagnosticsStrictBlockAddressGrantNote": { + "message": "Opcional: con el permiso de observación de red, el navegador informa de la dirección bloqueada exacta cuando redirige una página a la página de bloqueo estricto. Es la fuente exacta que menos recursos consume. Solo se conservan esas redirecciones. El registro usa el mismo permiso y solo registra después de que lo inicies.", + "description": "Dashboard > Diagnostics: explains the optional network-observation (webRequest) permission offered for the strict-blocking page" + }, + "diagnosticsStrictBlockAddressGrant": { + "message": "Conceder el permiso de observación de red", + "description": "Dashboard > Diagnostics: button which asks the browser for the optional network-observation (webRequest) permission" + }, + "diagnosticsStrictBlockAddressGranted": { + "message": "Permiso de observación de red concedido.", + "description": "Dashboard > Diagnostics: status after the browser granted the optional network-observation permission" + }, + "diagnosticsRegexTitle": { + "message": "Capacidad de reglas de expresiones regulares", + "description": "Dashboard > Diagnostics: title of the panel about the browser's regex rule limits" + }, + "diagnosticsRegexUsage": { + "message": "$1 de $2", + "description": "Dashboard > Diagnostics > Regex rule capacity: $1 = rules in use, $2 = limit" + }, + "diagnosticsRegexStatic": { + "message": "Listas integradas: reglas de expresiones regulares en uso", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules packaged in the enabled built-in lists, out of the browser's limit for packaged rulesets" + }, + "diagnosticsRegexVerifiedWith": { + "message": "Reglas de expresiones regulares comprobadas al compilar esta versión con", + "description": "Dashboard > Diagnostics > Regex rule capacity: label followed by a browser version such as \"Chrome/153.0.8010.53\"" + }, + "diagnosticsRegexNotVerified": { + "message": "No se comprobaron al compilar esta versión", + "description": "Dashboard > Diagnostics > Regex rule capacity: this version was built without checking the regex rules in a browser" + }, + "diagnosticsRegexRejectedAtBuild": { + "message": "Filtros de expresiones regulares integrados que el navegador no puede ejecutar", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex filters of the enabled built-in lists left out because the browser cannot run them" + }, + "diagnosticsRegexSkipped": { + "message": "Reglas de expresiones regulares integradas que este navegador omite", + "description": "Dashboard > Diagnostics > Regex rule capacity: packaged regex rules this browser version silently skips, counted by \"Check now\"" + }, + "diagnosticsRegexNotChecked": { + "message": "Aún no comprobado", + "description": "Dashboard > Diagnostics > Regex rule capacity: \"Check now\" was not run for this version and browser" + }, + "diagnosticsRegexVerify": { + "message": "Comprobar ahora", + "description": "Dashboard > Diagnostics > Regex rule capacity: button which checks every packaged regex rule of the enabled lists with this browser" + }, + "diagnosticsRegexShared": { + "message": "Límite compartido (tus filtros, listas importadas, bloqueo estricto): reglas de expresiones regulares en uso", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules in the browser's limit shared by dynamic and session rules" + }, + "diagnosticsRegexUser": { + "message": "Usadas por Mis filtros y las listas importadas", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by My filters and imported lists" + }, + "diagnosticsRegexStrictBlock": { + "message": "Usadas por el bloqueo estricto", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by the strict-blocking page redirects" + }, + "diagnosticsRegexStockFallback": { + "message": "Usadas por listas integradas (reglas dinámicas)", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by built-in lists installed as dynamic rules (shown only when not zero)" + }, + "diagnosticsRegexOther": { + "message": "Usadas por otras funciones", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by other features (shown only when not zero)" + }, + "diagnosticsRegexFree": { + "message": "Aún disponibles para tus filtros", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules still free" + }, + "diagnosticsRegexDropped": { + "message": "No instaladas porque el límite está lleno", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules which were not installed because the shared limit is full" + }, + "diagnosticsRulesetsNotEnabled": { + "message": "Listas seleccionadas que el navegador no activó", + "description": "Dashboard > Diagnostics > Regex rule capacity: selected built-in lists which the browser did not enable" + }, "performanceDetailsTitle": { "message": "Presupuesto de rendimiento efectivo" }, diff --git a/platform/mv3/extension/_locales/fr/messages.json b/platform/mv3/extension/_locales/fr/messages.json index 06bed92962e85..1845115525f3c 100644 --- a/platform/mv3/extension/_locales/fr/messages.json +++ b/platform/mv3/extension/_locales/fr/messages.json @@ -575,6 +575,14 @@ "message": "Continuer", "description": "A button to navigate to the blocked page" }, + "strictblockPlusUrlUnavailable": { + "message": "uBlock Plus+ n’a pas pu déterminer quelle adresse a été bloquée ; cette page ne peut donc pas l’ouvrir.", + "description": "Strict-block page: shown instead of the blocked address when the browser did not report it; Proceed and \"Don't warn\" are disabled" + }, + "strictblockPlusUrlApproximate": { + "message": "Voici l’adresse où la navigation a commencé. La page bloquée peut être une redirection depuis celle-ci. Dans ce cas, Continuer peut vous ramener ici : les avertissements pour ce site ne peuvent donc pas être désactivés définitivement.", + "description": "Strict-block page: the shown address is where the navigation started, which may differ from the blocked page after a server redirect. Proceed may then lead back to the warning, and the permanent \"don't warn\" choice is disabled" + }, "zapperTipEnter": { "message": "Supprimer un élément", "description": "Tooltip for the button used to enter zapper mode" @@ -919,6 +927,106 @@ "diagnosticsUnavailable": { "message": "Indisponible" }, + "diagnosticsStrictBlockAddress": { + "message": "Adresse affichée sur la page de blocage strict", + "description": "Dashboard > Diagnostics > Runtime capabilities: label of the row saying how the strict-blocking page learns the blocked address" + }, + "diagnosticsStrictBlockAddressExact": { + "message": "Exacte, signalée par le navigateur", + "description": "Dashboard > Diagnostics: the browser reports the exact blocked address (webRequest or rule-match events)" + }, + "diagnosticsStrictBlockAddressFragment": { + "message": "Exacte, transmise dans l’adresse de la page", + "description": "Dashboard > Diagnostics: the redirect itself carries the exact blocked address in the strict-blocking page's own address (Firefox)" + }, + "diagnosticsStrictBlockAddressApproximate": { + "message": "Approximative : là où la navigation a commencé", + "description": "Dashboard > Diagnostics: only the address where the navigation started is known" + }, + "diagnosticsStrictBlockAddressUnavailable": { + "message": "Non disponible", + "description": "Dashboard > Diagnostics: the strict-blocking page cannot learn the blocked address" + }, + "diagnosticsStrictBlockAddressGrantNote": { + "message": "Facultatif : avec l’autorisation d’observation du réseau, le navigateur signale l’adresse bloquée exacte lorsqu’il redirige une page vers la page de blocage strict. C’est la source exacte la moins coûteuse. Seules ces redirections sont conservées. Le journal utilise la même autorisation et n’enregistre qu’après que vous l’avez démarré.", + "description": "Dashboard > Diagnostics: explains the optional network-observation (webRequest) permission offered for the strict-blocking page" + }, + "diagnosticsStrictBlockAddressGrant": { + "message": "Accorder l’autorisation d’observation du réseau", + "description": "Dashboard > Diagnostics: button which asks the browser for the optional network-observation (webRequest) permission" + }, + "diagnosticsStrictBlockAddressGranted": { + "message": "Autorisation d’observation du réseau accordée.", + "description": "Dashboard > Diagnostics: status after the browser granted the optional network-observation permission" + }, + "diagnosticsRegexTitle": { + "message": "Capacité en règles d’expressions régulières", + "description": "Dashboard > Diagnostics: title of the panel about the browser's regex rule limits" + }, + "diagnosticsRegexUsage": { + "message": "$1 sur $2", + "description": "Dashboard > Diagnostics > Regex rule capacity: $1 = rules in use, $2 = limit" + }, + "diagnosticsRegexStatic": { + "message": "Listes intégrées : règles d’expressions régulières utilisées", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules packaged in the enabled built-in lists, out of the browser's limit for packaged rulesets" + }, + "diagnosticsRegexVerifiedWith": { + "message": "Règles d’expressions régulières vérifiées lors de la compilation de cette version avec", + "description": "Dashboard > Diagnostics > Regex rule capacity: label followed by a browser version such as \"Chrome/153.0.8010.53\"" + }, + "diagnosticsRegexNotVerified": { + "message": "Non vérifiées lors de la compilation de cette version", + "description": "Dashboard > Diagnostics > Regex rule capacity: this version was built without checking the regex rules in a browser" + }, + "diagnosticsRegexRejectedAtBuild": { + "message": "Filtres d’expressions régulières intégrés que le navigateur ne peut pas exécuter", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex filters of the enabled built-in lists left out because the browser cannot run them" + }, + "diagnosticsRegexSkipped": { + "message": "Règles d’expressions régulières intégrées ignorées par ce navigateur", + "description": "Dashboard > Diagnostics > Regex rule capacity: packaged regex rules this browser version silently skips, counted by \"Check now\"" + }, + "diagnosticsRegexNotChecked": { + "message": "Pas encore vérifié", + "description": "Dashboard > Diagnostics > Regex rule capacity: \"Check now\" was not run for this version and browser" + }, + "diagnosticsRegexVerify": { + "message": "Vérifier maintenant", + "description": "Dashboard > Diagnostics > Regex rule capacity: button which checks every packaged regex rule of the enabled lists with this browser" + }, + "diagnosticsRegexShared": { + "message": "Plafond partagé (vos filtres, listes importées, blocage strict) : règles d’expressions régulières utilisées", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules in the browser's limit shared by dynamic and session rules" + }, + "diagnosticsRegexUser": { + "message": "Utilisées par Mes filtres et les listes importées", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by My filters and imported lists" + }, + "diagnosticsRegexStrictBlock": { + "message": "Utilisées par le blocage strict", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by the strict-blocking page redirects" + }, + "diagnosticsRegexStockFallback": { + "message": "Utilisées par les listes intégrées (règles dynamiques)", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by built-in lists installed as dynamic rules (shown only when not zero)" + }, + "diagnosticsRegexOther": { + "message": "Utilisées par d’autres fonctions", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by other features (shown only when not zero)" + }, + "diagnosticsRegexFree": { + "message": "Encore disponibles pour vos filtres", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules still free" + }, + "diagnosticsRegexDropped": { + "message": "Non installées, car la limite est atteinte", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules which were not installed because the shared limit is full" + }, + "diagnosticsRulesetsNotEnabled": { + "message": "Listes sélectionnées que le navigateur n’a pas activées", + "description": "Dashboard > Diagnostics > Regex rule capacity: selected built-in lists which the browser did not enable" + }, "performanceDetailsTitle": { "message": "Budget de performances effectif" }, diff --git a/platform/mv3/extension/_locales/ja/messages.json b/platform/mv3/extension/_locales/ja/messages.json index 7f8d643dea01a..ca0b2e83c870f 100644 --- a/platform/mv3/extension/_locales/ja/messages.json +++ b/platform/mv3/extension/_locales/ja/messages.json @@ -575,6 +575,14 @@ "message": "続行する", "description": "A button to navigate to the blocked page" }, + "strictblockPlusUrlUnavailable": { + "message": "uBlock Plus+ はブロックされたアドレスを特定できなかったため、このページからは開けません。", + "description": "Strict-block page: shown instead of the blocked address when the browser did not report it; Proceed and \"Don't warn\" are disabled" + }, + "strictblockPlusUrlApproximate": { + "message": "これはナビゲーションが始まったアドレスです。ブロックされたページは、ここからのリダイレクト先である可能性があります。その場合、「続行する」でこのページに戻ることがあるため、このサイトの警告を永続的に無効にすることはできません。", + "description": "Strict-block page: the shown address is where the navigation started, which may differ from the blocked page after a server redirect. Proceed may then lead back to the warning, and the permanent \"don't warn\" choice is disabled" + }, "zapperTipEnter": { "message": "要素を削除する", "description": "Tooltip for the button used to enter zapper mode" @@ -919,6 +927,106 @@ "diagnosticsUnavailable": { "message": "利用不可" }, + "diagnosticsStrictBlockAddress": { + "message": "厳格なブロックのページに表示されるアドレス", + "description": "Dashboard > Diagnostics > Runtime capabilities: label of the row saying how the strict-blocking page learns the blocked address" + }, + "diagnosticsStrictBlockAddressExact": { + "message": "正確(ブラウザーが報告)", + "description": "Dashboard > Diagnostics: the browser reports the exact blocked address (webRequest or rule-match events)" + }, + "diagnosticsStrictBlockAddressFragment": { + "message": "正確(ページのアドレスに保持)", + "description": "Dashboard > Diagnostics: the redirect itself carries the exact blocked address in the strict-blocking page's own address (Firefox)" + }, + "diagnosticsStrictBlockAddressApproximate": { + "message": "おおよそ:ナビゲーションの開始地点", + "description": "Dashboard > Diagnostics: only the address where the navigation started is known" + }, + "diagnosticsStrictBlockAddressUnavailable": { + "message": "利用できません", + "description": "Dashboard > Diagnostics: the strict-blocking page cannot learn the blocked address" + }, + "diagnosticsStrictBlockAddressGrantNote": { + "message": "任意:ネットワーク観測権限を許可すると、ページを厳格なブロックのページへリダイレクトしたときに、ブラウザーがブロックされた正確なアドレスを報告します。正確なアドレスを得る方法の中で最も負荷が小さい方法です。保持するのはこのリダイレクトだけです。ロガーも同じ権限を使いますが、記録を開始するまで何も記録しません。", + "description": "Dashboard > Diagnostics: explains the optional network-observation (webRequest) permission offered for the strict-blocking page" + }, + "diagnosticsStrictBlockAddressGrant": { + "message": "ネットワーク観測権限を許可", + "description": "Dashboard > Diagnostics: button which asks the browser for the optional network-observation (webRequest) permission" + }, + "diagnosticsStrictBlockAddressGranted": { + "message": "ネットワーク観測権限が許可されました。", + "description": "Dashboard > Diagnostics: status after the browser granted the optional network-observation permission" + }, + "diagnosticsRegexTitle": { + "message": "正規表現ルールの容量", + "description": "Dashboard > Diagnostics: title of the panel about the browser's regex rule limits" + }, + "diagnosticsRegexUsage": { + "message": "$1 / $2", + "description": "Dashboard > Diagnostics > Regex rule capacity: $1 = rules in use, $2 = limit" + }, + "diagnosticsRegexStatic": { + "message": "組み込みリスト:使用中の正規表現ルール", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules packaged in the enabled built-in lists, out of the browser's limit for packaged rulesets" + }, + "diagnosticsRegexVerifiedWith": { + "message": "このバージョンのビルド時に正規表現ルールを検証したブラウザー", + "description": "Dashboard > Diagnostics > Regex rule capacity: label followed by a browser version such as \"Chrome/153.0.8010.53\"" + }, + "diagnosticsRegexNotVerified": { + "message": "このバージョンのビルド時には検証されていません", + "description": "Dashboard > Diagnostics > Regex rule capacity: this version was built without checking the regex rules in a browser" + }, + "diagnosticsRegexRejectedAtBuild": { + "message": "ブラウザーが実行できない組み込みの正規表現フィルター", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex filters of the enabled built-in lists left out because the browser cannot run them" + }, + "diagnosticsRegexSkipped": { + "message": "このブラウザーがスキップする組み込みの正規表現ルール", + "description": "Dashboard > Diagnostics > Regex rule capacity: packaged regex rules this browser version silently skips, counted by \"Check now\"" + }, + "diagnosticsRegexNotChecked": { + "message": "まだ確認していません", + "description": "Dashboard > Diagnostics > Regex rule capacity: \"Check now\" was not run for this version and browser" + }, + "diagnosticsRegexVerify": { + "message": "今すぐ確認", + "description": "Dashboard > Diagnostics > Regex rule capacity: button which checks every packaged regex rule of the enabled lists with this browser" + }, + "diagnosticsRegexShared": { + "message": "共有上限(マイフィルター、インポートしたリスト、厳格なブロック):使用中の正規表現ルール", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules in the browser's limit shared by dynamic and session rules" + }, + "diagnosticsRegexUser": { + "message": "マイフィルターとインポートしたリストが使用", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by My filters and imported lists" + }, + "diagnosticsRegexStrictBlock": { + "message": "厳格なブロックが使用", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by the strict-blocking page redirects" + }, + "diagnosticsRegexStockFallback": { + "message": "組み込みリストが使用(動的ルール)", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by built-in lists installed as dynamic rules (shown only when not zero)" + }, + "diagnosticsRegexOther": { + "message": "その他の機能が使用", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by other features (shown only when not zero)" + }, + "diagnosticsRegexFree": { + "message": "フィルターにまだ使える数", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules still free" + }, + "diagnosticsRegexDropped": { + "message": "上限に達したためインストールされなかった数", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules which were not installed because the shared limit is full" + }, + "diagnosticsRulesetsNotEnabled": { + "message": "選択したもののブラウザーが有効にしなかったリスト", + "description": "Dashboard > Diagnostics > Regex rule capacity: selected built-in lists which the browser did not enable" + }, "performanceDetailsTitle": { "message": "有効なパフォーマンス予算" }, diff --git a/platform/mv3/extension/_locales/ko/messages.json b/platform/mv3/extension/_locales/ko/messages.json index 804176f3a57bd..57f5491190ba2 100644 --- a/platform/mv3/extension/_locales/ko/messages.json +++ b/platform/mv3/extension/_locales/ko/messages.json @@ -575,6 +575,14 @@ "message": "계속", "description": "A button to navigate to the blocked page" }, + "strictblockPlusUrlUnavailable": { + "message": "uBlock Plus+가 차단된 주소를 확인하지 못해 이 페이지에서 열 수 없습니다.", + "description": "Strict-block page: shown instead of the blocked address when the browser did not report it; Proceed and \"Don't warn\" are disabled" + }, + "strictblockPlusUrlApproximate": { + "message": "이 주소는 탐색이 시작된 주소입니다. 차단된 페이지는 이 주소에서 리디렉션된 페이지일 수 있습니다. 이 경우 '계속'을 누르면 이 페이지로 다시 돌아올 수 있으므로, 이 사이트에 대한 경고를 영구적으로 끌 수 없습니다.", + "description": "Strict-block page: the shown address is where the navigation started, which may differ from the blocked page after a server redirect. Proceed may then lead back to the warning, and the permanent \"don't warn\" choice is disabled" + }, "zapperTipEnter": { "message": "요소 제거하기", "description": "Tooltip for the button used to enter zapper mode" @@ -919,6 +927,106 @@ "diagnosticsUnavailable": { "message": "사용 불가" }, + "diagnosticsStrictBlockAddress": { + "message": "엄격한 차단 페이지에 표시되는 주소", + "description": "Dashboard > Diagnostics > Runtime capabilities: label of the row saying how the strict-blocking page learns the blocked address" + }, + "diagnosticsStrictBlockAddressExact": { + "message": "정확함, 브라우저가 보고함", + "description": "Dashboard > Diagnostics: the browser reports the exact blocked address (webRequest or rule-match events)" + }, + "diagnosticsStrictBlockAddressFragment": { + "message": "정확함, 페이지 주소에 보관됨", + "description": "Dashboard > Diagnostics: the redirect itself carries the exact blocked address in the strict-blocking page's own address (Firefox)" + }, + "diagnosticsStrictBlockAddressApproximate": { + "message": "대략적: 탐색이 시작된 위치", + "description": "Dashboard > Diagnostics: only the address where the navigation started is known" + }, + "diagnosticsStrictBlockAddressUnavailable": { + "message": "사용할 수 없음", + "description": "Dashboard > Diagnostics: the strict-blocking page cannot learn the blocked address" + }, + "diagnosticsStrictBlockAddressGrantNote": { + "message": "선택 사항: 네트워크 관찰 권한을 허용하면 브라우저가 페이지를 엄격한 차단 페이지로 리디렉션할 때 차단된 정확한 주소를 알려 줍니다. 정확한 주소를 얻는 방법 중 부담이 가장 적습니다. 이 리디렉션만 보관합니다. 로거도 같은 권한을 사용하며, 수집을 시작한 뒤에만 기록합니다.", + "description": "Dashboard > Diagnostics: explains the optional network-observation (webRequest) permission offered for the strict-blocking page" + }, + "diagnosticsStrictBlockAddressGrant": { + "message": "네트워크 관찰 권한 허용", + "description": "Dashboard > Diagnostics: button which asks the browser for the optional network-observation (webRequest) permission" + }, + "diagnosticsStrictBlockAddressGranted": { + "message": "네트워크 관찰 권한이 허용되었습니다.", + "description": "Dashboard > Diagnostics: status after the browser granted the optional network-observation permission" + }, + "diagnosticsRegexTitle": { + "message": "정규식 규칙 용량", + "description": "Dashboard > Diagnostics: title of the panel about the browser's regex rule limits" + }, + "diagnosticsRegexUsage": { + "message": "$2개 중 $1개", + "description": "Dashboard > Diagnostics > Regex rule capacity: $1 = rules in use, $2 = limit" + }, + "diagnosticsRegexStatic": { + "message": "기본 제공 목록: 사용 중인 정규식 규칙", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules packaged in the enabled built-in lists, out of the browser's limit for packaged rulesets" + }, + "diagnosticsRegexVerifiedWith": { + "message": "이 버전을 빌드할 때 정규식 규칙을 검사한 브라우저", + "description": "Dashboard > Diagnostics > Regex rule capacity: label followed by a browser version such as \"Chrome/153.0.8010.53\"" + }, + "diagnosticsRegexNotVerified": { + "message": "이 버전을 빌드할 때 검사하지 않음", + "description": "Dashboard > Diagnostics > Regex rule capacity: this version was built without checking the regex rules in a browser" + }, + "diagnosticsRegexRejectedAtBuild": { + "message": "브라우저가 실행할 수 없는 기본 제공 정규식 필터", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex filters of the enabled built-in lists left out because the browser cannot run them" + }, + "diagnosticsRegexSkipped": { + "message": "이 브라우저가 건너뛰는 기본 제공 정규식 규칙", + "description": "Dashboard > Diagnostics > Regex rule capacity: packaged regex rules this browser version silently skips, counted by \"Check now\"" + }, + "diagnosticsRegexNotChecked": { + "message": "아직 확인하지 않음", + "description": "Dashboard > Diagnostics > Regex rule capacity: \"Check now\" was not run for this version and browser" + }, + "diagnosticsRegexVerify": { + "message": "지금 확인", + "description": "Dashboard > Diagnostics > Regex rule capacity: button which checks every packaged regex rule of the enabled lists with this browser" + }, + "diagnosticsRegexShared": { + "message": "공유 한도(내 필터, 가져온 목록, 엄격한 차단): 사용 중인 정규식 규칙", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules in the browser's limit shared by dynamic and session rules" + }, + "diagnosticsRegexUser": { + "message": "내 필터와 가져온 목록이 사용", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by My filters and imported lists" + }, + "diagnosticsRegexStrictBlock": { + "message": "엄격한 차단이 사용", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by the strict-blocking page redirects" + }, + "diagnosticsRegexStockFallback": { + "message": "기본 제공 목록이 사용(동적 규칙)", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by built-in lists installed as dynamic rules (shown only when not zero)" + }, + "diagnosticsRegexOther": { + "message": "기타 기능이 사용", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by other features (shown only when not zero)" + }, + "diagnosticsRegexFree": { + "message": "필터에 아직 사용할 수 있음", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules still free" + }, + "diagnosticsRegexDropped": { + "message": "한도가 가득 차서 설치되지 않음", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules which were not installed because the shared limit is full" + }, + "diagnosticsRulesetsNotEnabled": { + "message": "선택했지만 브라우저가 켜지 않은 목록", + "description": "Dashboard > Diagnostics > Regex rule capacity: selected built-in lists which the browser did not enable" + }, "performanceDetailsTitle": { "message": "유효 성능 예산" }, diff --git a/platform/mv3/extension/_locales/ru/messages.json b/platform/mv3/extension/_locales/ru/messages.json index 0912e934a06e8..96cf5533433d0 100644 --- a/platform/mv3/extension/_locales/ru/messages.json +++ b/platform/mv3/extension/_locales/ru/messages.json @@ -641,6 +641,14 @@ "message": "Продолжить", "description": "A button to navigate to the blocked page" }, + "strictblockPlusUrlUnavailable": { + "message": "uBlock Plus+ не удалось определить, какой адрес был заблокирован, поэтому эта страница не может его открыть.", + "description": "Strict-block page: shown instead of the blocked address when the browser did not report it; Proceed and \"Don't warn\" are disabled" + }, + "strictblockPlusUrlApproximate": { + "message": "Это адрес, с которого начался переход. Заблокированная страница может быть перенаправлением с него. В этом случае «Продолжить» может вернуть вас сюда, поэтому предупреждения для этого сайта нельзя отключить навсегда.", + "description": "Strict-block page: the shown address is where the navigation started, which may differ from the blocked page after a server redirect. Proceed may then lead back to the warning, and the permanent \"don't warn\" choice is disabled" + }, "zapperTipEnter": { "message": "Убрать элемент", "description": "Tooltip for the button used to enter zapper mode" @@ -985,6 +993,106 @@ "diagnosticsUnavailable": { "message": "Недоступно" }, + "diagnosticsStrictBlockAddress": { + "message": "Адрес на странице строгой блокировки", + "description": "Dashboard > Diagnostics > Runtime capabilities: label of the row saying how the strict-blocking page learns the blocked address" + }, + "diagnosticsStrictBlockAddressExact": { + "message": "Точный, сообщён браузером", + "description": "Dashboard > Diagnostics: the browser reports the exact blocked address (webRequest or rule-match events)" + }, + "diagnosticsStrictBlockAddressFragment": { + "message": "Точный, сохранён в адресе страницы", + "description": "Dashboard > Diagnostics: the redirect itself carries the exact blocked address in the strict-blocking page's own address (Firefox)" + }, + "diagnosticsStrictBlockAddressApproximate": { + "message": "Приблизительный: откуда начался переход", + "description": "Dashboard > Diagnostics: only the address where the navigation started is known" + }, + "diagnosticsStrictBlockAddressUnavailable": { + "message": "Недоступен", + "description": "Dashboard > Diagnostics: the strict-blocking page cannot learn the blocked address" + }, + "diagnosticsStrictBlockAddressGrantNote": { + "message": "Необязательно: с разрешением на наблюдение за сетью браузер сообщает точный заблокированный адрес, когда перенаправляет страницу на страницу строгой блокировки. Это самый экономный точный источник. Сохраняются только такие перенаправления. Журнал использует то же разрешение и ведёт запись только после того, как вы его запустите.", + "description": "Dashboard > Diagnostics: explains the optional network-observation (webRequest) permission offered for the strict-blocking page" + }, + "diagnosticsStrictBlockAddressGrant": { + "message": "Предоставить разрешение на наблюдение за сетью", + "description": "Dashboard > Diagnostics: button which asks the browser for the optional network-observation (webRequest) permission" + }, + "diagnosticsStrictBlockAddressGranted": { + "message": "Разрешение на наблюдение за сетью предоставлено.", + "description": "Dashboard > Diagnostics: status after the browser granted the optional network-observation permission" + }, + "diagnosticsRegexTitle": { + "message": "Ёмкость regex-правил", + "description": "Dashboard > Diagnostics: title of the panel about the browser's regex rule limits" + }, + "diagnosticsRegexUsage": { + "message": "$1 из $2", + "description": "Dashboard > Diagnostics > Regex rule capacity: $1 = rules in use, $2 = limit" + }, + "diagnosticsRegexStatic": { + "message": "Встроенные списки: используемые regex-правила", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules packaged in the enabled built-in lists, out of the browser's limit for packaged rulesets" + }, + "diagnosticsRegexVerifiedWith": { + "message": "Regex-правила проверены при сборке этой версии в", + "description": "Dashboard > Diagnostics > Regex rule capacity: label followed by a browser version such as \"Chrome/153.0.8010.53\"" + }, + "diagnosticsRegexNotVerified": { + "message": "Не проверены при сборке этой версии", + "description": "Dashboard > Diagnostics > Regex rule capacity: this version was built without checking the regex rules in a browser" + }, + "diagnosticsRegexRejectedAtBuild": { + "message": "Встроенные regex-фильтры, которые браузер не может выполнить", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex filters of the enabled built-in lists left out because the browser cannot run them" + }, + "diagnosticsRegexSkipped": { + "message": "Встроенные regex-правила, которые этот браузер пропускает", + "description": "Dashboard > Diagnostics > Regex rule capacity: packaged regex rules this browser version silently skips, counted by \"Check now\"" + }, + "diagnosticsRegexNotChecked": { + "message": "Ещё не проверено", + "description": "Dashboard > Diagnostics > Regex rule capacity: \"Check now\" was not run for this version and browser" + }, + "diagnosticsRegexVerify": { + "message": "Проверить сейчас", + "description": "Dashboard > Diagnostics > Regex rule capacity: button which checks every packaged regex rule of the enabled lists with this browser" + }, + "diagnosticsRegexShared": { + "message": "Общий лимит (ваши фильтры, импортированные списки, строгая блокировка): используемые regex-правила", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules in the browser's limit shared by dynamic and session rules" + }, + "diagnosticsRegexUser": { + "message": "Используются «Моими фильтрами» и импортированными списками", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by My filters and imported lists" + }, + "diagnosticsRegexStrictBlock": { + "message": "Используются строгой блокировкой", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by the strict-blocking page redirects" + }, + "diagnosticsRegexStockFallback": { + "message": "Используются встроенными списками (динамические правила)", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by built-in lists installed as dynamic rules (shown only when not zero)" + }, + "diagnosticsRegexOther": { + "message": "Используются другими функциями", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by other features (shown only when not zero)" + }, + "diagnosticsRegexFree": { + "message": "Ещё доступно для ваших фильтров", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules still free" + }, + "diagnosticsRegexDropped": { + "message": "Не установлены, так как лимит исчерпан", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules which were not installed because the shared limit is full" + }, + "diagnosticsRulesetsNotEnabled": { + "message": "Выбранные списки, которые браузер не включил", + "description": "Dashboard > Diagnostics > Regex rule capacity: selected built-in lists which the browser did not enable" + }, "performanceDetailsTitle": { "message": "Действующий бюджет производительности" }, diff --git a/platform/mv3/extension/_locales/vi/messages.json b/platform/mv3/extension/_locales/vi/messages.json index d2c5b3402818e..e9b87add7c490 100644 --- a/platform/mv3/extension/_locales/vi/messages.json +++ b/platform/mv3/extension/_locales/vi/messages.json @@ -552,7 +552,7 @@ "description": "Label for a checkbox in the options page" }, "enablePopupBlockLegend": { - "message": "Khi được kích hoạt, các bộ lọc phù hợp sẽ tự động đóng các tab trình duyệt không mong muốn do các trang web tạo ra.", + "message": "Khi được kích hoạt, các bộ lọc phù hợp sẽ tự động đóng các thẻ trình duyệt không mong muốn do các trang web tạo ra.", "description": "Short description for a checkbox in the options page" }, "popupPolicyLabel": { @@ -641,6 +641,14 @@ "message": "Tiếp tục", "description": "A button to navigate to the blocked page" }, + "strictblockPlusUrlUnavailable": { + "message": "uBlock Plus+ không xác định được địa chỉ nào đã bị chặn, nên trang này không thể mở địa chỉ đó.", + "description": "Strict-block page: shown instead of the blocked address when the browser did not report it; Proceed and \"Don't warn\" are disabled" + }, + "strictblockPlusUrlApproximate": { + "message": "Đây là địa chỉ nơi bắt đầu điều hướng. Trang bị chặn có thể là trang được chuyển hướng từ địa chỉ này. Khi đó, nút Tiếp tục có thể đưa bạn quay lại trang này, nên không thể tắt vĩnh viễn cảnh báo cho trang web này.", + "description": "Strict-block page: the shown address is where the navigation started, which may differ from the blocked page after a server redirect. Proceed may then lead back to the warning, and the permanent \"don't warn\" choice is disabled" + }, "zapperTipEnter": { "message": "Chuyển sang chế độ chặn phần tử tạm thời", "description": "Tooltip for the button used to enter zapper mode" @@ -985,6 +993,106 @@ "diagnosticsUnavailable": { "message": "Không khả dụng" }, + "diagnosticsStrictBlockAddress": { + "message": "Địa chỉ hiển thị trên trang chặn nghiêm ngặt", + "description": "Dashboard > Diagnostics > Runtime capabilities: label of the row saying how the strict-blocking page learns the blocked address" + }, + "diagnosticsStrictBlockAddressExact": { + "message": "Chính xác, do trình duyệt báo", + "description": "Dashboard > Diagnostics: the browser reports the exact blocked address (webRequest or rule-match events)" + }, + "diagnosticsStrictBlockAddressFragment": { + "message": "Chính xác, lưu trong địa chỉ của trang", + "description": "Dashboard > Diagnostics: the redirect itself carries the exact blocked address in the strict-blocking page's own address (Firefox)" + }, + "diagnosticsStrictBlockAddressApproximate": { + "message": "Gần đúng: nơi bắt đầu điều hướng", + "description": "Dashboard > Diagnostics: only the address where the navigation started is known" + }, + "diagnosticsStrictBlockAddressUnavailable": { + "message": "Không có", + "description": "Dashboard > Diagnostics: the strict-blocking page cannot learn the blocked address" + }, + "diagnosticsStrictBlockAddressGrantNote": { + "message": "Tùy chọn: khi có quyền quan sát mạng, trình duyệt báo chính xác địa chỉ bị chặn lúc chuyển hướng một trang sang trang chặn nghiêm ngặt. Đây là nguồn chính xác tốn ít tài nguyên nhất. Chỉ những lần chuyển hướng này được giữ lại. Nhật ký dùng cùng quyền này và chỉ ghi sau khi bạn bấm Bắt đầu.", + "description": "Dashboard > Diagnostics: explains the optional network-observation (webRequest) permission offered for the strict-blocking page" + }, + "diagnosticsStrictBlockAddressGrant": { + "message": "Cấp quyền quan sát mạng", + "description": "Dashboard > Diagnostics: button which asks the browser for the optional network-observation (webRequest) permission" + }, + "diagnosticsStrictBlockAddressGranted": { + "message": "Đã cấp quyền quan sát mạng.", + "description": "Dashboard > Diagnostics: status after the browser granted the optional network-observation permission" + }, + "diagnosticsRegexTitle": { + "message": "Dung lượng quy tắc regex", + "description": "Dashboard > Diagnostics: title of the panel about the browser's regex rule limits" + }, + "diagnosticsRegexUsage": { + "message": "$1 trên $2", + "description": "Dashboard > Diagnostics > Regex rule capacity: $1 = rules in use, $2 = limit" + }, + "diagnosticsRegexStatic": { + "message": "Danh sách tích hợp: quy tắc regex đang dùng", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules packaged in the enabled built-in lists, out of the browser's limit for packaged rulesets" + }, + "diagnosticsRegexVerifiedWith": { + "message": "Quy tắc regex được kiểm tra khi đóng gói phiên bản này bằng", + "description": "Dashboard > Diagnostics > Regex rule capacity: label followed by a browser version such as \"Chrome/153.0.8010.53\"" + }, + "diagnosticsRegexNotVerified": { + "message": "Chưa được kiểm tra khi đóng gói phiên bản này", + "description": "Dashboard > Diagnostics > Regex rule capacity: this version was built without checking the regex rules in a browser" + }, + "diagnosticsRegexRejectedAtBuild": { + "message": "Bộ lọc regex tích hợp mà trình duyệt không chạy được", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex filters of the enabled built-in lists left out because the browser cannot run them" + }, + "diagnosticsRegexSkipped": { + "message": "Quy tắc regex tích hợp mà trình duyệt này bỏ qua", + "description": "Dashboard > Diagnostics > Regex rule capacity: packaged regex rules this browser version silently skips, counted by \"Check now\"" + }, + "diagnosticsRegexNotChecked": { + "message": "Chưa kiểm tra", + "description": "Dashboard > Diagnostics > Regex rule capacity: \"Check now\" was not run for this version and browser" + }, + "diagnosticsRegexVerify": { + "message": "Kiểm tra ngay", + "description": "Dashboard > Diagnostics > Regex rule capacity: button which checks every packaged regex rule of the enabled lists with this browser" + }, + "diagnosticsRegexShared": { + "message": "Giới hạn dùng chung (bộ lọc của bạn, danh sách đã nhập, chặn nghiêm ngặt): quy tắc regex đang dùng", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules in the browser's limit shared by dynamic and session rules" + }, + "diagnosticsRegexUser": { + "message": "Bộ lọc của tôi và danh sách đã nhập đang dùng", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by My filters and imported lists" + }, + "diagnosticsRegexStrictBlock": { + "message": "Chặn nghiêm ngặt đang dùng", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by the strict-blocking page redirects" + }, + "diagnosticsRegexStockFallback": { + "message": "Danh sách tích hợp đang dùng (quy tắc động)", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by built-in lists installed as dynamic rules (shown only when not zero)" + }, + "diagnosticsRegexOther": { + "message": "Tính năng khác đang dùng", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by other features (shown only when not zero)" + }, + "diagnosticsRegexFree": { + "message": "Còn trống cho bộ lọc của bạn", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules still free" + }, + "diagnosticsRegexDropped": { + "message": "Không cài được vì đã đầy giới hạn", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules which were not installed because the shared limit is full" + }, + "diagnosticsRulesetsNotEnabled": { + "message": "Danh sách đã chọn nhưng trình duyệt không bật", + "description": "Dashboard > Diagnostics > Regex rule capacity: selected built-in lists which the browser did not enable" + }, "performanceDetailsTitle": { "message": "Ngân sách hiệu năng đang dùng" }, diff --git a/platform/mv3/extension/_locales/zh_CN/messages.json b/platform/mv3/extension/_locales/zh_CN/messages.json index 962f3f151bf7e..ded279d3281d2 100644 --- a/platform/mv3/extension/_locales/zh_CN/messages.json +++ b/platform/mv3/extension/_locales/zh_CN/messages.json @@ -641,6 +641,14 @@ "message": "继续", "description": "A button to navigate to the blocked page" }, + "strictblockPlusUrlUnavailable": { + "message": "uBlock Plus+ 无法确定被拦截的是哪个网址,因此此页面无法打开它。", + "description": "Strict-block page: shown instead of the blocked address when the browser did not report it; Proceed and \"Don't warn\" are disabled" + }, + "strictblockPlusUrlApproximate": { + "message": "这是导航开始时的网址。被拦截的页面可能是从该网址重定向而来的。如果是这样,点击“继续”可能会回到此页面,因此无法永久关闭对此网站的警告。", + "description": "Strict-block page: the shown address is where the navigation started, which may differ from the blocked page after a server redirect. Proceed may then lead back to the warning, and the permanent \"don't warn\" choice is disabled" + }, "zapperTipEnter": { "message": "进入临时移除元素模式", "description": "Tooltip for the button used to enter zapper mode" @@ -985,6 +993,106 @@ "diagnosticsUnavailable": { "message": "不可用" }, + "diagnosticsStrictBlockAddress": { + "message": "严格拦截页面上显示的网址", + "description": "Dashboard > Diagnostics > Runtime capabilities: label of the row saying how the strict-blocking page learns the blocked address" + }, + "diagnosticsStrictBlockAddressExact": { + "message": "精确,由浏览器报告", + "description": "Dashboard > Diagnostics: the browser reports the exact blocked address (webRequest or rule-match events)" + }, + "diagnosticsStrictBlockAddressFragment": { + "message": "精确,保存在页面网址中", + "description": "Dashboard > Diagnostics: the redirect itself carries the exact blocked address in the strict-blocking page's own address (Firefox)" + }, + "diagnosticsStrictBlockAddressApproximate": { + "message": "近似:导航开始的位置", + "description": "Dashboard > Diagnostics: only the address where the navigation started is known" + }, + "diagnosticsStrictBlockAddressUnavailable": { + "message": "无法获取", + "description": "Dashboard > Diagnostics: the strict-blocking page cannot learn the blocked address" + }, + "diagnosticsStrictBlockAddressGrantNote": { + "message": "可选:授予网络观察权限后,浏览器在将页面重定向到严格拦截页面时会报告被拦截的精确网址。这是开销最小的精确来源。只保留这些重定向。日志使用同一权限,并且只在您开始捕获后才记录。", + "description": "Dashboard > Diagnostics: explains the optional network-observation (webRequest) permission offered for the strict-blocking page" + }, + "diagnosticsStrictBlockAddressGrant": { + "message": "授予网络观察权限", + "description": "Dashboard > Diagnostics: button which asks the browser for the optional network-observation (webRequest) permission" + }, + "diagnosticsStrictBlockAddressGranted": { + "message": "已授予网络观察权限。", + "description": "Dashboard > Diagnostics: status after the browser granted the optional network-observation permission" + }, + "diagnosticsRegexTitle": { + "message": "正则规则容量", + "description": "Dashboard > Diagnostics: title of the panel about the browser's regex rule limits" + }, + "diagnosticsRegexUsage": { + "message": "$1 / $2", + "description": "Dashboard > Diagnostics > Regex rule capacity: $1 = rules in use, $2 = limit" + }, + "diagnosticsRegexStatic": { + "message": "内置列表:正在使用的正则规则", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules packaged in the enabled built-in lists, out of the browser's limit for packaged rulesets" + }, + "diagnosticsRegexVerifiedWith": { + "message": "构建此版本时用于检查正则规则的浏览器", + "description": "Dashboard > Diagnostics > Regex rule capacity: label followed by a browser version such as \"Chrome/153.0.8010.53\"" + }, + "diagnosticsRegexNotVerified": { + "message": "构建此版本时未检查", + "description": "Dashboard > Diagnostics > Regex rule capacity: this version was built without checking the regex rules in a browser" + }, + "diagnosticsRegexRejectedAtBuild": { + "message": "浏览器无法运行的内置正则过滤器", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex filters of the enabled built-in lists left out because the browser cannot run them" + }, + "diagnosticsRegexSkipped": { + "message": "此浏览器跳过的内置正则规则", + "description": "Dashboard > Diagnostics > Regex rule capacity: packaged regex rules this browser version silently skips, counted by \"Check now\"" + }, + "diagnosticsRegexNotChecked": { + "message": "尚未检查", + "description": "Dashboard > Diagnostics > Regex rule capacity: \"Check now\" was not run for this version and browser" + }, + "diagnosticsRegexVerify": { + "message": "立即检查", + "description": "Dashboard > Diagnostics > Regex rule capacity: button which checks every packaged regex rule of the enabled lists with this browser" + }, + "diagnosticsRegexShared": { + "message": "共享上限(您的过滤器、已导入的列表、严格拦截):正在使用的正则规则", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules in the browser's limit shared by dynamic and session rules" + }, + "diagnosticsRegexUser": { + "message": "“我的过滤器”和已导入的列表使用", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by My filters and imported lists" + }, + "diagnosticsRegexStrictBlock": { + "message": "严格拦截使用", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by the strict-blocking page redirects" + }, + "diagnosticsRegexStockFallback": { + "message": "内置列表使用(动态规则)", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by built-in lists installed as dynamic rules (shown only when not zero)" + }, + "diagnosticsRegexOther": { + "message": "其他功能使用", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by other features (shown only when not zero)" + }, + "diagnosticsRegexFree": { + "message": "仍可供您的过滤器使用", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules still free" + }, + "diagnosticsRegexDropped": { + "message": "因已达上限而未安装", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules which were not installed because the shared limit is full" + }, + "diagnosticsRulesetsNotEnabled": { + "message": "已选择但浏览器未启用的列表", + "description": "Dashboard > Diagnostics > Regex rule capacity: selected built-in lists which the browser did not enable" + }, "performanceDetailsTitle": { "message": "当前性能预算" }, diff --git a/platform/mv3/extension/_locales/zh_TW/messages.json b/platform/mv3/extension/_locales/zh_TW/messages.json index 4557581393cb5..78c9c7bed6a9e 100644 --- a/platform/mv3/extension/_locales/zh_TW/messages.json +++ b/platform/mv3/extension/_locales/zh_TW/messages.json @@ -641,6 +641,14 @@ "message": "繼續", "description": "A button to navigate to the blocked page" }, + "strictblockPlusUrlUnavailable": { + "message": "uBlock Plus+ 無法判斷被封鎖的是哪個網址,因此此頁面無法開啟它。", + "description": "Strict-block page: shown instead of the blocked address when the browser did not report it; Proceed and \"Don't warn\" are disabled" + }, + "strictblockPlusUrlApproximate": { + "message": "這是開始導覽時的網址。被封鎖的頁面可能是從此網址重新導向而來。若是如此,按下「繼續」可能會回到此頁面,因此無法永久關閉此網站的警告。", + "description": "Strict-block page: the shown address is where the navigation started, which may differ from the blocked page after a server redirect. Proceed may then lead back to the warning, and the permanent \"don't warn\" choice is disabled" + }, "zapperTipEnter": { "message": "移除元素", "description": "Tooltip for the button used to enter zapper mode" @@ -985,6 +993,106 @@ "diagnosticsUnavailable": { "message": "不可用" }, + "diagnosticsStrictBlockAddress": { + "message": "嚴格阻擋頁面上顯示的網址", + "description": "Dashboard > Diagnostics > Runtime capabilities: label of the row saying how the strict-blocking page learns the blocked address" + }, + "diagnosticsStrictBlockAddressExact": { + "message": "精確,由瀏覽器回報", + "description": "Dashboard > Diagnostics: the browser reports the exact blocked address (webRequest or rule-match events)" + }, + "diagnosticsStrictBlockAddressFragment": { + "message": "精確,保存在頁面網址中", + "description": "Dashboard > Diagnostics: the redirect itself carries the exact blocked address in the strict-blocking page's own address (Firefox)" + }, + "diagnosticsStrictBlockAddressApproximate": { + "message": "約略:開始導覽的位置", + "description": "Dashboard > Diagnostics: only the address where the navigation started is known" + }, + "diagnosticsStrictBlockAddressUnavailable": { + "message": "無法取得", + "description": "Dashboard > Diagnostics: the strict-blocking page cannot learn the blocked address" + }, + "diagnosticsStrictBlockAddressGrantNote": { + "message": "選用:授予網路觀察權限後,瀏覽器將頁面重新導向到嚴格阻擋頁面時,會回報被封鎖的精確網址。這是負擔最小的精確來源。只會保留這些重新導向。記錄器使用同一項權限,且只在您開始擷取後才記錄。", + "description": "Dashboard > Diagnostics: explains the optional network-observation (webRequest) permission offered for the strict-blocking page" + }, + "diagnosticsStrictBlockAddressGrant": { + "message": "授予網路觀察權限", + "description": "Dashboard > Diagnostics: button which asks the browser for the optional network-observation (webRequest) permission" + }, + "diagnosticsStrictBlockAddressGranted": { + "message": "已授予網路觀察權限。", + "description": "Dashboard > Diagnostics: status after the browser granted the optional network-observation permission" + }, + "diagnosticsRegexTitle": { + "message": "正規表示式規則容量", + "description": "Dashboard > Diagnostics: title of the panel about the browser's regex rule limits" + }, + "diagnosticsRegexUsage": { + "message": "$1 / $2", + "description": "Dashboard > Diagnostics > Regex rule capacity: $1 = rules in use, $2 = limit" + }, + "diagnosticsRegexStatic": { + "message": "內建清單:使用中的正規表示式規則", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules packaged in the enabled built-in lists, out of the browser's limit for packaged rulesets" + }, + "diagnosticsRegexVerifiedWith": { + "message": "建置此版本時用來檢查正規表示式規則的瀏覽器", + "description": "Dashboard > Diagnostics > Regex rule capacity: label followed by a browser version such as \"Chrome/153.0.8010.53\"" + }, + "diagnosticsRegexNotVerified": { + "message": "建置此版本時未檢查", + "description": "Dashboard > Diagnostics > Regex rule capacity: this version was built without checking the regex rules in a browser" + }, + "diagnosticsRegexRejectedAtBuild": { + "message": "瀏覽器無法執行的內建正規表示式過濾規則", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex filters of the enabled built-in lists left out because the browser cannot run them" + }, + "diagnosticsRegexSkipped": { + "message": "此瀏覽器略過的內建正規表示式規則", + "description": "Dashboard > Diagnostics > Regex rule capacity: packaged regex rules this browser version silently skips, counted by \"Check now\"" + }, + "diagnosticsRegexNotChecked": { + "message": "尚未檢查", + "description": "Dashboard > Diagnostics > Regex rule capacity: \"Check now\" was not run for this version and browser" + }, + "diagnosticsRegexVerify": { + "message": "立即檢查", + "description": "Dashboard > Diagnostics > Regex rule capacity: button which checks every packaged regex rule of the enabled lists with this browser" + }, + "diagnosticsRegexShared": { + "message": "共用上限(您的篩選器、已導入的清單、嚴格阻擋):使用中的正規表示式規則", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules in the browser's limit shared by dynamic and session rules" + }, + "diagnosticsRegexUser": { + "message": "「我的篩選器」和已導入的清單使用", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by My filters and imported lists" + }, + "diagnosticsRegexStrictBlock": { + "message": "嚴格阻擋使用", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by the strict-blocking page redirects" + }, + "diagnosticsRegexStockFallback": { + "message": "內建清單使用(動態規則)", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by built-in lists installed as dynamic rules (shown only when not zero)" + }, + "diagnosticsRegexOther": { + "message": "其他功能使用", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules used by other features (shown only when not zero)" + }, + "diagnosticsRegexFree": { + "message": "仍可供您的篩選器使用", + "description": "Dashboard > Diagnostics > Regex rule capacity: shared regex rules still free" + }, + "diagnosticsRegexDropped": { + "message": "因已達上限而未安裝", + "description": "Dashboard > Diagnostics > Regex rule capacity: regex rules which were not installed because the shared limit is full" + }, + "diagnosticsRulesetsNotEnabled": { + "message": "已選取但瀏覽器未啟用的清單", + "description": "Dashboard > Diagnostics > Regex rule capacity: selected built-in lists which the browser did not enable" + }, "performanceDetailsTitle": { "message": "目前效能預算" }, diff --git a/platform/mv3/extension/css/power-settings.css b/platform/mv3/extension/css/power-settings.css index 6102e92d0d2c2..5e1410bf79b9a 100644 --- a/platform/mv3/extension/css/power-settings.css +++ b/platform/mv3/extension/css/power-settings.css @@ -180,20 +180,41 @@ body > * { } #runtimeCapabilities, -#performanceDetails { +#performanceDetails, +#regexCapacity { display: grid; gap: var(--default-gap-small); grid-template-columns: minmax(10em, 1fr) minmax(8em, 1fr); } #runtimeCapabilities dt, -#performanceDetails dt { +#performanceDetails dt, +#regexCapacity dt { color: var(--ink-2); } #runtimeCapabilities dd, -#performanceDetails dd { +#performanceDetails dd, +#regexCapacity dd { margin: 0; overflow-wrap: anywhere; } +#regexCapacity .definitionItems { + font-size: var(--font-size-smaller); + margin: var(--default-gap-xxsmall) 0 0; + padding-inline-start: var(--default-gap-large); +} +#strictBlockAddressSetup { + border-block-start: 1px solid var(--border-1); + margin-block-start: var(--default-gap-large); + padding-block-start: var(--default-gap-small); +} +#strictBlockAddressSetup p { + color: var(--ink-2); + margin-block: 0 var(--default-gap-small); +} +#strictBlockAddressSetup[hidden], +#regexCapacityPanel[hidden] { + display: none; +} body[data-forbid~="dashboard"] #dashboard-nav [data-pane="siteRules"], body[data-forbid~="dashboard"] section[data-pane="siteRules"], diff --git a/platform/mv3/extension/css/strictblock.css b/platform/mv3/extension/css/strictblock.css index 5a459446434a3..47a02a3f88dbf 100644 --- a/platform/mv3/extension/css/strictblock.css +++ b/platform/mv3/extension/css/strictblock.css @@ -133,6 +133,15 @@ body[dir="rtl"] #toggleParse { #parsed span:first-of-type { font-weight: bold; } +/* How exact the displayed address is, or why there is none. */ +#urlNote { + color: var(--ink-2); + font-size: var(--font-size-smaller); + font-style: italic; + } +#theURL[hidden] { + display: none; + } #urlskip a { display: block; diff --git a/platform/mv3/extension/dashboard.html b/platform/mv3/extension/dashboard.html index 141d437a17278..b5de734b8fd65 100644 --- a/platform/mv3/extension/dashboard.html +++ b/platform/mv3/extension/dashboard.html @@ -351,12 +351,23 @@

_

_

+

_

+

_

_

diff --git a/platform/mv3/extension/js/background.js b/platform/mv3/extension/js/background.js index ce2ebea8a6ad1..e78355f719c09 100644 --- a/platform/mv3/extension/js/background.js +++ b/platform/mv3/extension/js/background.js @@ -42,6 +42,11 @@ import { STAGING_COMPILED_GENERATION_KEY, } from './compiled-storage.js'; +import { + PLAN_STORAGE_KEY as STRICTBLOCK_PLAN_KEY, + createStrictBlockTracker, +} from './strictblock-tracker.js'; + import { addCustomFilters, customFiltersFromHostname, @@ -121,12 +126,16 @@ import { getEffectiveUserRules, getEnabledRulesets, getEnabledRulesetsDetails, + getRegexCapacity, getRulesetDetails, getRulesetRules, patchDefaultRulesets, + reconcileStrictBlockSessionRules, recoverStockBadfilters, restoreNativeRulesetState, setStrictBlockMode, + setStrictBlockPlanListener, + setStrictBlockUrlSourceProvider, snapshotNativeRulesetState, updateDynamicAndSessionRules, updateSessionRules, @@ -184,6 +193,7 @@ import { import { syncToolbarIconMode, toggleToolbarIcon } from './action.js'; import { COMPILED_FILTERS_REVISION } from './compiled-cache.js'; import { POPUP_RUNTIME_ROUTE_CODE } from './compiled-popup-matcher.js'; +import { STRICTBLOCK_PAGE_PATH } from './strictblock-rules.js'; import { capturePopupFrameContext } from './popup-frame-context.js'; import { createFirewallManager } from './firewall-manager.js'; import { createPopupBlocker } from './popup-blocker.js'; @@ -201,6 +211,14 @@ const canShowBlockedCount = typeof dnr.setExtensionActionOptions === 'function'; const COMPILED_FILTERS_DIRTY_KEY = 'compiledFilters.dirtySources'; const COMPILED_FILTERS_REVISION_KEY = 'compiledFilters.compilerRevision'; const COMPILED_FILTERS_RETRY_JOB = 'retryCompiledFilters'; +// Deferred jobs (alarms.js) are dispatched through onMessage() by the worker +// itself, without a sender. A browser message always has one. A new job name +// must be added here. +const DEFERRED_JOB_MESSAGES = new Set([ + COMPILED_FILTERS_RETRY_JOB, + 'pruneCSSCache', + 'updateImportedLists', +]); const COMPILED_FILTERS_RETRY_DELAY = 5 * 60 * 1000; const COMPILED_FILTERS_MAX_RETRY_DELAY = 6 * 60 * 60 * 1000; const COMPILED_FILTER_WARNINGS_KEY = 'compiledFilters.lastWarnings'; @@ -477,6 +495,98 @@ const webRequestFirewall = createWebRequestFirewall({ }, }); +// Strict blocking: a strict-block redirect is a plain extensionPath redirect, +// so strictblock.html asks which address it replaced (strictblock-tracker.js). +// The tracker's listeners are registered synchronously here, so that the +// events which wake the worker reach them. Only Chromium builds use such +// redirects: Firefox builds carry the address in the redirect itself, and +// Safari has no strict blocking. +const strictBlockTracker = createStrictBlockTracker({ + dnr, + // The optional webRequest permission can be granted and removed while + // the worker runs. + webRequest: ( ) => browser.webRequest, + webNavigation: ( ) => browser.webNavigation, + permissions: browser.permissions, + sessionRead, + sessionWrite, +}); +if ( webextFlavor === 'chromium' ) { + strictBlockTracker.registerTopLevelListeners(); +} + +// The onRuleMatchedDebug listener sees every matched rule: the tracker keeps +// it only while the installed session plan has a redirect. +let strictBlockPlanRevision = 0; +function onStrictBlockPlan(plan) { + strictBlockPlanRevision += 1; + strictBlockTracker.setPlan(plan); +} +setStrictBlockPlanListener(onStrictBlockPlan); + +// Redirects for My filters and imported lists are installed only while the +// page can learn the exact address; otherwise they stay plain blocks. +setStrictBlockUrlSourceProvider(( ) => strictBlockTracker.isExact()); + +// A restarted worker finds the installed plan in storage.session. +async function syncStrictBlockTracker() { + const revision = strictBlockPlanRevision; + const plan = await sessionRead(STRICTBLOCK_PLAN_KEY).catch(( ) => undefined); + // A plan installed meanwhile already reached the tracker. + if ( revision !== strictBlockPlanRevision ) { return; } + strictBlockTracker.setPlan(plan); +} +syncStrictBlockTracker(); + +// How the strict-block page learns the blocked address on this browser, for +// the runtime capabilities (runtime-capabilities-core.js). +function strictBlockUrlSource() { + if ( webextFlavor === 'firefox' ) { return 'regex-substitution'; } + if ( webextFlavor === 'safari' ) { return 'unavailable'; } + return strictBlockTracker.urlSource(); +} + +// Strict-block redirects need broad host access: without it a redirect +// shadows the block below it and the page loads. Redirects for My filters +// and imported lists also need an exact address source, which the optional +// webRequest permission can add. Either change rebuilds the session plan. +function onStrictBlockPermissionsChanged(permissions) { + const sourceChecked = strictBlockTracker.permissionsChanged().catch(reason => { + ublockPlusErr(`strictBlockPermissions/${reason}`); + }); + const origins = Array.isArray(permissions?.origins) ? permissions.origins : []; + const apis = Array.isArray(permissions?.permissions) ? permissions.permissions : []; + if ( origins.length === 0 && apis.includes('webRequest') === false ) { return; } + return Promise.all([ sourceChecked, isFullyInitialized ]).then(( ) => + updateSessionRules() + ).then(result => { + if ( result?.error === undefined ) { return; } + ublockPlusErr(`strictBlockPermissions/${result.error}`); + }).catch(reason => { + ublockPlusErr(`strictBlockPermissions/${reason}`); + }); +} + +// The low-memory profile gives up the onRuleMatchedDebug source, which keeps +// the worker alive while the user browses (docs/POWER-RUNTIME.md). A changed +// source changes which redirects the session plan may install. +function followStrictBlockMemoryProfile(profile) { + if ( profile instanceof Object === false ) { return false; } + return strictBlockTracker.setLowMemory(profile.effective === 'low-memory'); +} + +function onStrictBlockMemoryProfile(profile) { + if ( followStrictBlockMemoryProfile(profile) === false ) { return; } + isFullyInitialized.then(( ) => + updateSessionRules() + ).then(result => { + if ( result?.error === undefined ) { return; } + ublockPlusErr(`strictBlockMemoryProfile/${result.error}`); + }).catch(reason => { + ublockPlusErr(`strictBlockMemoryProfile/${reason}`); + }); +} + // Automatic updates: the worker reads public release metadata; the optional, // separately installed native updater downloads, verifies and installs. const updateManager = createUpdateManager({ @@ -1383,6 +1493,14 @@ async function onMessage(request, sender) { return onUpdateMessage(request, sender); } + // Only the strict-block page itself may ask which address its tab was + // redirected from. The answer does not wait for initialization: the + // tracker keeps its own state, and the page gives up after 5 s. + if ( request.what === 'getStrictBlockDetails' ) { + if ( isStrictBlockPageSender(sender) === false ) { return; } + return strictBlockTracker.getDetails(sender.tab.id, request.timeOrigin); + } + // Does not require extension to be fully initialized // Does not require a trusted origin. @@ -1473,17 +1591,14 @@ async function onMessage(request, sender) { // Requires a trusted origin. - // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/MessageSender - // Firefox API does not set `sender.origin` - const isTrustedOrigin = sender?.origin === undefined || - sender.origin.toLowerCase() === UBLOCK_PLUS_ORIGIN; - if ( isTrustedOrigin === false ) { return; } - if ( [ 'getFirewallState', 'previewFirewallRules', 'applyFirewallRules', - 'testFirewallRequest' ].includes(request.what) ) { - if ( sender?.id !== runtime.id || - sender?.url?.toLowerCase().startsWith(`${UBLOCK_PLUS_ORIGIN}/`) !== true ) { - return; + if ( isTrustedSender(sender, request) === false ) { + if ( sender === undefined ) { + ublockPlusErr(`Deferred job ${request.what} is not in DEFERRED_JOB_MESSAGES`); } + return; + } + if ( EXTENSION_PAGE_MESSAGES.has(request.what) ) { + if ( isExtensionPageSender(sender) === false ) { return; } } switch ( request.what ) { @@ -1531,16 +1646,26 @@ async function onMessage(request, sender) { return rulesetConfig; case 'getRuntimeCapabilities': - return getRuntimeCapabilities(); + return getRuntimeCapabilities({ + strictBlockUrlSource: strictBlockUrlSource(), + }); - case 'getMemoryProfile': - return getMemoryProfileConfig(request.deviceMemoryGiB); + case 'getRegexCapacity': + return getRegexCapacity({ verifyStatic: request.verifyStatic === true }); + + case 'getMemoryProfile': { + // Auto follows the device memory hint of the asking page. + const profile = await getMemoryProfileConfig(request.deviceMemoryGiB); + onStrictBlockMemoryProfile(profile); + return profile; + } case 'setMemoryProfile': { const profile = await setMemoryProfile( request.profile, request.deviceMemoryGiB ); + onStrictBlockMemoryProfile(profile); if ( profile.retainScriptingMetadata === false ) { releaseScriptingMetadata(); } @@ -1931,6 +2056,73 @@ async function onMessage(request, sender) { /******************************************************************************/ +// Who may send what (tools/test-sender-trust.mjs): +// - any sender, content scripts included: the messages onMessage() handles +// before it checks the sender; +// - a trusted sender: an extension page, or the worker itself dispatching a +// deferred job (no sender); +// - an extension page, by extension ID and URL: EXTENSION_PAGE_MESSAGES and +// the update messages (onUpdateMessage() checks the same inline); +// - the strict-block page in the top frame of a tab: getStrictBlockDetails; +// - user scripts run in a less trusted world than content scripts, and only +// reach USER_SCRIPT_MESSAGES. + +const EXTENSION_PAGE_MESSAGES = new Set([ + 'applyFirewallRules', + 'getFirewallState', + 'getRegexCapacity', + 'previewFirewallRules', + 'testFirewallRequest', +]); + +const USER_SCRIPT_MESSAGES = new Set([ + 'getLoggerCapture', + 'recordContentDiagnostic', +]); + +function isExtensionPageSender(sender) { + if ( typeof sender !== 'object' || sender === null ) { return false; } + if ( sender.id !== runtime.id ) { return false; } + if ( typeof sender.url !== 'string' ) { return false; } + if ( sender.url.toLowerCase().startsWith(`${UBLOCK_PLUS_ORIGIN}/`) === false ) { + return false; + } + return sender.origin === undefined || + typeof sender.origin === 'string' && + sender.origin.toLowerCase() === UBLOCK_PLUS_ORIGIN; +} + +// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/MessageSender +// Firefox API does not set `sender.origin`: there the extension ID and the +// URL of the sender decide, and a content script is never trusted. +function isTrustedSender(sender, request) { + if ( sender === undefined ) { + return DEFERRED_JOB_MESSAGES.has(request?.what); + } + if ( typeof sender !== 'object' || sender === null ) { return false; } + if ( sender.origin !== undefined ) { + return typeof sender.origin === 'string' && + sender.origin.toLowerCase() === UBLOCK_PLUS_ORIGIN; + } + return isExtensionPageSender(sender); +} + +function isStrictBlockPageSender(sender) { + if ( isExtensionPageSender(sender) === false ) { return false; } + if ( Number.isInteger(sender.tab?.id) === false || sender.tab.id < 0 ) { + return false; + } + // Strict blocking only redirects top-level documents. + if ( sender.frameId !== 0 ) { return false; } + try { + return new URL(sender.url).pathname === STRICTBLOCK_PAGE_PATH; + } catch { + } + return false; +} + +/******************************************************************************/ + function onCommand(command, tab) { switch ( command ) { case 'enter-zapper-mode': { @@ -2137,6 +2329,8 @@ async function start() { loadRulesetConfig(), initializeMemoryProfile(), ]); + // Before the strict-block reconcile below, which follows the URL source. + followStrictBlockMemoryProfile(memoryProfile); // The toolbar icon mode is derived from stored modes, not worker memory. syncToolbarIconMode().catch(reason => { ublockPlusErr(`syncToolbarIconMode/${reason}`); @@ -2160,6 +2354,17 @@ async function start() { } } + // On every start, also a wake: strict-block redirects must not outlive + // the broad host access they need, and a plan lost with storage.session + // is rebuilt. Then the tracker follows the installed plan. + await reconcileStrictBlockSessionRules().then(result => { + if ( result?.error === undefined ) { return; } + ublockPlusErr(`Strict-block reconcile/${result.error}`); + }).catch(reason => { + ublockPlusErr(`Strict-block reconcile/${reason}`); + }); + await syncStrictBlockTracker(); + const scripts = await getRegisteredContentScripts(); if ( scripts.length === 0 ) { await registerContentScripts(); @@ -2258,6 +2463,7 @@ if ( supportsUserScripts() && runtime.onUserScriptMessage ) { }); runtime.onUserScriptMessage.addListener((request, sender, callback) => { if ( typeof request?.what !== 'string' ) { return; } + if ( USER_SCRIPT_MESSAGES.has(request.what) === false ) { return; } onMessage(request, sender).then(callback, reason => { ublockPlusErr(`onUserScriptMessage/${request.what}/${reason}`); callback(messageErrorReply(reason)); @@ -2268,6 +2474,7 @@ if ( supportsUserScripts() && runtime.onUserScriptMessage ) { browser.permissions.onRemoved.addListener((...args) => { webRequestFirewall.permissionsChanged(); + onStrictBlockPermissionsChanged(args[0]); isFullyInitialized.then(( ) => { return onPermissionsChanged('removed', ...args); }).catch(reason => { @@ -2277,6 +2484,7 @@ browser.permissions.onRemoved.addListener((...args) => { browser.permissions.onAdded.addListener((...args) => { webRequestFirewall.permissionsChanged(); + onStrictBlockPermissionsChanged(args[0]); isFullyInitialized.then(( ) => { return onPermissionsChanged('added', ...args); }).catch(reason => { @@ -2322,6 +2530,7 @@ browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { browser.webNavigation?.onBeforeNavigate?.addListener(details => { webRequestFirewall.observeNavigation(details); if ( details.frameId !== 0 ) { return; } + strictBlockTracker.onBeforeNavigate(details); isFullyInitialized.then(( ) => popupBlocker.onBeforeNavigate(details)).catch(reason => { ublockPlusErr(`popupBeforeNavigate/${reason}`); }); @@ -2340,6 +2549,7 @@ browser.webNavigation?.onErrorOccurred?.addListener(details => { browser.tabs.onRemoved.addListener(tabId => { webRequestFirewall.forgetTab(tabId); + strictBlockTracker.onTabRemoved(tabId); popupBlocker.onTabRemoved(tabId).catch(reason => { ublockPlusErr(`popupTabRemoved/${reason}`); }); diff --git a/platform/mv3/extension/js/compiled-cache.js b/platform/mv3/extension/js/compiled-cache.js index dc177d983ac33..21dcbd3fd59fb 100644 --- a/platform/mv3/extension/js/compiled-cache.js +++ b/platform/mv3/extension/js/compiled-cache.js @@ -19,7 +19,8 @@ import { // Increment when compiler semantics change; older envelopes must be rebuilt // from source instead of reusing output which lost exceptions or metadata. -export const COMPILED_FILTERS_REVISION = 4; +// 5: generations store strict-block redirect templates (strictBlockRules). +export const COMPILED_FILTERS_REVISION = 5; function isStats(value, fields) { if ( typeof value !== 'object' || value === null ) { return false; } diff --git a/platform/mv3/extension/js/compiled-storage.js b/platform/mv3/extension/js/compiled-storage.js index fec82ef7682de..29ec2dd50d53a 100644 --- a/platform/mv3/extension/js/compiled-storage.js +++ b/platform/mv3/extension/js/compiled-storage.js @@ -31,6 +31,10 @@ export const COMPILED_LOGICAL_KEYS = Object.freeze([ 'scriptletExceptions.schema', 'sandboxFilters.popupFilters', 'importedFilters.popupFilters', + // Strict-block redirect templates of each realm's $doc filters + // (strictblock-rules.js deriveUserStrictBlockRules). + 'sandboxFilters.strictBlockRules', + 'importedFilters.strictBlockRules', ]); export function newCompiledGeneration() { diff --git a/platform/mv3/extension/js/dnr-namespaces.js b/platform/mv3/extension/js/dnr-namespaces.js new file mode 100644 index 0000000000000..586e135daa882 --- /dev/null +++ b/platform/mv3/extension/js/dnr-namespaces.js @@ -0,0 +1,193 @@ +/******************************************************************************* + uBlock Plus+ - one registry of native DNR rule-ID ranges and priorities + Copyright (C) 2026-present uBlock Plus+ contributors; GPL-3.0-or-later + + Pure module: no browser API and no import from ext.js, so it can be loaded + by the build (Node), the offscreen document and the service worker. +******************************************************************************/ + +// Chrome keeps dynamic and session rules in two separate ID spaces, so an ID +// is only unique within its own namespace. Every owner that writes dynamic +// or session rules declares its range here; tools/test-dnr-namespaces.mjs +// asserts that the ranges of one namespace, and all priority bands, are +// pairwise disjoint. Ranges are inclusive [min, max]. + +export const MAX_RULE_ID = 2 ** 31 - 1; + +/******************************************************************************/ + +// Dynamic rule IDs + +// Stock regex rules which could not be packaged statically (the dynamic +// fallback). They are replaced wholesale on every stock refresh. +export const STOCK_REGEX_FALLBACK_MAX_RULE_ID = 4999999; +// First dynamic ID which is not a stock regex fallback rule. 5,000,000 to +// 5,999,999 is reserved and unused. +export const SPECIAL_RULES_REALM = 5000000; +// Per-site switches (roadmap step 3). +export const SITE_SWITCH_RULE_BASE = 6000000; +export const SITE_SWITCH_RULE_LIMIT = 1000; +// Engine DNR profile suppression rules (roadmap step 6). +export const ENGINE_PROFILE_RULE_BASE = 6100000; +export const ENGINE_PROFILE_RULE_LIMIT = 100000; +// Trusted-site directive: allowAllRequests at this dynamic ID, and the +// matching tabId -1 allow at this ID + 1 in the session namespace. +export const TRUSTED_DIRECTIVE_BASE_RULE_ID = 8000000; +// User rules: developer DNR text, My filters, imported lists, stock +// residuals and, later, fragments and stock deltas. +export const USER_RULES_BASE_RULE_ID = 9000000; + +// Session rule IDs + +// Strict-block plan: stock and user redirects plus their exclusion allows. +export const STRICTBLOCK_SESSION_ID_LIMIT = 1000000; +// Large-media tab allowances (roadmap step 3). +export const LARGE_MEDIA_SESSION_RULE_BASE = 6001000; +export const LARGE_MEDIA_SESSION_RULE_LIMIT = 64; +// Firewall cells. Today's firewall-core.js uses the first 4,096 IDs. +export const FIREWALL_SESSION_RULE_BASE = 7000000; +export const FIREWALL_SESSION_RULE_LIMIT = 50000; +// URL rules (roadmap step 7). +export const URL_RULE_SESSION_BASE = 7050000; +export const URL_RULE_SESSION_LIMIT = 50000; + +/******************************************************************************/ + +// Priorities +// +// Stock rulesets and imported lists share the stock bands; see the priority +// comment in src/js/static-net-filtering.js. My filters and developer DNR +// rules are offset by USER_RULES_PRIORITY. At equal priority Chrome prefers +// allow > allowAllRequests > block > upgradeScheme > redirect. + +export const STOCK_BLOCK_PRIORITY = 10; +export const STOCK_ALLOW_PRIORITY = 30; +export const STOCK_STRICTBLOCK_PRIORITY = 29; +export const ENGINE_SUPPRESSION_PRIORITY = 20; +export const USER_RULES_PRIORITY = 1000000; +// A developer DNR rule written with a priority of 500,000 or more reaches the +// firewall band; the compilers never emit such priorities. +export const USER_RULES_MAX_PRIORITY = 1499999; +export const FIREWALL_PRIORITY = 1500000; +export const URL_RULE_PRIORITY_MIN = 1600000; +export const URL_RULE_PRIORITY_MAX = 1749999; +// URL rule priorities grow with the depth of their context; step 7 clamps +// the depth so that the deepest rule stays inside its band. +export const URL_RULE_MAX_DEPTH = 64; +export const SITE_SWITCH_PRIORITY = 1900000; +export const LARGE_MEDIA_ALLOWANCE_PRIORITY = 1900001; +export const TRUSTED_DIRECTIVE_PRIORITY = 2000000; + +/******************************************************************************/ + +const range = (owner, min, max, note) => Object.freeze({ owner, min, max, note }); + +export const DYNAMIC_RULE_ID_RANGES = Object.freeze([ + range('stockRegexFallback', 1, STOCK_REGEX_FALLBACK_MAX_RULE_ID, + 'stock regex rules that are not packaged statically'), + range('siteSwitches', SITE_SWITCH_RULE_BASE, + SITE_SWITCH_RULE_BASE + SITE_SWITCH_RULE_LIMIT - 1, 'step 3'), + range('engineProfile', ENGINE_PROFILE_RULE_BASE, + ENGINE_PROFILE_RULE_BASE + ENGINE_PROFILE_RULE_LIMIT - 1, 'step 6'), + range('trustedDirective', TRUSTED_DIRECTIVE_BASE_RULE_ID, + TRUSTED_DIRECTIVE_BASE_RULE_ID, 'allowAllRequests for trusted sites'), + range('user', USER_RULES_BASE_RULE_ID, MAX_RULE_ID, + 'developer, My filters, imported, stock residuals, fragments, deltas'), +]); + +export const SESSION_RULE_ID_RANGES = Object.freeze([ + range('strictBlock', 1, STRICTBLOCK_SESSION_ID_LIMIT - 1, + 'strict-block plan (strictblock-rules.js)'), + range('largeMedia', LARGE_MEDIA_SESSION_RULE_BASE, + LARGE_MEDIA_SESSION_RULE_BASE + LARGE_MEDIA_SESSION_RULE_LIMIT - 1, + 'step 3'), + range('firewall', FIREWALL_SESSION_RULE_BASE, + FIREWALL_SESSION_RULE_BASE + FIREWALL_SESSION_RULE_LIMIT - 1, + 'firewall cells'), + range('urlRules', URL_RULE_SESSION_BASE, + URL_RULE_SESSION_BASE + URL_RULE_SESSION_LIMIT - 1, 'step 7'), + range('trustedDirective', TRUSTED_DIRECTIVE_BASE_RULE_ID + 1, + TRUSTED_DIRECTIVE_BASE_RULE_ID + 1, 'tabId -1 allow for trusted sites'), +]); + +export const PRIORITY_BANDS = Object.freeze([ + range('stockModify', 1, 4, 'removeparam, csp, permissions, urlskip'), + range('stockBlock', STOCK_BLOCK_PRIORITY, STOCK_BLOCK_PRIORITY, 'block'), + range('stockRedirect', 11, 19, 'redirect; imported strict-block 11'), + range('engineSuppression', ENGINE_SUPPRESSION_PRIORITY, + ENGINE_SUPPRESSION_PRIORITY, 'step 6'), + range('stockExceptedRedirect', 21, STOCK_STRICTBLOCK_PRIORITY, + 'excepted redirect; stock strict-block at 29'), + range('stockAllow', STOCK_ALLOW_PRIORITY, STOCK_ALLOW_PRIORITY, 'allow'), + range('stockImportant', 40, 49, + 'important block and redirect; imported strict-block 41'), + range('user', USER_RULES_PRIORITY + 1, USER_RULES_MAX_PRIORITY, + 'developer and My filters, with their strict-block tiers'), + range('firewall', FIREWALL_PRIORITY, FIREWALL_PRIORITY, 'firewall cells'), + range('urlRules', URL_RULE_PRIORITY_MIN, URL_RULE_PRIORITY_MAX, 'step 7'), + range('siteSwitches', SITE_SWITCH_PRIORITY, SITE_SWITCH_PRIORITY, 'step 3'), + range('largeMediaAllowance', LARGE_MEDIA_ALLOWANCE_PRIORITY, + LARGE_MEDIA_ALLOWANCE_PRIORITY, 'step 3'), + range('trustedDirective', TRUSTED_DIRECTIVE_PRIORITY, + TRUSTED_DIRECTIVE_PRIORITY, 'trusted sites'), +]); + +/******************************************************************************/ + +const ruleIdOf = ruleOrId => typeof ruleOrId === 'number' + ? ruleOrId + : ruleOrId?.id; + +const ownerIn = (ranges, ruleOrId) => { + const id = ruleIdOf(ruleOrId); + if ( Number.isInteger(id) === false ) { return ''; } + for ( const { owner, min, max } of ranges ) { + if ( id >= min && id <= max ) { return owner; } + } + return ''; +}; + +// The owner key of a dynamic or session rule (or rule ID); '' when no owner +// declared the ID. +export const dynamicRuleOwner = ruleOrId => + ownerIn(DYNAMIC_RULE_ID_RANGES, ruleOrId); + +export const sessionRuleOwner = ruleOrId => + ownerIn(SESSION_RULE_ID_RANGES, ruleOrId); + +export const priorityBand = priority => + ownerIn(PRIORITY_BANDS, priority); + +// Session rules owned by the strict-block plan. This replaces the older +// priority/shape heuristic; legacy regexSubstitution session rules (IDs +// 1..826) are in range, so an upgrade replaces them. +export const isStrictBlockSessionRule = rule => + Number.isInteger(rule?.id) && rule.id >= 1 && + rule.id < STRICTBLOCK_SESSION_ID_LIMIT; + +export const isStockRegexFallbackRule = rule => + dynamicRuleOwner(rule) === 'stockRegexFallback'; + +export const isUserDynamicRule = rule => dynamicRuleOwner(rule) === 'user'; + +/******************************************************************************/ + +// The single queue through which every dynamic and session DNR write goes, +// so that owners sharing Chrome's quotas never interleave their +// read-modify-write sequences. A rejected task is returned to its caller and +// reported, and never blocks the tasks queued after it. + +let pendingDNRMutation = Promise.resolve(); +let reportDNRMutationError = ( ) => {}; + +export function setDNRMutationErrorReporter(fn) { + reportDNRMutationError = typeof fn === 'function' ? fn : ( ) => {}; +} + +export function enqueueDNRMutation(task) { + const result = pendingDNRMutation.then(task); + pendingDNRMutation = result.catch(reason => { + try { reportDNRMutationError(reason); } catch { } + }); + return result; +} diff --git a/platform/mv3/extension/js/filter-lists.js b/platform/mv3/extension/js/filter-lists.js index e9627a9250e1f..3391df957699c 100644 --- a/platform/mv3/extension/js/filter-lists.js +++ b/platform/mv3/extension/js/filter-lists.js @@ -96,7 +96,10 @@ function updateNodes(listEntries) { export function rulesetStatsFromDetails(rulesetDetails) { const { rules, filters } = rulesetDetails; - const ruleCount = rules.plain + rules.regex; + // Chromium packages keep the Chrome-validated regex rules in the list's + // static ruleset (`regexStatic`); `regex` is the dynamic fallback. + // Imported lists, Firefox and older packages have no `regexStatic`. + const ruleCount = rules.plain + (rules.regexStatic ?? 0) + rules.regex; // Stock rulesets: `accepted` also counts the filters which could not be // converted, and counts a filter once per type. Imported rulesets do not // count the filters they reject as `accepted`. diff --git a/platform/mv3/extension/js/filter-store.js b/platform/mv3/extension/js/filter-store.js index 6c78b841f59e1..d5d4b9ea9496e 100644 --- a/platform/mv3/extension/js/filter-store.js +++ b/platform/mv3/extension/js/filter-store.js @@ -56,6 +56,7 @@ let catalogs = []; let entries = []; let enabledIds = new Set(); let importedDetails = new Map(); +let regexCapacity; let initialized = false; let working = false; @@ -259,10 +260,30 @@ async function cleanupVerifiedSourceHandoffs() { /******************************************************************************/ +// Regex rules the imported lists can use, from the worker's regex capacity +// report: what is free in the browser's shared dynamic+session pool plus +// what imported lists use now, since the estimate counts the enabled ones +// again. My filters, strict blocking and other owners keep their share. +// Without a report, the browser's documented limit. +export function regexQuotaFromCapacity(report) { + const shared = report?.schemaVersion === 1 ? report.shared : undefined; + if ( Number.isSafeInteger(shared?.free) === false || shared.free < 0 ) { + return FILTER_STORE_LIMITS.regexRules; + } + const count = value => Number.isSafeInteger(value) && value > 0 ? value : 0; + // The per-realm split is unknown when the installed rules were not + // written by the update which recorded it: count every user rule. + const imported = Number.isSafeInteger(shared.dynamic?.imported) + ? shared.dynamic.imported + : shared.dynamic?.user; + return shared.free + count(imported); +} + function getQuotaLimits() { return { staticRulesets: self.cachedRulesetData?.maxNumberOfEnabledRulesets || FILTER_STORE_LIMITS.staticRulesets, + regexRules: regexQuotaFromCapacity(regexCapacity), }; } @@ -610,12 +631,15 @@ async function reloadCatalogs() { } async function refreshEnabled() { - const [ rulesets = [], imported = [] ] = await Promise.all([ + const [ rulesets = [], imported = [], capacity ] = await Promise.all([ strictSendMessage({ what: 'getEnabledRulesets' }), strictSendMessage({ what: 'getImportedLists' }), + // An estimate input only: its failure falls back to the limit. + strictSendMessage({ what: 'getRegexCapacity' }).catch(( ) => undefined), ]); enabledIds = new Set(rulesets); importedDetails = new Map(imported.map(list => [ list.id, list ])); + regexCapacity = capacity; } function quotaAllows(toEnable) { diff --git a/platform/mv3/extension/js/offscreen/compile-filters.js b/platform/mv3/extension/js/offscreen/compile-filters.js index 9065fcd6d18be..b2b0af7692511 100644 --- a/platform/mv3/extension/js/offscreen/compile-filters.js +++ b/platform/mv3/extension/js/offscreen/compile-filters.js @@ -50,6 +50,7 @@ import { pendingImportedMetadataKey, } from '../imported-list-metadata.js'; import { createCompilerStorageClient } from '../offscreen-storage.js'; +import { deriveUserStrictBlockRules } from '../strictblock-rules.js'; import { fetchList } from './fetch-list.js'; import { isCredentialFreeHTTPS } from '../imported-fetch-policy.js'; import { isVerifiedSourceKey } from '../verified-source-handoff.js'; @@ -449,6 +450,10 @@ export async function toMv3Data(rulesetid, compiledData) { `Merged DNR validation failed: ${reasons.join(',')}` ); } + // Strict-block redirect templates for the $doc filters. The service + // worker installs them only while it can do so safely; the blocks + // above stay the fallback. + output.strictBlockRules = deriveUserStrictBlockRules(output.dnrRules); } if ( compiledData.popupFilters?.length ) { output.popupFilters = compiledData.popupFilters; @@ -1028,6 +1033,15 @@ async function runCompiler() { } else { toRemove.push(dnrKey); } + const strictBlockKey = compiledStorageKey( + compiledGeneration, + `${id}Filters.strictBlockRules` + ); + if ( compiled.strictBlockRules?.length ) { + values[strictBlockKey] = compiled.strictBlockRules; + } else { + toRemove.push(strictBlockKey); + } const scriptsKey = compiledStorageKey( compiledGeneration, `${id}Filters.userScripts` diff --git a/platform/mv3/extension/js/power-settings.js b/platform/mv3/extension/js/power-settings.js index 89f8ba555b05b..080df365ea8cd 100644 --- a/platform/mv3/extension/js/power-settings.js +++ b/platform/mv3/extension/js/power-settings.js @@ -33,6 +33,11 @@ let siteRulesLoading = false; let siteRulesSaving = false; let diagnosticsLoaded = false; let diagnosticsLoading = false; +let lastCapabilities; +// Only the newest regex-capacity answer is shown: a slow refresh must not +// replace the result of a later 'Check now'. +let regexCapacityRequests = 0; +let regexVerifying = false; let protectionRefreshTimer; // Profile whose radio this page last checked to show the live settings. Any // other checked radio, or none, is a choice the user has not applied yet. @@ -453,13 +458,28 @@ async function loadSiteRules() { } } +// [ label, value, title?, items? ]: `items` are shown as a list under the +// value, where keyboard, touch and screen-reader users can read them too. function appendDefinitionList(list, entries) { list.replaceChildren(); - for ( const [ label, value ] of entries ) { + for ( const [ label, value, title, items ] of entries ) { const term = document.createElement('dt'); term.textContent = label; const definition = document.createElement('dd'); definition.textContent = value; + if ( title ) { definition.title = title; } + if ( Array.isArray(items) && items.length !== 0 ) { + const itemList = document.createElement('ul'); + itemList.className = 'definitionItems'; + for ( const item of items ) { + const entry = document.createElement('li'); + const code = document.createElement('code'); + code.textContent = item; + entry.append(code); + itemList.append(entry); + } + definition.append(itemList); + } list.append(term, definition); } } @@ -472,6 +492,26 @@ function formatQuota(value) { return Number.isSafeInteger(value) ? value.toLocaleString() : '—'; } +const countOf = value => Number.isSafeInteger(value) && value > 0 ? value : 0; + +// How the strict-block page learns the blocked address, as classified by +// runtime-capabilities-core.js: the browser reports it (webRequest or +// rule-match events), the redirect carries it in the page's own address +// (Firefox), only the start of the navigation is known, or nothing is. +const strictBlockAddressKeys = new Map([ + [ 'webrequest', 'diagnosticsStrictBlockAddressExact' ], + [ 'rule-match', 'diagnosticsStrictBlockAddressExact' ], + [ 'regex-substitution', 'diagnosticsStrictBlockAddressFragment' ], + [ 'navigation-start', 'diagnosticsStrictBlockAddressApproximate' ], +]); + +function strictBlockAddressText(source) { + return message( + strictBlockAddressKeys.get(source) ?? + 'diagnosticsStrictBlockAddressUnavailable' + ); +} + function renderCapabilities(capabilities) { const quotas = capabilities?.quotas || {}; appendDefinitionList(qs$('#runtimeCapabilities'), [ @@ -481,14 +521,171 @@ function renderCapabilities(capabilities) { [ message('diagnosticsUserScripts'), availability(capabilities.userScripts) ], [ message('diagnosticsOffscreenCompiler'), availability(capabilities.offscreenCompilation) ], ...capabilityDetailsRows(capabilities, i18n.getUILanguage?.() || navigator.language), + [ message('diagnosticsStrictBlockAddress'), strictBlockAddressText(capabilities.strictBlockUrlSource) ], [ message('diagnosticsStaticRules'), formatQuota(quotas.availableStaticRules) ], [ message('diagnosticsDynamicRules'), formatQuota(quotas.dynamicRules) ], [ message('diagnosticsSessionRules'), formatQuota(quotas.sessionRules) ], [ message('diagnosticsRegexRules'), formatQuota(quotas.regexRules) ], ]); + renderStrictBlockAddressSetup(capabilities); renderWebRequestSetup(capabilities); } +// The optional webRequest permission is the cheapest exact address source +// (main-frame redirects only). Offer it only where this browser can still +// grant it and the page does not already get the address from it. +function renderStrictBlockAddressSetup(capabilities) { + const setup = qs$('#strictBlockAddressSetup'); + if ( setup === null ) { return; } + setup.hidden = capabilities.networkObservationRequestable !== true || + capabilities.networkObservationPermissionGranted === true || + capabilities.strictBlockUrlSource === 'webrequest' || + capabilities.strictBlockUrlSource === 'regex-substitution'; +} + +dom.on('#grantStrictBlockWebRequest', 'click', async ( ) => { + const button = qs$('#grantStrictBlockWebRequest'); + button.disabled = true; + try { + // Ask first, while the click is still a user gesture. + const granted = await browser.permissions.request({ + permissions: [ 'webRequest' ], + }).catch(( ) => false); + if ( granted !== true ) { + setOperationStatus(message('loggerPermissionRefused'), 'error'); + return; + } + setOperationStatus(message('diagnosticsStrictBlockAddressGranted')); + await refreshDiagnostics(); + // The worker switches to the new source on permissions.onAdded, + // which can arrive after this page asked: show what it reports + // once more, without assuming the switch happened. + if ( lastCapabilities?.strictBlockUrlSource !== 'webrequest' ) { + await new Promise(resolve => { self.setTimeout(resolve, 1000); }); + await refreshDiagnostics(); + } + } finally { + button.disabled = false; + } +}); + +/******************************************************************************/ + +// Regex rule capacity: the report of ruleset-manager.js getRegexCapacity() +// (schema in regex-capacity.js summarizeRegexCapacity). Every number is what +// the browser or the package reports; an unknown one is shown as such. + +function regexUsage(used, limit) { + return message('diagnosticsRegexUsage', [ + formatQuota(used), + formatQuota(limit), + ]); +} + +// Built-in regex rules this browser skipped at the last 'Check now' for this +// version and browser. Not checked is not zero. +function regexSkippedText(staticPool) { + if ( staticPool.enabled === 0 ) { return formatQuota(0); } + const skipped = staticPool.skippedByBrowser; + if ( Number.isSafeInteger(skipped) === false ) { + return message('diagnosticsRegexNotChecked'); + } + if ( Number.isFinite(staticPool.checkedAt) === false ) { + return formatQuota(skipped); + } + return `${formatQuota(skipped)} · ${new Date(staticPool.checkedAt).toLocaleString()}`; +} + +function regexCapacityRows(report) { + const staticPool = report.static ?? {}; + const shared = report.shared ?? {}; + const dynamic = shared.dynamic ?? {}; + const session = shared.session ?? {}; + const samples = Array.isArray(staticPool.skippedSamples) + ? staticPool.skippedSamples + : []; + const sampleLines = samples.map(sample => + `${sample.rulesetId}: ${sample.regex}` + ); + const rows = [ + [ message('diagnosticsRegexStatic'), regexUsage(staticPool.enabled, staticPool.limit) ], + [ message('diagnosticsRegexVerifiedWith'), typeof staticPool.verifiedWith === 'string' && + staticPool.verifiedWith !== '' + ? staticPool.verifiedWith + : message('diagnosticsRegexNotVerified') ], + [ message('diagnosticsRegexRejectedAtBuild'), formatQuota(staticPool.rejectedAtBuild) ], + [ message('diagnosticsRegexSkipped'), regexSkippedText(staticPool), + sampleLines.join('\n'), sampleLines ], + [ message('diagnosticsRegexShared'), regexUsage(shared.used, shared.limit) ], + [ message('diagnosticsRegexUser'), formatQuota(dynamic.user) ], + [ message('diagnosticsRegexStrictBlock'), formatQuota(session.strictBlock) ], + ]; + // Only where they exist, so that the rows add up to the shared usage: + // built-in regex rules installed as dynamic rules (Firefox, or a list + // over the built-in limit) and the shared pool's other owners. + if ( countOf(dynamic.stockFallback) !== 0 ) { + rows.push([ message('diagnosticsRegexStockFallback'), formatQuota(dynamic.stockFallback) ]); + } + const other = countOf(dynamic.other) + countOf(session.other); + if ( other !== 0 ) { + rows.push([ message('diagnosticsRegexOther'), formatQuota(other) ]); + } + const notEnabled = Array.isArray(report.rulesetsNotEnabled) + ? report.rulesetsNotEnabled + : []; + rows.push( + [ message('diagnosticsRegexFree'), formatQuota(shared.free) ], + [ message('diagnosticsRegexDropped'), formatQuota( + countOf(shared.droppedStrictBlock) + countOf(shared.droppedImported) + ) ], + [ message('diagnosticsRulesetsNotEnabled'), notEnabled.length === 0 + ? formatQuota(0) + : notEnabled.join(', ') ], + ); + return rows; +} + +function renderRegexCapacity(report) { + const panel = qs$('#regexCapacityPanel'); + const list = qs$('#regexCapacity'); + if ( panel === null || list === null ) { return; } + // A worker which does not provide the report answers nothing: show no + // numbers rather than assumed ones. + if ( report?.schemaVersion !== 1 ) { + panel.hidden = true; + return; + } + panel.hidden = false; + appendDefinitionList(list, regexCapacityRows(report)); +} + +async function loadRegexCapacity({ verifyStatic = false } = {}) { + // A 'Check now' in progress answers with the newer numbers. + if ( regexVerifying && verifyStatic === false ) { return; } + const request = ++regexCapacityRequests; + const report = await sendMessage(verifyStatic + ? { what: 'getRegexCapacity', verifyStatic: true } + : { what: 'getRegexCapacity' } + ); + if ( request !== regexCapacityRequests ) { return; } + renderRegexCapacity(report); +} + +dom.on('#verifyStaticRegex', 'click', async ( ) => { + if ( regexVerifying ) { return; } + const button = qs$('#verifyStaticRegex'); + button.disabled = true; + regexVerifying = true; + try { + await loadRegexCapacity({ verifyStatic: true }); + } catch { + setOperationStatus(message('diagnosticsLoadFailed'), 'error'); + } finally { + regexVerifying = false; + button.disabled = false; + } +}); + function renderWebRequestSetup(capabilities) { let panel = qs$('#webRequestSetup'); if ( panel === null ) { @@ -579,7 +776,12 @@ async function refreshDiagnostics() { deviceMemoryGiB: globalThis.navigator?.deviceMemory, }), sendMessage({ what: 'getPopupDiagnostics' }), + // Its own failure does not hide the other diagnostics. + loadRegexCapacity().catch(( ) => { + setOperationStatus(message('diagnosticsLoadFailed'), 'error'); + }), ]); + lastCapabilities = capabilities; renderCapabilities(capabilities); renderPerformance(profile); renderPopupDiagnostics(diagnostics); diff --git a/platform/mv3/extension/js/regex-capacity.js b/platform/mv3/extension/js/regex-capacity.js new file mode 100644 index 0000000000000..e37711bcf2b8b --- /dev/null +++ b/platform/mv3/extension/js/regex-capacity.js @@ -0,0 +1,266 @@ +/******************************************************************************* + uBlock Plus+ - regex rule capacity: the shared pool and the static pool + Copyright (C) 2026-present uBlock Plus+ contributors; GPL-3.0-or-later + + Pure module: no browser API and no import from ext.js. + + Chrome counts regexFilter rules in two pools. Static rulesets have their + own aggregate pool of 1,000 (stock regex packaged by make-rulesets.js). + Dynamic and session rules share a second pool of + MAX_NUMBER_OF_REGEX_RULES (1,000): the stock regex fallback, developer + DNR rules, My filters, imported lists, stock badfilter residuals and the + strict-block session plan, plus whatever later owners declare in + dnr-namespaces.js. Firefox documents one limit per namespace; the plans + keep the shared accounting there too, as before (Firefox is untested). +******************************************************************************/ + +import { + dynamicRuleOwner, + sessionRuleOwner, +} from './dnr-namespaces.js'; + +/******************************************************************************/ + +export const STATIC_REGEX_LIMIT = 1000; + +// Owners of the rules in one user-realm dynamic update, in the order +// ruleset-manager.js assembles them. +export const USER_REGEX_OWNERS = Object.freeze([ + 'developer', 'sandbox', 'imported', 'stock-residual', +]); + +const ownerCountKey = owner => owner === 'stock-residual' + ? 'stockResidual' + : owner; + +const isRegexRule = rule => Boolean(rule?.condition?.regexFilter); + +const isException = rule => + rule?.action?.type === 'allow' || rule?.action?.type === 'allowAllRequests'; + +const toCount = value => Number.isSafeInteger(value) && value > 0 ? value : 0; + +const toLimit = value => Number.isSafeInteger(value) && value >= 0 + ? value + : Number.MAX_SAFE_INTEGER; + +/******************************************************************************/ + +// Regex overflow policy of one user-realm dynamic update. `owners[i]` tags +// `rules[i]`; `retainedRegexCount` is the regex usage the update does not +// replace (stock fallback, other owners' dynamic and session regex rules). +// Strict-block session regex rules are not counted: they have lower +// precedence and the session plan is rebuilt into whatever is left. +// +// When the update does not fit, imported regex rules which are not +// exceptions are dropped from the end. Dropping a block, redirect or header +// rule only loses that filter; dropping an exception could broaden other +// lists' blocks, so exceptions, My filters, developer rules and stock +// residuals are never dropped. If the update still does not fit, the result +// is fatal and the caller keeps the previous rules. +export function planUserRegexBudget({ + rules = [], + owners = [], + retainedRegexCount = 0, + maxRegexCount, +} = {}) { + const max = toLimit(maxRegexCount); + const retained = toCount(retainedRegexCount); + const input = Array.isArray(rules) ? rules : []; + const keep = input.map(( ) => true); + let regexCount = 0; + for ( const rule of input ) { + if ( isRegexRule(rule) ) { regexCount += 1; } + } + let excess = retained + regexCount - max; + let droppedImported = 0; + for ( let i = input.length - 1; i >= 0 && excess > 0; i-- ) { + if ( owners[i] !== 'imported' ) { continue; } + const rule = input[i]; + if ( isRegexRule(rule) === false || isException(rule) ) { continue; } + keep[i] = false; + droppedImported += 1; + regexCount -= 1; + excess -= 1; + } + const out = { + rules: [], + owners: [], + counts: { developer: 0, sandbox: 0, imported: 0, stockResidual: 0 }, + droppedImported, + required: retained + regexCount, + maxRegexCount: max, + fatal: retained + regexCount > max, + }; + for ( let i = 0; i < input.length; i++ ) { + if ( keep[i] === false ) { continue; } + const rule = input[i]; + out.rules.push(rule); + out.owners.push(owners[i]); + if ( isRegexRule(rule) === false ) { continue; } + const key = ownerCountKey(owners[i]); + if ( Object.hasOwn(out.counts, key) ) { out.counts[key] += 1; } + } + return out; +} + +/******************************************************************************/ + +const countRegexByOwner = (rules, ownerOf) => { + const byOwner = {}; + let total = 0; + for ( const rule of Array.isArray(rules) ? rules : [] ) { + if ( isRegexRule(rule) === false ) { continue; } + const owner = ownerOf(rule) || 'unknown'; + byOwner[owner] = (byOwner[owner] ?? 0) + 1; + total += 1; + } + return { byOwner, total }; +}; + +const sumOver = (entries, pick) => entries.reduce((sum, entry) => + sum + toCount(pick(entry)), 0 +); + +const truncate = (text, max = 160) => typeof text === 'string' && text.length > max + ? `${text.slice(0, max)}…` + : text; + +// The static check ('Check now') applies to this package, this browser +// version and every enabled list with static regex rules; otherwise the +// number is unknown and reported as null, never as 0. +function staticCheckResult(check, enabledStock, extensionVersion, chromeMajor) { + const unknown = { skipped: null, samples: [], checkedAt: null }; + if ( check?.schemaVersion !== 1 ) { return unknown; } + if ( check.extensionVersion !== extensionVersion ) { return unknown; } + if ( check.chromeMajor !== chromeMajor ) { return unknown; } + const byRuleset = check.byRuleset ?? {}; + let skipped = 0; + for ( const details of enabledStock ) { + if ( toCount(details.rules?.regexStatic) === 0 ) { continue; } + const entry = byRuleset[details.id]; + if ( Number.isSafeInteger(entry?.skipped) === false ) { return unknown; } + skipped += entry.skipped; + } + const enabledIds = new Set(enabledStock.map(details => details.id)); + const samples = (Array.isArray(check.samples) ? check.samples : []) + .filter(sample => enabledIds.has(sample?.rulesetId)) + .slice(0, 10) + .map(sample => ({ ...sample, regex: truncate(sample.regex) })); + return { + skipped, + samples, + checkedAt: Number.isFinite(check.checkedAt) ? check.checkedAt : null, + }; +} + +// The report behind Dashboard > Diagnostics > Regex rule capacity and the +// Filter Store estimate. Every number comes from what the browser reports +// as installed (dynamic and session rules, enabled rulesets) or from the +// package's own build reports; nothing is assumed. +export function summarizeRegexCapacity(input = {}) { + const stockRulesets = Array.isArray(input.stockRulesets) + ? input.stockRulesets.filter(details => typeof details?.id === 'string') + : []; + const enabledIds = new Set(Array.isArray(input.enabledRulesetIds) + ? input.enabledRulesetIds + : []); + const enabledStock = stockRulesets.filter(details => enabledIds.has(details.id)); + const regexDetails = input.regexDetails?.schemaVersion === 1 + ? input.regexDetails + : undefined; + + // Static pool + const rejectedReason = (details, reason) => + details.rules?.rejectedReasons?.[reason]; + const memory = sumOver(enabledStock, details => + rejectedReason(details, 'unsupported-regex-memory')); + const syntax = sumOver(enabledStock, details => + rejectedReason(details, 'unsupported-regex-syntax')); + const check = staticCheckResult(input.staticCheck, enabledStock, + input.extensionVersion, input.chromeMajor); + const staticPool = { + limit: toCount(regexDetails?.staticRegexLimit) || STATIC_REGEX_LIMIT, + packaged: sumOver(stockRulesets, details => details.rules?.regexStatic), + enabled: sumOver(enabledStock, details => details.rules?.regexStatic), + verifiedWith: typeof regexDetails?.verifiedWith === 'string' + ? regexDetails.verifiedWith + : '', + rejectedAtBuild: memory + syntax, + rejectedAtBuildReasons: { memory, syntax }, + skippedByBrowser: check.skipped, + skippedSamples: check.samples, + checkedAt: check.checkedAt, + }; + + // Shared pool, per owner + const dynamic = countRegexByOwner(input.dynamicRules, dynamicRuleOwner); + const session = countRegexByOwner(input.sessionRules, sessionRuleOwner); + const userTotal = dynamic.byOwner.user ?? 0; + const record = input.userRecord; + const recorded = record?.schemaVersion === 1 + ? { + developer: toCount(record.developer), + sandbox: toCount(record.sandbox), + imported: toCount(record.imported), + stockResidual: toCount(record.stockResidual), + } + : undefined; + // The per-realm split is only known for the rules installed by the + // update which wrote the record. + const attributed = recorded !== undefined && + recorded.developer + recorded.sandbox + recorded.imported + + recorded.stockResidual === userTotal; + const stockFallback = dynamic.byOwner.stockRegexFallback ?? 0; + const strictBlockRegex = session.byOwner.strictBlock ?? 0; + const limit = toLimit(input.sharedLimit ?? 1000); + const used = dynamic.total + session.total; + const plan = input.plan?.schemaVersion === 1 ? input.plan : undefined; + const shared = { + limit, + dynamic: { + stockFallback, + developer: attributed ? recorded.developer : null, + sandbox: attributed ? recorded.sandbox : null, + imported: attributed ? recorded.imported : null, + stockResidual: attributed ? recorded.stockResidual : null, + user: userTotal, + other: dynamic.total - stockFallback - userTotal, + byOwner: dynamic.byOwner, + }, + session: { + strictBlock: strictBlockRegex, + other: session.total - strictBlockRegex, + byOwner: session.byOwner, + }, + used, + free: Math.max(0, limit - used), + droppedStrictBlock: plan + ? toCount(plan.dropped?.stockRegexPool) + + toCount(plan.dropped?.userRegexPool) + : 0, + droppedImported: record?.schemaVersion === 1 + ? toCount(record.droppedImported) + : 0, + }; + + const exclusions = input.exclusions; + return { + schemaVersion: 1, + static: staticPool, + shared, + rulesetsNotEnabled: Array.isArray(input.rulesetsNotEnabled) + ? input.rulesetsNotEnabled.slice() + : [], + strictBlock: { + redirectCount: toCount(plan?.redirectCount), + userRedirects: toCount(plan?.counts?.sandbox) + + toCount(plan?.counts?.imported), + userCandidates: toCount(plan?.userCandidates), + exclusions: Array.isArray(exclusions?.hosts) ? exclusions.hosts.length : 0, + allExcluded: exclusions?.all === true, + droppedSessionLimit: toCount(plan?.dropped?.stockSessionLimit) + + toCount(plan?.dropped?.userSessionLimit), + }, + }; +} diff --git a/platform/mv3/extension/js/ruleset-manager.js b/platform/mv3/extension/js/ruleset-manager.js index a07fd44f90dc9..0c6b7dfbf7832 100644 --- a/platform/mv3/extension/js/ruleset-manager.js +++ b/platform/mv3/extension/js/ruleset-manager.js @@ -26,6 +26,21 @@ import { compiledStorageKey, } from './compiled-storage.js'; +import { + SPECIAL_RULES_REALM, + TRUSTED_DIRECTIVE_BASE_RULE_ID, + TRUSTED_DIRECTIVE_PRIORITY, + USER_RULES_BASE_RULE_ID, + enqueueDNRMutation, + setDNRMutationErrorReporter, +} from './dnr-namespaces.js'; + +import { + USER_RULES_PRIORITY, + isStrictBlockSessionRule, + planStrictBlockSessionRules, +} from './strictblock-rules.js'; + import { addImportedLists, getEnabledImportedLists, @@ -43,6 +58,11 @@ import { webextFlavor, } from './ext.js'; +import { + planUserRegexBudget, + summarizeRegexCapacity, +} from './regex-capacity.js'; + import { rulesetConfig, saveRulesetConfig, @@ -58,26 +78,26 @@ import { rulesFromText } from './dnr-parser.js'; /******************************************************************************/ -const SPECIAL_RULES_REALM = 5000000; -const USER_RULES_BASE_RULE_ID = 9000000; -const USER_RULES_PRIORITY = 1000000; -const TRUSTED_DIRECTIVE_BASE_RULE_ID = 8000000; -const TRUSTED_DIRECTIVE_PRIORITY = USER_RULES_PRIORITY + 1000000; -const STRICTBLOCK_PRIORITY = 29; // Developer DNR text installed by the last successful user-rules update. const USER_DNR_APPLIED_KEY = 'userDnrRules.applied'; +// Session storage: the installed strict-block session plan (owners, counts). +const STRICTBLOCK_PLAN_KEY = 'strictBlock.plan'; +// Local (permanent) and session (temporary) "don't warn" hostnames. +const STRICTBLOCK_EXCLUSIONS_KEY = 'excludedStrictBlockHostnames'; +// Local storage: regex usage per realm of the installed user rules, and the +// last 'Check now' of the packaged static regex rules. +const REGEX_CAPACITY_USER_KEY = 'regexCapacity.user'; +const REGEX_CAPACITY_STATIC_KEY = 'regexCapacity.staticCheck'; +const DEFAULT_MAX_SESSION_RULES = 5000; const stockBadfilterManager = createStockBadfilterManager({ dnr, read: localRead, write: localWrite, remove: localRemove, fetchJSON, }); -let pendingDNRMutation = Promise.resolve(); -function enqueueDNRMutation(task) { - const result = pendingDNRMutation.then(task); - pendingDNRMutation = result.catch(reason => { - ublockPlusErr(`DNR transaction queue/${reason}`); - }); - return result; -} +// Every dynamic and session DNR write, of every owner, goes through the one +// queue of dnr-namespaces.js. It is re-exported here for the other writers. +setDNRMutationErrorReporter(reason => { + ublockPlusErr(`DNR transaction queue/${reason}`); +}); function appendDNRResponseError(response, reason) { const message = `${reason}`; @@ -86,9 +106,9 @@ function appendDNRResponseError(response, reason) { : `${response.error}; ${message}`; } -async function refreshSessionRules(response = {}) { +async function refreshSessionRules(response = {}, options = {}) { try { - const result = await updateSessionRulesNow(); + const result = await updateSessionRulesNow(options); if ( result?.error ) { appendDNRResponseError(response, result.error); } @@ -101,25 +121,11 @@ async function refreshSessionRules(response = {}) { /******************************************************************************/ -const isStrictBlockRule = rule => { - if ( rule.priority !== STRICTBLOCK_PRIORITY ) { return false; } - if ( rule.condition?.resourceTypes === undefined ) { return false; } - if ( rule.condition.resourceTypes.length !== 1 ) { return false; } - if ( rule.condition.resourceTypes[0] !== 'main_frame' ) { return false; } - if ( rule.action.type === 'redirect' ) { - const substitution = rule.action.redirect.regexSubstitution; - return substitution !== undefined && - substitution.includes('/strictblock.'); - } - if ( rule.action.type === 'allow' ) { - return Array.isArray(rule.condition?.requestDomains); - } - return false; -}; - +// Session rules with IDs 1..999,999 belong to the strict-block plan +// (dnr-namespaces.js); every other session rule belongs to another owner. const nativeRulesetState = createRulesetNativeState({ dnr, read: localRead, write: localWrite, remove: localRemove, - ownsSession: isStrictBlockRule, + ownsSession: isStrictBlockSessionRule, getPackageState: async ( ) => { const manifest = runtime.getManifest(); const index = await fetchJSON('/rulesets/badfilter-details'); @@ -137,12 +143,28 @@ const nativeRulesetState = createRulesetNativeState({ export function snapshotNativeRulesetState() { return enqueueDNRMutation(async ( ) => { await stockBadfilterManager.recover(); - return nativeRulesetState.snapshot(); + const state = await nativeRulesetState.snapshot(); + // The plan describes the snapshot's strict-block session rules: the + // strict-block page and the logger resolve their IDs through it. + const plan = await sessionRead(STRICTBLOCK_PLAN_KEY); + if ( plan?.schemaVersion === 1 ) { + state.strictBlockPlan = plan; + } + return state; }); } export function restoreNativeRulesetState(state) { - return enqueueDNRMutation(( ) => nativeRulesetState.restore(state)); + return enqueueDNRMutation(async ( ) => { + await nativeRulesetState.restore(state); + // The restored dynamic rules belong to the generation the caller + // restores as active. + installedUserGeneration = undefined; + await storeStrictBlockPlan(state?.strictBlockPlan?.schemaVersion === 1 + ? state.strictBlockPlan + : undefined + ); + }); } export function finalizeNativeRulesetRecovery() { @@ -166,41 +188,39 @@ export function getRulesetDetails() { /******************************************************************************/ -async function pruneInvalidRegexRules(realm, rulesIn, rejected = []) { - const validateRegex = (options, key) => { - return dnr.isRegexSupported(options).then(result => { - pruneInvalidRegexRules.validated.set(key, - result?.isSupported === true ? true : result?.reason || 'unsupported'); - if ( result.isSupported ) { return true; } - rejected.push({ regex: options.regex, reason: result?.reason }); - return false; - }); - }; +// Chrome compiles a regex with the rule's own matching and capture flags. +const regexOptionsOf = rule => ({ + regex: rule.condition.regexFilter, + isCaseSensitive: rule.condition.isUrlFilterCaseSensitive === true, + requireCapturing: rule.action?.redirect?.regexSubstitution !== undefined, +}); - // Validate regex-based rules - const toCheck = []; - for ( const rule of rulesIn ) { - if ( rule.condition?.regexFilter === undefined ) { - toCheck.push(true); - continue; - } - const { regexFilter } = rule.condition; - const options = { regex: regexFilter, - isCaseSensitive: rule.condition.isUrlFilterCaseSensitive === true, - requireCapturing: rule.action?.redirect?.regexSubstitution !== undefined }; - const key = JSON.stringify(options); - const reason = pruneInvalidRegexRules.validated.get(key); - if ( reason !== undefined ) { - toCheck.push(reason === true); - if ( reason === true ) { continue; } - rejected.push({ regex: regexFilter, reason }); - continue; - } - toCheck.push(validateRegex(options, key)); - } +// true, or the reason the browser gave. Verdicts are cached per exact +// options for the lifetime of the worker. +async function regexSupport(options) { + const key = JSON.stringify(options); + const known = pruneInvalidRegexRules.validated.get(key); + if ( known !== undefined ) { return known; } + const result = await dnr.isRegexSupported(options); + const verdict = result?.isSupported === true + ? true + : result?.reason || 'unsupported'; + pruneInvalidRegexRules.validated.set(key, verdict); + return verdict; +} - // Collate results - const isValid = await Promise.all(toCheck); +async function pruneInvalidRegexRules(realm, rulesIn, rejected = []) { + // Validate regex-based rules + const verdicts = await Promise.all(rulesIn.map(rule => + rule.condition?.regexFilter === undefined + ? true + : regexSupport(regexOptionsOf(rule)) + )); + const isValid = verdicts.map((verdict, i) => { + if ( verdict === true ) { return true; } + rejected.push({ regex: rulesIn[i].condition.regexFilter, reason: verdict }); + return false; + }); for ( let i = 0; i < rulesIn.length; i++ ) { if ( isValid[i] ) { continue; } @@ -234,9 +254,42 @@ const countRegexRules = rules => rules.reduce((count, rule) => count + (rule?.condition?.regexFilter ? 1 : 0), 0 ); -async function getDynamicRegexRuleCount() { - const rules = await dnr.getDynamicRules(); - return countRegexRules(rules); +const maxRegexRuleCount = ( ) => Number.isSafeInteger( + dnr.MAX_NUMBER_OF_REGEX_RULES +) ? dnr.MAX_NUMBER_OF_REGEX_RULES : Number.MAX_SAFE_INTEGER; + +// Chromium accounts dynamic and session regex rules against one pool. Before +// a dynamic update which needs more of it, the strict-block session regex +// rules make room: they have lower precedence, and the session plan is +// rebuilt into what is left afterwards. Other owners' session rules are +// never displaced. Returns the removed rules, for a rollback. +async function displaceStrictBlockRegexRules( + dynamicRegexCount, maxRegexCount, sessionRules +) { + if ( sessionRules === undefined ) { + sessionRules = await dnr.getSessionRules(); + } + if ( dynamicRegexCount + countRegexRules(sessionRules) <= maxRegexCount ) { + return []; + } + const displaced = sessionRules.filter(rule => + isStrictBlockSessionRule(rule) && Boolean(rule.condition?.regexFilter) + ); + if ( displaced.length === 0 ) { return []; } + await dnr.updateSessionRules({ + removeRuleIds: displaced.map(rule => rule.id), + }); + return displaced; +} + +// After a rejected dynamic update: returns the failure reason, if any. +async function restoreDisplacedRegexRules(displaced) { + if ( displaced.length === 0 ) { return; } + try { + await dnr.updateSessionRules({ addRules: displaced }); + } catch ( reason ) { + return reason; + } } /******************************************************************************/ @@ -369,9 +422,7 @@ async function updateDynamicAndSessionRulesNow() { rule.id = ruleId++; } const dynamicRegexCountAfter = retainedRegexCount + addedRegexCount; - const maxRegexCount = Number.isSafeInteger( - dnr.MAX_NUMBER_OF_REGEX_RULES - ) ? dnr.MAX_NUMBER_OF_REGEX_RULES : Number.MAX_SAFE_INTEGER; + const maxRegexCount = maxRegexRuleCount(); const response = {}; if ( safePlan.warnings.length !== 0 ) { response.warnings = safePlan.warnings; @@ -387,20 +438,11 @@ async function updateDynamicAndSessionRulesNow() { } let displacedSessionRegexRules = []; - let sessionRegexRemoved = false; try { if ( dynamicRegexCountAfter !== dynamicRegexCountBefore ) { - const sessionRules = await dnr.getSessionRules(); - if ( dynamicRegexCountAfter + countRegexRules(sessionRules) > - maxRegexCount ) { - displacedSessionRegexRules = sessionRules.filter(rule => - Boolean(rule.condition?.regexFilter) - ); - await dnr.updateSessionRules({ - removeRuleIds: displacedSessionRegexRules.map(a => a.id), - }); - sessionRegexRemoved = true; - } + displacedSessionRegexRules = await displaceStrictBlockRegexRules( + dynamicRegexCountAfter, maxRegexCount + ); } await dnr.updateDynamicRules({ addRules: safeAddRules, @@ -415,15 +457,11 @@ async function updateDynamicAndSessionRulesNow() { } catch(reason) { ublockPlusErr(`updateDynamicAndSessionRules/${reason}`); response.error = `${reason}`; - if ( sessionRegexRemoved ) { - try { - await dnr.updateSessionRules({ - addRules: displacedSessionRegexRules, - }); - } catch ( restoreReason ) { - response.error += - `; session rollback failed (${restoreReason})`; - } + const restoreReason = await restoreDisplacedRegexRules( + displacedSessionRegexRules + ); + if ( restoreReason !== undefined ) { + response.error += `; session rollback failed (${restoreReason})`; } } @@ -446,159 +484,334 @@ export function updateDynamicAndSessionRules() { /******************************************************************************/ -async function updateStrictBlockRules(currentRules, addRules, removeRuleIds) { - // Remove existing strictblock-related rules - for ( const rule of currentRules ) { - if ( isStrictBlockRule(rule) === false ) { continue; } - removeRuleIds.push(rule.id); - } - - if ( rulesetConfig.strictBlockMode === false ) { return; } +// Strict blocking: a blocked top-level document shows strictblock.html +// instead of the browser's error page (classic uBO: "document blocked"). +// Session rule IDs 1..999,999 hold the plan of strictblock-rules.js: the +// enabled stock lists' strict-block redirects, redirects for the $doc +// filters of My filters and imported lists, and their exclusion allows. The +// dynamic block of a user $doc filter is never modified: it stays the +// fallback whenever its redirect is not installed. + +// The generation whose user rules this worker last installed as dynamic +// rules; unknown after a restart, where the active generation applies. +let installedUserGeneration; +let strictBlockPlanListener; +let strictBlockUrlSourceProvider = ( ) => false; +let strictBlockExclusionProvider = readStoredStrictBlockExclusions; + +// Called with the stored plan whenever the installed plan changes. +export function setStrictBlockPlanListener(fn) { + strictBlockPlanListener = typeof fn === 'function' ? fn : undefined; +} - // https://github.com/uBlockOrigin/uBOL-home/issues/428#issuecomment-3172663563 - // https://bugs.webkit.org/show_bug.cgi?id=298199 - // https://developer.apple.com/forums/thread/756214 - if ( webextFlavor === 'safari' ) { return; } +// A function which returns true while the strict-block page can learn the +// exact blocked address (strictblock-tracker.js). User redirects are only +// installed then; otherwise user $doc filters stay plain blocks. +export function setStrictBlockUrlSourceProvider(fn) { + strictBlockUrlSourceProvider = typeof fn === 'function' ? fn : ( ) => false; +} - const [ - hasOmnipotence, - rulesetDetails, - permanentlyExcluded = [], - temporarilyExcluded = [], - ] = await Promise.all([ - hasBroadHostPermissions(), - getEnabledRulesetsDetails(true), - localRead('excludedStrictBlockHostnames'), - sessionRead('excludedStrictBlockHostnames'), - ]); +// Where exclusions come from. Per-site switches (roadmap step 3) replace +// the default, which reads the stored "don't warn" hostnames. +export function setStrictBlockExclusionProvider(fn) { + strictBlockExclusionProvider = typeof fn === 'function' + ? fn + : readStoredStrictBlockExclusions; +} - // Strict-block rules can only be enforced with omnipotence - if ( hasOmnipotence === false ) { - localRemove('excludedStrictBlockHostnames'); - sessionRemove('excludedStrictBlockHostnames'); - return; +const urlSourceIsExact = ( ) => { + try { + return strictBlockUrlSourceProvider() === true; + } catch { + return false; } +}; - // Fetch strick-block rules - const toFetch = []; - for ( const details of rulesetDetails ) { - if ( Boolean(details.rules.strictblock) === false ) { continue; } - toFetch.push(fetchJSON(`/rulesets/strictblock/${details.id}`)); +async function readStoredStrictBlockExclusions() { + const [ permanent, temporary ] = await Promise.all([ + localRead(STRICTBLOCK_EXCLUSIONS_KEY), + sessionRead(STRICTBLOCK_EXCLUSIONS_KEY), + ]); + const hosts = []; + for ( const list of [ permanent, temporary ] ) { + if ( Array.isArray(list) ) { hosts.push(...list); } } - const rulesets = await Promise.all(toFetch); + return { all: false, hosts, layers: [] }; +} - const substitution = `${runtime.getURL('/strictblock.html')}#\\0`; - const allRules = []; - for ( const rules of rulesets ) { - if ( Array.isArray(rules) === false ) { continue; } - for ( const rule of rules ) { - rule.action.redirect.regexSubstitution = substitution; - allRules.push(rule); +// { all, hosts, layers }: `all` turns strict blocking off everywhere, `hosts` +// are excluded with their subdomains, `layers` is reserved for per-site +// switch layers (none yet). +export async function getStrictBlockExclusions() { + const provided = await strictBlockExclusionProvider(); + const hosts = new Set(); + if ( Array.isArray(provided?.hosts) ) { + for ( const hostname of provided.hosts ) { + if ( typeof hostname !== 'string' || hostname === '' ) { continue; } + hosts.add(hostname.toLowerCase()); } } + return { + all: provided?.all === true, + hosts: Array.from(hosts).sort(), + layers: Array.isArray(provided?.layers) ? provided.layers : [], + }; +} - const validRules = await pruneInvalidRegexRules('strictblock', allRules); - if ( validRules.length === 0 ) { return; } - ublockPlusLog(`Add ${validRules.length} DNR strictblock rules`); - for ( const rule of validRules ) { - rule.priority = STRICTBLOCK_PRIORITY; - addRules.push(rule); - } - - const allExcluded = permanentlyExcluded.concat(temporarilyExcluded); - if ( allExcluded.length === 0 ) { return; } - addRules.unshift({ - action: { type: 'allow' }, - condition: { - requestDomains: allExcluded, - resourceTypes: [ 'main_frame' ], - }, - priority: STRICTBLOCK_PRIORITY, - }); - ublockPlusLog(`Add 1 DNR session rule with ${allExcluded.length} for excluded strict-block domains`); +export async function isStrictBlockExcluded(hostname) { + const { all, hosts } = await getStrictBlockExclusions(); + if ( all ) { return true; } + if ( typeof hostname !== 'string' || hostname === '' ) { return false; } + const excluded = new Set(hosts); + let hn = hostname.toLowerCase(); + for (;;) { + if ( excluded.has(hn) ) { return true; } + const pos = hn.indexOf('.'); + if ( pos === -1 ) { return false; } + hn = hn.slice(pos + 1); + } } async function excludeFromStrictBlock(hostname, permanent) { if ( typeof hostname !== 'string' || hostname === '' ) { return; } const readFn = permanent ? localRead : sessionRead; - const hostnames = new Set(await readFn('excludedStrictBlockHostnames')); + const hostnames = new Set(await readFn(STRICTBLOCK_EXCLUSIONS_KEY)); hostnames.add(hostname); const writeFn = permanent ? localWrite : sessionWrite; - await writeFn('excludedStrictBlockHostnames', Array.from(hostnames)); + await writeFn(STRICTBLOCK_EXCLUSIONS_KEY, Array.from(hostnames)); return updateSessionRules(); } +// Turning strict blocking off, like losing broad host access, only removes +// the redirects: the "don't warn" choices are kept for when it returns. async function setStrictBlockMode(state, force = false) { const newState = Boolean(state); if ( force === false ) { if ( newState === rulesetConfig.strictBlockMode ) { return; } } rulesetConfig.strictBlockMode = newState; - const promises = [ saveRulesetConfig() ]; - if ( newState === false ) { - promises.push( - localRemove('excludedStrictBlockHostnames'), - sessionRemove('excludedStrictBlockHostnames') - ); - } - await Promise.all(promises); + await saveRulesetConfig(); return updateSessionRules(); } /******************************************************************************/ -async function updateSessionRulesNow() { - const addRulesUnfiltered = []; - const removeRuleIds = []; +function notifyStrictBlockPlan(record) { + if ( strictBlockPlanListener === undefined ) { return; } + try { + strictBlockPlanListener(record ?? { redirectCount: 0, owners: [] }); + } catch ( reason ) { + ublockPlusErr(`strictBlockPlanListener/${reason}`); + } +} + +async function storeStrictBlockPlan(record) { + try { + if ( record === undefined ) { + await sessionRemove(STRICTBLOCK_PLAN_KEY); + } else { + await sessionWrite(STRICTBLOCK_PLAN_KEY, record); + } + } catch ( reason ) { + ublockPlusErr(`strictBlockPlan/${reason}`); + } + notifyStrictBlockPlan(record); +} + +async function sessionPlanGeneration(generation) { + if ( typeof generation === 'string' ) { return generation; } + if ( typeof installedUserGeneration === 'string' ) { + return installedUserGeneration; + } + return await localRead(ACTIVE_COMPILED_GENERATION_KEY) || ''; +} + +async function readStockStrictBlockRules() { + const rulesetDetails = (await getEnabledRulesetsDetails(true)) + .filter(details => Boolean(details.rules?.strictblock)); + const rulesets = await Promise.all(rulesetDetails.map(details => + fetchJSON(`/rulesets/strictblock/${details.id}`) + )); + const out = []; + for ( let i = 0; i < rulesetDetails.length; i++ ) { + if ( Array.isArray(rulesets[i]) === false ) { continue; } + out.push({ rulesetId: rulesetDetails[i].id, rules: rulesets[i] }); + } + return out; +} + +// Redirect templates stored by the compiler next to each realm's dnrRules. +// A generation compiled before they existed has none: plain blocks only. +async function readUserStrictBlockRules(generation) { + const out = []; + for ( const realm of [ 'sandbox', 'imported' ] ) { + const rules = await localRead(compiledStorageKey( + generation, `${realm}Filters.strictBlockRules` + )); + if ( Array.isArray(rules) === false || rules.length === 0 ) { continue; } + out.push({ realm, rules }); + } + return out; +} + +async function pruneStrictBlockCandidates(entries) { + let invalid = 0; + for ( const entry of entries ) { + const valid = await pruneInvalidRegexRules('strictblock', entry.rules); + invalid += entry.rules.length - valid.length; + entry.rules = valid; + } + return invalid; +} + +// Chrome may return rules with its own key order. +const canonicalRuleJSON = rule => JSON.stringify(rule, (key, value) => { + if ( value === null || typeof value !== 'object' || Array.isArray(value) ) { + return value; + } + return Object.fromEntries(Object.keys(value).sort().map(k => [ k, value[k] ])); +}); + +const sameRuleSet = (a, b) => { + if ( a.length !== b.length ) { return false; } + const byId = (x, y) => x.id - y.id; + const right = b.slice().sort(byId); + return a.slice().sort(byId).every((rule, i) => + canonicalRuleJSON(rule) === canonicalRuleJSON(right[i]) + ); +}; + +// Rebuild the strict-block session plan. options.generation: the compiled +// generation whose user rules are installed (default: the one this worker +// installed last, else the active one). options.dynamicRegexCount: the +// installed dynamic regex count, when the caller already knows it. +// A failed update keeps the previous plan and its stored record. +async function updateSessionRulesNow(options = {}) { const currentRules = await dnr.getSessionRules(); - await updateStrictBlockRules(currentRules, addRulesUnfiltered, removeRuleIds); - if ( addRulesUnfiltered.length === 0 && removeRuleIds.length === 0 ) { return; } - // Chromium accounts dynamic and session regex rules against one shared - // pool. Use the exact remaining capacity; a synthetic 5% reserve silently - // discarded valid strict-block rules without protecting another owner. - const maxRegexCount = Number.isSafeInteger( - dnr.MAX_NUMBER_OF_REGEX_RULES - ) ? dnr.MAX_NUMBER_OF_REGEX_RULES : Number.MAX_SAFE_INTEGER; - const dynamicRegexCount = await getDynamicRegexRuleCount(); - let sessionRegexCount = 0; - let ruleId = 1; - for ( const rule of addRulesUnfiltered ) { - rule.id = ruleId++; - if ( Boolean(rule.condition.regexFilter) === false ) { continue; } - if ( dynamicRegexCount + sessionRegexCount >= maxRegexCount ) { - rule.id = 0; - continue; + const ownedRules = currentRules.filter(isStrictBlockSessionRule); + const otherRules = currentRules.filter(rule => + isStrictBlockSessionRule(rule) === false + ); + const exclusions = await getStrictBlockExclusions(); + // https://github.com/uBlockOrigin/uBOL-home/issues/428#issuecomment-3172663563 + // https://bugs.webkit.org/show_bug.cgi?id=298199 + // https://developer.apple.com/forums/thread/756214 + const active = rulesetConfig.strictBlockMode !== false && + exclusions.all === false && webextFlavor !== 'safari'; + // Strict-block redirects can only be enforced with omnipotence: without + // host access a redirect shadows the block below it and the page loads. + const hasOmnipotence = active + ? await hasBroadHostPermissions().catch(( ) => false) + : false; + const generation = await sessionPlanGeneration(options.generation); + let stock = []; + let user = []; + let exactUrlSource = false; + let stockInvalidRegex = 0; + let userInvalidRegex = 0; + if ( active && hasOmnipotence ) { + stock = await readStockStrictBlockRules(); + stockInvalidRegex = await pruneStrictBlockCandidates(stock); + if ( webextFlavor === 'chromium' ) { + exactUrlSource = urlSourceIsExact(); + user = await readUserStrictBlockRules(generation); } - sessionRegexCount += 1; } - const addRules = addRulesUnfiltered.filter(a => a.id !== 0); - const rejectedRuleCount = addRulesUnfiltered.length - addRules.length; - if ( rejectedRuleCount !== 0 ) { - ublockPlusLog(`Too many regex-based filters, ${rejectedRuleCount} session rules dropped`); + const userCandidates = user.reduce((sum, entry) => + sum + entry.rules.length, 0 + ); + if ( exactUrlSource ) { + userInvalidRegex = await pruneStrictBlockCandidates(user); + } + + const maxSessionRules = Number.isSafeInteger(dnr.MAX_NUMBER_OF_SESSION_RULES) + ? dnr.MAX_NUMBER_OF_SESSION_RULES + : DEFAULT_MAX_SESSION_RULES; + const maxRegexCount = maxRegexRuleCount(); + const planned = exactUrlSource ? [ ...stock, ...user ] : stock; + const needsRegex = planned.some(entry => + entry.rules.some(rule => Boolean(rule?.condition?.regexFilter)) + ); + let dynamicRegexCount = 0; + if ( needsRegex ) { + dynamicRegexCount = Number.isSafeInteger(options.dynamicRegexCount) + ? options.dynamicRegexCount + : countRegexRules(await dnr.getDynamicRules()); + } + const otherRegexCount = countRegexRules(otherRules); + const plan = planStrictBlockSessionRules({ + flavor: webextFlavor, + strictBlockMode: active, + hasOmnipotence, + exactUrlSource, + extensionPageURL: runtime.getURL('/strictblock.html'), + stock, + user, + excludedHostnames: exclusions.hosts, + sessionRuleBudget: maxSessionRules - otherRules.length, + regexRuleBudget: maxRegexCount - dynamicRegexCount - otherRegexCount, + }); + + const dropped = { ...plan.dropped, stockInvalidRegex, userInvalidRegex }; + const response = { + droppedRegexRules: plan.dropped.stockRegexPool + plan.dropped.userRegexPool, + strictBlock: { redirectCount: plan.redirectCount, dropped }, + }; + if ( response.droppedRegexRules !== 0 ) { + ublockPlusLog(`Too many regex-based filters, ${response.droppedRegexRules} strict-block session rules dropped`); } - if ( sessionRegexCount !== 0 ) { - ublockPlusLog(`Using ${dynamicRegexCount + sessionRegexCount}/${maxRegexCount} shared dynamic/session regex-based DNR rules`); + const droppedSessionRules = plan.dropped.stockSessionLimit + + plan.dropped.userSessionLimit; + if ( droppedSessionRules !== 0 ) { + ublockPlusLog(`Session rule limit reached, ${droppedSessionRules} strict-block session rules dropped`); } - const response = { droppedRegexRules: rejectedRuleCount }; - try { - await dnr.updateSessionRules({ addRules, removeRuleIds }); - if ( removeRuleIds.length !== 0 ) { - ublockPlusLog(`Remove ${removeRuleIds.length} session DNR rules`); + if ( sameRuleSet(ownedRules, plan.rules) === false ) { + try { + await dnr.updateSessionRules({ + removeRuleIds: ownedRules.map(rule => rule.id), + addRules: plan.rules, + }); + } catch(reason) { + ublockPlusErr(`updateSessionRules/${reason}`); + response.error = `${reason}`; + return response; } - if ( addRules.length !== 0 ) { - ublockPlusLog(`Add ${addRules.length} session DNR rules`); + if ( ownedRules.length !== 0 ) { + ublockPlusLog(`Remove ${ownedRules.length} strict-block session DNR rules`); + } + if ( plan.rules.length !== 0 ) { + ublockPlusLog(`Add ${plan.rules.length} strict-block session DNR rules (${plan.redirectCount} redirects)`); + } + if ( plan.counts.regex !== 0 ) { + ublockPlusLog(`Using ${dynamicRegexCount + otherRegexCount + plan.counts.regex}/${maxRegexCount} shared dynamic/session regex-based DNR rules`); } - } catch(reason) { - ublockPlusErr(`updateSessionRules/${reason}`); - response.error = `${reason}`; } + await storeStrictBlockPlan({ + schemaVersion: 1, + generation, + // The setting, apart from `strictBlockMode`, which also folds in + // "exclude everything" and the platform. + configuredMode: rulesetConfig.strictBlockMode !== false, + strictBlockMode: active, + hasOmnipotence, + exactUrlSource, + owners: plan.owners, + counts: plan.counts, + dropped, + redirectCount: plan.redirectCount, + userCandidates, + builtAt: Date.now(), + }); return response; } -function updateSessionRules() { +// Queued rebuild of the session plan: the trigger for any owner which changed +// what the plan depends on (dynamic regex usage, exclusions, permissions). +// A task already running in the DNR queue calls updateSessionRulesNow(). +function updateSessionRules(options = {}) { return enqueueDNRMutation(async ( ) => { try { - return await updateSessionRulesNow(); + return await updateSessionRulesNow(options); } catch ( reason ) { ublockPlusErr(`updateSessionRules/${reason}`); return { error: `${reason}` }; @@ -606,6 +819,42 @@ function updateSessionRules() { }); } +// Called on every worker start. Redirects must not outlive the broad host +// access they need, user redirects follow the exact URL source, and a plan +// lost with the session storage is rebuilt. +export async function reconcileStrictBlockSessionRules() { + const [ plan, hasOmnipotence ] = await Promise.all([ + sessionRead(STRICTBLOCK_PLAN_KEY), + hasBroadHostPermissions().catch(( ) => false), + ]); + const configured = rulesetConfig.strictBlockMode !== false; + let stale = false; + if ( plan?.schemaVersion !== 1 ) { + stale = configured && hasOmnipotence || + (await dnr.getSessionRules()).some(isStrictBlockSessionRule); + } else if ( plan.redirectCount > 0 && hasOmnipotence === false ) { + stale = true; + } else if ( (typeof plan.configuredMode === 'boolean' + ? plan.configuredMode + : plan.strictBlockMode) !== configured + ) { + // Turned off or on, and the worker stopped before the rebuild. + stale = true; + } else if ( plan.strictBlockMode && plan.hasOmnipotence !== hasOmnipotence ) { + stale = true; + } else if ( plan.userCandidates > 0 && + plan.exactUrlSource !== urlSourceIsExact() ) { + stale = true; + } + if ( stale === false ) { return { rebuilt: false, plan }; } + const result = await updateSessionRules(); + return { + ...result, + rebuilt: true, + plan: await sessionRead(STRICTBLOCK_PLAN_KEY), + }; +} + async function filteringModesToDNRNow(modes) { const noneHostnames = new Set([ ...modes.none ]); const notNoneHostnames = new Set([ ...modes.basic, ...modes.optimal, ...modes.complete ]); @@ -948,16 +1197,12 @@ async function updateUserRulesNow(generation, stockResidualRules, effectiveRules // pronounced peak in a MV3 service worker on low-memory devices. let removeRuleIds; let retainedDynamicRegexCount = 0; - let previousUserRegexCount = 0; { const currentRules = await dnr.getDynamicRules(); removeRuleIds = []; for ( const rule of currentRules ) { if ( rule.id >= USER_RULES_BASE_RULE_ID ) { removeRuleIds.push(rule.id); - if ( rule.condition?.regexFilter ) { - previousUserRegexCount += 1; - } } else if ( rule.condition?.regexFilter ) { retainedDynamicRegexCount += 1; } @@ -980,6 +1225,8 @@ async function updateUserRulesNow(generation, stockResidualRules, effectiveRules return out; } const { rules } = parsed; + // The realm of every rule, for the regex overflow policy. + const ruleOwners = new Map(rules.map(rule => [ rule, 'developer' ])); { const sandboxRules = await localRead(compiledStorageKey( effectiveGeneration, @@ -988,6 +1235,7 @@ async function updateUserRulesNow(generation, stockResidualRules, effectiveRules if ( Array.isArray(sandboxRules) ) { for ( const rule of sandboxRules ) { rules.push(rule); + ruleOwners.set(rule, 'sandbox'); } } } @@ -1003,6 +1251,7 @@ async function updateUserRulesNow(generation, stockResidualRules, effectiveRules if ( Array.isArray(importedRules) ) { for ( const rule of importedRules ) { rules.push(rule); + ruleOwners.set(rule, 'imported'); } } } @@ -1010,7 +1259,11 @@ async function updateUserRulesNow(generation, stockResidualRules, effectiveRules // These are exact original stock predicates reconstructed from packaged // source-domain provenance. Keep stock priority and include every residual // in the same atomic native update: a quota rejection retains last-good. - for ( const rule of stockResidualRules ) { rules.push(structuredClone(rule)); } + for ( const rule of stockResidualRules ) { + const residual = structuredClone(rule); + rules.push(residual); + ruleOwners.set(residual, 'stock-residual'); + } let addRules; try { addRules = await pruneInvalidRegexRules('user', rules, rejectedRegexes); @@ -1028,14 +1281,14 @@ async function updateUserRulesNow(generation, stockResidualRules, effectiveRules if ( removeRuleIds.length === 0 && addRules.length === 0 ) { await localRemove('userDnrRuleCount'); + out.regexUsage = { + developer: 0, sandbox: 0, imported: 0, stockResidual: 0, + droppedImported: 0, + }; + out.dynamicRegexCount = retainedDynamicRegexCount; return out; } - let ruleId = 0; - for ( const rule of addRules ) { - rule.id = USER_RULES_BASE_RULE_ID + ruleId++; - } - let effectiveRuleCount = removeRuleIds.length; const safePlan = toSafeDynamicRules(addRules); if ( safePlan.error ) { @@ -1044,40 +1297,46 @@ async function updateUserRulesNow(generation, stockResidualRules, effectiveRules return out; } out.errors.push(...safePlan.warnings); - const safeAddRules = safePlan.rules; - const replacementRegexCount = countRegexRules(safeAddRules); - const projectedDynamicRegexCount = - retainedDynamicRegexCount + replacementRegexCount; - const maxRegexCount = Number.isSafeInteger( - dnr.MAX_NUMBER_OF_REGEX_RULES - ) ? dnr.MAX_NUMBER_OF_REGEX_RULES : Number.MAX_SAFE_INTEGER; - if ( projectedDynamicRegexCount > maxRegexCount ) { + + // Other owners' session regex rules share the pool and are retained; + // strict-block session regex rules make room and are planned afterwards. + const maxRegexCount = maxRegexRuleCount(); + const sessionRules = await dnr.getSessionRules(); + const regexPlan = planUserRegexBudget({ + rules: safePlan.rules, + owners: safePlan.rules.map(rule => ruleOwners.get(rule)), + retainedRegexCount: retainedDynamicRegexCount + countRegexRules( + sessionRules.filter(rule => isStrictBlockSessionRule(rule) === false) + ), + maxRegexCount, + }); + ruleOwners.clear(); + if ( regexPlan.fatal ) { out.fatalError = - `Dynamic regex plan requires ${projectedDynamicRegexCount}/` + + `Dynamic regex plan requires ${regexPlan.required}/` + `${maxRegexCount} rules; the previous generation remains active`; out.errors.push(out.fatalError); return out; } + if ( regexPlan.droppedImported !== 0 ) { + out.errors.push( + `${regexPlan.droppedImported} imported regex rule(s) were not ` + + `installed: the browser's ${maxRegexCount}-rule regex limit is full` + ); + } + const safeAddRules = regexPlan.rules; + let ruleId = 0; + for ( const rule of safeAddRules ) { + rule.id = USER_RULES_BASE_RULE_ID + ruleId++; + } + const dynamicRegexCount = retainedDynamicRegexCount + + countRegexRules(safeAddRules); - const regexPlanChanged = - replacementRegexCount !== previousUserRegexCount; let displacedSessionRegexRules = []; - let sessionRegexRemoved = false; try { - if ( regexPlanChanged ) { - const sessionRules = await dnr.getSessionRules(); - const sessionRegexCount = countRegexRules(sessionRules); - if ( projectedDynamicRegexCount + sessionRegexCount > - maxRegexCount ) { - displacedSessionRegexRules = sessionRules.filter(rule => - Boolean(rule.condition?.regexFilter) - ); - await dnr.updateSessionRules({ - removeRuleIds: displacedSessionRegexRules.map(a => a.id), - }); - sessionRegexRemoved = true; - } - } + displacedSessionRegexRules = await displaceStrictBlockRegexRules( + dynamicRegexCount, maxRegexCount, sessionRules + ); // A single DNR update is atomic: if Chrome rejects any added rule or // the quota is exhausted, the previously active rules remain intact. await dnr.updateDynamicRules({ @@ -1093,38 +1352,22 @@ async function updateUserRulesNow(generation, stockResidualRules, effectiveRules out.added = safeAddRules.length; out.removed = removeRuleIds.length; effectiveRuleCount = safeAddRules.length; - - if ( regexPlanChanged ) { - const sessionResult = await updateSessionRulesNow(); - if ( sessionResult?.error ) { - out.errors.push( - `Session regex rebuild failed: ${sessionResult.error}` - ); - } - const displacedBySharedPool = Math.max( - sessionResult?.droppedRegexRules || 0, - projectedDynamicRegexCount + - displacedSessionRegexRules.length - maxRegexCount, - 0 - ); - if ( displacedBySharedPool > 0 ) { - out.errors.push( - `${displacedBySharedPool} lower-priority ` + - `session regex rule(s) could not fit the shared ` + - `${maxRegexCount}-rule pool` - ); - } - } + // For the caller, which rebuilds the session plan and records the + // regex usage once the whole update has committed. + out.regexUsage = { + ...regexPlan.counts, + droppedImported: regexPlan.droppedImported, + }; + out.dynamicRegexCount = dynamicRegexCount; } catch(reason) { ublockPlusErr(`updateUserRules/${reason}`); out.fatalError = `${reason}`; out.errors.push(out.fatalError); - if ( sessionRegexRemoved && out.added === 0 ) { - try { - await dnr.updateSessionRules({ - addRules: displacedSessionRegexRules, - }); - } catch ( restoreReason ) { + if ( out.added === 0 ) { + const restoreReason = await restoreDisplacedRegexRules( + displacedSessionRegexRules + ); + if ( restoreReason !== undefined ) { out.fatalError += `; session rollback failed (${restoreReason})`; out.errors.push( @@ -1148,6 +1391,44 @@ async function updateUserRulesNow(generation, stockResidualRules, effectiveRules return out; } +async function recordUserRegexUsage(generation, usage) { + if ( usage === undefined ) { return; } + try { + await localWrite(REGEX_CAPACITY_USER_KEY, { + schemaVersion: 1, + generation, + ...usage, + updatedAt: Date.now(), + }); + } catch ( reason ) { + ublockPlusErr(`updateUserRules/regexCapacity/${reason}`); + } +} + +// The session plan always follows the installed user rules: their $doc +// redirects, and the regex capacity the dynamic rules left. +async function rebuildSessionAfterUserRules(generation, result) { + const { dynamicRegexCount } = result; + try { + const sessionResult = await updateSessionRulesNow({ + generation, dynamicRegexCount, + }); + if ( sessionResult?.error ) { + result.errors.push(`Session rule rebuild failed: ${sessionResult.error}`); + } + if ( sessionResult?.droppedRegexRules > 0 ) { + result.errors.push( + `${sessionResult.droppedRegexRules} lower-priority strict-block ` + + `regex rule(s) could not fit the shared ` + + `${maxRegexRuleCount()}-rule pool` + ); + } + } catch ( reason ) { + ublockPlusErr(`updateUserRules/session/${reason}`); + result.errors.push(`Session rule rebuild failed: ${reason}`); + } +} + // Only an explicit save (strictDeveloperDraft) may fail because of the saved // developer draft. Every other update which cannot use the draft keeps the // developer rules installed by the last successful update, so a draft error @@ -1205,6 +1486,15 @@ async function updateUserRulesWithStockNow(generation, options) { await stockBadfilterManager.apply(plan); await stockBadfilterManager.commit(plan); } + // Only now, after the stock badfilter journal committed: its recovery + // restores the session regex rules it snapshotted, which must not + // collide with the IDs of a newer plan. + const { regexUsage } = result; + delete result.regexUsage; + installedUserGeneration = effectiveGeneration; + await recordUserRegexUsage(effectiveGeneration, regexUsage); + await rebuildSessionAfterUserRules(effectiveGeneration, result); + delete result.dynamicRegexCount; try { await stockBadfilterManager.report(plan); } catch { } try { if ( await localRead(USER_DNR_APPLIED_KEY) !== developerRulesText ) { @@ -1241,8 +1531,148 @@ function updateUserRules(generation, options = {}) { /******************************************************************************/ +// Regex rule capacity (Dashboard > Diagnostics, Filter Store estimate). + +async function fetchPackagedJSON(path) { + try { + const response = await fetch(path); + if ( response.ok === false ) { return; } + return await response.json(); + } catch { + } +} + +const browserMajorVersion = ( ) => { + const match = /\b(?:Chrome|Firefox)\/(\d+)/.exec( + globalThis.navigator?.userAgent ?? '' + ); + return match !== null ? parseInt(match[1], 10) : 0; +}; + +// 'Check now': ask this browser about every packaged static regex rule of +// the enabled lists. Chrome silently skips a static regex it cannot run +// (for example over its memory limit), so only this check can count them. +// Rulesets are checked one at a time and only their regex rules are kept. +async function verifyStaticRegexRules(enabledRulesetIds, stockRulesets) { + const manifest = runtime.getManifest(); + const paths = new Map(); + for ( const { id, path } of manifest.declarative_net_request?.rule_resources ?? [] ) { + paths.set(id, path); + } + const expected = new Map(stockRulesets.map(details => + [ details.id, details.rules?.regexStatic ] + )); + const byRuleset = {}; + const samples = []; + for ( const id of enabledRulesetIds ) { + const count = expected.get(id); + if ( Number.isSafeInteger(count) === false || count <= 0 ) { continue; } + if ( typeof paths.get(id) !== 'string' ) { continue; } + const regexRules = await fetchPackagedJSON(paths.get(id)).then(rules => + Array.isArray(rules) + ? rules.filter(rule => Boolean(rule?.condition?.regexFilter)) + : undefined + ); + // A ruleset which could not be read stays unchecked (unknown). + if ( regexRules === undefined ) { continue; } + const verdicts = await Promise.all(regexRules.map(rule => + regexSupport(regexOptionsOf(rule)) + )); + let skipped = 0; + verdicts.forEach((verdict, i) => { + if ( verdict === true ) { return; } + skipped += 1; + if ( samples.length >= 10 ) { return; } + samples.push({ + rulesetId: id, + ruleId: regexRules[i].id, + regex: regexRules[i].condition.regexFilter.slice(0, 160), + reason: verdict, + }); + }); + byRuleset[id] = { checked: regexRules.length, skipped }; + } + const check = { + schemaVersion: 1, + extensionVersion: manifest.version, + chromeMajor: browserMajorVersion(), + byRuleset, + samples, + checkedAt: Date.now(), + }; + try { + await localWrite(REGEX_CAPACITY_STATIC_KEY, check); + } catch ( reason ) { + ublockPlusErr(`getRegexCapacity/staticCheck/${reason}`); + } + return check; +} + +export async function getRegexCapacity({ verifyStatic = false } = {}) { + const reImported = /^[a-z-]+:\/\//; + const [ + rulesetDetails, + regexDetails, + enabledRulesetIds, + dynamicRules, + sessionRules, + userRecord, + plan, + adminIds, + exclusions, + ] = await Promise.all([ + getRulesetDetails(), + fetchPackagedJSON('/rulesets/regex-details.json'), + dnr.getEnabledRulesets(), + dnr.getDynamicRules(), + dnr.getSessionRules(), + localRead(REGEX_CAPACITY_USER_KEY), + sessionRead(STRICTBLOCK_PLAN_KEY), + getAdminRulesets(), + getStrictBlockExclusions().catch(( ) => undefined), + ]); + const stockRulesets = Array.from(rulesetDetails.values()) + .filter(details => reImported.test(details.id) === false); + const staticCheck = verifyStatic + ? await verifyStaticRegexRules(enabledRulesetIds, stockRulesets) + : await localRead(REGEX_CAPACITY_STATIC_KEY); + // Selected lists, with the admin overrides enableRulesets() applies, + // which the browser did not enable (for example at its ruleset limit). + const selected = new Set(rulesetConfig.enabledRulesets); + for ( const token of adminIds ?? [] ) { + if ( token.charAt(0) === '+' ) { + selected.add(token.slice(1)); + } else if ( token.charAt(0) === '-' ) { + selected.delete(token.slice(1)); + } + } + const known = new Set(stockRulesets.map(details => details.id)); + const enabled = new Set(enabledRulesetIds); + const rulesetsNotEnabled = Array.from(selected).filter(id => + known.has(id) && enabled.has(id) === false + ); + return summarizeRegexCapacity({ + sharedLimit: dnr.MAX_NUMBER_OF_REGEX_RULES, + dynamicRules, + sessionRules, + userRecord, + plan, + stockRulesets, + enabledRulesetIds, + regexDetails, + staticCheck, + extensionVersion: runtime.getManifest().version, + chromeMajor: browserMajorVersion(), + rulesetsNotEnabled, + exclusions, + }); +} + +/******************************************************************************/ + export { enableRulesets, + enqueueDNRMutation, excludeFromStrictBlock, filteringModesToDNR, getEffectiveUserRules, @@ -1250,5 +1680,6 @@ export { recoverStockBadfilters, setStrictBlockMode, updateSessionRules, + updateSessionRulesNow, updateUserRules, }; diff --git a/platform/mv3/extension/js/runtime-capabilities-core.js b/platform/mv3/extension/js/runtime-capabilities-core.js index e656f375dcd1d..8de3856069776 100644 --- a/platform/mv3/extension/js/runtime-capabilities-core.js +++ b/platform/mv3/extension/js/runtime-capabilities-core.js @@ -25,6 +25,31 @@ const finiteQuota = value => Number.isSafeInteger(value) && value >= 0 ? value : undefined; +// How the strict-block page learns the blocked address, best first (see +// strictblock-tracker.js). 'regex-substitution' is the Firefox build, where +// the redirect itself carries the address in the page's fragment. +const strictBlockUrlSourceOrder = [ + 'webrequest', 'rule-match', 'navigation-start', 'unavailable', +]; +const exactStrictBlockUrlSources = new Set([ + 'webrequest', 'rule-match', 'regex-substitution', +]); + +// The worker reports the source it uses; each source is kept only while the +// browser still grants what it needs, otherwise the next one down applies. +// Nothing reported means nothing implemented: 'unavailable'. +const classifyStrictBlockUrlSource = (reported, granted) => { + if ( granted.dnr === false ) { return 'unavailable'; } + if ( reported === 'regex-substitution' ) { return reported; } + let i = strictBlockUrlSourceOrder.indexOf(reported); + if ( i === -1 ) { return 'unavailable'; } + for ( ; i < strictBlockUrlSourceOrder.length; i++ ) { + const source = strictBlockUrlSourceOrder[i]; + if ( source === 'unavailable' || granted[source] ) { return source; } + } + return 'unavailable'; +}; + /******************************************************************************/ export function classifyRuntimeCapabilities(input = {}) { @@ -72,6 +97,16 @@ export function classifyRuntimeCapabilities(input = {}) { const observationGranted = grantedPermissions.has('webRequest'); const matchFeedbackAPI = manifestPermissions.has('declarativeNetRequestFeedback') && api.nativeMatchFeedback === true; + const strictBlockUrlSource = classifyStrictBlockUrlSource( + input.strictBlockUrlSource, + { + dnr: dnrAvailable, + 'webrequest': observationDeclared && observationGranted && + api.webRequest === true, + 'rule-match': matchFeedbackAPI, + 'navigation-start': api.webNavigation === true, + } + ); const eligibleNetworkEngines = []; if ( dnrAvailable ) { @@ -111,6 +146,10 @@ export function classifyRuntimeCapabilities(input = {}) { inlineScriptFirewall: false, smartPopupObservation: api.tabs === true && api.webNavigation === true, + // 'webrequest' | 'rule-match' | 'regex-substitution' (exact), + // 'navigation-start' (approximate) or 'unavailable'. + strictBlockUrlSource, + strictBlockUrlExact: exactStrictBlockUrlSources.has(strictBlockUrlSource), userScripts: api.userScripts === true, offscreenCompilation: api.offscreen === true, quotas: { diff --git a/platform/mv3/extension/js/runtime-capabilities.js b/platform/mv3/extension/js/runtime-capabilities.js index da7876dcd57f8..eae0461e74425 100644 --- a/platform/mv3/extension/js/runtime-capabilities.js +++ b/platform/mv3/extension/js/runtime-capabilities.js @@ -63,7 +63,11 @@ const getUserScriptsUsable = async ( ) => { /******************************************************************************/ -export async function getRuntimeCapabilities() { +// strictBlockUrlSource: how the strict-block page learns the blocked address, +// as reported by the service worker (strictblock-tracker.js urlSource(), or +// 'regex-substitution' on Firefox). The classification drops it to what the +// browser still grants. +export async function getRuntimeCapabilities({ strictBlockUrlSource } = {}) { const manifest = runtime.getManifest(); const [ installType, @@ -92,6 +96,7 @@ export async function getRuntimeCapabilities() { manifestPermissions: manifest.permissions, optionalPermissions: manifest.optional_permissions, grantedPermissions, + strictBlockUrlSource, webRequestFirewall: getWebRequestFirewallStatus(), api: { declarativeNetRequest: diff --git a/platform/mv3/extension/js/strictblock-rules.js b/platform/mv3/extension/js/strictblock-rules.js new file mode 100644 index 0000000000000..f578c0c4f266e --- /dev/null +++ b/platform/mv3/extension/js/strictblock-rules.js @@ -0,0 +1,572 @@ +/******************************************************************************* + uBlock Plus+ - strict-block rule shapes and the session rule plan + Copyright (C) 2026-present uBlock Plus+ contributors; GPL-3.0-or-later + + Pure module: no browser API and no import from ext.js. It is imported by + the ruleset build (Node), the offscreen compiler and the service worker. + + Strict blocking redirects a blocked top-level document to the extension's + strictblock.html page instead of letting the browser show its error page + (classic uBO: traffic.js onBeforeRootFrameRequest). The redirects carry no + URL: the page learns the blocked address from strictblock-tracker.js, or + from the fragment of the legacy regexSubstitution shape (Firefox builds). +******************************************************************************/ + +import { + STOCK_STRICTBLOCK_PRIORITY, + STRICTBLOCK_SESSION_ID_LIMIT, + USER_RULES_PRIORITY, + isStrictBlockSessionRule, +} from './dnr-namespaces.js'; + +export { + STOCK_STRICTBLOCK_PRIORITY, + STRICTBLOCK_SESSION_ID_LIMIT, + USER_RULES_PRIORITY, + isStrictBlockSessionRule, +}; + +export const STRICTBLOCK_PAGE_PATH = '/strictblock.html'; + +/******************************************************************************/ + +// Conditions which make a block rule narrower than "this document": such a +// rule stays a plain block and never redirects to the strict-block page. +const nonCandidateConditions = [ + 'domainType', + 'excludedResourceTypes', + 'requestMethods', + 'excludedRequestMethods', + 'requestHeaders', + 'responseHeaders', + 'excludedResponseHeaders', + 'initiatorDomains', + 'excludedInitiatorDomains', + 'domains', + 'excludedDomains', + 'topDomains', + 'excludedTopDomains', + 'tabIds', + 'excludedTabIds', +]; + +const rePatternIsHostname = /^\|\|[^*/?|^]+\^$/; +const reRequestDomain = /^[a-z0-9_-]+(?:\.[a-z0-9_-]+)*$/; + +// `||host^` matches exactly what requestDomains:[host] matches, as long as +// the host is a plain lower-case name that DNR accepts as a request domain. +const hostnameFromUrlFilter = urlFilter => { + if ( rePatternIsHostname.test(urlFilter) === false ) { return ''; } + const hostname = urlFilter.slice(2, -1); + return reRequestDomain.test(hostname) ? hostname : ''; +}; + +// Chrome rejects rules with unknown properties; the compilers use `_`-prefixed +// properties for provenance. Returns a deep copy without them. +export function stripPrivateProperties(value) { + if ( value === undefined ) { return; } + return JSON.parse(JSON.stringify(value, (k, v) => + k.startsWith('_') ? undefined : v + )); +} + +const basePriority = priority => + Number.isSafeInteger(priority) && priority >= 1 ? priority : 1; + +const sortedUnion = (...lists) => { + const out = new Set(); + for ( const list of lists ) { + if ( Array.isArray(list) === false ) { continue; } + for ( const item of list ) { out.add(item); } + } + return Array.from(out).sort(); +}; + +// Exclusions come from the strict-block page (URL.hostname) and from storage. +// One malformed entry would make Chrome reject the whole session update. +const normalizeHostnames = list => { + const out = new Set(); + for ( const raw of Array.isArray(list) ? list : [] ) { + if ( typeof raw !== 'string' ) { continue; } + const hostname = raw.trim().toLowerCase(); + if ( /^[\x21-\x7e]+$/.test(hostname) === false ) { continue; } + if ( /[/?#@\\*^|%]/.test(hostname) ) { continue; } + out.add(hostname); + } + return Array.from(out).sort(); +}; + +const toBudget = value => Number.isFinite(value) + ? Math.max(0, Math.floor(value)) + : Number.POSITIVE_INFINITY; + +/******************************************************************************/ + +// A block rule which blocks whole documents. The explicit main_frame case is +// the runtime (My filters, imported lists) semantics. With hostnameOnly, a +// rule without resource types whose only URL predicate is a hostname also +// qualifies: stock lists strict-block `||host^` like classic uBO does +// (make-rulesets.js strips main_frame from such rules' plain copy). +export function isStrictBlockCandidate(rule, { hostnameOnly = false } = {}) { + if ( rule?.action?.type !== 'block' ) { return false; } + const { condition } = rule; + if ( typeof condition !== 'object' || condition === null ) { return false; } + for ( const prop of nonCandidateConditions ) { + if ( condition[prop] ) { return false; } + } + const { resourceTypes } = condition; + if ( resourceTypes ) { + return Array.isArray(resourceTypes) && resourceTypes.includes('main_frame'); + } + if ( hostnameOnly !== true ) { return false; } + if ( condition.requestDomains ) { + return condition.urlFilter === undefined && + condition.regexFilter === undefined; + } + return typeof condition.urlFilter === 'string' && + rePatternIsHostname.test(condition.urlFilter); +} + +/******************************************************************************/ + +// Build only: fold the stock strict-block candidates of one list into +// extensionPath redirects. The original URL predicate is kept, so only +// regex-sourced filters use a regex rule. Rules are grouped by predicate, +// case sensitivity and exact exclusions; groups with different +// excludedRequestDomains are never merged, since a union of exclusions would +// narrow the rules. requestDomains are unioned within a group, and a member +// without requestDomains makes the whole group unrestricted. +// `_sourceFilters` and `_sourceKeys` are unioned and carried for provenance; +// toJSONRuleset strips them on write. +export function foldStockStrictBlockRules(candidates) { + const groups = new Map(); + for ( const rule of Array.isArray(candidates) ? candidates : [] ) { + const condition = rule?.condition; + if ( typeof condition !== 'object' || condition === null ) { continue; } + let domains = condition.requestDomains; + if ( domains !== undefined ) { + // An empty list matches nothing, never everything. + if ( Array.isArray(domains) === false || domains.length === 0 ) { + continue; + } + } + const caseSensitive = condition.isUrlFilterCaseSensitive === true; + let kind, pattern = ''; + if ( typeof condition.regexFilter === 'string' ) { + kind = 're'; + pattern = condition.regexFilter; + } else if ( typeof condition.urlFilter === 'string' ) { + const hostname = domains === undefined + ? hostnameFromUrlFilter(condition.urlFilter) + : ''; + if ( hostname !== '' ) { + kind = 'hn'; + domains = [ hostname ]; + } else { + kind = 'uf'; + pattern = condition.urlFilter; + } + } else if ( domains !== undefined ) { + kind = 'hn'; + } else { + kind = '*'; + } + const excluded = sortedUnion(condition.excludedRequestDomains); + const hasPattern = kind === 're' || kind === 'uf'; + const key = [ + hasPattern ? `${kind}:${pattern}` : kind, + hasPattern && caseSensitive, + JSON.stringify(excluded), + ].join('\t'); + let group = groups.get(key); + if ( group === undefined ) { + group = { + kind, pattern, caseSensitive: hasPattern && caseSensitive, + excluded, + domains: domains !== undefined ? new Set() : null, + sourceFilters: new Set(), + sourceKeys: new Set(), + }; + groups.set(key, group); + } + if ( domains === undefined ) { + group.domains = null; + } else if ( group.domains !== null ) { + for ( const hn of domains ) { group.domains.add(hn); } + } + for ( const filter of rule._sourceFilters ?? [] ) { + group.sourceFilters.add(filter); + } + for ( const sourceKey of rule._sourceKeys ?? [] ) { + group.sourceKeys.add(sourceKey); + } + } + const out = []; + for ( const group of groups.values() ) { + const condition = {}; + if ( group.kind === 're' ) { + condition.regexFilter = group.pattern; + } else if ( group.kind === 'uf' ) { + condition.urlFilter = group.pattern; + } + if ( group.caseSensitive ) { + condition.isUrlFilterCaseSensitive = true; + } + if ( group.domains !== null ) { + condition.requestDomains = Array.from(group.domains).sort(); + } + if ( group.excluded.length !== 0 ) { + condition.excludedRequestDomains = group.excluded; + } + condition.resourceTypes = [ 'main_frame' ]; + const rule = { + action: { + type: 'redirect', + redirect: { extensionPath: STRICTBLOCK_PAGE_PATH }, + }, + condition, + priority: STOCK_STRICTBLOCK_PRIORITY, + }; + if ( group.sourceFilters.size !== 0 ) { + rule._sourceFilters = Array.from(group.sourceFilters); + } + if ( group.sourceKeys.size !== 0 ) { + rule._sourceKeys = Array.from(group.sourceKeys); + } + out.push(rule); + } + return out; +} + +/******************************************************************************/ + +// Runtime compiler output (My filters, imported lists): one redirect template +// per document-blocking rule, stored next to the generation's dnrRules. The +// dynamic block rule itself is not modified and stays the fallback whenever +// the redirect is not installed. +// +// A template keeps the block's condition (main_frame only) and priority; the +// planner adds 1, and USER_RULES_PRIORITY for My filters. Its `id` is not a +// DNR rule ID: it is the 1-based position of the source rule in `dnrRules`, +// which the planner records in the plan owners before assigning session IDs. +export function deriveUserStrictBlockRules(dnrRules) { + const out = []; + if ( Array.isArray(dnrRules) === false ) { return out; } + for ( let i = 0; i < dnrRules.length; i++ ) { + const rule = dnrRules[i]; + if ( isStrictBlockCandidate(rule) === false ) { continue; } + const { condition } = stripPrivateProperties(rule); + condition.resourceTypes = [ 'main_frame' ]; + out.push({ + id: i + 1, + action: { + type: 'redirect', + redirect: { extensionPath: STRICTBLOCK_PAGE_PATH }, + }, + condition, + priority: basePriority(rule.priority), + }); + } + return out; +} + +/******************************************************************************/ + +const emptyPlan = excludedCount => ({ + rules: [], + owners: [], + counts: { + rules: 0, regex: 0, redirects: 0, + stock: 0, sandbox: 0, imported: 0, + allows: 0, exclusions: excludedCount, + }, + dropped: { + stockSessionLimit: 0, stockRegexPool: 0, + userSessionLimit: 0, userRegexPool: 0, + stockMalformed: 0, userMalformed: 0, + }, + redirectCount: 0, +}); + +const isRegexRule = rule => typeof rule.condition.regexFilter === 'string'; + +const toUserRedirect = (stored, realm, excluded) => { + if ( stored?.action?.type !== 'redirect' ) { return; } + if ( typeof stored.condition !== 'object' || stored.condition === null ) { + return; + } + const { condition } = stripPrivateProperties(stored); + condition.resourceTypes = [ 'main_frame' ]; + const excludedRequestDomains = sortedUnion( + condition.excludedRequestDomains, excluded + ); + if ( excludedRequestDomains.length !== 0 ) { + condition.excludedRequestDomains = excludedRequestDomains; + } + const base = basePriority(stored.priority) + + (realm === 'sandbox' ? USER_RULES_PRIORITY : 0); + const source = Number.isSafeInteger(stored.id) && stored.id > 0 + ? stored.id + : null; + return { + owner: realm, + action: 'redirect', + base, + source, + rule: { + action: { + type: 'redirect', + redirect: { extensionPath: STRICTBLOCK_PAGE_PATH }, + }, + condition, + priority: base + 1, + }, + }; +}; + +const toStockRedirect = (packaged, rulesetId, excluded, extensionPageURL) => { + const redirect = packaged?.action?.redirect; + if ( packaged?.action?.type !== 'redirect' ) { return; } + if ( typeof redirect !== 'object' || redirect === null ) { return; } + if ( typeof packaged.condition !== 'object' || packaged.condition === null ) { + return; + } + const rule = stripPrivateProperties(packaged); + delete rule.id; + rule.priority = STOCK_STRICTBLOCK_PRIORITY; + rule.condition.resourceTypes = [ 'main_frame' ]; + if ( typeof redirect.extensionPath === 'string' ) { + const excludedRequestDomains = sortedUnion( + rule.condition.excludedRequestDomains, excluded + ); + if ( excludedRequestDomains.length !== 0 ) { + rule.condition.excludedRequestDomains = excludedRequestDomains; + } + } else if ( + typeof redirect.regexSubstitution === 'string' && + typeof rule.condition.regexFilter === 'string' && + typeof extensionPageURL === 'string' && extensionPageURL !== '' + ) { + // Firefox builds: the fragment carries the blocked URL. Exclusions + // are enforced by the allow rule at the same priority, as before. + rule.action.redirect = { + regexSubstitution: `${extensionPageURL}#\\0`, + }; + } else { + return; + } + const source = Number.isSafeInteger(packaged.id) && packaged.id > 0 + ? packaged.id + : null; + return { + owner: `stock:${rulesetId}`, + action: 'redirect', + source, + rule, + }; +}; + +const exclusionAllow = (priority, excluded) => ({ + action: { type: 'allow' }, + condition: { + requestDomains: excluded.slice(), + resourceTypes: [ 'main_frame' ], + }, + priority, +}); + +// The strict-block session plan. Input: +// - flavor: 'chromium' | 'firefox' | 'safari'; +// - strictBlockMode, hasOmnipotence, exactUrlSource: booleans; +// - extensionPageURL: runtime.getURL('/strictblock.html'), for the legacy +// regexSubstitution shape; +// - stock: [{ rulesetId, rules }] in enabled order (rulesets/strictblock/); +// - user: [{ realm: 'sandbox' | 'imported', rules }] (derived templates); +// - excludedHostnames: permanent and temporary exclusions; +// - sessionRuleBudget, regexRuleBudget: what the other owners left. +// +// User redirects exist only on Chromium with broad host access and an exact +// URL source: a redirect lacking host access shadows the lower-priority +// block and the page loads (measured), and without an exact source the page +// could not name the blocked address. They sit one above their own block, so +// allow rules of the same list still win; excluded hosts are carved out with +// excludedRequestDomains, plus one allow per user base priority that defeats +// the dynamic fallback block. Stock redirects keep priority 29; while any is +// installed, one main_frame allow at 29 for excluded hosts also unblocks the +// stock main_frame blocks which are not strict-block candidates, so Proceed +// never ends on the browser's error page. +// +// Rules which exceed the session or regex budget are dropped from the end, +// stock first. A dropped user redirect is safe: its dynamic block still +// applies. IDs are 1..N, below STRICTBLOCK_SESSION_ID_LIMIT. owners lists +// [firstId, lastId, owner, 'redirect' | 'allow', sources?] ranges, where +// owner is 'stock:', 'stock', 'imported' or 'sandbox' and sources +// holds, per ID, the 1-based source rule number (packaged strict-block rule +// ID, or position in the realm's compiled dnrRules), or null. +export function planStrictBlockSessionRules(input = {}) { + const excluded = normalizeHostnames(input.excludedHostnames); + const plan = emptyPlan(excluded.length); + if ( input.flavor === 'safari' ) { return plan; } + if ( Boolean(input.strictBlockMode) === false ) { return plan; } + if ( input.hasOmnipotence !== true ) { return plan; } + const { counts, dropped } = plan; + + const userCandidates = []; + if ( input.flavor === 'chromium' && input.exactUrlSource === true ) { + const userInput = Array.isArray(input.user) ? input.user : []; + for ( const realm of [ 'sandbox', 'imported' ] ) { + for ( const entry of userInput ) { + if ( entry?.realm !== realm ) { continue; } + if ( Array.isArray(entry.rules) === false ) { continue; } + for ( const stored of entry.rules ) { + const candidate = toUserRedirect(stored, realm, excluded); + if ( candidate === undefined ) { + dropped.userMalformed += 1; + continue; + } + userCandidates.push(candidate); + } + } + } + } + + const stockCandidates = []; + for ( const entry of Array.isArray(input.stock) ? input.stock : [] ) { + if ( typeof entry?.rulesetId !== 'string' ) { continue; } + if ( Array.isArray(entry.rules) === false ) { continue; } + for ( const packaged of entry.rules ) { + const candidate = toStockRedirect(packaged, entry.rulesetId, + excluded, input.extensionPageURL + ); + if ( candidate === undefined ) { + dropped.stockMalformed += 1; + continue; + } + stockCandidates.push(candidate); + } + } + + // Budgets: an exclusion allow is reserved together with the first + // redirect which needs it, so a kept redirect never lacks its allow. + const sessionCap = Math.min( + toBudget(input.sessionRuleBudget), + STRICTBLOCK_SESSION_ID_LIMIT - 1 + ); + const regexCap = toBudget(input.regexRuleBudget); + let used = 0; + let regexUsed = 0; + const admit = (candidate, extra) => { + if ( used + 1 + extra > sessionCap ) { return 'SessionLimit'; } + const isRegex = isRegexRule(candidate.rule); + if ( isRegex && regexUsed >= regexCap ) { return 'RegexPool'; } + used += 1 + extra; + if ( isRegex ) { regexUsed += 1; } + return ''; + }; + + const keptUser = []; + const userAllows = new Map(); + for ( const candidate of userCandidates ) { + const extra = excluded.length !== 0 && + userAllows.has(candidate.base) === false ? 1 : 0; + const reason = admit(candidate, extra); + if ( reason !== '' ) { + dropped[`user${reason}`] += 1; + continue; + } + if ( extra !== 0 ) { + userAllows.set(candidate.base, candidate.owner); + } + keptUser.push(candidate); + } + + const keptStock = []; + let stockAllow = false; + for ( const candidate of stockCandidates ) { + const extra = excluded.length !== 0 && stockAllow === false ? 1 : 0; + const reason = admit(candidate, extra); + if ( reason !== '' ) { + dropped[`stock${reason}`] += 1; + continue; + } + if ( extra !== 0 ) { stockAllow = true; } + keptStock.push(candidate); + } + + const ordered = [ ...keptUser ]; + for ( const [ base, owner ] of userAllows ) { + ordered.push({ + owner, action: 'allow', source: null, + rule: exclusionAllow(base, excluded), + }); + } + ordered.push(...keptStock); + if ( stockAllow ) { + ordered.push({ + owner: 'stock', action: 'allow', source: null, + rule: exclusionAllow(STOCK_STRICTBLOCK_PRIORITY, excluded), + }); + } + + let range; + for ( const entry of ordered ) { + const id = plan.rules.length + 1; + plan.rules.push({ id, ...entry.rule }); + if ( + range === undefined || + range[2] !== entry.owner || range[3] !== entry.action + ) { + range = [ id, id, entry.owner, entry.action, [] ]; + plan.owners.push(range); + } + range[1] = id; + range[4].push(entry.source); + if ( entry.action === 'redirect' ) { + plan.redirectCount += 1; + if ( entry.owner === 'sandbox' || entry.owner === 'imported' ) { + counts[entry.owner] += 1; + } else { + counts.stock += 1; + } + } else { + counts.allows += 1; + } + if ( isRegexRule(entry.rule) ) { counts.regex += 1; } + } + for ( const owner of plan.owners ) { + if ( owner[4].every(source => source === null) ) { owner.length = 4; } + } + counts.rules = plan.rules.length; + counts.redirects = plan.redirectCount; + return plan; +} + +/******************************************************************************/ + +// Who installed a strict-block session redirect: { kind: 'stock', rulesetId } +// | { kind: 'imported' } | { kind: 'sandbox' }, plus `source` (the 1-based +// source rule number) when the plan recorded it. Allow rules and unknown IDs +// give undefined. +export function ownerForRuleId(owners, ruleId) { + if ( Array.isArray(owners) === false ) { return; } + if ( Number.isInteger(ruleId) === false ) { return; } + for ( const entry of owners ) { + if ( Array.isArray(entry) === false ) { continue; } + const [ firstId, lastId, owner, action, sources ] = entry; + if ( ruleId < firstId || ruleId > lastId ) { continue; } + if ( action !== 'redirect' || typeof owner !== 'string' ) { return; } + let out; + if ( owner === 'imported' || owner === 'sandbox' ) { + out = { kind: owner }; + } else if ( owner.startsWith('stock:') && owner.length > 6 ) { + out = { kind: 'stock', rulesetId: owner.slice(6) }; + } else { + return; + } + const source = Array.isArray(sources) + ? sources[ruleId - firstId] + : undefined; + if ( Number.isSafeInteger(source) ) { out.source = source; } + return out; + } +} diff --git a/platform/mv3/extension/js/strictblock-tracker.js b/platform/mv3/extension/js/strictblock-tracker.js new file mode 100644 index 0000000000000..a475707315b57 --- /dev/null +++ b/platform/mv3/extension/js/strictblock-tracker.js @@ -0,0 +1,700 @@ +/******************************************************************************* + uBlock Plus+ - which address a strict-block redirect replaced + Copyright (C) 2026-present uBlock Plus+ contributors; GPL-3.0-or-later + + Strict-block redirects are plain extensionPath redirects: they carry no + URL, so the strictblock.html page asks the service worker which address + was blocked in its tab. The answer comes from browser events, in order of + preference: + + 1. 'webrequest': webRequest.onBeforeRedirect and onBeforeRequest, main + frames only, when the optional webRequest permission is granted (always + in Experimental). Exact. After a server redirect Chrome reports the + DNR hop only as an onBeforeRequest for strictblock.html with the same + requestId, so the last web address of each request is remembered. + 2. 'rule-match': declarativeNetRequest.onRuleMatchedDebug, which Chrome + delivers only to unpacked installs with declarativeNetRequestFeedback. + Exact. It fires for every matched rule, so it is registered only while + a strict-block redirect is installed, never next to webRequest, and not + on the low-memory profile: it keeps the worker from ever idling. + 3. 'navigation-start': webNavigation.onBeforeNavigate of the top frame, + fed by background.js. Approximate: a server redirect after the start + is not seen. + 4. 'unavailable'. + + The page then carries the address in its own history entry (fragment and + history.state), so Back/Forward, reload and duplicated tabs need no event. + + No import from ext.js or background.js: every browser API is injected, so + tools/test-strictblock-tracker.mjs runs it in Node. +******************************************************************************/ + +import { + STRICTBLOCK_PAGE_PATH, + isStrictBlockSessionRule, + ownerForRuleId, +} from './strictblock-rules.js'; + +/******************************************************************************/ + +export const LAST_MATCH_STORAGE_KEY = 'strictBlock.lastMatch'; +export const PLAN_STORAGE_KEY = 'strictBlock.plan'; + +const exactSources = new Set([ 'webrequest', 'rule-match' ]); + +const isWebURL = raw => { + if ( typeof raw !== 'string' ) { return false; } + try { + const { protocol } = new URL(raw); + return protocol === 'http:' || protocol === 'https:'; + } catch { + } + return false; +}; + +// The redirect target is chrome-extension:///strictblock.html, where +// is the use_dynamic_url GUID rather than the extension ID. An +// extensionPath may carry a query and a fragment. +const isStrictBlockPageURL = raw => { + if ( typeof raw !== 'string' ) { return false; } + try { + const url = new URL(raw); + return url.protocol === 'chrome-extension:' && + url.pathname === STRICTBLOCK_PAGE_PATH; + } catch { + } + return false; +}; + +const isTabId = tabId => Number.isInteger(tabId) && tabId >= 0; + +const isRequestId = id => + typeof id === 'string' && id !== '' || Number.isSafeInteger(id); + +const resolveNamespace = value => typeof value === 'function' ? value() : value; + +// Owners cross the message boundary and storage.session: keep known shapes. +export function sanitizeStrictBlockOwner(owner) { + if ( owner instanceof Object === false ) { return; } + let out; + if ( owner.kind === 'imported' || owner.kind === 'sandbox' ) { + out = { kind: owner.kind }; + } else if ( + owner.kind === 'stock' && + typeof owner.rulesetId === 'string' && + /^[\w.-]{1,64}$/.test(owner.rulesetId) + ) { + out = { kind: 'stock', rulesetId: owner.rulesetId }; + } else { + return; + } + if ( Number.isSafeInteger(owner.source) && owner.source > 0 ) { + out.source = owner.source; + } + return out; +} + +const sanitizeRecord = raw => { + if ( raw instanceof Object === false ) { return; } + if ( isWebURL(raw.url) === false ) { return; } + if ( Number.isFinite(raw.t) === false ) { return; } + if ( exactSources.has(raw.source) === false ) { return; } + const rec = { url: raw.url, t: raw.t, source: raw.source }; + if ( isStrictBlockSessionRule({ id: raw.ruleId }) ) { + rec.ruleId = raw.ruleId; + } + const owner = sanitizeStrictBlockOwner(raw.owner); + if ( owner !== undefined ) { rec.owner = owner; } + return rec; +}; + +/******************************************************************************/ + +export function createStrictBlockTracker({ + dnr, + // The optional webRequest permission can be granted and removed while + // the worker runs, so these may be getters returning the live namespace. + webRequest, + webNavigation, + permissions, + sessionRead, + sessionWrite, + now = Date.now, + setTimer = globalThis.setTimeout, + clearTimer = globalThis.clearTimeout, + waitMs = 1500, + slackMs = 100, + navSlackMs = 1000, + maxTabs = 64, +} = {}) { + // tabId -> { url, t, source, ruleId?, owner? }, least recently set first. + const records = new Map(); + // tabId -> { url, t }: the last top-level navigation start. + const navigations = new Map(); + // tabId -> Set of callbacks re-checking a pending getDetails(). + const waiters = new Map(); + // webRequest requestId -> the last web address of that main-frame + // request, least recently set first. Memory only. + const requestURLs = new Map(); + + let registered = false; + let active = true; + let lowMemory = false; + let webRequestNamespace; + let ruleMatchRegistered = false; + let owners = null; + let planGeneration = 0; + let planPromise; + let restorePromise; + let writeChain = Promise.resolve(); + + const sessionRuleset = ( ) => dnr?.SESSION_RULESET_ID ?? '_session'; + + const ruleMatchEvent = ( ) => { + const event = dnr?.onRuleMatchedDebug; + return typeof event?.addListener === 'function' ? event : undefined; + }; + + const webRequestRedirectEvent = ( ) => { + const event = resolveNamespace(webRequest)?.onBeforeRedirect; + return typeof event?.addListener === 'function' ? event : undefined; + }; + + // Browser event times: webRequest and webNavigation timeStamps are epoch + // milliseconds taken when the event happened, not when the worker got it. + // onRuleMatchedDebug has none: its records carry the arrival time. + const eventTime = details => Number.isFinite(details?.timeStamp) + ? details.timeStamp + : now(); + + /**************************************************************************/ + + // Session 'strictBlock.plan'.owners tells which session rule IDs are + // strict-block redirects, and whose. The cache is dropped whenever the + // plan changes (setActive/setPlan) and reloaded on demand. + + const loadPlan = ( ) => { + if ( owners !== null ) { return Promise.resolve(); } + if ( planPromise !== undefined ) { return planPromise; } + if ( typeof sessionRead !== 'function' ) { return Promise.resolve(); } + const generation = planGeneration; + planPromise = Promise.resolve().then(( ) => + sessionRead(PLAN_STORAGE_KEY) + ).then(plan => { + if ( generation !== planGeneration ) { return; } + if ( Array.isArray(plan?.owners) ) { owners = plan.owners; } + }).catch(( ) => { + }).finally(( ) => { + if ( generation === planGeneration ) { planPromise = undefined; } + }); + return planPromise; + }; + + const invalidatePlan = ( ) => { + planGeneration += 1; + planPromise = undefined; + owners = null; + }; + + /**************************************************************************/ + + // storage.session mirror, so that a worker restart between the event and + // the page's question loses nothing. + + const trim = map => { + while ( map.size > maxTabs ) { + map.delete(map.keys().next().value); + } + }; + + const restore = ( ) => { + if ( restorePromise !== undefined ) { return restorePromise; } + restorePromise = (async ( ) => { + if ( typeof sessionRead !== 'function' ) { return; } + let stored; + try { + stored = await sessionRead(LAST_MATCH_STORAGE_KEY); + } catch { + } + if ( stored?.schemaVersion !== 1 ) { return; } + if ( Array.isArray(stored.records) === false ) { return; } + // Records received since the worker started are newer. + const current = Array.from(records); + records.clear(); + // storage.session is open to content scripts (background.js): + // a record from the future would win every later selection. + const latest = now() + slackMs; + for ( const entry of stored.records ) { + if ( Array.isArray(entry) === false ) { continue; } + const [ tabId, raw ] = entry; + if ( isTabId(tabId) === false ) { continue; } + const rec = sanitizeRecord(raw); + if ( rec === undefined ) { continue; } + if ( rec.t > latest ) { continue; } + records.delete(tabId); + records.set(tabId, rec); + } + for ( const [ tabId, rec ] of current ) { + records.delete(tabId); + records.set(tabId, rec); + } + trim(records); + })(); + return restorePromise; + }; + + const persist = ( ) => { + if ( typeof sessionWrite !== 'function' ) { return writeChain; } + writeChain = writeChain.then(restore).then(( ) => + sessionWrite(LAST_MATCH_STORAGE_KEY, { + schemaVersion: 1, + records: Array.from(records), + }) + ).catch(( ) => { + }); + return writeChain; + }; + + /**************************************************************************/ + + const notify = (tabId, removed = false) => { + const set = waiters.get(tabId); + if ( set === undefined ) { return; } + for ( const check of Array.from(set) ) { check(removed); } + }; + + const record = (tabId, rec) => { + records.delete(tabId); + records.set(tabId, rec); + trim(records); + notify(tabId); + void persist(); + }; + + // The page carries the address in its own history entry once it has it: + // an answered record is dropped, so that the storage.session copy only + // holds addresses no page asked for yet. + const forget = (tabId, rec) => { + if ( records.get(tabId) !== rec ) { return; } + records.delete(tabId); + void persist(); + }; + + const isMainFrameRequest = details => + (details?.type === undefined || details.type === 'main_frame') && + isTabId(details?.tabId); + + // A redirect straight from the blocked address: the direct case. + const onBeforeRedirect = details => { + if ( isMainFrameRequest(details) === false ) { return; } + if ( isStrictBlockPageURL(details.redirectUrl) === false ) { return; } + if ( isWebURL(details.url) === false ) { return; } + // Its onBeforeRequest for strictblock.html must not record it twice. + requestURLs.delete(details.requestId); + record(details.tabId, { + url: details.url, + t: eventTime(details), + source: 'webrequest', + }); + }; + + // After a server redirect Chrome fires no onBeforeRedirect for the DNR + // hop: onBeforeRequest(A), onBeforeRedirect(A -> B), onBeforeRequest(B), + // onBeforeRequest(strictblock.html), all with one requestId. The blocked + // address is then the request's previous web address. + const onBeforeRequest = details => { + if ( isMainFrameRequest(details) === false ) { return; } + if ( isRequestId(details.requestId) === false ) { return; } + const { requestId } = details; + if ( isWebURL(details.url) ) { + requestURLs.delete(requestId); + requestURLs.set(requestId, details.url); + trim(requestURLs); + return; + } + if ( isStrictBlockPageURL(details.url) === false ) { return; } + const url = requestURLs.get(requestId); + if ( url === undefined ) { return; } + requestURLs.delete(requestId); + record(details.tabId, { + url, + t: eventTime(details), + source: 'webrequest', + }); + }; + + const onRuleMatched = info => { + const request = info?.request; + const rule = info?.rule; + if ( request?.type !== 'main_frame' ) { return; } + if ( rule?.rulesetId !== sessionRuleset() ) { return; } + if ( isStrictBlockSessionRule({ id: rule.ruleId }) === false ) { return; } + if ( isTabId(request.tabId) === false ) { return; } + if ( isWebURL(request.url) === false ) { return; } + const rec = { + url: request.url, + t: now(), + source: 'rule-match', + ruleId: rule.ruleId, + }; + if ( owners !== null ) { + // Exclusion allow rules share the ID range: redirects only. + const owner = ownerForRuleId(owners, rule.ruleId); + if ( owner === undefined ) { return; } + rec.owner = owner; + } else { + // A worker woken by this very event has no plan in memory yet: + // keep the record, getDetails() checks it against the plan. + void loadPlan(); + } + record(request.tabId, rec); + }; + + /**************************************************************************/ + + const syncRuleMatchListener = ( ) => { + const event = ruleMatchEvent(); + if ( event === undefined ) { + ruleMatchRegistered = false; + return; + } + const wanted = registered && active && lowMemory === false && + webRequestNamespace === undefined; + if ( wanted === ruleMatchRegistered ) { return; } + try { + if ( wanted ) { + event.addListener(onRuleMatched); + } else { + event.removeListener(onRuleMatched); + } + ruleMatchRegistered = wanted; + } catch { + } + }; + + const webRequestListeners = [ + [ 'onBeforeRedirect', onBeforeRedirect ], + [ 'onBeforeRequest', onBeforeRequest ], + ]; + + const removeWebRequestListeners = namespace => { + for ( const [ name, fn ] of webRequestListeners ) { + try { + namespace?.[name]?.removeListener(fn); + } catch { + } + } + }; + + // Both listeners or none: onBeforeRedirect alone misses every block + // which follows a server redirect. + const registerWebRequest = ( ) => { + const namespace = resolveNamespace(webRequest); + if ( webRequestRedirectEvent() === undefined ) { return false; } + if ( typeof namespace.onBeforeRequest?.addListener !== 'function' ) { + return false; + } + try { + for ( const [ name, fn ] of webRequestListeners ) { + namespace[name].addListener(fn, { + urls: [ '' ], + types: [ 'main_frame' ], + }); + } + webRequestNamespace = namespace; + return true; + } catch { + } + removeWebRequestListeners(namespace); + return false; + }; + + const unregisterWebRequest = ( ) => { + if ( webRequestNamespace === undefined ) { return; } + removeWebRequestListeners(webRequestNamespace); + webRequestNamespace = undefined; + requestURLs.clear(); + }; + + /**************************************************************************/ + + // Selection. A record belongs to the page's navigation when it arrived + // after the page's navigation started: t >= timeOrigin - slackMs, where + // the slack absorbs the clock difference between the page and the worker + // (measured: records arrive 2-70 ms after timeOrigin). A record from + // before timeOrigin is also superseded when a later navigation to another + // address started in the same tab: two quick navigations are about 50 ms + // apart, well inside the slack, and the second page must never show the + // first address. Navigations are timed by their start, not by arrival: + // after a server redirect, the start of the redirecting navigation + // reaches the worker after the match of the address it led to. + + const isRedirectRecord = rec => { + if ( rec.source !== 'rule-match' || rec.owner !== undefined ) { + return true; + } + // Without a plan in session storage there is nothing to check + // against: the ID range alone is the best information available. + if ( owners === null ) { return true; } + return ownerForRuleId(owners, rec.ruleId) !== undefined; + }; + + const isSuperseded = (tabId, rec) => { + const nav = navigations.get(tabId); + return nav !== undefined && nav.t > rec.t && nav.url !== rec.url; + }; + + const pick = (tabId, timeOrigin, strict) => { + const rec = records.get(tabId); + if ( rec === undefined ) { return; } + if ( rec.t < timeOrigin - slackMs ) { return; } + if ( isRedirectRecord(rec) === false ) { return; } + if ( strict && rec.t < timeOrigin && isSuperseded(tabId, rec) ) { + return; + } + return rec; + }; + + const freshNavigation = (tabId, timeOrigin) => { + const nav = navigations.get(tabId); + if ( nav === undefined ) { return; } + return nav.t >= timeOrigin - navSlackMs ? nav : undefined; + }; + + const exactListening = ( ) => + webRequestNamespace !== undefined || ruleMatchRegistered; + + const navigationListening = ( ) => { + const navigation = resolveNamespace(webNavigation); + return typeof navigation?.onBeforeNavigate?.addListener === 'function'; + }; + + // Resolves with test()'s first defined result after an event for the + // tab, or undefined after waitMs or when the tab is removed. + const waitFor = (tabId, test) => new Promise(resolve => { + let set = waiters.get(tabId); + if ( set === undefined ) { + set = new Set(); + waiters.set(tabId, set); + } + let timer; + const done = result => { + clearTimer(timer); + set.delete(check); + if ( set.size === 0 && waiters.get(tabId) === set ) { + waiters.delete(tabId); + } + resolve(result); + }; + const check = removed => { + if ( removed ) { return done(); } + const result = test(); + if ( result !== undefined ) { done(result); } + }; + set.add(check); + timer = setTimer(( ) => { done(); }, waitMs); + }); + + // Best effort: getMatchedRules has no URL and is rate limited, but it + // names the rule for the webRequest and navigation-start sources. + const ownerFromMatchedRules = async (tabId, timeOrigin) => { + if ( typeof dnr?.getMatchedRules !== 'function' ) { return; } + let details; + try { + details = await dnr.getMatchedRules({ + tabId, + minTimeStamp: Math.floor(timeOrigin - slackMs), + }); + } catch { + return; + } + await loadPlan(); + if ( owners === null ) { return; } + let best; + for ( const info of details?.rulesMatchedInfo ?? [] ) { + if ( info?.rule?.rulesetId !== sessionRuleset() ) { continue; } + const owner = ownerForRuleId(owners, info.rule.ruleId); + if ( owner === undefined ) { continue; } + const timeStamp = Number.isFinite(info.timeStamp) ? info.timeStamp : 0; + if ( best !== undefined && best.timeStamp > timeStamp ) { continue; } + best = { owner, timeStamp }; + } + return best?.owner; + }; + + const ownerOf = async (rec, tabId, timeOrigin) => { + if ( rec.owner !== undefined ) { return rec.owner; } + if ( rec.source === 'rule-match' && owners !== null ) { + return ownerForRuleId(owners, rec.ruleId); + } + return ownerFromMatchedRules(tabId, timeOrigin); + }; + + const withOwner = (details, owner) => { + const sanitized = sanitizeStrictBlockOwner(owner); + if ( sanitized !== undefined ) { details.owner = sanitized; } + return details; + }; + + /**************************************************************************/ + + const tracker = { + // Must run synchronously at the top level of the service worker, so + // that the events which wake the worker reach these listeners. + registerTopLevelListeners() { + if ( registered ) { return; } + registered = true; + registerWebRequest(); + syncRuleMatchListener(); + }, + + // Active while the session plan has at least one redirect. Inactive, + // the onRuleMatchedDebug firehose is removed; the main-frame + // webRequest listener stays. Every call means the plan changed. + setActive(state) { + active = state === true; + invalidatePlan(); + syncRuleMatchListener(); + }, + + setPlan(plan) { + active = plan?.redirectCount > 0; + invalidatePlan(); + if ( Array.isArray(plan?.owners) ) { owners = plan.owners; } + syncRuleMatchListener(); + }, + + // The low-memory profile gives up the onRuleMatchedDebug source: it + // keeps the worker alive for as long as the user browses. The + // webRequest source, main frames only, stays. Returns whether the + // URL source changed, which changes the session plan. + setLowMemory(state) { + const before = tracker.urlSource(); + lowMemory = state === true; + syncRuleMatchListener(); + return tracker.urlSource() !== before; + }, + + onBeforeNavigate(details) { + if ( details?.frameId !== 0 ) { return; } + if ( isTabId(details.tabId) === false ) { return; } + if ( isWebURL(details.url) === false ) { return; } + navigations.delete(details.tabId); + navigations.set(details.tabId, { + url: details.url, + t: eventTime(details), + }); + trim(navigations); + notify(details.tabId); + }, + + onTabRemoved(tabId) { + navigations.delete(tabId); + notify(tabId, true); + if ( records.delete(tabId) ) { void persist(); } + }, + + // The optional webRequest permission was granted or removed. + async permissionsChanged() { + let granted = webRequestRedirectEvent() !== undefined; + if ( granted && typeof permissions?.contains === 'function' ) { + try { + granted = await permissions.contains({ + permissions: [ 'webRequest' ], + }); + } catch { + granted = false; + } + } + if ( registered ) { + if ( granted && webRequestNamespace === undefined ) { + registerWebRequest(); + } else if ( granted === false ) { + unregisterWebRequest(); + } + syncRuleMatchListener(); + } + return tracker.urlSource(); + }, + + // { url, source, owner? }: source is 'webrequest' or 'rule-match' + // (exact), 'navigation-start' (approximate) or 'none' with url ''. + async getDetails(tabId, timeOrigin) { + if ( isTabId(tabId) === false || Number.isFinite(timeOrigin) === false ) { + return { url: '', source: 'none' }; + } + await restore(); + await loadPlan(); + let rec = pick(tabId, timeOrigin, true); + if ( rec === undefined && exactListening() ) { + rec = await waitFor(tabId, ( ) => pick(tabId, timeOrigin, true)); + } else if ( + rec === undefined && navigationListening() && + freshNavigation(tabId, timeOrigin) === undefined + ) { + // Without an exact source only the navigation start can + // come; it may arrive after the page asked. + await waitFor(tabId, ( ) => freshNavigation(tabId, timeOrigin)); + } + if ( rec === undefined ) { + // A superseded record which no newer event replaced. + rec = pick(tabId, timeOrigin, false); + } + if ( rec !== undefined ) { + forget(tabId, rec); + const details = { url: rec.url, source: rec.source }; + return withOwner(details, await ownerOf(rec, tabId, timeOrigin)); + } + const nav = freshNavigation(tabId, timeOrigin); + if ( nav !== undefined ) { + const details = { url: nav.url, source: 'navigation-start' }; + return withOwner(details, + await ownerFromMatchedRules(tabId, timeOrigin) + ); + } + return { url: '', source: 'none' }; + }, + + // How the page learns the blocked address on this browser. Based on + // registered listeners, so it never claims more than was granted. + urlSource() { + if ( registered && webRequestNamespace !== undefined ) { + return 'webrequest'; + } + if ( + registered && lowMemory === false && + ruleMatchEvent() !== undefined + ) { + return 'rule-match'; + } + if ( navigationListening() ) { return 'navigation-start'; } + return 'unavailable'; + }, + + isExact() { + return exactSources.has(tracker.urlSource()); + }, + + // For tests and diagnostics. + snapshot() { + return { + registered, + active, + lowMemory, + webRequestListener: webRequestNamespace !== undefined, + ruleMatchListener: ruleMatchRegistered, + records: Array.from(records, ([ tabId, rec ]) => [ tabId, { ...rec } ]), + navigations: Array.from(navigations, ([ tabId, nav ]) => [ tabId, { ...nav } ]), + pendingTabs: Array.from(waiters.keys()), + }; + }, + + flush() { + return writeChain; + }, + }; + + return tracker; +} + +/******************************************************************************/ diff --git a/platform/mv3/extension/js/strictblock.js b/platform/mv3/extension/js/strictblock.js index bcffb5cca3d92..27b29d52aa68e 100644 --- a/platform/mv3/extension/js/strictblock.js +++ b/platform/mv3/extension/js/strictblock.js @@ -27,7 +27,97 @@ import { urlSkip } from './urlskip.js'; /******************************************************************************/ -const rulesetDetailsPromise = sendMessage({ what: 'getEnabledRulesetsDetails' }); +const rulesetDetailsPromise = sendMessage({ + what: 'getEnabledRulesetsDetails', +}).then(details => Array.isArray(details) ? details : [], ( ) => []); + +// history.state key: how the address was learned and which list blocked it, +// kept with the history entry so Back/Forward, reload and duplicated tabs +// show the same page without asking the service worker again. +const STATE_KEY = 'ublockPlusStrictBlock'; + +// The service worker waits up to 1.5 s for a late browser event. +const DETAILS_TIMEOUT_MS = 5000; + +/******************************************************************************/ + +// Only web pages are ever displayed as the target or navigated to. +function toWebURL(raw) { + if ( typeof raw !== 'string' || raw === '' ) { return; } + try { + const url = new URL(raw); + if ( url.protocol === 'http:' || url.protocol === 'https:' ) { + return url; + } + } catch { + } +} + +function sanitizeSource(source) { + return typeof source === 'string' && /^[a-z-]{1,32}$/.test(source) + ? source + : ''; +} + +function sanitizeOwner(owner) { + if ( owner instanceof Object === false ) { return; } + if ( owner.kind === 'imported' || owner.kind === 'sandbox' ) { + return { kind: owner.kind }; + } + if ( owner.kind === 'stock' && typeof owner.rulesetId === 'string' ) { + return { kind: 'stock', rulesetId: owner.rulesetId }; + } +} + +// The blocked address, in this order: +// - the fragment: legacy regexSubstitution redirects (Firefox builds), and +// every revisit of a history entry this page already annotated; +// - otherwise the service worker, which saw the redirect happen. The answer +// is then carried in this history entry's own fragment and state. +async function blockedDetails() { + const fromHash = toWebURL(self.location.hash.slice(1)); + if ( fromHash !== undefined ) { + const state = self.history.state?.[STATE_KEY]; + return { + url: fromHash, + source: sanitizeSource(state?.source) || 'fragment', + owner: sanitizeOwner(state?.owner), + }; + } + let response; + try { + response = await Promise.race([ + sendMessage({ + what: 'getStrictBlockDetails', + timeOrigin: self.performance.timeOrigin, + }), + new Promise(resolve => { + self.setTimeout(resolve, DETAILS_TIMEOUT_MS); + }), + ]); + } catch { + } + const url = toWebURL(response?.url); + if ( url === undefined ) { + return { url: undefined, source: 'none' }; + } + const details = { + url, + source: sanitizeSource(response.source) || 'none', + owner: sanitizeOwner(response.owner), + }; + try { + const state = self.history.state instanceof Object + ? self.history.state + : {}; + self.history.replaceState({ + ...state, + [STATE_KEY]: { source: details.source, owner: details.owner }, + }, '', `#${url.href}`); + } catch { + } + return details; +} /******************************************************************************/ @@ -49,39 +139,6 @@ function urlToFragment(raw) { /******************************************************************************/ -const toURL = new URL('about:blank'); -const toFinalURL = new URL('about:blank'); - -try { - toURL.href = self.location.hash.slice(1); - toFinalURL.href = toURL.href; -} catch { -} - -dom.clear('#theURL > p > span:first-of-type'); -qs$('#theURL > p > span:first-of-type').append(urlToFragment(toURL.href)); - -/******************************************************************************/ - -async function proceed() { - const permanent = qs$('#disableWarning').checked; - // Do not exclude current hostname from strict-block ruleset if a urlskip - // directive to another site is in effect. - // TODO: what if the urlskip directive leads to a different subdomain on - // same site? - if ( toFinalURL.hostname !== toURL.hostname && permanent !== true ) { - return window.location.replace(toFinalURL.href); - } - await sendMessage({ - what: 'excludeFromStrictBlock', - hostname: toURL.hostname, - permanent, - }); - window.location.replace(toURL.href); -} - -/******************************************************************************/ - function fragmentFromTemplate(template, placeholder, text, details) { const fragment = new DocumentFragment(); const pos = template.indexOf(placeholder); @@ -112,11 +169,49 @@ function fragmentFromTemplate(template, placeholder, text, details) { /******************************************************************************/ +function showURLNote(key) { + const text = i18n$(key); + if ( text === '' ) { return; } + dom.text('#urlNote', text); + dom.attr('#urlNote', 'hidden', null); +} + +function disableDontWarn() { + dom.prop('#disableWarning', 'checked', false); + dom.prop('#disableWarning', 'disabled', true); + dom.attr('#disableWarning', 'aria-disabled', 'true'); + dom.attr('.input.checkbox', 'disabled', ''); +} + +// Without a known address there is nothing to proceed to and no site to +// stop warning about. +function renderUnknownURL() { + dom.attr('#theURL', 'hidden', ''); + showURLNote('strictblockPlusUrlUnavailable'); + dom.prop('#proceed', 'disabled', true); + disableDontWarn(); +} + +// Where the navigation started may be a redirector in front of the blocked +// site: Proceed may come back here, and a permanent "don't warn" must not +// rest on a guessed host. +function renderApproximateURL() { + showURLNote('strictblockPlusUrlApproximate'); + disableDontWarn(); +} + +function renderURL(toURL) { + dom.clear('#theURL > p > span:first-of-type'); + qs$('#theURL > p > span:first-of-type').append(urlToFragment(toURL.href)); +} + +/******************************************************************************/ + // https://github.com/gorhill/uBlock/issues/691 // Parse URL to extract as much useful information as possible. This is // useful to assist the user in deciding whether to navigate to the web page. -(( ) => { +function renderParsedURL(toURL) { const reURL = /^https?:\/\//; const liFromParam = function(name, value) { @@ -182,69 +277,93 @@ function fragmentFromTemplate(template, placeholder, text, details) { dom.on('#toggleParse', 'click', ( ) => { dom.cl.toggle('#theURL', 'collapsed'); }); -})(); +} /******************************************************************************/ -// Find which list caused the blocking. +// Which list caused the blocking. The service worker names it from the rule +// which matched; the rule files are searched only when it could not, and only +// if they still carry the URL in a regexSubstitution (Firefox builds). The +// extensionPath redirects of other builds have no regex to test. -(async ( ) => { - const rulesetDetails = await rulesetDetailsPromise; - let iList = -1; - const searchInList = async i => { - if ( iList !== -1 ) { return; } - const rules = await fetchJSON(`/rulesets/strictblock/${rulesetDetails[i].id}`); - if ( iList !== -1 ) { return; } - const toHref = toURL.href; +async function legacyListName(rulesetDetails, toURL) { + const candidates = rulesetDetails.filter(details => + Boolean(details?.rules?.strictblock) + ); + if ( candidates.length === 0 ) { return ''; } + const toHref = toURL.href; + const matchesDomain = requestDomains => { + if ( requestDomains === undefined ) { return true; } + if ( Array.isArray(requestDomains) === false ) { return false; } + let hn = toURL.hostname; + for (;;) { + if ( requestDomains.includes(hn) ) { return true; } + const pos = hn.indexOf('.'); + if ( pos === -1 ) { return false; } + hn = hn.slice(pos+1); + } + }; + const matchesList = rules => { + if ( Array.isArray(rules) === false ) { return false; } for ( const rule of rules ) { - const { regexFilter, requestDomains } = rule.condition; - let matchesDomain = requestDomains === undefined; - if ( requestDomains ) { - let hn = toURL.hostname; - for (;;) { - if ( requestDomains.includes(hn) ) { - matchesDomain = true; - break; - } - const pos = hn.indexOf('.'); - if ( pos === -1 ) { break; } - hn = hn.slice(pos+1); - } - if ( matchesDomain === false ) { continue; } + if ( typeof rule?.action?.redirect?.regexSubstitution !== 'string' ) { + continue; } - const re = new RegExp(regexFilter); - const matchesRegex = re.test(toHref); - if ( matchesDomain && matchesRegex ) { - iList = i; + const { regexFilter, requestDomains } = rule.condition || {}; + if ( typeof regexFilter !== 'string' ) { continue; } + if ( matchesDomain(requestDomains) === false ) { continue; } + try { + const flags = rule.condition.isUrlFilterCaseSensitive === true + ? '' + : 'i'; + if ( new RegExp(regexFilter, flags).test(toHref) ) { + return true; + } + } catch { } } + return false; }; - const toFetch = []; - for ( let i = 0; i < rulesetDetails.length; i++ ) { - if ( Boolean(rulesetDetails[i].rules.strictblock) === false ) { continue; } - toFetch.push(searchInList(i)); + const results = await Promise.all(candidates.map(details => + fetchJSON(`/rulesets/strictblock/${details.id}`).then(matchesList) + )); + const i = results.indexOf(true); + return i !== -1 ? candidates[i].name || '' : ''; +} + +async function renderReason(details) { + const rulesetDetails = await rulesetDetailsPromise; + const { owner } = details; + let name = ''; + if ( owner?.kind === 'stock' ) { + const ruleset = rulesetDetails.find(r => r?.id === owner.rulesetId); + name = typeof ruleset?.name === 'string' ? ruleset.name : ''; + } else if ( owner?.kind === 'imported' ) { + name = i18n$('3pGroupImported'); + } else if ( owner?.kind === 'sandbox' ) { + name = i18n$('myFiltersPageName'); + } else if ( details.url !== undefined ) { + name = await legacyListName(rulesetDetails, details.url); } - if ( toFetch.length === 0 ) { return; } - await Promise.all(toFetch); - if ( iList === -1 ) { return; } + if ( name === '' ) { return; } const fragment = fragmentFromTemplate( i18n$('strictblockReasonSentence1'), - '{{listname}}', rulesetDetails[iList].name, + '{{listname}}', name, { tag: 'q' } ); qs$('#reason').append(fragment); dom.attr('#reason', 'hidden', null); -})(); +} /******************************************************************************/ // Offer to skip redirection whenever possible -(async ( ) => { +async function renderURLSkip(toURL, toFinalURL) { const rulesetDetails = await rulesetDetailsPromise; const toFetch = []; for ( const details of rulesetDetails ) { - if ( Boolean(details.rules?.urlskip) === false ) { continue; } + if ( Boolean(details?.rules?.urlskip) === false ) { continue; } toFetch.push(fetchJSON(`/rulesets/urlskip/${details.id}`)); } if ( toFetch.length === 0 ) { return; } @@ -256,28 +375,57 @@ function fragmentFromTemplate(template, placeholder, text, details) { return toHn.charAt(toHn.length - hn.length - 1) === '.'; }; for ( const urlskips of urlskipLists ) { + if ( Array.isArray(urlskips) === false ) { continue; } for ( const urlskip of urlskips ) { - const re = new RegExp(urlskip.re, urlskip.c ? undefined : 'i'); + let re; + try { + re = new RegExp(urlskip.re, urlskip.c ? undefined : 'i'); + } catch { + continue; + } if ( re.test(toURL.href) === false ) { continue; } if ( urlskip.hostnames ) { if ( urlskip.hostnames.some(hn => matchesHn(hn)) === false ) { continue; } } - const finalURL = urlSkip(toURL.href, false, urlskip.steps); + const finalURL = toWebURL(urlSkip(toURL.href, false, urlskip.steps)); if ( finalURL === undefined ) { continue; } - toFinalURL.href = finalURL; + toFinalURL.href = finalURL.href; const fragment = fragmentFromTemplate( i18n$('strictblockRedirectSentence1'), - '{{url}}', urlToFragment(finalURL), - { tag: 'a', attributes: [ 'href', finalURL, 'class', 'code' ] } + '{{url}}', urlToFragment(finalURL.href), + { tag: 'a', attributes: [ 'href', finalURL.href, 'class', 'code' ] } ); qs$('#urlskip').append(fragment); dom.attr('#urlskip', 'hidden', null); return; } } -})(); +} + +/******************************************************************************/ + +async function proceed(toURL, toFinalURL) { + if ( toURL === undefined ) { return; } + const checkbox = qs$('#disableWarning'); + const permanent = checkbox.checked === true && checkbox.disabled !== true; + // Do not exclude current hostname from strict-block ruleset if a urlskip + // directive to another site is in effect. + // TODO: what if the urlskip directive leads to a different subdomain on + // same site? + if ( toFinalURL.hostname !== toURL.hostname && permanent !== true ) { + const finalURL = toWebURL(toFinalURL.href); + if ( finalURL === undefined ) { return; } + return window.location.replace(finalURL.href); + } + await sendMessage({ + what: 'excludeFromStrictBlock', + hostname: toURL.hostname, + permanent, + }); + window.location.replace(toURL.href); +} /******************************************************************************/ @@ -297,8 +445,29 @@ dom.on('#disableWarning', 'change', ev => { dom.cl.toggle('[data-i18n="strictblockClose"]', 'disabled', checked); }); -dom.on('#proceed', 'click', ( ) => { proceed(); }); +(async ( ) => { + let details = { url: undefined, source: 'none' }; + try { + details = await blockedDetails(); + } catch { + } + const toURL = details.url; + const toFinalURL = new URL(toURL !== undefined ? toURL.href : 'about:blank'); + + if ( toURL === undefined ) { + renderUnknownURL(); + } else { + renderURL(toURL); + if ( details.source === 'navigation-start' ) { + renderApproximateURL(); + } + renderParsedURL(toURL); + renderURLSkip(toURL, toFinalURL).catch(( ) => {}); + dom.on('#proceed', 'click', ( ) => { proceed(toURL, toFinalURL); }); + } + renderReason(details).catch(( ) => {}); -dom.cl.remove(dom.body, 'loading'); + dom.cl.remove(dom.body, 'loading'); +})(); /******************************************************************************/ diff --git a/platform/mv3/extension/js/troubleshooting.js b/platform/mv3/extension/js/troubleshooting.js index 7b7fd64499385..5d2490538c03e 100644 --- a/platform/mv3/extension/js/troubleshooting.js +++ b/platform/mv3/extension/js/troubleshooting.js @@ -63,6 +63,7 @@ export async function getTroubleshootingInfo(details) { registeredScripts, hasOmnipotence, runtimeCapabilities, + regexCapacity, ] = await Promise.all([ runtime.getPlatformInfo(), sendMessage({ what: 'getDefaultConfig' }), @@ -75,6 +76,8 @@ export async function getTroubleshootingInfo(details) { sendMessage({ what: 'getRegisteredContentScripts' }), sendMessage({ what: 'hasBroadHostPermissions' }), sendMessage({ what: 'getRuntimeCapabilities' }), + // Optional: a report without it is still useful. + sendMessage({ what: 'getRegexCapacity' }).catch(( ) => undefined), ]); const vendor = (( ) => { const extURL = runtime.getURL(''); @@ -121,6 +124,19 @@ export async function getTroubleshootingInfo(details) { 'smart popup': runtimeCapabilities.smartPopupObservation, }, }; + // Regex rules in use: the built-in lists' static pool and the pool + // shared by dynamic and session rules. + if ( regexCapacity?.schemaVersion === 1 ) { + const pool = (used, limit) => `${used ?? '?'}/${limit ?? '?'}`; + config.runtime.regex = [ + `static ${pool(regexCapacity.static?.enabled, regexCapacity.static?.limit)}`, + `shared ${pool(regexCapacity.shared?.used, regexCapacity.shared?.limit)}`, + ].join(', '); + } + // How the strict-block page learns the blocked address. + if ( typeof runtimeCapabilities.strictBlockUrlSource === 'string' ) { + config.runtime['strictblock url'] = runtimeCapabilities.strictBlockUrlSource; + } if ( currentConfig.strictBlockMode !== defaultConfig.strictBlockMode ) { config.strictblock = currentConfig.strictBlockMode; } diff --git a/platform/mv3/extension/strictblock.html b/platform/mv3/extension/strictblock.html index 9cd2570302a27..564498c8ffb75 100644 --- a/platform/mv3/extension/strictblock.html +++ b/platform/mv3/extension/strictblock.html @@ -23,6 +23,7 @@

 

    +