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;
}