2022年4月13日

跨窗口通信

“相同来源”(同一站点)策略限制了窗口和框架之间的相互访问。

其理念是,如果用户打开了两个页面:一个来自john-smith.com,另一个来自gmail.com,那么他们不希望来自john-smith.com的脚本读取来自gmail.com的邮件。因此,“相同来源”策略的目的是保护用户免受信息盗窃。

相同来源

如果两个 URL 具有相同的协议、域名和端口,则它们被称为具有“相同来源”。

以下 URL 具有相同的来源

  • http://site.com
  • http://site.com/
  • http://site.com/my/page.html

以下 URL 不具有相同的来源

  • http://www.site.com (另一个域名: www. 不同)
  • http://site.org (另一个域名: .org 不同)
  • https://site.com (另一个协议: https)
  • http://site.com:8080 (另一个端口: 8080)

“相同来源”策略规定:

  • 如果我们有一个对另一个窗口的引用,例如由 window.open 创建的弹出窗口或 <iframe> 内部的窗口,并且该窗口来自相同的来源,那么我们可以完全访问该窗口。
  • 否则,如果它来自另一个来源,那么我们无法访问该窗口的内容:变量、文档、任何内容。唯一的例外是 location:我们可以更改它(从而重定向用户)。但我们无法读取 location(因此我们无法看到用户现在的位置,没有信息泄露)。

实际应用:iframe

<iframe> 标签承载一个独立的嵌入式窗口,具有其自己的独立 documentwindow 对象。

我们可以使用属性访问它们

  • iframe.contentWindow 获取 <iframe> 内部的窗口。
  • iframe.contentDocument 获取 <iframe> 内部的文档,是 iframe.contentWindow.document 的简写。

当我们访问嵌入式窗口内部的内容时,浏览器会检查 iframe 是否具有相同的来源。如果不是,则访问被拒绝(写入 location 是一个例外,它仍然允许)。

例如,让我们尝试从另一个来源读取和写入 <iframe>

<iframe src="https://example.com" id="iframe"></iframe>

<script>
  iframe.onload = function() {
    // we can get the reference to the inner window
    let iframeWindow = iframe.contentWindow; // OK
    try {
      // ...but not to the document inside it
      let doc = iframe.contentDocument; // ERROR
    } catch(e) {
      alert(e); // Security Error (another origin)
    }

    // also we can't READ the URL of the page in iframe
    try {
      // Can't read URL from the Location object
      let href = iframe.contentWindow.location.href; // ERROR
    } catch(e) {
      alert(e); // Security Error
    }

    // ...we can WRITE into location (and thus load something else into the iframe)!
    iframe.contentWindow.location = '/'; // OK

    iframe.onload = null; // clear the handler, not to run it after the location change
  };
</script>

上面的代码显示了除以下操作之外的任何操作的错误

  • 获取对内部窗口 iframe.contentWindow 的引用 - 这是允许的。
  • 写入 location

相反,如果 <iframe> 具有相同的来源,我们可以对其进行任何操作

<!-- iframe from the same site -->
<iframe src="/" id="iframe"></iframe>

<script>
  iframe.onload = function() {
    // just do anything
    iframe.contentDocument.body.prepend("Hello, world!");
  };
</script>
iframe.onload vs iframe.contentWindow.onload

iframe.onload 事件(在 <iframe> 标签上)本质上与 iframe.contentWindow.onload(在嵌入式窗口对象上)相同。它在嵌入式窗口完全加载所有资源时触发。

…但我们无法访问来自不同来源的 iframe 的 iframe.contentWindow.onload,因此使用 iframe.onload

子域上的窗口:document.domain

根据定义,两个具有不同域的 URL 具有不同的来源。

但如果窗口共享相同的二级域,例如 john.site.competer.site.comsite.com(因此它们的公共二级域为 site.com),我们可以让浏览器忽略该差异,以便它们可以被视为来自“相同来源”的,用于跨窗口通信。

要使其正常工作,每个这样的窗口都应运行以下代码

document.domain = 'site.com';

就是这样。现在它们可以不受限制地交互。同样,这仅适用于具有相同二级域的页面。

已弃用,但仍在工作

document.domain 属性正在从 规范 中删除。跨窗口消息传递(将在下面解释)是建议的替代方案。

也就是说,截至目前,所有浏览器都支持它。并且支持将保留在未来,以避免破坏依赖于 document.domain 的旧代码。

Iframe:错误的文档陷阱

当 iframe 来自同一来源时,我们可以访问它的 document,存在一个陷阱。它与跨源问题无关,但很重要。

iframe 创建后立即拥有一个 document。但该 document 与加载到其中的 document 不同!

因此,如果我们立即对 document 做一些操作,它可能会丢失。

这里,看看

<iframe src="/" id="iframe"></iframe>

