diff --git a/platform/mv3/chromium/manifest.json b/platform/mv3/chromium/manifest.json index 49f1381b2..6f775f676 100644 --- a/platform/mv3/chromium/manifest.json +++ b/platform/mv3/chromium/manifest.json @@ -25,7 +25,7 @@ "128": "img/icon_128.png" }, "manifest_version": 3, - "minimum_chrome_version": "119.0", + "minimum_chrome_version": "122.0", "name": "__MSG_extName__", "options_page": "dashboard.html", "optional_host_permissions": [ diff --git a/platform/mv3/extension/_locales/en/messages.json b/platform/mv3/extension/_locales/en/messages.json index 60cc95401..0d69a4929 100644 --- a/platform/mv3/extension/_locales/en/messages.json +++ b/platform/mv3/extension/_locales/en/messages.json @@ -234,5 +234,29 @@ "findListsPlaceholder": { "message": "Find lists", "description": "Placeholder for the input field used to find lists" + }, + "strictblockTitle": { + "message": "Page blocked", + "description": "Webpage title for the strict-blocked page" + }, + "strictblockSentence1": { + "message": "uBO Lite has prevented the following page from loading:", + "description": "Sentence used in the strict-blocked page" + }, + "strictblockBack": { + "message": "Go back", + "description": "A button to go back to the previous webpage" + }, + "strictblockClose": { + "message": "Close this window", + "description": "A button to close the current tab" + }, + "strictblockDontWarn": { + "message": "Don't warn me again about this site", + "description": "Label for checkbox in document-blocked page" + }, + "strictblockProceed": { + "message": "Proceed", + "description": "A button to navigate to the blocked page" } } diff --git a/platform/mv3/extension/css/strict-block.css b/platform/mv3/extension/css/strict-block.css new file mode 100644 index 000000000..f7534078c --- /dev/null +++ b/platform/mv3/extension/css/strict-block.css @@ -0,0 +1,147 @@ +/** + uBlock Origin - a browser extension to block requests. + Copyright (C) 2018-present Raymond Hill + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + Home: https://github.com/gorhill/uBlock +*/ + +body { + display: flex; + padding: var(--default-gap-xxlarge) var(--default-gap-small); + justify-content: center; + } +:root.mobile body { + padding: var(--default-gap-small); + } + +#rootContainer { + width: min(100%, 640px); + } +#rootContainer > * { + margin: 0 0 var(--default-gap-xxlarge) 0; + } +:root.mobile #rootContainer > * { + margin-bottom: var(--default-gap-xlarge); + } + +p { + margin: 0.5em 0; + } +a { + text-decoration: none; + } +.code { + font-size: 13px; + word-break: break-all; + } +#warningSign { + color: var(--accent-surface-1); + fill: var(--accent-surface-1); + font-size: 96px; + line-height: 1; + width: 100%; + } +:root.mobile #warningSign { + font-size: 64px; + } +#theURL { + color: var(--ink-2); + padding: 0; + } +#theURL > * { + margin: 0; + } +#theURL > p { + position: relative; + z-index: 10; + } +#theURL > p > span:first-of-type { + display: block; + max-height: 6lh; + overflow-y: auto; + } +:root.mobile #theURL > p > span:first-of-type { + max-height: 3lh; + } +#theURL #toggleParse { + background-color: transparent; + top: 100%; + box-sizing: border-box; + color: var(--ink-3); + fill: var(--ink-3); + cursor: pointer; + font-size: 1.2rem; + padding: var(--default-gap-xxsmall); + position: absolute; + transform: translate(0, -50%); + } +#theURL:not(.collapsed) #toggleParse > span:first-of-type { + display: none; + } +#theURL.collapsed #toggleParse > span:last-of-type { + display: none; + } +body[dir="ltr"] #toggleParse { + right: 0; + } +body[dir="rtl"] #toggleParse { + left: 0; + } +#theURL > p:hover #toggleParse { + transform: translate(0, -50%) scale(1.15); + } +#parsed { + background-color: var(--surface-1); + border: 4px solid var(--surface-2); + font-size: small; + overflow-x: auto; + padding: var(--default-gap-xxsmall); + text-align: initial; + text-overflow: ellipsis; + } +#theURL.collapsed > #parsed { + display: none; + } +#parsed ul, #parsed li { + list-style-type: none; + } +#parsed li { + white-space: nowrap; + } +#parsed span { + display: inline-block; + } +#parsed span:first-of-type { + font-weight: bold; + } + +#actionContainer { + display: flex; + justify-content: space-between; + } +:root.mobile #actionContainer { + justify-content: center; + display: flex; + flex-direction: column; + } +#actionContainer > button { + margin-bottom: 2rem + } + +/* Small-screen devices */ +:root.mobile button { + width: 100%; + } diff --git a/platform/mv3/extension/js/ruleset-manager.js b/platform/mv3/extension/js/ruleset-manager.js index f7d811e7d..b824dc025 100644 --- a/platform/mv3/extension/js/ruleset-manager.js +++ b/platform/mv3/extension/js/ruleset-manager.js @@ -23,6 +23,7 @@ import { browser, dnr, i18n, + runtime, } from './ext.js'; import { fetchJSON } from './fetch.js'; @@ -40,6 +41,7 @@ const REDIRECT_REALM_START = REMOVEPARAMS_REALM_END; const REDIRECT_REALM_END = REDIRECT_REALM_START + RULE_REALM_SIZE; const MODIFYHEADERS_REALM_START = REDIRECT_REALM_END; const MODIFYHEADERS_REALM_END = MODIFYHEADERS_REALM_START + RULE_REALM_SIZE; +const STRICTBLOCK_REALM_START = MODIFYHEADERS_REALM_END; const TRUSTED_DIRECTIVE_BASE_RULE_ID = 8000000; /******************************************************************************/ @@ -60,74 +62,70 @@ function getRulesetDetails() { /******************************************************************************/ function getDynamicRules() { - if ( getDynamicRules.dynamicRuleMapPromise !== undefined ) { - return getDynamicRules.dynamicRuleMapPromise; + if ( getDynamicRules.promise !== undefined ) { + return getDynamicRules.promise; } - getDynamicRules.dynamicRuleMapPromise = dnr.getDynamicRules().then(rules => { + getDynamicRules.promise = dnr.getDynamicRules().then(rules => { const rulesMap = new Map(rules.map(rule => [ rule.id, rule ])); ubolLog(`Dynamic rule count: ${rulesMap.size}`); ubolLog(`Available dynamic rule count: ${dnr.MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES - rulesMap.size}`); return rulesMap; }); - return getDynamicRules.dynamicRuleMapPromise; + return getDynamicRules.promise; +} + +function getDynamicRuleIds() { + if ( getDynamicRuleIds.promise !== undefined ) { + return getDynamicRuleIds.promise; + } + getDynamicRuleIds.promise = dnr.getDynamicRules().then(rules => { + return rules.map(rule => rule.id); + }); + return getDynamicRuleIds.promise; } /******************************************************************************/ async function pruneInvalidRegexRules(realm, rulesIn) { - // Avoid testing already tested regexes - const dynamicRules = await dnr.getDynamicRules(); - const validRegexSet = new Set( - dynamicRules.filter(rule => - rule.condition?.regexFilter && true || false - ).map(rule => - rule.condition.regexFilter - ) - ); + const rejectedRegexRules = []; + + const validateRegex = regex => { + return dnr.isRegexSupported({ regex, isCaseSensitive: false }).then(result => { + const isSupported = result?.isSupported || false; + pruneInvalidRegexRules.validated.set(regex, isSupported); + if ( isSupported ) { return true; } + rejectedRegexRules.push(`\t${regex} ${result?.reason}`); + return false; + }); + }; // Validate regex-based rules const toCheck = []; - const rejectedRegexRules = []; for ( const rule of rulesIn ) { if ( rule.condition?.regexFilter === undefined ) { toCheck.push(true); continue; } - const { - regexFilter: regex, - isUrlFilterCaseSensitive: isCaseSensitive - } = rule.condition; - if ( validRegexSet.has(regex) ) { - toCheck.push(true); + const { regexFilter } = rule.condition; + if ( pruneInvalidRegexRules.validated.has(regexFilter) ) { + toCheck.push(pruneInvalidRegexRules.validated.get(regexFilter)); continue; } - if ( pruneInvalidRegexRules.invalidRegexes.has(regex) ) { - toCheck.push(false); - continue; - } - toCheck.push( - dnr.isRegexSupported({ regex, isCaseSensitive }).then(result => { - if ( result.isSupported ) { return true; } - pruneInvalidRegexRules.invalidRegexes.add(regex); - rejectedRegexRules.push(`\t${regex} ${result.reason}`); - return false; - }) - ); + toCheck.push(validateRegex(regexFilter)); } // Collate results const isValid = await Promise.all(toCheck); if ( rejectedRegexRules.length !== 0 ) { - ubolLog( - `${realm} realm: rejected regexes:\n`, + ubolLog(`${realm} realm: rejected regexes:\n`, rejectedRegexRules.join('\n') ); } return rulesIn.filter((v, i) => isValid[i]); } -pruneInvalidRegexRules.invalidRegexes = new Set(); +pruneInvalidRegexRules.validated = new Map(); /******************************************************************************/ @@ -426,14 +424,88 @@ async function updateModifyHeadersRules() { /******************************************************************************/ +async function updateStrictBlockRules() { + const [ + hasOmnipotence, + rulesetDetails, + dynamicRuleMap, + ] = await Promise.all([ + browser.permissions.contains({ origins: [ '' ] }), + getEnabledRulesetsDetails(), + getDynamicRules(), + ]); + + // Fetch strick-block hostnames + const toFetch = []; + for ( const details of rulesetDetails ) { + if ( details.rules.strictBlock === 0 ) { continue; } + toFetch.push(fetchJSON(`/rulesets/strict-block/${details.id}`)); + } + const strictBlockRulesets = await Promise.all(toFetch); + + // Strict-block rules can only be enforced with omnipotence + let toStrictBlock = new Set(); + if ( hasOmnipotence ) { + for ( const hostnames of strictBlockRulesets ) { + if ( Array.isArray(hostnames) === false ) { continue; } + toStrictBlock = toStrictBlock.union(new Set(hostnames)); + } + } + + const addRules = []; + const removeRuleIds = []; + + if ( dynamicRuleMap.has(STRICTBLOCK_REALM_START) ) { + removeRuleIds.push(STRICTBLOCK_REALM_START); + dynamicRuleMap.delete(STRICTBLOCK_REALM_START); + } + + if ( toStrictBlock.size !== 0 ) { + const ubolOrigin = runtime.getURL('').replace(/\/$/, ''); + addRules.push({ + id: STRICTBLOCK_REALM_START, + action: { + type: 'redirect', + redirect: { + regexSubstitution: `${ubolOrigin}/strict-block.html#\\0`, + }, + }, + condition: { + regexFilter: '^.+$', + requestDomains: Array.from(toStrictBlock), + resourceTypes: [ 'main_frame' ], + }, + priority: 19, + }); + dynamicRuleMap.set(STRICTBLOCK_REALM_START, addRules[0]); + } + + if ( addRules.length === 0 && removeRuleIds.length === 0 ) { return; } + + if ( removeRuleIds.length !== 0 ) { + ubolLog(`Remove ${removeRuleIds.length} DNR strict-block rules`); + } + if ( addRules.length !== 0 ) { + ubolLog(`Add ${addRules.length} DNR strict-block rules`); + } + + return dnr.updateDynamicRules({ addRules, removeRuleIds }).catch(reason => { + console.error(`updateStrictBlockRules() / ${reason}`); + }); +} + +/******************************************************************************/ + // TODO: group all omnipotence-related rules into one realm. async function updateDynamicRules() { + ubolLog('Called updateDynamicRules()'); return Promise.all([ updateRegexRules(), updateRemoveparamRules(), updateRedirectRules(), updateModifyHeadersRules(), + updateStrictBlockRules(), ]); } diff --git a/platform/mv3/extension/js/strict-block.js b/platform/mv3/extension/js/strict-block.js new file mode 100644 index 000000000..c9c686d9d --- /dev/null +++ b/platform/mv3/extension/js/strict-block.js @@ -0,0 +1,207 @@ +/******************************************************************************* + + uBlock Origin - a comprehensive, efficient content blocker + Copyright (C) 2024-present Raymond Hill + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + Home: https://github.com/gorhill/uBlock +*/ + +import { dom, qs$ } from './dom.js'; +import { i18n$ } from './i18n.js'; +import { sendMessage } from './ext.js'; + +/******************************************************************************/ + +const toURL = new URL('about:blank'); + +function setURL(url) { + try { + toURL.href = url; + } catch(_) { + } +} + +setURL(self.location.hash.slice(1)); + +/******************************************************************************/ + +const urlToFragment = raw => { + try { + const fragment = new DocumentFragment(); + const url = new URL(raw); + const hn = url.hostname; + const i = raw.indexOf(hn); + const b = document.createElement('b'); + b.append(hn); + fragment.append(raw.slice(0,i), b, raw.slice(i+hn.length)); + return fragment; + } catch(_) { + } + return raw; +}; + +/******************************************************************************/ + +dom.clear('#theURL > p > span:first-of-type'); +qs$('#theURL > p > span:first-of-type').append(urlToFragment(toURL)); + +/******************************************************************************/ + +// 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. +(( ) => { + if ( typeof URL !== 'function' ) { return; } + + const reURL = /^https?:\/\//; + + const liFromParam = function(name, value) { + if ( value === '' ) { + value = name; + name = ''; + } + const li = dom.create('li'); + let span = dom.create('span'); + dom.text(span, name); + li.appendChild(span); + if ( name !== '' && value !== '' ) { + li.appendChild(document.createTextNode(' = ')); + } + span = dom.create('span'); + if ( reURL.test(value) ) { + const a = dom.create('a'); + dom.attr(a, 'href', value); + dom.text(a, value); + span.appendChild(a); + } else { + dom.text(span, value); + } + li.appendChild(span); + return li; + }; + + // https://github.com/uBlockOrigin/uBlock-issues/issues/1649 + // Limit recursion. + const renderParams = function(parentNode, rawURL, depth = 0) { + let url; + try { + url = new URL(rawURL); + } catch(ex) { + return false; + } + + const search = url.search.slice(1); + if ( search === '' ) { return false; } + + url.search = ''; + const li = liFromParam(i18n$('docblockedNoParamsPrompt'), url.href); + parentNode.appendChild(li); + + const params = new self.URLSearchParams(search); + for ( const [ name, value ] of params ) { + const li = liFromParam(name, value); + if ( depth < 2 && reURL.test(value) ) { + const ul = dom.create('ul'); + renderParams(ul, value, depth + 1); + li.appendChild(ul); + } + parentNode.appendChild(li); + } + + return true; + }; + + if ( renderParams(qs$('#parsed'), toURL) === false ) { return; } + + dom.cl.remove('#toggleParse', 'hidden'); + + dom.on('#toggleParse', 'click', ( ) => { + dom.cl.toggle('#theURL', 'collapsed'); + vAPI.localStorage.setItem( + 'document-blocked-expand-url', + (dom.cl.has('#theURL', 'collapsed') === false).toString() + ); + }); + + vAPI.localStorage.getItemAsync('document-blocked-expand-url').then(value => { + dom.cl.toggle('#theURL', 'collapsed', value !== 'true' && value !== true); + }); +})(); + +/******************************************************************************/ + +// https://www.reddit.com/r/uBlockOrigin/comments/breeux/close_this_window_doesnt_work_on_firefox/ + +if ( window.history.length > 1 ) { + dom.on('#back', 'click', ( ) => { + window.history.back(); + }); + qs$('#bye').style.display = 'none'; +} else { + dom.on('#bye', 'click', ( ) => { + sendMessage({ + what: 'closeThisTab', + }); + }); + qs$('#back').style.display = 'none'; +} + +/******************************************************************************/ + +const getTargetHostname = function() { + const url = new URL(toURL); + return url.hostname; +}; + +const proceedToURL = function() { + window.location.replace(toURL); +}; + +const proceedTemporary = async function() { + await sendMessage({ + what: 'temporarilyWhitelistDocument', + hostname: getTargetHostname(), + }); + proceedToURL(); +}; + +const proceedPermanent = async function() { + await sendMessage({ + what: 'toggleHostnameSwitch', + name: 'no-strict-blocking', + hostname: getTargetHostname(), + deep: true, + state: true, + persist: true, + }); + proceedToURL(); +}; + +dom.on('#disableWarning', 'change', ev => { + const checked = ev.target.checked; + dom.cl.toggle('[data-i18n="docblockedBack"]', 'disabled', checked); + dom.cl.toggle('[data-i18n="docblockedClose"]', 'disabled', checked); +}); + +dom.on('#proceed', 'click', ( ) => { + if ( qs$('#disableWarning').checked ) { + proceedPermanent(); + } else { + proceedTemporary(); + } +}); + +/******************************************************************************/ diff --git a/platform/mv3/extension/strict-block.html b/platform/mv3/extension/strict-block.html new file mode 100644 index 000000000..ec0aa04f2 --- /dev/null +++ b/platform/mv3/extension/strict-block.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + +
+ + +
+

