├── .gitignore ├── images ├── risk_32.png ├── default_32.png ├── default_48.png ├── default_64.png ├── failure_32.png ├── default_128.png ├── validated_32.png ├── menu-badge.svg ├── chevron-right.svg ├── x.svg ├── circle-info.svg ├── circle-download-cta.svg ├── loading-header.svg ├── error-header.svg ├── validated-header.svg └── warning-header.svg ├── .prettierrc ├── tsconfig.json ├── src ├── js │ ├── detectIGMeta.ts │ ├── detectMSGRMeta.ts │ ├── detectWAMeta.ts │ ├── globals.ts │ ├── __tests__ │ │ ├── detectWAMeta-test.js │ │ ├── checkCSPForUnsafeInline-test.js │ │ ├── parseFailedJSON-test.js │ │ ├── removeDynamicStrings-test.js │ │ ├── parseCSPString-test.js │ │ ├── getCSPHeadersFromWebRequestResponse-test.js │ │ ├── doesWorkerUrlConformToCSP-test.js │ │ ├── checkWorkerEndpointCSP-test.js │ │ └── content-test.js │ ├── content │ │ ├── hasVaryServiceWorkerHeader.ts │ │ ├── getTagIdentifier.ts │ │ ├── iFrameUtils.ts │ │ ├── isPathNameExcluded.ts │ │ ├── alertBackgroundOfImminentFetch.ts │ │ ├── parseCSPString.ts │ │ ├── ensureManifestWasOrWillBeLoaded.ts │ │ ├── doesWorkerUrlConformToCSP.ts │ │ ├── checkCSPForUnsafeInline.ts │ │ ├── updateCurrentState.ts │ │ ├── parseFailedJSON.ts │ │ ├── downloadArchive.ts │ │ ├── getManifestVersionAndTypeFromNode.ts │ │ ├── genSourceText.ts │ │ ├── checkDocumentCSPHeaders.ts │ │ ├── contentUtils.ts │ │ ├── manualCSSInspector.ts │ │ ├── checkWorkerEndpointCSP.ts │ │ └── checkCSPForEvals.ts │ ├── shared │ │ ├── sendMessageToBackground.ts │ │ ├── getCSPHeadersFromWebRequestResponse.ts │ │ ├── nestedDataHelpers.ts │ │ └── MessageTypes.d.ts │ ├── index.d.ts │ ├── background │ │ ├── getCFRootHash.ts │ │ ├── tab_state_tracker │ │ │ ├── FrameStateMachine.ts │ │ │ ├── tabStateTracker.ts │ │ │ ├── StateMachine.ts │ │ │ └── TabStateMachine.ts │ │ ├── removeDynamicStrings.ts │ │ ├── validateSender.ts │ │ ├── validateMetaCompanyManifest.ts │ │ ├── setupCSPListener.ts │ │ ├── setUpWebRequestsListener.ts │ │ └── historyManager.ts │ ├── detectFBMeta.ts │ ├── config.ts │ ├── popup │ │ ├── violation-list.ts │ │ └── popup.ts │ └── background.ts ├── css │ ├── popup.css │ └── violations.css └── html │ └── popup.html ├── jest.setup.js ├── .eslintrc.json ├── .github └── workflows │ └── tests.js.yml ├── LICENSE.md ├── CONTRIBUTING.md ├── package.json ├── README.md ├── config ├── v2 │ └── manifest.json └── v3 │ └── manifest.json ├── rollup.config.ts ├── CODE_OF_CONDUCT.md ├── _locales └── en │ └── messages.json └── jest.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | node_modules 3 | .DS_Store 4 | .yarn/ 5 | .yarnrc.yml 6 | -------------------------------------------------------------------------------- /images/risk_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/facebookincubator/meta-code-verify/HEAD/images/risk_32.png -------------------------------------------------------------------------------- /images/default_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/facebookincubator/meta-code-verify/HEAD/images/default_32.png -------------------------------------------------------------------------------- /images/default_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/facebookincubator/meta-code-verify/HEAD/images/default_48.png -------------------------------------------------------------------------------- /images/default_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/facebookincubator/meta-code-verify/HEAD/images/default_64.png -------------------------------------------------------------------------------- /images/failure_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/facebookincubator/meta-code-verify/HEAD/images/failure_32.png -------------------------------------------------------------------------------- /images/default_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/facebookincubator/meta-code-verify/HEAD/images/default_128.png -------------------------------------------------------------------------------- /images/validated_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/facebookincubator/meta-code-verify/HEAD/images/validated_32.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | arrowParens: "avoid", 3 | singleQuote: true, 4 | trailingComma: "all", 5 | bracketSpacing: false, 6 | bracketSameLine: true 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "lib": ["ES2021"], 5 | "moduleResolution": "node", 6 | "noFallthroughCasesInSwitch": true, 7 | "strict": true, 8 | "target": "ES2017" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/js/detectIGMeta.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {ORIGIN_TYPE} from './config'; 9 | import {startFor} from './content.js'; 10 | 11 | startFor(ORIGIN_TYPE.INSTAGRAM, { 12 | checkLoggedInFromCookie: true, 13 | excludedPathnames: [], 14 | }); 15 | -------------------------------------------------------------------------------- /src/js/detectMSGRMeta.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {ORIGIN_TYPE} from './config'; 9 | import {startFor} from './content.js'; 10 | 11 | startFor(ORIGIN_TYPE.MESSENGER, { 12 | checkLoggedInFromCookie: true, 13 | excludedPathnames: [], 14 | }); 15 | -------------------------------------------------------------------------------- /src/js/detectWAMeta.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {ORIGIN_TYPE} from './config'; 9 | import {startFor} from './content.js'; 10 | 11 | startFor(ORIGIN_TYPE.WHATSAPP, { 12 | checkLoggedInFromCookie: false, 13 | excludedPathnames: [], 14 | }); 15 | -------------------------------------------------------------------------------- /src/js/globals.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | // See https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Chrome_incompatibilities 9 | // To enable Promise APIs we need to use `browser` outside of Chrome 10 | self.chrome = self.browser ?? self.chrome; 11 | -------------------------------------------------------------------------------- /src/js/__tests__/detectWAMeta-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | 'use strict'; 9 | 10 | describe('detectWAMeta', () => { 11 | it.todo('test extractMetaAndLoad'); 12 | it.todo('ensure extraMetaAndLoad is called statically'); 13 | it.todo('ensure extractMetaAndLoad is called after DOMContentReady'); 14 | }); 15 | -------------------------------------------------------------------------------- /src/js/content/hasVaryServiceWorkerHeader.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | export function hasVaryServiceWorkerHeader( 9 | response: chrome.webRequest.OnResponseStartedDetails, 10 | ): boolean { 11 | return ( 12 | response.responseHeaders?.find( 13 | header => 14 | header.name.includes('vary') && 15 | header.value?.includes('Service-Worker'), 16 | ) !== undefined 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/js/content/getTagIdentifier.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {TagDetails} from '../content'; 9 | 10 | export function getTagIdentifier(tagDetails: TagDetails): string { 11 | switch (tagDetails.type) { 12 | case 'script': 13 | return tagDetails.src; 14 | case 'link': 15 | return tagDetails.href; 16 | case 'style': 17 | return 'style_' + tagDetails.tag.innerHTML.substring(0, 100); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/js/shared/sendMessageToBackground.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {MessagePayload, MessageResponse} from './MessageTypes'; 9 | 10 | export function sendMessageToBackground( 11 | message: MessagePayload, 12 | callback?: (response: MessageResponse) => void, 13 | ): void { 14 | if (callback != null) { 15 | chrome.runtime.sendMessage(message, callback); 16 | } else { 17 | chrome.runtime.sendMessage(message); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/js/content/iFrameUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | function isTopWindow(): boolean { 9 | return window == window.top; 10 | } 11 | 12 | function isSameDomainAsTopWindow(): boolean { 13 | try { 14 | // This is inside a try/catch because even attempting to access the `origin` 15 | // property will throw a SecurityError if the domains don't match. 16 | return window.location.origin === window.top?.location.origin; 17 | } catch { 18 | return false; 19 | } 20 | } 21 | 22 | export {isTopWindow, isSameDomainAsTopWindow}; 23 | -------------------------------------------------------------------------------- /src/js/content/isPathNameExcluded.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | export default function isPathnameExcluded( 9 | excludedPathnames: Array, 10 | ): boolean { 11 | let pathname = location.pathname; 12 | if (!pathname.endsWith('/')) { 13 | pathname = pathname + '/'; 14 | } 15 | return excludedPathnames.some(rule => { 16 | if (typeof rule === 'string') { 17 | return pathname === rule; 18 | } else { 19 | const match = pathname.match(rule); 20 | return match != null && match[0] === pathname; 21 | } 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/js/content/alertBackgroundOfImminentFetch.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {MESSAGE_TYPE} from '../config'; 9 | import {sendMessageToBackground} from '../shared/sendMessageToBackground'; 10 | 11 | export default function alertBackgroundOfImminentFetch( 12 | url: string, 13 | ): Promise { 14 | return new Promise(resolve => { 15 | sendMessageToBackground( 16 | { 17 | type: MESSAGE_TYPE.UPDATED_CACHED_SCRIPT_URLS, 18 | url, 19 | }, 20 | () => { 21 | resolve(); 22 | }, 23 | ); 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import { jest } from '@jest/globals'; 9 | 10 | window.chrome = { 11 | browserAction: { 12 | setIcon: jest.fn(), 13 | setPopup: jest.fn(), 14 | }, 15 | runtime: { 16 | onMessage: { 17 | addListener: jest.fn(), 18 | }, 19 | sendMessage: jest.fn(), 20 | } 21 | }; 22 | 23 | window.crypto = { 24 | subtle: { 25 | digest: jest.fn(), 26 | } 27 | }; 28 | 29 | window.TextEncoder = function () {}; 30 | window.TextEncoder.encode = jest.fn(); 31 | 32 | window.Uint8Array = function () {}; 33 | -------------------------------------------------------------------------------- /src/js/content/parseCSPString.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | export function parseCSPString(csp: string): Map> { 9 | const directiveStrings = csp.split(';').filter(Boolean); 10 | return directiveStrings.reduce((map, directiveString) => { 11 | const [directive, ...values] = directiveString 12 | .trim() 13 | .toLowerCase() 14 | .split(/\s+/); 15 | // Ignore subsequent keys for a directive, if it's specified more than once 16 | if (!map.has(directive)) { 17 | map.set(directive, new Set(values)); 18 | } 19 | return map; 20 | }, new Map()); 21 | } 22 | -------------------------------------------------------------------------------- /src/js/index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | export {}; 9 | 10 | type CompressionFormat = 'deflate' | 'deflate-raw' | 'gzip'; 11 | 12 | declare class CompressionStream implements GenericTransformStream { 13 | readonly readable: ReadableStream; 14 | readonly writable: WritableStream; 15 | } 16 | 17 | declare global { 18 | interface Window { 19 | CompressionStream: { 20 | new (format?: CompressionFormat): CompressionStream; 21 | }; 22 | showSaveFilePicker: (_: { 23 | suggestedName: string; 24 | }) => Promise; 25 | browser: typeof chrome; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/js/background/getCFRootHash.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | export function getCFRootHash( 9 | host: string, 10 | version: string, 11 | ): Promise { 12 | return new Promise(resolve => { 13 | fetch( 14 | 'https://api.privacy-auditability.cloudflare.com/v1/hash/' + 15 | `${encodeURIComponent(host)}/${encodeURIComponent(version)}`, 16 | {method: 'GET'}, 17 | ) 18 | .then(response => { 19 | resolve(response); 20 | }) 21 | .catch(error => { 22 | console.error('error fetching hash from CF', error); 23 | resolve(null); 24 | }); 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "es2021": true, 6 | "jest": true, 7 | "webextensions": true 8 | }, 9 | "parserOptions": { "project": ["./tsconfig.json"] }, 10 | "extends": [ 11 | "eslint:recommended", 12 | "plugin:@typescript-eslint/recommended" 13 | ], 14 | "parser": "@typescript-eslint/parser", 15 | "plugins": ["@typescript-eslint"], 16 | "rules": { 17 | "@typescript-eslint/no-unused-vars": ["error", {"argsIgnorePattern": "^_"}], 18 | "@typescript-eslint/no-empty-function": 0, 19 | "@typescript-eslint/no-extra-semi": 0, 20 | "@typescript-eslint/ban-ts-comment": [ 21 | "error", 22 | {"ts-ignore": "allow-with-description"} 23 | ] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/js/content/ensureManifestWasOrWillBeLoaded.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {MANIFEST_TIMEOUT, STATES} from '../config'; 9 | import {updateCurrentState} from './updateCurrentState'; 10 | 11 | export default function ensureManifestWasOrWillBeLoaded( 12 | loadedVersions: Set, 13 | version: string, 14 | ) { 15 | if (loadedVersions.has(version)) { 16 | return; 17 | } 18 | setTimeout(() => { 19 | if (!loadedVersions.has(version)) { 20 | updateCurrentState( 21 | STATES.INVALID, 22 | `Detected script from manifest version ${version} that has not been loaded`, 23 | ); 24 | } 25 | }, MANIFEST_TIMEOUT); 26 | } 27 | -------------------------------------------------------------------------------- /images/menu-badge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /.github/workflows/tests.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: tests.js CI 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [20.x, 21.x, 22.x, 23.x, 24.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: 'npm' 28 | - run: yarn install --frozen-lockfile 29 | - run: yarn build 30 | - run: yarn test 31 | -------------------------------------------------------------------------------- /src/js/detectFBMeta.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {ORIGIN_TYPE} from './config'; 9 | import {startFor} from './content.js'; 10 | 11 | // Pathnames that do not currently have messaging enabled and are not BT 12 | // compliant/ 13 | // NOTE: All pathnames checked against this list will be surrounded by '/' 14 | const EXCLUDED_PATHNAMES: Array = [ 15 | /** 16 | * Like plugin 17 | */ 18 | // e.g. /v2.5/plugins/like.php 19 | /\/v[\d.]+\/plugins\/like.php\/.*$/, 20 | 21 | /** 22 | * Page embed plugin 23 | */ 24 | // e.g. /v2.5/plugins/page.php 25 | /\/v[\d.]+\/plugins\/page.php\/.*$/, 26 | ]; 27 | 28 | startFor(ORIGIN_TYPE.FACEBOOK, { 29 | checkLoggedInFromCookie: true, 30 | excludedPathnames: EXCLUDED_PATHNAMES, 31 | }); 32 | -------------------------------------------------------------------------------- /src/js/content/doesWorkerUrlConformToCSP.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | export function doesWorkerUrlConformToCSP( 9 | workerValues: Set, 10 | url: string, 11 | ): boolean { 12 | // https://www.w3.org/TR/CSP3/#match-paths 13 | 14 | // *.facebook.com/sw/ -> does not exactMatch 15 | // *.facebook.com/sw -> needs exact match 16 | 17 | for (const value of workerValues) { 18 | const exactMatch = !value.endsWith('/'); 19 | // Allowed query parameters for exact match, and everything for non exact match 20 | const regexEnd = exactMatch ? '(\\?*)?$' : '*$'; 21 | const regex = new RegExp(('^' + value + regexEnd).replaceAll('*', '.*')); 22 | if (regex.test(url)) { 23 | return true; 24 | } 25 | } 26 | return false; 27 | } 28 | -------------------------------------------------------------------------------- /src/js/background/tab_state_tracker/FrameStateMachine.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import StateMachine from './StateMachine'; 9 | import TabStateMachine from './TabStateMachine'; 10 | 11 | /** 12 | * Tracks the extension's state for each frame. It'll notify the overall tab's 13 | * state machine of any changes. The tab may or may not choose to apply those 14 | * changes based on the current states of the rest of its frames 15 | * (see TabStateMachine.ts). 16 | */ 17 | export default class FrameStateMachine extends StateMachine { 18 | private _tabStateMachine: TabStateMachine; 19 | 20 | constructor(tabStateMachine: TabStateMachine) { 21 | super(); 22 | this._tabStateMachine = tabStateMachine; 23 | } 24 | 25 | onStateUpdated() { 26 | this._tabStateMachine.updateStateIfValid(this.getState()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/js/content/checkCSPForUnsafeInline.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {parseCSPString} from './parseCSPString'; 9 | 10 | /** 11 | * Enforces that CSP headers do not allow unsafe-inline 12 | */ 13 | export function checkCSPForUnsafeInline( 14 | cspHeaders: Array, 15 | ): [true] | [false, string] { 16 | const preventsUnsafeInline = cspHeaders.some(cspHeader => { 17 | const headers = parseCSPString(cspHeader); 18 | 19 | const scriptSrc = headers.get('script-src'); 20 | if (scriptSrc) { 21 | return !scriptSrc.has(`'unsafe-inline'`); 22 | } 23 | 24 | const defaultSrc = headers.get('default-src'); 25 | if (defaultSrc) { 26 | return !defaultSrc.has(`'unsafe-inline'`); 27 | } 28 | 29 | return false; 30 | }); 31 | 32 | if (preventsUnsafeInline) { 33 | return [true]; 34 | } else { 35 | return [false, 'CSP Headers do not prevent unsafe-inline.']; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) Meta Platforms, Inc. and affiliates. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/js/content/updateCurrentState.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {MESSAGE_TYPE, Origin, State, STATES} from '../config'; 9 | import {sendMessageToBackground} from '../shared/sendMessageToBackground'; 10 | 11 | let currentOrigin: Origin | undefined; 12 | 13 | export function setCurrentOrigin(origin: Origin): void { 14 | currentOrigin = origin; 15 | } 16 | 17 | export function getCurrentOrigin(): Origin { 18 | if (!currentOrigin) { 19 | invalidateAndThrow( 20 | 'Attemting to access currentOrigin before it has been set', 21 | ); 22 | } 23 | 24 | return currentOrigin; 25 | } 26 | 27 | export function updateCurrentState(state: State, details?: string) { 28 | sendMessageToBackground({ 29 | type: MESSAGE_TYPE.UPDATE_STATE, 30 | state, 31 | origin: getCurrentOrigin(), 32 | details, 33 | }); 34 | } 35 | 36 | export function invalidateAndThrow(details?: string): never { 37 | updateCurrentState(STATES.INVALID, details); 38 | throw new Error(details); 39 | } 40 | -------------------------------------------------------------------------------- /src/js/content/parseFailedJSON.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | 'use strict'; 9 | 10 | import {STATES} from '../config'; 11 | import {updateCurrentState} from './updateCurrentState'; 12 | 13 | let failed_FOR_TEST_DO_NOT_USE: boolean | null = null; 14 | 15 | export function clearFailedForTestDoNotUse(): void { 16 | failed_FOR_TEST_DO_NOT_USE = null; 17 | } 18 | export function getFailedForTestDoNotUse(): boolean | null { 19 | return failed_FOR_TEST_DO_NOT_USE; 20 | } 21 | 22 | export function parseFailedJSON(queuedJsonToParse: { 23 | node: Element; 24 | retry: number; 25 | }): void { 26 | // Only a document/doctype can have textContent as null 27 | const nodeTextContent = queuedJsonToParse.node.textContent ?? ''; 28 | try { 29 | JSON.parse(nodeTextContent); 30 | } catch { 31 | if (queuedJsonToParse.retry > 0) { 32 | queuedJsonToParse.retry--; 33 | setTimeout(() => parseFailedJSON(queuedJsonToParse), 20); 34 | } else { 35 | updateCurrentState(STATES.INVALID); 36 | failed_FOR_TEST_DO_NOT_USE = true; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to meta-code-verify 2 | We want to make contributing to this project as easy and transparent as 3 | possible. 4 | 5 | ## Pull Requests 6 | We actively welcome your pull requests. 7 | 8 | 1. Fork the repo and create your branch from `master`. 9 | 2. If you've added code that should be tested, add tests. 10 | 3. If you've changed APIs, update the documentation. 11 | 4. Ensure the test suite passes. 12 | 5. Make sure your code lints. 13 | 6. If you haven't already, complete the Contributor License Agreement ("CLA"). 14 | 15 | ## Contributor License Agreement ("CLA") 16 | In order to accept your pull request, we need you to submit a CLA. You only need 17 | to do this once to work on any of Meta's open source projects. 18 | 19 | Complete your CLA here: 20 | 21 | ## Issues 22 | We use GitHub issues to track public bugs. Please ensure your description is 23 | clear and has sufficient instructions to be able to reproduce the issue. 24 | 25 | Meta has a [bounty program](https://www.facebook.com/whitehat/) for the safe 26 | disclosure of security bugs. In those cases, please go through the process 27 | outlined on that page and do not file a public issue. 28 | 29 | ## License 30 | By contributing to meta-code-verify, you agree that your contributions will be licensed 31 | under the LICENSE file in the root directory of this source tree. 32 | -------------------------------------------------------------------------------- /images/chevron-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/js/shared/getCSPHeadersFromWebRequestResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | export function getCSPHeadersFromWebRequestResponse( 9 | response: chrome.webRequest.OnHeadersReceivedDetails, 10 | reportHeader = false, 11 | ): Array { 12 | const responseHeaders = response.responseHeaders; 13 | if (!responseHeaders) { 14 | throw new Error('Request is missing responseHeaders'); 15 | } 16 | const cspHeaders = responseHeaders.filter( 17 | header => 18 | header.name.toLowerCase() === 19 | (reportHeader 20 | ? 'content-security-policy-report-only' 21 | : 'content-security-policy'), 22 | ); 23 | 24 | // A single header value can be a comma seperated list of headers 25 | // https://www.w3.org/TR/CSP3/#parse-serialized-policy-list 26 | const individualHeaders: Array = []; 27 | cspHeaders.forEach(header => { 28 | if (header.value?.includes(', ')) { 29 | header.value.split(', ').forEach(headerValue => { 30 | individualHeaders.push({name: header.name, value: headerValue}); 31 | }); 32 | } else { 33 | individualHeaders.push(header); 34 | } 35 | }); 36 | return individualHeaders; 37 | } 38 | -------------------------------------------------------------------------------- /src/js/__tests__/checkCSPForUnsafeInline-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | 'use strict'; 9 | 10 | import {jest} from '@jest/globals'; 11 | import {checkCSPForUnsafeInline} from '../content/checkCSPForUnsafeInline'; 12 | import {setCurrentOrigin} from '../content/updateCurrentState'; 13 | 14 | describe('checkCSPForUnsafeInline', () => { 15 | beforeEach(() => { 16 | window.chrome.runtime.sendMessage = jest.fn(() => {}); 17 | setCurrentOrigin('FACEBOOK'); 18 | }); 19 | it('Valid due to script-src', () => { 20 | const [valid] = checkCSPForUnsafeInline([ 21 | `default-src 'unsafe-inline';` + `script-src 'self';`, 22 | ]); 23 | expect(valid).toBeTruthy(); 24 | }); 25 | it('Invalid due to script-src', () => { 26 | const [valid] = checkCSPForUnsafeInline([ 27 | `default-src 'unsafe-inline';` + `script-src 'self' 'unsafe-inline';`, 28 | ]); 29 | expect(valid).toBeFalsy(); 30 | }); 31 | it('Valid due to default-src', () => { 32 | const [valid] = checkCSPForUnsafeInline([`default-src 'self';`]); 33 | expect(valid).toBeTruthy(); 34 | }); 35 | it('Invalid due to default-src', () => { 36 | const [valid] = checkCSPForUnsafeInline([ 37 | `default-src 'self' 'unsafe-inline';`, 38 | ]); 39 | expect(valid).toBeFalsy(); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/js/shared/nestedDataHelpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | export function setOrUpdateMapInMap( 9 | outerMap: Map>, 10 | outerKey: OuterKey, 11 | innerKey: InnerKey, 12 | value: Value, 13 | ): Map> { 14 | const innerMap = outerMap.get(outerKey) ?? new Map(); 15 | innerMap.set(innerKey, value); 16 | if (!outerMap.has(outerKey)) { 17 | outerMap.set(outerKey, innerMap); 18 | } 19 | return outerMap; 20 | } 21 | 22 | export function setOrUpdateSetInMap( 23 | outerMap: Map>, 24 | outerKey: OuterKey, 25 | value: Value, 26 | ): Map> { 27 | const innerSet = outerMap.get(outerKey) ?? new Set(); 28 | innerSet.add(value); 29 | if (!outerMap.has(outerKey)) { 30 | outerMap.set(outerKey, innerSet); 31 | } 32 | return outerMap; 33 | } 34 | 35 | export function pushToOrCreateArrayInMap( 36 | outerMap: Map>, 37 | outerKey: OuterKey, 38 | value: Value, 39 | ): Map> { 40 | const innerArray = outerMap.get(outerKey) ?? []; 41 | innerArray.push(value); 42 | if (!outerMap.has(outerKey)) { 43 | outerMap.set(outerKey, innerArray); 44 | } 45 | return outerMap; 46 | } 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "meta-code-verify", 3 | "version": "4.1.0", 4 | "description": "Browser extensions to verify code running in the browser against a published manifest", 5 | "main": "none", 6 | "repository": "git@github.com:facebookincubator/meta-code-verify.git", 7 | "author": "Richard Hansen ", 8 | "license": "MIT", 9 | "type": "module", 10 | "private": true, 11 | "engines": { 12 | "node": ">= 20" 13 | }, 14 | "scripts": { 15 | "build": "yarn run rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript", 16 | "watch": "yarn build -w", 17 | "lint": "yarn beautify && yarn run eslint src/js/**", 18 | "beautify": "yarn run prettier --write \"src/**/*.(ts|js)\" \"build/**/*.ts\"", 19 | "test": "node --experimental-vm-modules --no-warnings node_modules/jest/bin/jest.js" 20 | }, 21 | "devDependencies": { 22 | "@rollup/plugin-eslint": "^9.0.5", 23 | "@rollup/plugin-node-resolve": "^15.2.3", 24 | "@rollup/plugin-typescript": "^11.1.5", 25 | "@types/chrome": "^0.1.4", 26 | "@types/jest": "^29.4.0", 27 | "@typescript-eslint/eslint-plugin": "8.29.1", 28 | "@typescript-eslint/parser": "8.29.1", 29 | "eslint": "9.0.0", 30 | "jest": "^29.7.0", 31 | "jest-environment-jsdom": "^29.0.0", 32 | "prettier": "^2.3.2", 33 | "rollup": "^2.56.3", 34 | "ts-jest": "^29.0.0", 35 | "tslib": "^2.5.0", 36 | "typescript": "5.8.2" 37 | }, 38 | "dependencies": { 39 | "acorn": "^8.11.3", 40 | "jsdom": "^20.0.0" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /images/x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/js/background/tab_state_tracker/tabStateTracker.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {Origin, State} from '../../config'; 9 | import TabStateMachine from './TabStateMachine'; 10 | import {ValidSender} from '../validateSender'; 11 | 12 | const tabStateTracker = new Map(); 13 | 14 | chrome.tabs.onRemoved.addListener((tabId: number, _removeInfo) => { 15 | tabStateTracker.delete(tabId); 16 | }); 17 | chrome.tabs.onReplaced.addListener((_addedTabId, removedTabId: number) => { 18 | tabStateTracker.delete(removedTabId); 19 | }); 20 | 21 | function getOrCreateTabStateMachine(tabId: number, origin: Origin) { 22 | const tabState = 23 | tabStateTracker.get(tabId) ?? new TabStateMachine(tabId, origin); 24 | if (!tabStateTracker.has(tabId)) { 25 | tabStateTracker.set(tabId, tabState); 26 | } 27 | return tabState; 28 | } 29 | 30 | export function recordContentScriptStart(sender: ValidSender, origin: Origin) { 31 | // This is a top-level frame initializing 32 | if (sender.frameId === 0) { 33 | tabStateTracker.delete(sender.tab.id); 34 | } 35 | getOrCreateTabStateMachine(sender.tab.id, origin).addFrameStateMachine( 36 | sender.frameId, 37 | ); 38 | } 39 | 40 | export function updateContentScriptState( 41 | sender: ValidSender, 42 | newState: State, 43 | origin: Origin, 44 | ) { 45 | getOrCreateTabStateMachine(sender.tab.id, origin).updateStateForFrame( 46 | sender.frameId, 47 | newState, 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/js/__tests__/parseFailedJSON-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | 'use strict'; 9 | 10 | import {jest} from '@jest/globals'; 11 | import {setCurrentOrigin} from '../content/updateCurrentState'; 12 | import { 13 | getFailedForTestDoNotUse, 14 | clearFailedForTestDoNotUse, 15 | parseFailedJSON, 16 | } from '../content/parseFailedJSON'; 17 | 18 | jest.useFakeTimers(); 19 | describe('parseFailedJSON', () => { 20 | beforeEach(() => { 21 | window.chrome.runtime.sendMessage = jest.fn(() => {}); 22 | setCurrentOrigin('FACEBOOK'); 23 | clearFailedForTestDoNotUse(); 24 | }); 25 | it('Should correctly parse valid JSON', () => { 26 | parseFailedJSON({ 27 | node: {textContent: '{}'}, 28 | retry: 10, 29 | }); 30 | expect(getFailedForTestDoNotUse()).toBe(null); 31 | }); 32 | it('Should throw on invalid JSON', () => { 33 | parseFailedJSON({ 34 | node: {textContent: ''}, 35 | retry: 10, 36 | }); 37 | setTimeout(() => { 38 | expect(getFailedForTestDoNotUse()).toBe(true); 39 | }, 500); 40 | jest.runAllTimers(); 41 | }); 42 | it('Should eventually success', () => { 43 | const node = {textContent: ''}; 44 | parseFailedJSON({node, retry: 50}); 45 | setTimeout(() => { 46 | node.textContent = '{}'; 47 | }, 200); 48 | jest.runAllTimers(); 49 | setTimeout(() => { 50 | expect(getFailedForTestDoNotUse()).toBe(null); 51 | }, 200); 52 | jest.runAllTimers(); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/js/shared/MessageTypes.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {MESSAGE_TYPE, Origin, State} from '../config'; 9 | import {RawManifestOtherHashes} from '../content'; 10 | 11 | export type MessagePayload = 12 | | { 13 | type: typeof MESSAGE_TYPE.LOAD_COMPANY_MANIFEST; 14 | origin: Origin; 15 | rootHash: string; 16 | otherHashes: RawManifestOtherHashes; 17 | leaves: Array; 18 | version: string; 19 | workaround: string; 20 | } 21 | | { 22 | type: typeof MESSAGE_TYPE.RAW_SRC; 23 | pkgRaw: string; 24 | origin: Origin; 25 | version: string; 26 | } 27 | | { 28 | type: typeof MESSAGE_TYPE.DEBUG; 29 | log: string; 30 | src?: string; 31 | } 32 | | { 33 | type: typeof MESSAGE_TYPE.STATE_UPDATED; 34 | tabId: number; 35 | state: State; 36 | } 37 | | { 38 | type: typeof MESSAGE_TYPE.UPDATE_STATE; 39 | state: State; 40 | origin: Origin; 41 | details?: string; 42 | } 43 | | { 44 | type: typeof MESSAGE_TYPE.CONTENT_SCRIPT_START; 45 | origin: Origin; 46 | } 47 | | { 48 | type: typeof MESSAGE_TYPE.UPDATED_CACHED_SCRIPT_URLS; 49 | url: string; 50 | }; 51 | 52 | export type MessageResponse = { 53 | valid?: boolean; 54 | success?: boolean; 55 | debugList?: Array; 56 | reason?: string; 57 | hash?: string; 58 | cspHeaders?: Array; 59 | cspReportHeaders?: Array; 60 | }; 61 | -------------------------------------------------------------------------------- /src/js/content/downloadArchive.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | export default async function downloadArchive( 9 | sourceScripts: Map, 10 | ): Promise { 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 12 | const chunks: Array = []; 13 | const enc = new TextEncoder(); 14 | const compressionStream = new CompressionStream('gzip'); 15 | 16 | for (const [fileName, response] of sourceScripts.entries()) { 17 | const delim = `\n********** new file: ${fileName} **********\n`; 18 | const chunk = await response.bytes(); 19 | chunks.push(enc.encode(delim), chunk); 20 | } 21 | 22 | const readableFromChunks = new ReadableStream({ 23 | start(controller) { 24 | for (const chunk of chunks) { 25 | controller.enqueue(chunk); 26 | } 27 | controller.close(); 28 | }, 29 | }); 30 | 31 | if ('showSaveFilePicker' in window) { 32 | const fileHandle = await window.showSaveFilePicker({ 33 | suggestedName: 'meta_source_files.gz', 34 | }); 35 | const fileStream = await fileHandle.createWritable(); 36 | readableFromChunks.pipeThrough(compressionStream).pipeTo(fileStream); 37 | } else { 38 | const src = await new Response( 39 | readableFromChunks.pipeThrough(compressionStream), 40 | ).blob(); 41 | const url = URL.createObjectURL(src); 42 | const a = document.createElement('a'); 43 | a.href = url; 44 | a.download = `meta_source_files.gz`; 45 | document.body.appendChild(a); 46 | a.click(); 47 | document.body.removeChild(a); 48 | URL.revokeObjectURL(url); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/js/background/removeDynamicStrings.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {Parser} from 'acorn'; 9 | import {DYNAMIC_STRING_MARKER} from '../config'; 10 | 11 | const markerLength = DYNAMIC_STRING_MARKER.length; 12 | 13 | // This variable, and the parser, are reused because extending the 14 | // parser is surprisingly expensive. This is currently safe because 15 | // `removeDynamicStrings` is synchronous. 16 | let ranges = [0]; 17 | function plugin(BaseParser: typeof Parser): typeof Parser { 18 | // @ts-ignore third party typing doesn't support extension well. 19 | return class extends BaseParser { 20 | parseLiteral(value: string) { 21 | // @ts-ignore third party typing doesn't support extension well. 22 | const node = super.parseLiteral(value); 23 | const before = this.input.substring( 24 | node.start - markerLength, 25 | node.start, 26 | ); 27 | if (before === DYNAMIC_STRING_MARKER) { 28 | // This pushes the index directly after the opening quote and 29 | // before the closing quote so that only the contents of the 30 | // string are removed. 31 | ranges.push(node.start + 1, node.end - 1); 32 | } 33 | return node; 34 | } 35 | }; 36 | } 37 | 38 | const extendedParser = Parser.extend(plugin); 39 | 40 | export function removeDynamicStrings(rawjs: string): string { 41 | ranges = [0]; 42 | extendedParser.parse(rawjs, {ecmaVersion: 'latest'}); 43 | 44 | let result = ''; 45 | for (let i = 0; i < ranges.length; i += 2) { 46 | result += rawjs.substring(ranges[i], ranges[i + 1]); 47 | } 48 | 49 | return result; 50 | } 51 | -------------------------------------------------------------------------------- /src/js/content/getManifestVersionAndTypeFromNode.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {invalidateAndThrow} from './updateCurrentState'; 9 | 10 | export const BOTH = 'BOTH'; 11 | 12 | export function getManifestVersionAndTypeFromNode( 13 | element: HTMLElement, 14 | ): [string, string] { 15 | const versionAndType = tryToGetManifestVersionAndTypeFromNode(element); 16 | 17 | if (!versionAndType) { 18 | invalidateAndThrow( 19 | `Missing manifest data attribute or invalid version/typeon attribute`, 20 | ); 21 | } 22 | 23 | return versionAndType; 24 | } 25 | 26 | export function tryToGetManifestVersionAndTypeFromNode( 27 | element: HTMLElement, 28 | ): [string, string] | null { 29 | const dataBtManifest = element.getAttribute('data-btmanifest'); 30 | if (dataBtManifest == null) { 31 | return null; 32 | } 33 | 34 | // Scripts may contain packages from both main and longtail manifests, 35 | // e.g. "1009592080_main,1009592080_longtail" 36 | const [manifest1, manifest2] = dataBtManifest.split(','); 37 | 38 | // If this scripts contains packages from both main and longtail manifests 39 | // then require both manifests to be loaded before processing this script, 40 | // otherwise use the single type specified. 41 | const otherType = manifest2 ? BOTH : manifest1.split('_')[1]; 42 | 43 | // It is safe to assume a script will not contain packages from different 44 | // versions, so we can use the first manifest version as the script version. 45 | const version = manifest1.split('_')[0]; 46 | 47 | if (!version || !otherType) { 48 | return null; 49 | } 50 | 51 | return [version, otherType]; 52 | } 53 | -------------------------------------------------------------------------------- /src/js/content/genSourceText.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | /** 9 | * Return text from the response object. The main purpose of this method is to 10 | * extract and parse sourceURL and sourceMappingURL comments from inlined data 11 | * scripts. 12 | * Note that this function consumes the response body! 13 | * 14 | * @param {Response} response Response will be consumed! 15 | * @returns string Response text if the sourceURL is valid 16 | */ 17 | export default async function genSourceText( 18 | response: Response, 19 | ): Promise { 20 | const sourceText = await response.text(); 21 | // Just a normal script tag with a source url 22 | if (!response.url.startsWith('data:application/x-javascript')) { 23 | return sourceText; 24 | } 25 | 26 | // Inlined data-script. We need to extract with optional `//# sourceURL=` and 27 | // `//# sourceMappingURL=` comments before sending it over to be hashed... 28 | const sourceTextParts = sourceText.trimEnd().split('\n'); 29 | 30 | // NOTE: For security reasons, we expect inlined data scripts to *end* with 31 | // sourceURL comments. This is because a man-in-the-middle can insert code 32 | // after the sourceURL comment, which would execute on the browser but get 33 | // stripped away by the extension before getting hashed + verified. 34 | // As a result, we're always starting our search from the bottom. 35 | while (isValidSourceURL(sourceTextParts[sourceTextParts.length - 1])) { 36 | sourceTextParts.pop(); 37 | } 38 | return sourceTextParts.join('\n').trim(); 39 | } 40 | 41 | const isValidSourceURL = (sourceURL: string): boolean => { 42 | return /^\/\/#\s*source(Mapping)?URL=https?:\/\/[^\s]+.js(\.map)?(?!\s)$/u.test( 43 | sourceURL, 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/js/background/validateSender.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | export type ValidSender = { 9 | frameId: number; 10 | tab: {id: number}; 11 | }; 12 | 13 | export function validateSender( 14 | sender: chrome.runtime.MessageSender, 15 | ): ValidSender | undefined { 16 | // The declaration for `sender.tab`: 17 | // The tabs.Tab which opened the connection, if any. This property will only 18 | // be present when the connection was opened from a tab (including content 19 | // scripts), and only if the receiver is an extension, not an app. 20 | // 21 | // We will always be receiving this message in an extension, and always from 22 | // a tab. Thus receiving a message without a tab indicates something wrong. 23 | const tab = sender.tab; 24 | if (!tab) { 25 | return; 26 | } 27 | 28 | // The declaration for `tab.id`: 29 | // The ID of the tab. Tab IDs are unique within a browser session. Under some 30 | // circumstances a Tab may not be assigned an ID, for example when querying 31 | // foreign tabs using the sessions API, in which case a session ID may be 32 | // present. Tab ID can also be set to chrome.tabs.TAB_ID_NONE for apps and 33 | // devtools windows. 34 | // 35 | // Since none of these apply we can assume that a missing ID indicates something 36 | // has gone wrong. 37 | const tabId = tab.id; 38 | if (tabId === undefined) { 39 | return; 40 | } 41 | 42 | // If a tab is present, a frameId should always be present. 43 | let frameId = sender.frameId; 44 | if (frameId === undefined) { 45 | return; 46 | } 47 | 48 | // See setupCSPListener.ts for explanation 49 | if (sender?.documentLifecycle === 'prerender') { 50 | frameId = 0; 51 | } 52 | 53 | return { 54 | frameId, 55 | tab: {id: tabId}, 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Code Verify 2 | 3 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg?color=white)](/LICENSE.md) [![Build status](https://img.shields.io/github/actions/workflow/status/facebookincubator/meta-code-verify/tests.js.yml)](https://github.com/facebookincubator/meta-code-verify/actions/workflows/tests.js.yml) [![Chrome](https://img.shields.io/badge/Chrome-yellow?logo=Google%20Chrome&logoColor=white)](https://chrome.google.com/webstore/detail/code-verify/llohflklppcaghdpehpbklhlfebooeog) [![Edge](https://img.shields.io/badge/Edge-blue?logo=Microsoft%20Edge&logoColor=white)](https://microsoftedge.microsoft.com/addons/detail/code-verify/cpndjjealjjagamdecpipjfamiigaknk) [![Firefox](https://img.shields.io/badge/Firefox-orange?logo=Firefox&logoColor=white)](https://addons.mozilla.org/en-US/firefox/addon/code-verify/) [![Safari](https://img.shields.io/badge/Safari-red?logo=Safari&logoColor=white)](https://apps.apple.com/us/app/code-verify/id6475794471) 4 | 5 | Code Verify is an extension for verifying the integrity of a web page. 6 | 7 | The idea is you can publish what JavaScript/CSS should appear on your site into a "manifest". The manifest consists of the hashes of all the JavaScript/CSS files in a given release. This browser extension can consume the manifest and verify that _only_ that code executes, or else display a warning to the user. 8 | 9 | ## Installation 10 | 11 | You can install Code Verify from the extension store of [Chrome](https://chrome.google.com/webstore/detail/code-verify/llohflklppcaghdpehpbklhlfebooeog), [Firefox](https://addons.mozilla.org/en-US/firefox/addon/code-verify/), [Edge](https://microsoftedge.microsoft.com/addons/detail/code-verify/cpndjjealjjagamdecpipjfamiigaknk), or [Safari](https://apps.apple.com/us/app/code-verify/id6475794471). 12 | 13 | ### [Code of Conduct](https://code.fb.com/codeofconduct) 14 | 15 | Meta has adopted a Code of Conduct that we expect project participants to adhere to. Please read [the full text](https://code.fb.com/codeofconduct) so that you can understand what actions will and will not be tolerated. 16 | 17 | ### License 18 | 19 | Code Verify is [MIT licensed](./LICENSE.md). 20 | -------------------------------------------------------------------------------- /src/js/background/validateMetaCompanyManifest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {getCFRootHash} from './getCFRootHash'; 9 | 10 | export async function validateMetaCompanyManifest( 11 | rootHash: string, 12 | otherHashes: { 13 | combined_hash: string; 14 | longtail: string; 15 | main: string; 16 | }, 17 | leaves: Array, 18 | host: string, 19 | version: string, 20 | ): Promise<{valid: boolean; reason?: string}> { 21 | const cfResponse = await getCFRootHash(host, version); 22 | if (!(cfResponse instanceof Response)) { 23 | return { 24 | valid: false, 25 | reason: 'UNKNOWN_ENDPOINT_ISSUE', 26 | }; 27 | } 28 | const cfPayload = await cfResponse.json(); 29 | const cfRootHash = cfPayload.root_hash; 30 | if (rootHash !== cfRootHash) { 31 | return { 32 | valid: false, 33 | reason: 'ROOT_HASH_VERFIY_FAIL_3RD_PARTY', 34 | }; 35 | } 36 | 37 | // merge all the hashes into one 38 | const megaHash = JSON.stringify(leaves); 39 | // hash it 40 | const encoder = new TextEncoder(); 41 | const encodedMegaHash = encoder.encode(megaHash); 42 | const jsHashArray = Array.from( 43 | new Uint8Array(await crypto.subtle.digest('SHA-256', encodedMegaHash)), 44 | ); 45 | const jsHash = jsHashArray.map(b => b.toString(16).padStart(2, '0')).join(''); 46 | // compare to main and long tail, it should match one 47 | // then hash it with the other 48 | let combinedHash = ''; 49 | if (jsHash === otherHashes.main || jsHash === otherHashes.longtail) { 50 | const combinedHashArray = Array.from( 51 | new Uint8Array( 52 | await crypto.subtle.digest( 53 | 'SHA-256', 54 | encoder.encode(otherHashes.longtail + otherHashes.main), 55 | ), 56 | ), 57 | ); 58 | combinedHash = combinedHashArray 59 | .map(b => b.toString(16).padStart(2, '0')) 60 | .join(''); 61 | } else { 62 | return {valid: false}; 63 | } 64 | 65 | return {valid: combinedHash === rootHash}; 66 | } 67 | -------------------------------------------------------------------------------- /src/js/__tests__/removeDynamicStrings-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | 'use strict'; 9 | 10 | import {removeDynamicStrings} from '../background/removeDynamicStrings'; 11 | 12 | describe('removeDynamicStrings', () => { 13 | it('Handles different quote types', () => { 14 | expect( 15 | removeDynamicStrings(`const foo = /*BTDS*/'dynamic string';`), 16 | ).toEqual(`const foo = /*BTDS*/'';`); 17 | expect( 18 | removeDynamicStrings(`const foo = /*BTDS*/"dynamic string";`), 19 | ).toEqual(`const foo = /*BTDS*/"";`); 20 | }); 21 | it('Handles empty strings', () => { 22 | expect(removeDynamicStrings(`const foo = /*BTDS*/'';`)).toEqual( 23 | `const foo = /*BTDS*/'';`, 24 | ); 25 | }); 26 | it('Handles strings in different scenarios', () => { 27 | expect(removeDynamicStrings(`/*BTDS*/'dynamic string';`)).toEqual( 28 | `/*BTDS*/'';`, 29 | ); 30 | expect( 31 | removeDynamicStrings( 32 | `/*BTDS*/'dynamic string' + /*BTDS*/'dynamic string';`, 33 | ), 34 | ).toEqual(`/*BTDS*/'' + /*BTDS*/'';`); 35 | expect( 36 | removeDynamicStrings(`const foo = JSON.parse(/*BTDS*/'dynamic string');`), 37 | ).toEqual(`const foo = JSON.parse(/*BTDS*/'');`); 38 | expect( 39 | removeDynamicStrings("`before ${/*BTDS*/'dynamic string'} after`;"), 40 | ).toEqual("`before ${/*BTDS*/''} after`;"); 41 | }); 42 | it('Handles multiple strings', () => { 43 | expect( 44 | removeDynamicStrings( 45 | `/*BTDS*/'dynamic string';/*BTDS*/'dynamic string';/*BTDS*/'dynamic string';`, 46 | ), 47 | ).toEqual(`/*BTDS*/'';/*BTDS*/'';/*BTDS*/'';`); 48 | }); 49 | it('Handles strings across line breaks', () => { 50 | expect( 51 | removeDynamicStrings(`/*BTDS*/'dynamic \ 52 | string';`), 53 | ).toEqual(`/*BTDS*/'';`); 54 | }); 55 | it('Throws if parsing fails', () => { 56 | expect(() => 57 | removeDynamicStrings(`const foo = JSON.parse(/*BTDS*/'dynamic string';`), 58 | ).toThrow(); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /config/v2/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Code Verify", 4 | "version": "4.1.0", 5 | "default_locale": "en", 6 | "description": "An extension to verify the code running in your browser matches what was published.", 7 | "page_action": { 8 | "default_title": "Code Verify", 9 | "default_icon": { 10 | "32": "default_32.png", 11 | "48": "default_48.png", 12 | "64": "default_64.png", 13 | "128": "default_128.png" 14 | } 15 | }, 16 | "icons": { 17 | "32": "default_32.png", 18 | "48": "default_48.png", 19 | "64": "default_64.png", 20 | "128": "default_128.png" 21 | }, 22 | "background": { 23 | "persistent": true, 24 | "scripts": ["background.js"] 25 | }, 26 | "content_scripts": [ 27 | { 28 | "matches": ["*://*.messenger.com/*"], 29 | "all_frames": true, 30 | "js": ["contentMSGR.js"], 31 | "run_at": "document_start" 32 | }, 33 | { 34 | "matches": ["*://*.facebook.com/*"], 35 | "all_frames": true, 36 | "match_about_blank": true, 37 | "js": ["contentFB.js"], 38 | "run_at": "document_start" 39 | }, 40 | { 41 | "matches": ["*://*.instagram.com/*"], 42 | "all_frames": true, 43 | "match_about_blank": true, 44 | "js": ["contentIG.js"], 45 | "run_at": "document_start" 46 | }, 47 | { 48 | "matches": ["*://*.whatsapp.com/*"], 49 | "all_frames": true, 50 | "match_about_blank": true, 51 | "js": ["contentWA.js"], 52 | "run_at": "document_start" 53 | } 54 | ], 55 | "permissions": [ 56 | "webRequest", 57 | "storage", 58 | "https://*.privacy-auditability.cloudflare.com/*", 59 | "https://static.xx.fbcdn.net/", 60 | "https://static.cdninstagram.com/", 61 | "https://static.whatsapp.net/", 62 | "*://*.messenger.com/*", 63 | "*://*.facebook.com/*", 64 | "*://*.instagram.com/*", 65 | "*://*.whatsapp.com/*" 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /config/v3/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Code Verify", 4 | "version": "4.1.0", 5 | "default_locale": "en", 6 | "description": "An extension to verify the code running in your browser matches what was published.", 7 | "action": { 8 | "default_title": "Code Verify", 9 | "default_icon": { 10 | "32": "default_32.png", 11 | "48": "default_48.png", 12 | "64": "default_64.png", 13 | "128": "default_128.png" 14 | } 15 | }, 16 | "icons": { 17 | "32": "default_32.png", 18 | "48": "default_48.png", 19 | "64": "default_64.png", 20 | "128": "default_128.png" 21 | }, 22 | "background": { 23 | "service_worker": "background.js" 24 | }, 25 | "content_scripts": [ 26 | { 27 | "matches": ["*://*.messenger.com/*"], 28 | "all_frames": true, 29 | "js": ["contentMSGR.js"], 30 | "run_at": "document_start" 31 | }, 32 | { 33 | "matches": ["*://*.facebook.com/*"], 34 | "all_frames": true, 35 | "match_about_blank": true, 36 | "js": ["contentFB.js"], 37 | "run_at": "document_start" 38 | }, 39 | { 40 | "matches": ["*://*.instagram.com/*"], 41 | "all_frames": true, 42 | "match_about_blank": true, 43 | "js": ["contentIG.js"], 44 | "run_at": "document_start" 45 | }, 46 | { 47 | "matches": ["*://*.whatsapp.com/*"], 48 | "all_frames": true, 49 | "match_about_blank": true, 50 | "js": ["contentWA.js"], 51 | "run_at": "document_start" 52 | } 53 | ], 54 | "permissions": [ 55 | "webRequest", 56 | "storage" 57 | ], 58 | "host_permissions": [ 59 | "https://*.privacy-auditability.cloudflare.com/", 60 | "https://static.xx.fbcdn.net/", 61 | "https://static.cdninstagram.com/", 62 | "https://static.whatsapp.net/", 63 | "*://*.messenger.com/*", 64 | "*://*.facebook.com/*", 65 | "*://*.instagram.com/*", 66 | "*://*.whatsapp.com/*" 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /images/circle-info.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import type {Plugin, RollupOptions} from 'rollup'; 9 | 10 | import cleanOnce from './build/rollup-plugin-clean-once'; 11 | import eslintPlugin from '@rollup/plugin-eslint'; 12 | import typescript from '@rollup/plugin-typescript'; 13 | import nodeResolve from '@rollup/plugin-node-resolve'; 14 | import prettierBuildStart from './build/rollup-plugin-prettier-build-start'; 15 | import staticFiles from './build/rollup-plugin-static-files'; 16 | import watch from './build/rollup-plugin-watch-additional'; 17 | 18 | function eslint(): Plugin { 19 | return eslintPlugin({throwOnError: true}); 20 | } 21 | function prettierSrc(): Plugin { 22 | return prettierBuildStart('"src/**/*.(js|ts)"'); 23 | } 24 | 25 | const TARGETS = [ 26 | ['chrome', 'v3'], 27 | ['edge', 'v3'], 28 | ['firefox', 'v2'], 29 | ['safari', 'v2'], 30 | ]; 31 | const SITES = ['WA', 'MSGR', 'FB', 'IG']; 32 | 33 | const contentScriptSteps: Array = SITES.map((site, index) => ({ 34 | input: `src/js/detect${site}Meta.ts`, 35 | output: TARGETS.map(([target]) => ({ 36 | file: `dist/${target}/content${site}.js`, 37 | format: 'iife', 38 | })), 39 | plugins: [cleanOnce(), typescript(), prettierSrc(), eslint()], 40 | })); 41 | 42 | const config: Array = contentScriptSteps.concat([ 43 | { 44 | input: 'src/js/background.ts', 45 | output: TARGETS.map(([target]) => ({ 46 | file: `dist/${target}/background.js`, 47 | format: 'iife', 48 | })), 49 | plugins: [typescript(), prettierSrc(), eslint(), nodeResolve()], 50 | }, 51 | { 52 | input: 'src/js/popup/popup.ts', 53 | output: TARGETS.map(([target, version]) => ({ 54 | file: `dist/${target}/popup.js`, 55 | format: 'iife', 56 | plugins: [staticFiles(`config/${version}/`)], 57 | })), 58 | plugins: [ 59 | typescript(), 60 | prettierSrc(), 61 | eslint(), 62 | staticFiles(['images/', 'src/css/', 'src/html/']), 63 | staticFiles('_locales/', {keepDir: true}), 64 | watch(['images/', 'src/css/', 'src/html/', '_locales/', 'config/']), 65 | ], 66 | }, 67 | ]); 68 | 69 | export default config; 70 | -------------------------------------------------------------------------------- /src/js/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | export const STATES = Object.freeze({ 9 | // Starting state for all frames/tabs 10 | START: 'START', 11 | // Tab is processing scripts 12 | PROCESSING: 'PROCESSING', 13 | // Disable the extension (it shouldn't be running on this tab) 14 | IGNORE: 'IGNORE', 15 | // Script verification against the manifest failed. 16 | INVALID: 'INVALID', 17 | // Unknown inline script from an extension was found 18 | RISK: 'RISK', 19 | // All script verifications succeeded 20 | VALID: 'VALID', 21 | // Timed out waiting for the manifest to be available on the page 22 | TIMEOUT: 'TIMEOUT', 23 | }); 24 | 25 | export type State = keyof typeof STATES; 26 | 27 | const ICONS = { 28 | DEFAULT: { 29 | 32: 'default_32.png', 30 | 64: 'default_64.png', 31 | 128: 'default_128.png', 32 | }, 33 | FAILURE: { 34 | 32: 'failure_32.png', 35 | }, 36 | RISK: { 37 | 32: 'risk_32.png', 38 | }, 39 | VALID: { 40 | 32: 'validated_32.png', 41 | }, 42 | }; 43 | 44 | export const STATES_TO_ICONS = { 45 | [STATES.START]: ICONS.DEFAULT, 46 | [STATES.PROCESSING]: ICONS.DEFAULT, 47 | [STATES.IGNORE]: ICONS.DEFAULT, 48 | [STATES.INVALID]: ICONS.FAILURE, 49 | [STATES.RISK]: ICONS.RISK, 50 | [STATES.VALID]: ICONS.VALID, 51 | [STATES.TIMEOUT]: ICONS.RISK, 52 | }; 53 | 54 | export const MESSAGE_TYPE = Object.freeze({ 55 | DEBUG: 'DEBUG', 56 | LOAD_COMPANY_MANIFEST: 'LOAD_COMPANY_MANIFEST', 57 | POPUP_STATE: 'POPUP_STATE', 58 | RAW_SRC: 'RAW_SRC', 59 | UPDATE_STATE: 'UPDATE_STATE', 60 | STATE_UPDATED: 'STATE_UPDATED', 61 | CONTENT_SCRIPT_START: 'CONTENT_SCRIPT_START', 62 | UPDATED_CACHED_SCRIPT_URLS: 'UPDATED_CACHED_SCRIPT_URLS', 63 | }); 64 | 65 | export type MessageType = keyof typeof MESSAGE_TYPE; 66 | 67 | export const ORIGIN_HOST: Record = { 68 | FACEBOOK: 'facebook.com', 69 | WHATSAPP: 'whatsapp.com', 70 | MESSENGER: 'messenger.com', 71 | INSTAGRAM: 'instagram.com', 72 | }; 73 | 74 | export const ORIGIN_TYPE = Object.freeze({ 75 | FACEBOOK: 'FACEBOOK', 76 | WHATSAPP: 'WHATSAPP', 77 | MESSENGER: 'MESSENGER', 78 | INSTAGRAM: 'INSTAGRAM', 79 | }); 80 | 81 | export type Origin = keyof typeof ORIGIN_TYPE; 82 | 83 | export const MANIFEST_TIMEOUT = 45000; 84 | 85 | export const DYNAMIC_STRING_MARKER = '/*BTDS*/'; 86 | -------------------------------------------------------------------------------- /src/js/__tests__/parseCSPString-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | 'use strict'; 9 | 10 | import {parseCSPString} from '../content/parseCSPString'; 11 | 12 | describe('parseCSPString', () => { 13 | it('Correctly parses multiple keys/directives', () => { 14 | expect( 15 | parseCSPString( 16 | `default-src 'self' blob:;` + `script-src 'self' 'wasm-unsafe-eval';`, 17 | ), 18 | ).toEqual( 19 | new Map([ 20 | ['default-src', new Set(["'self'", 'blob:'])], 21 | ['script-src', new Set(["'self'", "'wasm-unsafe-eval'"])], 22 | ]), 23 | ); 24 | }); 25 | it('Normalizes CSP keys/values', () => { 26 | expect( 27 | parseCSPString( 28 | `sCriPt-src *.facebook.com *.fbcdn.net blob: data: 'self' 'wasm-UNsafe-eval';`, 29 | ), 30 | ).toEqual( 31 | new Map([ 32 | [ 33 | 'script-src', 34 | new Set([ 35 | '*.facebook.com', 36 | '*.fbcdn.net', 37 | 'blob:', 38 | 'data:', 39 | "'self'", 40 | "'wasm-unsafe-eval'", 41 | ]), 42 | ], 43 | ]), 44 | ); 45 | }); 46 | it('Ignores subsequent directive keys', () => { 47 | expect( 48 | parseCSPString( 49 | `script-src 'none';` + 50 | `script-src *.facebook.com *.fbcdn.net blob: data: 'self' 'wasm-UNsafe-eval';` + 51 | `connect-src 'self';`, 52 | ), 53 | ).toEqual( 54 | new Map([ 55 | ['script-src', new Set(["'none'"])], 56 | ['connect-src', new Set(["'self'"])], 57 | ]), 58 | ); 59 | }); 60 | it('Can still parse keys when invalid characters are present', () => { 61 | expect( 62 | parseCSPString(`default-src 'self'; script-src 'none';`), 63 | ).toEqual( 64 | new Map([ 65 | ['default-src', new Set(["'self'"])], 66 | ['script-src', new Set(["'none'"])], 67 | ]), 68 | ); 69 | }); 70 | it('Correctly parses other whitespace chars', () => { 71 | expect( 72 | parseCSPString( 73 | `default-src\t'self' blob:;` + `script-src 'self'\f'wasm-unsafe-eval';`, 74 | ), 75 | ).toEqual( 76 | new Map([ 77 | ['default-src', new Set(["'self'", 'blob:'])], 78 | ['script-src', new Set(["'self'", "'wasm-unsafe-eval'"])], 79 | ]), 80 | ); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /images/circle-download-cta.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/js/content/checkDocumentCSPHeaders.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {Origin, ORIGIN_HOST} from '../config'; 9 | import {invalidateAndThrow} from './updateCurrentState'; 10 | import {parseCSPString} from './parseCSPString'; 11 | import {checkCSPForEvals} from './checkCSPForEvals'; 12 | import {checkCSPForUnsafeInline} from './checkCSPForUnsafeInline'; 13 | 14 | export function checkCSPForWorkerSrc( 15 | cspHeaders: Array, 16 | origin: Origin, 17 | ): [true] | [false, string] { 18 | const host = ORIGIN_HOST[origin]; 19 | 20 | const headersWithWorkerSrc = cspHeaders.filter(cspHeader => 21 | parseCSPString(cspHeader).has('worker-src'), 22 | ); 23 | 24 | if (headersWithWorkerSrc.length === 0) { 25 | return [false, 'Missing worker-src directive on CSP of main document']; 26 | } 27 | 28 | // Valid CSP if at least one CSP header is strict enough, since the browser 29 | // should apply all. 30 | const isValid = headersWithWorkerSrc.some(cspHeader => { 31 | const cspMap = parseCSPString(cspHeader); 32 | const workersSrcValues = cspMap.get('worker-src'); 33 | return ( 34 | workersSrcValues && 35 | !workersSrcValues.has('data:') && 36 | !workersSrcValues.has('blob:') && 37 | !workersSrcValues.has("'self'") && 38 | /** 39 | * Ensure that worker-src doesn't have values like *.facebook.com 40 | * this would require us to assume that every non main-thread script 41 | * from this origin might be a worker setting us for potential breakages 42 | * in the future. Instead worker-src should be a finite list of urls, 43 | * which if fetched will be ensured to have valid CSPs within them, 44 | * since url backed workers have independent CSP. 45 | */ 46 | !Array.from(workersSrcValues.values()).some( 47 | value => value.endsWith(host) || value.endsWith(host + '/'), 48 | ) 49 | ); 50 | }); 51 | 52 | if (isValid) { 53 | return [true]; 54 | } else { 55 | return [false, 'Invalid worker-src directive on main document']; 56 | } 57 | } 58 | 59 | export function checkDocumentCSPHeaders( 60 | cspHeaders: Array, 61 | cspReportHeaders: Array | undefined, 62 | origin: Origin, 63 | ): void { 64 | [ 65 | checkCSPForUnsafeInline(cspHeaders), 66 | checkCSPForEvals(cspHeaders, cspReportHeaders), 67 | checkCSPForWorkerSrc(cspHeaders, origin), 68 | ].forEach(([valid, reason]) => { 69 | if (!valid) { 70 | invalidateAndThrow(reason); 71 | } 72 | }); 73 | } 74 | 75 | export function getAllowedWorkerCSPs( 76 | cspHeaders: Array, 77 | ): Array> { 78 | return cspHeaders 79 | .map(header => parseCSPString(header).get('worker-src')) 80 | .filter((header): header is Set => !!header); 81 | } 82 | -------------------------------------------------------------------------------- /src/js/__tests__/getCSPHeadersFromWebRequestResponse-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | 'use strict'; 9 | 10 | import {getCSPHeadersFromWebRequestResponse} from '../shared/getCSPHeadersFromWebRequestResponse'; 11 | 12 | const KEY = 'Content-Security-Policy'; 13 | 14 | describe('getCSPHeadersFromWebRequestResponse', () => { 15 | it('Works with single header key, single value', () => { 16 | expect( 17 | getCSPHeadersFromWebRequestResponse({ 18 | responseHeaders: [ 19 | {name: KEY, value: 'default-src facebook.com;'}, 20 | {name: 'other-header', value: ''}, 21 | ], 22 | }), 23 | ).toEqual([{name: KEY, value: 'default-src facebook.com;'}]); 24 | }); 25 | it('Works with single header key, multiple values', () => { 26 | expect( 27 | getCSPHeadersFromWebRequestResponse({ 28 | responseHeaders: [ 29 | { 30 | name: KEY, 31 | value: 32 | 'default-src facebook.com;, frame-ancestors https://www.facebook.com https://www.instagram.com;', 33 | }, 34 | {name: 'other-header', value: ''}, 35 | ], 36 | }), 37 | ).toEqual([ 38 | {name: KEY, value: 'default-src facebook.com;'}, 39 | { 40 | name: KEY, 41 | value: 42 | 'frame-ancestors https://www.facebook.com https://www.instagram.com;', 43 | }, 44 | ]); 45 | }); 46 | it('It works with multiple header keys', () => { 47 | expect( 48 | getCSPHeadersFromWebRequestResponse({ 49 | responseHeaders: [ 50 | { 51 | name: KEY, 52 | value: 'default-src facebook.com;', 53 | }, 54 | { 55 | name: KEY, 56 | value: 57 | 'frame-ancestors https://www.facebook.com https://www.instagram.com;', 58 | }, 59 | {name: 'other-header', value: ''}, 60 | ], 61 | }), 62 | ).toEqual([ 63 | {name: KEY, value: 'default-src facebook.com;'}, 64 | { 65 | name: KEY, 66 | value: 67 | 'frame-ancestors https://www.facebook.com https://www.instagram.com;', 68 | }, 69 | ]); 70 | }); 71 | it('It works with multiple header keys, multiple values', () => { 72 | expect( 73 | getCSPHeadersFromWebRequestResponse({ 74 | responseHeaders: [ 75 | { 76 | name: KEY, 77 | value: 'script-src facebook.com;', 78 | }, 79 | { 80 | name: KEY, 81 | value: 82 | 'default-src facebook.com;, frame-ancestors https://www.facebook.com https://www.instagram.com;', 83 | }, 84 | {name: 'other-header', value: ''}, 85 | ], 86 | }), 87 | ).toEqual([ 88 | {name: KEY, value: 'script-src facebook.com;'}, 89 | {name: KEY, value: 'default-src facebook.com;'}, 90 | { 91 | name: KEY, 92 | value: 93 | 'frame-ancestors https://www.facebook.com https://www.instagram.com;', 94 | }, 95 | ]); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/js/content/contentUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import alertBackgroundOfImminentFetch from './alertBackgroundOfImminentFetch'; 9 | 10 | import {TagDetails} from '../content'; 11 | import {MESSAGE_TYPE} from '../config'; 12 | import genSourceText from './genSourceText'; 13 | import {sendMessageToBackground} from '../shared/sendMessageToBackground'; 14 | import {getCurrentOrigin} from './updateCurrentState'; 15 | import downloadArchive from './downloadArchive'; 16 | 17 | const SOURCE_SCRIPTS_AND_STYLES = new Map(); 18 | 19 | async function processSrc( 20 | tagDetails: TagDetails, 21 | version: string, 22 | ): Promise<{ 23 | valid: boolean; 24 | type?: unknown; 25 | }> { 26 | try { 27 | let packages: Array = []; 28 | if (tagDetails.type === 'script' || tagDetails.type === 'link') { 29 | // fetch the script/style from page context, not the extension context. 30 | 31 | const url = 32 | tagDetails.type === 'script' ? tagDetails.src : tagDetails.href; 33 | const isServiceWorker = 34 | tagDetails.type === 'script' && tagDetails.isServiceWorker; 35 | 36 | await alertBackgroundOfImminentFetch(url); 37 | const sourceResponse = await fetch(url, { 38 | method: 'GET', 39 | // When the browser fetches a service worker it adds this header. 40 | // If this is missing it will cause a cache miss, resulting in invalidation. 41 | headers: isServiceWorker ? {'Service-Worker': 'script'} : undefined, 42 | }); 43 | const fileNameArr = url.split('/'); 44 | const fileName = fileNameArr[fileNameArr.length - 1].split('?')[0]; 45 | const responseBody = sourceResponse.clone(); 46 | if (!responseBody.body) { 47 | throw new Error('Response for fetched script has no body'); 48 | } 49 | SOURCE_SCRIPTS_AND_STYLES.set(fileName, responseBody); 50 | const sourceText = await genSourceText(sourceResponse); 51 | 52 | // split package up if necessary 53 | packages = sourceText.split('/*FB_PKG_DELIM*/\n'); 54 | } else if (tagDetails.type === 'style') { 55 | packages = [tagDetails.tag.innerHTML]; 56 | } 57 | 58 | const packagePromises = packages.map(pkg => { 59 | return new Promise((resolve, reject) => { 60 | sendMessageToBackground( 61 | { 62 | type: MESSAGE_TYPE.RAW_SRC, 63 | pkgRaw: pkg.trimStart(), 64 | origin: getCurrentOrigin(), 65 | version: version, 66 | }, 67 | response => { 68 | if (response.valid) { 69 | resolve(null); 70 | } else { 71 | reject(); 72 | } 73 | }, 74 | ); 75 | }); 76 | }); 77 | await Promise.all(packagePromises); 78 | return {valid: true}; 79 | } catch (scriptProcessingError) { 80 | return { 81 | valid: false, 82 | type: scriptProcessingError, 83 | }; 84 | } 85 | } 86 | 87 | function downloadSrc(): void { 88 | downloadArchive(SOURCE_SCRIPTS_AND_STYLES); 89 | } 90 | 91 | export {processSrc, downloadSrc}; 92 | -------------------------------------------------------------------------------- /src/js/background/tab_state_tracker/StateMachine.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {State, STATES} from '../../config'; 9 | 10 | // Table of possible transitions from one state to another. The entries for 11 | // each transition can be: 12 | // (a) a boolean indicating if the transition to that state is valid 13 | // (b) another state to transition to should a transition to the 'from' state 14 | // is attempted. 15 | const STATE_TRANSITIONS: { 16 | [key in State]: Partial<{[key in State]: boolean | State}>; 17 | } = { 18 | [STATES.START]: { 19 | [STATES.START]: true, 20 | [STATES.PROCESSING]: true, 21 | [STATES.IGNORE]: true, 22 | }, 23 | [STATES.PROCESSING]: { 24 | [STATES.PROCESSING]: true, 25 | [STATES.INVALID]: true, 26 | [STATES.RISK]: true, 27 | [STATES.VALID]: true, 28 | [STATES.TIMEOUT]: true, 29 | }, 30 | [STATES.IGNORE]: { 31 | [STATES.IGNORE]: true, 32 | [STATES.INVALID]: true, 33 | // Attempting to go from IGNORE to anything other than INVALID is bad and 34 | // should send you to an INVALID state. Either all frames in the tab are 35 | // being checked to some extent, or all should be ignored by the extension. 36 | // Nothing in between. 37 | [STATES.START]: STATES.INVALID, 38 | [STATES.PROCESSING]: STATES.INVALID, 39 | [STATES.RISK]: STATES.INVALID, 40 | [STATES.VALID]: STATES.INVALID, 41 | [STATES.TIMEOUT]: STATES.INVALID, 42 | }, 43 | [STATES.INVALID]: { 44 | [STATES.INVALID]: true, 45 | }, 46 | [STATES.RISK]: { 47 | [STATES.INVALID]: true, 48 | [STATES.RISK]: true, 49 | }, 50 | [STATES.VALID]: { 51 | [STATES.VALID]: true, 52 | [STATES.PROCESSING]: true, 53 | [STATES.INVALID]: true, 54 | [STATES.IGNORE]: STATES.INVALID, 55 | }, 56 | [STATES.TIMEOUT]: { 57 | [STATES.TIMEOUT]: true, 58 | }, 59 | }; 60 | 61 | /** 62 | * State machine that transitions through the states listed above. This is used 63 | * to track the extension's state of the overall tab, and for individual frames 64 | * within that tab. 65 | */ 66 | export default class StateMachine { 67 | private _state: State; 68 | constructor() { 69 | this._state = STATES.START; 70 | } 71 | 72 | getState() { 73 | return this._state; 74 | } 75 | 76 | updateStateIfValid(newState: State) { 77 | // You messed up. 78 | if (!(newState in STATES)) { 79 | console.error('State', newState, 'does not exist!'); 80 | this._setState(STATES.INVALID); 81 | return; 82 | } 83 | 84 | const skipState = STATE_TRANSITIONS[this._state][newState]; 85 | if (typeof skipState === 'string') { 86 | this.updateStateIfValid(skipState); 87 | return; 88 | } else if (skipState) { 89 | this._setState(newState); 90 | } 91 | } 92 | 93 | _setState(newState: State) { 94 | const oldState = this._state; 95 | this._state = newState; 96 | if (oldState !== newState) { 97 | this.onStateUpdated(); 98 | } 99 | } 100 | 101 | onStateUpdated() {} 102 | } 103 | -------------------------------------------------------------------------------- /src/css/popup.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | @import 'violations.css'; 9 | 10 | /* We only use objects to render out SVGs so disable pointer events */ 11 | object { 12 | pointer-events: none; 13 | } 14 | 15 | body { 16 | margin: 0; 17 | overflow: hidden; 18 | } 19 | 20 | state-element { 21 | display: block; 22 | width: 262px; 23 | } 24 | 25 | .body_image { 26 | height: 32px; 27 | width: 158px; 28 | } 29 | 30 | .content_body { 31 | align-items: center; 32 | display: flex; 33 | justify-content: center; 34 | flex-direction: column; 35 | margin-bottom: 16px; 36 | padding-top: 24px; 37 | } 38 | 39 | .action_bar { 40 | flex-direction: row; 41 | display: flex; 42 | justify-content: space-evenly; 43 | margin-top: 16px; 44 | width: 100%; 45 | gap: 12px; 46 | padding: 0 12px; 47 | box-sizing: border-box; 48 | } 49 | 50 | .button { 51 | flex: 1; 52 | border: 1px solid #cbd2d9; 53 | box-sizing: border-box; 54 | border-radius: 4px; 55 | cursor: pointer; 56 | min-height: 36px; 57 | font-family: SF Pro Text; 58 | font-style: normal; 59 | font-weight: 500; 60 | font-size: 13px; 61 | line-height: 20px; 62 | text-align: center; 63 | letter-spacing: -0.23px; 64 | } 65 | 66 | .primary_button { 67 | background: #1c2b33; 68 | color: #ffffff; 69 | } 70 | 71 | .secondary_button, .tertiary_button { 72 | background: #ffffff; 73 | color: #1c2b33; 74 | } 75 | 76 | header { 77 | display: flex; 78 | justify-content: space-between; 79 | padding: 16px 16px 0 16px; 80 | } 81 | 82 | .menu_button, 83 | #close_button { 84 | cursor: pointer; 85 | display: flex; 86 | } 87 | 88 | .state_boundary { 89 | display: none; 90 | } 91 | 92 | .status_header { 93 | font-family: SF Pro Text; 94 | font-style: normal; 95 | font-weight: 600; 96 | font-size: 13px; 97 | line-height: 18px; 98 | text-align: center; 99 | letter-spacing: -0.08px; 100 | color: #1c2b33; 101 | margin-top: 12px; 102 | } 103 | 104 | .status_message { 105 | font-family: SF Pro Text; 106 | font-style: normal; 107 | font-weight: normal; 108 | font-size: 13px; 109 | line-height: 18px; 110 | text-align: center; 111 | letter-spacing: -0.08px; 112 | color: #63788a; 113 | margin: 0 36px; 114 | } 115 | 116 | .header_title { 117 | display: flex; 118 | align-items: center; 119 | gap: 12px; 120 | } 121 | 122 | .header_label { 123 | font-family: Optimistic Display; 124 | font-style: normal; 125 | font-size: 16px; 126 | line-height: 20px; 127 | letter-spacing: 0.24px; 128 | color: #000000; 129 | height: 20px; 130 | margin: 0; 131 | padding: 0; 132 | } 133 | 134 | /* Menu Page */ 135 | .menu_top_level { 136 | width: 262px; 137 | display: flex; 138 | justify-content: end; 139 | padding-top: 12px; 140 | } 141 | 142 | .menu_right_sidebar { 143 | background: #f1f4f7; 144 | box-shadow: 0px 0px 16px rgba(52, 72, 84, 0.05); 145 | width: 209px; 146 | } 147 | 148 | .menu_row { 149 | display: flex; 150 | cursor: pointer; 151 | align-items: center; 152 | padding: 8px 16px; 153 | } 154 | 155 | 156 | .menu_row:not(:last-child) { 157 | border-bottom: 1px dotted #1c2b33; 158 | } 159 | 160 | .menu_row > p { 161 | font-family: SF Pro Text; 162 | font-style: normal; 163 | font-size: 13px; 164 | line-height: 20px; 165 | letter-spacing: -0.23px; 166 | color: #1c2b33; 167 | flex-grow: 1; 168 | margin: 0px 12px; 169 | } 170 | -------------------------------------------------------------------------------- /src/js/background/setupCSPListener.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {CSPHeaderMap} from '../background'; 9 | import {getCSPHeadersFromWebRequestResponse} from '../shared/getCSPHeadersFromWebRequestResponse'; 10 | import {setOrUpdateMapInMap} from '../shared/nestedDataHelpers'; 11 | 12 | export default function setupCSPListener( 13 | cspHeadersMap: CSPHeaderMap, 14 | cspReportHeadersMap: CSPHeaderMap, 15 | ): void { 16 | chrome.webRequest.onHeadersReceived.addListener( 17 | details => { 18 | if (details.responseHeaders) { 19 | const cspHeaders = getCSPHeadersFromWebRequestResponse(details); 20 | const cspReportHeaders = getCSPHeadersFromWebRequestResponse( 21 | details, 22 | true, 23 | ); 24 | let frameId = details.frameId; 25 | if ( 26 | details?.documentLifecycle === 'prerender' && 27 | details?.frameType === 'outermost_frame' && 28 | details.type === 'main_frame' && 29 | details.frameId !== 0 30 | ) { 31 | /** 32 | * Chrome uses a non-main frame for prerender 33 | * https://bugs.chromium.org/p/chromium/issues/detail?id=1492006 34 | * 35 | * This creates issues in tracking the document resources 36 | * because content scripts *might* start sending messages 37 | * to the background while the document is still in "prerender"-ing 38 | * mode. When that happens the "sender" correctly has the frameID; 39 | * however, at other times (if the user hits navigate/enter? quick enough) 40 | * content script ends up sending messages from the "main" frame 41 | * (frameId = 0). To handle the former case whenever the background 42 | * receives a messages from a frame that is still in "prerendering" 43 | * we assume the frameID to 0. See validateSender.ts 44 | */ 45 | frameId = 0; 46 | } 47 | if (details.tabId !== 0) { 48 | setOrUpdateMapInMap( 49 | cspHeadersMap, 50 | details.tabId, 51 | frameId, 52 | cspHeaders.map(h => h.value), 53 | ); 54 | setOrUpdateMapInMap( 55 | cspReportHeadersMap, 56 | details.tabId, 57 | frameId, 58 | cspReportHeaders.map(h => h.value), 59 | ); 60 | } else { 61 | // Safari, https://developer.apple.com/forums/thread/668159 62 | // Best guess effort, this should be fast enough that we can 63 | // get the correct tabID even if the user is opening multiple tabs 64 | chrome.tabs.query({active: true, currentWindow: true}).then(tabs => { 65 | if (tabs.length !== 0) { 66 | setOrUpdateMapInMap( 67 | cspHeadersMap, 68 | tabs[0].id, 69 | frameId, 70 | cspHeaders.map(h => h.value), 71 | ); 72 | setOrUpdateMapInMap( 73 | cspReportHeadersMap, 74 | tabs[0].id, 75 | frameId, 76 | cspReportHeaders.map(h => h.value), 77 | ); 78 | } 79 | }); 80 | } 81 | } 82 | return undefined; 83 | }, 84 | { 85 | types: ['main_frame', 'sub_frame'], 86 | urls: [ 87 | '*://*.facebook.com/*', 88 | '*://*.messenger.com/*', 89 | '*://*.instagram.com/*', 90 | '*://*.whatsapp.com/*', 91 | ], 92 | }, 93 | ['responseHeaders'], 94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | This Code of Conduct also applies outside the project spaces when there is a 56 | reasonable belief that an individual's behavior may have a negative impact on 57 | the project or its community. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported by contacting the project team at . All 63 | complaints will be reviewed and investigated and will result in a response that 64 | is deemed necessary and appropriate to the circumstances. The project team is 65 | obligated to maintain confidentiality with regard to the reporter of an incident. 66 | Further details of specific enforcement policies may be posted separately. 67 | 68 | Project maintainers who do not follow or enforce the Code of Conduct in good 69 | faith may face temporary or permanent repercussions as determined by other 70 | members of the project's leadership. 71 | 72 | ## Attribution 73 | 74 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 75 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 76 | 77 | [homepage]: https://www.contributor-covenant.org 78 | 79 | For answers to common questions about this code of conduct, see 80 | https://www.contributor-covenant.org/faq 81 | -------------------------------------------------------------------------------- /src/js/__tests__/doesWorkerUrlConformToCSP-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | 'use strict'; 9 | 10 | import {doesWorkerUrlConformToCSP} from '../content/doesWorkerUrlConformToCSP'; 11 | 12 | describe('doesWorkerUrlConformToCSP', () => { 13 | describe('Edge cases', () => { 14 | it('Empty worker values', () => { 15 | expect(doesWorkerUrlConformToCSP(new Set(), '')).toEqual(false); 16 | }); 17 | it('Empty url', () => { 18 | expect( 19 | doesWorkerUrlConformToCSP(new Set(['*.test.com']), ''), 20 | ).toBeFalsy(); 21 | }); 22 | }); 23 | describe('Exact match', () => { 24 | const EXACT_URL_SCHEME_VALUE = 25 | 'https://www.facebook.com/worker_init_script'; 26 | it('Allows exact match', () => { 27 | expect( 28 | doesWorkerUrlConformToCSP( 29 | new Set([EXACT_URL_SCHEME_VALUE]), 30 | 'https://www.facebook.com/worker_init_script', 31 | ), 32 | ).toBeTruthy(); 33 | }); 34 | it('Allows exact match with search params', () => { 35 | expect( 36 | doesWorkerUrlConformToCSP( 37 | new Set([EXACT_URL_SCHEME_VALUE]), 38 | 'https://www.facebook.com/worker_init_script?p=1&q=2', 39 | ), 40 | ).toBeTruthy(); 41 | }); 42 | it('Does not allow trailing slash with exact match', () => { 43 | expect( 44 | doesWorkerUrlConformToCSP( 45 | new Set([EXACT_URL_SCHEME_VALUE]), 46 | 'https://www.facebook.com/worker_init_script/?p=1&q=2', 47 | ), 48 | ).toBeFalsy(); 49 | }); 50 | it('Does notllows non-exact match', () => { 51 | expect( 52 | doesWorkerUrlConformToCSP( 53 | new Set([EXACT_URL_SCHEME_VALUE]), 54 | 'https://www.facebook.com/worker_init_script/sub/path', 55 | ), 56 | ).toBeFalsy(); 57 | }); 58 | }); 59 | describe('Non exact match', () => { 60 | const NON_EXACT_URL_SCHEME_VALUE = 61 | 'https://www.facebook.com/worker_init_script/'; 62 | it('Allows exact match', () => { 63 | expect( 64 | doesWorkerUrlConformToCSP( 65 | new Set([NON_EXACT_URL_SCHEME_VALUE]), 66 | 'https://www.facebook.com/worker_init_script/', 67 | ), 68 | ).toBeTruthy(); 69 | }); 70 | it('Does not allow missing trailing slash', () => { 71 | expect( 72 | doesWorkerUrlConformToCSP( 73 | new Set([NON_EXACT_URL_SCHEME_VALUE]), 74 | 'https://www.facebook.com/worker_init_script', 75 | ), 76 | ).toBeFalsy(); 77 | }); 78 | it('Allows non-exact match', () => { 79 | expect( 80 | doesWorkerUrlConformToCSP( 81 | new Set([NON_EXACT_URL_SCHEME_VALUE]), 82 | 'https://www.facebook.com/worker_init_script/sub/path', 83 | ), 84 | ).toBeTruthy(); 85 | }); 86 | it('Allows non-exact match with search params', () => { 87 | expect( 88 | doesWorkerUrlConformToCSP( 89 | new Set([NON_EXACT_URL_SCHEME_VALUE]), 90 | 'https://www.facebook.com/worker_init_script/sub/path?p=1', 91 | ), 92 | ).toBeTruthy(); 93 | }); 94 | }); 95 | describe('Wilcards support', () => { 96 | it('Allows wildcards (exact)', () => { 97 | expect( 98 | doesWorkerUrlConformToCSP( 99 | new Set(['*.facebook.com/worker_init_script']), 100 | 'https://www.facebook.com/worker_init_script', 101 | ), 102 | ).toBeTruthy(); 103 | }); 104 | it('Allows custom ports (non-exact)', () => { 105 | expect( 106 | doesWorkerUrlConformToCSP( 107 | new Set(['*://*.facebook.com:*/worker_init_script/']), 108 | 'https://www.facebook.com:84/worker_init_script/', 109 | ), 110 | ).toBeTruthy(); 111 | }); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /src/js/content/manualCSSInspector.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {STATES} from '../config'; 9 | import {tryToGetManifestVersionAndTypeFromNode} from './getManifestVersionAndTypeFromNode'; 10 | import {updateCurrentState} from './updateCurrentState'; 11 | 12 | const CHECKED_STYLESHEET_HASHES = new Set(); 13 | 14 | export function scanForCSSNeedingManualInspsection(): void { 15 | checkForStylesheetChanges(); 16 | setInterval(checkForStylesheetChanges, 1000); 17 | } 18 | 19 | async function checkForStylesheetChanges() { 20 | [...document.styleSheets, ...document.adoptedStyleSheets].forEach( 21 | async sheet => { 22 | const potentialOwnerNode = sheet.ownerNode; 23 | 24 | if (sheet.href && potentialOwnerNode instanceof HTMLLinkElement) { 25 | // Link style tags are checked agains the manifest 26 | return; 27 | } 28 | 29 | if ( 30 | potentialOwnerNode instanceof HTMLStyleElement && 31 | tryToGetManifestVersionAndTypeFromNode(potentialOwnerNode) != null 32 | ) { 33 | // Inline style covered by the main checks 34 | return; 35 | } 36 | 37 | updateStateOnInvalidStylesheet( 38 | await checkIsStylesheetValid(sheet), 39 | sheet, 40 | ); 41 | }, 42 | ); 43 | } 44 | 45 | async function checkIsStylesheetValid( 46 | styleSheet: CSSStyleSheet, 47 | ): Promise { 48 | const potentialOwnerNode = styleSheet.ownerNode; 49 | 50 | if (potentialOwnerNode instanceof HTMLStyleElement) { 51 | const hashedContent = await hashString( 52 | potentialOwnerNode.textContent ?? '', 53 | ); 54 | if (CHECKED_STYLESHEET_HASHES.has(hashedContent)) { 55 | return true; 56 | } 57 | CHECKED_STYLESHEET_HASHES.add(hashedContent); 58 | } 59 | 60 | // We have to look at every CSS rule 61 | return [...styleSheet.cssRules].every(isValidCSSRule); 62 | } 63 | 64 | function isValidCSSRule(rule: CSSRule): boolean { 65 | if ( 66 | rule instanceof CSSKeyframeRule && 67 | rule.style.getPropertyValue('font-family') !== '' 68 | ) { 69 | // Attempting to animate fonts 70 | return false; 71 | } 72 | 73 | if ( 74 | !( 75 | rule instanceof CSSGroupingRule || 76 | rule instanceof CSSKeyframesRule || 77 | rule instanceof CSSImportRule 78 | ) 79 | ) { 80 | return true; 81 | } 82 | 83 | let rulesToCheck: Array = []; 84 | 85 | if (rule instanceof CSSImportRule) { 86 | const styleSheet = rule.styleSheet; 87 | if (styleSheet != null) { 88 | ensureCORSEnabledForStylesheet(styleSheet); 89 | rulesToCheck = [...styleSheet.cssRules]; 90 | } 91 | } else { 92 | rulesToCheck = [...rule.cssRules]; 93 | } 94 | 95 | return rulesToCheck.every(isValidCSSRule); 96 | } 97 | 98 | function ensureCORSEnabledForStylesheet(styleSheet: CSSStyleSheet): void { 99 | try { 100 | // Ensure all non same origin stylesheets can be accessed (CORS) 101 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions 102 | styleSheet.cssRules; 103 | } catch { 104 | updateStateOnInvalidStylesheet(false, styleSheet); 105 | } 106 | } 107 | 108 | async function hashString(content: string): Promise { 109 | const text = new TextEncoder().encode(content); 110 | const hashBuffer = await crypto.subtle.digest('SHA-256', text); 111 | const hashArray = Array.from(new Uint8Array(hashBuffer)); 112 | return hashArray.map(byte => byte.toString(16).padStart(2, '0')).join(''); 113 | } 114 | 115 | function updateStateOnInvalidStylesheet( 116 | isValid: boolean, 117 | sheet: CSSStyleSheet, 118 | ): void { 119 | if (!isValid) { 120 | const potentialHref = sheet.href ?? ''; 121 | updateCurrentState( 122 | STATES.INVALID, 123 | `Violating CSS stylesheet ${potentialHref}`, 124 | ); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/js/background/setUpWebRequestsListener.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const isMetaInitiatedResponse = ( 9 | response: chrome.webRequest.OnResponseStartedDetails, 10 | ) => { 11 | const initiator = response.initiator; 12 | if (!initiator) { 13 | return false; 14 | } 15 | 16 | return [ 17 | 'https://www.facebook.com', 18 | 'https://www.messenger.com', 19 | 'https://www.instagram.com', 20 | 'https://web.whatsapp.com', 21 | ].some(v => initiator.includes(v)); 22 | }; 23 | 24 | function checkResponseMIMEType( 25 | response: chrome.webRequest.OnResponseStartedDetails, 26 | ): void { 27 | // Sniffable MIME types are a violation 28 | if ( 29 | response.responseHeaders?.find(header => 30 | header.name.toLowerCase().includes('x-content-type-options'), 31 | )?.value !== 'nosniff' 32 | ) { 33 | chrome.tabs.sendMessage( 34 | response.tabId, 35 | { 36 | greeting: 'sniffableMimeTypeResource', 37 | src: response.url, 38 | }, 39 | {frameId: response.frameId}, 40 | ); 41 | } 42 | } 43 | 44 | export default function setUpWebRequestsListener( 45 | cachedScriptsUrls: Map>, 46 | ): void { 47 | chrome.webRequest.onResponseStarted.addListener( 48 | response => { 49 | if (response.tabId === -1) { 50 | if ( 51 | response.url.startsWith('chrome-extension://') || 52 | response.url.startsWith('moz-extension://') 53 | ) { 54 | return; 55 | } 56 | if (!isMetaInitiatedResponse(response)) { 57 | return; 58 | } 59 | checkResponseMIMEType(response); 60 | 61 | // Potential `importScripts` call from Shared or Service Worker 62 | if (response.type === 'script') { 63 | const origin = response.initiator; 64 | 65 | // Send to all tabs of this origin 66 | chrome.tabs.query({url: `${origin}/*`}, tabs => { 67 | tabs.forEach(tab => { 68 | if (tab.id) { 69 | chrome.tabs.sendMessage( 70 | tab.id, 71 | { 72 | greeting: 'checkIfScriptWasProcessed', 73 | response, 74 | }, 75 | // Send this to the topframe since child frames 76 | // might have a different origin 77 | {frameId: 0}, 78 | ); 79 | } 80 | }); 81 | }); 82 | } 83 | 84 | return; 85 | } 86 | 87 | // Detect uncached responses 88 | if ( 89 | response.type === 'xmlhttprequest' && 90 | cachedScriptsUrls.get(response.tabId)?.has(response.url) 91 | ) { 92 | if (!response.fromCache) { 93 | chrome.tabs.sendMessage( 94 | response.tabId, 95 | { 96 | greeting: 'nocacheHeaderFound', 97 | uncachedUrl: response.url, 98 | }, 99 | {frameId: response.frameId}, 100 | ); 101 | } 102 | cachedScriptsUrls.get(response.tabId)?.delete(response.url); 103 | return; 104 | } 105 | 106 | if (response.type === 'script') { 107 | checkResponseMIMEType(response); 108 | /* 109 | * Scripts could be from main thread or dedicates WebWorkers. 110 | * Content scripts can't detect scripts from Workers so we need 111 | * to send them back to content script for verification. 112 | * */ 113 | chrome.tabs.sendMessage( 114 | response.tabId, 115 | { 116 | greeting: 'checkIfScriptWasProcessed', 117 | response, 118 | }, 119 | {frameId: response.frameId}, 120 | ); 121 | return; 122 | } 123 | }, 124 | {urls: ['']}, 125 | ['responseHeaders'], 126 | ); 127 | } 128 | -------------------------------------------------------------------------------- /src/css/violations.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | violation-list { 9 | display: block; 10 | width: 800px; 11 | height: 600px; 12 | overflow: auto; 13 | } 14 | 15 | violation-list popup-header { 16 | margin-bottom: 12px; 17 | display: block; 18 | } 19 | 20 | violation-list table { 21 | background: #fdf5e6; 22 | color: #8b6914; 23 | width: 100%; 24 | border-collapse: collapse; 25 | table-layout: fixed; 26 | font: 12px SF Pro Text; 27 | } 28 | 29 | violation-list th, 30 | violation-list td { 31 | border: 1px solid #e6c547; 32 | padding: 8px; 33 | overflow: hidden; 34 | text-overflow: ellipsis; 35 | text-wrap-mode: nowrap; 36 | box-sizing: border-box; 37 | } 38 | 39 | violation-list th { 40 | background: #f2e6a6; 41 | font-weight: 600; 42 | text-align: start; 43 | } 44 | 45 | /* Column widths */ 46 | violation-list th:nth-child(1), 47 | violation-list td:nth-child(1) { 48 | width: 54%; 49 | } 50 | 51 | violation-list th:nth-child(2), 52 | violation-list td:nth-child(2) { 53 | width: 21%; 54 | } 55 | 56 | violation-list th:nth-child(3), 57 | violation-list td:nth-child(3) { 58 | width: 12%; 59 | } 60 | 61 | violation-list th:nth-child(4), 62 | violation-list td:nth-child(4) { 63 | width: 13%; 64 | text-align: end; 65 | } 66 | 67 | /* Hashes table */ 68 | violation-list .subtable_wrapper { 69 | display: none; 70 | } 71 | 72 | violation-list .subtable_wrapper > td { 73 | padding: 0; 74 | border: 0; 75 | } 76 | 77 | violation-list .hashes_table { 78 | background: #fef9e7; 79 | color: #996515; 80 | font-size: 11px; 81 | } 82 | 83 | violation-list .hashes_table thead th { 84 | border-top: 0; 85 | background: #f0e6b8; 86 | } 87 | 88 | violation-list .hashes_table tr td:last-child, 89 | violation-list .hashes_table thead th:last-child { 90 | border-right: 0; 91 | } 92 | 93 | violation-list .hashes_table tr:last-child td { 94 | border-bottom: 0; 95 | } 96 | 97 | /* Indent */ 98 | violation-list .hashes_table tr { 99 | position: relative; 100 | } 101 | 102 | violation-list .hashes_table tr::before { 103 | content: ''; 104 | position: absolute; 105 | left: 0; 106 | top: 0; 107 | bottom: 0; 108 | width: 32px; 109 | background: linear-gradient(to right, #f8f2d3 0%, #fef9e7 100%); 110 | border-right: 2px solid #e6c547; 111 | } 112 | 113 | violation-list .hashes_table tr th:first-child, 114 | violation-list .hashes_table tr td:first-child { 115 | padding-left: 42px; 116 | } 117 | 118 | /* Override column Widths for first 2 columns */ 119 | violation-list .hashes_table th:nth-child(1), 120 | violation-list .hashes_table td:nth-child(1) { 121 | width: 64%; 122 | } 123 | 124 | violation-list .hashes_table th:nth-child(2), 125 | violation-list .hashes_table td:nth-child(2) { 126 | width: 11%; 127 | } 128 | 129 | /* Hashes */ 130 | violation-list code { 131 | padding: 4px; 132 | border-radius: 3px; 133 | font-family: SF Mono, Monaco, monospace; 134 | min-width: 100%; 135 | text-align: center; 136 | display: inline-block; 137 | overflow: hidden; 138 | text-overflow: ellipsis; 139 | background: #f7f2c4; 140 | border: 1px solid #e6d878; 141 | color: #5c5200; 142 | box-sizing: border-box; 143 | } 144 | 145 | .download_button { 146 | cursor: pointer; 147 | opacity: 0.8; 148 | display: inline-flex; 149 | } 150 | 151 | violation-list .subtable_wrapper.expanded { 152 | display: table-row; 153 | } 154 | 155 | .expand-row { 156 | cursor: pointer; 157 | } 158 | 159 | /* Current tab row styling */ 160 | violation-list tr.current-tab { 161 | background: #fef4e8; 162 | border: 2px solid #e67e22; 163 | box-shadow: 0 2px 4px rgba(230, 126, 34, 0.2); 164 | } 165 | 166 | violation-list tr.current-tab td { 167 | color: #b8540e; 168 | border-color: #e67e22; 169 | } 170 | 171 | violation-list tr.current-tab td:first-child { 172 | position: relative; 173 | } 174 | 175 | violation-list tr.current-tab td:first-child::after { 176 | content: 'CURRENT TAB'; 177 | position: absolute; 178 | top: 0; 179 | right: 0; 180 | background: #e67e22; 181 | color: white; 182 | font-size: 8px; 183 | font-weight: bold; 184 | padding: 2px 4px; 185 | border-radius: 0 0 0 4px; 186 | } 187 | -------------------------------------------------------------------------------- /src/js/popup/violation-list.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {downloadHashSource, getRecords} from '../background/historyManager'; 9 | 10 | class ViolationList extends HTMLElement { 11 | connectedCallback() { 12 | const currentTabID = new URL(document.location.href).searchParams.get( 13 | 'tab_id', 14 | )!; 15 | getRecords().then(records => { 16 | const rows = records.reverse().map(([tabID, record]) => { 17 | const hashesHeader = ` 18 | 19 | 20 | Hash 21 | Version 22 | Origin 23 | Download 24 | 25 | 26 | `; 27 | 28 | const hashes = record.violations 29 | .map(v => { 30 | return ` 31 | 32 | ${v.hash} 33 | ${v.version} 34 | ${v.origin} 35 | 36 | 40 | 45 | 46 | 47 | 48 | `; 49 | }) 50 | .join(''); 51 | 52 | const violationsTable = 53 | hashes.length > 0 54 | ? ` 55 | 56 | 57 | 58 | ${hashesHeader} 59 | ${hashes} 60 |
61 | 62 | 63 | 64 | ` 65 | : ''; 66 | 67 | const expandCell = 68 | record.violations.length > 0 69 | ? ` 70 | 74 | ▶ Show (${record.violations.length}) 75 | 76 | ` 77 | : ` 78 | - 79 | `; 80 | 81 | const currentTabClass = 82 | currentTabID === tabID ? ' class="current-tab"' : ''; 83 | 84 | return ` 85 | 86 | ${record.url} 87 | ${new Date(record.creationTime).toLocaleString()} 88 | ${tabID} 89 | ${expandCell} 90 | 91 | ${violationsTable} 92 | `; 93 | }); 94 | 95 | this.innerHTML = ` 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | ${rows.join('')} 105 |
URLTimeTab IDViolations
106 | `; 107 | 108 | document.querySelectorAll('.download_button').forEach(img => { 109 | img.addEventListener('click', () => { 110 | const tabID = img.getAttribute('data-tab-id')!; 111 | const hash = img.getAttribute('data-hash')!; 112 | downloadHashSource(tabID, hash); 113 | }); 114 | }); 115 | 116 | document.querySelectorAll('[data-expand-src]').forEach(expand => { 117 | const tabID = expand.getAttribute('data-expand-src')!; 118 | expand.addEventListener('click', () => { 119 | const subtable = document.querySelector( 120 | `[data-expand-target="${tabID}"]`, 121 | )!; 122 | subtable.classList.toggle('expanded'); 123 | expand.innerHTML = subtable.classList.contains('expanded') 124 | ? `▼ Hide` 125 | : `▶ Show (${expand.getAttribute('data-violation-count')})`; 126 | }); 127 | }); 128 | }); 129 | } 130 | } 131 | 132 | customElements.define('violation-list', ViolationList); 133 | -------------------------------------------------------------------------------- /_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "about_code_verify_faq_url_msgr": { 3 | "message": "https://www.messenger.com/help/799550494558955" 4 | }, 5 | "about_code_verify_faq_url_wa": { 6 | "message": "https://faq.whatsapp.com/web/security-and-privacy/about-code-verify" 7 | }, 8 | "about_code_verify_faq_url_fb": { 9 | "message": "https://www.facebook.com/help/728172628487328" 10 | }, 11 | "about_code_verify_faq_url_ig": { 12 | "message": "https://help.instagram.com/198632106142837" 13 | }, 14 | "validation_failure_faq_url_msgr": { 15 | "message": "https://www.messenger.com/help/799550494558955?ref=learn_more#validationfailure" 16 | }, 17 | "validation_failure_faq_url_wa": { 18 | "message": "https://faq.whatsapp.com/web/security-and-privacy/why-am-i-seeing-a-validation-failure-warning" 19 | }, 20 | "validation_failure_faq_url_fb": { 21 | "message": "https://www.facebook.com/help/728172628487328?ref=learn_more#validationfailure" 22 | }, 23 | "validation_failure_faq_url_ig": { 24 | "message": "https://help.instagram.com/198632106142837?ref=learn_more#validationfailure" 25 | }, 26 | "possible_risk_detected_faq_url_msgr": { 27 | "message": "https://www.messenger.com/help/799550494558955?ref=learn_more#possibleriskdetected" 28 | }, 29 | "possible_risk_detected_faq_url_wa": { 30 | "message": "https://faq.whatsapp.com/web/security-and-privacy/why-am-i-seeing-a-possible-risk-detected-warning" 31 | }, 32 | "possible_risk_detected_faq_url_fb": { 33 | "message": "https://www.facebook.com/help/728172628487328?ref=learn_more#possibleriskdetected" 34 | }, 35 | "possible_risk_detected_faq_url_ig": { 36 | "message": "https://help.instagram.com/198632106142837?ref=learn_more#possibleriskdetected" 37 | }, 38 | "network_timeout_faq_url_msgr": { 39 | "message": "https://www.messenger.com/help/799550494558955?ref=learn_more#networktimedout" 40 | }, 41 | "network_timeout_faq_url_wa": { 42 | "message": "https://faq.whatsapp.com/web/security-and-privacy/why-am-i-seeing-a-network-timeout-error" 43 | }, 44 | "network_timeout_faq_url_fb": { 45 | "message": "https://www.facebook.com/help/728172628487328?ref=learn_more#networktimedout" 46 | }, 47 | "network_timeout_faq_url_ig": { 48 | "message": "https://help.instagram.com/198632106142837?ref=learn_more#networktimedout" 49 | }, 50 | "i18nValidatedStatusHeader": { 51 | "message": "Validated" 52 | }, 53 | "i18nValidatedStatusMessage": { 54 | "message": "Web page code verified." 55 | }, 56 | "i18nCheckingStatusHeader": { 57 | "message": "Checking..." 58 | }, 59 | "i18nRiskDetectedStatusHeader": { 60 | "message": "Possible Risk Detected" 61 | }, 62 | "i18nRiskDetectedStatusMessage": { 63 | "message": "Cannot validate the page due to another browser extension. Consider pausing the other extension(s) and re-trying." 64 | }, 65 | "i18nRiskDetectedLearnMoreButton": { 66 | "message": "Learn More" 67 | }, 68 | "i18nRiskDetectedRetryButton": { 69 | "message": "Retry" 70 | }, 71 | "i18nNetworkTimeoutStatusHeader": { 72 | "message": "Network Timed Out" 73 | }, 74 | "i18nNetworkTimeoutStatusMessage": { 75 | "message": "Unable to validate this page." 76 | }, 77 | "i18nTimeoutLearnMoreButton": { 78 | "message": "Learn More" 79 | }, 80 | "i18nTimeoutRetryButton": { 81 | "message": "Retry" 82 | }, 83 | "i18nValidationFailureStatusHeader": { 84 | "message": "Validation Failure" 85 | }, 86 | "i18nValidationFailureStatusMessage": { 87 | "message": "The source code on this page doesn't match what was sent to other users. Download the source code to examine why the page was not validated." 88 | }, 89 | "i18nAnomalyLearnMoreButton": { 90 | "message": "Learn More" 91 | }, 92 | "i18nTopLevel": { 93 | "message": "Meta Code Verify" 94 | }, 95 | "i18nTopLevelLearnMore": { 96 | "message": "Learn More" 97 | }, 98 | "i18ndownloadSourcePrompt": { 99 | "message": "Download Page Source Code" 100 | }, 101 | "i18ndownloadReleaseSourcePrompt": { 102 | "message": "Download Release Source Code" 103 | }, 104 | "i18nDownloadPrompt": { 105 | "message": "Download" 106 | }, 107 | "i18ndownloadSourceDescription": { 108 | "message": "Download a .zip file containing all running JavaScript on the page." 109 | }, 110 | "i18nDownloadSourceButton": { 111 | "message": "Download" 112 | }, 113 | "i18nViewAllViolations": { 114 | "message": "View All Violations" 115 | }, 116 | "i18nViolations": { 117 | "message": "All Violations" 118 | }, 119 | "i18nCurrentViolations": { 120 | "message": "View Violations" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/js/background/historyManager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import type {Origin} from '../config'; 9 | 10 | const HISTORY_TTL_MSEC = 120 * 86400 * 1000; // 120 Days 11 | 12 | type Violation = { 13 | origin: Origin; 14 | version: string; 15 | hash: string; 16 | }; 17 | 18 | const TAB_TO_VIOLATING_SRCS = new Map>(); 19 | 20 | export async function getRecords(): Promise< 21 | Array< 22 | [string, {creationTime: number; violations: Array; url: string}] 23 | > 24 | > { 25 | return Object.entries(await chrome.storage.local.get(null)); 26 | } 27 | 28 | export async function downloadHashSource( 29 | tabID: string, 30 | hash: string, 31 | ): Promise { 32 | const opfsRoot = await navigator.storage.getDirectory(); 33 | const dir = await opfsRoot.getDirectoryHandle(tabID); 34 | const fileHandle = await dir.getFileHandle(hash); 35 | const file = await fileHandle.getFile(); 36 | 37 | const decompressedStream = file 38 | .stream() 39 | .pipeThrough(new DecompressionStream('gzip')); 40 | 41 | const fileName = `${tabID}-${hash}.txt`; 42 | 43 | if ('showSaveFilePicker' in window) { 44 | const fileHandle = await window.showSaveFilePicker({ 45 | suggestedName: fileName, 46 | }); 47 | const writable = await fileHandle.createWritable(); 48 | await decompressedStream.pipeTo(writable); 49 | } else { 50 | const src = await new Response(decompressedStream).blob(); 51 | const url = URL.createObjectURL(src); 52 | const a = document.createElement('a'); 53 | a.href = url; 54 | a.download = fileName; 55 | document.body.appendChild(a); 56 | a.click(); 57 | document.body.removeChild(a); 58 | URL.revokeObjectURL(url); 59 | } 60 | } 61 | 62 | export async function upsertInvalidRecord( 63 | tabID: number, 64 | creationTime: number, 65 | ): Promise { 66 | const tab = await chrome.tabs.get(tabID); 67 | const tabIDKey = String(tabID); 68 | const entry = await chrome.storage.local.get(tabIDKey); 69 | 70 | const violations = TAB_TO_VIOLATING_SRCS.get(tabID) ?? new Set(); 71 | 72 | if (Object.keys(entry).length !== 0) { 73 | creationTime = entry[tabIDKey].creationTime; 74 | entry[tabIDKey].violations.forEach((violation: Violation) => { 75 | violations.add(violation); 76 | }); 77 | } 78 | 79 | return chrome.storage.local.set({ 80 | [tabIDKey]: { 81 | creationTime, 82 | violations: Array.from(violations), 83 | url: tab.url, 84 | }, 85 | }); 86 | } 87 | 88 | export async function trackViolationForTab( 89 | tabId: number, 90 | rawSource: string, 91 | origin: Origin, 92 | version: string, 93 | hash: string, 94 | ) { 95 | if (!TAB_TO_VIOLATING_SRCS.has(tabId)) { 96 | TAB_TO_VIOLATING_SRCS.set(tabId, new Set()); 97 | } 98 | TAB_TO_VIOLATING_SRCS.get(tabId)?.add({ 99 | origin, 100 | version, 101 | hash, 102 | }); 103 | 104 | const opfsRoot = await navigator.storage.getDirectory(); 105 | // Make a directory for this tabId 106 | const dir = await opfsRoot.getDirectoryHandle(String(tabId), {create: true}); 107 | // Create one file per violating src keyed by the hash, gzip the contents 108 | const file = await dir.getFileHandle(hash, {create: true}); 109 | const writable = await file.createWritable(); 110 | const encoder = new TextEncoder(); 111 | const uint8 = encoder.encode(rawSource); 112 | const stream = new Blob([uint8]).stream(); 113 | const compressedStream = stream.pipeThrough(new CompressionStream('gzip')); 114 | 115 | await compressedStream.pipeTo(writable); 116 | } 117 | 118 | export async function setUpHistoryCleaner(): Promise { 119 | const now = Date.now(); 120 | 121 | const entriesToDelete = (await getRecords()) 122 | .filter(([_keys, entry]) => now - entry.creationTime >= HISTORY_TTL_MSEC) 123 | .map(entry => entry[0]); 124 | 125 | if (entriesToDelete.length > 0) { 126 | const opfsRoot = await navigator.storage.getDirectory(); 127 | await chrome.storage.local.remove(entriesToDelete); 128 | console.log('Removing entries from extension storage', entriesToDelete); 129 | entriesToDelete.forEach(async key => { 130 | await opfsRoot.removeEntry(key, {recursive: true}).then(() => { 131 | console.log(`Removed hashes for entry ${key}`); 132 | }); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/html/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 16 | 25 | 34 | 45 | 51 | 114 |
115 | 116 |
117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /src/js/background/tab_state_tracker/TabStateMachine.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import { 9 | MESSAGE_TYPE, 10 | Origin, 11 | State, 12 | STATES, 13 | STATES_TO_ICONS, 14 | ORIGIN_HOST, 15 | } from '../../config'; 16 | 17 | import StateMachine from './StateMachine'; 18 | import FrameStateMachine from './FrameStateMachine'; 19 | import {upsertInvalidRecord} from '../historyManager'; 20 | import {sendMessageToBackground} from '../../shared/sendMessageToBackground'; 21 | 22 | function getChromeV3Action() { 23 | if (self.chrome.runtime.getManifest().manifest_version >= 3) { 24 | return self.chrome.action; 25 | } else { 26 | return { 27 | setIcon: (_: chrome.pageAction.IconDetails) => 28 | self.chrome.pageAction.setIcon(_), 29 | disable: (tabId: number) => self.chrome.pageAction.hide?.(tabId), 30 | enable: (tabId: number) => self.chrome.pageAction.show?.(tabId), 31 | setPopup: (_: chrome.pageAction.PopupDetails) => 32 | self.chrome.pageAction.setPopup(_), 33 | }; 34 | } 35 | } 36 | 37 | /** 38 | * Tracks the extension's state based on the states of the individual frames 39 | * in it. 40 | */ 41 | export default class TabStateMachine extends StateMachine { 42 | private _tabId: number; 43 | private _origin: Origin; 44 | private _frameStates: {[key: number]: FrameStateMachine}; 45 | private _creationTime: number; 46 | 47 | constructor(tabId: number, origin: Origin) { 48 | super(); 49 | this._tabId = tabId; 50 | this._origin = origin; 51 | this._creationTime = Date.now(); 52 | this._frameStates = {}; 53 | } 54 | 55 | addFrameStateMachine(frameId: number) { 56 | this._frameStates[frameId] = new FrameStateMachine(this); 57 | } 58 | 59 | updateStateForFrame(frameId: number, newState: State): void { 60 | if (!(frameId in this._frameStates)) { 61 | this._frameStates[frameId].updateStateIfValid('INVALID'); 62 | throw new Error( 63 | `State machine for frame: ${frameId} does not exist for tab: ${this._tabId}`, 64 | ); 65 | } 66 | this._frameStates[frameId].updateStateIfValid(newState); 67 | } 68 | 69 | updateStateIfValid(newState: State) { 70 | // Only update the tab's state to VALID if all of it's frames are VALID or just starting 71 | if ( 72 | newState === STATES.VALID && 73 | !Object.values(this._frameStates).every( 74 | fsm => 75 | fsm.getState() === STATES.VALID || fsm.getState() === STATES.START, 76 | ) 77 | ) { 78 | return; 79 | } 80 | if (newState === STATES.INVALID) { 81 | upsertInvalidRecord(this._tabId, this._creationTime); 82 | } 83 | super.updateStateIfValid(newState); 84 | } 85 | 86 | onStateUpdated() { 87 | const state = this.getState(); 88 | const chromeAction = getChromeV3Action(); 89 | chromeAction.setIcon({ 90 | tabId: this._tabId, 91 | path: STATES_TO_ICONS[state], 92 | }); 93 | chromeAction.enable(this._tabId); 94 | chromeAction.setPopup({ 95 | tabId: this._tabId, 96 | popup: `popup.html?tab_id=${this._tabId}&state=${state}&origin=${this._origin}`, 97 | }); 98 | // Broadcast state update for relevant popup to update its contents. 99 | sendMessageToBackground( 100 | { 101 | type: MESSAGE_TYPE.STATE_UPDATED, 102 | tabId: this._tabId, 103 | state, 104 | }, 105 | () => { 106 | /** 107 | * The following suppresses an error that is thrown when we try to 108 | * send this message to popup.js before it's listener is set up. 109 | * 110 | * For more details on how this suppresses the error: 111 | * See: https://stackoverflow.com/questions/28431505/unchecked-runtime-lasterror-when-using-chrome-api/28432087#28432087 112 | */ 113 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions 114 | chrome.runtime.lastError && chrome.runtime.lastError.message; 115 | }, 116 | ); 117 | if (state === STATES.IGNORE) { 118 | this.genDisableTab(); 119 | } 120 | } 121 | 122 | private async genDisableTab(): Promise { 123 | const tab = await chrome.tabs.get(this._tabId); 124 | if (tab.url) { 125 | const host = new URL(tab.url).hostname.replace('www.', ''); 126 | if (Object.values(ORIGIN_HOST).includes(host)) { 127 | this.updateStateIfValid(STATES.INVALID); 128 | return; 129 | } 130 | } 131 | getChromeV3Action().disable(this._tabId); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/js/__tests__/checkWorkerEndpointCSP-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | 'use strict'; 9 | 10 | import {jest} from '@jest/globals'; 11 | import { 12 | areBlobAndDataExcluded, 13 | isWorkerSrcValid, 14 | isWorkerEndpointCSPValid, 15 | } from '../content/checkWorkerEndpointCSP'; 16 | import {ORIGIN_HOST, ORIGIN_TYPE} from '../config'; 17 | import {setCurrentOrigin} from '../content/updateCurrentState'; 18 | 19 | const CSP_KEY = 'content-security-policy'; 20 | const CSPRO_KEY = 'content-security-policy-report-only'; 21 | 22 | describe('checkWorkerEndpointCSP', () => { 23 | beforeEach(() => { 24 | window.chrome.runtime.sendMessage = jest.fn(() => {}); 25 | setCurrentOrigin('FACEBOOK'); 26 | }); 27 | it('Invalid if no CSP headers on Worker script', () => { 28 | const [valid] = isWorkerEndpointCSPValid( 29 | { 30 | responseHeaders: [], 31 | }, // Missing headers 32 | [new Set()], 33 | ORIGIN_TYPE.FACEBOOK, 34 | ); 35 | expect(valid).toBeFalsy(); 36 | }); 37 | it('Invalid if empty CSP headers on Worker script', () => { 38 | const [valid] = isWorkerEndpointCSPValid( 39 | { 40 | responseHeaders: [ 41 | {name: CSP_KEY, value: ''}, 42 | {name: CSPRO_KEY, value: ''}, 43 | ], 44 | }, 45 | [new Set()], 46 | ORIGIN_TYPE.FACEBOOK, 47 | ); 48 | expect(valid).toBeFalsy(); 49 | }); 50 | 51 | describe('areBlobAndDataExcluded', () => { 52 | it('Invalid if blob: allowed by script src', () => { 53 | const valid = areBlobAndDataExcluded([ 54 | `default-src 'self' *.facebook.com *.fbcdn.net 'wasm-unsafe-eval';` + 55 | `script-src *.facebook.com *.fbcdn.net 'self' blob: 'wasm-unsafe-eval';`, 56 | ]); 57 | expect(valid).toBeFalsy(); 58 | }); 59 | it('Invalid if data: allowed by script src', () => { 60 | const valid = areBlobAndDataExcluded([ 61 | `default-src 'self' *.facebook.com *.fbcdn.net 'wasm-unsafe-eval';` + 62 | `script-src *.facebook.com *.fbcdn.net 'self' data: 'wasm-unsafe-eval';`, 63 | ]); 64 | expect(valid).toBeFalsy(); 65 | }); 66 | it('Invalid if blob: allowed by default src', () => { 67 | const valid = areBlobAndDataExcluded([ 68 | `default-src blob: 'self' *.facebook.com *.fbcdn.net 'wasm-unsafe-eval';`, 69 | ]); 70 | expect(valid).toBeFalsy(); 71 | }); 72 | it('Invalid if data: allowed by default src', () => { 73 | const valid = areBlobAndDataExcluded([ 74 | `default-src data: 'self' *.facebook.com *.fbcdn.net 'wasm-unsafe-eval';`, 75 | ]); 76 | expect(valid).toBeFalsy(); 77 | }); 78 | }); 79 | 80 | describe('isWorkerSrcValid', () => { 81 | it('Valid CSP, same url nested workers and different origins', () => { 82 | const valid = isWorkerSrcValid( 83 | ['worker-src *.facebook.com/worker_url *.instagram.com;'], 84 | ORIGIN_HOST[ORIGIN_TYPE.FACEBOOK], 85 | [new Set(['*.facebook.com/worker_url'])], 86 | ); 87 | expect(valid).toBeTruthy(); 88 | }); 89 | it('Valid CSP, nested workers (non-exact match)', () => { 90 | const valid = isWorkerSrcValid( 91 | ['worker-src *.facebook.com/worker_url/;'], 92 | ORIGIN_HOST[ORIGIN_TYPE.FACEBOOK], 93 | [new Set(['*.facebook.com/worker_url/'])], 94 | ); 95 | expect(valid).toBeTruthy(); 96 | }); 97 | it('Valid CSP, subpath nested workers', () => { 98 | const valid = isWorkerSrcValid( 99 | [ 100 | 'worker-src *.facebook.com/worker_url/first *.facebook.com/worker_url/second;', 101 | ], 102 | ORIGIN_HOST[ORIGIN_TYPE.FACEBOOK], 103 | [new Set(['*.facebook.com/worker_url/'])], 104 | ); 105 | expect(valid).toBeTruthy(); 106 | }); 107 | 108 | it('Invalid CSP, subpath nested workers (exact match needed)', () => { 109 | const valid = isWorkerSrcValid( 110 | [ 111 | 'worker-src *.facebook.com/worker_url/first *.facebook.com/worker_url/second;', 112 | ], 113 | ORIGIN_HOST[ORIGIN_TYPE.FACEBOOK], 114 | [new Set(['*.facebook.com/worker_url'])], 115 | ); 116 | expect(valid).toBeFalsy(); 117 | }); 118 | it('Invalid CSP, different paths', () => { 119 | const valid = isWorkerSrcValid( 120 | [ 121 | 'worker-src *.facebook.com/wrong_worker_url *.facebook.com/worker_url;', 122 | ], 123 | ORIGIN_HOST[ORIGIN_TYPE.FACEBOOK], 124 | [new Set(['*.facebook.com/worker_url'])], 125 | ); 126 | expect(valid).toBeFalsy(); 127 | }); 128 | it('Invalid CSP, nested worker allowing domain wide urls', () => { 129 | const valid = isWorkerSrcValid( 130 | ['worker-src *.facebook.com/;'], 131 | ORIGIN_HOST[ORIGIN_TYPE.FACEBOOK], 132 | [new Set(['*.facebook.com/worker_url'])], 133 | ); 134 | expect(valid).toBeFalsy(); 135 | }); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /src/js/content/checkWorkerEndpointCSP.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {Origin, ORIGIN_HOST} from '../config'; 9 | import {getCSPHeadersFromWebRequestResponse} from '../shared/getCSPHeadersFromWebRequestResponse'; 10 | import {checkCSPForEvals} from './checkCSPForEvals'; 11 | import {doesWorkerUrlConformToCSP} from './doesWorkerUrlConformToCSP'; 12 | import {parseCSPString} from './parseCSPString'; 13 | import {invalidateAndThrow} from './updateCurrentState'; 14 | 15 | /** 16 | * Dedicated Workers can nest workers, we need to check their CSPs. 17 | * 18 | * worker-src CSP inside a worker should conform to atleast 19 | * one of the worker-src CSPs on the main document which 20 | * have already been validated, otherwise worker can spin 21 | * up arbitrary workers or blob:/data:. 22 | */ 23 | export function isWorkerSrcValid( 24 | cspHeaders: string[], 25 | host: string, 26 | documentWorkerCSPs: Set[], 27 | ): boolean { 28 | return cspHeaders.some(header => { 29 | const allowedWorkers = parseCSPString(header).get('worker-src'); 30 | 31 | if (allowedWorkers) { 32 | /** 33 | * Filter out worker-src that aren't same origin because of the bellow bug 34 | * This is safe to do since workers MUST be same-origin by definition 35 | * https://bugzilla.mozilla.org/show_bug.cgi?id=1847548&fbclid=IwAR3qIyYr5K92_Cw3UJmgtSbgBKwZ5bLppP6LNwN6lC-kQVEdxr_52zeQUPE 36 | */ 37 | const allowedWorkersToCheck = Array.from(allowedWorkers.values()).filter( 38 | worker => worker.includes('.' + host) || worker.startsWith(host), 39 | ); 40 | 41 | return documentWorkerCSPs.some(documentWorkerValues => { 42 | return allowedWorkersToCheck.every( 43 | workerSrcValue => 44 | doesWorkerUrlConformToCSP(documentWorkerValues, workerSrcValue) || 45 | documentWorkerValues.has(workerSrcValue), 46 | ); 47 | }); 48 | } 49 | return false; 50 | }); 51 | } 52 | 53 | /** 54 | * Check script-src for blob: data: 55 | * Workers can call importScripts/import on arbitrary strings. 56 | * This CSP should be in place to prevent that. 57 | */ 58 | export function areBlobAndDataExcluded(cspHeaders: string[]): boolean { 59 | const [hasValidScriptSrcEnforcement, hasScriptSrcDirectiveForEnforce] = 60 | getIsValidScriptSrcAndHasScriptSrcDirective(cspHeaders); 61 | if (hasValidScriptSrcEnforcement) { 62 | return true; 63 | } 64 | 65 | if (!hasScriptSrcDirectiveForEnforce) { 66 | if (getIsValidDefaultSrc(cspHeaders)) { 67 | return true; 68 | } 69 | } 70 | 71 | return false; 72 | } 73 | 74 | /** 75 | * This function should not have side-effects (no throw, no invalidation). 76 | * See checkWorkerEndpointCSP for enforcement. 77 | */ 78 | export function isWorkerEndpointCSPValid( 79 | response: chrome.webRequest.OnResponseStartedDetails, 80 | documentWorkerCSPs: Array>, 81 | origin: Origin, 82 | ): [true] | [false, string] { 83 | const host = ORIGIN_HOST[origin]; 84 | const cspHeaders = getCSPHeadersFromWebRequestResponse(response) 85 | .map(h => h.value) 86 | .filter((header): header is string => !!header); 87 | 88 | const cspReportHeaders = getCSPHeadersFromWebRequestResponse(response, true) 89 | .map(h => h.value) 90 | .filter((header): header is string => !!header); 91 | 92 | const [evalIsValid, evalReason] = checkCSPForEvals( 93 | cspHeaders, 94 | cspReportHeaders, 95 | ); 96 | if (!evalIsValid) { 97 | return [false, evalReason]; 98 | } 99 | 100 | if (!isWorkerSrcValid(cspHeaders, host, documentWorkerCSPs)) { 101 | return [ 102 | false, 103 | 'Nested worker-src does not conform to document worker-src CSP', 104 | ]; 105 | } 106 | 107 | if (!areBlobAndDataExcluded(cspHeaders)) { 108 | return [false, 'Worker allows blob:/data: importScripts/import']; 109 | } 110 | 111 | return [true]; 112 | } 113 | 114 | export function checkWorkerEndpointCSP( 115 | response: chrome.webRequest.OnResponseStartedDetails, 116 | documentWorkerCSPs: Array>, 117 | origin: Origin, 118 | ): void { 119 | const [valid, reason] = isWorkerEndpointCSPValid( 120 | response, 121 | documentWorkerCSPs, 122 | origin, 123 | ); 124 | if (!valid) { 125 | invalidateAndThrow(reason); 126 | } 127 | } 128 | 129 | function cspValuesExcludeBlobAndData(cspValues: Set): boolean { 130 | return !cspValues.has('blob:') && !cspValues.has('data:'); 131 | } 132 | 133 | function getIsValidDefaultSrc(cspHeaders: Array): boolean { 134 | return cspHeaders.some(cspHeader => { 135 | const cspMap = parseCSPString(cspHeader); 136 | const defaultSrc = cspMap.get('default-src'); 137 | if (!cspMap.has('script-src') && defaultSrc) { 138 | if (cspValuesExcludeBlobAndData(defaultSrc)) { 139 | return true; 140 | } 141 | } 142 | return false; 143 | }); 144 | } 145 | 146 | function getIsValidScriptSrcAndHasScriptSrcDirective( 147 | cspHeaders: Array, 148 | ): [boolean, boolean] { 149 | let hasScriptSrcDirective = false; 150 | const isValidScriptSrc = cspHeaders.some(cspHeader => { 151 | const cspMap = parseCSPString(cspHeader); 152 | const scriptSrc = cspMap.get('script-src'); 153 | if (scriptSrc) { 154 | hasScriptSrcDirective = true; 155 | if (cspValuesExcludeBlobAndData(scriptSrc)) { 156 | return true; 157 | } 158 | } 159 | return false; 160 | }); 161 | return [isValidScriptSrc, hasScriptSrcDirective]; 162 | } 163 | -------------------------------------------------------------------------------- /src/js/content/checkCSPForEvals.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {STATES} from '../config'; 9 | import alertBackgroundOfImminentFetch from './alertBackgroundOfImminentFetch'; 10 | import {parseCSPString} from './parseCSPString'; 11 | import {updateCurrentState} from './updateCurrentState'; 12 | 13 | function scanForCSPEvalReportViolations(): void { 14 | document.addEventListener('securitypolicyviolation', e => { 15 | // Older Browser can't distinguish between 'eval' and 'wasm-eval' violations 16 | // We need to check if there is an eval violation 17 | if (e.blockedURI !== 'eval') { 18 | return; 19 | } 20 | 21 | if (e.disposition === 'enforce') { 22 | return; 23 | } 24 | 25 | alertBackgroundOfImminentFetch(e.sourceFile).then(() => { 26 | fetch(e.sourceFile) 27 | .then(response => response.text()) 28 | .then(code => { 29 | const violatingLine = code.split(/\r?\n/)[e.lineNumber - 1]; 30 | if ( 31 | violatingLine.includes('WebAssembly') && 32 | !violatingLine.includes('eval(') && 33 | !violatingLine.includes('Function(') && 34 | !violatingLine.includes("setTimeout('") && 35 | !violatingLine.includes("setInterval('") && 36 | !violatingLine.includes('setTimeout("') && 37 | !violatingLine.includes('setInterval("') 38 | ) { 39 | return; 40 | } 41 | updateCurrentState(STATES.INVALID, `Caught eval in ${e.sourceFile}`); 42 | }); 43 | }); 44 | }); 45 | } 46 | 47 | function getIsValidDefaultSrc(cspHeaders: Array): boolean { 48 | return cspHeaders.some(cspHeader => { 49 | const cspMap = parseCSPString(cspHeader); 50 | const defaultSrc = cspMap.get('default-src'); 51 | const scriptSrc = cspMap.get('script-src'); 52 | if (!scriptSrc && defaultSrc) { 53 | if (!defaultSrc.has("'unsafe-eval'")) { 54 | return true; 55 | } 56 | } 57 | return false; 58 | }); 59 | } 60 | 61 | function getIsValidScriptSrcAndHasScriptSrcDirective( 62 | cspHeaders: Array, 63 | ): [boolean, boolean] { 64 | let hasScriptSrcDirective = false; 65 | const isValidScriptSrc = cspHeaders.some(cspHeader => { 66 | const cspMap = parseCSPString(cspHeader); 67 | const scriptSrc = cspMap.get('script-src'); 68 | if (scriptSrc) { 69 | hasScriptSrcDirective = true; 70 | if (!scriptSrc.has("'unsafe-eval'")) { 71 | return true; 72 | } 73 | } 74 | return false; 75 | }); 76 | return [isValidScriptSrc, hasScriptSrcDirective]; 77 | } 78 | 79 | export function checkCSPForEvals( 80 | cspHeaders: Array, 81 | cspReportHeaders: Array | undefined, 82 | ): [true] | [false, string] { 83 | const [hasValidScriptSrcEnforcement, hasScriptSrcDirectiveForEnforce] = 84 | getIsValidScriptSrcAndHasScriptSrcDirective(cspHeaders); 85 | 86 | // 1. This means that at least one CSP-header declaration has a script-src 87 | // directive that has no `unsafe-eval` keyword. This means the browser will 88 | // enforce unsafe eval for us. 89 | if (hasValidScriptSrcEnforcement) { 90 | return [true]; 91 | } 92 | 93 | // 2. If we have no script-src directives, the browser will fall back to 94 | // default-src. If at least one declaration has a default-src directive 95 | // with no `unsafe-eval`, the browser will enforce for us. 96 | if (!hasScriptSrcDirectiveForEnforce) { 97 | if (getIsValidDefaultSrc(cspHeaders)) { 98 | return [true]; 99 | } 100 | } 101 | 102 | // If we've gotten this far, it either means something is invalid, or this is 103 | // an older browser. We want to execute WASM, but still prevent unsafe-eval. 104 | // Newer browsers support the wasm-unsafe-eval keyword for this purpose, but 105 | // for older browsers we need to hack around this. 106 | 107 | // The technique we're using here involves setting report-only headers that 108 | // match the rules we checked above, but for enforce headers. These will not 109 | // cause the page to break, but will emit events that we can listen for in 110 | // scanForCSPEvalReportViolations. 111 | 112 | // 3. Thus, if we've gotten this far and we have no report headers, the page 113 | // should be considered invalid. 114 | if (!cspReportHeaders || cspReportHeaders.length === 0) { 115 | return [false, 'Missing CSP report-only header']; 116 | } 117 | 118 | // Check if at least one header has the correct report setup 119 | // If CSP is not reporting on evals we cannot catch them via event listeners 120 | const [hasValidScriptSrcReport, hasScriptSrcDirectiveForReport] = 121 | getIsValidScriptSrcAndHasScriptSrcDirective(cspReportHeaders); 122 | 123 | let hasValidDefaultSrcReport = false; 124 | if (!hasScriptSrcDirectiveForReport) { 125 | hasValidDefaultSrcReport = getIsValidDefaultSrc(cspReportHeaders); 126 | } 127 | 128 | // 4. If neither 129 | // a. We have at least one script-src without unsafe eval. 130 | // b. We have no script-src, and at least one default-src without unsafe-eval 131 | // Then we must invalidate because there is nothing preventing unsafe-eval. 132 | if (!hasValidScriptSrcReport && !hasValidDefaultSrcReport) { 133 | return [false, 'Missing unsafe-eval from CSP report-only header']; 134 | } 135 | 136 | // 5. If we've gotten here without throwing, we can start scanning for violations 137 | // from our report-only headers. 138 | scanForCSPEvalReportViolations(); 139 | return [true]; 140 | } 141 | -------------------------------------------------------------------------------- /images/loading-header.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /images/error-header.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /images/validated-header.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /images/warning-header.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | /* 9 | * For a detailed explanation regarding each configuration property, visit: 10 | * https://jestjs.io/docs/configuration 11 | */ 12 | 13 | export default { 14 | // All imported modules in your tests should be mocked automatically 15 | // automock: false, 16 | 17 | // Stop running tests after `n` failures 18 | // bail: 0, 19 | 20 | // The directory where Jest should store its cached dependency information 21 | // cacheDirectory: "/tmp/rrh/jest_2fus", 22 | 23 | // Automatically clear mock calls and instances between every test 24 | clearMocks: true, 25 | 26 | // Indicates whether the coverage information should be collected while executing the test 27 | // collectCoverage: false, 28 | 29 | // An array of glob patterns indicating a set of files for which coverage information should be collected 30 | // collectCoverageFrom: undefined, 31 | 32 | // The directory where Jest should output its coverage files 33 | // coverageDirectory: undefined, 34 | 35 | // An array of regexp pattern strings used to skip coverage collection 36 | // coveragePathIgnorePatterns: [ 37 | // "/node_modules/" 38 | // ], 39 | 40 | // Indicates which provider should be used to instrument code for coverage 41 | coverageProvider: 'v8', 42 | 43 | // A list of reporter names that Jest uses when writing coverage reports 44 | // coverageReporters: [ 45 | // "json", 46 | // "text", 47 | // "lcov", 48 | // "clover" 49 | // ], 50 | 51 | // An object that configures minimum threshold enforcement for coverage results 52 | // coverageThreshold: undefined, 53 | 54 | // A path to a custom dependency extractor 55 | // dependencyExtractor: undefined, 56 | 57 | // Make calling deprecated APIs throw helpful error messages 58 | // errorOnDeprecated: false, 59 | 60 | // Force coverage collection from ignored files using an array of glob patterns 61 | // forceCoverageMatch: [], 62 | 63 | // A path to a module which exports an async function that is triggered once before all test suites 64 | // globalSetup: undefined, 65 | 66 | // A path to a module which exports an async function that is triggered once after all test suites 67 | // globalTeardown: undefined, 68 | 69 | // A set of global variables that need to be available in all test environments 70 | // globals: {}, 71 | 72 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 73 | // maxWorkers: "50%", 74 | 75 | // An array of directory names to be searched recursively up from the requiring module's location 76 | // moduleDirectories: [ 77 | // "node_modules" 78 | // ], 79 | 80 | // An array of file extensions your modules use 81 | // moduleFileExtensions: [ 82 | // "js", 83 | // "jsx", 84 | // "ts", 85 | // "tsx", 86 | // "json", 87 | // "node" 88 | // ], 89 | 90 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 91 | // moduleNameMapper: {}, 92 | 93 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 94 | // modulePathIgnorePatterns: [], 95 | 96 | // Activates notifications for test results 97 | // notify: false, 98 | 99 | // An enum that specifies notification mode. Requires { notify: true } 100 | // notifyMode: "failure-change", 101 | 102 | // A preset that is used as a base for Jest's configuration 103 | preset: 'ts-jest', 104 | 105 | // Run tests from one or more projects 106 | // projects: undefined, 107 | 108 | // Use this configuration option to add custom reporters to Jest 109 | // reporters: undefined, 110 | 111 | // Automatically reset mock state between every test 112 | // resetMocks: false, 113 | 114 | // Reset the module registry before running each individual test 115 | // resetModules: false, 116 | 117 | // A path to a custom resolver 118 | // resolver: undefined, 119 | 120 | // Automatically restore mock state between every test 121 | // restoreMocks: false, 122 | 123 | // The root directory that Jest should scan for tests and modules within 124 | // rootDir: undefined, 125 | 126 | // A list of paths to directories that Jest should use to search for files in 127 | // roots: [ 128 | // "" 129 | // ], 130 | 131 | // Allows you to use a custom runner instead of Jest's default test runner 132 | // runner: "jest-runner", 133 | 134 | // The paths to modules that run some code to configure or set up the testing environment before each test 135 | // setupFiles: [], 136 | 137 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 138 | setupFilesAfterEnv: ['./jest.setup.js'], 139 | 140 | // The number of seconds after which a test is considered as slow and reported as such in the results. 141 | // slowTestThreshold: 5, 142 | 143 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 144 | // snapshotSerializers: [], 145 | 146 | // The test environment that will be used for testing 147 | testEnvironment: 'jsdom', 148 | 149 | // Options that will be passed to the testEnvironment 150 | // testEnvironmentOptions: {}, 151 | 152 | // Adds a location field to test results 153 | // testLocationInResults: false, 154 | 155 | // The glob patterns Jest uses to detect test files 156 | // testMatch: [ 157 | // "**/__tests__/**/*.[jt]s?(x)", 158 | // "**/?(*.)+(spec|test).[tj]s?(x)" 159 | // ], 160 | 161 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 162 | // testPathIgnorePatterns: [ 163 | // "/node_modules/" 164 | // ], 165 | 166 | // The regexp pattern or array of patterns that Jest uses to detect test files 167 | // testRegex: [], 168 | 169 | // This option allows the use of a custom results processor 170 | // testResultsProcessor: undefined, 171 | 172 | // This option allows use of a custom test runner 173 | // testRunner: "jest-circus/runner", 174 | 175 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 176 | // testURL: "http://localhost", 177 | 178 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 179 | 180 | // A map from regular expressions to paths to transformers 181 | transform: {}, 182 | 183 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 184 | // transformIgnorePatterns: [ 185 | // "/node_modules/", 186 | // "\\.pnp\\.[^\\/]+$" 187 | // ], 188 | 189 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 190 | // unmockedModulePathPatterns: undefined, 191 | 192 | // Indicates whether each individual test should be reported during the run 193 | // verbose: undefined, 194 | 195 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 196 | // watchPathIgnorePatterns: [], 197 | 198 | // Whether to use watchman for file crawling 199 | // watchman: true, 200 | }; 201 | -------------------------------------------------------------------------------- /src/js/__tests__/content-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | 'use strict'; 9 | 10 | import {jest} from '@jest/globals'; 11 | import {MESSAGE_TYPE} from '../config'; 12 | import { 13 | hasInvalidScriptsOrStyles, 14 | scanForScriptsAndStyles, 15 | FOUND_ELEMENTS, 16 | storeFoundElement, 17 | UNINITIALIZED, 18 | } from '../content'; 19 | import {setCurrentOrigin} from '../content/updateCurrentState'; 20 | 21 | describe('content', () => { 22 | beforeEach(() => { 23 | window.chrome.runtime.sendMessage = jest.fn(() => {}); 24 | setCurrentOrigin('FACEBOOK'); 25 | FOUND_ELEMENTS.clear(); 26 | FOUND_ELEMENTS.set(UNINITIALIZED, []); 27 | }); 28 | describe('storeFoundElement', () => { 29 | it('should handle scripts with src correctly', () => { 30 | const fakeUrl = 'https://fancytestingyouhere.com/'; 31 | const fakeScriptNode = { 32 | src: fakeUrl, 33 | getAttribute: () => { 34 | return '123_main'; 35 | }, 36 | nodeName: 'SCRIPT', 37 | }; 38 | storeFoundElement(fakeScriptNode); 39 | expect(FOUND_ELEMENTS.get('123').length).toEqual(1); 40 | expect(FOUND_ELEMENTS.get('123')[0].src).toEqual(fakeUrl); 41 | expect(window.chrome.runtime.sendMessage.mock.calls.length).toBe(1); 42 | }); 43 | it('should send update icon message if valid', () => { 44 | const fakeUrl = 'https://fancytestingyouhere.com/'; 45 | const fakeScriptNode = { 46 | src: fakeUrl, 47 | getAttribute: () => { 48 | return '123_main'; 49 | }, 50 | nodeName: 'SCRIPT', 51 | }; 52 | storeFoundElement(fakeScriptNode); 53 | const sentMessage = window.chrome.runtime.sendMessage.mock.calls[0][0]; 54 | expect(window.chrome.runtime.sendMessage.mock.calls.length).toBe(1); 55 | expect(sentMessage.type).toEqual(MESSAGE_TYPE.UPDATE_STATE); 56 | }); 57 | it.skip('storeFoundElement keeps existing icon if not valid', () => { 58 | // TODO: come back to this after testing processFoundJS 59 | }); 60 | }); 61 | describe('hasInvalidScriptsOrStyles', () => { 62 | it('should not check for non-HTMLElements', () => { 63 | const fakeElement = { 64 | nodeType: 2, 65 | tagName: 'tagName', 66 | }; 67 | hasInvalidScriptsOrStyles(fakeElement); 68 | expect(window.chrome.runtime.sendMessage.mock.calls.length).toBe(0); 69 | }); 70 | it('should store any script elements we find', () => { 71 | const fakeElement = { 72 | getAttribute: () => { 73 | return '123_main'; 74 | }, 75 | childNodes: [], 76 | nodeName: 'SCRIPT', 77 | nodeType: 1, 78 | tagName: 'tagName', 79 | src: 'fakeurl', 80 | }; 81 | hasInvalidScriptsOrStyles(fakeElement); 82 | expect(FOUND_ELEMENTS.get('123').length).toBe(1); 83 | expect(window.chrome.runtime.sendMessage.mock.calls.length).toBe(1); 84 | expect(window.chrome.runtime.sendMessage.mock.calls[0][0].type).toBe( 85 | MESSAGE_TYPE.UPDATE_STATE, 86 | ); 87 | }); 88 | it('should check all child nodes for non script elements', () => { 89 | const fakeElement = { 90 | childNodes: [ 91 | { 92 | getAttribute: () => { 93 | return 'attr'; 94 | }, 95 | nodeType: 2, 96 | nodeName: 'nodename', 97 | tagName: 'tagName', 98 | }, 99 | { 100 | getAttribute: () => { 101 | return 'attr'; 102 | }, 103 | nodeType: 3, 104 | nodeName: 'nodename', 105 | tagName: 'tagName', 106 | }, 107 | ], 108 | getAttribute: () => { 109 | return 'attr'; 110 | }, 111 | nodeType: 1, 112 | nodeName: 'nodename', 113 | tagName: 'tagName', 114 | }; 115 | hasInvalidScriptsOrStyles(fakeElement); 116 | expect(FOUND_ELEMENTS.get(UNINITIALIZED).length).toBe(0); 117 | expect(window.chrome.runtime.sendMessage.mock.calls.length).toBe(0); 118 | }); 119 | it('should store any script element direct children', () => { 120 | const fakeElement = { 121 | childNodes: [ 122 | { 123 | getAttribute: () => { 124 | return 'attr'; 125 | }, 126 | nodeType: 2, 127 | nodeName: 'nodename', 128 | childNodes: [], 129 | tagName: 'tagName', 130 | }, 131 | { 132 | getAttribute: () => { 133 | return '123_main'; 134 | }, 135 | nodeName: 'SCRIPT', 136 | nodeType: 1, 137 | childNodes: [], 138 | tagName: 'tagName', 139 | src: 'fakeUrl', 140 | }, 141 | ], 142 | getAttribute: () => { 143 | return null; 144 | }, 145 | nodeType: 1, 146 | nodeName: 'nodename', 147 | tagName: 'tagName', 148 | }; 149 | hasInvalidScriptsOrStyles(fakeElement); 150 | expect(FOUND_ELEMENTS.get('123').length).toBe(1); 151 | expect(window.chrome.runtime.sendMessage.mock.calls.length).toBe(1); 152 | expect(window.chrome.runtime.sendMessage.mock.calls[0][0].type).toBe( 153 | MESSAGE_TYPE.UPDATE_STATE, 154 | ); 155 | }); 156 | it('should check for any grandchildren script elements', () => { 157 | const fakeElement = { 158 | childNodes: [ 159 | { 160 | getAttribute: () => { 161 | return 'attr'; 162 | }, 163 | nodeType: 2, 164 | nodeName: 'nodename', 165 | childNodes: [], 166 | tagName: 'tagName', 167 | }, 168 | { 169 | childNodes: [ 170 | { 171 | nodeName: 'script', 172 | nodeType: 1, 173 | getAttribute: () => { 174 | return '123_main'; 175 | }, 176 | tagName: 'tagName', 177 | }, 178 | { 179 | getAttribute: () => { 180 | return '123_longtail'; 181 | }, 182 | nodeName: 'script', 183 | nodeType: 1, 184 | tagName: 'tagName', 185 | }, 186 | ], 187 | getAttribute: () => { 188 | return null; 189 | }, 190 | nodeType: 1, 191 | nodeName: 'nodename', 192 | tagName: 'tagName', 193 | }, 194 | ], 195 | getAttribute: () => { 196 | return null; 197 | }, 198 | nodeType: 1, 199 | nodeName: 'nodename', 200 | tagName: 'tagName', 201 | }; 202 | hasInvalidScriptsOrStyles(fakeElement); 203 | expect(FOUND_ELEMENTS.get('123').length).toBe(2); 204 | expect(window.chrome.runtime.sendMessage.mock.calls.length).toBe(2); 205 | }); 206 | }); 207 | describe('scanForScriptsAndStyles', () => { 208 | it('should find existing script tags in the DOM and check them', () => { 209 | jest.resetModules(); 210 | document.body.innerHTML = 211 | '
' + 212 | ' ' + 213 | '
'; 214 | scanForScriptsAndStyles(); 215 | expect(window.chrome.runtime.sendMessage.mock.calls.length).toBe(1); 216 | }); 217 | }); 218 | }); 219 | -------------------------------------------------------------------------------- /src/js/background.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import './globals'; 9 | 10 | import {DYNAMIC_STRING_MARKER, Origin, STATES} from './config'; 11 | import {MESSAGE_TYPE, ORIGIN_HOST} from './config'; 12 | 13 | import { 14 | recordContentScriptStart, 15 | updateContentScriptState, 16 | } from './background/tab_state_tracker/tabStateTracker'; 17 | import setupCSPListener from './background/setupCSPListener'; 18 | import setUpWebRequestsListener from './background/setUpWebRequestsListener'; 19 | import {validateMetaCompanyManifest} from './background/validateMetaCompanyManifest'; 20 | import {validateSender} from './background/validateSender'; 21 | import {removeDynamicStrings} from './background/removeDynamicStrings'; 22 | import {MessagePayload, MessageResponse} from './shared/MessageTypes'; 23 | import {setOrUpdateSetInMap} from './shared/nestedDataHelpers'; 24 | import { 25 | setUpHistoryCleaner, 26 | trackViolationForTab, 27 | } from './background/historyManager'; 28 | 29 | const MANIFEST_CACHE = new Map>(); 30 | 31 | // TabID -> FrameID -> Array 32 | // There might be multiple CSP policy headers per response 33 | export type CSPHeaderMap = Map>>; 34 | const CSP_HEADERS: CSPHeaderMap = new Map(); 35 | const CSP_REPORT_HEADERS: CSPHeaderMap = new Map(); 36 | 37 | // Keeps track of scripts `fetch`-ed by the extension to ensure they are all 38 | // resolved from browser cache 39 | const CACHED_SCRIPTS_URLS = new Map>(); 40 | 41 | type Manifest = { 42 | root: string; 43 | leaves: Array; 44 | }; 45 | 46 | function getManifestMapForOrigin(origin: Origin): Map { 47 | // store manifest to subsequently validate JS/CSS 48 | if (!MANIFEST_CACHE.has(origin)) { 49 | MANIFEST_CACHE.set(origin, new Map()); 50 | } 51 | return MANIFEST_CACHE.get(origin)!; 52 | } 53 | 54 | function logReceivedMessage( 55 | message: MessagePayload, 56 | sender: chrome.runtime.MessageSender, 57 | ): void { 58 | let logger = console.log; 59 | switch (message.type) { 60 | case MESSAGE_TYPE.UPDATE_STATE: 61 | if (message.state === STATES.INVALID) { 62 | logger = console.error; 63 | } else if (message.state === STATES.PROCESSING) { 64 | logger = () => {}; 65 | } 66 | break; 67 | case MESSAGE_TYPE.DEBUG: 68 | logger = console.debug; 69 | break; 70 | } 71 | if (sender.tab) { 72 | logger(`handleMessages from tab:${sender.tab.id}`, message); 73 | } else { 74 | logger(`handleMessages from unknown tab`, message); 75 | } 76 | } 77 | 78 | function handleMessages( 79 | message: MessagePayload, 80 | sender: chrome.runtime.MessageSender, 81 | sendResponse: (_: MessageResponse) => void, 82 | ): void | boolean { 83 | logReceivedMessage(message, sender); 84 | const validSender = validateSender(sender); 85 | 86 | // There are niche reasons we might receive messages from an unexpected 87 | // sender (see validateSender method for details). Our own messages should 88 | // never fall into this category, so we can simply ignore them. 89 | if (!validSender) { 90 | return; 91 | } 92 | 93 | switch (message.type) { 94 | // Log only 95 | case MESSAGE_TYPE.DEBUG: 96 | return; 97 | 98 | // Log only 99 | case MESSAGE_TYPE.STATE_UPDATED: 100 | return; 101 | 102 | case MESSAGE_TYPE.LOAD_COMPANY_MANIFEST: { 103 | validateMetaCompanyManifest( 104 | message.rootHash, 105 | message.otherHashes, 106 | message.leaves, 107 | ORIGIN_HOST[message.origin], 108 | message.version, 109 | ).then(validationResult => { 110 | if (validationResult.valid) { 111 | const manifestMap = getManifestMapForOrigin(message.origin); 112 | const manifest = manifestMap.get(message.version) ?? { 113 | leaves: [], 114 | root: message.rootHash, 115 | }; 116 | if (!manifestMap.has(message.version)) { 117 | manifestMap.set(message.version, manifest); 118 | } 119 | message.leaves.forEach(leaf => { 120 | if (!manifest.leaves.includes(leaf)) { 121 | manifest.leaves.push(leaf); 122 | } 123 | }); 124 | sendResponse({valid: true}); 125 | } else { 126 | sendResponse(validationResult); 127 | } 128 | }); 129 | 130 | // Indicates that the message will send an async response. 131 | return true; 132 | } 133 | 134 | case MESSAGE_TYPE.RAW_SRC: { 135 | const origin = MANIFEST_CACHE.get(message.origin); 136 | if (!origin) { 137 | sendResponse({valid: false, reason: 'no matching origin'}); 138 | return; 139 | } 140 | const manifestObj = origin.get(message.version); 141 | const manifest = manifestObj && manifestObj.leaves; 142 | if (!manifest) { 143 | sendResponse({valid: false, reason: 'no matching manifest'}); 144 | return; 145 | } 146 | 147 | if (message.pkgRaw.includes(DYNAMIC_STRING_MARKER)) { 148 | try { 149 | message.pkgRaw = removeDynamicStrings(message.pkgRaw); 150 | } catch { 151 | sendResponse({valid: false, reason: 'failed parsing AST'}); 152 | return; 153 | } 154 | } 155 | 156 | // fetch the src 157 | const encoder = new TextEncoder(); 158 | const encodedSrc = encoder.encode(message.pkgRaw); 159 | // hash the src 160 | crypto.subtle.digest('SHA-256', encodedSrc).then(hashBuffer => { 161 | const hashArray = Array.from(new Uint8Array(hashBuffer)); 162 | const hash = hashArray 163 | .map(b => b.toString(16).padStart(2, '0')) 164 | .join(''); 165 | 166 | if (manifestObj.leaves.includes(hash)) { 167 | sendResponse({valid: true, hash: hash}); 168 | } else { 169 | trackViolationForTab( 170 | validSender.tab.id, 171 | message.pkgRaw, 172 | message.origin, 173 | message.version, 174 | hash, 175 | ); 176 | sendResponse({ 177 | valid: false, 178 | hash: hash, 179 | reason: 180 | 'Error: hash does not match ' + 181 | message.origin + 182 | ', ' + 183 | message.version + 184 | ', unmatched SRC is ' + 185 | message.pkgRaw, 186 | }); 187 | } 188 | }); 189 | 190 | // Indicates that the message will send an async response. 191 | return true; 192 | } 193 | 194 | case MESSAGE_TYPE.UPDATE_STATE: { 195 | updateContentScriptState(validSender, message.state, message.origin); 196 | sendResponse({success: true}); 197 | return; 198 | } 199 | 200 | case MESSAGE_TYPE.CONTENT_SCRIPT_START: { 201 | recordContentScriptStart(validSender, message.origin); 202 | 203 | sendResponse({ 204 | success: true, 205 | cspHeaders: CSP_HEADERS.get(validSender.tab.id)?.get( 206 | validSender.frameId, 207 | ), 208 | cspReportHeaders: CSP_REPORT_HEADERS?.get(validSender.tab.id)?.get( 209 | validSender.frameId, 210 | ), 211 | }); 212 | 213 | return; 214 | } 215 | 216 | case MESSAGE_TYPE.UPDATED_CACHED_SCRIPT_URLS: { 217 | setOrUpdateSetInMap(CACHED_SCRIPTS_URLS, validSender.tab.id, message.url); 218 | sendResponse({success: true}); 219 | return true; 220 | } 221 | 222 | default: { 223 | // See: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#exhaustiveness-checking 224 | const _exhaustiveCheck: never = message; 225 | return _exhaustiveCheck; 226 | } 227 | } 228 | } 229 | 230 | chrome.runtime.onMessage.addListener(handleMessages); 231 | 232 | setUpHistoryCleaner(); 233 | setupCSPListener(CSP_HEADERS, CSP_REPORT_HEADERS); 234 | setUpWebRequestsListener(CACHED_SCRIPTS_URLS); 235 | 236 | // Emulate PageActions 237 | chrome.runtime.onInstalled.addListener(() => { 238 | if (chrome.runtime.getManifest().manifest_version >= 3) { 239 | chrome.action.disable(); 240 | } 241 | }); 242 | -------------------------------------------------------------------------------- /src/js/popup/popup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import '../globals'; 9 | 10 | import type {Origin, State} from '../config'; 11 | import {MESSAGE_TYPE, ORIGIN_TYPE, STATES} from '../config.js'; 12 | 13 | import './violation-list'; 14 | 15 | type PopupState = 16 | | 'loading' 17 | | 'warning_risk' 18 | | 'warning_timeout' 19 | | 'error' 20 | | 'valid' 21 | | 'menu' 22 | | 'download' 23 | | 'violation_list'; 24 | 25 | const STATE_TO_POPUP_STATE: Record = { 26 | [STATES.START]: 'loading', 27 | [STATES.PROCESSING]: 'loading', 28 | [STATES.IGNORE]: 'loading', 29 | [STATES.INVALID]: 'error', 30 | [STATES.RISK]: 'warning_risk', 31 | [STATES.VALID]: 'valid', 32 | [STATES.TIMEOUT]: 'warning_timeout', 33 | }; 34 | 35 | const ORIGIN_TO_LEARN_MORE_PAGES: Record> = { 36 | [ORIGIN_TYPE.FACEBOOK]: { 37 | about: chrome.i18n.getMessage('about_code_verify_faq_url_fb'), 38 | error: chrome.i18n.getMessage('validation_failure_faq_url_fb'), 39 | warning_risk: chrome.i18n.getMessage('possible_risk_detected_faq_url_fb'), 40 | warning_timeout: chrome.i18n.getMessage('network_timeout_faq_url_fb'), 41 | }, 42 | [ORIGIN_TYPE.MESSENGER]: { 43 | about: chrome.i18n.getMessage('about_code_verify_faq_url_msgr'), 44 | error: chrome.i18n.getMessage('validation_failure_faq_url_msgr'), 45 | warning_risk: chrome.i18n.getMessage('possible_risk_detected_faq_url_msgr'), 46 | warning_timeout: chrome.i18n.getMessage('network_timeout_faq_url_msgr'), 47 | }, 48 | [ORIGIN_TYPE.WHATSAPP]: { 49 | about: chrome.i18n.getMessage('about_code_verify_faq_url_wa'), 50 | error: chrome.i18n.getMessage('validation_failure_faq_url_wa'), 51 | warning_risk: chrome.i18n.getMessage('possible_risk_detected_faq_url_wa'), 52 | warning_timeout: chrome.i18n.getMessage('network_timeout_faq_url_wa'), 53 | }, 54 | [ORIGIN_TYPE.INSTAGRAM]: { 55 | about: chrome.i18n.getMessage('about_code_verify_faq_url_ig'), 56 | error: chrome.i18n.getMessage('validation_failure_faq_url_ig'), 57 | warning_risk: chrome.i18n.getMessage('possible_risk_detected_faq_url_ig'), 58 | warning_timeout: chrome.i18n.getMessage('network_timeout_faq_url_ig'), 59 | }, 60 | }; 61 | 62 | // doing this so we can add support for i18n using messages.json 63 | function attachTextToHtml(): void { 64 | const i18nElements = document.querySelectorAll(`[id^="i18n"]`); 65 | Array.from(i18nElements).forEach(element => { 66 | element.innerHTML = chrome.i18n.getMessage(element.id); 67 | }); 68 | } 69 | 70 | function attachMenuListeners(origin: Origin): void { 71 | document 72 | .getElementById('close_button') 73 | ?.addEventListener('click', () => window.close()); 74 | 75 | const menuRows = document.getElementsByClassName('menu_row'); 76 | 77 | menuRows[0].addEventListener('click', () => { 78 | chrome.tabs.create({url: ORIGIN_TO_LEARN_MORE_PAGES[origin].about}); 79 | }); 80 | 81 | menuRows[1].addEventListener('click', () => { 82 | updateDisplay('violation_list'); 83 | }); 84 | 85 | menuRows[2].addEventListener('click', () => updateDisplay('download')); 86 | 87 | menuRows[3].addEventListener('click', () => { 88 | sendMessageToActiveTab('downloadReleaseSource'); 89 | }); 90 | } 91 | 92 | function updateDisplay(state: State | PopupState): void { 93 | const popupState: PopupState = 94 | state in STATE_TO_POPUP_STATE 95 | ? STATE_TO_POPUP_STATE[state as State] 96 | : (state as PopupState); 97 | Array.from(document.getElementsByClassName('state_boundary')).forEach( 98 | (element: Element) => { 99 | if (element instanceof HTMLElement) { 100 | if (element.id == popupState) { 101 | element.style.display = 'block'; 102 | } else { 103 | element.style.display = 'none'; 104 | } 105 | } 106 | }, 107 | ); 108 | } 109 | 110 | function setUpBackgroundMessageHandler(tabId: string | null): void { 111 | if (tabId == null || tabId.trim() === '') { 112 | console.error('[Popup] No tab_id query param', document.location); 113 | return; 114 | } 115 | chrome.runtime.onMessage.addListener(message => { 116 | if (!('type' in message)) { 117 | return; 118 | } 119 | if ( 120 | message.type === MESSAGE_TYPE.STATE_UPDATED && 121 | message.tabId.toString() === tabId 122 | ) { 123 | updateDisplay(message.state); 124 | } 125 | }); 126 | } 127 | 128 | function sendMessageToActiveTab(message: string): void { 129 | chrome.tabs.query({active: true, currentWindow: true}, function (tabs) { 130 | const tabId = tabs[0].id; 131 | if (tabId) { 132 | chrome.tabs.sendMessage(tabId, {greeting: message}, () => {}); 133 | } 134 | }); 135 | } 136 | 137 | class PopupHeader extends HTMLElement { 138 | static observedAttributes = ['header-message']; 139 | 140 | connectedCallback() { 141 | const headerMessage = this.getAttribute('header-message'); 142 | this.innerHTML = ` 143 |
144 | 145 | 146 | ${ 147 | headerMessage 148 | ? ` 149 |

150 | ${chrome.i18n.getMessage(headerMessage)} 151 |

152 | ` 153 | : '' 154 | } 155 |
156 | 157 | 162 | 163 |
164 | `; 165 | 166 | document.querySelectorAll('.menu_button')?.forEach(menuButton => { 167 | menuButton.addEventListener('click', () => { 168 | updateDisplay('menu'); 169 | }); 170 | }); 171 | } 172 | } 173 | 174 | customElements.define('popup-header', PopupHeader); 175 | 176 | class StateElement extends HTMLElement { 177 | static observedAttributes = [ 178 | 'inner-id', 179 | 'type', 180 | 'status-header', 181 | 'status-message', 182 | 'secondary-button-id', 183 | 'primary-button-id', 184 | 'primary-button-action', 185 | 'secondary-button-id', 186 | 'secondary-button-action', 187 | 'tertiary-button-id', 188 | 'tertiary-button-action', 189 | 'header-message', 190 | ]; 191 | 192 | connectedCallback() { 193 | const type = this.getAttribute('type'); 194 | const innerId = this.getAttribute('inner-id')!; 195 | const headerMessage = this.getAttribute('header-message'); 196 | const statusHeader = this.getAttribute('status-header'); 197 | const statusMessage = this.getAttribute('status-message'); 198 | const primaryButtonId = this.getAttribute('primary-button-id'); 199 | const secondaryButtonId = this.getAttribute('secondary-button-id'); 200 | const tertiaryButtonId = this.getAttribute('tertiary-button-id'); 201 | const primaryButtonAction = this.getAttribute('primary-button-action'); 202 | const secondaryButtonAction = this.getAttribute('secondary-button-action'); 203 | const tertiaryButtonAction = this.getAttribute('tertiary-button-action'); 204 | const primaryButton = primaryButtonId 205 | ? `` 209 | : ''; 210 | const secondaryButton = secondaryButtonId 211 | ? `` 215 | : ''; 216 | const tertiaryButton = tertiaryButtonId 217 | ? `` 221 | : ''; 222 | const actionBar = 223 | primaryButton || secondaryButton 224 | ? `
225 | ${secondaryButton} 226 | ${tertiaryButton} 227 | ${primaryButton} 228 |
` 229 | : ''; 230 | this.innerHTML = ` 231 |
232 | 233 |
234 | ${ 235 | type 236 | ? `` 240 | : '' 241 | } 242 | ${ 243 | statusHeader 244 | ? `
` 245 | : '' 246 | } 247 | ${ 248 | statusMessage 249 | ? `
` 250 | : '' 251 | } 252 | ${actionBar} 253 |
254 |
255 | `; 256 | 257 | if (primaryButtonAction != null && primaryButtonId != null) { 258 | const button = document.getElementById(primaryButtonId); 259 | handleButtonAction(button!, primaryButtonAction, innerId); 260 | } 261 | if (secondaryButtonAction != null && secondaryButtonId != null) { 262 | const button = document.getElementById(secondaryButtonId); 263 | handleButtonAction(button!, secondaryButtonAction, innerId); 264 | } 265 | if (tertiaryButtonAction != null && tertiaryButtonId != null) { 266 | const button = document.getElementById(tertiaryButtonId); 267 | handleButtonAction(button!, tertiaryButtonAction, innerId); 268 | } 269 | } 270 | } 271 | 272 | const handleButtonAction = ( 273 | button: HTMLElement, 274 | action: string, 275 | id: string, 276 | ) => { 277 | button?.addEventListener('click', () => { 278 | if (action === 'retry') { 279 | chrome.tabs.reload(); 280 | } else if (action === 'learn_more') { 281 | chrome.tabs.create({ 282 | url: ORIGIN_TO_LEARN_MORE_PAGES[currentOrigin][id], 283 | }); 284 | } else if (action === 'download') { 285 | sendMessageToActiveTab('downloadSource'); 286 | } else if (action === 'violations_list') { 287 | updateDisplay('violation_list'); 288 | } 289 | }); 290 | }; 291 | 292 | customElements.define('state-element', StateElement); 293 | 294 | let currentOrigin: Origin; 295 | 296 | (function (): void { 297 | const params = new URL(document.location.href).searchParams; 298 | setUpBackgroundMessageHandler(params.get('tab_id')); 299 | const state = params.get('state') as State; 300 | updateDisplay(state); 301 | attachTextToHtml(); 302 | currentOrigin = params.get('origin') as Origin; 303 | attachMenuListeners(currentOrigin); 304 | })(); 305 | --------------------------------------------------------------------------------