diff options
Diffstat (limited to 'data/extensions/jsr@javascriptrestrictor/nscl/common/SyncMessage.js')
-rw-r--r-- | data/extensions/jsr@javascriptrestrictor/nscl/common/SyncMessage.js | 747 |
1 files changed, 548 insertions, 199 deletions
diff --git a/data/extensions/jsr@javascriptrestrictor/nscl/common/SyncMessage.js b/data/extensions/jsr@javascriptrestrictor/nscl/common/SyncMessage.js index af83b76..db98d6f 100644 --- a/data/extensions/jsr@javascriptrestrictor/nscl/common/SyncMessage.js +++ b/data/extensions/jsr@javascriptrestrictor/nscl/common/SyncMessage.js @@ -1,7 +1,7 @@ /* * NoScript Commons Library * Reusable building blocks for cross-browser security/privacy WebExtensions. - * Copyright (C) 2020-2023 Giorgio Maone <https://maone.net> + * Copyright (C) 2020-2024 Giorgio Maone <https://maone.net> * * SPDX-License-Identifier: GPL-3.0-or-later * @@ -18,249 +18,598 @@ * this program. If not, see <https://www.gnu.org/licenses/>. */ +// depends on /nscl/common/uuid.js +// depends on /nscl/common/SyncMessage/request.json +// depends on /nscl/common/SyncMessage/response.json + "use strict"; -(() => { - let MOZILLA = "mozSystem" in XMLHttpRequest.prototype; - let ENDPOINT_ORIGIN = "https://[ff00::]"; - let ENDPOINT_PREFIX = `${ENDPOINT_ORIGIN}/nscl/${browser.runtime.getURL("syncMessage")}?`; +if (!["onSyncMessage", "sendSyncMessage"].some((m) => browser.runtime[m])) { + const MOZILLA = + self.XMLHttpRequest && "mozSystem" in self.XMLHttpRequest.prototype; + + const INTERNAL_PATH = "/nscl/common/SyncMessage/"; + + const MANIFEST = browser.runtime.getManifest(); + const USE_INTERNAL_URIS = MANIFEST.web_accessible_resources + ?.some(({ resources }) => + resources.includes(`${INTERNAL_PATH}*`) + ); + const IPV6_DUMMY_ENDPOINT = "https://[ff00::]"; + const BASE_PREFIX = browser.runtime.getURL(INTERNAL_PATH); + // We cannot use BASE_PREFIX w/ internal URIs for requests (yet?) because + // neither DNR nor webRequest nor ServiceWorker intercept our own extension URLs :( + const REQUEST_PREFIX = `${IPV6_DUMMY_ENDPOINT}/${BASE_PREFIX}request.json?`; + // But we can redirect to extension URLs on MV3 + const RESPONSE_PREFIX = USE_INTERNAL_URIS ? BASE_PREFIX + "response.json?" : "data:application/json,"; + + const msgUrl = (msgId) => `${REQUEST_PREFIX}id=${encodeURIComponent(msgId)}`; + + // https://github.com/w3c/webappsec-permissions-policy/blob/main/permissions-policy-explainer.md#appendix-big-changes-since-this-was-called-feature-policy + const allowSyncXhr = (policy) => + policy + .replace(/(?:[,;]\s*)?\b(?:sync-xhr\b[^;,]*)/gi, "") + .replace(/^\s*[;,]\s*/, ""); if (browser.webRequest) { - if (typeof browser.runtime.onSyncMessage !== "object") { - // Background Script side - - let pending = new Map(); - if (MOZILLA) { - // we don't care this is async, as long as it get called before the - // sync XHR (we are not interested in the response on the content side) - browser.runtime.onMessage.addListener((m, sender) => { - let wrapper = m.__syncMessage__; - if (!wrapper) return; - let {id} = wrapper; - pending.set(id, wrapper); - wrapper.result = Promise.resolve(notifyListeners(JSON.stringify(wrapper.payload), sender)); - return Promise.resolve(null); - }); + // Background script / event page / service worker + + const USE_SERVICE_WORKER = "onfetch" in self && REQUEST_PREFIX.startsWith(BASE_PREFIX); + + let anyMessageYet = false; + + const retries = new Set(); + + // we don't care this is async, as long as it get called before the + // sync XHR (we are not interested in the response on the content side) + browser.runtime.onMessage.addListener((m, sender) => { + let wrapper = m.__syncMessage__; + if (!wrapper) return; + if(wrapper.retry) { + const retryKey = `${sender.tab.id}:${sender.frameId}:${sender.origin}@${sender.url}`; + let retried = retries.has(retryKey); + if (retried) { + retries.delete(retryKey); + } else { + retries.add(retryKey); + } + return Promise.resolve(!retried); + } + if (wrapper.release) { + suspender.release(wrapper.id); + } else if ("payload" in wrapper) { + anyMessageYet = true; + wrapper.result = Promise.resolve( + notifyListeners(JSON.stringify(wrapper.payload), sender) + ); + suspender.hold(wrapper); } + return Promise.resolve(null); + }); - let tabUrlCache = new Map(); - let asyncResults = new Map(); - let tabRemovalListener = null; - let CANCEL = {cancel: true}; - let {TAB_ID_NONE} = browser.tabs; + const asyncResults = new Map(); + const ret = (r) => ({ + redirectUrl: `${ + RESPONSE_PREFIX + }${ + encodeURIComponent(JSON.stringify(r)) + }`, + }); + const res = (payload) => ({ payload }); + const err = (e) => ({ error: { message: e.message, stack: e.stack } }); - let onBeforeRequest = request => { try { - let {url, tabId} = request; - let params = new URLSearchParams(url.split("?")[1]); - let msgId = params.get("id"); - if (asyncResults.has(msgId)) { - return asyncRet(msgId); + const LOOP_RET = ret({ loop: 1 }); + + const asyncRet = (msgId) => { + let chunks = asyncResults.get(msgId); + let chunk = chunks.shift(); + let more = chunks.length; + if (more === 0) { + asyncResults.delete(msgId); + suspender.release(msgId); + } + return ret({ chunk, more }); + }; + + const CHUNK_SIZE = 500000; // Work around any browser-dependent URL limit + const storeAsyncRet = (msgId, r) => { + r = JSON.stringify(r); + const len = r === undefined ? 0 : r.length; + const chunksCount = Math.ceil(len / CHUNK_SIZE); + const chunks = []; + for (let j = 0; j < chunksCount; j++) { + chunks.push(r.substr(j * CHUNK_SIZE, CHUNK_SIZE)); + } + asyncResults.set(msgId, chunks); + }; + + const listeners = new Set(); + function notifyListeners(msg, sender) { + // Just like in the async runtime.sendMessage() API, + // we process the listeners in order until we find a not undefined + // result, then we return it (or undefined if none returns anything). + for (let l of listeners) { + try { + let result = l(JSON.parse(msg), sender); + if (result !== undefined) return result; + } catch (e) { + console.error("%o processing message %o from %o", e, msg, sender); } - let msg = params.get("msg"); - - if (MOZILLA || tabId === TAB_ID_NONE) { - // this shoud be a mozilla suspension request - if (pending.has(msgId)) { - let wrapper = pending.get(msgId); - pending.delete(msgId); - return (async () => { + } + } + + const url2MsgId = url => new URLSearchParams(url.split("?")[1])?.get("id"); + class Suspender { + #pending = new Map(); + constructor(init) { + init.apply(this); + } + async hold(wrapper) { + this.#pending.set(wrapper.id, wrapper); + } + release(id) { + this.#pending.delete(id); + } + get(id) { + return this.#pending.get(id); + } + } + + const suspender = + USE_SERVICE_WORKER + ? new Suspender(function() { + // MV3 with service worker + addEventListener("fetch", event => { + const msgId = url2MsgId(event.request.url); + if (!msgId) return; + const wrapper = this.get(msgId); + this.release(msgId); + event.respondWith((async () => new Response(await wrapper.result))()); + }); + }) + : browser.declarativeNetRequest && !MOZILLA + ? (() => { + // MV3 + const DNR_BASE_ID = 65535; + const DNR_BASE_PRIORITY = 1000; + let lastRuleId = DNR_BASE_ID; + const msg2redirector = new Map(); + const { redirectUrl } = LOOP_RET; + const resourceTypes = ["xmlhttprequest"]; + + const createRedirector = async ( + urlFilter, + redirectUrl, + options + ) => { + const DEFAULT_OPTIONS = { + ruleSet: "Session", + priority: DNR_BASE_PRIORITY + 10, + addRules: [], + removeRuleIds: [] + } + let { ruleSet, priority, addRules, removeRuleIds } = Object.assign( + {}, + DEFAULT_OPTIONS, + options + ); + + const rule = { + id: ++lastRuleId, + priority, + action: { + type: "redirect", + redirect: { url: redirectUrl }, + }, + condition: { + urlFilter, + resourceTypes, + }, + }; + + + addRules.push(rule); + const method = `update${ruleSet}Rules`; + await browser.declarativeNetRequest[method]({ + addRules, + removeRuleIds, + }); + + return lastRuleId; + }; + + const removeRedirector = (redirId) => { + browser.declarativeNetRequest.updateSessionRules({ + removeRuleIds: [redirId], + }); + }; + + (async () => { + const allowSyncXhrRules = [ + { + id: ++lastRuleId, + priority: DNR_BASE_PRIORITY, + action: { + type: "modifyHeaders", + // Note: notwithstanding poor documentation, looks like in modern browsers + // permissions-policy overrides (document|feature)-policy, & DNR appending + // to the header overrides the restrictive token despite inheritance rules, + // making the following hack work, quite surprisingly and nicely (i.e. + // other policies, if present, remain effective). + responseHeaders: [ + { + header: "permissions-policy", + operation: "append", + value: "sync-xhr=*", + }, + ], + }, + condition: { + resourceTypes: ["main_frame", "sub_frame"], + }, + }, + ]; + + for (const ruleSet of ["Dynamic", "Session"]) { + try { + const removeRuleIds = ( + await browser.declarativeNetRequest[`get${ruleSet}Rules`]() + ) + .map((r) => r.id) + .filter((id) => id >= DNR_BASE_ID); + const options = { + ruleSet, + priority: DNR_BASE_PRIORITY, + addRules: allowSyncXhrRules, + removeRuleIds, + }; + await createRedirector( + `|${REQUEST_PREFIX}*`, + redirectUrl, + options + ); + } catch (e) { + console.error(e, "Error initializing SyncMessage DNR responders."); + } + } + + })(); + + return { + async hold(wrapper) { + let result; + try { + result = ret(res(await wrapper.result)); + } catch (e) { + result = ret(err(e)); + } + const { id } = wrapper; + const urlFilter = `|${msgUrl(wrapper.id)}`; + const redirId = await createRedirector(urlFilter, result.redirectUrl); + msg2redirector.set(id, redirId); + }, + release(id) { + const redirId = msg2redirector.get(id); + if (!redirId) return; + msg2redirector.delete(id); + removeRedirector(redirId); + }, + }; + })() + : new Suspender(function() { + // MV2 + const CANCEL = { cancel: true }; + const onBeforeRequest = (request) => { try { - return ret({payload: (await wrapper.result)}); + const { url } = request; + const shortUrl = url.replace(REQUEST_PREFIX, ""); + const msgId = url2MsgId(url); + + const chromeRet = (resultReady) => { + const r = resultReady + ? asyncRet(msgId) // promise was already resolved + : LOOP_RET; + return r; + }; + + if (asyncResults.has(msgId)) { + return chromeRet(true); + } + + const wrapper = this.get(msgId); + + if (!wrapper) { + return anyMessageYet + ? CANCEL // cannot reconcile with any pending message, abort + : LOOP_RET; // never received any message yet, retry + } + + if (MOZILLA) { + // this should be a mozilla suspension request + return (async () => { + try { + return ret(res(await wrapper.result)); + } catch (e) { + return ret(err(e)); + } finally { + this.release(msgId); + } + })(); + } + + // CHROMIUM from now on + // On Chromium, if the promise is not resolved yet, + // we redirect the XHR to the same URL (hence same msgId) + // while the result get cached for asynchronous retrieval + wrapper.result.then( + (r) => storeAsyncRet(msgId, res(r)), + (e) => storeAsyncRet(msgId, err(e)) + ); + return chromeRet(asyncResults.has(msgId)); } catch (e) { - return ret({error: { message: e.message, stack: e.stack }}); + console.error(e); + return CANCEL; } - })() - } - return CANCEL; // otherwise, bail - } - // CHROME from now on - let documentUrl = request.initiator || params.get("url"); - let {frameAncestors, frameId} = request; - let isTop = frameId === 0 || !!params.get("top"); - let tabUrl = frameAncestors && frameAncestors.length - && frameAncestors[frameAncestors.length - 1].url; - - if (!tabUrl) { - if (isTop) { - tabUrlCache.set(tabId, tabUrl = documentUrl); - if (!tabRemovalListener) { - browser.tabs.onRemoved.addListener(tabRemovalListener = tab => { - tabUrlCache.delete(tab.id); - }); - } - } else { - tabUrl = tabUrlCache.get(tabId); - } - } - let sender = { - tab: { - id: tabId, - url: tabUrl - }, - frameId, - url: documentUrl, - timeStamp: Date.now() - }; - - if (!(msg !== null && sender)) { - return CANCEL; - } - let result = Promise.resolve(notifyListeners(msg, sender)); - // On Chromium, if the promise is not resolved yet, - // we redirect the XHR to the same URL (hence same msgId) - // while the result get cached for asynchronous retrieval - result.then(r => storeAsyncRet(msgId, r)); - return asyncResults.has(msgId) - ? asyncRet(msgId) // promise was already resolved - : {redirectUrl: url.replace( - /&redirects=(\d+)|$/, // redirects count to avoid loop detection - (all, count) => `&redirects=${parseInt(count) + 1 || 1}`)}; - } catch(e) { - console.error(e); - return CANCEL; - } }; - - let onHeaderReceived = request => { - let replaced = ""; - let {responseHeaders} = request; - let rxFP = /^feature-policy$/i; - for (let h of request.responseHeaders) { - if (rxFP.test(h.name)) { - h.value = h.value.replace(/\b(sync-xhr\s+)([^*][^;]*)/g, - (all, m1, m2) => replaced = - `${m1}${m2.replace(/'none'/, '')} 'self'` - ); - } - } - return replaced ? {responseHeaders} : null; - }; + }; - let ret = r => ({redirectUrl: `data:application/json,${encodeURIComponent(JSON.stringify(r))}`}); + const NOP = () => {}; + let bug1899786 = NOP; + if (browser.webRequest.filterResponseData) { + bug1899786 = (request) => { + // work-around for https://bugzilla.mozilla.org/show_bug.cgi?id=1899786 + let compressed = false, + xml = false; + for (const { name, value } of request.responseHeaders) { + switch (name.toLowerCase()) { + case "content-encoding": + if ( + compressed || + !(compressed = + /^(?:gzip|compress|deflate|br|zstd)$/i.test(value)) + ) { + continue; + } + break; + case "content-type": + if (xml || !(xml = /\bxml\b/i.test(value))) { + continue; + } + break; + default: + continue; + } + if (compressed && xml) { + console.log("Applying mozbug 1899786 work-around", request); + const filter = browser.webRequest.filterResponseData( + request.requestId + ); + filter.ondata = (e) => { + filter.write(e.data); + }; + filter.onstop = () => { + filter.close(); + }; + break; + } + } + }; + (async () => { + const version = parseInt( + (await browser.runtime.getBrowserInfo()).version + ); + if (version < 126) bug1899786 = NOP; + })(); + } - let asyncRet = msgId => { - let chunks = asyncResults.get(msgId); - let chunk = chunks.shift(); - let more = chunks.length; - if (more === 0) { - asyncResults.delete(msgId); - } - return ret({chunk, more}); - }; + const patchHeadersForXhr = MANIFEST.manifest_version < 3 + ? NOP // XHR don't need to bypass CSP in manifest V2 + : (request) => { + let replaced = false; + let replacedCSP = false; + const { responseHeaders } = request; + const CSP = "content-security-policy"; + const rxPolicy = /^(?:feature|permissions|document)-policy$/; + for (let h of responseHeaders) { + const name = h.name.toLowerCase(); + let value; + if (rxPolicy.test(name)) { + value = allowSyncXhr(h.value); + } else if (name == CSP) { + value = h.value.replace(/connect-src [^;]+/g, m => { + const tokens = new Set(m.split(/\s+/)); + tokens.delete("'none'"); + const msgSrc = new URL(REQUEST_PREFIX).origin; + tokens.has(msgSrc) || tokens.add(msgSrc); + return [...tokens].join(" "); + }); + replacedCSP = true; + } else { + continue; + } + if (value !== h.value) { + h.value = value; + replaced = true; + } + } + if (replaced) { + console.log("Patched responseHeaders", request.url, responseHeaders); // DEV_ONLY + if (replacedCSP) { + // We need to clear the header first, in order to avoid merging, see + // - https://searchfox.org/mozilla-central/source/toolkit/components/extensions/webrequest/WebRequest.sys.mjs#257 + // - https://bugzilla.mozilla.org/show_bug.cgi?id=1462989 + // This does NOT work (yet?) on MV3, see https://github.com/w3c/webextensions/issues/730 + responseHeaders.unshift({name: CSP, value: ""}); + } + return { responseHeaders }; + } + }; - const CHUNK_SIZE = 500000; // Work around any browser-dependent URL limit - let storeAsyncRet = (msgId, r) => { - r = JSON.stringify(r); - let len = r.length; - let chunksCount = Math.ceil(len / CHUNK_SIZE); - let chunks = []; - for (let j = 0; j < chunksCount; j++) { - chunks.push(r.substr(j * CHUNK_SIZE, CHUNK_SIZE)); - } - asyncResults.set(msgId, chunks); - }; + const onHeadersReceived = (request) => { + bug1899786(request); + return patchHeadersForXhr(request); + }; - let listeners = new Set(); - function notifyListeners(msg, sender) { - // Just like in the async runtime.sendMessage() API, - // we process the listeners in order until we find a not undefined - // result, then we return it (or undefined if none returns anything). - for (let l of listeners) { - try { - let result = l(JSON.parse(msg), sender); - if (result !== undefined) return result; - } catch (e) { - console.error("%o processing message %o from %o", e, msg, sender); - } - } - } - browser.runtime.onSyncMessage = Object.freeze({ - ENDPOINT_PREFIX, - addListener(l) { - listeners.add(l); - if (listeners.size === 1) { - browser.webRequest.onBeforeRequest.addListener(onBeforeRequest, + browser.webRequest.onBeforeRequest.addListener( + onBeforeRequest, { - urls: [`${ENDPOINT_PREFIX}*`], - types: ["xmlhttprequest"] + urls: [`${REQUEST_PREFIX}*`], + types: ["xmlhttprequest"], }, ["blocking"] ); - browser.webRequest.onHeadersReceived.addListener(onHeaderReceived, + browser.webRequest.onHeadersReceived.addListener( + onHeadersReceived, { urls: ["<all_urls>"], - types: ["main_frame", "sub_frame"] + types: ["main_frame", "sub_frame"], }, ["blocking", "responseHeaders"] ); + + + } + ); + + + browser.runtime.onSyncMessage = Object.freeze({ + BASE_PREFIX, + REQUEST_PREFIX, + RESPONSE_PREFIX, + addListener(l) { + listeners.add(l); + }, + removeListener(l) { + listeners.delete(l); + }, + hasListener(l) { + return listeners.has(l); + }, + isMessageRequest({type, url}) { + return ( + type === "xmlhttprequest" && + url.includes(INTERNAL_PATH) && + (url.includes(REQUEST_PREFIX) || url.includes(RESPONSE_PREFIX)) + ); + }, + }); + } else { + // Content Script side + { + // re-enable Sync XHR if disabled by featurePolicy + const allow = f => { + if (f.allow) { + const allowingValue = allowSyncXhr(f.allow); + if (f.allow != allowingValue) { + f.allow = allowingValue; + f.src = f.src; } - }, - removeListener(l) { - listeners.remove(l); - if (listeners.size === 0) { - browser.webRequest.onBeforeRequest.removeListener(onBeforeRequest); - browser.webRequest.onHeadersReceived.removeListener(onHeadersReceived); + } + }; + try { + // this is probably useless, but nontheless... + window.frameElement && allow(window.frameElement); + } catch (e) { + // SOP violation? + console.error(e); // DEV_ONLY + } + const mutationsCallback = records => { + for (var r of records) { + switch (r.type) { + case "attributes": + allow(r.target); + break; + case "childList": + [...r.addedNodes].forEach(allow); + break; } - }, - hasListener(l) { - return listeners.has(l); - }, - isMessageRequest(request) { - return request.type === "xmlhttprequest" && request.url.startsWith(ENDPOINT_PREFIX); } + }; + const observer = new MutationObserver(mutationsCallback); + observer.observe(document.documentElement, { + childList: true, + subtree: true, + attributeFilter: ["allow"], }); } - } else if (typeof browser.runtime.sendSyncMessage !== "function") { - // Content Script side - let uuid = () => (Math.random() * Date.now()).toString(16); - let docUrl = document.URL; - browser.runtime.sendSyncMessage = (msg, callback) => { - let msgId = `${uuid()},${docUrl}`; - let url = `${ENDPOINT_PREFIX}id=${encodeURIComponent(msgId)}` + - `&url=${encodeURIComponent(docUrl)}`; - if (window.top === window) { - // we add top URL information because Chromium doesn't know anything - // about frameAncestors - url += "&top=true"; - } - if (MOZILLA) { - // on Firefox we first need to send an async message telling the - // background script about the tab ID, which does not get sent - // with "privileged" XHR - browser.runtime.sendMessage( - {__syncMessage__: {id: msgId, payload: msg}} - ); - } - // then we send the payload using a privileged XHR, which is not subject - // to CORS but unfortunately doesn't carry any tab id except on Chromium + const docId = uuid(); + browser.runtime.sendSyncMessage = (msg) => { + let msgId = `${uuid()}:${docId}`; + let url = msgUrl(msgId); - url += `&msg=${encodeURIComponent(JSON.stringify(msg))}`; // adding the payload + const preSend = __syncMessage__ => browser.runtime.sendMessage({__syncMessage__}); + + // We first need to send an async message with both the payload + // and "trusted" sender metadata, along with an unique msgId to + // reconcile with in the retrieval phase via synchronous XHR + const preflight = preSend({ id: msgId, payload: msg }); + + // Now go retrieve the result! + const MAX_LOOPS = 1000; let r = new XMLHttpRequest(); + let result; let chunks = []; - for (;;) { + for (let loop = 0; ; ) { try { r.open("GET", url, false); r.send(null); - result = JSON.parse(r.responseText); + const rawResult = r.responseURL.startsWith(RESPONSE_PREFIX) + ? decodeURIComponent(r.responseURL.replace(RESPONSE_PREFIX, "")) + : r.responseText; + result = JSON.parse(rawResult); if ("chunk" in result) { - let {chunk, more} = result; + let { chunk, more } = result; chunks.push(chunk); if (more) { continue; } - result = JSON.parse(chunks.join('')); - } else { - if (result.error) throw result.error; - result = "payload" in result ? result.payload : result; + result = JSON.parse(chunks.join("")); + } else if (result.loop) { + if (++loop > MAX_LOOPS) { + console.debug( + "Too many loops (%s), look for deadlock conditions.", + loop + ); + throw new Error("Too many SyncMessage loops!"); + } + continue; + } else if (result.error) { + result.error = new Error(result.error.message + ` (${url})`, result.error); } - } catch(e) { - console.error(`syncMessage error in ${document.URL}: ${e.message} (response ${r.responseText})`); + } catch (e) { + console.error(e, + `SyncMessage ${msgId} error in ${document.URL}: ${e.message} (response ${url} - ${r.responseURL} - ${r.responseText})` + ); + result = { + error: new Error(`SyncMessage Error ${e.message}`, { cause: e }), + }; } break; } - if (callback) callback(result); - return result; + preSend({ id: msgId, release: true }); + if (result.error) { + if (document.featurePolicy && !document.featurePolicy?.allowsFeature("sync-xhr")) { + throw new Error(`SyncMessage fails on ${document.URL} because sync-xhr is not allowed!`); + } + if (document.readyState == "loading" && /Failed to load/.test(result.error.message)) { + window.stop(); + (async () => { + try { + await preflight; + browser.runtime.sendSyncMessage(msg); + } catch (e) { + console.error(e, `SyncMessage immediate retry failed on ${document.URL}!`); + if (!(await preSend({retry: true}))) { + return; + } + } + history.go(0); + })(); + } + throw result.error; + } + return result.payload; }; } -})(); +} |