_

+ +
+ +
+ +
+ +
+ + + +
+
+ + + + + + + diff --git a/platform/mv3/firefox/manifest.json b/platform/mv3/firefox/manifest.json index e191e3c6b..926fa7abc 100644 --- a/platform/mv3/firefox/manifest.json +++ b/platform/mv3/firefox/manifest.json @@ -16,10 +16,10 @@ "browser_specific_settings": { "gecko": { "id": "uBOLite@raymondhill.net", - "strict_min_version": "114.0" + "strict_min_version": "127.0" }, "gecko_android": { - "strict_min_version": "114.0" + "strict_min_version": "127.0" } }, "declarative_net_request": { diff --git a/platform/mv3/make-rulesets.js b/platform/mv3/make-rulesets.js index 04688f15a..dab7b1698 100644 --- a/platform/mv3/make-rulesets.js +++ b/platform/mv3/make-rulesets.js @@ -433,6 +433,33 @@ async function processNetworkFilters(assetDetails, network) { ); } + const strictBlocked = new Set(); + for ( const rule of plainGood ) { + if ( rule.action.type !== 'block' ) { continue; } + if ( rule.condition.domainType ) { continue; } + if ( rule.condition.regexFilter ) { continue; } + if ( rule.condition.urlFilter ) { continue; } + if ( rule.condition.requestMethods ) { continue; } + if ( rule.condition.excludedRequestMethods ) { continue; } + if ( rule.condition.resourceTypes ) { continue; } + if ( rule.condition.excludedResourceTypes ) { continue; } + if ( rule.condition.responseHeaders ) { continue; } + if ( rule.condition.excludedResponseHeaders ) { continue; } + if ( rule.condition.initiatorDomains ) { continue; } + if ( rule.condition.excludedInitiatorDomains ) { continue; } + if ( rule.condition.requestDomains === undefined ) { continue; } + if ( rule.condition.excludedRequestDomains ) { continue; } + for ( const hn of rule.condition.requestDomains ) { + strictBlocked.add(hn); + } + } + if ( strictBlocked.size !== 0 ) { + writeFile( + `${rulesetDir}/strict-block/${assetDetails.id}.json`, + toJSONRuleset(Array.from(strictBlocked)) + ); + } + return { total: rules.length, plain: plainGood.length, @@ -442,6 +469,7 @@ async function processNetworkFilters(assetDetails, network) { removeparam: removeparamsGood.length, redirect: redirects.length, modifyHeaders: modifyHeaders.length, + strictBlock: strictBlocked.size, }; } @@ -1085,6 +1113,7 @@ async function rulesetFromURLs(assetDetails) { removeparam: netStats.removeparam, redirect: netStats.redirect, modifyHeaders: netStats.modifyHeaders, + strictBlock: netStats.strictBlock, discarded: netStats.discarded, rejected: netStats.rejected, }, diff --git a/src/document-blocked.html b/src/document-blocked.html index 56d6a95a4..4c69359f1 100644 --- a/src/document-blocked.html +++ b/src/document-blocked.html @@ -47,10 +47,12 @@