diff --git a/src/bg/LifeCycle.js b/src/bg/LifeCycle.js index 9e78ec2..6acee42 100644 --- a/src/bg/LifeCycle.js +++ b/src/bg/LifeCycle.js @@ -20,6 +20,7 @@ // depends on /nscl/common/sha256.js // depends on /nscl/common/uuid.js +// depends on /nscl/service/Scripting.js "use strict"; @@ -56,7 +57,7 @@ var LifeCycle = (() => { async createAndStore() { let allSeen = {}; let tab; - await Promise.all((await browser.tabs.query({})).map( + await Promise.allSettled((await browser.tabs.query({})).map( async t => { let seen = await ns.collectSeen(t.id); if (seen) { @@ -215,7 +216,7 @@ var LifeCycle = (() => { destroyIfNeeded(); if (ns.initializing) await ns.initializing; ns.policy = new Policy(policy); - await Promise.all( + await Promise.allSettled( Object.entries(allSeen).map( async ([tabId, seen]) => { try { @@ -240,19 +241,19 @@ var LifeCycle = (() => { 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 => + const contentScripts = browser.runtime.getManifest().content_scripts.find(s => s.js && s.matches.includes("") && 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; + await Promise.allSettled((await browser.tabs.query({})).map(async tab => { + try { + await Scripting.executeScript({ + target: {tabId: tab.id, allFrames: true}, + files: contentScripts, + }); + } catch (e) { + await include("/nscl/common/restricted.js"); + if (!isRestrictedURL(tab.url)) { + error(e, "Can't run content script on tab", tab); } } })); diff --git a/src/bg/ReportingCSP.js b/src/bg/ReportingCSP.js index 32166cd..9757f66 100644 --- a/src/bg/ReportingCSP.js +++ b/src/bg/ReportingCSP.js @@ -20,17 +20,11 @@ "use strict"; -function ReportingCSP(marker, reportURI = "") { - const DOM_SUPPORTED = "SecurityPolicyViolationEvent" in window; - - if (DOM_SUPPORTED) reportURI = ""; +function ReportingCSP(marker) { return Object.assign( - new CapsCSP(new NetCSP( - reportURI ? `report-uri ${reportURI}` : marker - )), + new CapsCSP(new NetCSP(marker)), { - reportURI, patchHeaders(responseHeaders, capabilities) { let header = null; let blocker; diff --git a/src/bg/RequestGuard.js b/src/bg/RequestGuard.js index bf5be1c..b01dde9 100644 --- a/src/bg/RequestGuard.js +++ b/src/bg/RequestGuard.js @@ -18,13 +18,12 @@ * this program. If not, see . */ -var RequestGuard = (() => { +{ 'use strict'; const VERSION_LABEL = `NoScript ${browser.runtime.getManifest().version}`; - browser.browserAction.setTitle({title: VERSION_LABEL}); - const CSP_REPORT_URI = "https://noscript-csp.invalid/__NoScript_Probe__/"; - const CSP_MARKER = "noscript-marker"; - let csp = new ReportingCSP(CSP_MARKER, CSP_REPORT_URI); + browser.action.setTitle({title: VERSION_LABEL}); + const CSP_MARKER = "report-to noscript-reports"; + const csp = new ReportingCSP(CSP_MARKER); const policyTypesMap = { main_frame: "", @@ -49,6 +48,32 @@ var RequestGuard = (() => { } const TabStatus = { + _session: new SessionCache( + "RequestGuard.TabStatus", + { + afterLoad(data) { + if (data) { + TabStatus.map = new Map(data.map); + TabStatus._originsCache = new Map(data._originsCache); + } + }, + beforeSave() { // beforeSave + return { + map: [...TabStatus.map], + _originsCache: [...TabStatus._originsCache], + }; + }, + } + ), + init() { + for (const event of ["Activated", "Updated", "Removed"]) { + browser.tabs[`on${event}`].addListener(TabStatus[`on${event}Tab`]); + } + (async () => { + await TabStatus._session.load(); + TabStatus.updateTab(); + }); + }, map: new Map(), _originsCache: new Map(), types: ["script", "object", "media", "frame", "font"], @@ -89,6 +114,7 @@ var RequestGuard = (() => { initTab(tabId, records = this.newRecords()) { if (tabId < 0) return; this.map.set(tabId, records); + this._session.save(); return records; }, _record(request, what, optValue) { @@ -121,6 +147,7 @@ var RequestGuard = (() => { collection[type] = [requestKey]; } } + this._session.save(); return records; }, record(request, what, optValue) { @@ -132,10 +159,11 @@ var RequestGuard = (() => { } }, _pendingTabs: new Set(), - updateTab(tabId) { - if (tabId < 0) return; + async updateTab(tabId) { + tabId ??= (await browser.tabs.getCurrent())?.tabId; + if (!(tabId >= 0)) return; if (this._pendingTabs.size === 0) { - window.setTimeout(() => { // clamp UI updates + setTimeout(() => { // clamp UI updates for (let tabId of this._pendingTabs) { this._updateTabNow(tabId); } @@ -166,22 +194,22 @@ var RequestGuard = (() => { : (numAllowed ? "sub" : "no")) // not topAllowed : "global"; // not enforced let showBadge = ns.local.showCountBadge && numBlocked > 0; - let browserAction = browser.browserAction; - if (!browserAction.setIcon) { // Fennec - browserAction.setTitle({tabId, title: `NoScript (${numBlocked})`}); + let {action} = browser; + if (!action.setIcon) { // Fennec + action.setTitle({tabId, title: `NoScript (${numBlocked})`}); return; } (async () => { let iconPath = (await Themes.isVintage()) ? '/img/vintage' : '/img'; - browserAction.setIcon({tabId, path: {64: `${iconPath}/ui-${icon}64.png`}}); + action.setIcon({tabId, path: {64: `${iconPath}/ui-${icon}64.png`}}); })(); - browserAction.setBadgeText({ + action.setBadgeText({ tabId, text: TabGuard.isAnonymizedTab(tabId) ? "TG" : showBadge ? numBlocked.toString() : "" }); - browserAction.setBadgeBackgroundColor({tabId, color: [128, 0, 0, 160]}); - browserAction.setTitle({tabId, + action.setBadgeBackgroundColor({tabId, color: [128, 0, 0, 160]}); + action.setTitle({tabId, title: UA.mobile ? "NoScript" : `${VERSION_LABEL} \n${enforced ? _("BlockedItems", [numBlocked, numAllowed + numBlocked]) + ` \n${report}` : _("NotEnforced")}` @@ -233,16 +261,15 @@ var RequestGuard = (() => { TabStatus._originsCache.clear(); TabStatus._pendingTabs.delete(tabId); }, - } - for (let event of ["Activated", "Updated", "Removed"]) { - browser.tabs[`on${event}`].addListener(TabStatus[`on${event}Tab`]); - } + }; + TabStatus.init(); + + const messageHandler = { - let messageHandler = { async pageshow(message, sender) { if (sender.frameId === 0) { TabStatus.recordAll(sender.tab.id, message.seen); - } else { + } else if (sender.tab) { // merge subframes records back into main frame's seen report const tabId = sender.tab.id; for (const {request, allowed, policyType} of message.seen) { @@ -253,20 +280,40 @@ var RequestGuard = (() => { } return true; }, - violation({url, type}, sender) { - let tabId = sender.tab.id; - let {frameId} = sender; - let r = { - url, type, tabId, frameId, documentUrl: sender.url + + // returns true if it's a true violation (request should be blocked) + violation({url, type, isReport}, sender) { + const {tab, frameId} = sender; + const documentUrl = sender.url; + + let request = { + url, + type, + tabId: tab.id, + tabUrl: tab.url, + frameId, + documentUrl, + originUrl: documentUrl, }; - Content.reportTo(r, false, policyTypesMap[type]); - debug("Violation", type, url, tabId, frameId); // DEV_ONLY - if (type === "script" && url === sender.url) { - TabStatus.record(r, "noscriptFrame", true); - } else { - TabStatus.record(r, "blocked"); + + debug("CSP", isReport ? "report" : "violation", request, sender); // DEV_ONLY + + if (isReport && !checkRequest(request)?.cancel) { + // not a real violation + return false; } + + Content.reportTo(request, false, policyTypesMap[type]); + + if (type === "script" && url === sender.url) { + TabStatus.record(request, "noscriptFrame", true); + } else { + TabStatus.record(request, "blocked"); + } + + return true; }, + async blockedObjects(message, sender) { let {url, documentUrl, policyType} = message; let TAG = `<${policyType.toUpperCase()}>`; @@ -313,7 +360,8 @@ var RequestGuard = (() => { } return {enable: key}; }, - } + }; + const Content = { async reportTo(request, allowed, policyType) { let {requestId, tabId, frameId, type, url, documentUrl, originUrl} = request; @@ -378,9 +426,9 @@ var RequestGuard = (() => { function fakeOriginFromTab({tabId, type} = request) { if (type !== "main_frame") { - let tab = tabId !== -1 && TabCache.get(tabId); - if (tab) { - return request.initiator = request.originUrl = request.documentUrl = tab.url; + let tabUrl = request.tabUrl || tabId !== -1 && TabCache.get(tabId)?.url; + if (tabUrl) { + return request.initiator = request.originUrl = request.documentUrl = tabUrl; } } return request.initiator || request.originUrl; @@ -411,14 +459,11 @@ var RequestGuard = (() => { }; function intersectCapabilities(perms, request) { - let {frameId, frameAncestors, tabId} = request; - if (frameId !== 0 && ns.sync.cascadeRestrictions) { - let topUrl = frameAncestors && frameAncestors.length - && frameAncestors[frameAncestors.length - 1].url; - if (!topUrl) { - let tab = TabCache.get(tabId); - if (tab) topUrl = tab.url; - } + if (request.frameId !== 0 && ns.sync.cascadeRestrictions) { + const {tabUrl, frameAncestors} = request; + const topUrl = tabUrl || + frameAncestors && frameAncestors[frameAncestors?.length - 1]?.url || + TabCache.get(request.tabId)?.url; if (topUrl) { return ns.policy.cascadeRestrictions(perms, topUrl).capabilities; } @@ -426,7 +471,9 @@ var RequestGuard = (() => { return perms.capabilities; } - const ABORT = {cancel: true}, ALLOW = {}; + const ABORT = {cancel: true}, + ALLOW = {}; + const recent = { MAX_AGE: 500, _pendingGC: 0, @@ -448,6 +495,7 @@ var RequestGuard = (() => { return null; }, add(request) { + request.timeStamp ??= Date.now(); let last = this._byUrl.get(request.url); if (!last) { last = [request]; @@ -511,86 +559,101 @@ var RequestGuard = (() => { } } + // returns null if request.type is unknown, otherwise either ALLOW, ABORT or a redirect response + function checkRequest(request) { + if (!request.type in policyTypesMap) { + return null; + } + + normalizeRequest(request); + + let {tabId, type, url, originUrl} = request; + + const {policy} = ns + + let previous = recent.find(request); + if (previous) { + debug("Rapid fire request", previous); // DEV_ONLY + return previous.return; + } + (previous = request).return = ALLOW; + recent.add(previous); + + let policyType = policyTypesMap[type]; + let {documentUrl} = request; + if (!ns.isEnforced(tabId)) { + if (ns.unrestrictedTabs.has(tabId) && type.endsWith("frame") && url.startsWith("https:")) { + TabStatus.addOrigin(tabId, url); + } + if (type !== "main_frame") { + Content.reportTo(request, true, policyType); + } + return ALLOW; + } + let isFetch = "fetch" === policyType; + + if ((isFetch || "frame" === policyType) && + (((isFetch && !originUrl + || url === originUrl) && originUrl === documentUrl + // some extensions make them both undefined, + // see https://github.com/eight04/image-picka/issues/150 + ) || + Sites.isInternal(originUrl)) + ) { + // livemark request or similar browser-internal, always allow; + return ALLOW; + } + + if (/^(?:data|blob):/.test(url)) { + request._dataUrl = url; + request.url = url = documentUrl || originUrl; + } + + let allowed = Sites.isInternal(url); + if (!allowed) { + if (tabId < 0 && documentUrl && documentUrl.startsWith("https:")) { + allowed = [...ns.unrestrictedTabs] + .some(tabId => TabStatus.hasOrigin(tabId, documentUrl)); + } + if (!allowed) { + let capabilities = intersectCapabilities( + policy.get(url, ns.policyContext(request)).perms, + request); + allowed = !policyType || capabilities.has(policyType); + if (allowed && request._dataUrl && type.endsWith("frame")) { + let blocker = csp.buildFromCapabilities(capabilities); + if (blocker) { + let redirectUrl = CSP.patchDataURI(request._dataUrl, blocker); + if (redirectUrl !== request._dataUrl) { + return previous.return = {redirectUrl}; + } + } + } + } + } + if (type !== "main_frame") { + Content.reportTo(request, allowed, policyType); + } + + if (!allowed) { + debug(`${policyType} must be blocked`, request); + TabStatus.record(request, "blocked"); + return previous.return = ABORT; + } + + return ALLOW; + } + const listeners = { onBeforeRequest(request) { try { - if (browser.runtime.onSyncMessage && browser.runtime.onSyncMessage.isMessageRequest(request)) return ALLOW; - normalizeRequest(request); + if (browser.runtime?.onSyncMessage.isMessageRequest(request)) return ALLOW; + initPendingRequest(request); - let {policy} = ns - let {tabId, type, url, originUrl} = request; + let result = checkRequest(request); + if (result) return result; - if (type in policyTypesMap) { - let previous = recent.find(request); - if (previous) { - debug("Rapid fire request", previous); // DEV_ONLY - return previous.return; - } - (previous = request).return = ALLOW; - recent.add(previous); - - let policyType = policyTypesMap[type]; - let {documentUrl} = request; - if (!ns.isEnforced(tabId)) { - if (ns.unrestrictedTabs.has(tabId) && type.endsWith("frame") && url.startsWith("https:")) { - TabStatus.addOrigin(tabId, url); - } - if (type !== "main_frame") { - Content.reportTo(request, true, policyType); - } - return ALLOW; - } - let isFetch = "fetch" === policyType; - - if ((isFetch || "frame" === policyType) && - (((isFetch && !originUrl - || url === originUrl) && originUrl === documentUrl - // some extensions make them both undefined, - // see https://github.com/eight04/image-picka/issues/150 - ) || - Sites.isInternal(originUrl)) - ) { - // livemark request or similar browser-internal, always allow; - return ALLOW; - } - - if (/^(?:data|blob):/.test(url)) { - request._dataUrl = url; - request.url = url = documentUrl || originUrl; - } - - let allowed = Sites.isInternal(url); - if (!allowed) { - if (tabId < 0 && documentUrl && documentUrl.startsWith("https:")) { - allowed = [...ns.unrestrictedTabs] - .some(tabId => TabStatus.hasOrigin(tabId, documentUrl)); - } - if (!allowed) { - let capabilities = intersectCapabilities( - policy.get(url, ns.policyContext(request)).perms, - request); - allowed = !policyType || capabilities.has(policyType); - if (allowed && request._dataUrl && type.endsWith("frame")) { - let blocker = csp.buildFromCapabilities(capabilities); - if (blocker) { - let redirectUrl = CSP.patchDataURI(request._dataUrl, blocker); - if (redirectUrl !== request._dataUrl) { - return previous.return = {redirectUrl}; - } - } - } - } - } - if (type !== "main_frame") { - Content.reportTo(request, allowed, policyType); - } - if (!allowed) { - debug(`Blocking ${policyType}`, request); - TabStatus.record(request, "blocked"); - return previous.return = ABORT; - } - } } catch (e) { error(e); } @@ -695,7 +758,7 @@ var RequestGuard = (() => { promises = promises.filter(p => p instanceof Promise); if (promises.length > 0) { - return Promise.all(promises).then(() => result); + return Promise.allSettled(promises).then(() => result); } return result; @@ -708,6 +771,9 @@ var RequestGuard = (() => { TabStatus.initTab(tabId); TabGuard.onCleanup(request); } + if (!RequestGuard.canBlock) { + return; + } let scriptBlocked = request.responseHeaders.some( h => csp.isMine(h) && csp.blocks(h.value, "script") ); @@ -715,7 +781,6 @@ var RequestGuard = (() => { TabStatus.record(request, "noscriptFrame", scriptBlocked); let pending = pendingRequests.get(requestId); if (pending) { - pending.scriptBlocked = scriptBlocked; if (!(pending.headersProcessed && (scriptBlocked || ns.requestCan(request, "script")) @@ -746,81 +811,64 @@ var RequestGuard = (() => { TabGuard.onCleanup(request); } }; - function fakeRequestFromCSP(report, request) { - let type = report["violated-directive"].split("-", 1)[0]; // e.g. script-src 'none' => script - if (type === "frame") type = "sub_frame"; - let url = report['blocked-uri']; - if (!url || url === 'self') url = request.documentUrl; - return Object.assign({}, request, { - url, - type, - }); - } - - let utf8Decoder = new TextDecoder("UTF-8"); - function onViolationReport(request) { - try { - let text = utf8Decoder.decode(request.requestBody.raw[0].bytes); - if (text.includes(`"inline"`)) return ABORT; - let report = JSON.parse(text)["csp-report"]; - let originalPolicy = report["original-policy"] - debug("CSP report", report); // DEV_ONLY - let blockedURI = report['blocked-uri']; - if (blockedURI && blockedURI !== 'self') { - let r = fakeRequestFromCSP(report, request); - if (!/:/.test(r.url)) r.url = request.documentUrl; - Content.reportTo(r, false, policyTypesMap[r.type]); - TabStatus.record(r, "blocked"); - } else if (report["violated-directive"].startsWith("script-src") && (originalPolicy.includes("script-src 'none'"))) { - let r = fakeRequestFromCSP(report, request); - Content.reportTo(r, false, "script"); // NEW - TabStatus.record(r, "noscriptFrame", true); - } - } catch(e) { - error(e); - } - return ABORT; - } function injectPolicyScript(details) { - let {url, tabId, frameId} = details; - let policy = ns.computeChildPolicy({url}, {tab: {id: tabId}, frameId}); - policy.navigationURL = url; + const {url, tabId, frameId} = details; + const domPolicy = ns.computeChildPolicy({url}, {tab: {id: tabId}, frameId}); + domPolicy.navigationURL = url; + const callback = "ns_setupCallback"; + if (DocStartInjection.mv3Callbacks) { + return { + data: {domPolicy}, + callback, + assign: "ns", + }; + } let debugStatement = ns.local.debug ? ` let mark = Date.now() + ":" + Math.random(); console.debug("domPolicy", domPolicy, document.readyState, location.href, mark, window.ns);` : ''; return ` - let domPolicy = ${JSON.stringify(policy)}; - let {ns} = window; - if (ns) { - ns.domPolicy = domPolicy; - if (ns.setup) { - if (ns.syncSetup) ns.syncSetup(domPolicy); - else ns.setup(domPolicy); - } ; + const domPolicy = ${JSON.stringify(domPolicy)}; + if (globalThis.${callback}) { + globalThis.${callback}(domPolicy); } else { - window.ns = {domPolicy} + globalThis.ns ||= {domPolicy} } ${debugStatement}`; } - const RequestGuard = { - async start() { - Messages.addHandler(messageHandler); - let wr = browser.webRequest; - let listen = (what, ...args) => wr[what].addListener(listeners[what], ...args); - let allUrls = [""]; - let docTypes = ["main_frame", "sub_frame", "object"]; - let filterDocs = {urls: allUrls, types: docTypes}; - let filterAll = {urls: allUrls}; - listen("onBeforeRequest", filterAll, ["blocking"]); - listen("onBeforeSendHeaders", filterAll, ["blocking", "requestHeaders"]); + // external interface + globalThis.RequestGuard = { + canBlock: UA.isMozilla, + DNRPolicy: null, + policyTypesMap, + }; - let mergingCSP = "getBrowserInfo" in browser.runtime; - if (mergingCSP) { - let {vendor, version} = await browser.runtime.getBrowserInfo(); - mergingCSP = vendor === "Mozilla" && parseInt(version) >= 77; - } + // initialization + { + Messages.addHandler(messageHandler); + const wr = browser.webRequest; + const listen = (what, ...args) => wr[what].addListener(listeners[what], ...args); + const allUrls = [""]; + const docTypes = ["main_frame", "sub_frame", "object"]; + const filterDocs = {urls: allUrls, types: docTypes}; + const filterAll = {urls: allUrls}; + + listen("onBeforeRequest", filterAll, + RequestGuard.canBlock ? ["blocking"] : []); + listen("onResponseStarted", filterDocs, ["responseHeaders"]); + listen("onCompleted", filterAll); + listen("onErrorOccurred", filterAll); + DocStartInjection.register(injectPolicyScript); + TabStatus.probe(); + + if (!RequestGuard.canBlock) { + include("/bg/DNRPolicy.js") + } else { + // From here on, only webRequestBlocking-enabled code (Gecko MV2.5) + listen("onBeforeSendHeaders", filterAll, ["blocking", "requestHeaders"]); + + const mergingCSP = true; // TODO: check whether it's still true... if (mergingCSP) { // In Gecko>=77 (https://bugzilla.mozilla.org/show_bug.cgi?id=1462989) // we need to cleanup our own cached headers in a dedicated listener :( @@ -848,33 +896,6 @@ var RequestGuard = (() => { } return ALLOW; }, filterDocs, ["blocking", "responseHeaders"])).install(); - - listen("onResponseStarted", filterDocs, ["responseHeaders"]); - listen("onCompleted", filterAll); - listen("onErrorOccurred", filterAll); - if (csp.reportURI) { - wr.onBeforeRequest.addListener(onViolationReport, - {urls: [csp.reportURI], types: ["csp_report"]}, ["blocking", "requestBody"]); - } - DocStartInjection.register(injectPolicyScript); - TabStatus.probe(); - }, - stop() { - let wr = browser.webRequest; - for (let [name, listener] of Object.entries(listeners)) { - if (typeof listener === "function") { - wr[name].removeListener(listener); - } else if (listener instanceof LastListener) { - listener.uninstall(); - } - } - wr.onBeforeRequest.removeListener(onViolationReport); - if (listeners.onHeadersReceived.resetCSP) { - wr.onHeadersReceived.removeListener(listeners.onHeadersReceived.resetCSP); - } - DocStartInjection.unregister(injectPolicyScript); - Messages.removeHandler(messageHandler); } - }; - return RequestGuard; -})(); + } +} \ No newline at end of file diff --git a/src/bg/Settings.js b/src/bg/Settings.js index ad7100b..36bc0fa 100644 --- a/src/bg/Settings.js +++ b/src/bg/Settings.js @@ -163,7 +163,7 @@ var Settings = { reloadOptionsUI = true; } - await Promise.all(["local", "sync"].map( + await Promise.allSettled(["local", "sync"].map( async storage => (settings[storage] || // changed or... settings[storage] === null // ... needs reset to default ) && await ns.save(settings[storage] @@ -180,7 +180,7 @@ var Settings = { } if (typeof unrestrictedTab === "boolean") { - ns.toggleTabRestrictions(tabId, !unrestrictedTab); + await ns.toggleTabRestrictions(tabId, !unrestrictedTab); } if (reloadAffected && tabId !== -1) { try { diff --git a/src/bg/TabGuard.js b/src/bg/TabGuard.js index 1dcd450..ba88f26 100644 --- a/src/bg/TabGuard.js +++ b/src/bg/TabGuard.js @@ -18,6 +18,7 @@ * this program. If not, see . */ +// depends on /nscl/service/Scripting.js // depends on /nscl/common/SessionCache.js // depends on /nscl/service/TabCache.js // depends on /nscl/service/TabTies.js @@ -111,6 +112,7 @@ var TabGuard = (() => { wakening: Promise.all([TabCache.wakening, TabTies.wakening, session.load()]), forget, // must be called from a webRequest.onBeforeSendHeaders blocking listener + // TODO: explore DNR alternative onSend(request) { const mode = ns.sync.TabGuardMode; if (mode === "off" || !request.incognito && mode!== "global") return; @@ -189,16 +191,18 @@ var TabGuard = (() => { return suspiciousTabs.length > 0 && (async () => { let suspiciousDomains = []; - await Promise.all(suspiciousTabs.map(async (tab) => { + await Promise.allSettled(suspiciousTabs.map(async (tab) => { if (!tab._isExplicitOrigin) { // e.g. about:blank // let's try retrieving actual origin tab._externalUrl = tab.url; tab._isExplicitOrigin = true; try { - tab.url = await browser.tabs.executeScript(tab.id, { - runAt: "document_start", - code: "window.origin === 'null' ? window.location.href : window.origin" - }); + tab.url = (await Scripting.executeScript({ + target: {tabId: tab.id, allFrames: false}, + func: () => { + return window.origin === 'null' ? window.location.href : window.origin; + }, + }))[0].result; } catch (e) { // We don't have permissions to run in this tab, probably because it has been left empty. debug(e); @@ -223,10 +227,10 @@ var TabGuard = (() => { } if (!tab._contentType) { try { - tab._contentType = await browser.tabs.executeScript(tab.id, { - runAt: "document_start", - code: "document.contentType" - }); + tab._contentType = (await Scripting.executeScript({ + target: {tabId: tab.id}, + func() { return document.contentType } + }))[0].result; } catch (e) { // We don't have permissions to run in this tab: privileged! debug(e); @@ -292,6 +296,7 @@ var TabGuard = (() => { })(); }, // must be called from a webRequest.onHeadersReceived blocking listener + // TODO: explore DNR alternative onReceive(request) { if (!anonymizedRequests.has(request.id)) return false; let headersModified = false; diff --git a/src/bg/main.js b/src/bg/main.js index eb5e3ec..84511ff 100644 --- a/src/bg/main.js +++ b/src/bg/main.js @@ -18,7 +18,9 @@ * this program. If not, see . */ - { +// depends on /nscl/service/Scripting.js + +{ 'use strict'; { for (let event of ["onInstalled", "onUpdateAvailable"]) { @@ -36,7 +38,7 @@ const menuUpdater = async (tabId) => { if (!menuShowing) return; try { - const badgeText = await browser.browserAction.getBadgeText({tabId}); + const badgeText = await browser.action.getBadgeText({tabId}); let title = "NoScript"; if (badgeText) title = `${title} [${badgeText}]`; await browser.contextMenus.update(ctxMenuId, {title}); @@ -69,9 +71,9 @@ { afterLoad(data) { if (data) { - ns.policy = new Policy(data.policy); - ns.unrestrictedTabs = new Set(data.unrestrictedTabs); ns.gotTorBrowserInit = data.gotTorBrowserInit; + ns.unrestrictedTabs = new Set(data.unrestrictedTabs); + ns.policy = new Policy(data.policy); } }, beforeSave() { // beforeSave @@ -90,10 +92,12 @@ if (!ns.policy) { // ns.policy could have been already set by LifeCycle or SessionCache const policyData = (await Storage.get("sync", "policy")).policy; if (policyData && policyData.DEFAULT) { - ns.policy = new Policy(policyData); - if (ns.local.enforceOnRestart && !ns.policy.enforced) { - ns.policy.enforced = true; + const policy = new Policy(policyData); + if (ns.local.enforceOnRestart && !policy.enforced) { + (ns.policy = policy).enforced = true; await ns.savePolicy(); + } else { + ns.policy = policy; } } else { ns.policy = new Policy(Settings.createDefaultDryPolicy()); @@ -108,7 +112,6 @@ await include("/nscl/service/prefetchCSSResources.js"); } await TabGuard.wakening; - await RequestGuard.start(); try { await Messages.send("started"); @@ -130,7 +133,7 @@ active: true })); if (tab) { - ns.toggleTabRestrictions(tab.id); + await ns.toggleTabRestrictions(tab.id); browser.tabs.reload(tab.id); } }, @@ -154,7 +157,7 @@ } // wiring main UI - browser.browserAction.setPopup({popup: popupURL}); + browser.action.setPopup({popup: popupURL}); } }; @@ -222,29 +225,30 @@ }); }, async getTheme(msg, {tab, frameId}) { - let code = await Themes.getContentCSS(); + let css = await Themes.getContentCSS(); if (!ns.local.showProbePlaceholders) { - code += `\n.__NoScript_Offscreen_PlaceHolders__ {display: none}`; + css += `\n.__NoScript_Offscreen_PlaceHolders__ {display: none}`; } try { - browser.tabs.insertCSS(tab.id, { - code, - frameId, - runAt: "document_start", - matchAboutBlank: true, - cssOrigin: "user", + await Scripting.insertCSS({ + target: {tabId: tab.id, frameId}, + css, }); } catch (e) { console.error(e); } - return {"vintage": await Themes.isVintage()}; + const ret = {"vintage": await Themes.isVintage()}; + console.debug("Returning from getTheme", ret); // DEV_ONLY + return ret; }, async promptHook(msg, {tabId}) { - await browser.tabs.executeScript(tabId, { - code: "try { if (document.fullscreenElement) document.exitFullscreen(); } catch (e) {}", - matchAboutBlank: true, - allFrames: true, + const func = () => { + try { if (document.fullscreenElement) document.exitFullscreen(); } catch (e) {} + }; + await Scripting.executeScript({ + target: {tabId}, + func, }); }, @@ -265,16 +269,24 @@ } } - var ns = { + let _policy = null; + + globalThis.ns = { running: false, - policy: null, + set policy(p) { + _policy = p; + RequestGuard.DNRPolicy?.update(); + }, + get policy() { return _policy; }, local: null, sync: null, initializing: null, unrestrictedTabs: new Set(), - toggleTabRestrictions(tabId, restrict = ns.unrestrictedTabs.has(tabId)) { + async toggleTabRestrictions(tabId, restrict = ns.unrestrictedTabs.has(tabId)) { ns.unrestrictedTabs[restrict ? "delete": "add"](tabId); - session.save(); + Promise.allSettled([session.save(), + RequestGuard.DNRPolicy?.updateTabs() + ]); }, isEnforced(tabId = -1) { return this.policy.enforced && (tabId === -1 || !this.unrestrictedTabs.has(tabId)); @@ -342,7 +354,8 @@ async init() { browser.runtime.onSyncMessage.addListener(onSyncMessage); - await Wakening.waitFor(Messages.wakening = this.initializing = init()); + await (Messages.wakening = this.initializing = init()); + Wakening.done(); Commands.install(); try { this.devMode = (await browser.management.getSelf()).installType === "development"; @@ -369,7 +382,7 @@ async savePolicy() { if (this.policy) { - await Promise.all([ + await Promise.allSettled([ Storage.set("sync", { policy: this.policy.dry() }), diff --git a/src/content/content.css b/src/content/content.css index 9808037..56e21c1 100644 --- a/src/content/content.css +++ b/src/content/content.css @@ -28,6 +28,10 @@ a.__NoScript_PlaceHolder__ { border-radius: 4px; } +a.__NoScript_PlaceHolder__.no-theme { + visibility: hidden !important; +} + a.__NoScript_PlaceHolder__.mozilla { background-image: var(--img-logo); } diff --git a/src/content/content.js b/src/content/content.js index 5b0a6d9..8bf0491 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -126,21 +126,29 @@ var notifyPage = async () => { window.addEventListener("pageshow", notifyPage); -let violations = new Set(); -let documentOrigin = new URL(document.URL).origin; -window.addEventListener("securitypolicyviolation", e => { +const violations = new Set(); +const documentOrigin = new URL(document.URL).origin; + +window.addEventListener("securitypolicyviolation", async e => { if (!e.isTrusted) return; - let {violatedDirective, originalPolicy} = e; + let {violatedDirective, originalPolicy, disposition} = e; let type = violatedDirective.split("-", 1)[0]; // e.g. script-src 'none' => script let url = e.blockedURI; if (type === "media" && /^data\b/.test(url) && (!CSP.isMediaBlocker(originalPolicy) || - ns.embeddingDocument || !document.querySelector("video,audio"))) + ns.embeddingDocument || !document.querySelector("video,audio"))) { + // MediaBlocker probe, don't report return; - if (!ns.CSP || !(CSP.normalize(originalPolicy).includes(ns.CSP))) { + } + + const isReport = disposition === "report" && + /; report-to noscript-reports-[\w-]+$/.test(originalPolicy); + if (!(isReport || + ns.CSP && CSP.normalize(originalPolicy).includes(ns.CSP))) { // this seems to come from page's own CSP return; } + let documentUrl = document.URL; let origin; if (!(url && url.includes(":"))) { @@ -149,23 +157,55 @@ window.addEventListener("securitypolicyviolation", e => { } else { ({origin} = new URL(url)); } - let key = RequestKey.create(origin, type, documentOrigin); + const key = RequestKey.create(url, type, documentOrigin); if (violations.has(key)) return; violations.add(key); if (type === "frame") type = "sub_frame"; - seen.record({ - policyType: type, - request: { - key, - url, - type, - documentUrl, - }, - allowed: false - }); - Messages.send("violation", {url, type}); + Messages.send("violation", {url, type, isReport}); }, true); +if (!/^https:/.test(location.protocol)) { + // Reporting CSP can only be injected in HTTP responses, + // let's emulate them using mutation observers + const checked = new Set(); + const checkSrc = async (node) => { + if (!('src' in node && node.parentNode)) { + return; + } + const type = node instanceof HTMLMediaElement ? "media" + : node instanceof HTMLIFrameElement ? "sub_frame" + : node instanceof HTMLObjectElement || node instanceof HTMLEmbedElement ? "object" + : ""; + if (!type) { + return; + } + const url = node.src; + const key = RequestKey.create(url, type, documentOrigin); + if (checked.has(key)) { + return; + } + checked.add(key); + Messages.send("violation", {url, type, isReport: true}); + } + const mutationsCallback = records => { + for (var r of records) { + switch (r.type) { + case "attributes": + checkSrc(r.target); + break; + case "childList": + [...r.addedNodes].forEach(checkSrc); + break; + } + } + }; + const observer = new MutationObserver(mutationsCallback); + observer.observe(document.documentElement, { + childList: true, + subtree: true, + attributeFilter: ["src"], + }); +} ns.on("capabilities", () => { seen.record({ diff --git a/src/content/staticNS.js b/src/content/staticNS.js index bac16fd..fcb10eb 100644 --- a/src/content/staticNS.js +++ b/src/content/staticNS.js @@ -172,6 +172,11 @@ return this.capabilities && this.capabilities.has(cap); }, }; - window.ns = window.ns ? Object.assign(ns, window.ns) : ns; - debug("StaticNS", Date.now(), JSON.stringify(window.ns)); // DEV_ONLY + globalThis.ns = globalThis.ns ? Object.assign(ns, globalThis.ns) : ns; + debug("StaticNS", Date.now(), JSON.stringify(globalThis.ns)); // DEV_ONLY + globalThis.ns_setupCallBack = ns.domPolicy + ? () => {} + : ({domPolicy}) => { + + }; } diff --git a/src/manifest.json b/src/manifest.json index d793ccd..04a53bc 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -14,8 +14,6 @@ "description": "__MSG_Description__", "incognito": "spanning", - "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'none'", - "icons": { "48": "img/icon48.png", "96": "img/icon96.png", @@ -27,15 +25,20 @@ "storage", "tabs", "unlimitedStorage", + "scripting", + "declarativeNetRequest", "webNavigation", "webRequest", "webRequestBlocking", "dns", "" ], + "host_permissions": [ + "" + ], "background": { - "persistent": false, + "service_worker": "sw.js", "scripts": [ "/nscl/lib/browser-polyfill.js", "/nscl/lib/punycode.js", @@ -61,6 +64,7 @@ "/nscl/common/AddressMatcherWithDNS.js", "/nscl/common/iputil.js", "/nscl/common/SessionCache.js", + "/nscl/service/Scripting.js", "/nscl/service/DocStartInjection.js", "/nscl/service/LastListener.js", "/nscl/service/patchWorkers.js", @@ -75,7 +79,8 @@ "bg/Settings.js", "bg/main.js", "common/themes.js" - ] + ], + "persistent": false }, "content_scripts": [ @@ -119,7 +124,6 @@ "match_origin_as_fallback": true, "all_frames": true, "js": [ - "/nscl/common/UA.js", "content/ftp.js", "/nscl/content/DocumentFreezer.js", "content/syncFetchPolicy.js" @@ -132,6 +136,14 @@ "open_in_tab": true }, + "action": { + "default_area": "navbar", + "default_title": "NoScript", + "default_icon": { + "64": "img/ui-maybe64.png" + } + }, + "browser_action": { "default_area": "navbar", "default_title": "NoScript", @@ -141,12 +153,6 @@ }, "commands": { - "openPageUI": { - "description": "__MSG_pagePermissionsUI__", - "suggested_key": { - "default": "Alt+Shift+N" - } - }, "toggleEnforcementForTab": { "description": "__MSG_toggleEnforcementForTab__", "suggested_key": { @@ -154,6 +160,14 @@ "windows": "Alt+Shift+Comma" } }, + + "openPageUI": { + "description": "__MSG_pagePermissionsUI__", + "suggested_key": { + "default": "Alt+Shift+N" + } + }, + "tempTrustPage": { "description": "__MSG_TempTrustPage__" }, @@ -161,6 +175,7 @@ "description": "__MSG_RevokeTemp__" }, + "_execute_action": {}, "_execute_browser_action": {} } } diff --git a/src/sw.js b/src/sw.js new file mode 100644 index 0000000..5cb2667 --- /dev/null +++ b/src/sw.js @@ -0,0 +1,23 @@ +/* + * NoScript - a Firefox extension for whitelist driven safe JavaScript execution + * + * Copyright (C) 2005-2024 Giorgio Maone + * + * 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 . + */ + +importScripts("/nscl/common/include.js"); +browser.browserAction = browser.action; +console.log("NoScript Manifest V3 service worker"); diff --git a/src/ui/options.js b/src/ui/options.js index fd74752..5ea8aa5 100644 --- a/src/ui/options.js +++ b/src/ui/options.js @@ -156,7 +156,7 @@ document.querySelector("#version").textContent = _("Version", opt("showFullAddresses", "local"); opt("showProbePlaceholders", "local"); - UI.wireChoice("theme", o => Themes.setup(o && o.value) ); + UI.wireChoice("theme", async o => await Themes.setup(o?.value) ); opt("vintageTheme", async o => await (o ? Themes.setVintage(o.checked) : Themes.isVintage())); addEventListener("NoScriptThemeChanged", ({detail}) => { if ("theme" in detail) { diff --git a/src/ui/popup.js b/src/ui/popup.js index 198cc3a..f47eb20 100644 --- a/src/ui/popup.js +++ b/src/ui/popup.js @@ -249,13 +249,16 @@ addEventListener("unload", e => { setupEnforcement(); - let mainFrame = UI.seen && UI.seen.find(thing => thing.request.type === "main_frame"); debug("Seen: %o", UI.seen); if (!mainFrame) { let isHttp = /^https?:/.test(pageTab.url); try { - await browser.tabs.executeScript(tabId, { code: "" }); + await include("/nscl/service/Scripting.js"); + await Scripting.executeScript({ + target: {tabId, allFrames: false}, + func: () => {} + }); if (isHttp) { document.body.classList.add("disabled"); messageBox("warning", _("freshInstallReload")); diff --git a/src/xss/XSS.js b/src/xss/XSS.js index 983128c..310783c 100644 --- a/src/xss/XSS.js +++ b/src/xss/XSS.js @@ -39,12 +39,12 @@ var XSS = (() => { async function getUserResponse(xssReq) { let {originKey, request} = xssReq; let {tabId, frameId} = request; - let {browserAction} = browser; + const {action} = browser; if (frameId === 0) { if (blockedTabs.has(tabId)) { blockedTabs.delete(tabId); - if ("setBadgeText" in browserAction) { - browserAction.setBadgeText({tabId, text: ""}); + if ("setBadgeText" in action) { + action.setBadgeText({tabId, text: ""}); } } } @@ -57,9 +57,9 @@ var XSS = (() => { log("Blocking request from %s to %s by previous XSS prompt user choice", xssReq.srcUrl, xssReq.destUrl); - if ("setBadgeText" in browserAction) { - browserAction.setBadgeText({tabId, text: "XSS"}); - browserAction.setBadgeBackgroundColor({tabId, color: [128, 0, 0, 160]}); + if ("setBadgeText" in action) { + action.setBadgeText({tabId, text: "XSS"}); + action.setBadgeBackgroundColor({tabId, color: [128, 0, 0, 160]}); } let keys = blockedTabs.get(tabId); if (!keys) blockedTabs.set(tabId, keys = new Set());