From 65e33b5efbc7105677436d7a30b6259bdc22f27a Mon Sep 17 00:00:00 2001 From: Luiz Filho Date: Sat, 13 Jun 2020 17:16:11 -0700 Subject: [PATCH] feat: working version with deny list * chore: tune jest to work with ESM * feat(request-matcher): basic matching and unit tests * feat: requests blocking (wip - missing unit tests) * chore: add html test pages * feat(List): new List data structure Closes #40 * refactor(request_matcher): use the new List instead * refactor: extract requestListener to its own file * test: add playwright and intial e2e test * refactor: move url fixtures to shared folder * refactor:fix * docs: add draft testing doc * tests: pause work on automated functional tests for now * feat: add model folder and DenyList model * feat: add ListPopulator to prepoluate lists * tests: use itty.bitty.site for https * feat: add error listener to webRequest * tests: silence window.alert's :) * refactor: url fixtures export style * refactor: share url fixtures with src/shared * refactor: make bin script executable * refactor: require a type when constructing a List * refactor: list_populator * refactor: update test pages html --- __tests__/e2e/blocking_requests.spec.js | 67 ++++++++++++++++++++++ __tests__/shared/test_page.html | 45 +++++++++++++++ __tests__/src/lib/__request_fixtures.js | 7 +++ __tests__/src/lib/list.spec.js | 62 ++++++++++++++++++++ __tests__/src/lib/request_listener.spec.js | 25 ++++++++ __tests__/src/lib/request_matcher.spec.js | 17 ++++++ bin/change-ext-version.js | 2 + package.json | 15 +++-- src/background_script.html | 10 ++++ src/background_script.js | 5 +- src/browserAction/script.js | 2 +- src/content_script.js | 2 +- src/lib/list_populator.js | 20 +++++++ src/lib/model/deny_list.js | 7 +++ src/lib/model/list.js | 50 ++++++++++++++++ src/lib/request_blocker.js | 19 ++++++ src/lib/request_listener.js | 14 +++++ src/lib/request_matcher.js | 18 ++++++ src/manifest.json | 3 +- src/shared/__url_fixtures.js | 23 ++++++++ test/index.spec.js | 7 --- 21 files changed, 403 insertions(+), 17 deletions(-) create mode 100644 __tests__/e2e/blocking_requests.spec.js create mode 100644 __tests__/shared/test_page.html create mode 100644 __tests__/src/lib/__request_fixtures.js create mode 100644 __tests__/src/lib/list.spec.js create mode 100644 __tests__/src/lib/request_listener.spec.js create mode 100644 __tests__/src/lib/request_matcher.spec.js mode change 100644 => 100755 bin/change-ext-version.js create mode 100644 src/background_script.html create mode 100644 src/lib/list_populator.js create mode 100644 src/lib/model/deny_list.js create mode 100644 src/lib/model/list.js create mode 100644 src/lib/request_blocker.js create mode 100644 src/lib/request_listener.js create mode 100644 src/lib/request_matcher.js create mode 100644 src/shared/__url_fixtures.js delete mode 100644 test/index.spec.js diff --git a/__tests__/e2e/blocking_requests.spec.js b/__tests__/e2e/blocking_requests.spec.js new file mode 100644 index 0000000..c6acf1c --- /dev/null +++ b/__tests__/e2e/blocking_requests.spec.js @@ -0,0 +1,67 @@ +//TODO work in this file is paused/block. +// More details and follow up on: https://github.com/lfilho/sample-webextension/issues/38 + +/* +import browserWrapper from 'playwright-firefox'; + +import child_process from 'child_process'; +import webExt from 'web-ext'; +import pptr from 'puppeteer-core'; + +import { LOCAL_TEST_PAGE_URL } from '../shared/__url_fixtures.js'; +*/ +let page; +/* +let browser; + +beforeAll(async () => { + webExt.default.util.logger.consoleStream.makeVerbose(); + const runningInfo = await webExt.default.cmd + .run( + { + sourceDir: `${process.cwd()}/src`, + firefox: 'nightly' + }, + { shouldExitProgram: false } + ) + .then((runner) => runner.extensionRunners[0].runningInfo); + + // Needed because `webExt.cmd.run` returns before the DevTools agent starts running. + // Alternative would be to wrap the call to pptr.connect() with some custom retry logic + child_process.execSync('sleep 5'); + + const browserURL = `ws://127.0.0.1:${runningInfo.debuggerPort}`; + + browser = await browserWrapper.firefox.connect({ + wsEndpoint: browserURL, + logger: { + isEnabled: () => true, + log: (name, severity, message, args) => { + console.log(`[${severity}] ${name} ${message}. Args: ${args}`); + }, + }, + }); + const context = await browser.newContext(); + page = await context.newPage(); + + await page.goto(LOCAL_TEST_PAGE_URL); +}); + +afterAll(async () => { + await browser.close(); +}); +*/ + +describe('request to known tracker urls should be blocked', () => { + it.skip('loads the test page', async () => { + const text = await page.evaluate(() => { + return document.querySelector('[data-testid="test-page-header"]'); + }); + expect(text).toBeTruthy(); + }); + + it.skip('blocks a known bad url by not loading its iframe', async () => { + //TODO waiting on resolution for: https://github.com/mozilla/web-ext/issues/1927 + // More details and follow up on: https://github.com/lfilho/sample-webextension/issues/38 + }); +}); diff --git a/__tests__/shared/test_page.html b/__tests__/shared/test_page.html new file mode 100644 index 0000000..ed6429c --- /dev/null +++ b/__tests__/shared/test_page.html @@ -0,0 +1,45 @@ + + + + + + + Test page + + + + +

