DeclarativeNetRequest-backed policy enforcement.

This commit is contained in:
hackademix 2024-11-10 01:16:58 +01:00
parent 971697043c
commit 501f2c96ce
No known key found for this signature in database
GPG Key ID: 231A83AFDA9C2434
1 changed files with 290 additions and 0 deletions

290
src/bg/DNRPolicy.js Normal file
View File

@ -0,0 +1,290 @@
/*
* NoScript - a Firefox extension for whitelist driven safe JavaScript execution
*
* Copyright (C) 2005-2024 Giorgio Maone <https://maone.net>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any later
* version.
*
* This program 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/>.
*/
'use strict';
{
const DEFAULT_PRIORITY = 1;
const SITE_PRIORITY = 10;
const CTX_PRIORITY = 20;
const CASCADE_PRIORITY = 30;
const TAB_PRIORITY = 40;
const REPORT_PRIORITY = 50;
const MAX_PRIORITY = 100;
const SESSION_BASE = 100;
const TAB_BASE = 10000;
const DYNAMIC_BASE = 20000;
let _lastPolicy;
const resourceTypesMap = {};
{
const dnrTypes = Object.values(browser.declarativeNetRequest.ResourceType);
for(const [key, value] of Object.entries(RequestGuard.policyTypesMap)) {
if (!(value && dnrTypes.includes(key))) continue;
const mapping = resourceTypesMap[value] ||= [];
mapping.push(key);
}
}
const ResourceTypeFor = {
block(caps) {
return this.allow(Permissions.ALL.filter(cap => !caps.has(cap)));
},
allow(caps) {
const resourceTypes = [];
for (let c of [...caps]) {
if (c in resourceTypesMap) {
resourceTypes.push(...resourceTypesMap[c]);
}
}
return resourceTypes;
}
}
function forBlockAllow(capabilities, callback) {
for (const actionType of ["block", "allow"]) {
const resourceTypes = ResourceTypeFor[actionType](capabilities);
if (resourceTypes?.length) {
callback(actionType, resourceTypes);
}
}
}
function toUrlFilter(siteKey) {
let urlFilter = `|${siteKey.replace(/^§:/, '|')}`;
if (!urlFilter.replace(/^\w+:\/+/).includes("/")) {
urlFilter += "/";
}
return urlFilter;
}
const reportedCaps = ['script', 'object', 'media', 'frame', 'font'];
const reportingCSP = `${reportedCaps
.map(cap => `${cap}-src 'none'`)
.join(';')
}; report-to noscript-reports-${uuid()}`; // see /content/content.js securitypolicyviolation handler
let updatingSemaphore;
async function update() {
await updatingSemaphore;
const {policy} = ns;
if (policy === _lastPolicy) {
if (!policy || policy.equals(_lastPolicy)) {
return await updateTabs();
}
_lastPolicy = policy;
}
const Rules = {
// Using capitalized keys to allow DRY tricks with get/update methods
Session: [],
Dynamic: [{
id: 1,
priority: REPORT_PRIORITY,
action: {
type: "modifyHeaders",
responseHeaders: [{
header: "content-security-policy-report-only",
operation: "set",
value: reportingCSP,
}],
},
condition: {
resourceTypes: ["main_frame", "sub_frame"],
},
}],
lastId: 1,
add({capabilities, temp}, priority = SITE_PRIORITY, siteKey) {
const urlFilter = siteKey ? toUrlFilter(siteKey) : undefined;
forBlockAllow(capabilities, (type, resourceTypes) => {
const rules = temp ? this.Session : this.Dynamic;
const id = (temp ? SESSION_BASE : DYNAMIC_BASE) + rules.length;
rules.push({
id,
priority,
action: {
type,
},
condition: {
urlFilter,
resourceTypes,
}
});
});
}
};
if (policy?.enforced) {
Rules.add(policy.DEFAULT, DEFAULT_PRIORITY);
for (const [siteKey, perms] of [...policy.sites]) {
Rules.add(perms, SITE_PRIORITY, siteKey);
}
}
await addTabRules(Rules.Session);
const ts = Date.now(); // DEV_ONLY
await Promise.allSettled(["Dynamic", "Session"].map((async (ruleType) => {
const ts = Date.now(); // DEV_ONLY
const removeRuleIds = (
await browser.declarativeNetRequest[`get${ruleType}Rules`]()
).filter(r => r.priority <= MAX_PRIORITY).map(r => r.id);
try {
await browser.declarativeNetRequest[`update${ruleType}Rules`]({
addRules: Rules[ruleType],
removeRuleIds,
});
console.debug(`DNRPolicy ${Rules[ruleType].length} ${ruleType} rules updated in ${Date.now() - ts}ms`); // DEV_ONLY
} catch (e) {
console.error(e, `Failed to update DNRPolicy ${ruleType}rules %o - remove %o, add %o`, Rules[ruleType], addRules, removeRuleIds);
}
})));
console.debug(`All DNRPolicy rules updated in ${Date.now() - ts}ms`); // DEV_ONLY
}
async function addTabRules(rules = []) {
if (ns.unrestrictedTabs.size) {
rules.push({
id: TAB_BASE,
priority: TAB_PRIORITY,
action: {
type: "allowAllRequests",
},
condition: {
tabIds: [...ns.unrestrictedTabs],
resourceTypes: ["main_frame", "sub_frame"],
}
});
}
await addCtxRules(rules);
return rules;
}
async function addCtxRules(rules) {
const {policy} = ns;
const cascade = ns.sync.cascadeRestrictions;
const ctxSettings = [...policy.sites].filter(([siteKey, perms]) => perms.contextual?.size);
const tabs = (ctxSettings.length || cascade) &&
(await browser.tabs.query({})).filter(tab => !ns.unrestrictedTabs.has(tab.id));
if (!tabs?.length) {
return rules;
}
for (const [siteKey, perms] of ctxSettings) {
const tabIds = tabs.filter(tab => perms.contextual.match(tab.url)).map(tab => tab.id);
if (!tabIds.length) continue;
const urlFilter = toUrlFilter(siteKey);
forBlockAllow(perms.capabilities, (type, resourceTypes) => {
rules.push({
id: TAB_BASE + rules.length,
priority: CTX_PRIORITY,
action: {
type,
},
condition: {
tabIds,
urlFilter,
resourceTypes,
}
});
});
}
if (!cascade) {
return rules;
}
const tabPresets = new Map();
for({url, id} of tabs) {
const resourceTypes = ResourceTypeFor.block(policy.get(url).perms.capabilities);
if (!resourceTypes.length) continue;
const key = JSON.stringify(resourceTypes);
if (tabPresets.has(key)) {
tabPresets.get(key).tabIds.push(id);
} else {
tabPresets.set(key, {
resourceTypes,
tabIds: [id],
});
}
}
for (const {resourceTypes, tabIds} of tabPresets.values()) {
rules.push({
id: TAB_BASE + rules.length,
priority: CASCADE_PRIORITY,
action: {
type: "block",
},
condition: {
tabIds,
resourceTypes,
}
});
}
}
async function updateTabs() {
const ts = Date.now();
const removeRuleIds = (
await browser.declarativeNetRequest.getSessionRules()
).filter(r => r.id >= TAB_BASE && r.id < DYNAMIC_BASE &&
r.priority <= MAX_PRIORITY && r.condition.tabIds)
.map(r => r.id);
const addRules = await addTabRules();
try {
await browser.declarativeNetRequest.updateSessionRules({
addRules,
removeRuleIds,
});
console.debug(`DNRPolicy tab-bound rules updated in ${Date.now() - ts}ms`); // DEV_ONLY
} catch (e) {
console.error(e, `Failed to update DNRPolicy tab-bound rules (remove %o, add %o)`, addRules, removeRuleIds);
}
}
RequestGuard.DNRPolicy = {
async update() {
await updatingSemaphore;
updatingSemaphore = await update();
},
async updateTabs() {
await updatingSemaphore;
updatingSemaphore = await updateTabs();
}
}
browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
if (changeInfo.url) {
// TODO: see if the update can be made more granular
await RequestGuard.DNRPolicy.updateTabs();
}
});
let delay;
browser.tabs.onRemoved.addListener((tabId, removeInfo) => {
// let's coalesce tabs updates on close
delay ??= setTimeout(() => {
delay = undefined;
RequestGuard.DNRPolicy.updateTabs();
}, 500);
});
};