与页面脚本共享对象

注意:本节中描述的技术仅在 Firefox 中可用,并且仅在 Firefox 49 及更高版本中可用。

警告:作为扩展开发者,您应该考虑到在任意网页中运行的脚本都是恶意代码,其目的是窃取用户的个人信息、损坏其计算机或以其他方式攻击他们。

内容脚本和网页加载的脚本之间的隔离旨在使恶意网页更难以做到这一点。

由于本节中描述的技术打破了这种隔离,因此它们本质上是危险的,应谨慎使用。

内容脚本指南注释,内容脚本看不到网页加载的脚本对 DOM 所做的更改。这意味着,例如,如果网页加载了一个像 jQuery 这样的库,内容脚本将无法使用它,并且必须加载自己的副本。反之,网页加载的脚本也看不到内容脚本所做的更改。

但是,Firefox 提供了一些 API,使内容脚本能够

  • 访问页面脚本创建的 JavaScript 对象
  • 将其自己的 JavaScript 对象公开给页面脚本。

Firefox 中的 X 射线视觉

在 Firefox 中,内容脚本和页面脚本之间的部分隔离是使用称为“X 射线视觉”的功能实现的。当在特权级别更高的作用域中的脚本访问在特权级别较低的作用域中定义的对象时,它只会看到该对象的“原生版本”。任何扩展属性都是不可见的,如果对象的任何属性已被重新定义,它会看到原始实现,而不是重新定义的版本。

此功能的目的是使特权级别较低的脚本更难以通过重新定义对象的原生属性来混淆特权级别较高的脚本。

因此,例如,当内容脚本访问页面的window时,它将看不到页面脚本添加到窗口的任何属性,并且如果页面脚本已重新定义窗口的任何现有属性,内容脚本将看到原始版本。

从内容脚本访问页面脚本对象

在 Firefox 中,内容脚本中的 DOM 对象会获得一个额外的属性wrappedJSObject。这是该对象的“未包装”版本,其中包含任何页面脚本对该对象所做的更改。

让我们举一个简单的例子。假设一个网页加载了一个脚本

html
<!doctype html>
<html lang="en-US">
  <head>
    <meta charset="UTF-8" />
  </head>
  <body>
    <script src="main.js"></script>
  </body>
</html>

该脚本向全局window添加了一个扩展属性

js
// main.js

let foo = "I'm defined in a page script!";

X 射线视觉意味着,如果内容脚本尝试访问foo,它将是未定义的

js
// content-script.js

console.log(window.foo); // undefined

在 Firefox 中,内容脚本可以使用window.wrappedJSObject来查看扩展属性

js
// content-script.js

console.log(window.wrappedJSObject.foo); // "I'm defined in a page script!"

请注意,一旦执行此操作,您便无法再依赖此对象的任何属性或函数是或执行您期望的操作。它们中的任何一个,即使是设置器和获取器,也可能已被不受信任的代码重新定义。

另请注意,解包是可传递的:当您使用wrappedJSObject时,解包对象的任何属性本身都会被解包(因此不可靠)。因此,最佳实践是在获得所需的对象后,将其重新包装,您可以像这样操作

js
XPCNativeWrapper(window.wrappedJSObject.foo);

有关此内容的更多详细信息,请参阅有关X 射线视觉的文档。

与页面脚本共享内容脚本对象

Firefox 还提供了 API,使内容脚本能够将对象提供给页面脚本。这里有几种方法

  • exportFunction():将函数导出到页面脚本。
  • cloneInto():将对象导出到页面脚本。
  • 页面上下文中的构造函数

exportFunction

给定在内容脚本中定义的函数,exportFunction()将其导出到页面脚本的作用域,以便页面脚本可以调用它。

例如,让我们考虑一个扩展,它具有如下所示的后台脚本

js
/*
Execute content script in the active tab.
*/
function loadContentScript() {
  browser.tabs.executeScript({
    file: "/content_scripts/export.js",
  });
}

/*
Add loadContentScript() as a listener to clicks
on the browser action.
*/
browser.browserAction.onClicked.addListener(loadContentScript);

/*
Show a notification when we get messages from
the content script.
*/
browser.runtime.onMessage.addListener((message) => {
  browser.notifications.create({
    type: "basic",
    title: "Message from the page",
    message: message.content,
  });
});

这会执行两件事

  • 在用户单击浏览器操作时,在当前选项卡中执行内容脚本
  • 侦听来自内容脚本的消息,并在消息到达时显示通知

内容脚本如下所示

