Initial upload

このコミットが含まれているのは:
Wrongthink 2023-04-29 08:50:32 -04:00
コミット 803826ae9e
15個のファイルの変更764行の追加0行の削除

39
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 <https://www.gnu.org/licenses/>.
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.

2
Makefile ノーマルファイル
ファイルの表示

@ -0,0 +1,2 @@
bundle.zip: manifest.json icon.svg src/*
zip $@ $^

43
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.

14
icon.svg ノーマルファイル
ファイルの表示

@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect fill="#6B6B6B" x="0" y="0" width="128" height="128" rx="16" ry="16" />
<g fill="#5643ff">
<rect x="24" y="24" width="16" height="16" />
<rect x="56" y="24" width="16" height="16" />
<rect x="88" y="24" width="16" height="16" />
<rect x="24" y="56" width="16" height="16" />
<rect x="56" y="56" width="16" height="16" />
<rect x="88" y="56" width="16" height="16" />
<rect x="24" y="88" width="16" height="16" />
<rect x="56" y="88" width="16" height="16" />
<rect x="88" y="88" width="16" height="16" />
</g>
</svg>

変更後

幅:  |  高さ:  |  サイズ: 652 B

42
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": ["<all_urls>"],
"run_at": "document_start"
}],
"options_ui": {
"page": "src/settings.html",
"open_in_tab": true
},
"permissions": [
"storage",
"tabs",
"webNavigation",
"webRequest",
"webRequestBlocking",
"<all_urls>"
],
"browser_specific_settings": {
"gecko": {
"id": "{936cea12-8e61-4929-b589-caece971bbd7}"
}
}
}

バイナリ
screenshot.png ノーマルファイル

バイナリファイルは表示されません。

変更後

幅:  |  高さ:  |  サイズ: 44 KiB

225
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: ['<all_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: ['<all_urls>'],
types: ['main_frame'],
}, ['blocking', 'responseHeaders']);

17
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,
});
}
});

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

31
src/popup.html ノーマルファイル
ファイルの表示

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="popup.css">
</head>
<body text="#d9d9d9" bgcolor="#1f2042">
<table></table>
<div class="toolbar">
<label>
<input type="checkbox" name="recording">
recording
</label>
<button type="button" name="commit" disabled>Commit</button>
<button type="button" name="reload" disabled>Reload page</button>
<button type="button" name="settings">Edit rules</button>
</div>
<details>
<summary>Help</summary>
<p>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.</p>
<p>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:</p>
<ul>
<li><strong>inline</strong>: Controls <code>&lt;script&gt;</code>-elements that are directly embedded in the HTML code. Categories that cannot be inline are disabled.</li>
<li><strong>first-party</strong>: Sets global defaults for requests to the same domain as the page itself.</li>
<li><strong>sub-domains</strong>: If a domain is allowed, all of its subdomains inhereit that rule.</li>
</ul>
</details>
<script src="shared.js"></script>
<script src="popup.js"></script>
</body>
</html>

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

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

17
src/settings.html ノーマルファイル
ファイルの表示

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="settings.css">
<title>paraMatrix &mdash; edit rules</title>
</head>
<body text="#d9d9d9" bgcolor="#1f2042">
<form>
<textarea style="background-color:#868686;" class="rules"></textarea>
<textarea style="background-color:#868686;" class="savedRules"></textarea>
<button>Save</button>
</form>
<script src="shared.js"></script>
<script src="settings.js"></script>
</body>
</html>

22
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();
});
});

35
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];
});
});
});
};