与页面脚本共享对象

注意:本节介绍的技术仅在 Firefox 中可用,并且仅从 Firefox 49 及更高版本开始。

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

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

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

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

然而,Firefox 提供了一些 API,允许内容脚本

  • 访问页面脚本创建的 JavaScript 对象
  • 将自己的 JavaScript 对象暴露给页面脚本。

Firefox 中的 Xray Vision

在 Firefox 中,内容脚本和页面脚本之间的部分隔离是通过一种称为“Xray Vision”的功能实现的。当一个特权范围较高的脚本访问一个定义在特权范围较低的脚本中的对象时,它只能看到该对象的“原生版本”。任何 扩展属性 都是不可见的,如果对象的任何属性已被重新定义,它将看到原始实现,而不是重新定义后的版本。

此功能旨在让特权范围较低的脚本更难通过重新定义对象的原生属性来混淆特权范围较高的脚本。

因此,例如,当内容脚本访问页面的 window 对象时,它看不到页面脚本添加到 window 对象的任何属性,如果页面脚本重新定义了 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!";

Xray Vision 意味着,如果内容脚本尝试访问 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!"

请注意,一旦您这样做,您就无法再依赖该对象的任何属性或函数是否如您所预期那样存在或执行。其中任何一个,包括 setter 和 getter,都可能被不受信任的代码重新定义。

另请注意,解开过程是传递性的:当您使用 wrappedJSObject 时,解开对象的所有属性本身也会被解开(因此不可靠)。因此,一旦您获得了所需的对象,最好将其重新包装起来,您可以这样做:

js
XPCNativeWrapper(window.wrappedJSObject.foo);

有关更多详细信息,请参阅关于 Xray Vision 的文档。

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

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

给定在内容脚本中定义的某个对象,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,
});

现在页面脚本在 window 对象上看到一个新属性 messenger,它包含一个名为 notify() 的函数。

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

来自页面上下文的构造函数

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

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

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",
  {
    // getters must be exported like regular functions
    get: exportFunction(() => "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 克隆

Promise 无法直接使用 cloneInto 进行克隆,因为 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