diff options
Diffstat (limited to 'browser/extensions/webcompat/lib/shims.js')
-rw-r--r-- | browser/extensions/webcompat/lib/shims.js | 1044 |
1 files changed, 1044 insertions, 0 deletions
diff --git a/browser/extensions/webcompat/lib/shims.js b/browser/extensions/webcompat/lib/shims.js new file mode 100644 index 0000000000..ee33627c57 --- /dev/null +++ b/browser/extensions/webcompat/lib/shims.js @@ -0,0 +1,1044 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* globals browser, module, onMessageFromTab */ + +// To grant shims access to bundled logo images without risking +// exposing our moz-extension URL, we have the shim request them via +// nonsense URLs which we then redirect to the actual files (but only +// on tabs where a shim using a given logo happens to be active). +const LogosBaseURL = "https://smartblock.firefox.etp/"; + +const releaseBranchPromise = browser.appConstants.getReleaseBranch(); + +const platformPromise = browser.runtime.getPlatformInfo().then(info => { + return info.os === "android" ? "android" : "desktop"; +}); + +let debug = async function () { + if ((await releaseBranchPromise) !== "release_or_beta") { + console.debug.apply(this, arguments); + } +}; +let error = async function () { + if ((await releaseBranchPromise) !== "release_or_beta") { + console.error.apply(this, arguments); + } +}; +let warn = async function () { + if ((await releaseBranchPromise) !== "release_or_beta") { + console.warn.apply(this, arguments); + } +}; + +class Shim { + constructor(opts, manager) { + this.manager = manager; + + const { contentScripts, matches, unblocksOnOptIn } = opts; + + this.branches = opts.branches; + this.bug = opts.bug; + this.isGoogleTrendsDFPIFix = opts.custom == "google-trends-dfpi-fix"; + this.file = opts.file; + this.hiddenInAboutCompat = opts.hiddenInAboutCompat; + this.hosts = opts.hosts; + this.id = opts.id; + this.logos = opts.logos || []; + this.matches = []; + this.name = opts.name; + this.notHosts = opts.notHosts; + this.onlyIfBlockedByETP = opts.onlyIfBlockedByETP; + this.onlyIfDFPIActive = opts.onlyIfDFPIActive; + this.onlyIfPrivateBrowsing = opts.onlyIfPrivateBrowsing; + this._options = opts.options || {}; + this.needsShimHelpers = opts.needsShimHelpers; + this.platform = opts.platform || "all"; + this.runFirst = opts.runFirst; + this.unblocksOnOptIn = unblocksOnOptIn; + this.requestStorageAccessForRedirect = opts.requestStorageAccessForRedirect; + + this._hostOptIns = new Set(); + + this._disabledByConfig = opts.disabled; + this._disabledGlobally = false; + this._disabledForSession = false; + this._disabledByPlatform = false; + this._disabledByReleaseBranch = false; + + this._activeOnTabs = new Set(); + this._showedOptInOnTabs = new Set(); + + const pref = `disabled_shims.${this.id}`; + + this.redirectsRequests = !!this.file && matches?.length; + + this._contentScriptRegistrations = []; + this.contentScripts = contentScripts || []; + for (const script of this.contentScripts) { + if (typeof script.css === "string") { + script.css = [{ file: `/shims/${script.css}` }]; + } + if (typeof script.js === "string") { + script.js = [{ file: `/shims/${script.js}` }]; + } + } + + for (const match of matches || []) { + if (!match.types) { + this.matches.push({ patterns: [match], types: ["script"] }); + } else { + this.matches.push(match); + } + if (match.target) { + this.redirectsRequests = true; + } + } + + browser.aboutConfigPrefs.onPrefChange.addListener(async () => { + const value = await browser.aboutConfigPrefs.getPref(pref); + this._disabledPrefValue = value; + this._onEnabledStateChanged(); + }, pref); + + this.ready = Promise.all([ + browser.aboutConfigPrefs.getPref(pref), + platformPromise, + releaseBranchPromise, + ]).then(([disabledPrefValue, platform, branch]) => { + this._disabledPrefValue = disabledPrefValue; + + this._disabledByPlatform = + this.platform !== "all" && this.platform !== platform; + + this._disabledByReleaseBranch = false; + for (const supportedBranchAndPlatform of this.branches || []) { + const [supportedBranch, supportedPlatform] = + supportedBranchAndPlatform.split(":"); + if ( + (!supportedPlatform || supportedPlatform == platform) && + supportedBranch != branch + ) { + this._disabledByReleaseBranch = true; + } + } + + this._preprocessOptions(platform, branch); + this._onEnabledStateChanged(); + }); + } + + _preprocessOptions(platform, branch) { + // options may be any value, but can optionally be gated for specified + // platform/branches, if in the format `{value, branches, platform}` + this.options = {}; + for (const [k, v] of Object.entries(this._options)) { + if (v?.value) { + if ( + (!v.platform || v.platform === platform) && + (!v.branches || v.branches.includes(branch)) + ) { + this.options[k] = v.value; + } + } else { + this.options[k] = v; + } + } + } + + get enabled() { + if (this._disabledGlobally || this._disabledForSession) { + return false; + } + + if (this._disabledPrefValue !== undefined) { + return !this._disabledPrefValue; + } + + return ( + !this._disabledByConfig && + !this._disabledByPlatform && + !this._disabledByReleaseBranch + ); + } + + get disabledReason() { + if (this._disabledGlobally) { + return "globalPref"; + } + + if (this._disabledForSession) { + return "session"; + } + + if (this._disabledPrefValue !== undefined) { + if (this._disabledPrefValue === true) { + return "pref"; + } + return false; + } + + if (this._disabledByConfig) { + return "config"; + } + + if (this._disabledByPlatform) { + return "platform"; + } + + if (this._disabledByReleaseBranch) { + return "releaseBranch"; + } + + return false; + } + + onAllShimsEnabled() { + const wasEnabled = this.enabled; + this._disabledGlobally = false; + if (!wasEnabled) { + this._onEnabledStateChanged(); + } + } + + onAllShimsDisabled() { + const wasEnabled = this.enabled; + this._disabledGlobally = true; + if (wasEnabled) { + this._onEnabledStateChanged(); + } + } + + enableForSession() { + const wasEnabled = this.enabled; + this._disabledForSession = false; + if (!wasEnabled) { + this._onEnabledStateChanged(); + } + } + + disableForSession() { + const wasEnabled = this.enabled; + this._disabledForSession = true; + if (wasEnabled) { + this._onEnabledStateChanged(); + } + } + + async _onEnabledStateChanged() { + this.manager?.onShimStateChanged(this.id); + if (!this.enabled) { + await this._unregisterContentScripts(); + return this._revokeRequestsInETP(); + } + await this._registerContentScripts(); + return this._allowRequestsInETP(); + } + + async _registerContentScripts() { + if ( + this.contentScripts.length && + !this._contentScriptRegistrations.length + ) { + const matches = []; + for (const options of this.contentScripts) { + matches.push(options.matches); + const reg = await browser.contentScripts.register(options); + this._contentScriptRegistrations.push(reg); + } + const urls = Array.from(new Set(matches.flat())); + debug("Enabling content scripts for these URLs:", urls); + } + } + + async _unregisterContentScripts() { + for (const registration of this._contentScriptRegistrations) { + registration.unregister(); + } + this._contentScriptRegistrations = []; + } + + async _allowRequestsInETP() { + const matches = this.matches.map(m => m.patterns).flat(); + if (matches.length) { + await browser.trackingProtection.shim(this.id, matches); + } + + if (this._hostOptIns.size) { + const optIns = this.getApplicableOptIns(); + if (optIns.length) { + await browser.trackingProtection.allow( + this.id, + this._optInPatterns, + Array.from(this._hostOptIns) + ); + } + } + } + + _revokeRequestsInETP() { + return browser.trackingProtection.revoke(this.id); + } + + setActiveOnTab(tabId, active = true) { + if (active) { + this._activeOnTabs.add(tabId); + } else { + this._activeOnTabs.delete(tabId); + this._showedOptInOnTabs.delete(tabId); + } + } + + isActiveOnTab(tabId) { + return this._activeOnTabs.has(tabId); + } + + meantForHost(host) { + const { hosts, notHosts } = this; + if (hosts || notHosts) { + if ( + (notHosts && notHosts.includes(host)) || + (hosts && !hosts.includes(host)) + ) { + return false; + } + } + return true; + } + + async unblocksURLOnOptIn(url) { + if (!this._optInPatterns) { + this._optInPatterns = await this.getApplicableOptIns(); + } + + if (!this._optInMatcher) { + this._optInMatcher = browser.matchPatterns.getMatcher( + Array.from(this._optInPatterns) + ); + } + + return this._optInMatcher.matches(url); + } + + isTriggeredByURLAndType(url, type) { + for (const entry of this.matches || []) { + if (!entry.types.includes(type)) { + continue; + } + if (!entry.matcher) { + entry.matcher = browser.matchPatterns.getMatcher( + Array.from(entry.patterns) + ); + } + if (entry.matcher.matches(url)) { + return entry; + } + } + + return undefined; + } + + async getApplicableOptIns() { + if (this._applicableOptIns) { + return this._applicableOptIns; + } + const optins = []; + for (const unblock of this.unblocksOnOptIn || []) { + if (typeof unblock === "string") { + optins.push(unblock); + continue; + } + const { branches, patterns, platforms } = unblock; + if (platforms?.length) { + const platform = await platformPromise; + if (platform !== "all" && !platforms.includes(platform)) { + continue; + } + } + if (branches?.length) { + const branch = await releaseBranchPromise; + if (!branches.includes(branch)) { + continue; + } + } + optins.push.apply(optins, patterns); + } + this._applicableOptIns = optins; + return optins; + } + + async onUserOptIn(host) { + const optins = await this.getApplicableOptIns(); + if (optins.length) { + this.userHasOptedIn = true; + this._hostOptIns.add(host); + await browser.trackingProtection.allow( + this.id, + optins, + Array.from(this._hostOptIns) + ); + } + } + + hasUserOptedInAlready(host) { + return this._hostOptIns.has(host); + } + + showOptInWarningOnce(tabId, origin) { + if (this._showedOptInOnTabs.has(tabId)) { + return Promise.resolve(); + } + this._showedOptInOnTabs.add(tabId); + + const { bug, name } = this; + const warning = `${name} is allowed on ${origin} for this browsing session due to user opt-in. See https://bugzilla.mozilla.org/show_bug.cgi?id=${bug} for details.`; + return browser.tabs + .executeScript(tabId, { + code: `console.warn(${JSON.stringify(warning)})`, + runAt: "document_start", + }) + .catch(() => {}); + } +} + +class Shims { + constructor(availableShims) { + if (!browser.trackingProtection) { + console.error("Required experimental add-on APIs for shims unavailable"); + return; + } + + this._registerShims(availableShims); + + onMessageFromTab(this._onMessageFromShim.bind(this)); + + this.ENABLED_PREF = "enable_shims"; + browser.aboutConfigPrefs.onPrefChange.addListener(() => { + this._checkEnabledPref(); + }, this.ENABLED_PREF); + this._haveCheckedEnabledPref = this._checkEnabledPref(); + } + + bindAboutCompatBroker(broker) { + this._aboutCompatBroker = broker; + } + + getShimInfoForAboutCompat(shim) { + const { bug, disabledReason, hiddenInAboutCompat, id, name } = shim; + const type = "smartblock"; + return { bug, disabledReason, hidden: hiddenInAboutCompat, id, name, type }; + } + + disableShimForSession(id) { + const shim = this.shims.get(id); + shim?.disableForSession(); + } + + enableShimForSession(id) { + const shim = this.shims.get(id); + shim?.enableForSession(); + } + + onShimStateChanged(id) { + if (!this._aboutCompatBroker) { + return; + } + + const shim = this.shims.get(id); + if (!shim) { + return; + } + + const shimsChanged = [this.getShimInfoForAboutCompat(shim)]; + this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({ shimsChanged }); + } + + getAvailableShims() { + const shims = Array.from(this.shims.values()).map( + this.getShimInfoForAboutCompat + ); + shims.sort((a, b) => a.name.localeCompare(b.name)); + return shims; + } + + _registerShims(shims) { + if (this.shims) { + throw new Error("_registerShims has already been called"); + } + + this.shims = new Map(); + for (const shimOpts of shims) { + const { id } = shimOpts; + if (!this.shims.has(id)) { + this.shims.set(shimOpts.id, new Shim(shimOpts, this)); + } + } + + // Register onBeforeRequest listener which handles storage access requests + // on matching redirects. + let redirectTargetUrls = Array.from(shims.values()) + .filter(shim => shim.requestStorageAccessForRedirect) + .flatMap(shim => shim.requestStorageAccessForRedirect) + .map(([, dstUrl]) => dstUrl); + + // Unique target urls. + redirectTargetUrls = Array.from(new Set(redirectTargetUrls)); + + if (redirectTargetUrls.length) { + debug("Registering redirect listener for requestStorageAccess helper", { + redirectTargetUrls, + }); + browser.webRequest.onBeforeRequest.addListener( + this._onRequestStorageAccessRedirect.bind(this), + { urls: redirectTargetUrls, types: ["main_frame"] }, + ["blocking"] + ); + } + + function addTypePatterns(type, patterns, set) { + if (!set.has(type)) { + set.set(type, { patterns: new Set() }); + } + const allSet = set.get(type).patterns; + for (const pattern of patterns) { + allSet.add(pattern); + } + } + + const allMatchTypePatterns = new Map(); + const allHeaderChangingMatchTypePatterns = new Map(); + const allLogos = []; + for (const shim of this.shims.values()) { + const { logos, matches } = shim; + allLogos.push(...logos); + for (const { patterns, target, types } of matches || []) { + for (const type of types) { + if (shim.isGoogleTrendsDFPIFix) { + addTypePatterns(type, patterns, allHeaderChangingMatchTypePatterns); + } + if (target || shim.file || shim.runFirst) { + addTypePatterns(type, patterns, allMatchTypePatterns); + } + } + } + } + + if (allLogos.length) { + const urls = Array.from(new Set(allLogos)).map(l => { + return `${LogosBaseURL}${l}`; + }); + debug("Allowing access to these logos:", urls); + const unmarkShimsActive = tabId => { + for (const shim of this.shims.values()) { + shim.setActiveOnTab(tabId, false); + } + }; + browser.tabs.onRemoved.addListener(unmarkShimsActive); + browser.tabs.onUpdated.addListener((tabId, changeInfo) => { + if (changeInfo.discarded || changeInfo.url) { + unmarkShimsActive(tabId); + } + }); + browser.webRequest.onBeforeRequest.addListener( + this._redirectLogos.bind(this), + { urls, types: ["image"] }, + ["blocking"] + ); + } + + if (allHeaderChangingMatchTypePatterns) { + for (const [ + type, + { patterns }, + ] of allHeaderChangingMatchTypePatterns.entries()) { + const urls = Array.from(patterns); + debug("Shimming these", type, "URLs:", urls); + browser.webRequest.onBeforeSendHeaders.addListener( + this._onBeforeSendHeaders.bind(this), + { urls, types: [type] }, + ["blocking", "requestHeaders"] + ); + browser.webRequest.onHeadersReceived.addListener( + this._onHeadersReceived.bind(this), + { urls, types: [type] }, + ["blocking", "responseHeaders"] + ); + } + } + + if (!allMatchTypePatterns.size) { + debug("Skipping shims; none enabled"); + return; + } + + for (const [type, { patterns }] of allMatchTypePatterns.entries()) { + const urls = Array.from(patterns); + debug("Shimming these", type, "URLs:", urls); + + browser.webRequest.onBeforeRequest.addListener( + this._ensureShimForRequestOnTab.bind(this), + { urls, types: [type] }, + ["blocking"] + ); + } + } + + async _checkEnabledPref() { + await browser.aboutConfigPrefs.getPref(this.ENABLED_PREF).then(value => { + if (value === undefined) { + browser.aboutConfigPrefs.setPref(this.ENABLED_PREF, true); + } else if (value === false) { + this.enabled = false; + } else { + this.enabled = true; + } + }); + } + + get enabled() { + return this._enabled; + } + + set enabled(enabled) { + if (enabled === this._enabled) { + return; + } + + this._enabled = enabled; + + for (const shim of this.shims.values()) { + if (enabled) { + shim.onAllShimsEnabled(); + } else { + shim.onAllShimsDisabled(); + } + } + } + + async _onRequestStorageAccessRedirect({ + originUrl: srcUrl, + url: dstUrl, + tabId, + }) { + debug("Detected redirect", { srcUrl, dstUrl, tabId }); + + // Check if a shim needs to request storage access for this redirect. This + // handler is called when the *source url* matches a shims redirect pattern, + // but we still need to check if the *destination url* matches. + const matchingShims = Array.from(this.shims.values()).filter(shim => { + const { enabled, requestStorageAccessForRedirect } = shim; + + if (!enabled || !requestStorageAccessForRedirect) { + return false; + } + + return requestStorageAccessForRedirect.some( + ([srcPattern, dstPattern]) => + browser.matchPatterns.getMatcher([srcPattern]).matches(srcUrl) && + browser.matchPatterns.getMatcher([dstPattern]).matches(dstUrl) + ); + }); + + // For each matching shim, find out if its enabled in regard to dFPI state. + const bugNumbers = new Set(); + let isDFPIActive = null; + await Promise.all( + matchingShims.map(async shim => { + if (shim.onlyIfDFPIActive) { + // Only get the dFPI state for the first shim which requires it. + if (isDFPIActive === null) { + const tabIsPB = (await browser.tabs.get(tabId)).incognito; + isDFPIActive = await browser.trackingProtection.isDFPIActive( + tabIsPB + ); + } + if (!isDFPIActive) { + return; + } + } + bugNumbers.add(shim.bug); + }) + ); + + // If there is no shim which needs storage access for this redirect src/dst + // pair, resume it. + if (!bugNumbers.size) { + return; + } + + // Inject the helper to call requestStorageAccessForOrigin on the document. + await browser.tabs.executeScript(tabId, { + file: "/lib/requestStorageAccess_helper.js", + runAt: "document_start", + }); + + const bugUrls = Array.from(bugNumbers) + .map(bugNo => `https://bugzilla.mozilla.org/show_bug.cgi?id=${bugNo}`) + .join(", "); + const warning = `Firefox calls the Storage Access API for ${dstUrl} on behalf of ${srcUrl}. See the following bugs for details: ${bugUrls}`; + + // Request storage access for the origin of the destination url of the + // redirect. + const { origin: requestStorageAccessOrigin } = new URL(dstUrl); + + // Wait for the requestStorageAccess request to finish before resuming the + // redirect. + const { success } = await browser.tabs.sendMessage(tabId, { + requestStorageAccessOrigin, + warning, + }); + debug("requestStorageAccess callback", { + success, + requestStorageAccessOrigin, + srcUrl, + dstUrl, + bugNumbers, + }); + } + + async _onMessageFromShim(payload, sender, sendResponse) { + const { tab, frameId } = sender; + const { id, url } = tab; + const { shimId, message } = payload; + + // Ignore unknown messages (for instance, from about:compat). + if (message !== "getOptions" && message !== "optIn") { + return undefined; + } + + if (sender.id !== browser.runtime.id || id === -1) { + throw new Error("not allowed"); + } + + // Important! It is entirely possible for sites to spoof + // these messages, due to shims allowing web pages to + // communicate with the extension. + + const shim = this.shims.get(shimId); + if (!shim?.needsShimHelpers?.includes(message)) { + throw new Error("not allowed"); + } + + if (message === "getOptions") { + return Object.assign( + { + platform: await platformPromise, + releaseBranch: await releaseBranchPromise, + }, + shim.options + ); + } else if (message === "optIn") { + try { + await shim.onUserOptIn(new URL(url).hostname); + const origin = new URL(tab.url).origin; + warn( + "** User opted in for", + shim.name, + "shim on", + origin, + "on tab", + id, + "frame", + frameId + ); + await shim.showOptInWarningOnce(id, origin); + } catch (err) { + console.error(err); + throw new Error("error"); + } + } + + return undefined; + } + + async _redirectLogos(details) { + await this._haveCheckedEnabledPref; + + if (!this.enabled) { + return { cancel: true }; + } + + const { tabId, url } = details; + const logo = new URL(url).pathname.slice(1); + + for (const shim of this.shims.values()) { + await shim.ready; + + if (!shim.enabled) { + continue; + } + + if (shim.onlyIfDFPIActive) { + const isPB = (await browser.tabs.get(details.tabId)).incognito; + if (!(await browser.trackingProtection.isDFPIActive(isPB))) { + continue; + } + } + + if (!shim.logos.includes(logo)) { + continue; + } + + if (shim.isActiveOnTab(tabId)) { + return { redirectUrl: browser.runtime.getURL(`shims/${logo}`) }; + } + } + + return { cancel: true }; + } + + async _onHeadersReceived(details) { + await this._haveCheckedEnabledPref; + + for (const shim of this.shims.values()) { + await shim.ready; + + if (!shim.enabled) { + continue; + } + + if (shim.onlyIfDFPIActive) { + const isPB = (await browser.tabs.get(details.tabId)).incognito; + if (!(await browser.trackingProtection.isDFPIActive(isPB))) { + continue; + } + } + + if (shim.isGoogleTrendsDFPIFix) { + if (shim.GoogleNidCookieToUse) { + continue; + } + + for (const header of details.responseHeaders) { + if (header.name == "set-cookie") { + shim.GoogleNidCookieToUse = header.value; + return { redirectUrl: details.url }; + } + } + } + } + + return undefined; + } + + async _onBeforeSendHeaders(details) { + await this._haveCheckedEnabledPref; + + const { frameId, requestHeaders, tabId } = details; + + if (!this.enabled) { + return { requestHeaders }; + } + + for (const shim of this.shims.values()) { + await shim.ready; + + if (!shim.enabled) { + continue; + } + + if (shim.isGoogleTrendsDFPIFix) { + const value = shim.GoogleNidCookieToUse; + + if (!value) { + continue; + } + + let found; + for (let header of requestHeaders) { + if (header.name.toLowerCase() === "cookie") { + header.value = value; + found = true; + } + } + if (!found) { + requestHeaders.push({ name: "Cookie", value }); + } + + browser.tabs + .get(tabId) + .then(({ url }) => { + debug( + `Google Trends dFPI fix used on tab ${tabId} frame ${frameId} (${url})` + ); + }) + .catch(() => {}); + + const warning = `Working around Google Trends tracking protection breakage. See https://bugzilla.mozilla.org/show_bug.cgi?id=${shim.bug} for details.`; + browser.tabs + .executeScript(tabId, { + code: `console.warn(${JSON.stringify(warning)})`, + runAt: "document_start", + }) + .catch(() => {}); + } + } + + return { requestHeaders }; + } + + async _ensureShimForRequestOnTab(details) { + await this._haveCheckedEnabledPref; + + if (!this.enabled) { + return undefined; + } + + // We only ever reach this point if a request is for a URL which ought to + // be shimmed. We never get here if a request is blocked, and we only + // unblock requests if at least one shim matches it. + + const { frameId, originUrl, requestId, tabId, type, url } = details; + + // Ignore requests unrelated to tabs + if (tabId < 0) { + return undefined; + } + + // We need to base our checks not on the frame's host, but the tab's. + const topHost = new URL((await browser.tabs.get(tabId)).url).hostname; + const unblocked = await browser.trackingProtection.wasRequestUnblocked( + requestId + ); + + let match; + let shimToApply; + for (const shim of this.shims.values()) { + await shim.ready; + + if (!shim.enabled || (!shim.redirectsRequests && !shim.runFirst)) { + continue; + } + + if (shim.onlyIfDFPIActive || shim.onlyIfPrivateBrowsing) { + const isPB = (await browser.tabs.get(details.tabId)).incognito; + if (!isPB && shim.onlyIfPrivateBrowsing) { + continue; + } + if ( + shim.onlyIfDFPIActive && + !(await browser.trackingProtection.isDFPIActive(isPB)) + ) { + continue; + } + } + + // Do not apply the shim if it is only meant to apply when strict mode ETP + // (content blocking) was going to block the request. + if (!unblocked && shim.onlyIfBlockedByETP) { + continue; + } + + if (!shim.meantForHost(topHost)) { + continue; + } + + // If this URL and content type isn't meant for this shim, don't apply it. + match = shim.isTriggeredByURLAndType(url, type); + if (match) { + if (!unblocked && match.onlyIfBlockedByETP) { + continue; + } + + // If the user has already opted in for this shim, all requests it covers + // should be allowed; no need for a shim anymore. + if (shim.hasUserOptedInAlready(topHost)) { + warn( + `Allowing tracking ${type} ${url} on tab ${tabId} frame ${frameId} due to opt-in` + ); + shim.showOptInWarningOnce(tabId, new URL(originUrl).origin); + return undefined; + } + shimToApply = shim; + break; + } + } + + let runFirst = false; + + if (shimToApply) { + // Note that sites may request the same shim twice, but because the requests + // may differ enough for some to fail (CSP/CORS/etc), we always let the request + // complete via local redirect. Shims should gracefully handle this as well. + + const { target } = match; + const { bug, file, id, name, needsShimHelpers } = shimToApply; + runFirst = shimToApply.runFirst; + + const redirect = target || file; + + warn( + `Shimming tracking ${type} ${url} on tab ${tabId} frame ${frameId} with ${ + redirect || runFirst + }` + ); + + const warning = `${name} is being shimmed by Firefox. See https://bugzilla.mozilla.org/show_bug.cgi?id=${bug} for details.`; + + let needConsoleMessage = true; + + if (runFirst) { + try { + await browser.tabs.executeScript(tabId, { + file: `/shims/${runFirst}`, + frameId, + runAt: "document_start", + }); + } catch (_) {} + } + + // For scripts, we also set up any needed shim helpers. + if (type === "script" && needsShimHelpers?.length) { + try { + await browser.tabs.executeScript(tabId, { + file: "/lib/shim_messaging_helper.js", + frameId, + runAt: "document_start", + }); + const origin = new URL(originUrl).origin; + await browser.tabs.sendMessage( + tabId, + { origin, shimId: id, needsShimHelpers, warning }, + { frameId } + ); + needConsoleMessage = false; + shimToApply.setActiveOnTab(tabId); + } catch (_) {} + } + + if (needConsoleMessage) { + try { + await browser.tabs.executeScript(tabId, { + code: `console.warn(${JSON.stringify(warning)})`, + runAt: "document_start", + }); + } catch (_) {} + } + + if (!redirect.indexOf("http://") || !redirect.indexOf("https://")) { + return { redirectUrl: redirect }; + } + + // If any shims matched the request to replace it, then redirect to the local + // file bundled with SmartBlock, so the request never hits the network. + return { redirectUrl: browser.runtime.getURL(`shims/${redirect}`) }; + } + + // Sanity check: if no shims end up handling this request, + // yet it was meant to be blocked by ETP, then block it now. + if (unblocked) { + error(`unexpected: ${url} not shimmed on tab ${tabId} frame ${frameId}`); + return { cancel: true }; + } + + if (!runFirst) { + debug(`ignoring ${url} on tab ${tabId} frame ${frameId}`); + } + return undefined; + } +} + +module.exports = Shims; |