<iframe> 套娃?安全策略?跨域?微前端?好像知道,但又不多

1,364 阅读3分钟

iframe.png

字数:4062

字符数:12662

推荐阅读时间:15分钟

大家好,我是 San 同学 ,专注输出精品原创技术文章,目前在浙大读研 & 字节实习

最近在工作期间频繁的遇到 iframe 标签,一想,iframe 不就是一个嵌套网页的东西吗?不过再一细想,一些细节又好像没摸清楚过,循环套娃浏览器会直接崩溃吗?随便挂个网站都可以展示吗?安全问题如何规避?知道 iframe 可以解决跨域问题吗?知道 iframe 还可以是微前端的一种方案吗?

好的,不知道,于是我开始认真学习 iframe...

iframe 到底是个啥?

The iframe element represents its nested browsing context. from WHATWG

乍一看,不就是嵌套的浏览器上下文嘛,来做几个简单实验瞅瞅

极简套娃

<!-- FirstPage/index.html -->
<body>
  <h1>This is First Page!</h1>
  <iframe src="../SecondPage/index.html" height="500px" width="100%">
  </iframe>
</body>

<!-- SecondPage/index.html -->
<body>
  <h2>This is the second page!</h2>
</body>

就这样一层嵌套的套娃就完成了 ✅,来简单看看效果

ba1ae79b755b11ea14b1138462778804.png 这就完事儿了?怎么可能,来看看中阶版本会发生什么...

中阶-娃中娃

<!-- FirstPage/index.html -->
<body>
  <h1>This is First Page!</h1>
  <iframe src="../SecondPage/index.html" height="500px" width="100%">
  </iframe>
</body>

<!-- SecondPage/index.html -->
<body>
  <h2>This is the second page!</h2>
  <iframe src="../FirstPage/index.html" height="500px" width="100%"></iframe>
</body>

02ff602d4245a9e7d2d7e53bb5e3a884.png 诶?怎么只套了两层,不应该是无限嵌套下去吗?这个时候就得去看下这个 iframe 的标准具体是怎么规定的了,在查找资料之后,发现在 97 年 W3C 发布的 frame 草案中有这么一段话

Infinite recursion is prevented. Any frame that attempts to assign as its SRC a URL used by any of its ancestors is treated as if it has no SRC URL at all (basically a blank frame). This doesn't prevent all malicious documents, but it eliminates a troublesome class of them.

虽然这是对 frame 的规定,但是草案中也提到了 iframe 提案,并且两者的区别可以简单理解为一个是需要放在 FRAMESET 中,一个是可以放在 BODY 中并加入文档流

那么这句话是什么意思的?简单的说就是为了避免无限递归,任何想要使用其祖先页面的 url 作为他的 src 的话,都会被当做一个 src 为空的 iframe 。这也就解释了为啥只套了两层就套不下去了

那么既然是根据 URL 判定是否为祖先页面,那么我们设置不同的 query 是否可以实现无限套娃呢?

<body>
  <h1>This is First Page!</h1>
  <iframe width="100%" height="800px"></iframe>
  <script>
    document.querySelector('iframe').src = `index.html?${Math.random() * 1000}`;
  </script>
</body>

94ab60b9fe428ca340a17689401c7466.png

果不其然,这样就出现了无限套娃的现象 (冷知识++)

看到这里不知道大家是不是对与 iframe 是啥、iframe 的一些特性以及最基本的用途有个比较清晰的认识了?那么对应上面的问题,往前走一步,随便挂个网站都能显示?

iframe 的安全机制

来源限制

首先回到之前那个问题,随便挂个网站都能显示吗?那我挂一个知乎看看... 724601bff8e7d22de1aa6839a3fcf41e.png 发现知乎拒绝了我们的连接请求,于是我打开了 devtools,我们可以看到有这么一个报错,由于知乎配置了 CSP 安全策略,导致我们的连接请求被拒绝了;同时我们观察知乎的返回的请求响应头,除了 CSP 之外,还有一个响应头是 X-Frame-Options,这个响应头设置的是 SAMEORIGIN,表明只有祖先页面的域名是同域的才允许嵌入

637c89540002d6da4c57c3b5279afab5.png 769aa3a02ce17a8f22134c24ae405f30.png

CSP iframe

