trisquel-icecat/icecat/extensions/gnu/jsr@javascriptrestrictor/nscl/common/SyncMessage.js

615 lines
21 KiB
JavaScript

/*
* NoScript Commons Library
* Reusable building blocks for cross-browser security/privacy WebExtensions.
* Copyright (C) 2020-2024 Giorgio Maone <https://maone.net>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
// depends on /nscl/common/uuid.js
// depends on /nscl/common/SyncMessage/request.json
// depends on /nscl/common/SyncMessage/response.json
"use strict";
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) {
// 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);
});
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 } });
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);
}
}
}
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 {
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) {
console.error(e);
return CANCEL;
}
};
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;
})();
}
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 onHeadersReceived = (request) => {
bug1899786(request);
return patchHeadersForXhr(request);
};
browser.webRequest.onBeforeRequest.addListener(
onBeforeRequest,
{
urls: [`${REQUEST_PREFIX}*`],
types: ["xmlhttprequest"],
},
["blocking"]
);
browser.webRequest.onHeadersReceived.addListener(
onHeadersReceived,
{
urls: ["<all_urls>"],
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;
}
}
};
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;
}
}
};
const observer = new MutationObserver(mutationsCallback);
observer.observe(document.documentElement, {
childList: true,
subtree: true,
attributeFilter: ["allow"],
});
}
const docId = uuid();
browser.runtime.sendSyncMessage = (msg) => {
let msgId = `${uuid()}:${docId}`;
let url = msgUrl(msgId);
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 (let loop = 0; ; ) {
try {
r.open("GET", url, false);
r.send(null);
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;
chunks.push(chunk);
if (more) {
continue;
}
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(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;
}
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;
};
}
}