Add ability to add domains to an "allow all" list from the browser action, issue #6

This commit is contained in:
Peter Snyder 2017-11-11 09:03:51 -05:00
parent 6b0b35cdf5
commit 95480a5449
19 changed files with 507 additions and 66 deletions

View File

@ -23,6 +23,7 @@
"valid-jsdoc": "error",
"prefer-const": "error",
"no-template-curly-in-string": "error",
"no-unused-expressions": "error"
"no-unused-expressions": "error",
"no-trailing-spaces": "error"
}
}

View File

@ -54,14 +54,14 @@
rootObject.tabs.onUpdated.addListener(updateBrowserActionBadge);
rootObject.tabs.onActivated.addListener(updateBrowserActionBadge);
window.setInterval(function () {
rootObject.tabs.getCurrent(function (currentTab) {
if (currentTab === undefined) {
return;
}
updateBrowserActionBadge({tabId: currentTab.id});
});
}, 1000);
// window.setInterval(function () {
// rootObject.tabs.getCurrent(function (currentTab) {
// if (currentTab === undefined) {
// return;
// }
// updateBrowserActionBadge({tabId: currentTab.id});
// });
// }, 1000);
// Listen for updates to the domain rules from the config page.
// The two types of messages that are sent to the background page are
@ -73,26 +73,56 @@
rootObject.runtime.onMessage.addListener(function (request, ignore, sendResponse) {
const [label, data] = request;
// Sent from the config page, when the "which APIs should be
// blocked for which domains" has been changed on the config page.
if (label === "stateUpdate") {
domainRules = data.domainRules;
shouldLog = data.shouldLog;
return;
}
// Sent from the popup / browser action, asking for infromation about
// which blocking rules are being applied to which domains in the
// tab.
if (label === "rulesForDomains") {
const matchHostName = domainMatcherLib.matchHostName;
const matchHostNameBound = matchHostName.bind(undefined, Object.keys(domainRules));
const rulesForDomains = data.map(matchHostNameBound);
const domainToRuleMapping = {};
data.forEach(function (aHostName, index) {
domainToRuleMapping[aHostName] = rulesForDomains[index] || defaultKey;
data.forEach(function (aHostName) {
const ruleNameForHost = matchHostNameBound(aHostName) || defaultKey;
domainToRuleMapping[aHostName] = {
"ruleName": ruleNameForHost,
"numRules": domainRules[ruleNameForHost].length
};
});
sendResponse(domainToRuleMapping);
return;
}
// Sent from the popup / browser action, saying that a given
// host name should have the default blocking rule applied
// (action === "block", or all APIs allowed
// (action === "allow").
if (label === "toggleBlocking") {
const {action, hostName} = data;
if (action === "block") {
delete domainRules[hostName];
sendResponse(["toggleBlockingResponse", domainRules[defaultKey].length]);
} else if (action === "allow") {
domainRules[hostName] = [];
sendResponse(["toggleBlockingResponse", 0]);
}
storageLib.set({
domainRules,
shouldLog
});
}
});
const requestFilter = {

View File

@ -3,6 +3,75 @@
const defaultDomain = "(default)";
/**
* Checks if two arrays contain the same values, regardless of order.
*
* @param {array} arrayOne
* One array to test.
* @param {array} arrayTwo
* The second array to test against.
*
* @return {bool}
* Returns true if the two arrays contain all of the same values,
* an otherwise false.
*/
const areArrayValuesIdentical = function (arrayOne, arrayTwo) {
if (arrayOne.length !== arrayTwo.length) {
return false;
}
const arrayOneSorted = arrayOne.sort();
const arrayTwoSorted = arrayTwo.sort();
const areAllValuesEqual = arrayOneSorted.every(function (value, index) {
return value === arrayTwoSorted[index];
});
return areAllValuesEqual;
};
/**
* Checks if two domain rule sets describe the standard blocking same policy.
*
* This check is independent of the ordering of the domain matching rules
* (the keys of the rule sets), or the standards that should be blocked
* (the values in the rule sets).
*
* @param {object} firstRuleSet
* The first rule set to compare.
* @param {object} secondRuleSet
* The second rule set to compare against.
*
* @return {bool}
* Returns true if the two objects describe identical policies
* (ie would block the same standards on the same domains), and
* otherwise false.
*/
const areRuleSetsIdentical = function (firstRuleSet, secondRuleSet) {
const firstRuleSetDomains = Object.keys(firstRuleSet).sort();
const secondRuleSetDomains = Object.keys(secondRuleSet).sort();
// First check if both rule sets have the same matching patterns
// defined. If not, then no need to consider further.
const haveSameMatchPatterns = areArrayValuesIdentical(firstRuleSet, secondRuleSetDomains);
if (haveSameMatchPatterns === false) {
return false;
}
// Next, now that we know both rule sets have rules describing the
// same domains, check that standards blocked for all domains are
// the same.
return firstRuleSetDomains.every(function (value) {
if (secondRuleSet[value] === undefined) {
return false;
}
return areArrayValuesIdentical(firstRuleSet[value], secondRuleSet[value]);
});
};
const generateStateObject = function (initialDomain, standards) {
const state = {
@ -20,15 +89,45 @@
};
},
domainsBlockingNoStandards: function () {
return this.domainNames
.filter(domain => this.domainRules[domain].length === 0)
.sort();
},
domainsBlockingStandards: function () {
return this.domainNames
.filter(domain => this.domainRules[domain].length > 0)
.sort();
},
populateFromStorage: function (storedValues) {
this.setDomainRules(storedValues.domainRules);
this.setShouldLog(storedValues.shouldLog);
},
setDomainRules: function (newDomainRules) {
const isRuleSetMatchingCurrentRules = areRuleSetsIdentical(
newDomainRules,
this.domainRules
);
// If the "new" domain rule set is identical to the existing
// one, then don't set any propreties (to avoid unnecessarily
// triggering storage and Vue.js callbacks).
if (isRuleSetMatchingCurrentRules === true) {
return;
}
this.domainRules = newDomainRules;
this.domainNames = Object.keys(newDomainRules);
this.selectedStandards = this.domainRules[this.selectedDomain];
if (this.domainRules[this.selectedDomain] === undefined) {
this.selectedStandards = this.domainRules[this.defaultDomain];
} else {
this.selectedStandards = this.domainRules[this.selectedDomain];
}
},
setSelectedDomain: function (newDomain) {
@ -77,6 +176,8 @@
};
window.WEB_API_MANAGER.stateLib = {
generateStateObject
generateStateObject,
areRuleSetsIdentical,
areArrayValuesIdentical
};
}());

View File

@ -1,7 +1,7 @@
(function () {
"use strict";
const rootObject = (window.browser || window.chrome);
const rootObject = window.browser || window.chrome;
const doc = window.document;
const standards = window.WEB_API_MANAGER.standards;
const {storageLib, stateLib} = window.WEB_API_MANAGER;
@ -10,12 +10,14 @@
const state = stateLib.generateStateObject(defaultDomain, standards);
let globalVmInstance;
const onSettingsLoaded = function (storedSettings) {
state.populateFromStorage(storedSettings);
state.activeTab = "domain-rules";
const vm = new Vue({
globalVmInstance = new Vue({
el: doc.querySelector("#config-root"),
render: window.WEB_API_MANAGER.vueComponents["config-root"].render,
staticRenderFns: window.WEB_API_MANAGER.vueComponents["config-root"].staticRenderFns,
@ -36,12 +38,15 @@
});
};
vm.$watch("selectedStandards", updateStoredSettings);
vm.$watch("domainNames", updateStoredSettings);
vm.$watch("shouldLog", updateStoredSettings);
globalVmInstance.$watch("selectedStandards", updateStoredSettings);
globalVmInstance.$watch("domainNames", updateStoredSettings);
globalVmInstance.$watch("shouldLog", updateStoredSettings);
};
window.onload = function () {
storageLib.get(onSettingsLoaded);
storageLib.onChange(function (newStoredValues) {
globalVmInstance.$data.setDomainRules(newStoredValues.domainRules);
});
};
}());

View File

@ -14,6 +14,12 @@
};
},
methods: {
blockingRules: function () {
return this.$root.$data.domainsBlockingStandards();
},
allowingRules: function () {
return this.$root.$data.domainsBlockingNoStandards();
},
newDomainSubmitted: function () {
const state = this.$root.$data;

View File

@ -51,7 +51,7 @@
const logMessages = newDomainRules.map(function (newDomainRule) {
const {pattern, standards} = newDomainRule;
if (currentDomainRules[pattern] !== undefined && shouldOverwrite === false) {
return ` ! ${pattern}: Skipped. Set to not override.\n`;
return ` ! ${pattern}: Skipped. Set to not override.\n`;
}
stateObject.setStandardsForDomain(pattern, standards);

View File

@ -55,7 +55,7 @@
/**
* Returns a new CSP instruction, with source with the given hash
* whitelisted.
*
*
* If the CSP instruction has a "script-src" rule, then the hash-value
* will be inserted there. Otherwise, it will be inserted in the
* default-src section.

View File

@ -20,7 +20,7 @@
/**
* Encodes a buffer (such as a Uint8Array) to a base64 encoded string.
*
*
* @param {ArrayBuffer} buf
* A buffer of binary data.
*

View File

@ -34,8 +34,39 @@
storageObject.sync.set(valueToStore, callback);
};
const onChange = (function () {
const queue = [];
storageObject.onChanged.addListener(function (changes) {
if (changes[webApiManagerKeySettingsKey] === undefined) {
return;
}
const {newValue, oldValue} = changes[webApiManagerKeySettingsKey];
if (JSON.stringify(newValue) === JSON.stringify(oldValue)) {
return;
}
queue.forEach(function (callback) {
try {
callback(newValue);
} catch (e) {
// Intentionally left blank...
}
});
});
return function (callback) {
queue.push(callback);
};
}());
window.WEB_API_MANAGER.storageLib = {
get,
set
set,
onChange
};
}());

View File

@ -1,5 +1,6 @@
body, html {
min-width: 320px;
width: 640px;
}
section .row {
@ -12,4 +13,4 @@ section .row {
.loaded .loading-section {
display: none;
}
}

View File

@ -5,31 +5,131 @@
const rootObject = window.browser || window.chrome;
const doc = window.document;
const configureButton = doc.getElementById("config-page-link");
const listGroupElm = doc.querySelector("ul.list-group");
const domainRuleTableBody = doc.querySelector("#domain-rule-table tbody");
const defaultDomainRule = "(default)";
const addRuleToList = function (hostToRuleMapping, listElm, aHostName) {
/**
* Returns a function for use as the "onclick" handler for a toggle button.
*
* @param {string} hostName
* The name of the host to change the blocking settings for.
* @param {string} action
* Either "allow" (indicating that all APIs should be allowed for this
* host) or "block" (indicating that all APIs should be blocked for
* this host).
*
* @return {function}
* A function that takes a single event object as an argument. For
* use as an event handler callback.
*/
const createOnToggleHandler = function (hostName, action) {
const onClickHandler = function (event) {
const message = ["toggleBlocking", {
"action": action,
"hostName": hostName
}];
const button = event.target;
const containingRowElm = button.parentNode.parentNode;
const domainRule = hostToRuleMapping[aHostName];
const appliedRuleTd = containingRowElm.querySelector("td:nth-child(2)");
const numApisBlockedTd = containingRowElm.querySelector("td:nth-child(3)");
const liElm = doc.createElement("li");
liElm.className = "list-group-item";
button.className += " disabled";
button.innerHtml = "setting…";
if (domainRule !== "(default)") {
liElm.className += " list-group-item-success";
rootObject.runtime.sendMessage(message, function (responseMessage) {
const [messageType, numAPIsBlocked] = responseMessage;
if (messageType === "toggleBlockingResponse") {
numApisBlockedTd.innerText = numAPIsBlocked;
if (action === "block") {
appliedRuleTd.innerText = defaultDomainRule;
} else if (action === "allow") {
appliedRuleTd.innerText = hostName;
}
button.innerText = "👍";
}
});
event.preventDefault();
event.stopImmediatePropagation();
};
return onClickHandler;
};
/**
* Generates a TR element based on a domain's blocking status
*
* @param {string} domainName
* The name of a domain of a frame on the current tab
* @param {string} appliedRuleName
* The pattern matching rule for the rule set applied (or,
* if no matching rule "(default)").
* @param {number} numAPIsBlocked
* The number of APIs blocked for this domain.
*
* @return {Node}
* a HTMLTRElement object.
*/
const ruleToTr = function (domainName, appliedRuleName, numAPIsBlocked) {
const trElm = doc.createElement("tr");
const domainTd = doc.createElement("td");
const domainTdText = doc.createTextNode(domainName);
domainTd.appendChild(domainTdText);
trElm.appendChild(domainTd);
const ruleTd = doc.createElement("td");
ruleTd.appendChild(doc.createTextNode(appliedRuleName));
trElm.appendChild(ruleTd);
const numBlockedTd = doc.createElement("td");
numBlockedTd.appendChild(doc.createTextNode(numAPIsBlocked));
trElm.appendChild(numBlockedTd);
const actionsTd = doc.createElement("td");
const toggleButton = doc.createElement("button");
toggleButton.className = "btn btn-default btn-xs block-toggle";
const isAllowingAll = numAPIsBlocked === 0;
let toggleButtonText;
let toggleAction;
// If the domain is using the default rule, and the default rule is
// allowing all API's then do nothing, since there is no sensible
// option to "toggle" to.
if (isAllowingAll === false) {
toggleButtonText = "allow all";
toggleButton.className += " success";
toggleAction = "allow";
} else {
toggleButtonText = "remove grant";
toggleButton.className += " warn";
toggleAction = "block";
}
const spanElm = doc.createElement("span");
spanElm.className = "badge";
if (toggleButtonText !== undefined) {
const toggleButtonTextElm = doc.createTextNode(toggleButtonText);
toggleButton.appendChild(toggleButtonTextElm);
const badgeText = doc.createTextNode(domainRule);
spanElm.appendChild(badgeText);
liElm.appendChild(spanElm);
if (toggleAction !== undefined) {
const onClickToggleButton = createOnToggleHandler(domainName, toggleAction);
toggleButton.addEventListener("click", onClickToggleButton, false);
}
}
const textElm = doc.createTextNode(aHostName);
liElm.appendChild(textElm);
listElm.appendChild(liElm);
actionsTd.appendChild(toggleButton);
trElm.appendChild(actionsTd);
return trElm;
};
configureButton.addEventListener("click", function (event) {
@ -52,10 +152,12 @@
doc.body.className = "loaded";
const domainNames = Object.keys(response);
const addRuleToListBound = addRuleToList.bind(undefined, response, listGroupElm);
domainNames.forEach(addRuleToListBound);
const currentDomains = Object.keys(response);
currentDomains.forEach(function (aDomain) {
const {ruleName, numRules} = response[aDomain];
const rowElm = ruleToTr(aDomain, ruleName, numRules);
domainRuleTableBody.appendChild(rowElm);
});
});
}
);

View File

@ -27,8 +27,19 @@
<div class="loaded-section">
<p>The following origins are executing code on this page.</p>
<ul class="list-group">
</ul>
<table class="table table-striped" id="domain-rule-table">
<thead>
<tr>
<th>Domain</th>
<th>Rule</th>
<th># APIs Blocked</th>
<th>Action</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
<button type="button" id="config-page-link" class="btn btn-default btn-block">

View File

@ -28,7 +28,8 @@
"lint:fix": "node_modules/eslint/bin/eslint.js --fix .",
"test": "npm run clean; npm run bundle && ln -s `ls dist/` dist/webapi_manager.zip && node_modules/cross-env/dist/bin/cross-env.js node_modules/mocha/bin/mocha test/unit/*.js test/functional/*.js --only-local-tests",
"test:watch": "node_modules/cross-env/dist/bin/cross-env.js npm test --watch",
"test:all": "npm run clean; npm run bundle && ln -s `ls dist/` dist/webapi_manager.zip && node_modules/cross-env/dist/bin/cross-env.js node_modules/mocha/bin/mocha test/unit/*.js test/functional/*.js"
"test:all": "npm run clean; npm run bundle && ln -s `ls dist/` dist/webapi_manager.zip && node_modules/cross-env/dist/bin/cross-env.js node_modules/mocha/bin/mocha test/unit/*.js test/functional/*.js",
"test:unit": "npm run clean; npm run bundle && ln -s `ls dist/` dist/webapi_manager.zip && node_modules/cross-env/dist/bin/cross-env.js node_modules/mocha/bin/mocha test/unit/*.js"
},
"pre-push": {
"run": [

View File

@ -1,5 +1,6 @@
<div class="domain-rules-container well">
<div class="radio" v-for="aDomain in domainNames">
<h3>Domains</h3>
<div class="radio" v-for="aDomain in blockingRules()">
<label>
<input type="radio"
:value="aDomain"
@ -13,16 +14,31 @@
</label>
</div>
<h3>Whitelisted Domains</h3>
<div class="radio" v-for="aDomain in allowingRules()">
<label>
<input type="radio"
:value="aDomain"
v-model="selectedDomain"
@change="onRadioChange">
{{ aDomain }}
<span class="glyphicon glyphicon-remove"
v-if="!isDefault(aDomain)"
:data-domain="aDomain"
@click="onRemoveClick"></span>
</label>
</div>
<div class="alert alert-danger" role="alert" v-if="errorMessage">
{{ errorMessage }}
</div>
<div class="form-group" v-bind:class="{ 'has-error': errorMessage }">
<label for="newDomainName">Add New Domain Rule</label>
<input
class="form-control"
v-model.trim="newDomain"
placeholder="*.example.org">
<input class="form-control"
v-model.trim="newDomain"
placeholder="*.example.org">
</div>
<button type="submit"

View File

@ -6,6 +6,7 @@ const testServer = require("./lib/server");
const webdriver = require("selenium-webdriver");
const by = webdriver.By;
const until = webdriver.until;
const Context = require("selenium-webdriver/firefox").Context;
describe("Basic Functionality", function () {

View File

@ -19,7 +19,7 @@ const promiseOpenImportExportTab = function (driver) {
describe("Import / Export", function () {
this.timeout = () => 10000;
this.timeout = () => 20000;
describe("Exporting", function () {
@ -35,10 +35,14 @@ describe("Import / Export", function () {
.then(() => driverReference.findElement(by.css(".export-section select option:nth-child(1)")).click())
.then(() => driverReference.findElement(by.css(".export-section textarea")).getAttribute("value"))
.then(function (exportValue) {
driverReference.quit();
assert.equal(exportValue.trim(), emptyRuleSet, "Exported ruleset does not match expected value.");
done();
})
.catch(done);
.catch(function (e) {
driverReference.quit();
done(e);
});
});
it("Exporting SVG and Beacon blocking rules", function (done) {
@ -55,9 +59,13 @@ describe("Import / Export", function () {
.then(() => driverReference.findElement(by.css(".export-section textarea")).getAttribute("value"))
.then(function (exportValue) {
assert.equal(exportValue.trim(), blockingSVGandBeacon, "Exported ruleset does not match expected value.");
driverReference.quit();
done();
})
.catch(done);
.catch(function (e) {
driverReference.quit();
done(e);
});
});
});
@ -89,10 +97,13 @@ describe("Import / Export", function () {
})
.then(function (secondCheckboxValue) {
assert.equal(secondCheckboxValue, utils.constants.svgBlockRule[0], "The second blocked standard should be the SVG standard.");
driverReference.close();
driverReference.quit();
done();
})
.catch(done);
.catch(function (e) {
driverReference.quit();
done(e);
});
});
it("Importing rules for new domain", function (done) {
@ -112,7 +123,7 @@ describe("Import / Export", function () {
.then(() => driverReference.findElements(by.css("#domain-rules input[type='radio']")))
.then(function (radioElms) {
assert.equal(radioElms.length, 2, "There should be two domain rules in place.");
return radioElms[1].click();
return radioElms[0].click();
})
.then(() => driverReference.findElements(by.css("#domain-rules input[type='checkbox']:checked")))
.then(function (checkboxElms) {
@ -126,10 +137,13 @@ describe("Import / Export", function () {
})
.then(function (secondCheckboxValue) {
assert.equal(secondCheckboxValue, "WebGL Specification", "The second blocked standard should be 'WebGL Specification'.");
driverReference.close();
driverReference.quit();
done();
})
.catch(done);
.catch(function (e) {
driverReference.quit();
done(e);
});
});
});
});

View File

@ -66,11 +66,6 @@ module.exports.promiseExtensionConfigPage = function (driver) {
});
};
module.exports.promiseAddonConfigButton = function (driver) {
driver.setContext(Context.CHROME);
return driver.wait(until.elementLocated(by.id("config-page-link")), 2000);
};
module.exports.promiseSetBlockingRules = function (driver, standardsToBlock) {
const setStandardsScript = injectedScripts.setStandardsAsBlockedScript(standardsToBlock);
driver.setContext(Context.CONTENT);

View File

@ -1,7 +1,7 @@
/**
* Tests to ensure that the pattern matching code (what determins which
* standard blocking rules should be applied to which domain) is correct.
*
*
* The code being tested here mostly lives in (from the project root)
* add-on/lib/domainmatcher.js
*/

View File

@ -0,0 +1,126 @@
/**
* Tests to ensure that the tools that are used to manage the state
* of the extension perform as expected..
*
* The code being tested here mostly lives in (from the project root)
* add-on/config/js/state.js
*/
"use strict";
const assert = require("assert");
const path = require("path");
const addonLibPath = path.join(__dirname, "..", "..", "add-on", "lib");
const addonConfigLibPath = path.join(__dirname, "..", "..", "add-on", "config", "js");
// These will end up not returing anything, but will instead populate
// window.WEB_API_MANAGER
require(path.join(addonLibPath, "init.js"));
require(path.join(addonConfigLibPath, "state.js"));
const stateLib = window.WEB_API_MANAGER.stateLib;
describe("Extension State Management", function () {
describe("Array comparisons", function () {
it("Identical values in same order", function (done) {
const firstArray = [1, 2, 3, "A", "B", "C"];
const secondArray = [1, 2, 3, "A", "B", "C"];
const areEqual = stateLib.areArrayValuesIdentical(firstArray, secondArray);
assert.equal(areEqual, true, "Arrays contain identical values, so should evaluate to identical.");
done();
});
it("Identical values in different order", function (done) {
const firstArray = [1, 2, 3, "A", "B", "C"];
const secondArray = [1, "B", 2, "A", 3, "C"];
const areEqual = stateLib.areArrayValuesIdentical(firstArray, secondArray);
assert.equal(areEqual, true, "Arrays contain identical values, so should evaluate to identical.");
done();
});
it("Different values", function (done) {
const firstArray = [1, 2, 3, "A", "B", "C"];
const secondArray = ["Totally", "different", "values"];
const areEqual = stateLib.areArrayValuesIdentical(firstArray, secondArray);
assert.equal(areEqual, false, "Arrays contain different values, so should evaluate to not identical.");
done();
});
});
describe("Domain rule comparisons", function () {
it("Identical rule sets: same order", function (done) {
const firstRuleSet = {
"(default)": [],
"www.example.com": ["Beacon", "Vibrate API"]
};
const secondRuleSet = {
"(default)": [],
"www.example.com": ["Beacon", "Vibrate API"]
};
const areEqual = stateLib.areRuleSetsIdentical(firstRuleSet, secondRuleSet);
assert.equal(areEqual, false, "Both rule sets block the same standards on the same domains.");
done();
});
it("Identical rule sets: different orders", function (done) {
const firstRuleSet = {
"(default)": [],
"www.example.com": ["Beacon", "Vibrate API"]
};
const secondRuleSet = {
"www.example.com": ["Vibrate API", "Beacon"],
"(default)": []
};
const areEqual = stateLib.areRuleSetsIdentical(firstRuleSet, secondRuleSet);
assert.equal(areEqual, false, "Both rule sets block the same standards on the same domains.");
done();
});
it("Different rule sets: different domains", function (done) {
const firstRuleSet = {
"(default)": [],
"www.example.com": ["Beacon", "Vibrate API"]
};
const secondRuleSet = {
"(default)": [],
"www.example.net": ["Beacon", "Vibrate API"]
};
const areEqual = stateLib.areRuleSetsIdentical(firstRuleSet, secondRuleSet);
assert.equal(areEqual, false, "The domains being described by these rule sets are different.");
done();
});
it("Different rule sets: different standards", function (done) {
const firstRuleSet = {
"(default)": [],
"www.example.com": ["Beacon", "Vibrate API"]
};
const secondRuleSet = {
"(default)": [],
"www.example.com": ["Beacon", "Gamepad API"]
};
const areEqual = stateLib.areRuleSetsIdentical(firstRuleSet, secondRuleSet);
assert.equal(areEqual, false, "The standards blocked by the rule sets are different.");
done();
});
});
});