🎯 通过配置 CSP 的一些策略,可以对 iframe 的嵌套进行一定的控制,保证一定的安全性;下边列举一部分 iframe 相关的 CSP 配置项

  • frame-ancestors:指定可以使用<frame>, <iframe>, <object>, <embed>,或 <applet> 来嵌入本页面的有效父节点;像上边知乎配置的就是 frame-ancestors *.zhihu.com;可以避免点击劫持的网络攻击
  • frame-src:指定本页面能够嵌入网页的源,像知乎配置的是一大堆东西,'self' *.zhihu.com mailto: tel: weixin: *.vzuu.com mo.m.taobao.com...

这俩的区别简单的说就是指定的对象是相反的,前者是指定父级,后者是指定子级

X-Frame-Options

🎯 这个 HTTP 请求头同样可以限制可嵌套的祖先源,同样可以避免点击劫持 ⚠️ 不过支持上边 CSP:frame-ancestors 的浏览器已经废弃了这个指令 具体的参数属性如下

  • DENY:表示该页面不允许在 frame 中展示,即便是在相同域名的页面中嵌套也不允许
  • SAMEORIGIN:表示该页面可以在相同域名父级页面通过 frame 进行嵌套展示;但是又个需要注意的点,这个属性一开始有个问题,它只支持去检测顶级的页面节点是否是同域,后来经过讨论后觉得这种检测比较鸡肋,恶意网站完全可以不在顶级节点;因此,到目前部分浏览器已经做出了更有效的实现,如 chrome61 版本后 SAMEORIGIN 会检测所有上层的节点是否同域
  • ALLOW-FROM uri:允许指定特定域名的父级进行嵌套,不过目前基本都被浏览器废弃了

9d75987e9fc034b31e30d6f8a0c8ba21.png

沙盒 - Sandbox

该属性对呈现在 iframe 框架中的内容启用一些额外的限制条件,此属性目前在绝大多数浏览器中都已经支持

5ffe78d1e06d6196449be6bdfa23cb3c.png

咱们来设想一个 场景,如果你的网站嵌入了某一个第三方网站 A.com,只用做静态展示,平时一切正常,但是在某次第三方网站更新后,加入了一些 JS 脚本,该脚本有一个死循环的 bug;那么直接就导致你的网站卡死,崩溃;那么如何能够防护这个问题呢?

iframe 提供了一个 sandbox 模式,这个模式可以对 iframe 的内容进行一系列限制,如果 sandbox 的值是空字符串的话,那么会启动所有的限制;比如说上边的场景,由于只是静态展示的网站,并不需要脚本的执行,那么可以将其设置成 sandbox 模式,然后不开启 allow-scripts 即可,具体的一些常见的值和作用就在下边简单列举一下:

  • allow-scripts:允许嵌入的浏览上下文运行脚本
  • allow-downloads-without-user-activation:允许在没有征求用户同意的情况下下载文件
  • allow-forms:允许嵌入的浏览上下文提交表单
  • allow-modals:允许嵌入的浏览上下文打开 Modal
  • allow-popups:允许弹窗,打开新窗口
  • allow-same-origin:不设置该属性,任何与该网站的资源交互会被同源策略拦截

至此,大家应该对 iframe 的一个基本使用以及一些安全方面的机制有一定的了解了吧 接下来,继续整点有意思的,iframe 还能跨域?还能整微前端?


iframe 跨域

总所周知,跨域是指 protocol + host + port 其中任意一个不同,既表示两个资源是跨域的,那么这个小结主要总结一下 iframe 如何去处理跨域的问题,说实话以前还真没注意过这个事儿,但是在一次腾讯面试中,就被问到了 🤦‍♂️

window.domain + iframe

使用场景

假设一个使用场景,网站A first.doc.com/a.html 想要访问 网站B second.doc.com/b.html 文档中的 form 表单,那么可以通过在 script 设置 document.domain=doc.com 来实现跨域访问

原理

原理其实也比较简单就是通过改变 document.domain 来实现二级域名相同的网站进行跨域访问的方案,但是这个方案仅局限于两个网站的二级域名相同,就像上边的 first.doc.com <==> second.doc.com,原因是因为document.domain 仅支持更改为域名的二级域名 + 顶级域名;如果两个网站二级域名不一致想通过利用 document.domain 改二级域名进行跨域访问的话,就会报错如下