js
/*
Define a function in the content script's scope, then export it
into the page script's scope.
*/
function notify(message) {
  browser.runtime.sendMessage({ content: `Function call: ${message}` });
}

exportFunction(notify, window, { defineAs: "notify" });

这定义了一个函数notify(),它只是将其参数发送到后台脚本。然后,它将函数导出到页面脚本的作用域。现在页面脚本可以调用此函数

js
window.notify("Message from the page script!");

cloneInto

给定在内容脚本中定义的对象,这会在页面脚本的作用域中创建该对象的克隆,从而使克隆可供页面脚本访问。默认情况下,这会使用结构化克隆算法克隆对象,这意味着对象中的函数不包含在克隆中。要包含函数,请传递cloneFunctions选项。

例如,这是一个内容脚本,它定义了一个包含函数的对象,然后将其克隆到页面脚本的作用域

js
/*
Create an object that contains functions in
the content script's scope, then clone it
into the page script's scope.

Because the object contains functions,
the cloneInto call must include
the `cloneFunctions` option.
*/
let messenger = {
  notify(message) {
    browser.runtime.sendMessage({
      content: `Object method call: ${message}`,
    });
  },
};

window.wrappedJSObject.messenger = cloneInto(messenger, window, {
  cloneFunctions: true,
});

现在页面脚本在窗口上看到了一个新属性messenger,它具有一个函数notify()

js
window.messenger.notify("Message from the page script!");

页面上下文中的构造函数

在经过 X 射线处理的窗口对象上,可使用一些内置 JavaScript 对象(如ObjectFunctionProxy)以及各种 DOM 类的原始构造函数。XMLHttpRequest不会以这种方式运行,有关详细信息,请参阅XHR 和 fetch部分。它们将创建属于页面全局对象层次结构的实例,然后返回一个 X 射线包装器。

由于以这种方式创建的对象已属于页面而不是内容脚本,因此将它们传回页面不需要额外的克隆或导出。

js
/* JavaScript built-ins */

const objA = new Object();
const objB = new window.Object();

console.log(
  objA instanceof Object, // true
  objB instanceof Object, // false
  objA instanceof window.Object, // false
  objB instanceof window.Object, // true
  "wrappedJSObject" in objB, // true; xrayed
);

objA.foo = "foo";
objB.foo = "foo"; // xray wrappers for plain JavaScript objects pass through property assignments
objB.wrappedJSObject.bar = "bar"; // unwrapping before assignment does not rely on this special behavior

window.wrappedJSObject.objA = objA;
window.wrappedJSObject.objB = objB; // automatically unwraps when passed to page context

window.eval(`
  console.log(objA instanceof Object);           // false
  console.log(objB instanceof Object);           // true

  try {
    console.log(objA.foo);
  } catch (error) {
    console.log(error);                       // Error: permission denied
  }
 
  try {
    objA.baz = "baz";
  } catch (error) {
    console.log(error);                       // Error: permission denied
  }

  console.log(objB.foo, objB.bar);               // "foo", "bar"
  objB.baz = "baz";
`);

/* other APIs */

const ev = new Event("click");

console.log(
  ev instanceof Event, // true
  ev instanceof window.Event, // true; Event constructor is actually inherited from the xrayed window
  "wrappedJSObject" in ev, // true; is an xrayed object
);

ev.propA = "propA"; // xray wrappers for native objects do not pass through assignments
ev.propB = "wrapper"; // define property on xray wrapper
ev.wrappedJSObject.propB = "unwrapped"; // define same property on page object
Reflect.defineProperty(
  // privileged reflection can operate on less privileged objects
  ev.wrappedJSObject,
  "propC",
  {
    get: exportFunction(() => {
      // getters must be exported like regular functions
      return "propC";
    }, window),
  },
);

window.eval(`
  document.addEventListener("click", (e) => {
    console.log(e instanceof Event, e.propA, e.propB, e.propC);
  });
`);

document.dispatchEvent(ev); // true, undefined, "unwrapped", "propC"

Promise 克隆

不能使用cloneInto直接克隆 Promise,因为 Promise 不受结构化克隆算法支持。但是,可以使用window.Promise而不是Promise来实现所需的结果,然后像这样克隆解析值

js
const promise = new window.Promise((resolve) => {
  // if just a primitive, then cloneInto is not needed:
  // resolve("string is a primitive");

  // if not a primitive, such as an object, then the value must be cloned
  const result = { exampleKey: "exampleValue" };
  resolve(cloneInto(result, window));
});
// now the promise can be passed to the web page