commit 803826ae9e4ac65bee920740f8c89e20bc90f7da Author: Wrongthink Date: Sat Apr 29 08:50:32 2023 -0400 Initial upload diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..814ef90 --- /dev/null +++ b/LICENSE @@ -0,0 +1,39 @@ +Copyright (c) 2007 GPL Project + +This file is free software: you may copy, redistribute and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation, either version 2 of the License, or (at your +option) any later version. + +This file is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +This file incorporates work covered by the following copyright and +permission notice: + +MIT License + +Copyright (c) 2022 Tobias Bengfort + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7b6331f --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +bundle.zip: manifest.json icon.svg src/* + zip $@ $^ diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f8e4fa --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +paraMatrix - block requests based on domain and request type + +Built atop the excellent project [xiMatrix](https://github.com/xi/xiMatrix) by Tobias Bengfort, itself influenced by Raymond Hill's [uMatrix](https://github.com/gorhill/uMatrix). paraMatrix blocks web requests via default-deny global default which can then whitelist elements on a per-site basis. For those who demand nothing less than granular control. + +![screenshot](screenshot.png) + +*~ "para- word-forming element of Latin origin meaning "defense, protection against; that which protects from."* + +## Why use xiMatrix as a basis instead of uMatrix? + +xiMatrix is simpler with a smaller codebase and is therefore more suitable to hack upon. It is already built in the spririt of 'do one thing and do it well' and implements some of the features I would have liked to see added to uMatrix. uMatrix covers things which may be considered outside the scope of a web request firewall such as spoofing values and managing local storage. + +## Why not just contribute to xi's xiMatrix? + +He has clearly stated in his project that it is a personal tool and will not likely be extended further. I will, however, try to track changes to xiMatrix to incorprate into paraMatrix. Although it may eventually be spun off as its own fork. + +## In comparison to xiMatrix + +- Open to being extended +- Separate image and media columns +- Reload page button in-panel +- Dark theme! +- Dialog clarifications +- More to come + +## In comparison to uMatrix + +- Actively maintained +- Smaller, simpler code +- Rules encoded as JSON +- Rows for managing inline scripts, styles, and images +- Keyboard navigation +- A column for blocking fonts + +## TO DO + +- Add column for blocking cookies. Cookie blocking is not as simple as other requests, as it necessitates accepting site cookies (but *not returning* them) when they are blocked. +- Display enumerated requests blocked in icon. +- Placeholder element for blocked images/video. +- Update in-place while keeping paraMatrix popup open. +- Explicit deny option for cells. +- Remove setting to disable request enumeration (performance impact is negligible). +- Migrate help dialog to dedicated docs page. diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..5dc2b93 --- /dev/null +++ b/icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..189b4a3 --- /dev/null +++ b/manifest.json @@ -0,0 +1,42 @@ +{ + "manifest_version": 2, + "name": "paraMatrix", + "author": "Wrongthink", + "homepage_url": "https://gitler.moe/Wrongthink/paraMatrix", + "description": "block requests based on domain and type", + "version": "🐢.🐎", + "browser_action": { + "default_title": "paraMatrix", + "default_popup": "src/popup.html", + "default_icon": "icon.svg" + }, + "icons": { + "48": "icon.svg" + }, + "background": { + "scripts": ["src/shared.js", "src/bg.js"], + "persistent": false + }, + "content_scripts": [{ + "js": ["src/content.js"], + "matches": [""], + "run_at": "document_start" + }], + "options_ui": { + "page": "src/settings.html", + "open_in_tab": true + }, + "permissions": [ + "storage", + "tabs", + "webNavigation", + "webRequest", + "webRequestBlocking", + "" + ], + "browser_specific_settings": { + "gecko": { + "id": "{936cea12-8e61-4929-b589-caece971bbd7}" + } + } +} diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..b49b420 Binary files /dev/null and b/screenshot.png differ diff --git a/src/bg.js b/src/bg.js new file mode 100644 index 0000000..2a18bca --- /dev/null +++ b/src/bg.js @@ -0,0 +1,225 @@ +/* global browser shared */ + +var lock = Promise.resolve(); + +var STORAGE_DEFAULTS = { + 'rules': {}, + 'savedRules': {}, + 'requests': {}, + 'recording': true, +}; + +var getHostname = function(url) { + var u = new URL(url); + return u.hostname; +}; + +var storageGet = function(key) { + return browser.storage.local.get(key).then(data => { + return data[key] ?? STORAGE_DEFAULTS[key]; + }); +}; + +var _storageChange = function(key, fn) { + return storageGet(key).then(oldValue => { + var data = {}; + data[key] = fn(oldValue); + return browser.storage.local.set(data); + }); +}; + +var storageChange = function(key, fn) { + lock = lock.then(() => _storageChange(key, fn)); + return lock; +}; + +var setRule = function(context, hostname, type, rule) { + return storageGet('savedRules').then(savedRules => { + return storageChange('rules', rules => { + if (hostname === 'first-party') { + context = '*'; + } + if (!rules[context]) { + rules[context] = savedRules[context] || {}; + } + if (!rules[context][hostname]) { + rules[context][hostname] = {}; + } + if (rule) { + rules[context][hostname][type] = rule; + } else { + delete rules[context][hostname][type]; + if (Object.keys(rules[context][hostname]).length === 0) { + delete rules[context][hostname]; + } + if (Object.keys(rules[context]).length === 0 && !savedRules[context]) { + delete rules[context]; + } + } + return rules; + }); + }); +}; + +var getRules = function(context) { + return Promise.all([ + storageGet('rules'), + storageGet('savedRules'), + ]).then(([rules, savedRules]) => { + var restricted = {}; + restricted['*'] = rules['*'] || savedRules['*'] || {}; + restricted[context] = rules[context] || savedRules[context] || {}; + restricted.dirty = !!rules[context]; + return restricted; + }); +}; + +var pushRequest = function(tabId, hostname, type) { + return storageGet('recording').then(recording => { + if (recording) { + return storageChange('requests', requests => { + if (!requests[tabId]) { + requests[tabId] = {}; + } + if (!requests[tabId][hostname]) { + requests[tabId][hostname] = {}; + } + if (!requests[tabId][hostname][type]) { + requests[tabId][hostname][type] = 0; + } + requests[tabId][hostname][type] += 1; + return requests; + }); + } + }); +}; + +var clearRequests = function(tabId) { + return storageChange('requests', requests => { + if (requests[tabId]) { + delete requests[tabId]; + } + return requests; + }); +}; + +var getCurrentTab = function() { + return browser.tabs.query({ + active: true, + currentWindow: true, + }).then(tabs => tabs[0]); +}; + +browser.runtime.onMessage.addListener((msg, sender) => { + if (msg.type === 'get') { + return getCurrentTab().then(tab => { + var context = getHostname(tab.url); + return Promise.all([ + getRules(context), + storageGet('requests'), + storageGet('recording'), + ]).then(([rules, requests, recording]) => { + return { + context: context, + rules: rules, + requests: requests[tab.id] || {}, + recording: recording, + }; + }); + }); + } else if (msg.type === 'setRule') { + return setRule( + msg.data.context, + msg.data.hostname, + msg.data.type, + msg.data.value, + ).then(() => getRules(msg.data.context)); + } else if (msg.type === 'commit') { + var r; + return storageChange('rules', rules => { + r = rules[msg.data]; + delete rules[msg.data]; + return rules; + }).then(() => storageChange('savedRules', savedRules => { + if (Object.keys(r).length === 0) { + delete savedRules[msg.data]; + } else { + savedRules[msg.data] = r; + } + return savedRules; + })); + } else if (msg.type === 'securitypolicyviolation') { + return pushRequest(sender.tab.id, 'inline', msg.data); + } else if (msg.type === 'toggleRecording') { + return storageChange('recording', recording => !recording); + } +}); + +browser.tabs.onRemoved.addListener(clearRequests); +browser.webNavigation.onBeforeNavigate.addListener(details => { + if (details.frameId === 0) { + return clearRequests(details.tabId); + } +}); + +browser.webRequest.onBeforeRequest.addListener(details => { + if (details.type === 'main_frame') { + return; + } + + var context = getHostname(details.documentUrl); + if (details.frameAncestors.length) { + var last = details.frameAncestors.length - 1; + context = getHostname(details.frameAncestors[last].url); + } + var hostname = getHostname(details.url); + var type = shared.TYPE_MAP[details.type] || 'other'; + + return Promise.all([ + pushRequest(details.tabId, hostname, type), + getRules(context), + ]).then(([_, rules]) => { + if (!shared.shouldAllow(rules, context, hostname, type)) { + if (details.type === 'sub_frame') { + // this can in turn be blocked by a local CSP + return {redirectUrl: 'data:,' + encodeURIComponent(details.url)}; + } else { + return {cancel: true}; + } + } + }); +}, {urls: ['']}, ['blocking']); + +browser.webRequest.onHeadersReceived.addListener(function(details) { + var context = getHostname(details.url); + return Promise.all([ + getRules(context), + storageGet('recording'), + ]).then(([rules, recording]) => { + var csp = (type, value) => { + var name = 'Content-Security-Policy'; + if (shared.shouldAllow(rules, context, 'inline', type)) { + if (recording) { + name = 'Content-Security-Policy-Report-Only'; + } else { + return; + } + } + details.responseHeaders.push({ + name: name, + value: value, + }); + }; + + csp('css', "style-src 'self' *"); + csp('script', "script-src 'self' *"); + csp('media', "img-src 'self' *"); + + return { + responseHeaders: details.responseHeaders, + }; + }); +}, { + urls: [''], + types: ['main_frame'], +}, ['blocking', 'responseHeaders']); diff --git a/src/content.js b/src/content.js new file mode 100644 index 0000000..f605ef2 --- /dev/null +++ b/src/content.js @@ -0,0 +1,17 @@ +/* global browser */ + +const TYPE_MAP = { + 'style-src': 'css', + 'script-src': 'script', + 'img-src': 'media', +}; + +document.addEventListener('securitypolicyviolation', event => { + var type = TYPE_MAP[event.violatedDirective]; + if (type) { + browser.runtime.sendMessage({ + type: 'securitypolicyviolation', + data: type, + }); + } +}); diff --git a/src/popup.css b/src/popup.css new file mode 100644 index 0000000..b25549f --- /dev/null +++ b/src/popup.css @@ -0,0 +1,69 @@ +:root { + --blue-light: #5e69f0; + --blue-dark: #2425ac; + --orange-light: #3f372f; + --dark-grey: #1b1b1b; + --text-light: #838383; + --text-dark: #fff; +} + +.toolbar { + display: flex; + gap: 0.5em; +} +.toolbar label { + flex-grow: 1; +} + +table { + background: var(--orange-light); + border-spacing: 0; + margin-bottom: 0.2em; +} +th, td { + position: relative; + border: 1px solid #1f2042; + min-width: 3.4em; + line-height: 1.8; + text-align: center; + font-weight: normal; +} +th:first-child { + text-align: right; +} +td.disabled { + background-color: var(--dark-grey); +} + +table input { + appearance: none; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + margin: 0; + cursor: pointer; +} +table input:focus-visible { + outline: 2px solid; + z-index: 1; +} +.inherit-allow { + background: var(--blue-light); +} +.inherit-allow ~ span { + color: var(--text-dark); +} +table input:checked { + background: var(--blue-dark); +} +table input ~ span { + pointer-events: none; + position: relative; + z-index: 1; + color: var(--text-light); +} +table input:checked ~ span { + color: var(--text-dark); +} diff --git a/src/popup.html b/src/popup.html new file mode 100644 index 0000000..117ad50 --- /dev/null +++ b/src/popup.html @@ -0,0 +1,31 @@ + + + + + + + +
+
+ + + + +
+
+ Help +

Columns represent different types of requests. Rows represent domains. Numbers (if recording is enabled) show how many requests of a given type the current tab tries to make to the given domain. Grey cells are blocked, blue cells are allowed. Light blue cells inherit rules indirectly because they represent a sub-domain of an allowed domain. Black cells are disabled.

+

Everything is blocked by default. Click on a cell to allow it, then click reload page to load those assets. Click commit to save the configuration for the current site. Click on the domain or type to allow an entire row or column. Master rows are available for:

+
    +
  • inline: Controls <script>-elements that are directly embedded in the HTML code. Categories that cannot be inline are disabled.
  • +
  • first-party: Sets global defaults for requests to the same domain as the page itself.
  • +
  • sub-domains: If a domain is allowed, all of its subdomains inhereit that rule.
  • +
+
+ + + + diff --git a/src/popup.js b/src/popup.js new file mode 100644 index 0000000..96bba3d --- /dev/null +++ b/src/popup.js @@ -0,0 +1,179 @@ +/* global browser shared */ + +var context; +var requests; +var rules; + +var table = document.querySelector('table'); +var recording = document.querySelector('[name="recording"]') +var commitButton = document.querySelector('[name="commit"]'); +var reloadButton = document.querySelector('[name="reload"]'); + +var sendMessage = function(type, data) { + return browser.runtime.sendMessage({type: type, data: data}); +}; + +var getHostnames = function() { + var hostnames = []; + + var addSubdomains = function(h) { + if (['inline', 'first-party', '*'].includes(h)) { + return; + } + hostnames.unshift(h); + var parts = h.split('.'); + while (parts.length > 2) { + parts.shift(); + hostnames.unshift(parts.join('.')); + } + }; + + for (const hostname in rules[context]) { + addSubdomains(hostname); + } + for (const hostname in requests) { + addSubdomains(hostname); + } + + addSubdomains(context); + + var contextRoot = context.split('.').slice(-2).join('.'); + hostnames = hostnames + .map(h => { + var parts = h.split('.'); + var root = parts.slice(-2).join('.'); + var isContext = root === contextRoot ? 0 : 1; + return [isContext, parts.reverse()]; + }) + .sort() + .map(a => a[1].reverse().join('.')); + + return hostnames.filter((value, i) => hostnames.indexOf(value) === i); +}; + +var updateInherit = function(type) { + var selector = 'input'; + if (type !== '*') { + selector += `[data-type="${type}"]`; + } + table.querySelectorAll(selector).forEach(input => { + input.classList.toggle('inherit-allow', shared.shouldAllow( + rules, + context, + input.dataset.hostname, + input.dataset.type, + )); + }); +}; + +var createCheckbox = function(hostname, type) { + var input = document.createElement('input'); + input.type = 'checkbox'; + input.dataset.hostname = hostname; + input.dataset.type = type; + + var c = (hostname === 'first-party') ? '*' : context; + input.checked = (rules[c][hostname] || {})[type]; + + input.onchange = () => { + sendMessage('setRule', { + context: context, + hostname: hostname, + type: type, + value: input.checked, + }).then(newRules => { + rules = newRules; + commitButton.disabled = !rules.dirty; + reloadButton.disabled = !rules.dirty; + updateInherit(type); + }); + }; + + return input; +}; + +var createCell = function(tag, hostname, type, text) { + const cell = document.createElement(tag); + cell.append(createCheckbox(hostname, type)); + + const span = document.createElement('span'); + span.textContent = text; + cell.append(span); + + return cell; +}; + +var createHeader = function() { + var tr = document.createElement('tr'); + + var th = document.createElement('th'); + th.textContent = context; + tr.append(th); + + for (const type of shared.TYPES) { + tr.append(createCell('th', '*', type, type)); + } + return tr; +}; + +var createRow = function(hostname) { + var tr = document.createElement('tr'); + tr.append(createCell('th', hostname, '*', hostname)); + for (const type of shared.TYPES) { + const count = (requests[hostname] || {})[type]; + + if (hostname !== 'inline' || ['css', 'script', 'image', 'media'].includes(type)) { + tr.append(createCell('td', hostname, type, count)); + } else { + const td = document.createElement('td'); + td.className = 'disabled'; + tr.append(td); + } + } + return tr; +}; + +var loadContext = function() { + sendMessage('get').then(data => { + context = data.context; + requests = data.requests; + rules = data.rules; + recording.checked = data.recording; + commitButton.disabled = !rules.dirty; + + table.innerHTML = ''; + table.append(createHeader()); + table.append(createRow('inline')); + table.append(createRow('first-party')); + + for (const hostname of getHostnames()) { + table.append(createRow(hostname)); + } + + updateInherit('*'); + }); +}; + +browser.webNavigation.onBeforeNavigate.addListener(window.close); + +document.querySelector('[name="settings"]').addEventListener('click', event => { + browser.runtime.openOptionsPage(); +}); + +document.addEventListener('DOMContentLoaded', () => { + loadContext(); +}); + +recording.addEventListener('change', event => { + sendMessage('toggleRecording'); +}); + +reloadButton.addEventListener('click', event => { + browser.tabs.reload({bypassCache: true}); +}); + +commitButton.addEventListener('click', event => { + sendMessage('commit', context).then(() => { + commitButton.disabled = true; + }); +}); diff --git a/src/settings.css b/src/settings.css new file mode 100644 index 0000000..e24c787 --- /dev/null +++ b/src/settings.css @@ -0,0 +1,29 @@ +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +form { + height: 100vh; + display: grid; + grid-template-rows: 1fr min-content; + grid-template-columns: 1fr 1fr; + grid-gap: 0.5em; + padding: 0.5em; +} + +textarea { + width: 100%; + resize: none; +} + +button { + padding: 0.5em 2em; + justify-self: end; + grid-column: 1 / 3; +} diff --git a/src/settings.html b/src/settings.html new file mode 100644 index 0000000..fe1a854 --- /dev/null +++ b/src/settings.html @@ -0,0 +1,17 @@ + + + + + + paraMatrix — edit rules + + +
+ + + +
+ + + + diff --git a/src/settings.js b/src/settings.js new file mode 100644 index 0000000..644d5bd --- /dev/null +++ b/src/settings.js @@ -0,0 +1,22 @@ +var form = document.querySelector('form'); +var textarea1 = document.querySelector('textarea.rules'); +var textarea2 = document.querySelector('textarea.savedRules'); + +browser.storage.local.get(['rules', 'savedRules']).then(data => { + var rules = data.rules || {}; + var savedRules = data.savedRules || {}; + textarea1.value = JSON.stringify(rules, null, 2) + textarea2.value = JSON.stringify(savedRules, null, 2) +}); + +form.addEventListener('submit', event => { + event.preventDefault(); + var rules = JSON.parse(textarea1.value); + var savedRules = JSON.parse(textarea2.value); + browser.storage.local.set({ + 'rules': rules, + 'savedRules': savedRules, + }).then(() => { + location.reload(); + }); +}); diff --git a/src/shared.js b/src/shared.js new file mode 100644 index 0000000..5bc8012 --- /dev/null +++ b/src/shared.js @@ -0,0 +1,35 @@ +var shared = {}; + +shared.TYPES = ['font', 'css', 'image', 'media', 'script', 'xhr', 'frame', 'other']; +shared.TYPE_MAP = { + 'stylesheet': 'css', + 'font': 'font', + 'image': 'image', + 'imageset': 'image', + 'media': 'media', + 'script': 'script', + 'beacon': 'xhr', + 'xmlhttprequest': 'xhr', + 'websocket': 'xhr', + 'sub_frame': 'frame', +}; + +shared.shouldAllow = function(rules, context, hostname, type) { + var hostnames = ['*', hostname]; + var parts = hostname.split('.'); + while (parts.length > 2) { + parts.shift(); + hostnames.push(parts.join('.')); + } + if (context !== '*' && hostnames.some(h => h === context)) { + hostnames.push('first-party'); + } + + return [context, '*'].some(c => { + return rules[c] && hostnames.some(h => { + return rules[c][h] && [type, '*'].some(t => { + return !!rules[c][h][t]; + }); + }); + }); +};