禁止入内 · iframe 内嵌引发的连环案件
本文主要和大家分享一套跨窗口登录方案,旨在解决新窗口登录,原窗口获取登录态的跨窗口通信问题。期间还遇到了 iframe
页面无法内嵌、跨站请求无法携带 cookie
和跨窗口无法共享 storage
等问题,涉及的点还是比较多的,值得记录和大家分享。
起因
其他项目组使用 iframe 内嵌我们的页面,在使用其中的谷歌的三方授权时,会出现该页面无法加载的场景。打开控制台会发现如下报错:
错误提示是由于谷歌的页面将 X-Frame-Options
设置为 deny
,从而导致该页面拒绝在 iframe 中显示。我们在 Network 中也确实找到了该项:
这是个什么响应头,又为什么要这么设置呢?
案件一:禁止内嵌 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
嵌入的。它有三个参数:
属性 | 类型 | 是否必传 | 说明 |
---|---|---|---|
url | string | N | 表示要加载的资源的 URL 或路径 , 空 "" 或省略此参数,则会在目标浏览上下文中打开一个空白页 |
target | string | N | 可以使用特殊关键字 _self 、_blank 、_parent 和 _top |
windowFeatures | string | N | 设置窗口的默认大小和位置、是否打开最小弹出窗口等选项 |
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.uk
、http://github.io
,从域的角度讲 .io
是顶级域名, github
是二级域名,但其实 github.io
是一个完整的有效顶级域名。注意,我说的是有效。类似的还有很多,可以查阅 PUBLIC SUFFIX LIST
eTLD+1
则表示 有效顶级域名 + 二级域名(SLD),由于 eTLD
是可注册的,因此具有相同 eTLD+1
的所有域都归同一组织所有,即「同站」。
举几个例子:
www.taobao.com
和www.baidu.com
是跨站,因为二级域名不一样;www.a.taobao.com
和www.b.taobao.com
是同站,虽然三级域名不同,但都属于二级域名下的子域名,它们的eTLD+1
是一样的;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,真的是麻烦,要是能像 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
其实前面的轮询中有两个问题我们没有解决:
- 何时结束轮询?
- 如果 cookie 之前已存在,会导致新窗口还没登录,原窗口就已经使用老的 cookie 去请求接口了。
综合以上两点,我们并不能以有无 cookie 为标准判定用户是否登录,而应该以最新的登录请求结果为标准:
- 登录成功并且轮询到 cookie,表示当前用户登录成功,可以开始获取用户信息;
- 登录失败,结束轮询。
这里又引出一个问题:新窗口的请求结果,如何通知给原窗口呢?
方案一:Storage 共享
不需要额外的操作,浏览器支持同域名下的跨窗口 Storage 共享,但是 sesionStorage
和 localStorage
的表现却不一样:
localStorage:只要是同源,不同窗口之间均可读写,相互影响。
sessionStorage:
- 前提还是同源;
- 同一窗口内的所有跳转可读写且相互影响;
- 不同窗口之间,读写操作独立,互不影响;
- 新打开的窗口:通过新开 Tab 标签或
<a href="同源页面" target="_blank">跳转</a>
打开,读写操作独立,互不影响; - 新打开的窗口:通过
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 aMessageEvent
on it withtargetWindow.postMessage()
. The receiving window is then free to handle this event as needed. The arguments passed towindow.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 以及由此扩展开的一些知识点:
- iframe 的
X-Frame-Options
属性; - cookie 的跨站携带问题;
- storage 的跨窗口共享问题;
- postMessage 的跨窗口通信。
借助开发中遇到的三个问题,我们针对以上四点依次展开分析,并给出一些解决方案。
文中的代码示例仅供参考,希望对大家有所帮助,如有什么问题大家可以在评论区留言讨论哦~😃
参考资料
iframe 和 cookie
- iframe | MDN
- X-Frame-Options | MDN
- 点击劫持(en-us) | MDN
- SameSite | MDN
- chrome浏览器 同站(SameSite)策略 导致 iframe 内嵌页面cookie无法写入
域名相关
跨窗口通信相关
转载自:https://juejin.cn/post/7276798688898695228