348 lines
No EOL
12 KiB
JavaScript
348 lines
No EOL
12 KiB
JavaScript
/*
|
|
* NoScript - a Firefox extension for whitelist driven safe JavaScript execution
|
|
*
|
|
* Copyright (C) 2005-2021 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";
|
|
|
|
var LifeCycle = (() => {
|
|
|
|
const AES = "AES-GCM",
|
|
keyUsages = ["encrypt", "decrypt"];
|
|
|
|
function toBase64(bytes) {
|
|
return btoa(Array.from(bytes).map(b => String.fromCharCode(b)).join(''));
|
|
}
|
|
function fromBase64(string) {
|
|
return Uint8Array.from((Array.from(atob(string)).map(c => c.charCodeAt(0))));
|
|
}
|
|
async function encrypt(clearText) {
|
|
let key = await crypto.subtle.generateKey({
|
|
name: AES,
|
|
length: 256,
|
|
},
|
|
true,
|
|
keyUsages,
|
|
);
|
|
let iv = crypto.getRandomValues(new Uint8Array(12));
|
|
let encoded = new TextEncoder().encode(clearText);
|
|
let cypherText = await crypto.subtle.encrypt({
|
|
name: AES,
|
|
iv
|
|
}, key, encoded);
|
|
return {cypherText, key: await crypto.subtle.exportKey("jwk", key), iv};
|
|
}
|
|
|
|
var LifeBoat = {
|
|
url: "about:blank",
|
|
async createAndStore() {
|
|
let allSeen = {};
|
|
let tab;
|
|
await Promise.all((await browser.tabs.query({})).map(
|
|
async t => {
|
|
let seen = await ns.collectSeen(t.id);
|
|
if (seen) {
|
|
allSeen[t.id] = seen;
|
|
if (!tab || !tab.incognito && t.incognito) {
|
|
tab = t;
|
|
}
|
|
}
|
|
}
|
|
));
|
|
|
|
|
|
if (!tab) { // no suitable existing tab, let's open a new one
|
|
if (!UA.isMozilla) {
|
|
// injecting new about:blank tabs is supported only by Mozilla: let's bailout
|
|
return;
|
|
}
|
|
let {url} = LifeBoat;
|
|
let tabInfo = {
|
|
url,
|
|
active: false,
|
|
};
|
|
if (browser.windows) { // it may be missing on mobile
|
|
// check if an incognito window exists and open our "survival" tab there
|
|
for (let w of await browser.windows.getAll()) {
|
|
if (w.incognito) {
|
|
tabInfo.windowId = w.id;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
for (;!tab;) {
|
|
try {
|
|
tab = await browser.tabs.create(tabInfo);
|
|
} catch (e) {
|
|
error(e);
|
|
if (tabInfo.windowId) {
|
|
// we might not have incognito permissions, let's try using any window
|
|
delete tabInfo.windowId;
|
|
} else {
|
|
return; // bailout
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let tabId = tab.id;
|
|
let {url} = tab;
|
|
let {cypherText, key, iv} = await encrypt(JSON.stringify({
|
|
policy: ns.policy.dry(true),
|
|
allSeen,
|
|
unrestrictedTabs: [...ns.unrestrictedTabs]
|
|
}));
|
|
|
|
let attr;
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
let l = async (tabId, changeInfo) => {
|
|
if (!!attr || tabId !== tab.id) return;
|
|
debug("Survival tab updating", changeInfo);
|
|
if (changeInfo.status !== "complete") return;
|
|
try {
|
|
attr = await Messages.send("store", {url, data: toBase64(new Uint8Array(cypherText))}, {tabId, frameId: 0});
|
|
resolve();
|
|
debug("Survival tab updated");
|
|
} catch (e) {
|
|
if (!Messages.isMissingEndpoint(e)) {
|
|
error(e, "Survival tab failed");
|
|
reject(e);
|
|
} // otherwise we keep waiting for further updates from the tab until content script is ready to answer
|
|
return false;
|
|
};
|
|
return true;
|
|
};
|
|
|
|
|
|
l(tabId, tab).then(r => {
|
|
if (!r) browser.tabs.onUpdated.addListener(l);
|
|
});
|
|
});
|
|
|
|
await Storage.set("local", { "updateInfo": {key, iv: toBase64(iv), tabId, url, attr}});
|
|
tabId = -1;
|
|
debug("Ready to reload...", await Storage.get("local", "updateInfo"));
|
|
} finally {
|
|
if (tabId !== -1 && url === LifeBoat.url && !ns.local.debug) {
|
|
browser.tabs.remove(tabId); // cleanup on failure unless we want to debug a post-mortem
|
|
}
|
|
}
|
|
},
|
|
|
|
async retrieveAndDestroy() {
|
|
let {updateInfo} = await Storage.get("local", "updateInfo");
|
|
if (!updateInfo) return;
|
|
await Storage.remove("local", "updateInfo");
|
|
let {key, iv, tabId, attr, url} = updateInfo;
|
|
|
|
let destroyIfNeeded = url === LifeBoat.url ? (keepIfDebug = false) => {
|
|
if (tabId === -1 || url !== LifeBoat.url) return;
|
|
if (keepIfDebug && ns.local.debug) {
|
|
debug("Failed survival tab %s left open for debugging.", tabId);
|
|
} else {
|
|
browser.tabs.remove(tabId);
|
|
}
|
|
tabId = -1;
|
|
} : () => {};
|
|
|
|
try {
|
|
key = await crypto.subtle.importKey("jwk", key, AES, true, keyUsages);
|
|
iv = fromBase64(iv);
|
|
let cypherText;
|
|
for (let attempts = 3; attempts-- > 0;) {
|
|
try {
|
|
cypherText = await Messages.send("retrieve", {url, attr}, {tabId, frameId: 0});
|
|
break;
|
|
} catch (e) {
|
|
if (Messages.isMissingEndpoint(e)) {
|
|
debug("Cannot retrieve survival tab data, maybe content script not loaded yet. Retrying...");
|
|
await ns.initializing;
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
if (!cypherText) {
|
|
throw new Error("Could not retrieve survival tab data!");
|
|
}
|
|
cypherText = fromBase64(cypherText);
|
|
let encoded = await crypto.subtle.decrypt({
|
|
name: AES,
|
|
iv
|
|
}, key, cypherText
|
|
);
|
|
let {policy, allSeen, unrestrictedTabs} = JSON.parse(new TextDecoder().decode(encoded));
|
|
if (!policy) {
|
|
throw new error("Ephemeral policy not found in survival tab %s!", tabId);
|
|
}
|
|
ns.unrestrictedTabs = new Set(unrestrictedTabs);
|
|
destroyIfNeeded();
|
|
if (ns.initializing) await ns.initializing;
|
|
ns.policy = new Policy(policy);
|
|
await Promise.all(
|
|
Object.entries(allSeen).map(
|
|
async ([tabId, seen]) => {
|
|
try {
|
|
debug("Restoring seen %o to tab %s", seen, tabId);
|
|
await Messages.send("allSeen", {seen}, {tabId, frameId: 0});
|
|
} catch (e) {
|
|
error(e, "Cannot send previously seen data to tab", tabId);
|
|
}
|
|
}
|
|
)
|
|
);
|
|
} catch (e) {
|
|
error(e);
|
|
} finally {
|
|
destroyIfNeeded(true);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
async onInstalled(details) {
|
|
browser.runtime.onInstalled.removeListener(this.onInstalled);
|
|
|
|
if (!UA.isMozilla) {
|
|
// Chromium does not inject content scripts at startup automatically for already loaded pages,
|
|
// let's hack it manually.
|
|
let contentScripts = browser.runtime.getManifest().content_scripts.find(s =>
|
|
s.js && s.matches.includes("<all_urls>") && s.all_frames && s.match_about_blank).js;
|
|
|
|
await Promise.all((await browser.tabs.query({})).map(async tab => {
|
|
for (let file of contentScripts) {
|
|
try {
|
|
await browser.tabs.executeScript(tab.id, {file, allFrames: true, matchAboutBlank: true});
|
|
} catch (e) {
|
|
await include("/nscl/common/restricted.js");
|
|
if (!isRestrictedURL(tab.url)) {
|
|
error(e, "Can't run content script on tab", tab);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}));
|
|
}
|
|
|
|
let {reason, previousVersion} = details;
|
|
if (reason === "update") {
|
|
try {
|
|
await LifeBoat.retrieveAndDestroy();
|
|
} catch (e) {
|
|
error(e);
|
|
}
|
|
}
|
|
|
|
if (!previousVersion) return;
|
|
|
|
await include("/nscl/common/Ver.js");
|
|
previousVersion = new Ver(previousVersion);
|
|
let currentVersion = new Ver(browser.runtime.getManifest().version);
|
|
let upgrading = Ver.is(previousVersion, "<=", currentVersion);
|
|
if (!upgrading) return;
|
|
|
|
// put here any version specific upgrade adjustment in stored data
|
|
|
|
let forEachPreset = async (callback, presetNames = "*") => {
|
|
await ns.initializing;
|
|
let changed = false;
|
|
for (let p of ns.policy.getPresets(presetNames)) {
|
|
if (callback(p)) changed = true;
|
|
if (p.contextual) {
|
|
for (let ctxP of p.contextual.values()) {
|
|
if (callback(ctxP)) changed = true;
|
|
}
|
|
}
|
|
}
|
|
if (changed) {
|
|
await ns.savePolicy();
|
|
}
|
|
};
|
|
|
|
let configureNewCap = async (cap, presetNames, capsFilter) => {
|
|
log(`Upgrading from ${previousVersion}: configure the "${cap}" capability.`);
|
|
await forEachPreset(({capabilities}) => {
|
|
if (capsFilter(capabilities) && !capabilities.has(cap)) {
|
|
capabilities.add(cap);
|
|
return true;
|
|
}
|
|
}, presetNames);
|
|
};
|
|
|
|
let renameCap = async (oldName, newName) => {
|
|
log(`Upgrading from ${previousVersion}: rename capability "${oldName}" to "${newName}`);
|
|
await forEachPreset(({capabilities}) => {
|
|
if (capabilities.has(oldName)) {
|
|
capabilities.delete(oldName);
|
|
capabilities.add(newName);
|
|
return true;
|
|
}
|
|
});
|
|
};
|
|
|
|
if (Ver.is(previousVersion, "<=", "11.0.10")) {
|
|
await configureNewCap("ping", ["TRUSTED"]);
|
|
}
|
|
if (Ver.is(previousVersion, "<=", "11.2.1")) {
|
|
await configureNewCap("noscript", ["DEFAULT", "TRUSTED", "CUSTOM"])
|
|
}
|
|
if (Ver.is(previousVersion, "<=", "11.2.4")) {
|
|
// add the unchecked_css capability to any preset which already has the script capability
|
|
await configureNewCap("unchecked_css", ["DEFAULT", "TRUSTED", "CUSTOM"], caps => caps.has("script"));
|
|
}
|
|
if (Ver.is(previousVersion, "<=", "11.2.5rc1")) {
|
|
await renameCap("csspp0", "unchecked_css");
|
|
}
|
|
if (Ver.is(previousVersion, "<=", "11.3rc2")) {
|
|
// add the lan capability to any preset which already has the script capability
|
|
await configureNewCap("lan", ["DEFAULT", "TRUSTED", "CUSTOM"], caps => caps.has("script"));
|
|
}
|
|
|
|
if (Ver.is(previousVersion, "<=", "11.4.1rc3")) {
|
|
// show theme switcher on update unless user has already chosen between Vintage Blue and Modern Red
|
|
(async () => {
|
|
let isVintage = await Themes.isVintage();
|
|
if (typeof isVintage === "boolean") return;
|
|
await ns.init;
|
|
ns.openOptionsPage({tab: 2, focus: "#opt-vintageTheme", hilite: "#sect-themes"});
|
|
})();
|
|
}
|
|
},
|
|
|
|
async onUpdateAvailable(details) {
|
|
try {
|
|
if (ns.local.amnesticUpdates) {
|
|
// user doesn't want us to remember temporary settings across updates: bail out
|
|
return;
|
|
}
|
|
await include("/nscl/common/Ver.js");
|
|
if (Ver.is(details.version, "<", browser.runtime.getManifest().version)) {
|
|
// downgrade: temporary survival might not be supported, and we don't care
|
|
return;
|
|
}
|
|
await LifeBoat.createAndStore();
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
browser.runtime.reload(); // apply update
|
|
}
|
|
}
|
|
};
|
|
})(); |