iT邦幫忙

2025 iThome 鐵人賽

DAY 11
0
Modern Web

Learn HTTP With JS系列 第 11

iframe security

  • 分享至 

  • xImage
  •  

大綱

本篇要來介紹 iframe security,內容包含

  1. <iframe sandbox>
  2. X-Frame-Options
  3. CSP: frame-ancestors

<iframe sandbox>

根據 html.spec.whatwg.org 的文件描述

When the attribute is set, the content is treated as being from a unique opaque origin, forms, scripts, and various potentially annoying APIs are disabled, and links are prevented from targeting other navigables.

也就是說,當我們設定 <iframe sandbox src="URL"></iframe> 的時候,就會限制 <iframe> 內的網站行為,但我們可以透過設定多組 allow- 的 token 來指定被嵌入的頁面可以執行哪些事情,包含以下:

實作環節

我們建立以下:

檔案名稱 用途
5000.html 主網站,等等要透過瀏覽器打開
5000sandbox.html 被嵌入 <iframe> 的網站
5000sandbox-popup.html 被嵌入 <iframe> 的網站所開啟的新分頁
index.ts NodeJS HTTP Server
download.js 被下載的測試檔案,內容隨意

allow-downloads

5000.html

<html>
  <head></head>
  <body>
    <h1>5000.html</h1>
    <style>
      iframe {
        width: 100%;
        height: 300px;
      }
    </style>

    <iframe
      src="http://localhost:5000/sandbox"
      sandbox="allow-scripts"
    ></iframe>
  </body>
</html>

5000sandbox.html

<html>
  <head></head>
  <body>
    <h1>5000sandbox.html</h1>

    <h3>allow-downloads</h3>
    <div>
      <a download href="http://localhost:5000/download">
        download file with download attribute
      </a>
      <br />
      <a href="http://localhost:5000/download">
        navigate to a URL with Content-Disposition: attachment
      </a>
      <br />
      <script>
        function handleDownload() {
          const a = document.createElement("a");
          a.href = "http://localhost:5000/download";
          a.click();
        }
      </script>
      <button onclick="handleDownload()">download file with js control</button>
    </div>
  </body>
</html>

index.ts

import { readFileSync } from "fs";
import { http5000Server } from "./httpServers";
import { join } from "path";
import { faviconListener } from "../listeners/faviconListener";
import { notFoundListener } from "../listeners/notFoundlistener";

// 為了開發方便,每次 request 都去讀取 static html

http5000Server.removeAllListeners("request");
http5000Server.on("request", function requestListener(req, res) {
  if (req.url === "/favicon.ico") return faviconListener(req, res);
  if (req.url === "/") {
    res.setHeader("Content-Type", "text/html; charset=utf-8");
    return res.end(readFileSync(join(__dirname, "5000.html")));
  }
  if (req.url === "/sandbox") {
    res.setHeader("Content-Type", "text/html; charset=utf-8");
    return res.end(readFileSync(join(__dirname, "5000sandbox.html")));
  }
  if (req.url === "/download") {
    res.setHeader("Content-Type", "text/javascript");
    res.setHeader("Content-Disposition", "attachment; filename=download.js");
    return res.end(readFileSync(join(__dirname, "download.js")));
  }
  return notFoundListener(req, res);
});

download.js

console.log("downloaded js file!!!");

瀏覽器打開 http://localhost:5000/ ,點擊下載連結跟按鈕,會看到以下錯誤訊息
not-allow-downloads

allow-downloads 加上去

5000.html

<iframe
  src="http://localhost:5000/sandbox"
  sandbox="allow-scripts allow-downloads"
></iframe>

重整畫面,點擊下載按鈕,此時就可以正常下載了 ✨✨✨

allow-forms

5000.html

<iframe src="http://localhost:5000/sandbox" sandbox=""></iframe>

5000sandbox.html

<h3>allow-forms</h3>
<form
  method="post"
  action="http://localhost:5000/form"
  enctype="multipart/form-data"
>
  <input type="text" placeholder="請輸入帳號" name="username" />
  <button type="submit">送出</button>
</form>

<dialog open>
  <form method="dialog">
    <input type="text" placeholder="請輸入帳號" name="username" pattern="" />
    <button type="submit">Close Dialog</button>
  </form>
</dialog>

<form
  method="post"
  action="http://localhost:5000/form"
  enctype="multipart/form-data"
>
  <input
    required
    type="text"
    placeholder="請輸入帳號(長度4~16)"
    name="username"
    pattern="\w{4,16}"
  />
  <button type="submit">驗證 && 送出</button>
