likes
comments
collection
share

禁止入内 · iframe 内嵌引发的连环案件

作者站长头像
站长
· 阅读数 22

本文主要和大家分享一套跨窗口登录方案,旨在解决新窗口登录,原窗口获取登录态的跨窗口通信问题。期间还遇到了 iframe 页面无法内嵌、跨站请求无法携带 cookie 和跨窗口无法共享 storage 等问题,涉及的点还是比较多的,值得记录和大家分享。

起因

其他项目组使用 iframe 内嵌我们的页面,在使用其中的谷歌的三方授权时,会出现该页面无法加载的场景。打开控制台会发现如下报错:

禁止入内 · iframe 内嵌引发的连环案件

错误提示是由于谷歌的页面将 X-Frame-Options 设置为 deny,从而导致该页面拒绝在 iframe 中显示。我们在 Network 中也确实找到了该项:

禁止入内 · iframe 内嵌引发的连环案件

这是个什么响应头,又为什么要这么设置呢?

案件一:禁止内嵌 iframe

X-Frame-Options

X-Frame-Options 会指示浏览器该页面是否可以在 <frame><iframe><embed><object> 中展现。它有两个值:

X-Frame-Options: DENY;
X-Frame-Options: SAMEORIGIN;

DENY:表示该页面不允许在 frame 中展示,即便是在相同域名的页面中也不允许嵌套。

SAMEORIGIN:表示该页面可以在相同域名页面的 frame 中展示。

点击劫持

为什么 Google 的三方授权页面要这样做呢?其实是为了确保没有被嵌入到别人的站点中,从根本上避免点击劫持(Clickjacking)

所谓点击劫持,就是一种基于界面的攻击手段。会欺骗用户点击与用户预期不同的链接或按钮,从而窃取登录凭据。比如上文中提到的 Google 三方授权页面,在用户点击确认登录授权页面后,会从 Google 拿到登录 token,并返回到重定向的 URL。

试想,如果该授权页面被不法分子内嵌到诈骗网站,而用户又在该诈骗网站上进行三方授权登录,那么最终攻击者就会获取到该用户的个人信息,造成隐私泄露。

除了 X-Frame-Options,还有一种方式就是使用 Set-Cookie 属性来限制 cookie 的发送。至于网络攻击,和本文关系不大,不展开赘述,有兴趣可以查阅网络攻击类型(en-us)

解决方案

step 1. window.open()

我们使用 window.open() 来打开新窗口,这样可以避免 X-Frame-Options 限制,因为内容不再是通过 iframe 嵌入的。它有三个参数:

属性类型是否必传说明
urlstringN表示要加载的资源的 URL 或路径 , 空 "" 或省略此参数,则会在目标浏览上下文中打开一个空白页
targetstringN可以使用特殊关键字 _self_blank_parent_top
windowFeaturesstringN设置窗口的默认大小和位置、是否打开最小弹出窗口等选项
const openOAuth = (url, isIframe) => window.open(url, isIframe ? '_blank' : '_self');

这样,我们就能轻易的绕开限制,直接在新页面中进行授权登录,登录完成后再关闭页面即可。

这里,我们还需要额外介绍一下 window.open() 的返回值,它是一个 WindowProxy 对象,只要符合同源策略,返回的引用就可以用于访问新窗口的属性和方法。

我们在点击事件中顺便接收这个返回值,待会儿会在跨窗口通信中用到它。

// 原窗口
const newTab = ref(null);
const openOAuth = (url, isIframe) => window.open(url, isIframe ? '_blank' : '_self');

// 接收返回值
const onClick = () => {
    newTab.value = openOAuth('目标网页URL', true);
}

// 访问新窗口的属性和方法:
console.log(newTab.value.location.href);
newTab.value.alert('我被召唤出来了!');

step 2. 原窗口轮询登录态

登录成功后,后端会在响应头中种下 cookie。而根据同源策略,只要 Domain 和 Path 一致情况下,不同窗口之间即可相互读取。也就是说同源 cookie 是可以跨窗口共享的。

原窗口可以通过轮询 cookie 的方式检查用户是否已经登录,并进行相关的处理,比如查询该用户的信息、获取用户订单等等。

// 原窗口
import Cookies from 'js-cookie';

const timer = ref(null);

const pollingCookie = () => {
    timer.value = setInterval(() => {
        const cookie = Cookies.get('sessionId');
        if (cookie) {
            // 可以进行登录后的一些处理
        }
    })
}

到这里,一切都符合预期,但是没想到,就在计划进行到下一步的时候,意外发生了······

案件二:跨站禁止携带 Cookie

什么是跨站

首先需要声明的是,跨站和跨域不是同一个概念。

「同源」的标准相对苛刻,必须协议、域名、端口一致。相对而言,Cookie 中的「同站」判断就比较宽松:只要两个 URL 的 eTLD+1 相同即可,不需要考虑协议和端口。