<script>
  let oldDoc = iframe.contentDocument;
  iframe.onload = function() {
    let newDoc = iframe.contentDocument;
    // the loaded document is not the same as initial!
    alert(oldDoc == newDoc); // false
  };
</script>

我们不应该使用尚未加载的 iframe 的 document,因为那是错误的 document。如果我们在其上设置任何事件处理程序,它们将被忽略。

如何检测 document 存在的那一刻?

iframe.onload 触发时,正确的 document 一定已到位。但它只在整个 iframe 及其所有资源加载完毕时触发。

我们可以尝试使用 setInterval 中的检查来更早地捕获那一刻

<iframe src="/" id="iframe"></iframe>

<script>
  let oldDoc = iframe.contentDocument;

  // every 100 ms check if the document is the new one
  let timer = setInterval(() => {
    let newDoc = iframe.contentDocument;
    if (newDoc == oldDoc) return;

    alert("New document is here!");

    clearInterval(timer); // cancel setInterval, don't need it any more
  }, 100);
</script>

集合:window.frames

获取 <iframe> 的窗口对象的另一种方法是 - 从名为 window.frames 的集合中获取它

  • 按编号:window.frames[0] - 文档中第一个框架的窗口对象。
  • 按名称:window.frames.iframeName - 具有 name="iframeName" 的框架的窗口对象。

例如

<iframe src="/" style="height:80px" name="win" id="iframe"></iframe>

<script>
  alert(iframe.contentWindow == frames[0]); // true
  alert(iframe.contentWindow == frames.win); // true
</script>

一个 iframe 可以包含其他 iframe。相应的 window 对象形成一个层次结构。

导航链接是

  • window.frames – “子” 窗口的集合(用于嵌套框架)。
  • window.parent – 对“父” (外部) 窗口的引用。
  • window.top – 对最顶层父窗口的引用。

例如

window.frames[0].parent === window; // true

我们可以使用 top 属性来检查当前文档是否在框架内打开。

if (window == top) { // current window == window.top?
  alert('The script is in the topmost window, not in a frame');
} else {
  alert('The script runs in a frame!');
}

“沙箱” iframe 属性

sandbox 属性允许排除 <iframe> 内的某些操作,以防止执行不受信任的代码。它通过将 iframe 视为来自另一个来源和/或应用其他限制来“沙箱化” iframe。

对于 <iframe sandbox src="...">,会应用一组“默认”限制。但如果我们提供一个以空格分隔的限制列表作为属性的值,则可以放宽这些限制,例如:<iframe sandbox="allow-forms allow-popups">

换句话说,一个空的 "sandbox" 属性会施加最严格的限制,但我们可以放置一个空格分隔的列表,列出我们想要解除的限制。

以下是限制列表

allow-same-origin
默认情况下,"sandbox" 会对 iframe 强制执行“不同来源”策略。换句话说,它会让浏览器将 iframe 视为来自另一个来源,即使它的 src 指向同一个站点。对于脚本,会施加所有隐含的限制。此选项会移除该功能。
allow-top-navigation
允许 iframe 更改 parent.location
allow-forms
允许从 iframe 提交表单。
allow-scripts
允许从 iframe 运行脚本。
allow-popups
允许从 iframe window.open 弹出窗口。

有关更多信息,请参阅 手册

以下示例演示了一个带有默认限制集的沙箱化 iframe:<iframe sandbox src="...">。它包含一些 JavaScript 和一个表单。

请注意,没有任何内容有效。因此,默认集确实很严格。

结果
index.html
sandboxed.html
<!doctype html>
<html>

<head>
  <meta charset="UTF-8">
</head>

<body>

  <div>The iframe below has the <code>sandbox</code> attribute.</div>

  <iframe sandbox src="sandboxed.html" style="height:60px;width:90%"></iframe>

</body>
</html>
<!doctype html>
<html>

<head>
  <meta charset="UTF-8">
</head>