</form>

index.ts

if (req.url === "/form") {
  res.setHeader("Content-Type", "text/plain");
  return res.end("form submitted");
}

瀏覽器打開 http://localhost:5000/ ,點擊按鈕,會看到以下錯誤訊息
not-allow-forms

allow-forms 加上去

5000.html

<iframe src="http://localhost:5000/sandbox" sandbox="allow-forms"></iframe>

重整畫面,點擊 submit 按鈕,此時就可以正常表單驗證 & 送出表單了 ✨✨✨

allow-modals

5000.html

<iframe src="http://localhost:5000/sandbox" sandbox="allow-scripts"></iframe>

5000sandbox.html

<h3>allow-modals</h3>
<script>
  function promptUsername() {
    const username = prompt("username");
    console.log(username);
  }
  addEventListener("beforeunload", (e) => {
    e.preventDefault();
    e.returnValue = "beforeunload";
    return "beforeunload";
  });
</script>
<button onclick="alert('alert')">alert</button>
<button onclick="confirm('confirm')">confirm</button>
<button onclick="print()">print</button>
<button onclick="promptUsername()">prompt</button>

瀏覽器打開 http://localhost:5000/ ,點擊按鈕,會看到以下錯誤訊息
not-allow-modals

allow-modals 加上去

5000.html

<iframe
  src="http://localhost:5000/sandbox"
  sandbox="allow-scripts allow-modals"
></iframe>

重整畫面,點擊按鈕,此時就可以正常跳出 modal 了 ✨✨✨

allow-popups

5000.html

<iframe src="http://localhost:5000/sandbox" sandbox="allow-scripts"></iframe>

5000sandbox.html

<h3>allow-popups</h3>
<div>
  <script>
    function openExampleCom() {
      open("https://example.com/", "_blank");
    }
  </script>
  <a target="_blank" href="https://example.com/">open example.com</a>
  <button onclick="openExampleCom()">open example.com</button>
</div>

瀏覽器打開 http://localhost:5000/ ,點擊按鈕,會看到以下錯誤訊息
not-allow-popups

allow-popups 加上去

5000.html

<iframe
  src="http://localhost:5000/sandbox"
  sandbox="allow-scripts allow-popups"
></iframe>

重整畫面,點擊按鈕,此時就可以正常開啟新視窗了 ✨✨✨

allow-popups-to-escape-sandbox

5000.html

<iframe src="http://localhost:5000/sandbox" sandbox="allow-popups"></iframe>

5000sandbox.html

<h3>allow-popups-to-escape-sandbox</h3>
<a target="_blank" href="http://localhost:5000/sandbox-popup">另開新頁</a>
<a target="_self" href="http://localhost:5000/sandbox-popup">原頁導轉</a>

5000sandbox-popup.html

<html>
  <head></head>
  <body>
    <h1>5000sandbox-popup.html</h1>
    <h2 id="h2" style="display: none">
      JavaScript is enabled (allow-popups-to-escape-sandbox)
    </h2>
    <script>
      document.getElementById("h2").style.display = "block";
    </script>
    <noscript>
      <h2>JavaScript is disabled (not-allow-popups-to-escape-sandbox)</h2>
    </noscript>
  </body>
</html>

index.ts

if (req.url === "/sandbox-popup") {
  res.setHeader("Content-Type", "text/html; charset=utf-8");
  return res.end(readFileSync(join(__dirname, "5000sandbox-popup.html")));
}

瀏覽器打開 http://localhost:5000/ ,分別點擊兩個按鈕

原頁導轉
same-browsing-context

另開新頁
not-allow-popups-to-escape-sandbox

allow-popups-to-escape-sandbox 加上去

5000.html

<iframe
  src="http://localhost:5000/sandbox"
  sandbox="allow-popups allow-popups-to-escape-sandbox"
></iframe>

重整畫面,分別點擊兩個按鈕

原頁導轉
same-browsing-context

另開新頁
allow-popups-to-escape-sandbox

  • ✅ 原頁導轉,由於還是同一個 browsing context(不是 popup),所以還是會被 sandbox 限制(不能執行 script)
  • ✅ 另開新頁,吃到 allow-popups-to-escape-sandbox,所以可以跳出 sandbox 的限制(可以執行 script)

allow-top-navigation & allow-top-navigation-by-user-activation

5000.html

<iframe src="http://localhost:5000/sandbox" sandbox="allow-scripts"></iframe>

5000sandbox.html

<h3>allow-top-navigation-by-user-activation</h3>
<script>
  function navigateTopToExampleCom() {
    top.location.href = "https://example.com/";
  }