✅ Test page

+ +

Bad page iframe (should be blank):

+ + +

Good page iframe (should not be blank):

+ + + + diff --git a/__tests__/src/lib/__request_fixtures.js b/__tests__/src/lib/__request_fixtures.js new file mode 100644 index 0000000..03d323d --- /dev/null +++ b/__tests__/src/lib/__request_fixtures.js @@ -0,0 +1,7 @@ +import { A_URL } from '../../../src/shared/__url_fixtures.js'; + +export const RANDOM_REQUEST_DETAILS = { url: A_URL }; +export const BLOCKING_RESPONSE = { + CANCELLED: { cancel: true }, + NOT_CANCELLED: { cancel: false }, +}; diff --git a/__tests__/src/lib/list.spec.js b/__tests__/src/lib/list.spec.js new file mode 100644 index 0000000..56210a4 --- /dev/null +++ b/__tests__/src/lib/list.spec.js @@ -0,0 +1,62 @@ +import List from '../../../src/lib/model/list.js'; +import { + A_URL, + ANOTHER_URL, + SOME_URLS, +} from '../../../src/shared/__url_fixtures.js'; + +let list; + +beforeEach(() => { + const RANDOM_TYPE = List.types.DENY_LIST; + list = new List(RANDOM_TYPE); +}); + +describe('List', () => { + it('should add an url to the list', () => { + const originalListSize = list.size; + list.add(A_URL); + expect(list.size).toBe(originalListSize + 1); + }); + + it('should not add duplicate urls to the list', () => { + const originalListSize = list.size; + list.add(A_URL); + list.add(A_URL); + expect(list.size).toBe(originalListSize + 1); + }); + + it('should remove an url from the list', () => { + const originalListSize = list.size; + list.add(A_URL); + list.remove(A_URL); + expect(list.size).toBe(originalListSize); + }); + + it('should tell if an url is on the list', () => { + list.add(A_URL); + expect(list.has(A_URL)).toBe(true); + expect(list.has(ANOTHER_URL)).toBe(false); + }); + + it('should clear the list', () => { + list.add(A_URL); + list.add(ANOTHER_URL); + list.clear(); + expect(list.size).toBe(0); + }); + + it('should return the size of the list', () => { + expect(list.size).toBe(0); + list.add(A_URL); + expect(list.size).toBe(1); + list.add(ANOTHER_URL); + expect(list.size).toBe(2); + }); + + it('should add several urls at once', () => { + expect(list.size).toBe(0); + list.bulkAdd(SOME_URLS); + expect(list.size).toBe(SOME_URLS.length); + }); +}); diff --git a/__tests__/src/lib/request_listener.spec.js b/__tests__/src/lib/request_listener.spec.js new file mode 100644 index 0000000..4c0c66d --- /dev/null +++ b/__tests__/src/lib/request_listener.spec.js @@ -0,0 +1,25 @@ +import { jest } from '@jest/globals'; + +import requestListener from '../../../src/lib/request_listener.js'; +import RequestMatcher from '../../../src/lib/request_matcher.js'; + +import { + RANDOM_REQUEST_DETAILS, + BLOCKING_RESPONSE, +} from './__request_fixtures.js'; + +jest.mock('../../../src/lib/request_matcher.js'); + +describe('Request Listener', () => { + it('should return a BlockingResponse with cancel: true for a tracker url', () => { + RequestMatcher.isDenied = jest.fn().mockReturnValue(true); + const result = requestListener(RANDOM_REQUEST_DETAILS); + expect(result).toEqual(BLOCKING_RESPONSE.CANCELLED); + }); + + it('should return a BlockingResponse with cancel: false for a non-tracker url', () => { + RequestMatcher.isDenied = jest.fn().mockReturnValue(false); + const result = requestListener(RANDOM_REQUEST_DETAILS); + expect(result).toEqual(BLOCKING_RESPONSE.NOT_CANCELLED); + }); +}); diff --git a/__tests__/src/lib/request_matcher.spec.js b/__tests__/src/lib/request_matcher.spec.js new file mode 100644 index 0000000..46bdb82 --- /dev/null +++ b/__tests__/src/lib/request_matcher.spec.js @@ -0,0 +1,17 @@ +import RequestMatcher from '../../../src/lib/request_matcher.js'; +import { BAD_URL, GOOD_URL } from '../../../src/shared/__url_fixtures.js'; + +beforeAll(() => RequestMatcher.denyList.add(BAD_URL)); +afterAll(() => RequestMatcher.denyList.clear()); + +describe('Request Matcher', () => { + it('should deny urls in the deny list', () => { + const result = RequestMatcher.isDenied(BAD_URL); + expect(result).toBe(true); + }); + + it('should not deny urls if they are not in the deny list', () => { + const result = RequestMatcher.isDenied(GOOD_URL); + expect(result).toBe(false); + }); +}); diff --git a/bin/change-ext-version.js b/bin/change-ext-version.js old mode 100644 new mode 100755 index 830fe2e..8ab410d --- a/bin/change-ext-version.js +++ b/bin/change-ext-version.js @@ -1,3 +1,5 @@ +#!/usr/bin/env node + import fs from 'fs'; const MANITFEST_FILE = 'src/manifest.json'; diff --git a/package.json b/package.json index 878a6d2..e619a14 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ }, "homepage": "https://github.com/lfilho/sample-webextension#readme", "scripts": { - "develop": "npm run web-ext -- run", + "develop": "npm run web-ext -- run --bc --url $(pwd)/__tests__/shared/test_page.html", "format": "prettier --write \"**/*.{js,json,css,md}\"", "//eslint is configured with prettier, so it will check and fix js formatting too": "", "lint": "npm run lint:js && npm run lint:ext", @@ -25,18 +25,19 @@ "lint:js:fix": "npm run lint:js -- --fix", "pretest": "npm run lint", "start": "npm run develop", - "test": "NODE_ENV=test jest", + "test": "NODE_ENV=test node --experimental-vm-modules node_modules/jest/bin/jest.js", "test:watch": "npm test -- --watch", "web-ext": "web-ext", "//Release related scripts:": "", "release": "semantic-release", "build": "npm run web-ext -- build", "get-version": "echo $npm_package_version", - "change-ext-version": "node bin/change-ext-version.js" + "change-ext-version": "bin/change-ext-version.js" }, "devDependencies": { "@commitlint/cli": "8.3.5", "@commitlint/config-conventional": "8.3.4", + "@jest/globals": "26.0.1", "@semantic-release/commit-analyzer": "8.0.1", "@semantic-release/exec": "5.0.0", "@semantic-release/github": "7.0.7", @@ -59,7 +60,7 @@ "jest": { "displayName": "test", "testRegex": "\\.spec\\.js$", - "testEnvironment": "jsdom", + "testEnvironment": "jest-environment-node", "testURL": "http://localhost", "watchPlugins": [ "jest-watch-typeahead/filename", @@ -67,7 +68,8 @@ ], "coveragePathIgnorePatterns": [ "/node_modules/" - ] + ], + "transform": {} }, "husky": { "hooks": { @@ -145,5 +147,6 @@ "webExt": { "sourceDir": "src", "artifactsDir": "dist" - } + }, + "dependencies": {} } diff --git a/src/background_script.html b/src/background_script.html new file mode 100644 index 0000000..439caac --- /dev/null +++ b/src/background_script.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/background_script.js b/src/background_script.js index 863bd10..adf33df 100644 --- a/src/background_script.js +++ b/src/background_script.js @@ -1 +1,4 @@ -console.log('This is coming from background script!'); +import RequestBlocker from './lib/request_blocker.js'; + +console.log('Extension is ready to start blocking!'); +RequestBlocker.startMonitoring(); diff --git a/src/browserAction/script.js b/src/browserAction/script.js index b472979..7133b56 100644 --- a/src/browserAction/script.js +++ b/src/browserAction/script.js @@ -1 +1 @@ -window.alert('This is coming from the extension button!'); +console.info('This is coming from the extension button!'); diff --git a/src/content_script.js b/src/content_script.js index f1890e0..151c69d 100644 --- a/src/content_script.js +++ b/src/content_script.js @@ -1 +1 @@ -window.alert('This is comming from content script!'); +console.info('This is comming from content script!'); diff --git a/src/lib/list_populator.js b/src/lib/list_populator.js new file mode 100644 index 0000000..1943344 --- /dev/null +++ b/src/lib/list_populator.js @@ -0,0 +1,20 @@ +import List from './model/list.js'; +import { BAD_URLS } from '../shared/__url_fixtures.js'; + +// TODO for now, just populating it with the examples from fixtures +// TODO real deal: fetch from local files shipped with the extension, augment it with live sources... +// https://github.com/lfilho/sample-webextension/issues/24 +// https://github.com/lfilho/sample-webextension/issues/18 +// https://github.com/lfilho/sample-webextension/issues/17 + +export default class ListPopulator { + static async populateList(list) { + const listTypeToUrlMapper = { + [List.types.DENY_LIST]: BAD_URLS, + }; + const urls = listTypeToUrlMapper[list.type]; + list.bulkAdd(urls); + + //TODO https://github.com/lfilho/sample-webextension/issues/8 + } +} diff --git a/src/lib/model/deny_list.js b/src/lib/model/deny_list.js new file mode 100644 index 0000000..1b3864d --- /dev/null +++ b/src/lib/model/deny_list.js @@ -0,0 +1,7 @@ +import List from './list.js'; + +export default class DenyList extends List { + constructor() { + super(List.types.DENY_LIST); + } +} diff --git a/src/lib/model/list.js b/src/lib/model/list.js new file mode 100644 index 0000000..7fa8114 --- /dev/null +++ b/src/lib/model/list.js @@ -0,0 +1,50 @@ +export default class List { + /* + * Using a Set and mainly proxying the methods to their native ones. + * So if we change the underlaying data structure in the future, + * the API stays the same and the impact on the codebase is minimal. + */ + + constructor(type) { + if (!type) { + throw new Error('List constructor needs a type'); + //TODO Refactor Error strategy later on to contain an unique error code and both technical and user friendly messages + } + this.list = new Set(); + this.type = type; + } + + static get types() { + return Object.freeze({ + DENY_LIST: 'DENY', + ALLOW_LIST: 'ALLOW', + }); + } + + has(url) { + // Look kid, one day there will be a complex algorithm here. + // For now, full matches only. In future iterations, + // we should support some sort of pattern instead: *evil-tracker.com/* + return this.list.has(url); + } + + add(url) { + this.list.add(url); + } + + remove(url) { + this.list.delete(url); + } + + clear() { + this.list.clear(); + } + + get size() { + return this.list.size; + } + + bulkAdd(values) { + values.forEach(this.list.add.bind(this.list)); + } +} diff --git a/src/lib/request_blocker.js b/src/lib/request_blocker.js new file mode 100644 index 0000000..02e9f16 --- /dev/null +++ b/src/lib/request_blocker.js @@ -0,0 +1,19 @@ +import requestListener from './request_listener.js'; + +/** + * @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/webRequest/onBeforeRequest + */ +export default class RequestBlocker { + static startMonitoring() { + const BLOCKING_FLAG = 'blocking'; + const URL_FILTER = { urls: [''] }; + + browser.webRequest.onErrorOccurred.addListener(console.error, URL_FILTER); + + browser.webRequest.onBeforeRequest.addListener( + requestListener, + URL_FILTER, + [BLOCKING_FLAG] + ); + } +} diff --git a/src/lib/request_listener.js b/src/lib/request_listener.js new file mode 100644 index 0000000..7da01ac --- /dev/null +++ b/src/lib/request_listener.js @@ -0,0 +1,14 @@ +import RequestMatcher from './request_matcher.js'; + +export default function requestListener(requestDetails) { + const url = requestDetails.url; + const isUrlDenied = RequestMatcher.isDenied(url); + const shouldBlock = { cancel: isUrlDenied }; + + if (isUrlDenied) { + console.log(`Blocking request for: ${url}. Nice try!`); + } else { + console.log(`URL is good to go: ${url}.`); + } + return shouldBlock; +} diff --git a/src/lib/request_matcher.js b/src/lib/request_matcher.js new file mode 100644 index 0000000..eb47375 --- /dev/null +++ b/src/lib/request_matcher.js @@ -0,0 +1,18 @@ +import DenyList from './model/deny_list.js'; +import ListPopulator from './list_populator.js'; + +let denyList; + +export default class RequestMatcher { + static get denyList() { + if (!denyList) { + denyList = new DenyList(); + ListPopulator.populateList(denyList); + } + return denyList; + } + + static isDenied(url) { + return this.denyList.has(url); + } +} diff --git a/src/manifest.json b/src/manifest.json index 36f98cf..efcd2f4 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -6,8 +6,9 @@ "icons": { "64": "icons/icon.png" }, + "permissions": ["webRequest", "webRequestBlocking", ""], "background": { - "scripts": ["background_script.js"] + "page": "background_script.html" }, "content_scripts": [ { diff --git a/src/shared/__url_fixtures.js b/src/shared/__url_fixtures.js new file mode 100644 index 0000000..17be928 --- /dev/null +++ b/src/shared/__url_fixtures.js @@ -0,0 +1,23 @@ +const A_URL = 'https://example.com'; +const GOOD_URL = 'https://luiz.dev'; +const BAD_URL = 'https://evil-tracker.com'; +const ANOTHER_URL = GOOD_URL; +const SOME_URLS = [A_URL, ANOTHER_URL, BAD_URL]; +const TEST_PAGE_GOOD_URL = + 'https://itty.bitty.site/#good_page/data:text/html;charset=utf-8;bxze64,XQAAAALrAQAAAAAAAAAeGInmWR9D5qtrM4PFJv4W1okR98bzFbE2QlIiIqEKRNhWozLGaVxy2UBVi6vb5PjLiS+KmhnoBI2zbVEi38FFqGt0V2dZ/n48NtEOjSTkOFXBuNLAKC6rlcwmvnHnUnMAWA2l/QImsEbNvvf1bv40vbtBzNp9F3TGp/HpcdlmwUSp59tjbjdUlRkOVnMxBaTPI6tnqjg9UBREwBH6Y4c6xLg53hJodPJyK8AysLdOEqC5OPFdGrHq7n6ViwKq90juHDM+UhFD8ug4iSu0Yo74yBMAo7Rtj+Jd5h9AkkjDCs/m4RMIP7KQKT4AldOuVvxaNDd4LcfbH/7lFxzMpv2FPyYxeR5ZmDMwE6422v7jh2OnV4nTcu42kWkhVBP7U06PxG1bBjmd5+5p11z/jK399tAktg=='; +const TEST_PAGE_BAD_URL = + 'https://itty.bitty.site/#bad_page/data:text/html;charset=utf-8;bxze64,XQAAAAI1AgAAAAAAAAAeGInmWR9D5qtrM4PFJv4W1okR98bzFbE2QlIWuIKxrmOKcpOUCz+bgN13tm1YwI65ckLaaMk97I0eK4ZJmz5rEyi5AYIrCihWPyWsU8imdwCjsAu9mVY4Uz3NDJSwS2tBy4fpBWIzlJM+PEIF1Dz7uHdB5dd0JkIYoHoNI51/xPcOHjaLtkdq929hapYOYARvUkxhKCAYZqoeWl/azB+/iRrItZhXoN2Xhb7cEouQ4ySG85i7cH0nZAJxZezxYYPzjYOKj1QECrYU2ufsndUGql9zsUXyBzVv3UCYzraVIpXLzEWabESPgYK1HY3biPhAiXzCU1i3msUdqKm0jl/HwqnVgJjhKaCsQvuv8pyy+VVkaz/XPVibLpmrbELCVUdG/jgKW2ig1pqjIAIoFb7YROo5+5026HlBBKlBJSJtJPiB5wAAbZMivZGwt72qmdA5R4jjQVr+djo2'; +const BAD_URLS = [BAD_URL, TEST_PAGE_BAD_URL, 'https://facebook.com']; +const GOOD_URLS = [GOOD_URL, TEST_PAGE_GOOD_URL, 'https://eff.org']; + +export { + A_URL, + GOOD_URL, + BAD_URL, + ANOTHER_URL, + SOME_URLS, + TEST_PAGE_BAD_URL, + TEST_PAGE_GOOD_URL, + BAD_URLS, + GOOD_URLS, +}; diff --git a/test/index.spec.js b/test/index.spec.js deleted file mode 100644 index 34511b9..0000000 --- a/test/index.spec.js +++ /dev/null @@ -1,7 +0,0 @@ -describe('hello world', () => { - it('is round', () => { - const world = 'round'; - const shape = 'round'; - expect(world).toBe(shape); - }); -});