5db50aa11979f4be72835808fa04a8a2.png

window.name + iframe

使用场景

<!-- localhost:8001/FirstPage/index.html -->
<body>
    <iframe
      src="http://localhost:8001/ThirdPage/index.html"
      onload="load()"
    ></iframe>
    <script>
      let state = 0;
      function load() {
        const iframe = document.querySelector("iframe");
        if (!state) {
	  iframe.contentWindow.name = '这里是我要传的数据';
          iframe.src = "http://localhost:8002/SecondPage/index.html";
          state = 1;
        } else {
          console.log(iframe.contentWindow.name);
        }
      }
   </script>
</body>

<!-- localhost:8001/ThirdPage/index.html -->
<!-- 空界面 -->

原理

上述操作本质上, 就是在 iframe 中通过一个中间同域的代理界面,利用 iframe 的window.name 属性传递跨域的数据,为什么要用 window.name 这个属性来传递消息呢?因为它有一定的特性:

  • 对于同一个窗口 window (包括 iframe 窗口) ,在加载过不同页面后同样会保存 (只要没修改过值,就不会改变,想要验证的话可以直接在控制台上找个网站进行验证),因此先将 iframe 的 src 设置为中间代理界面,通过 window.name 传递完数据后,再将src设置为目标界面,以达到跨域传输数据的目的;
  • 并且,window.name 的长度可以达到 2M,足以来传递一些数据

location.hash + iframe

原理

本质上跟上边的 window.name 方案一样,同样是在 iframe 中通过一个中间代理的同域页面,然后利用其 location.hash 进行数据的跨域传递,差别就是 location.hash 和 window.name 的差别,不再多做赘述


iframe 实现简易的微前端方案

在讲之前,先简单说下微前端是个啥,解决的问题是啥

微前端

一句话解释微前端

一种架构风格,其中可独立交付的前端应用程序组合成一个更大的整体

微前端带来了什么好处

  • 渐近增量升级的必要性:当使用一些过时技术栈的老旧项目想要使用新技术的时候,肯定不可能整体推翻重构,那样人力成本和时间成本都不可控,更合理的就是能够拆分成多个小模块,渐进的部分升级;像在字节的第一段实习经历中,一个历史项目用的是 vue2 ,当时想转向 react 技术栈,就得采用微前端的方案渐进升级
  • 简单、解耦:微前端架构能够使一整个大项目,解耦成主工程 + n个子应用,能够使得开发人员对项目的理解成本大大降低,同时可维护性也会提升
  • 独立部署:每个微前端子应用,都应该有自己的 CI/CD pipeline,不需要去考虑其他的子应用如何,只要管好自己这部分就可以

a335beac126b3be4b693af560d2be8f7.png

微前端注意事项

  • 微应用的注册、异步加载和生命周期管理
  • 微应用之间、主从之间的消息机制
  • 微应用之间的安全隔离措施
  • 微应用的发布流程

iframe 实现简单的微前端

主要包含一个主工程界面,两个子应用界面,分别通过 iframe 引入主工程;进一步利用 事件总线 以及 postMessage API 通过主工程进行消息分发,实现子工程间通信;先展示下效果👇

实现效果

iframe-demo.gif

左边是 Vue 子应用工程,右边是 React 的子应用工程,通过上述机制进行互相间通信,同时保持两者相互独立、隔离,这样就实现了简单的微前端架构

核心代码

主工程 & 子工程
<!-- index.html 主工程入口 -->
<body>
    <iframe src="./LeftModule/index.html" id="left" frameborder="0"></iframe>
    <iframe src="./RightModule/index.html" id="right" frameborder="0"></iframe>
    <script type="module">
      const handleMessagePostToIframes = (event) => {
        const {
          data: { type },
          source,
        } = event;
        document.querySelectorAll("iframe").forEach((iframe) => {
          iframe.contentWindow.postMessage({ type });
        });
      };
      window.addEventListener("message", handleMessagePostToIframes);
    </script>
 </body>

