488 lines
No EOL
17 KiB
JavaScript
488 lines
No EOL
17 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
|
|
|
|
"use strict";
|
|
/**
|
|
* Injects code into page context in a cross-browser way, providing it
|
|
* with tools to wrap/patch the DOM and the JavaScript environment
|
|
* and propagating the changes to child windows created on the fly in order
|
|
* to prevent the modifications to be cancelled by hostile code.
|
|
*
|
|
* @param {function} patchingCallback
|
|
* the (semi)privileged wrapping code to be injected.
|
|
* Warning: this is not to be considered a closure, since Chromium
|
|
* injection needs it to be reparsed out of context.
|
|
* Use the env argument to propagate parameters.
|
|
* It will be called as patchingCallback(unwrappedWindow, env).
|
|
* @param {object} env
|
|
* a JSON-serializable object to made available to patchingCallback as
|
|
* its second argument. It gets augmented by two additional properties:
|
|
* 1. a Port (port: {postMessage(), onMessage()}) object
|
|
* allowing the injected script to communicate with
|
|
* the privileged content script by calling port.postMessage(msg, [event])
|
|
* and/or by listening to a port.onMessage(msg, event) user-defined callback.
|
|
* 2. A "xray" object property to help handling
|
|
* Firefox's XRAY wrappers.
|
|
* xray: {
|
|
* enabled: true, // false on Chromium
|
|
* unwrap(obj), // returns the XPC-wrapped object - or just obj on Chromium
|
|
* wrap(obj), // returns the XPC wrapper around the object - or just obj on Chromium
|
|
* forPage(obj), // returns cloneInto(obj) including functions and DOM objects - or just obj on Chromium
|
|
* window, // the XPC-wrapped version of unwrappedWindow, or unwrappedWindow itself on Chromium
|
|
* }
|
|
* @returns {object} port
|
|
* A Port object allowing the privileged content script to communicate
|
|
* with the injected script on the page by calling port.postMessage(msg, [event])
|
|
* and/or by listening to a port.onMessage(msg, event) user-defined callback.
|
|
*/
|
|
|
|
function patchWindow(patchingCallback, env = {}) {
|
|
const forcedPortId = patchingCallback.portId;
|
|
const justPort = forcedPortId && !patchingCallback.code;
|
|
const portId = forcedPortId ||
|
|
this && this.portId ||
|
|
`windowPatchMessages:${uuid()}`;
|
|
|
|
const { dispatchEvent, addEventListener } = self;
|
|
|
|
function Port(from, to) {
|
|
if (!self.document) {
|
|
// ServiceWorker scope, dummy port, won't be used.
|
|
this.postMessage = () => {};
|
|
return;
|
|
}
|
|
// we need a double dispatching dance and maintaining a stack of
|
|
// return values / thrown errors because Chromium seals the detail object
|
|
// (on Firefox we could just append further properties to it...)
|
|
let retStack = [];
|
|
|
|
function fire(e, detail, target = window) {
|
|
dispatchEvent.call(target, new CustomEvent(`${portId}:${e}`, {detail, composed: true}));
|
|
}
|
|
this.postMessage = function(msg, target = window) {
|
|
retStack.push({});
|
|
let detail = {msg};
|
|
fire(to, detail, target);
|
|
let ret = retStack.pop();
|
|
if (ret.error) throw ret.error;
|
|
return ret.value;
|
|
};
|
|
addEventListener.call(window, `${portId}:${from}`, event => {
|
|
if (typeof this.onMessage === "function" && event.detail) {
|
|
let ret = {};
|
|
try {
|
|
ret.value = this.onMessage(event.detail.msg, event);
|
|
} catch (error) {
|
|
ret.error = error;
|
|
}
|
|
fire(`return:${to}`, ret);
|
|
}
|
|
}, true);
|
|
addEventListener.call(window, `${portId}:return:${from}`, event => {
|
|
let {detail} = event;
|
|
if (detail && retStack.length) {
|
|
retStack[retStack.length -1] = detail;
|
|
}
|
|
}, true);
|
|
this.onMessage = null;
|
|
}
|
|
let port = new Port("extension", "page");
|
|
if (patchWindow.disabled) {
|
|
return port;
|
|
}
|
|
if (justPort) {
|
|
return port;
|
|
} else if (patchingCallback.code) {
|
|
patchingCallback = patchingCallback.code;
|
|
}
|
|
|
|
const nativeExport = typeof exportFunction == "function";
|
|
if (typeof patchingCallback !== "function") {
|
|
patchingCallback =
|
|
nativeExport ? new Function("unwrappedWindow", "env", patchingCallback)
|
|
: `function (unwrappedWindow, env) {\n${patchingCallback}\n}`;
|
|
}
|
|
if (!(nativeExport || this && this.exportFunction)) {
|
|
// Chromium
|
|
let exportFunction = (func, targetObject, {defineAs, original} = {}) => {
|
|
try {
|
|
let [propDef, getOrSet, propName] = defineAs && /^([gs]et)(?:\s+(\w+))$/.exec(defineAs) || [null, null, defineAs];
|
|
let propDes = propName && Object.getOwnPropertyDescriptor(targetObject, propName);
|
|
if (getOrSet && !propDes) { // escalate through prototype chain
|
|
for (let proto = Object.getPrototypeOf(targetObject); proto; proto = Object.getPrototypeOf(proto)) {
|
|
propDes = Object.getOwnPropertyDescriptor(proto, propName);
|
|
if (propDes) {
|
|
targetObject = proto;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
let toString = Function.prototype.toString;
|
|
let strVal;
|
|
if (!original) {
|
|
original = propDef && propDes ? propDes[getOrSet] : defineAs && targetObject[defineAs];
|
|
}
|
|
if (!original) {
|
|
// It seems to be a brand new function, rather than a replacement.
|
|
// Let's ensure it appears as a native one with little hack: we proxy a Promise callback ;)
|
|
Promise.resolve(new Promise(resolve => original = resolve));
|
|
let name = propDef && propDes ? `${getOrSet} ${propName}` : defineAs;
|
|
if (name) {
|
|
let nameDef = Reflect.getOwnPropertyDescriptor(original, "name");
|
|
nameDef.value = name;
|
|
Reflect.defineProperty(original, "name", nameDef);
|
|
strVal = toString.call(original).replace(/^function \(\)/, `function ${name}()`)
|
|
}
|
|
}
|
|
|
|
strVal = strVal || toString.call(original);
|
|
|
|
let proxy = new Proxy(original, {
|
|
apply(target, thisArg, args) {
|
|
return func.apply(thisArg, args);
|
|
}
|
|
});
|
|
|
|
if (!exportFunction._toStringMap) {
|
|
let map = new WeakMap();
|
|
exportFunction._toStringMap = map;
|
|
let toStringProxy = new Proxy(toString, {
|
|
apply(target, thisArg, args) {
|
|
return map.has(thisArg) ? map.get(thisArg) : Reflect.apply(target, thisArg, args);
|
|
}
|
|
});
|
|
map.set(toStringProxy, toString.apply(toString));
|
|
Function.prototype.toString = toStringProxy;
|
|
}
|
|
exportFunction._toStringMap.set(proxy, strVal);
|
|
|
|
if (propName) {
|
|
if (!propDes) {
|
|
targetObject[propName] = proxy;
|
|
} else {
|
|
if (getOrSet) {
|
|
propDes[getOrSet] = proxy;
|
|
} else {
|
|
if ("value" in propDes) {
|
|
propDes.value = proxy;
|
|
} else {
|
|
return exportFunction(() => proxy, targetObject, `get ${propName}`);
|
|
}
|
|
}
|
|
Object.defineProperty(targetObject, propName, propDes);
|
|
}
|
|
}
|
|
return proxy;
|
|
} catch (e) {
|
|
console.error(e, `setting ${targetObject}.${defineAs || original}`, func);
|
|
}
|
|
return null;
|
|
};
|
|
let cloneInto = (obj, targetObject) => {
|
|
return obj; // dummy for assignment
|
|
};
|
|
|
|
const code = `
|
|
(() => {
|
|
let patchWindow = ${patchWindow};
|
|
let cloneInto = ${cloneInto};
|
|
let exportFunction = ${exportFunction};
|
|
let env = ${JSON.stringify(env)};
|
|
let portId = ${JSON.stringify(portId)};
|
|
const console = Object.fromEntries(Object.entries(self.console).map(([n, v]) => v.bind ? [n, v.bind(self.console)] : [n,v]));
|
|
|
|
env.port = new (${Port})("page", "extension");
|
|
({
|
|
patchWindow,
|
|
exportFunction,
|
|
cloneInto,
|
|
portId,
|
|
}).patchWindow(${patchingCallback}, env);
|
|
})();
|
|
`;
|
|
if (!self.document) {
|
|
// we're doing it with userScripts on mv3
|
|
return {portId, code};
|
|
}
|
|
let script = document.createElement("script");
|
|
script.text = code;
|
|
try {
|
|
document.documentElement.insertBefore(script, document.documentElement.firstChild);
|
|
} catch(e) {
|
|
console.error(e, code);
|
|
}
|
|
script.remove();
|
|
return port;
|
|
}
|
|
|
|
env.port = new Port("page", "extension");
|
|
|
|
const {xrayEnabled} = patchWindow;
|
|
const zombieDanger = xrayEnabled && document.readyState === "complete";
|
|
const isZombieException = e => e.message.includes("dead object");
|
|
|
|
const getSafeMethod = zombieDanger
|
|
? (obj, method, wrappedObj) => {
|
|
let actualTarget = obj[method];
|
|
return XPCNativeWrapper.unwrap(new window.Proxy(actualTarget, cloneInto({
|
|
apply(targetFunc, thisArg, args) {
|
|
try {
|
|
return actualTarget.apply(thisArg, args);
|
|
} catch (e) {
|
|
if (isZombieException(e)) {
|
|
return (actualTarget = (wrappedObj || XPCNativeWrapper(obj))[method]).apply(thisArg, args);
|
|
}
|
|
throw e;
|
|
}
|
|
},
|
|
}, window, {cloneFunctions: true, wrapReflectors: true}
|
|
)));
|
|
|
|
} : (obj, method) => obj[method];
|
|
|
|
const getSafeDescriptor = (proto, prop, accessor) => {
|
|
const des = Reflect.getOwnPropertyDescriptor(proto, prop);
|
|
if (zombieDanger) {
|
|
const wrappedDescriptor = Reflect.getOwnPropertyDescriptor(xray.wrap(proto), prop);
|
|
des[accessor] = getSafeMethod(des, accessor, wrappedDescriptor);
|
|
}
|
|
return des;
|
|
}
|
|
|
|
let xrayMake = (enabled, wrap, unwrap = wrap, forPage = wrap) => ({
|
|
enabled, wrap, unwrap, forPage,
|
|
getSafeMethod, getSafeDescriptor
|
|
});
|
|
|
|
let xray = !xrayEnabled
|
|
? xrayMake(false, o => o)
|
|
: xrayMake(true, o => XPCNativeWrapper(o), o => XPCNativeWrapper.unwrap(o),
|
|
function(obj, win = this.window || window) {
|
|
return cloneInto(obj, win, {cloneFunctions: true, wrapReflectors: true});
|
|
});
|
|
|
|
const patchedWindows = new WeakSet(); // track them to avoid indirect recursion
|
|
|
|
// win: window object to modify.
|
|
function modifyWindow(win) {
|
|
try {
|
|
win = xray.unwrap(win);
|
|
env.xray = Object.assign({window: xray.wrap(win)}, xray);
|
|
|
|
if (patchedWindows.has(win)) return;
|
|
patchedWindows.add(win);
|
|
patchingCallback(win, env);
|
|
modifyWindowOpenMethod(win);
|
|
modifyFramingElements(win);
|
|
// we don't need to modify win.opener, read skriptimaahinen notes
|
|
// at https://forums.informaction.com/viewtopic.php?p=103754#p103754
|
|
} catch (e) {
|
|
if (e instanceof DOMException && e.name === "SecurityError") {
|
|
// In case someone tries to access SOP restricted window.
|
|
// We can just ignore this.
|
|
} else throw e;
|
|
}
|
|
}
|
|
|
|
function modifyWindowOpenMethod(win) {
|
|
let windowOpen = win.open;
|
|
exportFunction(function(...args) {
|
|
let newWin = windowOpen.call(this, ...args);
|
|
if (newWin) modifyWindow(newWin);
|
|
return newWin;
|
|
}, win, {defineAs: "open"});
|
|
}
|
|
|
|
function modifyFramingElements(win) {
|
|
for (let property of ["contentWindow", "contentDocument"]) {
|
|
for (let iface of ["Frame", "IFrame", "Object"]) {
|
|
let proto = win[`HTML${iface}Element`].prototype;
|
|
modifyContentProperties(proto, property)
|
|
}
|
|
}
|
|
// auto-trigger window patching whenever new elements are added to the DOM
|
|
let patchAll = () => {
|
|
if (patchWindow.disabled) {
|
|
observer.disconnect();
|
|
}
|
|
for (let j = 0; j in window; j++) {
|
|
try {
|
|
modifyWindow(window[j]);
|
|
} catch (e) {
|
|
console.error(e, `Patching frames[${j}]`);
|
|
}
|
|
}
|
|
};
|
|
|
|
let xrayWin = xray.wrap(win);
|
|
let observer = new MutationObserver(patchAll);
|
|
observer.observe(win.document, { subtree: true, childList: true });
|
|
let patchHandler = {
|
|
apply(target, thisArg, args) {
|
|
let ret = Reflect.apply(target, thisArg, args);
|
|
const wrapped = thisArg && xray.wrap(thisArg);
|
|
if (wrapped) {
|
|
try {
|
|
if ((wrapped.ownerDocument || wrapped) === xrayWin.document) {
|
|
patchAll();
|
|
}
|
|
} catch (e) {
|
|
console.error("Can't propagate patches (likely SOP violation).", e, thisArg, wrapped, location); // DEV_ONLY
|
|
}
|
|
}
|
|
try {
|
|
return ret ? xray.forPage(ret, win) : ret;
|
|
} catch (e) {
|
|
console.error("Can't wrap return value.", e, thisArg, target, args, ret, location); // DEV_ONLY
|
|
}
|
|
return ret;
|
|
}
|
|
};
|
|
|
|
let domChangers = {
|
|
Element: [
|
|
"set innerHTML", "set outerHTML",
|
|
"after", "append", "appendChild",
|
|
"before",
|
|
"insertAdjacentElement", "insertAdjacentHTML", "insertBefore",
|
|
"prepend",
|
|
"replaceChildren", "replaceWith", "replaceChild",
|
|
"setHTML",
|
|
],
|
|
Document: [
|
|
"append", "prepend", "replaceChildren",
|
|
"write", "writeln",
|
|
]
|
|
};
|
|
|
|
function patch(proto, method) {
|
|
let accessor;
|
|
if (method.startsWith("set ")) {
|
|
accessor = "set";
|
|
method = method.replace("set ", "");
|
|
} else {
|
|
accessor = "value";
|
|
}
|
|
if (!(method in proto)) return;
|
|
while (!proto.hasOwnProperty(method)) {
|
|
proto = Object.getPrototypeOf(proto);
|
|
if (!proto) {
|
|
console.error(`Couldn't find property ${method} on the prototype chain!`);
|
|
return;
|
|
}
|
|
}
|
|
let des = getSafeDescriptor(proto, method, accessor);
|
|
des[accessor] = exportFunction(new Proxy(des[accessor], patchHandler), proto, {defineAs: `${accessor} ${method}`});;
|
|
Reflect.defineProperty(xray.unwrap(proto), method, des);
|
|
}
|
|
|
|
for (let [obj, methods] of Object.entries(domChangers)) {
|
|
let proto = win[obj].prototype;
|
|
for (let method of methods) {
|
|
patch(proto, method);
|
|
}
|
|
}
|
|
if (patchWindow.onObject) patchWindow.onObject.add(patchAll);
|
|
}
|
|
|
|
function modifyContentProperties(proto, property) {
|
|
let descriptor = getSafeDescriptor(proto, property, "get");
|
|
let origGetter = descriptor.get;
|
|
let replacements = {
|
|
contentWindow() {
|
|
let win = origGetter.call(this);
|
|
if (win) modifyWindow(win);
|
|
return win;
|
|
},
|
|
contentDocument() {
|
|
let document = origGetter.call(this);
|
|
if (document && document.defaultView) modifyWindow(document.defaultView);
|
|
return document;
|
|
}
|
|
};
|
|
|
|
descriptor.get = exportFunction(replacements[property], proto, {defineAs: `get ${property}`});
|
|
Reflect.defineProperty(proto, property, descriptor);
|
|
}
|
|
|
|
modifyWindow(window);
|
|
return port;
|
|
}
|
|
|
|
patchWindow.xrayEnabled = typeof XPCNativeWrapper !== "undefined";
|
|
if (patchWindow.xrayEnabled) {
|
|
// make up for object element initialization inconsistencies on Firefox
|
|
let callbacks = new Set();
|
|
patchWindow.onObject = {
|
|
add(callback) {
|
|
callbacks.add(callback);
|
|
},
|
|
fire() {
|
|
for (let callback of [...callbacks]) {
|
|
callback();
|
|
}
|
|
}
|
|
};
|
|
|
|
const eventId = "__nscl_patchWindow_onObject__";
|
|
const intercepted = new WeakSet();
|
|
addEventListener(eventId, e => {
|
|
let {target} = e;
|
|
if (target instanceof HTMLObjectElement &&
|
|
target.contentWindow &&
|
|
!intercepted.has(target.contentWindow)) {
|
|
intercepted.add(target.contentWindow);
|
|
e.stopImmediatePropagation();
|
|
patchWindow.onObject.fire();
|
|
}
|
|
}, true);
|
|
|
|
if (frameElement instanceof HTMLObjectElement) {
|
|
frameElement.dispatchEvent(new CustomEvent(eventId));
|
|
}
|
|
}
|
|
|
|
Object.defineProperty(patchWindow, "disabled", {
|
|
get() {
|
|
if (typeof ns === "object" && ns) {
|
|
if (ns.allows && ns.policy) {
|
|
const value = !ns.allows("script");
|
|
Object.defineProperty(patchWindow, "disabled", { value, configurable: true });
|
|
return value;
|
|
}
|
|
if (typeof ns.on === "function") {
|
|
ns.on("capabilities", () => {
|
|
if (ns.allows) {
|
|
this.disabled;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
set(value) {
|
|
Object.defineProperty(patchWindow, "disabled", { value, configurable: true });
|
|
return value;
|
|
},
|
|
configurable: true,
|
|
}); |