From 0d52545899ff791844f0f6893667e799f07edad5 Mon Sep 17 00:00:00 2001 From: Nishant Arora <1895906+whizzzkid@users.noreply.github.com> Date: Wed, 10 May 2023 17:08:47 -0600 Subject: [PATCH] feat(mv3): adding dynamicNetRequest rule reconciliation logic + Firefox Builds (#1186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mv3): :sparkles: MV3 Manifest Migration * fix(mv3): :wastebasket: No longer needed * fix(mv3): :wrench: Corresponding MV3 Changes * feat(mv3): :package: Adding deps * feat(telemetry): Refactor Metrics Tracking from background service_worker (#1172) * feat(telemetry): :recycle: Init Telemetry away from background service_worker. * feat(telemetry): :recycle: Track metrics from page context instead of service_worker context * feat(mv3): :adhesive_bandage: Patch @protobufjs/inquire to not have eval * fix(mv3): :alien: Fixing contextMenus API changes (#1177) * fix(mv3): :alien: Fixing contextMenus API changes * fix(mv3): :adhesive_bandage: Fixing the browser.action api * fix(mv3): webpack configs (#1178) * fix(mv3): :alien: Fixing contextMenus API changes * fix(mv3): :adhesive_bandage: Fixing the browser.action api * fix(mv3): :wrench: Fixing webpack config * fix(mv3): :adhesive_bandage: Patching debug package and making background sw work. * feat(mv3): ✨ XHR to Fetch Migration (#1179) * fix(mv3): :alien: Fixing contextMenus API changes * fix(mv3): :adhesive_bandage: Fixing the browser.action api * fix(mv3): :wrench: Fixing webpack config * fix(mv3): :adhesive_bandage: Patching debug package and making background sw work. * feat(mv3): :sparkles: XMLHttpRequest => fetch * fix(mv3): :construction: Related changes to ipfs-path * fix(mv3): :construction: Other Related changes * fix(mv3): :construction: Changes to companion * fix(mv3): :white_check_mark: Fixing tests to account for async code. * Fix(mv3): Popup Was Broken (#1180) * fix(mv3): :alien: Fixing contextMenus API changes * fix(mv3): :adhesive_bandage: Fixing the browser.action api * fix(mv3): :wrench: Fixing webpack config * fix(mv3): :adhesive_bandage: Patching debug package and making background sw work. * feat(mv3): :sparkles: XMLHttpRequest => fetch * fix(mv3): :construction: Related changes to ipfs-path * fix(mv3): :construction: Other Related changes * fix(mv3): :construction: Changes to companion * fix(mv3): :white_check_mark: Fixing tests to account for async code. * feat(mv3): :recycle: Implementing a non-windowed companion instance * fix(mv3): :wastebasket: Removing calls to background page. * fix: :wastebasket: Unneeded debug statement * fix(mv3): :passport_control: Limiting permissions to chrome-extension * Update add-on/src/lib/ipfs-companion.js Co-authored-by: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> * fix(types): :label: Refactoring existing type declaration * fix(types): :label: Moving to new types path * feat(types): :sparkles: Adding typescript support for transpilation * feat(mv3): :sparkles: Adding blocking request tester * fix(mv3): :adhesive_bandage: package.json * fix(mv3): :rotating_light: Fix Lint * fix: :rotating_light: fix lint * fix(mv3): :adhesive_bandage: temp fix to build background context * fix(mv3): :necktie: Detection Logic for MV3 world. * feat(mv3): :sparkles: Dynamic RegexSubstitution * fix(types): :arrow_up: Adding .mocharc.json to fix mocha for TS. * fix: :rotating_light: Lint Fix * fix(mv3): :recycle: refactor background.service_worker * feat(mv3): :sparkles: Passing state to BlockOrObserve * fix(recovery): :bug: conditional for recovery * fix: :wastebasket: unneeded @ts-ignore * fix: :bulb: Adding comments * fix: fixing string method. * fix: removing extra space. * fix: removing @ts-nocheck * no longer needed * fix(mv3): :recycle: Refactor * feat(mv3): :sparkles: Adding rule-recon logic * saving state * fix(mv3): :wrench: Manifest * fix(mv3): :wrench: Fixing firefox webpack config * fix(mv3): :adhesive_bandage: Patching debug to use in memory store instead of browser.storage.local * fix: :rotating_light: fixing lint and moving from record type to map type. * fix: :memo: Adding docstrings. * fix(mv3): :poop: web-ext making things harder than it needs to be. * fix(mv3): :rewind: no more debug patching * fix(mv3): :poop: improved recon logic * fix: :memo: adding comments regarding debug. * fix: :rotating_light: Fix lint * fix(mv3): :passport_control: manifest perms * fix: :wastebasket: unnecessary blank line * feat(mv3): :test_tube: Adding initial tests * feat(mv3): :clown_face: Adding Mock DeclarativeNetRequest Implementation * nits * fix: adding more test examples * fix: self-documenting code. * fix: unneeded comment * Update test/functional/lib/redirect-handler/blockOrObserve.test.ts --------- Co-authored-by: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> --- add-on/manifest.chromium.json | 6 +- add-on/manifest.common.json | 8 +- add-on/manifest.firefox-beta.json | 2 +- add-on/manifest.firefox.json | 6 +- add-on/src/lib/ipfs-companion.js | 35 ++- add-on/src/lib/ipfs-request.js | 53 ++-- .../lib/redirect-handler/blockOrObserve.ts | 231 ++++++++++++++---- package.json | 4 +- patches/debug+4.3.4.patch | 32 --- .../redirect-handler/blockOrObserve.test.ts | 107 ++++++++ .../declarativeNetRequest.mock.ts | 32 +++ webpack.config.js | 15 ++ 12 files changed, 394 insertions(+), 137 deletions(-) delete mode 100644 patches/debug+4.3.4.patch create mode 100644 test/functional/lib/redirect-handler/blockOrObserve.test.ts create mode 100644 test/functional/lib/redirect-handler/declarativeNetRequest.mock.ts diff --git a/add-on/manifest.chromium.json b/add-on/manifest.chromium.json index feb693edf..88c82cfb9 100644 --- a/add-on/manifest.chromium.json +++ b/add-on/manifest.chromium.json @@ -1,5 +1,8 @@ { - "minimum_chrome_version": "101", + "minimum_chrome_version": "111", + "background": { + "service_worker": "dist/bundles/backgroundPage.bundle.js" + }, "permissions": [ "clipboardWrite", "contextMenus", @@ -13,6 +16,5 @@ "declarativeNetRequest", "declarativeNetRequestFeedback" ], - "host_permissions": [""], "incognito": "not_allowed" } diff --git a/add-on/manifest.common.json b/add-on/manifest.common.json index e88cd6918..6ec5994d5 100644 --- a/add-on/manifest.common.json +++ b/add-on/manifest.common.json @@ -6,9 +6,6 @@ "description": "__MSG_manifest_extensionDescription__", "homepage_url": "https://github.com/ipfs-shipyard/ipfs-companion", "author": "IPFS Community", - "background": { - "service_worker": "dist/bundles/backgroundPage.bundle.js" - }, "icons": { "19": "icons/png/ipfs-logo-on_19.png", "38": "icons/png/ipfs-logo-on_38.png", @@ -28,6 +25,7 @@ "browser_style": false, "page": "dist/options/options.html" }, + "host_permissions": [""], "web_accessible_resources": [ { "resources": [ @@ -40,9 +38,7 @@ "dist/recovery/recovery.html", "dist/recovery/recovery.js" ], - "matches": [ - "" - ] + "matches": [""] } ], "content_security_policy": { diff --git a/add-on/manifest.firefox-beta.json b/add-on/manifest.firefox-beta.json index 2f8d485d2..ae6f3d3c1 100644 --- a/add-on/manifest.firefox-beta.json +++ b/add-on/manifest.firefox-beta.json @@ -2,7 +2,7 @@ "browser_specific_settings": { "gecko": { "id": "ipfs-companion-dev-build@ci.ipfs.team", - "update_url": "https://ipfs-shipyard.github.io/ipfs-companion/ci/firefox/update.json" + "strict_min_version": "111.0.0" } } } diff --git a/add-on/manifest.firefox.json b/add-on/manifest.firefox.json index 53ab00945..0993471b7 100644 --- a/add-on/manifest.firefox.json +++ b/add-on/manifest.firefox.json @@ -5,14 +5,16 @@ "options_ui": { "browser_style": false }, + "background": { + "scripts": ["dist/bundles/backgroundPage.firefox.bundle.js"] + }, "browser_specific_settings": { "gecko": { "id": "ipfs-firefox-addon@lidel.org", - "strict_min_version": "109.0.0" + "strict_min_version": "111.0.0" } }, "permissions": [ - "", "idle", "tabs", "notifications", diff --git a/add-on/src/lib/ipfs-companion.js b/add-on/src/lib/ipfs-companion.js index da878b30c..50b453f9a 100644 --- a/add-on/src/lib/ipfs-companion.js +++ b/add-on/src/lib/ipfs-companion.js @@ -25,13 +25,15 @@ import { registerSubdomainProxy } from './http-proxy.js' import { runPendingOnInstallTasks } from './on-installed.js' import { getExtraInfoSpec } from './redirect-handler/blockOrObserve.js' +// this won't work in webworker context. Needs to be enabled manually +// https://github.com/debug-js/debug/issues/916 const log = debug('ipfs-companion:main') log.error = debug('ipfs-companion:main:error') let browserActionPort // reuse instance for status updates between on/off toggles // init happens on addon load in background/background.js -export default async function init (windowedContext = false) { +export default async function init () { // INIT // =================================================================== let ipfs // ipfs-api instance @@ -79,23 +81,19 @@ export default async function init (windowedContext = false) { copier = createCopier(notify, ipfsPathValidator) ipfsImportHandler = createIpfsImportHandler(getState, getIpfs, ipfsPathValidator, runtime, copier) inspector = createInspector(notify, ipfsPathValidator, getState) - if (!windowedContext) { - contextMenus = createContextMenus(getState, runtime, ipfsPathValidator, { - onAddFromContext, - onCopyCanonicalAddress: copier.copyCanonicalAddress, - onCopyRawCid: copier.copyRawCid, - onCopyAddressAtPublicGw: copier.copyAddressAtPublicGw - }) - modifyRequest = createRequestModifier(getState, dnslinkResolver, ipfsPathValidator, runtime) - log('register all listeners') - registerListeners() - await registerSubdomainProxy(getState, runtime, notify) - log('init done') - setApiStatusUpdateInterval(options.ipfsApiPollMs) - await runPendingOnInstallTasks() - } else { - log('init done (windowed context)') - } + contextMenus = createContextMenus(getState, runtime, ipfsPathValidator, { + onAddFromContext, + onCopyCanonicalAddress: copier.copyCanonicalAddress, + onCopyRawCid: copier.copyRawCid, + onCopyAddressAtPublicGw: copier.copyAddressAtPublicGw + }) + modifyRequest = createRequestModifier(getState, dnslinkResolver, ipfsPathValidator, runtime) + log('register all listeners') + registerListeners() + await registerSubdomainProxy(getState, runtime, notify) + log('init done') + setApiStatusUpdateInterval(options.ipfsApiPollMs) + await runPendingOnInstallTasks() } catch (error) { log.error('Unable to initialize addon due to error', error) if (notify) notify('notify_addonIssueTitle', 'notify_addonIssueMsg') @@ -120,7 +118,6 @@ export default async function init (windowedContext = false) { onBeforeSendInfoSpec.push('extraHeaders') } browser.webRequest.onBeforeSendHeaders.addListener( - onBeforeSendHeaders, { urls: [''] }, getExtraInfoSpec(onBeforeSendInfoSpec)) browser.webRequest.onBeforeRequest.addListener(onBeforeRequest, { urls: [''] }, getExtraInfoSpec()) browser.webRequest.onHeadersReceived.addListener(onHeadersReceived, { urls: [''] }, getExtraInfoSpec(['responseHeaders'])) diff --git a/add-on/src/lib/ipfs-request.js b/add-on/src/lib/ipfs-request.js index 20632ecde..4bcceaa61 100644 --- a/add-on/src/lib/ipfs-request.js +++ b/add-on/src/lib/ipfs-request.js @@ -10,7 +10,7 @@ import { dropSlash, ipfsUri, pathAtHttpGateway, sameGateway } from './ipfs-path. import { safeURL } from './options.js' import { braveNodeType } from './ipfs-client/brave.js' import { recoveryPagePath } from './constants.js' -import { addRuleToDynamicRuleSetGenerator, supportsBlock } from './redirect-handler/blockOrObserve.js' +import { addRuleToDynamicRuleSetGenerator, isLocalHost, supportsBlock } from './redirect-handler/blockOrObserve.js' const log = debug('ipfs-companion:request') log.error = debug('ipfs-companion:request:error') @@ -100,7 +100,7 @@ export function createRequestModifier (getState, dnslinkResolver, ipfsPathValida ignore(request.requestId) } // skip all local requests - if (request.url.startsWith('http://127.0.0.1') || request.url.startsWith('http://localhost') || request.url.startsWith('http://[::1]')) { + if (isLocalHost(request.url)) { ignore(request.requestId) } @@ -160,23 +160,19 @@ export function createRequestModifier (getState, dnslinkResolver, ipfsPathValida // take advantage of subdomain redirect provided by go-ipfs >= 0.5 if (state.redirect && request.type === 'main_frame' && sameGateway(request.url, state.gwURL)) { const redirectUrl = safeURL(request.url, { useLocalhostName: state.useSubdomains }).toString() - if (redirectUrl !== request.url) { - return handleRedirection({ - originUrl: request.url, - redirectUrl - }) - } + return handleRedirection({ + originUrl: request.url, + redirectUrl + }) } // For now normalize API to the IP to comply with go-ipfs checks if (state.redirect && request.type === 'main_frame' && sameGateway(request.url, state.apiURL)) { const redirectUrl = safeURL(request.url, { useLocalhostName: false }).toString() - if (redirectUrl !== request.url) { - return handleRedirection({ - originUrl: request.url, - redirectUrl - }) - } + return handleRedirection({ + originUrl: request.url, + redirectUrl + }) } // early sanity checks @@ -476,13 +472,21 @@ export function createRequestModifier (getState, dnslinkResolver, ipfsPathValida } } +/** + * Handles redirection in MV2 and MV3. + * + * @param {object} input contains originUrl and redirectUrl. + * @returns + */ function handleRedirection ({ originUrl, redirectUrl }) { - if (supportsBlock) { - return { redirectUrl } - } + if (redirectUrl !== '' && originUrl !== '' && redirectUrl !== originUrl) { + if (supportsBlock) { + return { redirectUrl } + } - // Let browser handle redirection MV3 style. - addRuleToDynamicRuleSet({ originUrl, redirectUrl }) + // Let browser handle redirection MV3 style. + addRuleToDynamicRuleSet({ originUrl, redirectUrl }) + } } // Returns a string with URL at the active gateway (local or public) @@ -532,13 +536,10 @@ async function redirectToGateway (request, url, state, ipfsPathValidator, runtim } } - // return a redirect only if URL changed - if (redirectUrl && request.url !== redirectUrl) { - return handleRedirection({ - originUrl: request.url, - redirectUrl - }) - } + return handleRedirection({ + originUrl: request.url, + redirectUrl + }) } function isSafeToRedirect (request, runtime) { diff --git a/add-on/src/lib/redirect-handler/blockOrObserve.ts b/add-on/src/lib/redirect-handler/blockOrObserve.ts index 998f0d468..43bc45f85 100644 --- a/add-on/src/lib/redirect-handler/blockOrObserve.ts +++ b/add-on/src/lib/redirect-handler/blockOrObserve.ts @@ -2,16 +2,58 @@ import browser from 'webextension-polyfill' import debug from 'debug' import { CompanionState } from '../../types/companion.js' +// this won't work in webworker context. Needs to be enabled manually +// https://github.com/debug-js/debug/issues/916 const log = debug('ipfs-companion:redirect-handler:blockOrObserve') log.error = debug('ipfs-companion:redirect-handler:blockOrObserve:error') -const savedRegexFilters = new Map() +interface regexFilterMap { + id: number + regexSubstitution: string +} interface redirectHandlerInput { originUrl: string redirectUrl: string } +const savedRegexFilters: Map = new Map() +const DEFAULT_LOCAL_RULES: redirectHandlerInput[] = [ + { + originUrl: 'http://127.0.0.1', + redirectUrl: 'http://localhost' + }, + { + originUrl: 'http://[::1]', + redirectUrl: 'http://localhost' + } +] + +/** + * This function determines if the request is headed to a local IPFS gateway. + * + * @param url + * @returns + */ +export function isLocalHost (url: string): boolean { + return url.startsWith('http://127.0.0.1') || + url.startsWith('http://localhost') || + url.startsWith('http://[::1]') +} + +/** + * Escape the characters that are allowed in the URL, but not in the regex. + * + * @param str URL string to escape + * @returns + */ +function escapeURLRegex (str: string): string { + // these characters are allowed in the URL, but not in the regex. + // eslint-disable-next-line no-useless-escape + const ALLOWED_CHARS_URL_REGEX = /([:\/\?#\[\]@!$&'\(\ )\*\+,;=-_\.~])/g + return str.replace(ALLOWED_CHARS_URL_REGEX, '\\$1') +} + /** * Construct a regex filter and substitution for a redirect. * @@ -23,9 +65,6 @@ function constructRegexFilter ({ originUrl, redirectUrl }: redirectHandlerInput) regexSubstitution: string regexFilter: string } { - // these characters are allowed in the URL, but not in the regex. - // eslint-disable-next-line no-useless-escape - const ALLOWED_CHARS_URL_REGEX = /([:\/\?#\[\]@!$&'\(\ )\*\+,;=-_\.~])/g // We can traverse the URL from the end, and find the first character that is different. let commonIdx = 1 while (commonIdx < Math.min(originUrl.length, redirectUrl.length)) { @@ -36,11 +75,22 @@ function constructRegexFilter ({ originUrl, redirectUrl }: redirectHandlerInput) } // We can now construct the regex filter and substitution. - const regexSubstitution = redirectUrl.slice(0, redirectUrl.length - commonIdx + 1) + '\\1' + let regexSubstitution = redirectUrl.slice(0, redirectUrl.length - commonIdx + 1) + '\\1' // We need to escape the characters that are allowed in the URL, but not in the regex. - const regexFilterFirst = `${originUrl.slice(0, originUrl.length - commonIdx + 1).replace(ALLOWED_CHARS_URL_REGEX, '\\$1')}` + const regexFilterFirst = escapeURLRegex(originUrl.slice(0, originUrl.length - commonIdx + 1)) // We need to match the rest of the URL, so we can use a wildcard. - const regexFilter = `^${regexFilterFirst}(.*)$` + const regexEnding = '((?:[^\\.]|$).*)$' + let regexFilter = `^${regexFilterFirst}${regexEnding}`.replace('https', 'https?') + + // This method does not parse: + // originUrl: "https://awesome.ipfs.io/" + // redirectUrl: "http://localhost:8081/ipns/awesome.ipfs.io/" + // that ends up with capturing all urls which we do not want. + if (regexFilter === `^https?\\:\\/${regexEnding}`) { + const subdomain = new URL(originUrl).hostname + regexFilter = `^https?\\:\\/\\/${escapeURLRegex(subdomain)}${regexEnding}}` + regexSubstitution = regexSubstitution.replace('\\1', `/${subdomain}\\1`) + } return { regexSubstitution, regexFilter } } @@ -58,6 +108,113 @@ export function getExtraInfoSpec (additionalParams: T[] = []): T[] { return additionalParams } +/** + * Validates if the rule has changed. + * + * @param rule + * @returns {boolean} + */ +function validateIfRuleChanged (rule: browser.DeclarativeNetRequest.Rule): boolean { + if (rule.condition.regexFilter !== undefined) { + const savedRule = savedRegexFilters.get(rule.condition.regexFilter) + if (savedRule !== undefined) { + return savedRule.id !== rule.id || savedRule.regexSubstitution !== rule.action.redirect?.regexSubstitution + } + } + return true +} + +/** + * Reconciles the rules on fresh start. + * + * @param {CompanionState} state + */ +async function reconcileRulesAndRemoveOld (state: CompanionState): Promise { + const rules = await browser.declarativeNetRequest.getDynamicRules() + const addRules: browser.DeclarativeNetRequest.Rule[] = [] + const removeRuleIds: number[] = [] + + // parse the existing rules and remove the ones that are not needed. + for (const rule of rules) { + if (rule.action.type === 'redirect' && + rule.condition.regexFilter !== undefined && + rule.action.redirect?.regexSubstitution !== undefined) { + if (validateIfRuleChanged(rule)) { + // We need to remove the old rule. + removeRuleIds.push(rule.id) + savedRegexFilters.delete(rule.condition.regexFilter) + } else { + savedRegexFilters.set(rule.condition.regexFilter, { + id: rule.id, + regexSubstitution: rule.action.redirect?.regexSubstitution + }) + } + } + } + + // add the new rules. + for (const { originUrl, redirectUrl } of DEFAULT_LOCAL_RULES) { + const { port } = new URL(state.gwURLString) + const regexFilter = `^${escapeURLRegex(`${originUrl}:${port}`)}(.*)$` + const regexSubstitution = `${redirectUrl}:${port}\\1` + + if (!savedRegexFilters.has(regexFilter)) { + // We need to add the new rule. + addRules.push(generateRule(regexFilter, regexSubstitution)) + } + } + await browser.declarativeNetRequest.updateDynamicRules({ addRules, removeRuleIds }) +} + +/** + * Generates a rule for the declarativeNetRequest API. + * + * @param regexFilter - The regex filter for the rule. + * @param regexSubstitution - The regex substitution for the rule. + * @param excludedInitiatorDomains - The domains that are excluded from the rule. + * @returns + */ +function generateRule ( + regexFilter: string, + regexSubstitution: string, + excludedInitiatorDomains: string[] = [] +): browser.DeclarativeNetRequest.Rule { + // We need to generate a random ID for the rule. + const id = Math.floor(Math.random() * 29999) + // We need to save the regex filter and ID to check if the rule already exists later. + savedRegexFilters.set(regexFilter, { id, regexSubstitution }) + + return { + id, + priority: 1, + action: { + type: 'redirect', + redirect: { regexSubstitution } + }, + condition: { + regexFilter, + excludedInitiatorDomains, + resourceTypes: [ + 'csp_report', + 'font', + 'image', + 'main_frame', + 'media', + 'object', + 'other', + 'ping', + 'script', + 'stylesheet', + 'sub_frame', + 'webbundle', + 'websocket', + 'webtransport', + 'xmlhttprequest' + ] + } + } +} + /** * Register a redirect rule in the dynamic rule set. * @@ -69,58 +226,38 @@ export function addRuleToDynamicRuleSetGenerator ( // returning a closure to avoid passing `getState` as an argument to `addRuleToDynamicRuleSet`. return async function ({ originUrl, redirectUrl }: redirectHandlerInput): Promise { const state = getState() + const redirectIsOrigin = originUrl === redirectUrl + const redirectIsLocal = isLocalHost(originUrl) && isLocalHost(redirectUrl) + const badOriginRedirect = originUrl.includes(state.gwURL.host) && !redirectUrl.includes('recovery') // We don't want to redirect to the same URL. Or to the gateway. - if (originUrl === redirectUrl || - (originUrl.includes(state.gwURL.host) && !redirectUrl.includes('recovery'))) { + if (redirectIsOrigin || badOriginRedirect || redirectIsLocal + ) { return } - // We need to generate a random ID for the rule. - const id = Math.floor(Math.random() * 29999) // We need to construct the regex filter and substitution. const { regexSubstitution, regexFilter } = constructRegexFilter({ originUrl, redirectUrl }) - // We need to check if the rule already exists. - if (!savedRegexFilters.has(regexFilter)) { + + const savedRule = savedRegexFilters.get(regexFilter) + if (savedRule === undefined || savedRule.regexSubstitution !== regexSubstitution) { + const removeRuleIds: number[] = [] + if (savedRule !== undefined) { + // We need to remove the old rule because the substitution has changed. + removeRuleIds.push(savedRule.id) + savedRegexFilters.delete(regexFilter) + } + await browser.declarativeNetRequest.updateDynamicRules( { // We need to add the new rule. - addRules: [ - { - id, - priority: 1, - action: { - type: 'redirect', - redirect: { regexSubstitution } - }, - condition: { - regexFilter, - excludedInitiatorDomains: [state.gwURL.host], - resourceTypes: [ - 'csp_report', - 'font', - 'image', - 'main_frame', - 'media', - 'object', - 'other', - 'ping', - 'script', - 'stylesheet', - 'sub_frame', - 'webbundle', - 'websocket', - 'webtransport', - 'xmlhttprequest' - ] - } - } - ], + addRules: [generateRule(regexFilter, regexSubstitution)], // We need to remove the old rules. - removeRuleIds: await browser.declarativeNetRequest.getDynamicRules().then((rules) => rules.map((rule) => rule.id)) + removeRuleIds } ) - // We need to save the regex filter and ID to check if the rule already exists later. - savedRegexFilters.set(regexFilter, id.toString()) } + + // async call to reconcile rules and remove old ones. + await reconcileRulesAndRemoveOld(state) } } diff --git a/package.json b/package.json index 1006cf0e6..9ac518345 100644 --- a/package.json +++ b/package.json @@ -44,10 +44,10 @@ "watch:js:webpack": "webpack --watch --mode development --devtool inline-source-map --config ./webpack.config.js", "test": "run-s test:*", "test:e2e": "mocha --timeout 300000 \"test/e2e/**/*.test.js\"", - "test:functional": "c8 mocha --timeout 5000 \"test/functional/**/*.test.js\"", + "test:functional": "c8 mocha --timeout 5000 \"test/functional/**/*.test.js\" \"test/functional/**/*.test.ts\"", "lint": "run-s lint:*", "lint:standard": "ts-standard -v \"*.js\" \"add-on/src/**/*.js\" \"add-on/src/**/*.ts\" \"test/**/*.js\" \"scripts/**/*.js\"", - "lint:web-ext": "web-ext lint --firefox-preview", + "lint:web-ext": "shx cat add-on/manifest.common.json add-on/manifest.chromium.json add-on/manifest.firefox-beta.json | json --deep-merge > add-on/manifest.json && web-ext lint --firefox-preview", "fix:lint": "run-s fix:lint:*", "fix:lint:standard": "ts-standard -v --fix \"*.js\" \"add-on/src/**/*.js\" \"add-on/src/**/*.ts\" \"test/**/*.js\" \"scripts/**/*.js\"", "precommit": "run-s lint:standard", diff --git a/patches/debug+4.3.4.patch b/patches/debug+4.3.4.patch deleted file mode 100644 index ae295b5f5..000000000 --- a/patches/debug+4.3.4.patch +++ /dev/null @@ -1,32 +0,0 @@ -diff --git a/node_modules/debug/src/browser.js b/node_modules/debug/src/browser.js -index cd0fc35..794db58 100644 ---- a/node_modules/debug/src/browser.js -+++ b/node_modules/debug/src/browser.js -@@ -116,7 +116,7 @@ function useColors() { - // NB: In an Electron preload script, document will be defined but not fully - // initialized. Since we know we're in Chrome, we'll just detect this case - // explicitly -- if (typeof window !== 'undefined' && window.process && (window.process.type === 'renderer' || window.process.__nwjs)) { -+ if (typeof globalThis !== 'undefined' && globalThis.process && (globalThis.process.type === 'renderer' || globalThis.process.__nwjs)) { - return true; - } - -@@ -129,7 +129,7 @@ function useColors() { - // document is undefined in react-native: https://github.com/facebook/react-native/pull/1632 - return (typeof document !== 'undefined' && document.documentElement && document.documentElement.style && document.documentElement.style.WebkitAppearance) || - // Is firebug? http://stackoverflow.com/a/398120/376773 -- (typeof window !== 'undefined' && window.console && (window.console.firebug || (window.console.exception && window.console.table))) || -+ (typeof globalThis !== 'undefined' && globalThis.console && (globalThis.console.firebug || (globalThis.console.exception && globalThis.console.table))) || - // Is firefox >= v31? - // https://developer.mozilla.org/en-US/docs/Tools/Web_Console#Styling_messages - (typeof navigator !== 'undefined' && navigator.userAgent && navigator.userAgent.toLowerCase().match(/firefox\/(\d+)/) && parseInt(RegExp.$1, 10) >= 31) || -@@ -245,6 +245,9 @@ function localstorage() { - try { - // TVMLKit (Apple TV JS Runtime) does not have a window object, just localStorage in the global context - // The Browser also has localStorage in the global context. -+ if (chrome?.storage?.local) { -+ return chrome.storage.local; -+ } - return localStorage; - } catch (error) { - // Swallow diff --git a/test/functional/lib/redirect-handler/blockOrObserve.test.ts b/test/functional/lib/redirect-handler/blockOrObserve.test.ts new file mode 100644 index 000000000..5f7626d60 --- /dev/null +++ b/test/functional/lib/redirect-handler/blockOrObserve.test.ts @@ -0,0 +1,107 @@ +import { expect } from 'chai' +import { before, describe, it } from 'mocha' +import sinon from 'sinon' +import browserMock from 'sinon-chrome' + +import { optionDefaults } from '../../../../add-on/src/lib/options.js' +import { addRuleToDynamicRuleSetGenerator, isLocalHost } from '../../../../add-on/src/lib/redirect-handler/blockOrObserve' +import { initState } from '../../../../add-on/src/lib/state.js' +import DeclarativeNetRequestMock from './declarativeNetRequest.mock.js' + +const dynamicRulesConditions = (regexFilter) => ({ + regexFilter, + excludedInitiatorDomains: [], + resourceTypes: [ + 'csp_report', + 'font', + 'image', + 'main_frame', + 'media', + 'object', + 'other', + 'ping', + 'script', + 'stylesheet', + 'sub_frame', + 'webbundle', + 'websocket', + 'webtransport', + 'xmlhttprequest' + ] +}) + +describe('lib/redirect-handler/blockOrObserve', () => { + before(function () { + browserMock.runtime.id = 'testid' + }) + + describe('isLocalHost', () => { + it('should return true for localhost', () => { + expect(isLocalHost('http://[::1]:8080/ipfs/QmHash')).to.be.true + expect(isLocalHost('http://[::1]:8080/ipfs/QmHash')).to.be.true + expect(isLocalHost('http://127.0.0.1:8080/ipns/QmHash')).to.be.true + expect(isLocalHost('http://127.0.0.1:8080/ipns/QmHash')).to.be.true + expect(isLocalHost('http://ipfs.tech')).to.be.false + expect(isLocalHost('http://localhost:8080')).to.be.true + expect(isLocalHost('http://localhost:8080')).to.be.true + expect(isLocalHost('http://localhost:8080')).to.be.true + expect(isLocalHost('http://localhost:8080/ipfs/QmHash')).to.be.true + expect(isLocalHost('http://localhost:8080/ipfs/QmHash')).to.be.true + expect(isLocalHost('http://localhost')).to.be.true + expect(isLocalHost('https://google.com')).to.be.false + expect(isLocalHost('https://ipfs.io')).to.be.false + }) + }) + + describe('addRuleToDynamicRuleSetGenerator', () => { + let addRuleToDynamicRuleSet + let state + let sinonSandbox + + before(() => { + sinonSandbox = sinon.createSandbox() + state = Object.assign(initState(optionDefaults), { peerCount: 1 }) + addRuleToDynamicRuleSet = addRuleToDynamicRuleSetGenerator(() => state) + }) + + beforeEach(() => { + sinonSandbox.restore() + browserMock.declarativeNetRequest = sinonSandbox.spy(new DeclarativeNetRequestMock()) + }) + + it('Should not redirect requests from localhost', () => { + // when both redirectUrl and originUrl are same. + addRuleToDynamicRuleSet({ originUrl: 'http://localhost:8080', redirectUrl: 'http://localhost:8080' }) + expect(browserMock.declarativeNetRequest.updateDynamicRules.called).to.be.false + + // when redirectUrl is different from originUrl, but both are localhost. + addRuleToDynamicRuleSet({ originUrl: 'http://localhost:9001/foo', redirectUrl: 'http://localhost:9001/bar' }) + expect(browserMock.declarativeNetRequest.updateDynamicRules.called).to.be.false + }) + + it('Should allow pages to be recovered', () => { + // when redirecting to recovery page + addRuleToDynamicRuleSet({ + originUrl: 'http://localhost:8080', + redirectUrl: 'chrome-extension://some-path/dist/recover/recovery.html' + }) + expect(browserMock.declarativeNetRequest.updateDynamicRules.called).to.be.true + }) + + it('Should add redirect rules for local gateway', () => { + addRuleToDynamicRuleSet({ + originUrl: 'https://ipfs.io/ipns/en.wikipedia-on-ipfs.org', + redirectUrl: 'http://localhost:8080/ipns/en.wikipedia-on-ipfs.org' + }) + expect(browserMock.declarativeNetRequest.updateDynamicRules.called).to.be.true + const [{ addRules, removeRuleIds }] = browserMock.declarativeNetRequest.updateDynamicRules.firstCall.args + expect(removeRuleIds).to.deep.equal([]) + expect(addRules).to.have.lengthOf(1) + const [{ id, priority, action, condition }] = addRules + expect(id).to.be.a('number') + expect(priority).to.equal(1) + expect(action).to.deep.equal({ type: 'redirect', redirect: { "regexSubstitution": "http://localhost:8080\\1" } }) + expect(condition).to.deep.equal(dynamicRulesConditions('^https?\\:\\/\\/ipfs\\.io((?:[^\\.]|$).*)$')) + }) + }) +}) diff --git a/test/functional/lib/redirect-handler/declarativeNetRequest.mock.ts b/test/functional/lib/redirect-handler/declarativeNetRequest.mock.ts new file mode 100644 index 000000000..a873c1ad2 --- /dev/null +++ b/test/functional/lib/redirect-handler/declarativeNetRequest.mock.ts @@ -0,0 +1,32 @@ +import browser from 'webextension-polyfill' + +/** + * https://github.com/acvetkov/sinon-chrome/issues/110 + * + * Since this is not implemented in sinon-chrome, this is a bare-bones mock implementation. + * This still needs to be instrumented in sinon, to be able to assert on calls. + */ +class DeclarativeNetRequestMock { + private rules: Map; + + constructor() { + this.rules = new Map() + } + + async getDynamicRules(): Promise { + return [...this.rules.values()] + } + + async updateDynamicRules({ + addRules, + removeRuleIds + }: { + addRules: browser.DeclarativeNetRequest.Rule[], + removeRuleIds: number[] + }): Promise { + removeRuleIds.forEach(id => this.rules.delete(id)) + addRules.forEach(rule => this.rules.set(rule.id, rule)) + } +} + +export default DeclarativeNetRequestMock diff --git a/webpack.config.js b/webpack.config.js index b9dbbdf08..fbbcb8e44 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -165,6 +165,20 @@ const bgConfig = merge(commonConfig, { } }) +/** + * background page bundle (with heavy dependencies) + * @type {import('webpack').Configuration} + */ +const bgFirefoxConfig = merge(commonConfig, { + name: 'background-firefox', + entry: { + backgroundPage: './add-on/src/background/background.js' + }, + output: { + filename: '[name].firefox.bundle.js' + } +}) + /** * user interface pages with shared common libraries * @type {import('webpack').Configuration} @@ -209,6 +223,7 @@ const contentScriptsConfig = merge(commonConfig, { const config = [ bgConfig, + bgFirefoxConfig, uiConfig, contentScriptsConfig ]