其中,TLD(Top-Level Domain),表示顶级域名。比如 .com.io.gov.cn 等。

eTLD 就是 effective TLD,表示有效的顶级域名。由于许多注册商允许单位组织在顶级以下的级别注册域名,这就导致有些二级域名也被视为一个完整的有效顶级域名。比如 http://co.ukhttp://github.io,从域的角度讲 .io 是顶级域名, github 是二级域名,但其实 github.io 是一个完整的有效顶级域名。注意,我说的是有效。类似的还有很多,可以查阅 PUBLIC SUFFIX LIST

eTLD+1 则表示 有效顶级域名 + 二级域名(SLD),由于 eTLD 是可注册的,因此具有相同 eTLD+1 的所有域都归同一组织所有,即「同站」。

举几个例子:

  1. www.taobao.com 和 www.baidu.com 是跨站,因为二级域名不一样;
  2. www.a.taobao.com 和 www.b.taobao.com 是同站,虽然三级域名不同,但都属于二级域名下的子域名,它们的 eTLD+1 是一样的;
  3. a.github.io 和 b.github.io 是跨站,因为 eTLD.github.io,导致 eTLD+1 并不一致。

Cookie 域内共享

如果 cookie 种在二级域名下,那么该域名下的所有子域名都能获取到该 cookie,也就是说 cookie 在当前域名及子域名下都是共享的。

SameSite + Secure

iframe 内嵌的页面中发送出的请求,所有第三方 Cookie 都会被 Chrome 屏蔽,目的是防止 CSRF 攻击和用户追踪。并且 Chrome 会将未声明 SameSite 值的 Cookie 默认设置为 SameSite=Lax

SameSite 属性说明
Strict浏览器仅对同一站点的请求发送
Lax不会在跨站请求中发送 cookie,如:加载图像或 frame 的请求。
None浏览器会在跨站和同站请求中均发送 cookie,但必须同时设置 Secure 属性

解决方案比较简单:就是让后端配置 Set-Cookie 时,加上 SameSite=None;Secure。表示可以随着跨站请求一起发送,但必须使用 https 协议。

到这里,一切都符合预期,但是没想到,就在计划进行到下一步的时候,意外又发生了······

火狐兼容

使用 FireFox 进行调试,又发现一个问题:iframe 内嵌的场景下,即使内嵌的页面能够获取 cookie,即使 cookie 设置 SameSite=None,浏览器依然禁止跨域携带 cookie

找了一圈也没找到合适的答案,离了个大五线谱 😠······

于是问了下 ChatGPT,它是这么说的:

禁止入内 · iframe 内嵌引发的连环案件

跨站碰到 iframe,真的是麻烦,要是能像 JWT 一样,无需考虑跨域场景,一个 token 走天下该多好哇!

咦?token ?

解决方案:我们可以利用 Authorization 头,将获取到的 cookie 当做 token 携带上去,这样就解决了这个离谱的问题,当时,这是没有办法的办法。

// 原窗口
import Cookies from 'js-cookie';

const timer = ref(null);

const pollingCookie = () => {
    timer.value = setInterval(() => {
        const cookie = Cookies.get('sessionId');
        if (cookie) {
            // 可以进行登录后的一些处理
            axios.post(url, data, { headers: { 'Authorization': cookie } })
        }
    })
}

到这里,一切都符合预期,但是没想到,就在计划进行到下一步的时候,意外又又发生了······

案件三:禁止共享 Storage

其实前面的轮询中有两个问题我们没有解决:

  1. 何时结束轮询?
  2. 如果 cookie 之前已存在,会导致新窗口还没登录,原窗口就已经使用老的 cookie 去请求接口了。

综合以上两点,我们并不能以有无 cookie 为标准判定用户是否登录,而应该以最新的登录请求结果为标准:

  1. 登录成功并且轮询到 cookie,表示当前用户登录成功,可以开始获取用户信息;
  2. 登录失败,结束轮询。

这里又引出一个问题:新窗口的请求结果,如何通知给原窗口呢?

方案一:Storage 共享

不需要额外的操作,浏览器支持同域名下的跨窗口 Storage 共享,但是 sesionStoragelocalStorage 的表现却不一样:

localStorage:只要是同源,不同窗口之间均可读写,相互影响。

sessionStorage

  1. 前提还是同源;
  2. 同一窗口内的所有跳转可读写且相互影响;
  3. 不同窗口之间,读写操作独立,互不影响;
  4. 新打开的窗口:通过新开 Tab 标签或 <a href="同源页面" target="_blank">跳转</a> 打开,读写操作独立,互不影响;
  5. 新打开的窗口:通过 window.open() 打开,新窗口可以基于原窗口的 sessionStorage 拷贝一份,作为自己的缓存初始值,但之后,读写操作独立,互不影响。

总结一句话:localStorage 可以跨窗口共享, sessionStorage 不可以,但是通过 window.open() 打开的新窗口可以获取原窗口的 sessionStorage 作为自己的初始值。