<body>

  <button onclick="alert(123)">Click to run a script (doesn't work)</button>

  <form action="http://google.com">
    <input type="text">
    <input type="submit" value="Submit (doesn't work)">
  </form>

</body>
</html>
请注意

"sandbox" 属性的用途仅仅是添加更多限制。它不能移除限制。特别是,如果 iframe 来自另一个来源,它不能放宽同源限制。

跨窗口消息传递

postMessage 接口允许窗口之间相互通信,无论它们来自哪个来源。

因此,它是绕过“同源策略”的一种方法。它允许来自 john-smith.com 的窗口与 gmail.com 通信并交换信息,但前提是它们都同意并调用相应的 JavaScript 函数。这对于用户来说是安全的。

该接口有两个部分。

postMessage

想要发送消息的窗口会调用接收窗口的 postMessage 方法。换句话说,如果我们要向 win 发送消息,我们应该调用 win.postMessage(data, targetOrigin)

参数

data
要发送的数据。可以是任何对象,数据使用“结构化序列化算法”进行克隆。IE 只支持字符串,因此我们应该 JSON.stringify 复杂对象以支持该浏览器。
targetOrigin
指定目标窗口的来源,以便只有来自给定来源的窗口才能收到消息。

targetOrigin 是一种安全措施。请记住,如果目标窗口来自另一个来源,我们无法在发送窗口中读取其 location。因此,我们无法确定目标窗口中当前打开了哪个网站:用户可能会导航离开,而发送窗口对此一无所知。

指定 targetOrigin 可确保窗口仅在它仍处于正确网站时才接收数据。当数据敏感时,这一点很重要。

例如,这里 win 只有在它拥有来自 http://example.com 来源的文档时才会收到消息。

<iframe src="http://example.com" name="example">

<script>
  let win = window.frames.example;

  win.postMessage("message", "http://example.com");
</script>

如果我们不想进行该检查,我们可以将 targetOrigin 设置为 *

<iframe src="http://example.com" name="example">

<script>
  let win = window.frames.example;

  win.postMessage("message", "*");
</script>

onmessage

要接收消息,目标窗口应该在 message 事件上有一个处理程序。当调用 postMessage(并且 targetOrigin 检查成功)时,它会触发。

事件对象具有特殊属性

data
来自 postMessage 的数据。
origin
发送者的来源,例如 https://javascript.js.cn
source
对发送者窗口的引用。如果需要,我们可以立即 source.postMessage(...) 回复。

要分配该处理程序,我们应该使用 addEventListener,简短语法 window.onmessage 不起作用。

以下是一个示例

window.addEventListener("message", function(event) {
  if (event.origin != 'https://javascript.js.cn') {
    // something from an unknown domain, let's ignore it
    return;
  }

  alert( "received: " + event.data );

  // can message back using event.source.postMessage(...)
});

完整示例

结果
iframe.html
index.html
<!doctype html>
<html>

<head>
  <meta charset="UTF-8">
</head>

<body>

  Receiving iframe.
  <script>
    window.addEventListener('message', function(event) {
      alert(`Received ${event.data} from ${event.origin}`);
    });
  </script>

</body>
</html>
<!doctype html>
<html>

<head>
  <meta charset="UTF-8">
</head>

<body>

  <form id="form">
    <input type="text" placeholder="Enter message" name="message">
    <input type="submit" value="Click to send">
  </form>

  <iframe src="iframe.html" id="iframe" style="display:block;height:60px"></iframe>

  <script>
    form.onsubmit = function() {
      iframe.contentWindow.postMessage(this.message.value, '*');
      return false;
    };
  </script>

</body>
</html>

摘要

要调用方法并访问另一个窗口的内容,我们首先需要一个对它的引用。

对于弹出窗口,我们有以下引用:

  • 从打开窗口:window.open - 打开一个新窗口并返回对它的引用,
  • 从弹出窗口:window.opener - 是从弹出窗口到打开窗口的引用。

对于 iframe,我们可以使用以下方法访问父/子窗口:

  • window.frames - 一组嵌套的窗口对象,
  • window.parent, window.top 是对父窗口和顶层窗口的引用,
  • iframe.contentWindow<iframe> 标签内部的窗口。

如果窗口共享相同的来源(主机、端口、协议),那么窗口可以相互执行任何操作。

否则,只有以下操作是可能的:

  • 更改另一个窗口的 location(只写访问)。
  • 向它发送消息。

例外情况是:

  • 共享相同二级域的窗口:a.site.comb.site.com。然后在两个窗口中设置 document.domain='site.com' 将它们置于“相同来源”状态。
  • 如果 iframe 具有 sandbox 属性,则它将强制置于“不同来源”状态,除非在属性值中指定了 allow-same-origin。这可以用来从同一个站点运行 iframe 中不受信任的代码。

postMessage 接口允许具有任何来源的两个窗口进行通信

  1. 发送方调用 targetWin.postMessage(data, targetOrigin)

  2. 如果 targetOrigin 不是 '*',则浏览器会检查窗口 targetWin 是否具有来源 targetOrigin

  3. 如果是,则 targetWin 会触发 message 事件,并带有以下特殊属性:

    • origin - 发送方窗口的来源(如 http://my.site.com
    • source - 对发送方窗口的引用。
    • data - 数据,除了 IE 之外的任何对象,IE 仅支持字符串。

    我们应该使用 addEventListener 在目标窗口中设置此事件的处理程序。

教程地图

评论

在评论之前请阅读...
  • 如果您有改进建议,请提交 GitHub 问题或拉取请求,而不是评论。
  • 如果您不理解文章中的某些内容,请详细说明。
  • 要插入几行代码,请使用<code>标签,对于多行代码,请用<pre>标签包裹,对于超过 10 行的代码,请使用沙箱(plnkrjsbincodepen…)