diff --git a/src/bg/ChildPolicies.js b/src/bg/ChildPolicies.js index 32abafe..91263fd 100644 --- a/src/bg/ChildPolicies.js +++ b/src/bg/ChildPolicies.js @@ -1,6 +1,7 @@ "use strict"; { let marker = JSON.stringify(uuid()); + let allUrls = [""]; let Scripts = { references: new Set(), @@ -10,27 +11,52 @@ matchAboutBlank: true, runAt: "document_start" }, + async init() { + let opts = Object.assign({}, this.opts); + opts.js = [{file: "/content/dynamicNS.js"}]; + opts.matches = allUrls; + delete opts.excludedMatches; + this._stubScript = await browser.contentScripts.register(opts); + + this.init = this.forget; + }, forget() { for (let script of [...this.references]) { script.unregister(); this.references.delete(script); } }, + debug: false, + trace(code) { + return this.debug + ? `console.debug("Executing child policy", ${JSON.stringify(code)});${code}` + : code + ; + }, async register(code, matches, excludeMatches) { debug("Registering child policy.", code, matches, excludeMatches); if (!matches.length) return; try { - this.opts.js[0].code = code; - this.opts.matches = matches; + let opts = Object.assign({}, this.opts); + opts.js[0].code = this.trace(code); + opts.matches = matches; if (excludeMatches && excludeMatches.length) { - this.opts.excludeMatches = excludeMatches; - } else { - delete this.opts.excludeMatches; + opts.excludeMatches = excludeMatches; } - this.references.add(await browser.contentScripts.register(this.opts)); + this.references.add(await browser.contentScripts.register(opts)); } catch (e) { error(e); } + }, + + buildPerms(perms, finalizeSetup = false) { + if (typeof perms !== "string") { + perms = JSON.stringify(perms); + } + return finalizeSetup + ? `ns.setup(${perms}, ${marker});` + : `ns.config.CURRENT = ${perms};` + ; } }; @@ -69,12 +95,13 @@ error(e); } }, - async update(policy) { - Scripts.forget(); + async update(policy, debug) { + if (debug !== "undefined") Scripts.debug = debug; + + await Scripts.init(); if (!policy.enforced) { - await Scripts.register(`ns.setup(null, ${marker});`, - [""]); + await Scripts.register(`ns.setup(null, ${marker});`, allUrls); return; } @@ -122,10 +149,27 @@ // register new content scripts for (let [perms, keys] of [...permsMap]) { - await Scripts.register(`ns.perms.CURRENT = ${perms};`, siteKeys2MatchPatterns(keys), excludeMap.get(perms)); + await Scripts.register(Scripts.buildPerms(perms), siteKeys2MatchPatterns(keys), excludeMap.get(perms)); } - await Scripts.register(`ns.setup(${JSON.stringify(serialized.DEFAULT)}, ${marker});`, - [""]); + await Scripts.register(Scripts.buildPerms(serialized.DEFAULT, true), allUrls); + }, + + getForDocument(policy, url, context = null) { + return { + CURRENT: policy.get(url, context).perms.dry(), + DEFAULT: policy.DEFAULT.dry(), + MARKER: marker + }; + }, + + async updateFrame(tabId, frameId, perms, defaultPreset) { + let code = Scripts.buildPerms(perms) + Scripts.buildPerms(defaultPreset, true); + await browser.tabs.executeScript(tabId, { + code, + frameId, + matchAboutBlank: true, + runAt: "document_start" + }); } - } + }; } diff --git a/src/bg/ReportingCSP.js b/src/bg/ReportingCSP.js index f8764e8..03926c2 100644 --- a/src/bg/ReportingCSP.js +++ b/src/bg/ReportingCSP.js @@ -1,6 +1,12 @@ "use strict"; -function ReportingCSP(reportURI, reportGroup) { +function ReportingCSP(reportURI, reportGroup) { + const REPORT_TO = { + name: "Report-To", + value: JSON.stringify({ "url": reportURI, + "group": reportGroup, + "max-age": 10886400 }), + }; return Object.assign( new CapsCSP(new NetCSP( `report-uri ${reportURI};`, @@ -9,11 +15,32 @@ function ReportingCSP(reportURI, reportGroup) { { reportURI, reportGroup, - reportToHeader: { - name: "Report-To", - value: JSON.stringify({ "url": reportURI, - "group": reportGroup, - "max-age": 10886400 }), + patchHeaders(responseHeaders, capabilities) { + let header = null; + let hasReportTo = false; + for (let h of responseHeaders) { + if (this.isMine(h)) { + header = h; + h.value = this.inject(h.value, ""); + } else if (h.name === REPORT_TO.name && h.value === REPORT_TO.value) { + hasReportTo = true; + } + } + + let blocker = capabilities && this.buildFromCapabilities(capabilities); + if (blocker) { + if (!hasReportTo) { + responseHeaders.push(REPORT_TO); + } + if (header) { + header.value = this.inject(header.value, blocker); + } else { + header = this.asHeader(blocker); + responseHeaders.push(header); + } + } + + return header; } } ); diff --git a/src/bg/RequestGuard.js b/src/bg/RequestGuard.js index cf2ff71..5dea994 100644 --- a/src/bg/RequestGuard.js +++ b/src/bg/RequestGuard.js @@ -2,12 +2,9 @@ var RequestGuard = (() => { 'use strict'; const VERSION_LABEL = `NoScript ${browser.runtime.getManifest().version}`; browser.browserAction.setTitle({title: VERSION_LABEL}); - const REPORT_URI = "https://noscript-csp.invalid/__NoScript_Probe__/"; const REPORT_GROUP = "NoScript-Endpoint"; - let csp = new ReportingCSP(REPORT_URI, REPORT_GROUP); - const policyTypesMap = { main_frame: "", sub_frame: "frame", @@ -25,7 +22,6 @@ var RequestGuard = (() => { }; const allTypes = Object.keys(policyTypesMap); Object.assign(policyTypesMap, {"webgl": "webgl"}); // fake types - const TabStatus = { map: new Map(), types: ["script", "object", "media", "frame", "font"], @@ -36,13 +32,11 @@ var RequestGuard = (() => { noscriptFrames: {}, } }, - initTab(tabId, records = this.newRecords()) { if (tabId < 0) return; this.map.set(tabId, records); return records; }, - _record(request, what, optValue) { let {tabId, frameId, type, url, documentUrl} = request; let policyType = policyTypesMap[type] || type; @@ -54,7 +48,6 @@ var RequestGuard = (() => { } else { records = this.initTab(tabId); } - if (what === "noscriptFrame" && type !== "object") { let nsf = records.noscriptFrames; nsf[frameId] = optValue; @@ -76,7 +69,6 @@ var RequestGuard = (() => { } return records; }, - record(request, what, optValue) { let {tabId} = request; if (tabId < 0) return; @@ -85,9 +77,7 @@ var RequestGuard = (() => { this.updateTab(request.tabId); } }, - _pendingTabs: new Set(), - updateTab(tabId) { if (tabId < 0) return; if (this._pendingTabs.size === 0) { @@ -105,22 +95,18 @@ var RequestGuard = (() => { let records = this.map.get(tabId) || this.initTab(tabId); let {allowed, blocked, noscriptFrames} = records; let topAllowed = !(noscriptFrames && noscriptFrames[0]); - let numAllowed = 0, numBlocked = 0, sum = 0; let report = this.types.map(t => { let a = allowed[t] && allowed[t].length || 0, b = blocked[t] && blocked[t].length || 0, s = a + b; numAllowed+= a, numBlocked += b, sum += s; return s && `<${t === "sub_frame" ? "frame" : t}>: ${b}/${s}`; }).filter(s => s).join("\n"); - let enforced = ns.isEnforced(tabId); - let icon = topAllowed ? (numBlocked ? "part" : enforced ? "yes" : "global") : (numAllowed ? "sub" : "no"); let showBadge = ns.local.showCountBadge && numBlocked > 0; - let browserAction = browser.browserAction; browserAction.setIcon({tabId, path: {64: `/img/ui-${icon}64.png`}}); browserAction.setBadgeText({tabId, text: showBadge ? numBlocked.toString() : ""}); @@ -131,11 +117,9 @@ var RequestGuard = (() => { : _("NotEnforced")}` }); }, - totalize(sum, value) { return sum + value; }, - async probe(tabId) { if (tabId === undefined) { (await browser.tabs.query({})).forEach(tab => TabStatus.probe(tab.id)); @@ -147,7 +131,6 @@ var RequestGuard = (() => { } } }, - recordAll(tabId, seen) { if (seen) { let records = TabStatus.map.get(tabId); @@ -156,17 +139,21 @@ var RequestGuard = (() => { records.blocked = {}; } for (let thing of seen) { - thing.request.tabId = tabId; - TabStatus._record(thing.request, thing.allowed ? "allowed" : "blocked"); + let {request, allowed} = thing; + request.tabId = tabId; + debug(`Recording`, request); + TabStatus._record(request, allowed ? "allowed" : "blocked"); + if (request.key === "noscript-probe" && request.type === "main_frame" ) { + request.frameId = 0; + TabStatus._record(request, "noscriptFrame", !allowed); + } } this._updateTabNow(tabId); } }, - async onActivatedTab(info) { let {tabId} = info; let seen = await ns.collectSeen(tabId); - TabStatus.recordAll(tabId, seen); }, onRemovedTab(tabId) { @@ -175,12 +162,9 @@ var RequestGuard = (() => { } browser.tabs.onActivated.addListener(TabStatus.onActivatedTab); browser.tabs.onRemoved.addListener(TabStatus.onRemovedTab); - if (!("setIcon" in browser.browserAction)) { // unsupported on Android TabStatus._updateTabNow = TabStatus.updateTab = () => {}; } - - let messageHandler = { async pageshow(message, sender) { TabStatus.recordAll(sender.tab.id, message.seen); @@ -215,7 +199,6 @@ var RequestGuard = (() => { if (!capabilities.has(policyType)) { perms = new Permissions(new Set(capabilities), false); perms.capabilities.add(policyType); - /* TODO: handle contextual permissions if (documentUrl) { let context = new URL(documentUrl).origin; @@ -228,23 +211,8 @@ var RequestGuard = (() => { } return true; }, - - async queryDocStatus(message, sender) { - let {frameId, tab} = sender; - let {url} = message; - let tabId = tab.id; - let records = TabStatus.map.get(tabId); - let noscriptFrames = records && records.noscriptFrames; - let canScript = !(noscriptFrames && noscriptFrames[sender.frameId]); - let shouldScript = !ns.isEnforced(tabId) || !url.startsWith("http") || ns.policy.can(url, "script"); - debug("Frame %s %s of %o, canScript: %s, shouldScript: %s", frameId, url, noscriptFrames, canScript, shouldScript); - return {canScript, shouldScript}; - } - } - const Content = { - async reportTo(request, allowed, policyType) { let {requestId, tabId, frameId, type, url, documentUrl, originUrl} = request; let pending = pendingRequests.get(requestId); // null if from a CSP report @@ -276,7 +244,6 @@ var RequestGuard = (() => { } } }; - const pendingRequests = new Map(); function initPendingRequest(request) { let {requestId, url} = request; @@ -288,8 +255,6 @@ var RequestGuard = (() => { }); return redirected; } - - const ABORT = {cancel: true}, ALLOW = {}; const INTERNAL_SCHEME = /^(?:chrome|resource|moz-extension|about):/; const listeners = { @@ -307,7 +272,6 @@ var RequestGuard = (() => { // livemark request or similar browser-internal, always allow; return ALLOW; } - if (/^(?:data|blob):/.test(url)) { request._dataUrl = url; request.url = url = documentUrl; @@ -316,7 +280,6 @@ var RequestGuard = (() => { !ns.isEnforced(request.tabId) || policy.can(url, policyType, originUrl); Content.reportTo(request, allowed, policyType); - if (!allowed) { debug(`Blocking ${policyType}`, request); TabStatus.record(request, "blocked"); @@ -326,13 +289,10 @@ var RequestGuard = (() => { } catch (e) { error(e); } - return ALLOW; }, - async onHeadersReceived(request) { // called for main_frame, sub_frame and object - // check for duplicate calls let pending = pendingRequests.get(request.requestId); if (pending) { @@ -347,67 +307,28 @@ var RequestGuard = (() => { pending = pendingRequests.get(request.requestId); } pending.headersProcessed = true; - let {url, documentUrl, statusCode, tabId, responseHeaders, type} = request; - let isMainFrame = type === "main_frame"; - try { - let header; - let content = {}; - const REPORT_TO = csp.reportToHeader; - let hasReportTo = false; - for (let h of responseHeaders) { - if (csp.isMine(h)) { - header = h; - h.value = csp.inject(h.value, ""); - } else if (/^\s*Content-(Type|Disposition)\s*$/i.test(h.name)) { - content[RegExp.$1.toLowerCase()] = h.value; - } else if (h.name === REPORT_TO.name && h.value === REPORT_TO.value) { - hasReportTo = true; - } - } - + let capabilities; if (ns.isEnforced(tabId)) { let policy = ns.policy; let perms = policy.get(url, documentUrl).perms; - if (policy.autoAllowTop && isMainFrame && perms === policy.DEFAULT) { policy.set(Sites.optimalKey(url), perms = policy.TRUSTED.tempTwin); await ChildPolicies.update(policy); } - - let blockHttp = !content.disposition && - (!content.type || /^\s*(?:video|audio|application)\//.test(content.type)); - if (blockHttp) { - debug(`Suspicious content type "%s" in request %o with capabilities %o`, - content.type, request, capabilities); - } - - let blocker = csp.buildFromCapabilities(perms.capabilities, blockHttp); - if (blocker) { - if (!hasReportTo) { - responseHeaders.push(csp.reportToHeader); - } - - if (header) { - pending.cspHeader = header; - header.value = csp.inject(header.value, blocker); - } else { - header = csp.asHeader(blocker); - responseHeaders.push(header); - } - - debug(`CSP blocker on %s:`, url, blocker, header.value); - } - - if (isMainFrame && !TabStatus.map.has(tabId)) { - debug("No TabStatus data yet for noscriptFrame", tabId); - TabStatus.record(request, "noscriptFrame", true); - } + capabilities = perms.capabilities; } - + if (isMainFrame && !TabStatus.map.has(tabId)) { + debug("No TabStatus data yet for noscriptFrame", tabId); + TabStatus.record(request, "noscriptFrame", + capabilities && !capabilities.has("script")); + } + let header = csp.patchHeaders(responseHeaders, capabilities); if (header) { + pending.cspHeader = header; + debug(`CSP blocker on %s:`, url, header.value); return {responseHeaders}; } } catch (e) { @@ -415,7 +336,6 @@ var RequestGuard = (() => { } return ALLOW; }, - onResponseStarted(request) { debug("onResponseStarted", request); let {requestId, url, tabId, frameId, type} = request; @@ -430,25 +350,15 @@ var RequestGuard = (() => { let pending = pendingRequests.get(requestId); if (pending) { pending.scriptBlocked = scriptBlocked; - if (!(pending.headersProcessed && + if (!(pending.headersProcessed && (scriptBlocked || !ns.isEnforced(tabId) || ns.policy.can(url, "script", request.documentURL)) )) { debug("[WARNING] onHeadersReceived %s %o", frameId, tabId, - pending.headersProcessed ? "has been overridden on": "could not process", + pending.headersProcessed ? "has been overridden on": "could not process", request); - - if (tabId !== -1 && type !== "object") { - debug("[WARNING] Reloading %s frame %s of tab %s.", url, frameId, tabId); - browser.tabs.executeScript(tabId, { - runAt: "document_start", - code: "window.location.reload(false)", - frameId - }); - } } } }, - onCompleted(request) { let {requestId} = request; if (pendingRequests.has(requestId)) { @@ -463,12 +373,10 @@ var RequestGuard = (() => { } } }, - onErrorOccurred(request) { pendingRequests.delete(request.requestId); } }; - function fakeRequestFromCSP(report, request) { let type = report["violated-directive"].split("-", 1)[0]; // e.g. script-src 'none' => script if (type === "frame") type = "sub_frame"; @@ -479,7 +387,6 @@ var RequestGuard = (() => { type, }); } - async function onViolationReport(request) { try { let decoder = new TextDecoder("UTF-8"); @@ -501,28 +408,21 @@ var RequestGuard = (() => { } return ABORT; } - 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, types: allTypes}; - listen("onBeforeRequest", filterAll, ["blocking"]); - listen("onHeadersReceived", filterDocs, ["blocking", "responseHeaders"]); - (listeners.onHeadersReceivedLast = new LastListener(wr.onHeadersReceived, request => { let {requestId, responseHeaders} = request; let pending = pendingRequests.get(request.requestId); - if (pending && pending.headersProcessed) { + if (pending && pending.headersProcessed) { let {cspHeader} = pending; if (cspHeader) { debug("Safety net: injecting again %o in %o", cspHeader, request); @@ -541,18 +441,13 @@ var RequestGuard = (() => { } return null; }, filterDocs, ["blocking", "responseHeaders"])).install(); - listen("onResponseStarted", filterDocs, ["responseHeaders"]); listen("onCompleted", filterAll); listen("onErrorOccurred", filterAll); - - wr.onBeforeRequest.addListener(onViolationReport, {urls: [csp.reportURI], types: ["csp_report"]}, ["blocking", "requestBody"]); - TabStatus.probe(); }, - stop() { let wr = browser.webRequest; for (let [name, listener] of Object.entries(listeners)) { @@ -566,6 +461,5 @@ var RequestGuard = (() => { Messages.removeHandler(messageHandler); } }; - return RequestGuard; })(); diff --git a/src/bg/main.js b/src/bg/main.js index 47d28a3..fa2831c 100644 --- a/src/bg/main.js +++ b/src/bg/main.js @@ -23,10 +23,13 @@ } async function init() { + await include("/bg/defaults.js"); + await ns.defaults; + let policyData = (await Storage.get("sync", "policy")).policy; if (policyData && policyData.DEFAULT) { ns.policy = new Policy(policyData); - await ChildPolicies.update(policyData); + await ChildPolicies.update(policyData, ns.local.debug); } else { await include("/legacy/Legacy.js"); ns.policy = await Legacy.createOrMigratePolicy(); @@ -34,8 +37,7 @@ } - await include("/bg/defaults.js"); - await ns.defaults; + await include("/bg/RequestGuard.js"); await RequestGuard.start(); await XSS.start(); // we must start it anyway to initialize sub-objects @@ -135,7 +137,11 @@ async importSettings({data}) { return await Settings.import(data); }, - + + async fetchChildPolicy({url, contextUrl}) { + return ChildPolicies.getForDocument(ns.policy, url, contextUrl); + }, + async openStandalonePopup() { let win = await browser.windows.getLastFocused(); let [tab] = (await browser.tabs.query({ @@ -203,7 +209,7 @@ async savePolicy() { if (this.policy) { - await ChildPolicies.update(this.policy); + await ChildPolicies.update(this.policy, this.local.debug); await Storage.set("sync", { policy: this.policy.dry() }); diff --git a/src/content/DocumentCSP.js b/src/content/DocumentCSP.js index 228b2a2..371e547 100644 --- a/src/content/DocumentCSP.js +++ b/src/content/DocumentCSP.js @@ -6,9 +6,9 @@ class DocumentCSP { this.builder = new CapsCSP(); } - apply(capabilities) { + apply(capabilities, embedding = CSP.isEmbedType(this.document.contentType)) { let csp = this.builder; - let blocker = csp.buildFromCapabilities(capabilities); + let blocker = csp.buildFromCapabilities(capabilities, embedding); if (!blocker) return; let document = this.document; @@ -19,8 +19,11 @@ class DocumentCSP { let parent = document.head || document.documentElement; try { parent.insertBefore(meta, parent.firstChild); + debug(`Failsafe CSP inserted in the DOM: "%s"`, header.value); + if (capabilities.has("script")) meta.remove(); } catch (e) { error(e, "Error inserting CSP %s in the DOM", header && header.value); } } + } diff --git a/src/content/PlaceHolder.js b/src/content/PlaceHolder.js index ec2ab5b..09f6767 100644 --- a/src/content/PlaceHolder.js +++ b/src/content/PlaceHolder.js @@ -1,6 +1,17 @@ var PlaceHolder = (() => { const HANDLERS = new Map(); - + + let checkStyle = async () => { + checkStyle = () => {}; + if (!ns.embeddingDocument) return; + let replacement = document.querySelector("a.__NoScript_PlaceHolder__"); + if (!replacement) return; + if (window.getComputedStyle(replacement, null).opacity !== "0.8") { + document.head.appendChild(createHTMLElement("style")).textContent = await + (await fetch(browser.extension.getURL("/content/content.css"))).text(); + } + } + class Handler { constructor(type, selector) { this.type = type; @@ -9,6 +20,7 @@ var PlaceHolder = (() => { HANDLERS.set(type, this); } filter(element, request) { + if (request.embeddingDocument) return true; let url = request.initialUrl || request.url; return "data" in element ? element.data === url : element.src === url; } @@ -77,10 +89,14 @@ var PlaceHolder = (() => { .filter(element => this.handler.filter(element, request)) .forEach(element => this.replace(element)); }; - if (this.replacements.size) PlaceHolder.listen(); + if (this.replacements.size) { + PlaceHolder.listen(); + checkStyle(); + } } replace(element) { + if (!element.parentElement) return; let { url } = this.request; @@ -108,10 +124,10 @@ var PlaceHolder = (() => { replacement._placeHolderObj = this; replacement._placeHolderElement = element; - this.replacements.add(replacement); + - if (element.parentNode) element.parentNode.replaceChild(replacement, element); - else document.body.appendChild(replacement); + element.parentNode.replaceChild(replacement, element); + this.replacements.add(replacement); } async enable(replacement) { diff --git a/src/content/content.js b/src/content/content.js index a5d996d..c7fc045 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -1,112 +1,12 @@ 'use strict'; +// debug = () => {}; // REL_ONLY - // debug = () => {}; // REL_ONLY -{ - let listenersMap = new Map(); - let backlog = new Set(); - var ns = { - on(eventName, listener) { - let listeners = listenersMap.get(eventName); - if (!listeners) listenersMap.set(eventName, listeners = new Set()); - listeners.add(listener); - if (backlog.has(eventName)) this.fire(eventName, listener); - }, - detach(eventName, listener) { - let listeners = listenersMap.get(eventName); - if (listeners) listeners.delete(listener); - }, - fire(eventName, listener = null) { - if (listener) { - listener({type:eventName, source: this}); - return; - } - let listeners = listenersMap.get(eventName); - if (listeners) { - for (let l of listeners) { - this.fire(eventName, l); - } - } - backlog.add(eventName); - }, - setup(DEFAULT, MARKER) { - this.perms.DEFAULT = DEFAULT; - if(!this.perms.CURRENT) this.perms.CURRENT = DEFAULT; - - // ugly hack: since now we use registerContentScript instead of the - // filterRequest dynamic script injection hack, we use top.name - // to store per-tab information. We don't want web content to - // mess with it, though, so we wrap it around auto-hiding accessors - this.perms.MARKER = MARKER; - let eraseTabInfoRx = new RegExp(`[^]*${MARKER},?`); - if (eraseTabInfoRx.test(top.name)) { - let _name = top.name; - let tabInfoRx = new RegExp(`^${MARKER}\\[([^]*?)\\]${MARKER},`); - if (top === window) { // wrap to hide - Reflect.defineProperty(top.wrappedJSObject, "name", { - get: exportFunction(() => top.name.replace(eraseTabInfoRx, ""), top.wrappedJSObject), - set: exportFunction(value => { - let preamble = top.name.match(tabInfoRx); - top.name = `${preamble && preamble[0] || ""}${value}`; - return value; - }, top.wrappedJSObject) - }); - } - let tabInfoMatch = _name.match(tabInfoRx); - if (tabInfoMatch) try { - this.perms.tabInfo = JSON.parse(tabInfoMatch[1]); - } catch (e) { - error(e); - } - } - - if (!this.perms.DEFAULT || this.perms.tabInfo.unrestricted) { - this.allows = () => true; - this.capabilities = Object.assign( - new Set(["script"]), { has() { return true; } }); - } else { - let perms = this.perms.CURRENT || this.perms.DEFAULT; - this.capabilities = new Set(perms.capabilities); - new DocumentCSP(document).apply(this.capabilities); - } - ns.fire("perms"); - }, - perms: { DEFAULT: null, CURRENT: null, tabInfo: {}, MARKER: "" }, - - allows(cap) { - return this.capabilities && this.capabilities.has(cap); - }, - - getWindowName() { - return top !== window || !this.perms.MARKER ? window.name - : window.name.split(this.perms.MARKER + ",").pop(); - } - } -} - -var canScript = true, shouldScript = false; - -let now = () => performance.now() + performance.timeOrigin; +var _ = browser.i18n.getMessage; function createHTMLElement(name) { return document.createElementNS("http://www.w3.org/1999/xhtml", name); } -function probe() { - try { - debug("Probing execution..."); - let s = document.createElement("script"); - s.textContent = ";"; - document.documentElement.appendChild(s); - s.remove(); - } catch(e) { - debug(e); - } -} - -var _ = browser.i18n.getMessage; - -var embeddingDocument = false; - var seen = { _map: new Map(), _list: null, @@ -128,9 +28,8 @@ Messages.addHandler({ seen.record(event); } if (ownFrame) { - init(); if (!allowed && PlaceHolder.canReplace(policyType)) { - request.embeddingDocument = embeddingDocument; + request.embeddingDocument = ns.embeddingDocument; PlaceHolder.create(policyType, request); } } @@ -142,87 +41,38 @@ Messages.addHandler({ } }); -if (document.readyState !== "complete") { - let pageshown = e => { - removeEventListener("pageshow", pageshown); - init(); - }; - addEventListener("pageshow", pageshown); -} else { - init(true); -} -let notifyPage = async () => { + +debug(`Loading NoScript in document %s, scripting=%s, readyState %s`, + document.URL, ns.canScript, document.readyState); + +var notifyPage = async () => { debug("Page %s shown, %s", document.URL, document.readyState); if (document.readyState === "complete") { try { - await Messages.send("pageshow", {seen: seen.list, canScript}); + if (!("canScript" in ns)) { + let childPolicy = await Messages.send("fetchChildPolicy", {url: document.URL, contextUrl: top.location.href}); + ns.config.CURRENT = childPolicy.CURRENT; + ns.setup(childPolicy.DEFAULT, childPolicy.MARKER); + return; + } + + await Messages.send("pageshow", {seen: seen.list, canScript: ns.canScript}); return true; } catch (e) { debug(e); + if (/Receiving end does not exist/.test(e.message)) { + window.setTimeout(notifyPage, 2000); + } } } return false; } -var queryingStatus = false; +notifyPage(); -function reload(noCache = false) { - init = () => {}; - location.reload(noCache); -} +window.addEventListener("pageshow", notifyPage); -async function init(oldPage = false) { - if (queryingStatus) return; - if (!document.URL.startsWith("http")) { - return; - } - queryingStatus = true; - - debug(`init() called in document %s, contentType %s readyState %s, frameElement %o`, - document.URL, document.contentType, document.readyState, window.frameElement && frameElement.data); - - try { - ({canScript, shouldScript} = await Messages.send("queryDocStatus", {url: document.URL})); - debug(`document %s, canScript=%s, shouldScript=%s, readyState %s`, document.URL, canScript, shouldScript, document.readyState); - if (canScript) { - if (oldPage) { - probe(); - return; - } - if (!shouldScript && - (document.readyState !== "complete" || - now() - performance.timing.domContentLoadedEventStart < 5000)) { - // Something wrong: scripts can run, permissions say they shouldn't. - // Was webRequest bypassed by caching/session restore/service workers? - window.stop(); - let noCache = !!navigator.serviceWorker.controller; - if (noCache) { - for (let r of await navigator.serviceWorker.getRegistrations()) { - await r.unregister(); - } - } - debug("Reloading %s (%s)", document.URL, noCache ? "no cache" : "cached"); - reload(noCache); - return; - } - } - init = () => {}; - } catch (e) { - debug("Error querying docStatus", e); - if (!oldPage && - /Receiving end does not exist/.test(e.message)) { - // probably startup and bg page not ready yet, hence no CSP: reload! - debug("Reloading", document.URL); - reload(); - } else { - setTimeout(() => init(oldPage), 100); - } - return; - } finally { - queryingStatus = false; - } - - if (!canScript) onScriptDisabled(); +ns.on("capabilities", () => { seen.record({ request: { key: "noscript-probe", @@ -230,21 +80,13 @@ async function init(oldPage = false) { documentUrl: document.URL, type: window === window.top ? "main_frame" : "script", }, - allowed: canScript - } - ); - - debug(`Loading NoScript in document %s, scripting=%s, readyState %s`, - document.URL, canScript, document.readyState); - - if (/application|video|audio/.test(document.contentType)) { - debug("Embedding document detected"); - embeddingDocument = true; - window.addEventListener("pageshow", e => { - debug("Active content still in document %s: %o", document.url, document.querySelectorAll("embed,object,video,audio")); - }, true); - // document.write(""); + allowed: ns.canScript + }); + + if (!ns.canScript) { + if (document.readyState !== "loading") onScriptDisabled(); + window.addEventListener("DOMContentLoaded", onScriptDisabled); } + notifyPage(); - addEventListener("pageshow", notifyPage); -} +}); diff --git a/src/content/dynamicNS.js b/src/content/dynamicNS.js new file mode 100644 index 0000000..cdd7a0e --- /dev/null +++ b/src/content/dynamicNS.js @@ -0,0 +1,21 @@ +'use strict'; + +// ensure the order which manifest scripts and dynamically registered scripts +// are executed in doesn't matter for initialization, by using a stub. + +if (!this.ns) { + let deferredSetup = null; + let nsStub = this.ns = { + config: {}, + setup(DEFAULT, MARKER) { + deferredSetup = [DEFAULT, MARKER]; + }, + merge: ns => { + ns.config = Object.assign(ns.config, nsStub.config); + this.ns = ns; + if (deferredSetup) { + ns.setup(...deferredSetup); + } + } + } +} diff --git a/src/content/embeddingDocument.js b/src/content/embeddingDocument.js new file mode 100644 index 0000000..75b0db0 --- /dev/null +++ b/src/content/embeddingDocument.js @@ -0,0 +1,20 @@ +if (ns.embeddingDocument) { + ns.on("capabilities", () => { + for (let policyType of ["object", "media"]) { + if (!ns.allows(policyType)) { + let request = { + id: `noscript-${policyType}-doc`, + type: policyType, + url: document.URL, + documentUrl: document.URL, + embeddingDocument: true, + }; + let ph = PlaceHolder.create(policyType, request); + if (ph.replacements.size > 0) { + debug(`Created placeholder for ${policyType} at ${document.URL}`); + seen.record({policyType, request, allowed: false}); + } + } + } + }); +} diff --git a/src/content/media.js b/src/content/media.js index 910fd27..770a43f 100644 --- a/src/content/media.js +++ b/src/content/media.js @@ -1,5 +1,5 @@ -ns.on("perms", event => { - debug("Media Hook", document.URL, document.documentElement && document.documentElement.innerHTML, ns.perms.CURRENT); // DEV_ONLY +ns.on("capabilities", event => { + debug("Media Hook", document.URL, document.documentElement && document.documentElement.innerHTML, ns.capabilities); // DEV_ONLY let mediaBlocker = !ns.allows("media"); let unpatched = new Map(); function patch(obj, methodName, replacement) { diff --git a/src/content/staticNS.js b/src/content/staticNS.js new file mode 100644 index 0000000..817351a --- /dev/null +++ b/src/content/staticNS.js @@ -0,0 +1,99 @@ +'use strict'; +{ + let listenersMap = new Map(); + let backlog = new Set(); + + let ns = { + debug: true, // DEV_ONLY + get embeddingDocument() { + delete this.embeddingDocument; + return this.embeddingDocument = CSP.isEmbedType(document.contentType); + }, + on(eventName, listener) { + let listeners = listenersMap.get(eventName); + if (!listeners) listenersMap.set(eventName, listeners = new Set()); + listeners.add(listener); + if (backlog.has(eventName)) this.fire(eventName, listener); + }, + detach(eventName, listener) { + let listeners = listenersMap.get(eventName); + if (listeners) listeners.delete(listener); + }, + fire(eventName, listener = null) { + if (listener) { + listener({type:eventName, source: this}); + return; + } + let listeners = listenersMap.get(eventName); + if (listeners) { + for (let l of listeners) { + this.fire(eventName, l); + } + } + backlog.add(eventName); + }, + + setup(DEFAULT, MARKER) { + this.config.DEFAULT = DEFAULT; + if(!this.config.CURRENT) this.config.CURRENT = DEFAULT; + + // ugly hack: since now we use registerContentScript instead of the + // filterRequest dynamic script injection hack, we use top.name + // to store per-tab information. We don't want web content to + // mess with it, though, so we wrap it around auto-hiding accessors + this.config.MARKER = MARKER; + let eraseTabInfoRx = new RegExp(`[^]*${MARKER},?`); + if (eraseTabInfoRx.test(top.name)) { + let _name = top.name; + let tabInfoRx = new RegExp(`^${MARKER}\\[([^]*?)\\]${MARKER},`); + if (top === window) { // wrap to hide + Reflect.defineProperty(top.wrappedJSObject, "name", { + get: exportFunction(() => top.name.replace(eraseTabInfoRx, ""), top.wrappedJSObject), + set: exportFunction(value => { + let preamble = top.name.match(tabInfoRx); + top.name = `${preamble && preamble[0] || ""}${value}`; + return value; + }, top.wrappedJSObject) + }); + } + let tabInfoMatch = _name.match(tabInfoRx); + if (tabInfoMatch) try { + this.config.tabInfo = JSON.parse(tabInfoMatch[1]); + } catch (e) { + error(e); + } + } + + if (!this.config.DEFAULT || this.config.tabInfo.unrestricted) { + this.allows = () => true; + this.capabilities = Object.assign( + new Set(["script"]), { has() { return true; } }); + } else { + let perms = this.config.CURRENT; + this.capabilities = new Set(perms.capabilities); + new DocumentCSP(document).apply(this.capabilities, this.embeddingDocument); + } + + this.canScript = this.allows("script"); + this.fire("capabilities"); + }, + config: { DEFAULT: null, CURRENT: null, tabInfo: {}, MARKER: "" }, + + allows(cap) { + return this.capabilities && this.capabilities.has(cap); + }, + + getWindowName() { + let marker = this.config.MARKER; + return (top === window && marker) ? + window.name.split(`${marker},`).pop() + : window.name; + } + }; + + if (this.ns) { + this.ns.merge(ns); + } else { + this.ns = ns; + } +} diff --git a/src/content/webglHook.js b/src/content/webglHook.js index efafcd5..4475585 100644 --- a/src/content/webglHook.js +++ b/src/content/webglHook.js @@ -1,5 +1,5 @@ -ns.on("perms", event => { - debug("WebGL Hook", document.URL, document.documentElement && document.documentElement.innerHTML, ns.perms.CURRENT); // DEV_ONLY +ns.on("capabilities", event => { + debug("WebGL Hook", document.URL, document.documentElement && document.documentElement.innerHTML, ns.capabilities); // DEV_ONLY if (ns.allows("webgl")) return; let proto = HTMLCanvasElement.prototype; let getContext = proto.getContext; diff --git a/src/lib/CSP.js b/src/lib/CSP.js index 8550f09..79590bc 100644 --- a/src/lib/CSP.js +++ b/src/lib/CSP.js @@ -19,4 +19,5 @@ class CSP { } } +CSP.isEmbedType = type => /\b(?:application|video|audio)\b/.test(type); CSP.headerName = "content-security-policy"; diff --git a/src/lib/NetCSP.js b/src/lib/NetCSP.js index cb79a80..90ef8ad 100644 --- a/src/lib/NetCSP.js +++ b/src/lib/NetCSP.js @@ -27,4 +27,6 @@ class NetCSP extends CSP { return `${this.start}${super.build(...directives)}${this.end}`; } + cleanup(headers) { + } } diff --git a/src/manifest.json b/src/manifest.json index 143a776..7a78bef 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -59,6 +59,14 @@ }, "content_scripts": [ + { + "matches": ["<all_urls>"], + "match_about_blank": true, + "all_frames": true, + "css": [ + "/content/content.css" + ] + }, { "run_at": "document_start", "matches": ["<all_urls>"], @@ -71,20 +79,14 @@ "common/CapsCSP.js", "content/DocumentCSP.js", "content/onScriptDisabled.js", + "content/staticNS.js", "content/content.js", - "content/webglHook.js", "content/PlaceHolder.js", + "content/embeddingDocument.js", + "content/webglHook.js", "content/media.js" ] - }, - { - "matches": ["<all_urls>"], - "match_about_blank": true, - "all_frames": true, - "css": [ - "/content/content.css" - ] - } + } ], "options_ui": { diff --git a/src/xss/sanitizeName.js b/src/xss/sanitizeName.js index 4f36cbf..2a8acb6 100644 --- a/src/xss/sanitizeName.js +++ b/src/xss/sanitizeName.js @@ -1,4 +1,4 @@ -ns.on("perms", event => { +ns.on("capabilities", event => { if (ns.allows("script")) { let name = ns.getWindowName(); if (/[<"'\`(=:]/.test(name)) {