diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index ab6b06411731..db0f0a34329a 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -3789,6 +3789,12 @@ "outdatedBrowserNotification": { "message": "Your browser is out of date. If you don't update your browser, you won't be able to get security patches and new features from MetaMask." }, + "overrideContentSecurityPolicyHeader": { + "message": "Override Content-Security-Policy header" + }, + "overrideContentSecurityPolicyHeaderDescription": { + "message": "This option is a workaround for a known issue in Firefox, where a dapp's Content-Security-Policy header may prevent the extension from loading properly. Disabling this option is not recommended unless required for specific web page compatibility." + }, "padlock": { "message": "Padlock" }, diff --git a/app/scripts/background.js b/app/scripts/background.js index bacb6adddf9f..90a52b6c0d19 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -56,6 +56,8 @@ import { // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { getCurrentChainId } from '../../ui/selectors'; +import { addNonceToCsp } from '../../shared/modules/add-nonce-to-csp'; +import { checkURLForProviderInjection } from '../../shared/modules/provider-injection'; import migrations from './migrations'; import Migrator from './lib/migrator'; import ExtensionPlatform from './platforms/extension'; @@ -333,6 +335,40 @@ function maybeDetectPhishing(theController) { ); } +/** + * Overrides the Content-Security-Policy (CSP) header by adding a nonce to the `script-src` directive. + * This is a workaround for [Bug #1446231](https://bugzilla.mozilla.org/show_bug.cgi?id=1446231), + * which involves overriding the page CSP for inline script nodes injected by extension content scripts. + */ +function overrideContentSecurityPolicyHeader() { + // The extension url is unique per install on Firefox, so we can safely add it as a nonce to the CSP header + const nonce = btoa(browser.runtime.getURL('/')); + browser.webRequest.onHeadersReceived.addListener( + ({ responseHeaders, url }) => { + // Check whether inpage.js is going to be injected into the page or not. + // There is no reason to modify the headers if we are not injecting inpage.js. + const isInjected = checkURLForProviderInjection(new URL(url)); + + // Check if the user has enabled the overrideContentSecurityPolicyHeader preference + const isEnabled = + controller.preferencesController.state + .overrideContentSecurityPolicyHeader; + + if (isInjected && isEnabled) { + for (const header of responseHeaders) { + if (header.name.toLowerCase() === 'content-security-policy') { + header.value = addNonceToCsp(header.value, nonce); + } + } + } + + return { responseHeaders }; + }, + { types: ['main_frame', 'sub_frame'], urls: ['http://*/*', 'https://*/*'] }, + ['blocking', 'responseHeaders'], + ); +} + // These are set after initialization let connectRemote; let connectExternalExtension; @@ -479,6 +515,11 @@ async function initialize() { if (!isManifestV3) { await loadPhishingWarningPage(); + // Workaround for Bug #1446231 to override page CSP for inline script nodes injected by extension content scripts + // https://bugzilla.mozilla.org/show_bug.cgi?id=1446231 + if (getPlatform() === PLATFORM_FIREFOX) { + overrideContentSecurityPolicyHeader(); + } } await sendReadyMessageToTabs(); log.info('MetaMask initialization complete.'); diff --git a/app/scripts/constants/sentry-state.ts b/app/scripts/constants/sentry-state.ts index 3125016ea0b5..655851590441 100644 --- a/app/scripts/constants/sentry-state.ts +++ b/app/scripts/constants/sentry-state.ts @@ -223,6 +223,7 @@ export const SENTRY_BACKGROUND_STATE = { advancedGasFee: true, currentLocale: true, dismissSeedBackUpReminder: true, + overrideContentSecurityPolicyHeader: true, featureFlags: true, forgottenPassword: true, identities: false, diff --git a/app/scripts/controllers/preferences-controller.test.ts b/app/scripts/controllers/preferences-controller.test.ts index 25010cdd3a0f..39a2d49648b2 100644 --- a/app/scripts/controllers/preferences-controller.test.ts +++ b/app/scripts/controllers/preferences-controller.test.ts @@ -837,6 +837,23 @@ describe('preferences controller', () => { }); }); + describe('overrideContentSecurityPolicyHeader', () => { + it('defaults overrideContentSecurityPolicyHeader to true', () => { + const { controller } = setupController({}); + expect( + controller.state.overrideContentSecurityPolicyHeader, + ).toStrictEqual(true); + }); + + it('set overrideContentSecurityPolicyHeader to false', () => { + const { controller } = setupController({}); + controller.setOverrideContentSecurityPolicyHeader(false); + expect( + controller.state.overrideContentSecurityPolicyHeader, + ).toStrictEqual(false); + }); + }); + describe('snapsAddSnapAccountModalDismissed', () => { it('defaults snapsAddSnapAccountModalDismissed to false', () => { const { controller } = setupController({}); diff --git a/app/scripts/controllers/preferences-controller.ts b/app/scripts/controllers/preferences-controller.ts index bc7d03155f75..b937d02275d0 100644 --- a/app/scripts/controllers/preferences-controller.ts +++ b/app/scripts/controllers/preferences-controller.ts @@ -133,6 +133,7 @@ export type PreferencesControllerState = Omit< useNonceField: boolean; usePhishDetect: boolean; dismissSeedBackUpReminder: boolean; + overrideContentSecurityPolicyHeader: boolean; useMultiAccountBalanceChecker: boolean; useSafeChainsListValidation: boolean; use4ByteResolution: boolean; @@ -173,6 +174,7 @@ export const getDefaultPreferencesControllerState = useNonceField: false, usePhishDetect: true, dismissSeedBackUpReminder: false, + overrideContentSecurityPolicyHeader: true, useMultiAccountBalanceChecker: true, useSafeChainsListValidation: true, // set to true means the dynamic list from the API is being used @@ -304,6 +306,10 @@ const controllerMetadata = { persist: true, anonymous: true, }, + overrideContentSecurityPolicyHeader: { + persist: true, + anonymous: true, + }, useMultiAccountBalanceChecker: { persist: true, anonymous: true, @@ -1005,6 +1011,20 @@ export class PreferencesController extends BaseController< }); } + /** + * A setter for the user preference to override the Content-Security-Policy header + * + * @param overrideContentSecurityPolicyHeader - User preference for overriding the Content-Security-Policy header. + */ + setOverrideContentSecurityPolicyHeader( + overrideContentSecurityPolicyHeader: boolean, + ): void { + this.update((state) => { + state.overrideContentSecurityPolicyHeader = + overrideContentSecurityPolicyHeader; + }); + } + /** * A setter for the incomingTransactions in preference to be updated * diff --git a/app/scripts/lib/backup.test.js b/app/scripts/lib/backup.test.js index b3a7f176c2e6..826aa04018d9 100644 --- a/app/scripts/lib/backup.test.js +++ b/app/scripts/lib/backup.test.js @@ -150,6 +150,7 @@ const jsonData = JSON.stringify({ useNonceField: false, usePhishDetect: true, dismissSeedBackUpReminder: false, + overrideContentSecurityPolicyHeader: true, useTokenDetection: false, useCollectibleDetection: false, openSeaEnabled: false, diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index e7e115e6ed7c..f87792c2a276 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -3484,6 +3484,10 @@ export default class MetamaskController extends EventEmitter { preferencesController.setDismissSeedBackUpReminder.bind( preferencesController, ), + setOverrideContentSecurityPolicyHeader: + preferencesController.setOverrideContentSecurityPolicyHeader.bind( + preferencesController, + ), setAdvancedGasFee: preferencesController.setAdvancedGasFee.bind( preferencesController, ), diff --git a/development/build/utils.js b/development/build/utils.js index 525815d2520a..626aacd588c7 100644 --- a/development/build/utils.js +++ b/development/build/utils.js @@ -293,7 +293,7 @@ function getBuildName({ function makeSelfInjecting(filePath) { const fileContents = readFileSync(filePath, 'utf8'); const textContent = JSON.stringify(fileContents); - const js = `{let d=document,s=d.createElement('script');s.textContent=${textContent};d.documentElement.appendChild(s).remove();}`; + const js = `{let d=document,s=d.createElement('script');s.textContent=${textContent};s.nonce=btoa((globalThis.browser||chrome).runtime.getURL('/'));d.documentElement.appendChild(s).remove();}`; writeFileSync(filePath, js, 'utf8'); } diff --git a/development/create-static-server.js b/development/create-static-server.js index a8d5e28b0088..8e55fa54ca13 100755 --- a/development/create-static-server.js +++ b/development/create-static-server.js @@ -4,10 +4,17 @@ const path = require('path'); const serveHandler = require('serve-handler'); -const createStaticServer = (rootDirectory) => { +/** + * Creates an HTTP server that serves static files from a directory using serve-handler. + * If a request URL starts with `/node_modules/`, it rewrites the URL and serves files from the `node_modules` directory. + * + * @param { NonNullable[2]> } options - Configuration options for serve-handler. Documentation can be found here: https://github.com/vercel/serve-handler + * @returns {http.Server} An instance of an HTTP server configured with the specified options. + */ +const createStaticServer = (options) => { return http.createServer((request, response) => { if (request.url.startsWith('/node_modules/')) { - request.url = request.url.substr(14); + request.url = request.url.slice(14); return serveHandler(request, response, { directoryListing: false, public: path.resolve('./node_modules'), @@ -15,7 +22,7 @@ const createStaticServer = (rootDirectory) => { } return serveHandler(request, response, { directoryListing: false, - public: rootDirectory, + ...options, }); }); }; diff --git a/development/static-server.js b/development/static-server.js index bb15133d6fdd..ec3a51a512f0 100755 --- a/development/static-server.js +++ b/development/static-server.js @@ -31,7 +31,7 @@ const onRequest = (request, response) => { }; const startServer = ({ port, rootDirectory }) => { - const server = createStaticServer(rootDirectory); + const server = createStaticServer({ public: rootDirectory }); server.on('request', onRequest); diff --git a/development/webpack/test/plugins.SelfInjectPlugin.test.ts b/development/webpack/test/plugins.SelfInjectPlugin.test.ts index 3a3ef729eacf..b6390654a4bb 100644 --- a/development/webpack/test/plugins.SelfInjectPlugin.test.ts +++ b/development/webpack/test/plugins.SelfInjectPlugin.test.ts @@ -55,7 +55,7 @@ describe('SelfInjectPlugin', () => { // reference the `sourceMappingURL` assert.strictEqual( newSource, - `{let d=document,s=d.createElement('script');s.textContent="${source}\\n//# sourceMappingURL=${filename}.map"+\`\\n//# sourceURL=\${(globalThis.browser||chrome).runtime.getURL("${filename}")};\`;d.documentElement.appendChild(s).remove()}`, + `{let d=document,s=d.createElement('script');s.textContent="${source}\\n//# sourceMappingURL=${filename}.map"+\`\\n//# sourceURL=\${(globalThis.browser||chrome).runtime.getURL("${filename}")};\`;s.nonce=btoa((globalThis.browser||chrome).runtime.getURL("/"));d.documentElement.appendChild(s).remove()}`, ); } else { // the new source should NOT reference the new sourcemap, since it's @@ -66,7 +66,7 @@ describe('SelfInjectPlugin', () => { // console. assert.strictEqual( newSource, - `{let d=document,s=d.createElement('script');s.textContent="console.log(3);"+\`\\n//# sourceURL=\${(globalThis.browser||chrome).runtime.getURL("${filename}")};\`;d.documentElement.appendChild(s).remove()}`, + `{let d=document,s=d.createElement('script');s.textContent="console.log(3);"+\`\\n//# sourceURL=\${(globalThis.browser||chrome).runtime.getURL("${filename}")};\`;s.nonce=btoa((globalThis.browser||chrome).runtime.getURL("/"));d.documentElement.appendChild(s).remove()}`, ); } diff --git a/development/webpack/utils/plugins/SelfInjectPlugin/index.ts b/development/webpack/utils/plugins/SelfInjectPlugin/index.ts index b80f6102ab75..18d3624310ae 100644 --- a/development/webpack/utils/plugins/SelfInjectPlugin/index.ts +++ b/development/webpack/utils/plugins/SelfInjectPlugin/index.ts @@ -6,6 +6,19 @@ import type { SelfInjectPluginOptions, Source, Compiler } from './types'; export { type SelfInjectPluginOptions } from './types'; +/** + * Generates a runtime URL expression for a given path. + * + * This function constructs a URL string using the `runtime.getURL` method + * from either the `globalThis.browser` or `chrome` object, depending on + * which one is available in the global scope. + * + * @param path - The path of the runtime URL. + * @returns The constructed runtime URL string. + */ +const getRuntimeURLExpression = (path: string) => + `(globalThis.browser||chrome).runtime.getURL(${JSON.stringify(path)})`; + /** * Default options for the SelfInjectPlugin. */ @@ -13,8 +26,11 @@ const defaultOptions = { // The default `sourceUrlExpression` is configured for browser extensions. // It generates the absolute url of the given file as an extension url. // e.g., `chrome-extension:///scripts/inpage.js` - sourceUrlExpression: (filename: string) => - `(globalThis.browser||chrome).runtime.getURL(${JSON.stringify(filename)})`, + sourceUrlExpression: getRuntimeURLExpression, + // The default `nonceExpression` is configured for browser extensions. + // It generates the absolute url of a path as an extension url in base64. + // e.g., `Y2hyb21lLWV4dGVuc2lvbjovLzxleHRlbnNpb24taWQ+Lw==` + nonceExpression: (path: string) => `btoa(${getRuntimeURLExpression(path)})`, } satisfies SelfInjectPluginOptions; /** @@ -142,6 +158,7 @@ export class SelfInjectPlugin { `\`\\n//# sourceURL=\${${this.options.sourceUrlExpression(file)}};\``, ); newSource.add(`;`); + newSource.add(`s.nonce=${this.options.nonceExpression('/')};`); // add and immediately remove the script to avoid modifying the DOM. newSource.add(`d.documentElement.appendChild(s).remove()`); newSource.add(`}`); diff --git a/development/webpack/utils/plugins/SelfInjectPlugin/types.ts b/development/webpack/utils/plugins/SelfInjectPlugin/types.ts index 2240227bd76a..e70467cd270b 100644 --- a/development/webpack/utils/plugins/SelfInjectPlugin/types.ts +++ b/development/webpack/utils/plugins/SelfInjectPlugin/types.ts @@ -23,7 +23,7 @@ export type SelfInjectPluginOptions = { * will be injected into matched file to provide a sourceURL for the self * injected script. * - * Defaults to `(filename: string) => (globalThis.browser||globalThis.chrome).runtime.getURL("${filename}")` + * Defaults to `(filename: string) => (globalThis.browser||chrome).runtime.getURL("${filename}")` * * @example Custom * ```js @@ -39,11 +39,22 @@ export type SelfInjectPluginOptions = { * * ```js * { - * sourceUrlExpression: (filename) => `(globalThis.browser||globalThis.chrome).runtime.getURL("${filename}")` + * sourceUrlExpression: (filename) => `(globalThis.browser||chrome).runtime.getURL("${filename}")` * } * ``` * @param filename - the chunk's relative filename as it will exist in the output directory * @returns */ sourceUrlExpression?: (filename: string) => string; + /** + * A function that returns a JavaScript expression escaped as a string which + * will be injected into matched file to set a nonce for the self + * injected script. + * + * Defaults to `(path: string) => btoa((globalThis.browser||chrome).runtime.getURL("${path}"))` + * + * @param path - the path to be encoded as a nonce + * @returns + */ + nonceExpression?: (path: string) => string; }; diff --git a/package.json b/package.json index 14b38d504c87..dd77241a00a7 100644 --- a/package.json +++ b/package.json @@ -527,6 +527,7 @@ "@types/redux-mock-store": "1.0.6", "@types/remote-redux-devtools": "^0.5.5", "@types/selenium-webdriver": "^4.1.19", + "@types/serve-handler": "^6.1.4", "@types/sinon": "^10.0.13", "@types/sprintf-js": "^1", "@types/w3c-web-hid": "^1.0.3", diff --git a/shared/modules/add-nonce-to-csp.test.ts b/shared/modules/add-nonce-to-csp.test.ts new file mode 100644 index 000000000000..dc80bfd9a92a --- /dev/null +++ b/shared/modules/add-nonce-to-csp.test.ts @@ -0,0 +1,98 @@ +import { addNonceToCsp } from './add-nonce-to-csp'; + +describe('addNonceToCsp', () => { + it('empty string', () => { + const input = ''; + const expected = ''; + const output = addNonceToCsp(input, 'test'); + expect(output).toBe(expected); + }); + + it('one empty directive', () => { + const input = 'script-src'; + const expected = `script-src 'nonce-test'`; + const output = addNonceToCsp(input, 'test'); + expect(output).toBe(expected); + }); + + it('one directive, one value', () => { + const input = 'script-src default.example'; + const expected = `script-src default.example 'nonce-test'`; + const output = addNonceToCsp(input, 'test'); + expect(output).toBe(expected); + }); + + it('one directive, two values', () => { + const input = "script-src 'self' default.example"; + const expected = `script-src 'self' default.example 'nonce-test'`; + const output = addNonceToCsp(input, 'test'); + expect(output).toBe(expected); + }); + + it('multiple directives', () => { + const input = + "default-src 'self'; script-src 'unsafe-eval' scripts.example; object-src; style-src styles.example"; + const expected = `default-src 'self'; script-src 'unsafe-eval' scripts.example 'nonce-test'; object-src; style-src styles.example`; + const output = addNonceToCsp(input, 'test'); + expect(output).toBe(expected); + }); + + it('no applicable directive', () => { + const input = 'img-src https://example.com'; + const expected = `img-src https://example.com`; + const output = addNonceToCsp(input, 'test'); + expect(output).toBe(expected); + }); + + it('non-ASCII directives', () => { + const input = 'script-src default.example;\u0080;style-src style.example'; + const expected = `script-src default.example 'nonce-test';\u0080;style-src style.example`; + const output = addNonceToCsp(input, 'test'); + expect(output).toBe(expected); + }); + + it('uppercase directive names', () => { + const input = 'SCRIPT-SRC DEFAULT.EXAMPLE'; + const expected = `SCRIPT-SRC DEFAULT.EXAMPLE 'nonce-test'`; + const output = addNonceToCsp(input, 'test'); + expect(output).toBe(expected); + }); + + it('duplicate directive names', () => { + const input = + 'default-src default.example; script-src script.example; script-src script.example'; + const expected = `default-src default.example; script-src script.example 'nonce-test'; script-src script.example`; + const output = addNonceToCsp(input, 'test'); + expect(output).toBe(expected); + }); + + it('nonce value contains script-src', () => { + const input = + "default-src 'self' 'nonce-script-src'; script-src 'self' https://example.com"; + const expected = `default-src 'self' 'nonce-script-src'; script-src 'self' https://example.com 'nonce-test'`; + const output = addNonceToCsp(input, 'test'); + expect(output).toBe(expected); + }); + + it('url value contains script-src', () => { + const input = + "default-src 'self' https://script-src.com; script-src 'self' https://example.com"; + const expected = `default-src 'self' https://script-src.com; script-src 'self' https://example.com 'nonce-test'`; + const output = addNonceToCsp(input, 'test'); + expect(output).toBe(expected); + }); + + it('fallback to default-src', () => { + const input = `default-src 'none'`; + const expected = `default-src 'none' 'nonce-test'`; + const output = addNonceToCsp(input, 'test'); + expect(output).toBe(expected); + }); + + it('keep ascii whitespace characters', () => { + const input = ' script-src default.example '; + const expected = ` script-src default.example 'nonce-test'`; + const output = addNonceToCsp(input, 'test'); + expect(output).toBe(expected); + }); +}); diff --git a/shared/modules/add-nonce-to-csp.ts b/shared/modules/add-nonce-to-csp.ts new file mode 100644 index 000000000000..a8b7fe333089 --- /dev/null +++ b/shared/modules/add-nonce-to-csp.ts @@ -0,0 +1,38 @@ +// ASCII whitespace is U+0009 TAB, U+000A LF, U+000C FF, U+000D CR, or U+0020 SPACE. +// See . +const ASCII_WHITESPACE_CHARS = ['\t', '\n', '\f', '\r', ' '].join(''); + +const matchDirective = (directive: string) => + /* eslint-disable require-unicode-regexp */ + new RegExp( + `^([${ASCII_WHITESPACE_CHARS}]*${directive}[${ASCII_WHITESPACE_CHARS}]*)`, // Match the directive and surrounding ASCII whitespace + 'is', // Case-insensitive, including newlines + ); +const matchScript = matchDirective('script-src'); +const matchDefault = matchDirective('default-src'); + +/** + * Adds a nonce to a Content Security Policy (CSP) string. + * + * @param text - The Content Security Policy (CSP) string to add the nonce to. + * @param nonce - The nonce to add to the Content Security Policy (CSP) string. + * @returns The updated Content Security Policy (CSP) string. + */ +export const addNonceToCsp = (text: string, nonce: string) => { + const formattedNonce = ` 'nonce-${nonce}'`; + const directives = text.split(';'); + const scriptIndex = directives.findIndex((directive) => + matchScript.test(directive), + ); + if (scriptIndex >= 0) { + directives[scriptIndex] += formattedNonce; + } else { + const defaultIndex = directives.findIndex((directive) => + matchDefault.test(directive), + ); + if (defaultIndex >= 0) { + directives[defaultIndex] += formattedNonce; + } + } + return directives.join(';'); +}; diff --git a/shared/modules/provider-injection.js b/shared/modules/provider-injection.js index 25a316e93440..b96df88c29e2 100644 --- a/shared/modules/provider-injection.js +++ b/shared/modules/provider-injection.js @@ -5,40 +5,36 @@ */ export default function shouldInjectProvider() { return ( - doctypeCheck() && - suffixCheck() && - documentElementCheck() && - !blockedDomainCheck() + checkURLForProviderInjection(new URL(window.location)) && + checkDocumentForProviderInjection() ); } /** - * Checks the doctype of the current document if it exists + * Checks if a given URL is eligible for provider injection. * - * @returns {boolean} {@code true} if the doctype is html or if none exists + * This function determines if a URL passes the suffix check and is not part of the blocked domains. + * + * @param {URL} url - The URL to be checked for injection. + * @returns {boolean} Returns `true` if the URL passes the suffix check and is not blocked, otherwise `false`. */ -function doctypeCheck() { - const { doctype } = window.document; - if (doctype) { - return doctype.name === 'html'; - } - return true; +export function checkURLForProviderInjection(url) { + return suffixCheck(url) && !blockedDomainCheck(url); } /** - * Returns whether or not the extension (suffix) of the current document is prohibited + * Returns whether or not the extension (suffix) of the given URL's pathname is prohibited * - * This checks {@code window.location.pathname} against a set of file extensions - * that we should not inject the provider into. This check is indifferent of - * query parameters in the location. + * This checks the provided URL's pathname against a set of file extensions + * that we should not inject the provider into. * - * @returns {boolean} whether or not the extension of the current document is prohibited + * @param {URL} url - The URL to check + * @returns {boolean} whether or not the extension of the given URL's pathname is prohibited */ -function suffixCheck() { +function suffixCheck({ pathname }) { const prohibitedTypes = [/\.xml$/u, /\.pdf$/u]; - const currentUrl = window.location.pathname; for (let i = 0; i < prohibitedTypes.length; i++) { - if (prohibitedTypes[i].test(currentUrl)) { + if (prohibitedTypes[i].test(pathname)) { return false; } } @@ -46,24 +42,12 @@ function suffixCheck() { } /** - * Checks the documentElement of the current document + * Checks if the given domain is blocked * - * @returns {boolean} {@code true} if the documentElement is an html node or if none exists + * @param {URL} url - The URL to check + * @returns {boolean} {@code true} if the given domain is blocked */ -function documentElementCheck() { - const documentElement = document.documentElement.nodeName; - if (documentElement) { - return documentElement.toLowerCase() === 'html'; - } - return true; -} - -/** - * Checks if the current domain is blocked - * - * @returns {boolean} {@code true} if the current domain is blocked - */ -function blockedDomainCheck() { +function blockedDomainCheck(url) { // If making any changes, please also update the same list found in the MetaMask-Mobile & SDK repositories const blockedDomains = [ 'execution.consensys.io', @@ -85,8 +69,7 @@ function blockedDomainCheck() { 'cdn.shopify.com/s/javascripts/tricorder/xtld-read-only-frame.html', ]; - const { hostname: currentHostname, pathname: currentPathname } = - window.location; + const { hostname: currentHostname, pathname: currentPathname } = url; const trimTrailingSlash = (str) => str.endsWith('/') ? str.slice(0, -1) : str; @@ -104,3 +87,38 @@ function blockedDomainCheck() { ) ); } + +/** + * Checks if the document is suitable for provider injection by verifying the doctype and document element. + * + * @returns {boolean} `true` if the document passes both the doctype and document element checks, otherwise `false`. + */ +export function checkDocumentForProviderInjection() { + return doctypeCheck() && documentElementCheck(); +} + +/** + * Checks the doctype of the current document if it exists + * + * @returns {boolean} {@code true} if the doctype is html or if none exists + */ +function doctypeCheck() { + const { doctype } = window.document; + if (doctype) { + return doctype.name === 'html'; + } + return true; +} + +/** + * Checks the documentElement of the current document + * + * @returns {boolean} {@code true} if the documentElement is an html node or if none exists + */ +function documentElementCheck() { + const documentElement = document.documentElement.nodeName; + if (documentElement) { + return documentElement.toLowerCase() === 'html'; + } + return true; +} diff --git a/shared/modules/provider-injection.test.ts b/shared/modules/provider-injection.test.ts index 1742e31b4db6..c7da24b46642 100644 --- a/shared/modules/provider-injection.test.ts +++ b/shared/modules/provider-injection.test.ts @@ -8,11 +8,7 @@ describe('shouldInjectProvider', () => { const urlObj = new URL(urlString); mockedWindow.mockImplementation(() => ({ - location: { - hostname: urlObj.hostname, - origin: urlObj.origin, - pathname: urlObj.pathname, - }, + location: urlObj, document: { doctype: { name: 'html', diff --git a/test/e2e/default-fixture.js b/test/e2e/default-fixture.js index 2d3d2999ed43..fd2d5be42891 100644 --- a/test/e2e/default-fixture.js +++ b/test/e2e/default-fixture.js @@ -193,6 +193,7 @@ function defaultFixture(inputChainId = CHAIN_IDS.LOCALHOST) { currentLocale: 'en', useExternalServices: true, dismissSeedBackUpReminder: true, + overrideContentSecurityPolicyHeader: true, featureFlags: {}, forgottenPassword: false, identities: { diff --git a/test/e2e/fixture-builder.js b/test/e2e/fixture-builder.js index 334e2f74ceca..bea9e9bad77f 100644 --- a/test/e2e/fixture-builder.js +++ b/test/e2e/fixture-builder.js @@ -63,6 +63,7 @@ function onboardingFixture() { advancedGasFee: {}, currentLocale: 'en', dismissSeedBackUpReminder: false, + overrideContentSecurityPolicyHeader: true, featureFlags: {}, forgottenPassword: false, identities: {}, diff --git a/test/e2e/helpers.js b/test/e2e/helpers.js index 1cb9e47f1f80..c3705c1ebf6c 100644 --- a/test/e2e/helpers.js +++ b/test/e2e/helpers.js @@ -65,6 +65,7 @@ async function withFixtures(options, testSuite) { smartContract, driverOptions, dappOptions, + staticServerOptions, title, ignoredConsoleErrors = [], dappPath = undefined, @@ -159,7 +160,9 @@ async function withFixtures(options, testSuite) { 'dist', ); } - dappServer.push(createStaticServer(dappDirectory)); + dappServer.push( + createStaticServer({ public: dappDirectory, ...staticServerOptions }), + ); dappServer[i].listen(`${dappBasePort + i}`); await new Promise((resolve, reject) => { dappServer[i].on('listening', resolve); diff --git a/test/e2e/phishing-warning-page-server.js b/test/e2e/phishing-warning-page-server.js index 2f6099e1a3d0..a66c9211ed83 100644 --- a/test/e2e/phishing-warning-page-server.js +++ b/test/e2e/phishing-warning-page-server.js @@ -13,7 +13,7 @@ const phishingWarningDirectory = path.resolve( class PhishingWarningPageServer { constructor() { - this._server = createStaticServer(phishingWarningDirectory); + this._server = createStaticServer({ public: phishingWarningDirectory }); } async start({ port = 9999 } = {}) { diff --git a/test/e2e/tests/content-security-policy/content-security-policy-mock-page/index.html b/test/e2e/tests/content-security-policy/content-security-policy-mock-page/index.html new file mode 100644 index 000000000000..dae42d094890 --- /dev/null +++ b/test/e2e/tests/content-security-policy/content-security-policy-mock-page/index.html @@ -0,0 +1,9 @@ + + + + Mock CSP header testing + + +
Mock Page for Content-Security-Policy header testing
+ + diff --git a/test/e2e/tests/content-security-policy/content-security-policy.spec.ts b/test/e2e/tests/content-security-policy/content-security-policy.spec.ts new file mode 100644 index 000000000000..996c64343c5b --- /dev/null +++ b/test/e2e/tests/content-security-policy/content-security-policy.spec.ts @@ -0,0 +1,46 @@ +import { strict as assert } from 'assert'; +import { Suite } from 'mocha'; +import { + defaultGanacheOptions, + openDapp, + unlockWallet, + withFixtures, +} from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; + +describe('Content-Security-Policy', function (this: Suite) { + it('opening a restricted website should still load the extension', async function () { + await withFixtures( + { + dapp: true, + dappPaths: [ + './tests/content-security-policy/content-security-policy-mock-page', + ], + staticServerOptions: { + headers: [ + { + source: 'index.html', + headers: [ + { + key: 'Content-Security-Policy', + value: `default-src 'none'`, + }, + ], + }, + ], + }, + fixtures: new FixtureBuilder().build(), + ganacheOptions: defaultGanacheOptions, + title: this.test?.fullTitle(), + }, + async ({ driver }) => { + await unlockWallet(driver); + await openDapp(driver); + const isExtensionLoaded: boolean = await driver.executeScript( + 'return typeof window.ethereum !== "undefined"', + ); + assert.equal(isExtensionLoaded, true); + }, + ); + }); +}); diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json index 1a871780591f..cebacab2b1d9 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json @@ -198,6 +198,7 @@ "useNonceField": false, "usePhishDetect": true, "dismissSeedBackUpReminder": true, + "overrideContentSecurityPolicyHeader": true, "useMultiAccountBalanceChecker": true, "useSafeChainsListValidation": "boolean", "useTokenDetection": true, diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json index e8f8f81a6293..97399d34c508 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json @@ -115,6 +115,7 @@ "useNonceField": false, "usePhishDetect": true, "dismissSeedBackUpReminder": true, + "overrideContentSecurityPolicyHeader": true, "useMultiAccountBalanceChecker": true, "useSafeChainsListValidation": true, "useTokenDetection": true, diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json index 1a51023a2ca1..7622ad15937c 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json @@ -115,6 +115,7 @@ "currentLocale": "en", "useExternalServices": "boolean", "dismissSeedBackUpReminder": true, + "overrideContentSecurityPolicyHeader": true, "featureFlags": {}, "forgottenPassword": false, "identities": "object", diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json index bf7f87a16134..b3fa8d117beb 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json @@ -115,6 +115,7 @@ "currentLocale": "en", "useExternalServices": "boolean", "dismissSeedBackUpReminder": true, + "overrideContentSecurityPolicyHeader": true, "featureFlags": {}, "forgottenPassword": false, "identities": "object", diff --git a/test/e2e/tests/synchronous-injection/synchronous-injection.spec.js b/test/e2e/tests/synchronous-injection/synchronous-injection.spec.js index 917e289f320f..8e4254fb8b34 100644 --- a/test/e2e/tests/synchronous-injection/synchronous-injection.spec.js +++ b/test/e2e/tests/synchronous-injection/synchronous-injection.spec.js @@ -7,7 +7,7 @@ const dappPort = 8080; describe('The provider', function () { it('can be injected synchronously and successfully used by a dapp', async function () { - const dappServer = createStaticServer(__dirname); + const dappServer = createStaticServer({ public: __dirname }); dappServer.listen(dappPort); await new Promise((resolve, reject) => { dappServer.on('listening', resolve); diff --git a/ui/helpers/constants/settings.js b/ui/helpers/constants/settings.js index c22b0cbcf183..232bcdae5aff 100644 --- a/ui/helpers/constants/settings.js +++ b/ui/helpers/constants/settings.js @@ -1,4 +1,8 @@ /* eslint-disable @metamask/design-tokens/color-no-hex*/ +// TODO: Remove restricted import +// eslint-disable-next-line import/no-restricted-paths +import { getPlatform } from '../../../app/scripts/lib/util'; +import { PLATFORM_FIREFOX } from '../../../shared/constants/app'; import { IconName } from '../../components/component-library'; import { ADVANCED_ROUTE, @@ -19,6 +23,7 @@ import { * # @param {string} route tab route with appended arbitrary, unique anchor tag / hash route * # @param {string} iconName * # @param {string} featureFlag ENV variable name. If the ENV value exists, the route will be searchable; else, route will not be searchable. + * # @param {boolean} hidden If true, the route will not be searchable. */ /** @type {SettingRouteConfig[]} */ @@ -154,6 +159,16 @@ const SETTINGS_CONSTANTS = [ route: `${ADVANCED_ROUTE}#export-data`, icon: 'fas fa-download', }, + // advanced settingsRefs[11] + { + tabMessage: (t) => t('advanced'), + sectionMessage: (t) => t('overrideContentSecurityPolicyHeader'), + descriptionMessage: (t) => + t('overrideContentSecurityPolicyHeaderDescription'), + route: `${ADVANCED_ROUTE}#override-content-security-policy-header`, + icon: 'fas fa-sliders-h', + hidden: getPlatform() !== PLATFORM_FIREFOX, + }, { tabMessage: (t) => t('contacts'), sectionMessage: (t) => t('contacts'), diff --git a/ui/helpers/utils/settings-search.js b/ui/helpers/utils/settings-search.js index 07b4501c0208..8c11ad8fad52 100644 --- a/ui/helpers/utils/settings-search.js +++ b/ui/helpers/utils/settings-search.js @@ -8,8 +8,10 @@ export function getSettingsRoutes() { if (settingsRoutes) { return settingsRoutes; } - settingsRoutes = SETTINGS_CONSTANTS.filter((routeObject) => - routeObject.featureFlag ? process.env[routeObject.featureFlag] : true, + settingsRoutes = SETTINGS_CONSTANTS.filter( + (routeObject) => + (routeObject.featureFlag ? process.env[routeObject.featureFlag] : true) && + !routeObject.hidden, ); return settingsRoutes; } diff --git a/ui/helpers/utils/settings-search.test.js b/ui/helpers/utils/settings-search.test.js index cc7b875d8c5e..30af3ee6b9da 100644 --- a/ui/helpers/utils/settings-search.test.js +++ b/ui/helpers/utils/settings-search.test.js @@ -68,6 +68,10 @@ const t = (key) => { return 'Dismiss Secret Recovery Phrase backup reminder'; case 'dismissReminderDescriptionField': return 'Turn this on to dismiss the Secret Recovery Phrase backup reminder message. We highly recommend that you back up your Secret Recovery Phrase to avoid loss of funds'; + case 'overrideContentSecurityPolicyHeader': + return 'Override Content-Security-Policy header'; + case 'overrideContentSecurityPolicyHeaderDescription': + return "This option is a workaround for a known issue in Firefox, where a dapp's Content-Security-Policy header may prevent the extension from loading properly. Disabling this option is not recommended unless required for specific web page compatibility."; case 'Contacts': return 'Contacts'; case 'securityAndPrivacy': @@ -147,9 +151,12 @@ describe('Settings Search Utils', () => { describe('getSettingsRoutes', () => { it('should be an array of settings routes objects', () => { const NUM_OF_ENV_FEATURE_FLAG_SETTINGS = 4; + const NUM_OF_HIDDEN_SETTINGS = 1; expect(getSettingsRoutes()).toHaveLength( - SETTINGS_CONSTANTS.length - NUM_OF_ENV_FEATURE_FLAG_SETTINGS, + SETTINGS_CONSTANTS.length - + NUM_OF_ENV_FEATURE_FLAG_SETTINGS - + NUM_OF_HIDDEN_SETTINGS, ); }); }); diff --git a/ui/pages/settings/advanced-tab/advanced-tab.component.js b/ui/pages/settings/advanced-tab/advanced-tab.component.js index 132b97f7caa9..6be7bcd52004 100644 --- a/ui/pages/settings/advanced-tab/advanced-tab.component.js +++ b/ui/pages/settings/advanced-tab/advanced-tab.component.js @@ -29,6 +29,10 @@ import { getNumberOfSettingRoutesInTab, handleSettingsRefs, } from '../../../helpers/utils/settings-search'; +// TODO: Remove restricted import +// eslint-disable-next-line import/no-restricted-paths +import { getPlatform } from '../../../../app/scripts/lib/util'; +import { PLATFORM_FIREFOX } from '../../../../shared/constants/app'; export default class AdvancedTab extends PureComponent { static contextTypes = { @@ -58,6 +62,8 @@ export default class AdvancedTab extends PureComponent { backupUserData: PropTypes.func.isRequired, showExtensionInFullSizeView: PropTypes.bool, setShowExtensionInFullSizeView: PropTypes.func.isRequired, + overrideContentSecurityPolicyHeader: PropTypes.bool, + setOverrideContentSecurityPolicyHeader: PropTypes.func.isRequired, }; state = { @@ -583,6 +589,42 @@ export default class AdvancedTab extends PureComponent { ); } + renderOverrideContentSecurityPolicyHeader() { + const { t } = this.context; + const { + overrideContentSecurityPolicyHeader, + setOverrideContentSecurityPolicyHeader, + } = this.props; + + return ( + +
+ {t('overrideContentSecurityPolicyHeader')} +
+ {t('overrideContentSecurityPolicyHeaderDescription')} +
+
+ +
+ setOverrideContentSecurityPolicyHeader(!value)} + offLabel={t('off')} + onLabel={t('on')} + /> +
+
+ ); + } + render() { const { errorInSettings } = this.props; // When adding/removing/editing the order of renders, double-check the order of the settingsRefs. This affects settings-search.js @@ -602,6 +644,9 @@ export default class AdvancedTab extends PureComponent { {this.renderAutoLockTimeLimit()} {this.renderUserDataBackup()} {this.renderDismissSeedBackupReminderControl()} + {getPlatform() === PLATFORM_FIREFOX + ? this.renderOverrideContentSecurityPolicyHeader() + : null} ); } diff --git a/ui/pages/settings/advanced-tab/advanced-tab.container.js b/ui/pages/settings/advanced-tab/advanced-tab.container.js index aaa094e0655c..0be61499c1af 100644 --- a/ui/pages/settings/advanced-tab/advanced-tab.container.js +++ b/ui/pages/settings/advanced-tab/advanced-tab.container.js @@ -7,6 +7,7 @@ import { backupUserData, setAutoLockTimeLimit, setDismissSeedBackUpReminder, + setOverrideContentSecurityPolicyHeader, setFeatureFlag, setShowExtensionInFullSizeView, setShowFiatConversionOnTestnetsPreference, @@ -31,6 +32,7 @@ export const mapStateToProps = (state) => { featureFlags: { sendHexData } = {}, useNonceField, dismissSeedBackUpReminder, + overrideContentSecurityPolicyHeader, } = metamask; const { showFiatInTestnets, @@ -49,6 +51,7 @@ export const mapStateToProps = (state) => { autoLockTimeLimit, useNonceField, dismissSeedBackUpReminder, + overrideContentSecurityPolicyHeader, }; }; @@ -81,6 +84,9 @@ export const mapDispatchToProps = (dispatch) => { setDismissSeedBackUpReminder: (value) => { return dispatch(setDismissSeedBackUpReminder(value)); }, + setOverrideContentSecurityPolicyHeader: (value) => { + return dispatch(setOverrideContentSecurityPolicyHeader(value)); + }, }; }; diff --git a/ui/pages/settings/advanced-tab/advanced-tab.stories.js b/ui/pages/settings/advanced-tab/advanced-tab.stories.js index 36c84cbba8c0..855bdd8aa86a 100644 --- a/ui/pages/settings/advanced-tab/advanced-tab.stories.js +++ b/ui/pages/settings/advanced-tab/advanced-tab.stories.js @@ -12,6 +12,7 @@ export default { showFiatInTestnets: { control: 'boolean' }, useLedgerLive: { control: 'boolean' }, dismissSeedBackUpReminder: { control: 'boolean' }, + overrideContentSecurityPolicyHeader: { control: 'boolean' }, setAutoLockTimeLimit: { action: 'setAutoLockTimeLimit' }, setShowFiatConversionOnTestnetsPreference: { action: 'setShowFiatConversionOnTestnetsPreference', @@ -20,6 +21,9 @@ export default { setIpfsGateway: { action: 'setIpfsGateway' }, setIsIpfsGatewayEnabled: { action: 'setIsIpfsGatewayEnabled' }, setDismissSeedBackUpReminder: { action: 'setDismissSeedBackUpReminder' }, + setOverrideContentSecurityPolicyHeader: { + action: 'setOverrideContentSecurityPolicyHeader', + }, setUseNonceField: { action: 'setUseNonceField' }, setHexDataFeatureFlag: { action: 'setHexDataFeatureFlag' }, displayErrorInSettings: { action: 'displayErrorInSettings' }, @@ -38,6 +42,7 @@ export const DefaultStory = (args) => { sendHexData, showFiatInTestnets, dismissSeedBackUpReminder, + overrideContentSecurityPolicyHeader, }, updateArgs, ] = useArgs(); @@ -65,6 +70,12 @@ export const DefaultStory = (args) => { dismissSeedBackUpReminder: !dismissSeedBackUpReminder, }); }; + + const handleOverrideContentSecurityPolicyHeader = () => { + updateArgs({ + overrideContentSecurityPolicyHeader: !overrideContentSecurityPolicyHeader, + }); + }; return (
{ setShowFiatConversionOnTestnetsPreference={handleShowFiatInTestnets} dismissSeedBackUpReminder={dismissSeedBackUpReminder} setDismissSeedBackUpReminder={handleDismissSeedBackUpReminder} + overrideContentSecurityPolicyHeader={ + overrideContentSecurityPolicyHeader + } + setOverrideContentSecurityPolicyHeader={ + handleOverrideContentSecurityPolicyHeader + } ipfsGateway="ipfs-gateway" />
@@ -91,4 +108,5 @@ DefaultStory.args = { showFiatInTestnets: false, useLedgerLive: false, dismissSeedBackUpReminder: false, + overrideContentSecurityPolicyHeader: true, }; diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 8018595371f4..fb69edb4bb92 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -4218,6 +4218,18 @@ export function setDismissSeedBackUpReminder( }; } +export function setOverrideContentSecurityPolicyHeader( + value: boolean, +): ThunkAction { + return async (dispatch: MetaMaskReduxDispatch) => { + dispatch(showLoadingIndication()); + await submitRequestToBackground('setOverrideContentSecurityPolicyHeader', [ + value, + ]); + dispatch(hideLoadingIndication()); + }; +} + export function getRpcMethodPreferences(): ThunkAction< void, MetaMaskReduxState, diff --git a/yarn.lock b/yarn.lock index 9a54de8897d6..a46a60233919 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11106,6 +11106,15 @@ __metadata: languageName: node linkType: hard +"@types/serve-handler@npm:^6.1.4": + version: 6.1.4 + resolution: "@types/serve-handler@npm:6.1.4" + dependencies: + "@types/node": "npm:*" + checksum: 10/c92ae204605659b37202af97cfcc7690be43b9290692c1d6c3c93805b399044fd67573af4eb2e7b1fd975451db6d0d5c6cd2f09b20997209fa3341f345f661e4 + languageName: node + linkType: hard + "@types/serve-index@npm:^1.9.4": version: 1.9.4 resolution: "@types/serve-index@npm:1.9.4" @@ -26625,6 +26634,7 @@ __metadata: "@types/redux-mock-store": "npm:1.0.6" "@types/remote-redux-devtools": "npm:^0.5.5" "@types/selenium-webdriver": "npm:^4.1.19" + "@types/serve-handler": "npm:^6.1.4" "@types/sinon": "npm:^10.0.13" "@types/sprintf-js": "npm:^1" "@types/w3c-web-hid": "npm:^1.0.3"