diff --git a/src/js/__tests__/content-test.js b/src/js/__tests__/content-test.js index 9d1059c..2eb486f 100644 --- a/src/js/__tests__/content-test.js +++ b/src/js/__tests__/content-test.js @@ -10,10 +10,10 @@ import {jest} from '@jest/globals'; import {MESSAGE_TYPE} from '../config'; import { - hasInvalidScripts, - scanForScripts, - FOUND_SCRIPTS, - storeFoundJS, + hasInvalidScriptsOrStyles, + scanForScriptsAndStyles, + FOUND_ELEMENTS, + storeFoundElement, UNINITIALIZED, } from '../content'; import {setCurrentOrigin} from '../content/updateCurrentState'; @@ -22,10 +22,10 @@ describe('content', () => { beforeEach(() => { window.chrome.runtime.sendMessage = jest.fn(() => {}); setCurrentOrigin('FACEBOOK'); - FOUND_SCRIPTS.clear(); - FOUND_SCRIPTS.set(UNINITIALIZED, []); + FOUND_ELEMENTS.clear(); + FOUND_ELEMENTS.set(UNINITIALIZED, []); }); - describe('storeFoundJS', () => { + describe('storeFoundElement', () => { it('should handle scripts with src correctly', () => { const fakeUrl = 'https://fancytestingyouhere.com/'; const fakeScriptNode = { @@ -33,10 +33,11 @@ describe('content', () => { getAttribute: () => { return '123_main'; }, + nodeName: 'SCRIPT', }; - storeFoundJS(fakeScriptNode); - expect(FOUND_SCRIPTS.get('123').length).toEqual(1); - expect(FOUND_SCRIPTS.get('123')[0].src).toEqual(fakeUrl); + storeFoundElement(fakeScriptNode); + expect(FOUND_ELEMENTS.get('123').length).toEqual(1); + expect(FOUND_ELEMENTS.get('123')[0].src).toEqual(fakeUrl); expect(window.chrome.runtime.sendMessage.mock.calls.length).toBe(1); }); it('should send update icon message if valid', () => { @@ -46,23 +47,24 @@ describe('content', () => { getAttribute: () => { return '123_main'; }, + nodeName: 'SCRIPT', }; - storeFoundJS(fakeScriptNode); + storeFoundElement(fakeScriptNode); const sentMessage = window.chrome.runtime.sendMessage.mock.calls[0][0]; expect(window.chrome.runtime.sendMessage.mock.calls.length).toBe(1); expect(sentMessage.type).toEqual(MESSAGE_TYPE.UPDATE_STATE); }); - it.skip('storeFoundJS keeps existing icon if not valid', () => { + it.skip('storeFoundElement keeps existing icon if not valid', () => { // TODO: come back to this after testing processFoundJS }); }); - describe('hasInvalidScripts', () => { + describe('hasInvalidScriptsOrStyles', () => { it('should not check for non-HTMLElements', () => { const fakeElement = { nodeType: 2, tagName: 'tagName', }; - hasInvalidScripts(fakeElement); + hasInvalidScriptsOrStyles(fakeElement); expect(window.chrome.runtime.sendMessage.mock.calls.length).toBe(0); }); it('should store any script elements we find', () => { @@ -74,10 +76,10 @@ describe('content', () => { nodeName: 'SCRIPT', nodeType: 1, tagName: 'tagName', - src: '', + src: 'fakeurl', }; - hasInvalidScripts(fakeElement); - expect(FOUND_SCRIPTS.get('123').length).toBe(1); + hasInvalidScriptsOrStyles(fakeElement); + expect(FOUND_ELEMENTS.get('123').length).toBe(1); expect(window.chrome.runtime.sendMessage.mock.calls.length).toBe(1); expect(window.chrome.runtime.sendMessage.mock.calls[0][0].type).toBe( MESSAGE_TYPE.UPDATE_STATE, @@ -110,8 +112,8 @@ describe('content', () => { nodeName: 'nodename', tagName: 'tagName', }; - hasInvalidScripts(fakeElement); - expect(FOUND_SCRIPTS.get(UNINITIALIZED).length).toBe(0); + hasInvalidScriptsOrStyles(fakeElement); + expect(FOUND_ELEMENTS.get(UNINITIALIZED).length).toBe(0); expect(window.chrome.runtime.sendMessage.mock.calls.length).toBe(0); }); it('should store any script element direct children', () => { @@ -134,7 +136,7 @@ describe('content', () => { nodeType: 1, childNodes: [], tagName: 'tagName', - src: '', + src: 'fakeUrl', }, ], getAttribute: () => { @@ -144,8 +146,8 @@ describe('content', () => { nodeName: 'nodename', tagName: 'tagName', }; - hasInvalidScripts(fakeElement); - expect(FOUND_SCRIPTS.get('123').length).toBe(1); + hasInvalidScriptsOrStyles(fakeElement); + expect(FOUND_ELEMENTS.get('123').length).toBe(1); expect(window.chrome.runtime.sendMessage.mock.calls.length).toBe(1); expect(window.chrome.runtime.sendMessage.mock.calls[0][0].type).toBe( MESSAGE_TYPE.UPDATE_STATE, @@ -197,19 +199,19 @@ describe('content', () => { nodeName: 'nodename', tagName: 'tagName', }; - hasInvalidScripts(fakeElement); - expect(FOUND_SCRIPTS.get('123').length).toBe(2); + hasInvalidScriptsOrStyles(fakeElement); + expect(FOUND_ELEMENTS.get('123').length).toBe(2); expect(window.chrome.runtime.sendMessage.mock.calls.length).toBe(2); }); }); - describe('scanForScripts', () => { + describe('scanForScriptsAndStyles', () => { it('should find existing script tags in the DOM and check them', () => { jest.resetModules(); document.body.innerHTML = '
' + ' ' + '
'; - scanForScripts(); + scanForScriptsAndStyles(); expect(window.chrome.runtime.sendMessage.mock.calls.length).toBe(1); }); }); diff --git a/src/js/__tests__/removeDynamicStrings-test.js b/src/js/__tests__/removeDynamicStrings-test.js index 9018e72..56d3481 100644 --- a/src/js/__tests__/removeDynamicStrings-test.js +++ b/src/js/__tests__/removeDynamicStrings-test.js @@ -19,9 +19,9 @@ describe('removeDynamicStrings', () => { ).toEqual(`const foo = /*BTDS*/"";`); }); it('Handles empty strings', () => { - expect( - removeDynamicStrings(`const foo = /*BTDS*/'';`), - ).toEqual(`const foo = /*BTDS*/'';`); + expect(removeDynamicStrings(`const foo = /*BTDS*/'';`)).toEqual( + `const foo = /*BTDS*/'';`, + ); }); it('Handles strings in different scenarios', () => { expect(removeDynamicStrings(`/*BTDS*/'dynamic string';`)).toEqual( diff --git a/src/js/background.ts b/src/js/background.ts index f0e1ec9..4c316a9 100644 --- a/src/js/background.ts +++ b/src/js/background.ts @@ -39,7 +39,7 @@ type Manifest = { }; function getManifestMapForOrigin(origin: Origin): Map { - // store manifest to subsequently validate JS + // store manifest to subsequently validate JS/CSS let manifestMap = MANIFEST_CACHE.get(origin); if (manifestMap == null) { manifestMap = new Map(); @@ -137,7 +137,7 @@ function handleMessages( return true; } - case MESSAGE_TYPE.RAW_JS: { + case MESSAGE_TYPE.RAW_SRC: { const origin = MANIFEST_CACHE.get(message.origin); if (!origin) { sendResponse({valid: false, reason: 'no matching origin'}); @@ -150,9 +150,9 @@ function handleMessages( return; } - if (message.rawjs.includes(DYNAMIC_STRING_MARKER)) { + if (message.pkgRaw.includes(DYNAMIC_STRING_MARKER)) { try { - message.rawjs = removeDynamicStrings(message.rawjs); + message.pkgRaw = removeDynamicStrings(message.pkgRaw); } catch (e) { sendResponse({valid: false, reason: 'failed parsing AST'}); return; @@ -161,27 +161,27 @@ function handleMessages( // fetch the src const encoder = new TextEncoder(); - const encodedJS = encoder.encode(message.rawjs); + const encodedSrc = encoder.encode(message.pkgRaw); // hash the src - crypto.subtle.digest('SHA-256', encodedJS).then(jsHashBuffer => { - const jsHashArray = Array.from(new Uint8Array(jsHashBuffer)); - const jsHash = jsHashArray + crypto.subtle.digest('SHA-256', encodedSrc).then(hashBuffer => { + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hash = hashArray .map(b => b.toString(16).padStart(2, '0')) .join(''); - if (manifestObj.leaves.includes(jsHash)) { - sendResponse({valid: true, hash: jsHash}); + if (manifestObj.leaves.includes(hash)) { + sendResponse({valid: true, hash: hash}); } else { sendResponse({ valid: false, - hash: jsHash, + hash: hash, reason: 'Error: hash does not match ' + message.origin + ', ' + message.version + - ', unmatched JS is ' + - message.rawjs, + ', unmatched SRC is ' + + message.pkgRaw, }); } }); diff --git a/src/js/config.ts b/src/js/config.ts index 2dc1ec8..38d7ef5 100644 --- a/src/js/config.ts +++ b/src/js/config.ts @@ -55,7 +55,7 @@ export const MESSAGE_TYPE = Object.freeze({ DEBUG: 'DEBUG', LOAD_COMPANY_MANIFEST: 'LOAD_COMPANY_MANIFEST', POPUP_STATE: 'POPUP_STATE', - RAW_JS: 'RAW_JS', + RAW_SRC: 'RAW_SRC', UPDATE_STATE: 'UPDATE_STATE', STATE_UPDATED: 'STATE_UPDATED', CONTENT_SCRIPT_START: 'CONTENT_SCRIPT_START', diff --git a/src/js/content.ts b/src/js/content.ts index 696da0d..57ac887 100644 --- a/src/js/content.ts +++ b/src/js/content.ts @@ -32,10 +32,16 @@ import {checkWorkerEndpointCSP} from './content/checkWorkerEndpointCSP'; import {MessagePayload} from './shared/MessageTypes'; import {pushToOrCreateArrayInMap} from './shared/nestedDataHelpers'; import ensureManifestWasOrWillBeLoaded from './content/ensureManifestWasOrWillBeLoaded'; -import scanForCSS from './content/scanForCSS'; -import {downloadJS, processJSWithSrc} from './content/contentJSUtils'; +import {downloadSrc, processSrc} from './content/contentUtils'; import {hasVaryServiceWorkerHeader} from './content/hasVaryServiceWorkerHeader'; import {isSameDomainAsTopWindow, isTopWindow} from './content/iFrameUtils'; +import {getTagIdentifier} from './content/getTagIdentifier'; +import { + BOTH, + getManifestVersionAndTypeFromNode, + tryToGetManifestVersionAndTypeFromNode, +} from './content/getManifestVersionAndTypeFromNode'; +import {scanForCSSNeedingManualInspsection} from './content/manualCSSInspector'; type ContentScriptConfig = { checkLoggedInFromCookie: boolean; @@ -45,20 +51,31 @@ type ContentScriptConfig = { let originConfig: ContentScriptConfig | null = null; export const UNINITIALIZED = 'UNINITIALIZED'; -const BOTH = 'BOTH'; let currentFilterType = UNINITIALIZED; -// Map> -export const FOUND_SCRIPTS = new Map>([ +// Map> +export const FOUND_ELEMENTS = new Map>([ [UNINITIALIZED, []], ]); -const ALL_FOUND_SCRIPT_TAGS = new Set(); +const ALL_FOUND_TAGS_URLS = new Set(); const FOUND_MANIFEST_VERSIONS = new Set(); -export type ScriptDetails = { - otherType: string; - src: string; - isServiceWorker?: boolean; -}; +export type TagDetails = + | { + otherType: string; + src: string; + isServiceWorker?: boolean; + type: 'script'; + } + | { + otherType: string; + href: string; + type: 'link'; + } + | { + otherType: string; + tag: HTMLStyleElement; + type: 'style'; + }; let manifestTimeoutID: string | number = ''; export type RawManifestOtherHashes = { @@ -142,21 +159,23 @@ function handleManifestNode(manifestNode: HTMLScriptElement): void { // now that we know the actual version of the scripts, transfer the ones we know about. // also set the correct manifest type, "otherType" for already collected scripts - const foundScriptsWithoutVersion = FOUND_SCRIPTS.get(UNINITIALIZED); - if (foundScriptsWithoutVersion) { - const scriptsWithUpdatedType = foundScriptsWithoutVersion.map(script => ({ - ...script, - otherType: currentFilterType, - })); - - FOUND_SCRIPTS.set(version, [ - ...scriptsWithUpdatedType, - ...(FOUND_SCRIPTS.get(version) ?? []), + const foundElementsWithoutVersion = FOUND_ELEMENTS.get(UNINITIALIZED); + if (foundElementsWithoutVersion) { + const elementsWithUpdatedType = foundElementsWithoutVersion.map( + element => ({ + ...element, + otherType: currentFilterType, + }), + ); + + FOUND_ELEMENTS.set(version, [ + ...elementsWithUpdatedType, + ...(FOUND_ELEMENTS.get(version) ?? []), ]); - FOUND_SCRIPTS.delete(UNINITIALIZED); - } else if (!FOUND_SCRIPTS.has(version)) { + FOUND_ELEMENTS.delete(UNINITIALIZED); + } else if (!FOUND_ELEMENTS.has(version)) { // New version is being loaded in - FOUND_SCRIPTS.set(version, []); + FOUND_ELEMENTS.set(version, []); } sendMessageToBackground(messagePayload, response => { @@ -167,7 +186,7 @@ function handleManifestNode(manifestNode: HTMLScriptElement): void { manifestTimeoutID = ''; } FOUND_MANIFEST_VERSIONS.add(version); - window.setTimeout(() => processFoundJS(version), 0); + window.setTimeout(() => processFoundElements(version), 0); } else { if ('UNKNOWN_ENDPOINT_ISSUE' === response.reason) { updateCurrentState(STATES.TIMEOUT); @@ -178,130 +197,198 @@ function handleManifestNode(manifestNode: HTMLScriptElement): void { }); } -function handleScriptNode(scriptNode: HTMLScriptElement): void { - const dataBtManifest = scriptNode.getAttribute('data-btmanifest'); - if (dataBtManifest == null) { +export const processFoundElements = async (version: string): Promise => { + const elementsForVersion = FOUND_ELEMENTS.get(version); + if (!elementsForVersion) { invalidateAndThrow( - `No data-btmanifest attribute found on script ${scriptNode.src}`, + `attempting to process elements for nonexistent version ${version}`, ); } + const elements = elementsForVersion.splice(0).filter(element => { + if ( + element.otherType === currentFilterType || + [BOTH, UNINITIALIZED].includes(currentFilterType) + ) { + return true; + } else { + elementsForVersion.push(element); + } + }); + let pendingElementCount = elements.length; + for (const element of elements) { + await processSrc(element, version).then(response => { + const tagIdentifier = getTagIdentifier(element); - // Scripts may contain packages from both main and longtail manifests, - // e.g. "1009592080_main,1009592080_longtail" - const [manifest1, manifest2] = dataBtManifest.split(','); - - // If this scripts contains packages from both main and longtail manifests - // then require both manifests to be loaded before processing this script, - // otherwise use the single type specified. - const otherType = manifest2 ? BOTH : manifest1.split('_')[1]; - - // It is safe to assume a script will not contain packages from different - // versions, so we can use the first manifest version as the script version. - const version = manifest1.split('_')[0]; - - if (!version || !otherType) { - invalidateAndThrow( - `Missing manifest version or type from the data-btmanifest property of ${scriptNode.src}`, - ); + pendingElementCount--; + if (response.valid) { + if (pendingElementCount == 0) { + updateCurrentState(STATES.VALID); + } + } else { + if (response.type === 'EXTENSION') { + updateCurrentState(STATES.RISK); + } else { + updateCurrentState(STATES.INVALID, `Invalid Tag ${tagIdentifier}`); + } + } + sendMessageToBackground({ + type: MESSAGE_TYPE.DEBUG, + log: + 'processed JS with SRC response is ' + + JSON.stringify(response).substring(0, 500), + src: tagIdentifier, + }); + }); } + window.setTimeout(() => processFoundElements(version), 3000); +}; - ALL_FOUND_SCRIPT_TAGS.add(scriptNode.src); +function handleScriptNode(scriptNode: HTMLScriptElement): void { + const [version, otherType] = getManifestVersionAndTypeFromNode(scriptNode); + ALL_FOUND_TAGS_URLS.add(scriptNode.src); ensureManifestWasOrWillBeLoaded(FOUND_MANIFEST_VERSIONS, version); - pushToOrCreateArrayInMap(FOUND_SCRIPTS, version, { + pushToOrCreateArrayInMap(FOUND_ELEMENTS, version, { src: scriptNode.src, otherType, + type: 'script', }); - updateCurrentState(STATES.PROCESSING); } -export function hasInvalidScripts(scriptNodeMaybe: Node): void { - // if not an HTMLElement ignore it! - if (scriptNodeMaybe.nodeType !== Node.ELEMENT_NODE) { +function handleStyleNode(style: HTMLStyleElement): void { + const versionAndOtherType = tryToGetManifestVersionAndTypeFromNode(style); + if (versionAndOtherType == null) { + // Will be handled by manualCSSInspector return; } + const [version, otherType] = versionAndOtherType; + ensureManifestWasOrWillBeLoaded(FOUND_MANIFEST_VERSIONS, version); + pushToOrCreateArrayInMap(FOUND_ELEMENTS, version, { + tag: style, + otherType: otherType, + type: 'style', + }); + updateCurrentState(STATES.PROCESSING); +} - if (scriptNodeMaybe.nodeName.toLowerCase() === 'script') { - storeFoundJS(scriptNodeMaybe as HTMLScriptElement); - } else if (scriptNodeMaybe.childNodes.length > 0) { - scriptNodeMaybe.childNodes.forEach(childNode => { - hasInvalidScripts(childNode); - }); - } +function handleLinkNode(link: HTMLLinkElement): void { + const [version, otherType] = getManifestVersionAndTypeFromNode(link); + ALL_FOUND_TAGS_URLS.add(link.href); + ensureManifestWasOrWillBeLoaded(FOUND_MANIFEST_VERSIONS, version); + pushToOrCreateArrayInMap(FOUND_ELEMENTS, version, { + href: link.href, + otherType, + type: 'link', + }); + updateCurrentState(STATES.PROCESSING); } -export function storeFoundJS(scriptNode: HTMLScriptElement): void { +export function storeFoundElement(element: HTMLElement): void { if (!isTopWindow() && isSameDomainAsTopWindow()) { - // this means that content utils is running in an iframe - disable timer and call processFoundJS on manifest processed in top level frame + // this means that content utils is running in an iframe - disable timer and call processFoundElements on manifest processed in top level frame clearTimeout(manifestTimeoutID); manifestTimeoutID = ''; - FOUND_SCRIPTS.forEach((_val, key) => { - window.setTimeout(() => processFoundJS(key), 0); + FOUND_ELEMENTS.forEach((_val, key) => { + window.setTimeout(() => processFoundElements(key), 0); }); } // check if it's the manifest node if ( (isTopWindow() || !isSameDomainAsTopWindow()) && - (scriptNode.id === 'binary-transparency-manifest' || - scriptNode.getAttribute('name') === 'binary-transparency-manifest') + (element.id === 'binary-transparency-manifest' || + element.getAttribute('name') === 'binary-transparency-manifest') ) { - handleManifestNode(scriptNode); + handleManifestNode(element as HTMLScriptElement); } // Only a document/doctype can have textContent as null - const nodeTextContent = scriptNode.textContent ?? ''; - if (scriptNode.getAttribute('type') === 'application/json') { + const nodeTextContent = element.textContent ?? ''; + if (element.getAttribute('type') === 'application/json') { try { JSON.parse(nodeTextContent); } catch (parseError) { - setTimeout(() => parseFailedJSON({node: scriptNode, retry: 1500}), 20); + setTimeout(() => parseFailedJSON({node: element, retry: 1500}), 20); } return; } - if ( - scriptNode.src != null && - scriptNode.src !== '' && - scriptNode.src.indexOf('blob:') === 0 - ) { - // TODO: try to process the blob. For now, flag as warning. - updateCurrentState(STATES.INVALID, 'blob: src'); + if (element.nodeName.toLowerCase() === 'script') { + const script = element as HTMLScriptElement; + if ( + script.src != null && + script.src !== '' && + script.src.indexOf('blob:') === 0 + ) { + // TODO: try to process the blob. For now, flag as warning. + updateCurrentState(STATES.INVALID, 'blob: src'); + return; + } + if (script.src !== '' || script.innerHTML !== '') { + handleScriptNode(script); + } + } else if (element.nodeName.toLowerCase() === 'style') { + const style = element as HTMLStyleElement; + if (style.innerHTML !== '') { + handleStyleNode(style); + } + } else if (element.nodeName.toLowerCase() === 'link') { + handleLinkNode(element as HTMLLinkElement); + } +} + +export function hasInvalidScriptsOrStyles(scriptNodeMaybe: Node): void { + // if not an HTMLElement ignore it! + if (scriptNodeMaybe.nodeType !== Node.ELEMENT_NODE) { return; } - if (scriptNode.src !== '' || scriptNode.innerHTML !== '') { - handleScriptNode(scriptNode); + const nodeName = scriptNodeMaybe.nodeName.toLowerCase(); + + if ( + nodeName === 'script' || + nodeName === 'style' || + (nodeName === 'link' && + (scriptNodeMaybe as HTMLElement).getAttribute('rel') == 'stylesheet') + ) { + storeFoundElement(scriptNodeMaybe as HTMLElement); + } else if (scriptNodeMaybe.childNodes.length > 0) { + scriptNodeMaybe.childNodes.forEach(childNode => { + hasInvalidScriptsOrStyles(childNode); + }); } } -export const scanForScripts = (): void => { - const allElements = document.getElementsByTagName('script'); +export const scanForScriptsAndStyles = (): void => { + const allElements = document.querySelectorAll( + 'script,style,link[rel="stylesheet"]', + ); Array.from(allElements).forEach(element => { - storeFoundJS(element); + storeFoundElement(element as HTMLElement); }); try { - // track any new scripts that get loaded in - const scriptMutationObserver = new MutationObserver(mutationsList => { + // track any new tags that get loaded in + const mutationObserver = new MutationObserver(mutationsList => { mutationsList.forEach(mutation => { if (mutation.type === 'childList') { - Array.from(mutation.addedNodes).forEach(checkScript => { - // Code within a script tag has changed + Array.from(mutation.addedNodes).forEach(node => { + // Code within a script or style tag has changed + const targetNodeName = mutation.target.nodeName.toLocaleLowerCase(); if ( - checkScript.nodeType === Node.TEXT_NODE && - mutation.target.nodeName.toLocaleLowerCase() === 'script' + node.nodeType === Node.TEXT_NODE && + (targetNodeName === 'script' || targetNodeName === 'style') ) { - hasInvalidScripts(mutation.target); + hasInvalidScriptsOrStyles(mutation.target); } else { - hasInvalidScripts(checkScript); + hasInvalidScriptsOrStyles(node); } }); } }); }); - scriptMutationObserver.observe(document, { + mutationObserver.observe(document, { attributes: true, childList: true, subtree: true, @@ -311,53 +398,6 @@ export const scanForScripts = (): void => { } }; -export const processFoundJS = async (version: string): Promise => { - const scriptsForVersion = FOUND_SCRIPTS.get(version); - if (!scriptsForVersion) { - invalidateAndThrow( - `attempting to process scripts for nonexistent version ${version}`, - ); - } - const scripts = scriptsForVersion.splice(0).filter(script => { - if ( - script.otherType === currentFilterType || - [BOTH, UNINITIALIZED].includes(currentFilterType) - ) { - return true; - } else { - scriptsForVersion.push(script); - } - }); - let pendingScriptCount = scripts.length; - for (const script of scripts) { - await processJSWithSrc(script, version).then(response => { - pendingScriptCount--; - if (response.valid) { - if (pendingScriptCount == 0) { - updateCurrentState(STATES.VALID); - } - } else { - if (response.type === 'EXTENSION') { - updateCurrentState(STATES.RISK); - } else { - updateCurrentState( - STATES.INVALID, - `Invalid ScriptDetailsWithSrc ${script.src}`, - ); - } - } - sendMessageToBackground({ - type: MESSAGE_TYPE.DEBUG, - log: - 'processed JS with SRC response is ' + - JSON.stringify(response).substring(0, 500), - src: script.src, - }); - }); - } - window.setTimeout(() => processFoundJS(version), 3000); -}; - let isUserLoggedIn = false; let allowedWorkerCSPs: Array> = []; @@ -405,8 +445,8 @@ export function startFor(origin: Origin, config: ContentScriptConfig): void { } if (isUserLoggedIn) { updateCurrentState(STATES.PROCESSING); - scanForScripts(); - scanForCSS(); + scanForScriptsAndStyles(); + scanForCSSNeedingManualInspsection(); // set the timeout once, in case there's an iframe and contentUtils sets another manifest timer if (manifestTimeoutID === '') { manifestTimeoutID = window.setTimeout(() => { @@ -419,14 +459,14 @@ export function startFor(origin: Origin, config: ContentScriptConfig): void { chrome.runtime.onMessage.addListener(request => { if (request.greeting === 'downloadSource' && DOWNLOAD_JS_ENABLED) { - downloadJS(); + downloadSrc(); } else if (request.greeting === 'nocacheHeaderFound') { updateCurrentState( STATES.INVALID, - `Detected uncached script ${request.uncachedUrl}`, + `Detected uncached script/style ${request.uncachedUrl}`, ); } else if (request.greeting === 'checkIfScriptWasProcessed') { - if (isUserLoggedIn && !ALL_FOUND_SCRIPT_TAGS.has(request.response.url)) { + if (isUserLoggedIn && !ALL_FOUND_TAGS_URLS.has(request.response.url)) { const hostname = window.location.hostname; const resourceURL = new URL(request.response.url); if (resourceURL.hostname === hostname) { @@ -448,15 +488,16 @@ chrome.runtime.onMessage.addListener(request => { type: MESSAGE_TYPE.DEBUG, log: `Tab is processing ${request.response.url}`, }); - ALL_FOUND_SCRIPT_TAGS.add(request.response.url); - const uninitializedScripts = FOUND_SCRIPTS.get( - FOUND_SCRIPTS.keys().next().value, + ALL_FOUND_TAGS_URLS.add(request.response.url); + const uninitializedScripts = FOUND_ELEMENTS.get( + FOUND_ELEMENTS.keys().next().value, ); if (uninitializedScripts) { uninitializedScripts.push({ src: request.response.url, otherType: currentFilterType, isServiceWorker: hasVaryServiceWorkerHeader(request.response), + type: 'script', }); } updateCurrentState(STATES.PROCESSING); @@ -467,7 +508,7 @@ chrome.runtime.onMessage.addListener(request => { `Sniffable MIME type resource: ${request.src}`, ); } else if (request.greeting === 'downloadReleaseSource') { - for (const key of FOUND_SCRIPTS.keys()) { + for (const key of FOUND_ELEMENTS.keys()) { if (key !== 'UNINITIALIZED') { window.open( `https://www.facebook.com/btarchive/${key}/${getCurrentOrigin().toLowerCase()}`, diff --git a/src/js/content/contentJSUtils.ts b/src/js/content/contentJSUtils.ts deleted file mode 100644 index 91cbd31..0000000 --- a/src/js/content/contentJSUtils.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import alertBackgroundOfImminentFetch from './alertBackgroundOfImminentFetch'; - -import {ScriptDetails} from '../content'; -import {DOWNLOAD_JS_ENABLED, MESSAGE_TYPE} from '../config'; -import genSourceText from './genSourceText'; -import {sendMessageToBackground} from '../shared/sendMessageToBackground'; -import {getCurrentOrigin} from './updateCurrentState'; -import downloadJSArchive from './downloadJSArchive'; - -const SOURCE_SCRIPTS = new Map(); - -async function processJSWithSrc( - script: ScriptDetails, - version: string, -): Promise<{ - valid: boolean; - type?: unknown; -}> { - // fetch the script from page context, not the extension context. - try { - await alertBackgroundOfImminentFetch(script.src); - const sourceResponse = await fetch(script.src, { - method: 'GET', - // When the browser fetches a service worker it adds this header. - // If this is missing it will cause a cache miss, resulting in invalidation. - headers: script.isServiceWorker - ? {'Service-Worker': 'script'} - : undefined, - }); - if (DOWNLOAD_JS_ENABLED) { - const fileNameArr = script.src.split('/'); - const fileName = fileNameArr[fileNameArr.length - 1].split('?')[0]; - const responseBody = sourceResponse.clone().body; - if (!responseBody) { - throw new Error('Response for fetched script has no body'); - } - SOURCE_SCRIPTS.set( - fileName, - responseBody.pipeThrough(new window.CompressionStream('gzip')), - ); - } - const sourceText = await genSourceText(sourceResponse); - // split package up if necessary - const packages = sourceText.split('/*FB_PKG_DELIM*/\n'); - const packagePromises = packages.map(jsPackage => { - return new Promise((resolve, reject) => { - sendMessageToBackground( - { - type: MESSAGE_TYPE.RAW_JS, - rawjs: jsPackage.trimStart(), - origin: getCurrentOrigin(), - version: version, - }, - response => { - if (response.valid) { - resolve(null); - } else { - reject(); - } - }, - ); - }); - }); - await Promise.all(packagePromises); - return {valid: true}; - } catch (scriptProcessingError) { - return { - valid: false, - type: scriptProcessingError, - }; - } -} - -function downloadJS(): void { - downloadJSArchive(SOURCE_SCRIPTS); -} - -export {processJSWithSrc, downloadJS}; diff --git a/src/js/content/contentUtils.ts b/src/js/content/contentUtils.ts new file mode 100644 index 0000000..c3ff6c0 --- /dev/null +++ b/src/js/content/contentUtils.ts @@ -0,0 +1,96 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import alertBackgroundOfImminentFetch from './alertBackgroundOfImminentFetch'; + +import {TagDetails} from '../content'; +import {DOWNLOAD_JS_ENABLED, MESSAGE_TYPE} from '../config'; +import genSourceText from './genSourceText'; +import {sendMessageToBackground} from '../shared/sendMessageToBackground'; +import {getCurrentOrigin} from './updateCurrentState'; +import downloadJSArchive from './downloadJSArchive'; + +const SOURCE_SCRIPTS_AND_STYLES = new Map(); + +async function processSrc( + tagDetails: TagDetails, + version: string, +): Promise<{ + valid: boolean; + type?: unknown; +}> { + try { + let packages: Array = []; + if (tagDetails.type === 'script' || tagDetails.type === 'link') { + // fetch the script/style from page context, not the extension context. + + const url = + tagDetails.type === 'script' ? tagDetails.src : tagDetails.href; + const isServiceWorker = + tagDetails.type === 'script' && tagDetails.isServiceWorker; + + await alertBackgroundOfImminentFetch(url); + const sourceResponse = await fetch(url, { + method: 'GET', + // When the browser fetches a service worker it adds this header. + // If this is missing it will cause a cache miss, resulting in invalidation. + headers: isServiceWorker ? {'Service-Worker': 'script'} : undefined, + }); + if (DOWNLOAD_JS_ENABLED) { + const fileNameArr = url.split('/'); + const fileName = fileNameArr[fileNameArr.length - 1].split('?')[0]; + const responseBody = sourceResponse.clone().body; + if (!responseBody) { + throw new Error('Response for fetched script has no body'); + } + SOURCE_SCRIPTS_AND_STYLES.set( + fileName, + responseBody.pipeThrough(new window.CompressionStream('gzip')), + ); + } + const sourceText = await genSourceText(sourceResponse); + + // split package up if necessary + packages = sourceText.split('/*FB_PKG_DELIM*/\n'); + } else if (tagDetails.type === 'style') { + packages = [tagDetails.tag.innerHTML]; + } + + const packagePromises = packages.map(pkg => { + return new Promise((resolve, reject) => { + sendMessageToBackground( + { + type: MESSAGE_TYPE.RAW_SRC, + pkgRaw: pkg.trimStart(), + origin: getCurrentOrigin(), + version: version, + }, + response => { + if (response.valid) { + resolve(null); + } else { + reject(); + } + }, + ); + }); + }); + await Promise.all(packagePromises); + return {valid: true}; + } catch (scriptProcessingError) { + return { + valid: false, + type: scriptProcessingError, + }; + } +} + +function downloadSrc(): void { + downloadJSArchive(SOURCE_SCRIPTS_AND_STYLES); +} + +export {processSrc, downloadSrc}; diff --git a/src/js/content/ensureManifestWasOrWillBeLoaded.ts b/src/js/content/ensureManifestWasOrWillBeLoaded.ts index eff3bc3..b39dce6 100644 --- a/src/js/content/ensureManifestWasOrWillBeLoaded.ts +++ b/src/js/content/ensureManifestWasOrWillBeLoaded.ts @@ -19,7 +19,7 @@ export default function ensureManifestWasOrWillBeLoaded( if (!loadedVersions.has(version)) { updateCurrentState( STATES.INVALID, - 'Detected script from manifest that has not been loaded', + `Detected script from manifest version ${version} that has not been loaded`, ); } }, MANIFEST_TIMEOUT); diff --git a/src/js/content/getManifestVersionAndTypeFromNode.ts b/src/js/content/getManifestVersionAndTypeFromNode.ts new file mode 100644 index 0000000..f32a3cd --- /dev/null +++ b/src/js/content/getManifestVersionAndTypeFromNode.ts @@ -0,0 +1,52 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {invalidateAndThrow} from './updateCurrentState'; + +export const BOTH = 'BOTH'; + +export function getManifestVersionAndTypeFromNode( + element: HTMLElement, +): [string, string] { + const versionAndType = tryToGetManifestVersionAndTypeFromNode(element); + + if (!versionAndType) { + invalidateAndThrow( + `Missing manifest data attribute or invalid version/typeon attribute`, + ); + } + + return versionAndType; +} + +export function tryToGetManifestVersionAndTypeFromNode( + element: HTMLElement, +): [string, string] | null { + const dataBtManifest = element.getAttribute('data-btmanifest'); + if (dataBtManifest == null) { + return null; + } + + // Scripts may contain packages from both main and longtail manifests, + // e.g. "1009592080_main,1009592080_longtail" + const [manifest1, manifest2] = dataBtManifest.split(','); + + // If this scripts contains packages from both main and longtail manifests + // then require both manifests to be loaded before processing this script, + // otherwise use the single type specified. + const otherType = manifest2 ? BOTH : manifest1.split('_')[1]; + + // It is safe to assume a script will not contain packages from different + // versions, so we can use the first manifest version as the script version. + const version = manifest1.split('_')[0]; + + if (!version || !otherType) { + return null; + } + + return [version, otherType]; +} diff --git a/src/js/content/getTagIdentifier.ts b/src/js/content/getTagIdentifier.ts new file mode 100644 index 0000000..360a19e --- /dev/null +++ b/src/js/content/getTagIdentifier.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {TagDetails} from '../content'; + +export function getTagIdentifier(tagDetails: TagDetails): string { + switch (tagDetails.type) { + case 'script': + return tagDetails.src; + case 'link': + return tagDetails.href; + case 'style': + return 'style_' + tagDetails.tag.innerHTML.substring(0, 100); + } +} diff --git a/src/js/content/scanForCSS.ts b/src/js/content/manualCSSInspector.ts similarity index 73% rename from src/js/content/scanForCSS.ts rename to src/js/content/manualCSSInspector.ts index cb92f0a..667f67e 100644 --- a/src/js/content/scanForCSS.ts +++ b/src/js/content/manualCSSInspector.ts @@ -6,44 +6,48 @@ */ import {STATES} from '../config'; +import {tryToGetManifestVersionAndTypeFromNode} from './getManifestVersionAndTypeFromNode'; import {updateCurrentState} from './updateCurrentState'; -const CHECKED_STYLESHEET_HREFS = new Set(); const CHECKED_STYLESHEET_HASHES = new Set(); -export default function scanForCSS(): void { +export function scanForCSSNeedingManualInspsection(): void { checkForStylesheetChanges(); - setInterval(checkForStylesheetChanges, 1000); } async function checkForStylesheetChanges() { - [...document.styleSheets].forEach(async sheet => { - const isValid = await checkIsStylesheetValid(sheet); - updateStateOnInvalidStylesheet(isValid, sheet); - }); + [...document.styleSheets, ...document.adoptedStyleSheets].forEach( + async sheet => { + const potentialOwnerNode = sheet.ownerNode; + + if (sheet.href && potentialOwnerNode instanceof HTMLLinkElement) { + // Link style tags are checked agains the manifest + return; + } + + if ( + potentialOwnerNode instanceof HTMLStyleElement && + tryToGetManifestVersionAndTypeFromNode(potentialOwnerNode) != null + ) { + // Inline style covered by the main checks + return; + } + + updateStateOnInvalidStylesheet( + await checkIsStylesheetValid(sheet), + sheet, + ); + }, + ); } async function checkIsStylesheetValid( styleSheet: CSSStyleSheet, ): Promise { const potentialOwnerNode = styleSheet.ownerNode; - if ( - // CSS external resource - styleSheet.href && - potentialOwnerNode instanceof Element && - potentialOwnerNode.tagName === 'LINK' - ) { - if (CHECKED_STYLESHEET_HREFS.has(styleSheet.href)) { - return true; - } - CHECKED_STYLESHEET_HREFS.add(styleSheet.href); - ensureCORSEnabledForStylesheet(styleSheet); - } else if ( - // Inline css - potentialOwnerNode instanceof Element && - potentialOwnerNode.tagName === 'STYLE' - ) { + + if (potentialOwnerNode instanceof HTMLStyleElement) { const hashedContent = await hashString( potentialOwnerNode.textContent ?? '', ); @@ -52,6 +56,8 @@ async function checkIsStylesheetValid( } CHECKED_STYLESHEET_HASHES.add(hashedContent); } + + // We have to look at every CSS rule return [...styleSheet.cssRules].every(isValidCSSRule); } diff --git a/src/js/shared/MessageTypes.d.ts b/src/js/shared/MessageTypes.d.ts index d077cf0..aba2812 100644 --- a/src/js/shared/MessageTypes.d.ts +++ b/src/js/shared/MessageTypes.d.ts @@ -19,8 +19,8 @@ export type MessagePayload = workaround: string; } | { - type: typeof MESSAGE_TYPE.RAW_JS; - rawjs: string; + type: typeof MESSAGE_TYPE.RAW_SRC; + pkgRaw: string; origin: Origin; version: string; }