<!-- vue index.html vue 子工程入口 -->
<body>
  <link rel="stylesheet" href="./style.css" />
  <script
    src="https://cdnjs.cloudflare.com/ajax/libs/vue/3.2.38/vue.global.prod.min.js"
    integrity="sha512-npQPwoPEoxzuLDSytF9RIdsHJd122lMGlUoLuQo2vCYtk6R1DEB03wIknFzHNQNHJKQlPjwcrEqflYWp417eVw=="
    crossorigin="anonymous"
    referrerpolicy="no-referrer"
  ></script>
  <div id="app">
    <img src="../statics/vue.png" id="image" alt="" />
    <button @click="handleSendTomCoin">Send Tom a coin</button>
  </div>
  <script type="module">
    import eventEmitter from "../EventEmitter.js";
    const { createApp } = Vue;
    createApp({
      methods: {
        handleSendTomCoin() {
          eventEmitter.emit("sendCoin");
        },
      },
    }).mount("#app");
  </script>
</body>

<!-- React index.js react 子应用代码 -->
<script>
"use strict";

const e = React.createElement;
import eventEmitter from "../EventEmitter.js";

class TomPocket extends React.Component {
  constructor(props) {
    super(props);
    this.state = { coins: 0 };
    eventEmitter.subscribe("sendCoin", this.handleSolveMessage.bind(this));
  }

  handleSolveMessage() {
    this.setState({
      coins: this.state.coins + 1,
    });
  }

  render() {
    if (this.state.liked) {
      return "You liked this.";
    }
    return e(
      "div",
      null,
      e("img", {
        src: "../statics/react.png",
      }),
      e(
        "div",
        {
          style: {
            color: "white",
          },
        },
        `Tom have ${this.state.coins} coin${this.state.coins > 1 ? "s" : ""}`
      )
    );
  }
}

const domContainer = document.querySelector("#reactApp");
const root = ReactDOM.createRoot(domContainer);
root.render(e(TomPocket));

</script>

EventEmitter
// EventEmitter.js
class EventEmitter {
  static emitter = null;
  constructor() {
    this.subs = this.subs ?? new Map();
    window.addEventListener("message", this.handleMessage.bind(this));
  }

  subscribe(type, cb) {
    if (!this.subs.has(type)) {
      this.subs.set(type, [cb]);
    } else {
      this.subs.get(type).push(cb);
    }
  }

  emit(type) {
    window.parent.postMessage({ type }); // 这里的 emit 跟通常的 emit 不太一样,需要通知主工程进行消息分发
  }

  remove(type, cb) {
    if (this.subs.has(type)) {
      const index = this.subs.get(type).indexOf(cb);
      if (~index) {
        this.subs.get(type).splice(index, 1);
      }
      if (!this.subs.get(type).length) {
        this.subs.delete(type);
      }
    }
  }

  once(type, cb) {
    // 只触发一次的发布订阅
    const that = this;
    function callback() {
      cb(...arguments);
      that.remove(type, callback);
    }
    this.subscribe(type, callback);
  }

  handleMessage(event) {
    const {data: {type}} = event;
    if (this.subs.has(type)) {
      this.subs.get(type).forEach(sub => {
        sub([...arguments].slice(1));
      });
    }
  }

  static getEmitter() {
    if (!EventEmitter.emitter) {
      EventEmitter.emitter = new EventEmitter();
    }
    return EventEmitter.emitter;
  }
}
export default EventEmitter.getEmitter();
至此,咱们就利用 iframe,不到 200 行核心代码 简单实现了微前端架构 🎉🎉🎉

完整代码 github 🌟++

iframe 实现的微前端的局限性

  • 路由状态丢失,每次刷新界面都会使得 iframe 内部的路有状态丢失
  • DOM 割裂,iframe 内部的弹窗只能局限于 iframe 窗口内
  • 通信相对来说存在一定局限性,因为 postMessage 只能传递可序列化的数据
  • iframe 白屏时间长,因为得先等主工程加载完才开始加载

对于这些局限性,业界也做过很多尝试,比如说腾讯的无界微前端框架,就是继承iframe的优点,补足iframe的局限,配合 Web Component 实现的微前端,开源代码戳👉 wujie

参考

👍 + ❤️ + 📨

如果觉得写得有什么不对的地方,欢迎讨论交流 👏 觉得写得好的可以 点赞+收藏+关注 ,San 同学 会继续定期更新优质文章,也可以关注我的公众号 前端 San 同学,希望可以一起进步,一起成长,争做鄙视链顶端的前端工程师 🐶