</script>
<button onclick="navigateTopToExampleCom()">
  top navigation to example.com
</button>

瀏覽器打開 http://localhost:5000/ ,點擊按鈕,會看到以下錯誤訊息
not-allow-top-navigation

allow-top-navigation 加上去

5000.html

<iframe
  src="http://localhost:5000/sandbox"
  sandbox="allow-scripts allow-top-navigation"
></iframe>

重整畫面,點擊按鈕,此時就可以正常把 top window 導轉了 ✨✨✨

X-Frame-Options

  • 一句話總結,這個 Response Header 決定該網頁是否可以被 HTML 的 <iframe>, <frame>, <embed><object> 嵌入
  • 其中,<frame>, <embed><object> 都是比較老舊的 HTMLElement,故本篇會著重在 <iframe>

X-Frame-Options: DENY

不讓任何網頁嵌入

X-Frame-Options: SAMEORIGIN

只讓同源的網頁嵌入

CSP: frame-ancestors

X-Frame-Options 更新的 HTTP Response Header,可以提供更精細的控制,可設定多個白名單

frame-ancestors 'none'

X-Frame-Options: DENY 類似,不讓任何網頁嵌入

frame-ancestors 'self'

X-Frame-Options: SAMEORIGIN 類似,只讓同源的網頁嵌入

X-Frame-Options 跟 CSP 都沒設定的情況

  • 該網頁可被任何網頁嵌入
  • 遵守 Same-origin_policy
  • 嵌入同源的網站,可互相存取對方的 window
    • parent 網站可透過 window.frames[number]HTMLIFrameElement.contentWindow 存取嵌入的網站
    • 嵌入的網站則可以透過 window.parent 存取 parent 網站
  • 嵌入跨域的網站,則瀏覽器會限制能存取的屬性
    • 若需要互相通訊,則需要透過 window.postMessage 以及 addEventListener('message', callback)
    • 確保雙方的程式接有實作對應的發送事件以及接收事件,如此就可減少未經授權的存取
    • 若嘗試在跨域的網站互相存取其對方 window 底下的屬性,則會被瀏覽器擋下來
    Uncaught SecurityError: Failed to read a named property 'document' from 'Window': Blocked a frame with origin "http://localhost:5001" from accessing a cross-origin frame.
    

X-Frame-Options: DENY + frame-ancestors 'self'

如果同時設定這兩個,瀏覽器會以哪個為更高優先度呢?我們使用 NodeJS HTTP 模組來試試看:

NodeJS

http5000Server.on("request", function requestListener(req, res) {
  if (req.url === "/") {
    res.setHeader("Content-Type", "text/html; charset=utf-8");
    return res.end(readFileSync(join(__dirname, "5000.html")));
  }
});

http5001Server.on("request", function requestListener(req, res) {
  if (req.url === "/DENY+self") {
    res.setHeader("X-Frame-Options", "DENY");
    res.setHeader("Content-Security-Policy", "frame-ancestors 'self'");
  }
});

5000.html

<html>
  <head></head>
  <body>
    <h1>5000</h1>
    <div>5000DENY+self</div>
    <iframe src="http://localhost:5000/DENY+self"></iframe>
  </body>
</html>

5000DENY+self.html

<html>
  <head></head>
  <body>
    <h1>5000DENY+self</h1>
    <script>
      console.log("5000DENY+self.html", window.parent.document);
    </script>
  </body>
</html>

兩者都有設定的情況,CSP 的優先權會高於 X-Frame-Options
5000DENY+self

CSP2 的官方文件中有描述到這點

The frame-ancestors directive obsoletes the X-Frame-Options header. If a resource has both policies, the frame-ancestors policy SHOULD be enforced and the X-Frame-Options policy SHOULD be ignored.

X-Frame-Options 跟 CSP frame-ancestors 該怎麼設定

因為 CSP frame-ancestors 是比較新的功能,為了瀏覽器的向後兼容性,建議兩者都設置

如果不想讓所有網站嵌入,就設定

res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");

如果只想讓同源的網站嵌入,就設定

res.setHeader("X-Frame-Options", "SAMEORIGIN");
res.setHeader("Content-Security-Policy", "frame-ancestors 'self'");

如果想要更精細的控制哪些網站可嵌入,就設定

res.setHeader(
  "Content-Security-Policy",
  "frame-ancestors <host-source> <host-source>",
);

參考資料


上一篇
Sec-Fetch
下一篇
HTTP Range Requests
系列文
Learn HTTP With JS30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言