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

這些不共用

  • http://www.site.com(另一個網域:www. 有差)
  • http://site.org(另一個網域:.org 有差)
  • https://site.com(另一個通訊協定:https
  • http://site.com:8080(另一個埠:8080

「相同來源」政策指出

  • 如果我們有另一個視窗的參考,例如由 window.open 建立的快顯視窗或 <iframe> 內的視窗,而且該視窗來自相同的來源,那麼我們就可以完全存取該視窗。
  • 否則,如果它來自另一個來源,那麼我們就無法存取該視窗的內容:變數、文件、任何東西。唯一的例外是 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.onloadiframe.contentWindow.onload

iframe.onload 事件(在 <iframe> 標籤上)基本上與 iframe.contentWindow.onload(在嵌入式視窗物件上)相同。當嵌入式視窗完全載入所有資源時,它會觸發。

…但是我們無法存取來自另一個來源的 iframe 的 iframe.contentWindow.onload,因此使用 iframe.onload

子網域中的 Windows:document.domain

根據定義,具有不同網域的兩個 URL 具有不同的來源。

但是,如果 Windows 共享相同的二級網域,例如 john.site.competer.site.comsite.com(因此它們的共同二級網域是 site.com),我們可以讓瀏覽器忽略該差異,以便它們可以被視為來自「相同來源」以進行跨視窗通訊。

為了讓它運作,每個這樣的視窗都應該執行程式碼

document.domain = 'site.com';

這樣就夠了。現在它們可以毫無限制地進行互動。再次強調,這僅適用於具有相同二級網域的頁面。

已棄用,但仍然可用

document.domain 屬性正在從 規格 中移除。跨視窗訊息傳遞(稍後說明)是建議的替代方案。

話雖如此,截至目前為止,所有瀏覽器都支援它。而且支援將會保留在未來,以免中斷依賴 document.domain 的舊程式碼。

Iframe:錯誤的 document 陷阱

當 iframe 來自相同來源,我們可以存取其 document 時,有一個陷阱。這與跨來源無關,但很重要要知道。

在建立 iframe 時,它會立即有一個文件。但該文件與載入其中的文件不同!

因此,如果我們立即對文件做一些事,那可能會遺失。

這裡,請看

<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 的文件,因為那是錯誤的文件。如果我們在其中設定任何事件處理常式,它們將會被忽略。

如何偵測文件存在的那一刻?

iframe.onload 觸發時,正確的文件肯定存在。但它只會在載入所有資源的整個 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 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://javascriptinfo.dev.org.tw
source
指向傳送者視窗的參考。如果我們想要,可以立即 source.postMessage(...) 回傳。

若要指定該處理常式,我們應該使用 addEventListener,簡短語法 window.onmessage 無法使用。

以下是一個範例

window.addEventListener("message", function(event) {
  if (event.origin != 'https://javascriptinfo.dev.org.tw') {
    // 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.parentwindow.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 之外的所有環境中為任何物件。

    我們應該使用 addEventListener 在目標視窗內設定此事件的處理常式。

教學地圖

留言

留言前請先閱讀這段文字…
  • 如果你有建議要如何改進 - 請 提交 GitHub 議題 或提交 pull request,而不是留言。
  • 如果你看不懂文章中的某個部分 - 請說明。
  • 要插入少量的程式碼,請使用 <code> 標籤,要插入多行程式碼 - 請將它們包在 <pre> 標籤中,要插入超過 10 行的程式碼 - 請使用 sandbox(plnkrjsbincodepen…)