所以,我们可以在新窗口登录成功后,往 localStorage 中存入一个状态,用来判定当前请求是否结束:

const login = async () => {
    try {
        const res = await axios.post(url, data);
        localStorage.setItem('loginStatus', JSON.stringify(1));
    } catch() {
        localStorage.setItem('loginStatus', JSON.stringify(0));
    }.finally {
        window.close();
    }
}

原窗口就可以轮询 localStorage 来判断是否登录成功了:

const endPolling = () => {
    clearInterval(timer.value);
    timer.value = null;
    localStorage.removeItem('loginStatus');
}

const pollingCookie = () => {
    timer.value = setInterval(() => {
        const cookie = Cookies.get('sessionId');
        const loginStatus = JSON.parse(localStorage.getItem('loginStatus'));

        if (loginStatus && cookie) {
            // 此时才被视为登录成功
            endPolling();
            axios.post(url, data, { headers: { 'Authorization': cookie } })
        } else if (!loginStatus) {
            // 登录失败,没必要再轮询了
            endPolling();
            Cookies.remove('sessionId')
        }
    })
}

到这里,一切都符合预期,但是没想到,就在计划进行到下一步的时候,意外又又又发生了······

方案二:postMessage

又是火狐!在 iframe 内嵌模式下,内嵌页面即使与新窗口是同源,依然不能 storage 共享,也就是 localStorage 跨窗口共享失效,服了 🥴······

所以只能搬出最终的杀手锏 —— window.postMessage。中文的 MDN 翻译有点过时了,并且有些翻译错误,我们看下英版对它的介绍:

The window.postMessage() method safely enables cross-origin communication between Window objects; e.g., between a page and a pop-up that it spawned, or between a page and an iframe embedded within it.

window.postMessage() 方法可以安全地在 window 对象之间实现跨源通信。例如,在页面和由该页面弹出窗口之间,或者在页面和嵌入其中的 iframe 之间。

......

Broadly, one window may obtain a reference to another (e.g.,  via targetWindow = window.opener), and then dispatch a MessageEvent on it with targetWindow.postMessage(). The receiving window is then free to handle this event as needed. The arguments passed to window.postMessage() (i.e.,  the "message") are exposed to the receiving window through the event object.

一般来说,一个窗口可以获得对另一个窗口的引用(例如,通过 targetWindow = window.openter),然后调用 targetWindow.postMessage() 方法分发一个 MessageEvent 事件。然后,接收窗口可以根据需要自行处理此事件。传递给 window.postMessage()的参数(比如 message)将通过事件对象(event)暴露给接收窗口。

还记得开头说过 window.open() 的返回值吗?那个返回值就是新窗口的引用,如果我们要给新窗口发送数据,可以直接调用 postMessage()

// 原窗口
const newTab = ref(null);
const openOAuth = (url, isIframe) => window.open(url, isIframe ? '_blank' : '_self');

// 接收返回值
const onClick = () => {
    newTab.value = openOAuth('目标网页URL', true);
}

// 给新窗口发送数据
const sendData = () => {
    newTab.value.postMessage({ data: 123 }, window.location.origin);)
}

新窗口就要注册 message 事件接收数据。

// 新窗口
const setPostMessage = (event) => {
    console.log(event.data)
}

window.addEventListener('message', setPostMessage);

onBeforeUnmount(() => {
    window.removeEventListener('message', setPostMessage);
});

同理,如果新窗口需要向原窗口发送数据,重复以上操作。

window.opener 返回打开当前窗口的那个窗口的引用。我们使用它来接收原窗口的引用。

改造一下 login 方法:

// 新窗口
const originalTab = window.opener;

const login = async () => {
    try {
        const res = await axios.post(url, data);
        originalTab.postMessage(
            { loginStatus: 1 },
            window.location.origin
        );
    } catch() {
        originalTab.postMessage(
            { loginStatus: 0 },
            window.location.origin
        );
    }.finally {
        window.close();
    }
}

原窗口接收登录态数据,并写入 localStorage 中:

// 原窗口
const setLoginStatus = (status) => {
    localStorage.setItem('loginStatus', JSON.stringify(status));
};

window.addEventListener('message', setLoginStatus);

onBeforeUnmount(() => {
    window.removeEventListener('message', setLoginStatus);
});

这样就可以通过 postMessage 实现 iframe 模式下,原窗口的内嵌页面与新窗口的通信了。

总结

本文主要分享了 iframe 以及由此扩展开的一些知识点:

  1. iframe 的 X-Frame-Options 属性;
  2. cookie 的跨站携带问题;
  3. storage 的跨窗口共享问题;
  4. postMessage 的跨窗口通信。

借助开发中遇到的三个问题,我们针对以上四点依次展开分析,并给出一些解决方案。

文中的代码示例仅供参考,希望对大家有所帮助,如有什么问题大家可以在评论区留言讨论哦~😃

参考资料

iframe 和 cookie

域名相关

跨窗口通信相关