学习链接
阮一峰:同源限制(👍👍👍)
阮一峰:CORS 通信(👍👍👍)
鉴定一下网络热门面试题:讲讲JS如何跨域(👍👍👍)
A Clarification About the Same-Origin Policy
现代JavaScript 教程:资源加载:onload,onerror
(整理的还是有些冗余,再慢慢精进吧)
跨域问题
跨域问题其实就是浏览器的同源策略造成的。
同源策略
同源:具有相同的协议、域名和端口号的两个 URL 是同源的。
同源策略:浏览器允许包含在 A 网页的脚本访问 B 网页的数据,但前提是这两个网页是同源的。
这是浏览器的安全机制,主要是为了保护用户信息的安全,防止恶意的网站窃取数据(Cookie)。
所以,当一个网页的脚本访问不同源的另一个网页的数据时,默认无法获取,也就出现了所谓的跨域问题。
注意,标准规定端口不同的网址不是同源(比如8000端口和8001端口不是同源),但是浏览器没有遵守这条规定。实际上,同一个网域的不同端口,是可以互相读取 Cookie 的。
同源策略仅适用于浏览器中的脚本,即浏览器跨域的基本规则是编程的不可访问性。
潜台词:UI 的可访问性,这意味着在默认情况下,图像、CSS 和动态加载的脚本等资源可以通过相应的 HTML 标签跨源访问,同时,链接跳转、iframe、src、href、表单提交、打开窗口都不会有跨域的限制。
而跨站请求伪造(CSRF)就是利用同源策略不适用于 HTML 标签 的缺陷。(例如,img标签中链接到其他网站中,带着用户的身份信息去执行操作,无同源限制)
CSRF(Cross-site request forgery)跨站请求伪造,攻击者诱导受害者进入第三方网站,在第三方网站中,向被攻击网站发送跨站请求。利用受害者在被攻击网站已经获取的注册凭证,绕过后台的用户验证,冒充用户向服务器执行一些操作。
限制范围
随着互联网的发展,同源政策越来越严格。目前,如果非同源,共有三种行为受到限制。
(1) 无法读取非同源网页的 Cookie、LocalStorage 和 IndexedDB
(2) 无法接触非同源网页的 DOM
(3) 无法向非同源地址发送 AJAX 请求(可以发送,但浏览器会拒绝接受响应)
跨域问题主要在 ajax 的请求不能跨域,跨域的 img 数据无法被绘制到 canvas 上,也就是无法访问其中的内容
另外,通过 JavaScript 脚本可以拿到其他窗口的 window
对象。
如果是非同源的网页,目前允许一个窗口可以接触其他网页的 window
对象的九个属性和四个方法。
- window.closed,window.frames,window.length,window.location,window.opener,window.parent,window.self,window.top,window.window
- window.blur(),window.close(),window.focus(),window.postMessage()
上面的九个属性之中,只有window.location
是可读写的,其他八个全部都是只读。而且,即使是location
对象,非同源的情况下,也只允许调用 location.replace()
方法和写入 location.href
属性。
解决方案
跨域问题是浏览器的安全机制,所以不能只关注解决方案,还要关注安全隐私问题。
前端的跨域问题,是要在双方同意的基础上实现数据的可编程访问(真跨域问题)。
主域名一致,子域名之间的跨域问题(伪跨域问题),暂不讨论。
方案根据安全等级可分为三种
带域名限制的跨域方案
无法限制来访域名的跨域方案
不该用的方法
带域名限制的跨域方案
这类方案中被访问页面可以明确约束哪些域名可以来访问(完美的安全方案)。
CORS 跨域资源共享
CORS(Cross-Origin Resource Sharing,跨域资源共享)是一个基于一系列 HTTP 头部字段构成的一种机制,这些头部字段决定了浏览器是否会阻止前端 JavaScript 代码去获取跨域请求的响应。
CORS 需要浏览器和服务器同时支持。
整个 CORS 通信过程,都是浏览器自动完成,不需要用户参与。
事实上,浏览器一旦发现 AJAX 请求跨域,就会自动在 HTTP 请求报文中添加一些附加的头信息,也就是会自动发送所谓的 CORS 请求,有时还会多出一次附加的请求,但用户不会有感知。
因此,实现 CORS 通信的关键是服务器。只要服务器实现了 CORS 接口,就可以跨域通信。
浏览器将 CORS 请求分为 简单请求(simple request)和 非简单请求(not-simple-request)
若该请求满足以下两个条件,就可以看作是简单请求。
(1)请求方法是以下三种方法之一:
- HEAD
- GET
- POST
(2)HTTP的头信息不超出以下几种字段:
- Accept
- Accept-Language
- Content-Language
- Last-Event-ID
- Content-Type:只限于三个值
application/x-www-form-urlencoded
、multipart/form-data
、text/plain
凡是不同时满足上面两个条件,就属于非简单请求。
一句话,简单请求就是简单的 HTTP 方法与简单的 HTTP 头信息的结合。
这样划分的原因是,表单在历史上一直可以跨域发出请求。
简单请求就是表单请求,浏览器沿袭了传统的处理方式,不把行为复杂化,否则开发者可能转而使用表单,规避 CORS 的限制。
对于非简单请求,浏览器会采用新的处理方式。
(1)简单请求过程:
对于简单请求,浏览器直接发出 CORS 请求。
自动在头信息之中,添加一个 Origin
字段,用来说明本次请求来自哪个源(协议 + 域名 + 端口)。
服务器根据这个值,决定是否同意这次请求。
如果
Origin
指定的源,不在许可范围内,服务器会返回一个正常的 HTTP 回应。
浏览器发现,这个回应的头信息没有包含Access-Control-Allow-Origin
字段,就知道出错了,从而抛出一个错误,被 XMLHttpRequest
的 onerror
回调函数捕获。
注意,这种错误无法通过状态码识别,因为 HTTP 回应的状态码有可能是200。
如果
Origin
指定的源在许可范围内,服务器返回的响应会多出几个与 CORS 请求相关的头部字段。
Access-Control-Allow-Origin
该字段是必须的。
值为Origin
字段的值,或者是一个*
,表示接受任意域名的请求。Access-Control-Allow-Credentials
该字段可选。
它的值是一个布尔值,表示是否允许发送 Cookie。默认情况下,Cookie 不包括在 CORS 请求之中。
这个值只能设为true
,如果服务器不要浏览器发送 Cookie,不发送该字段即可。Access-Control-Expose-Headers
该字段可选。
CORS 请求时,XMLHttpRequest
对象的getResponseHeader()
方法只能访问 6 个服务器返回的基本字段:Cache-Control
、Content-Language
、Content-Type
、Expires
、Last-Modified
、Pragma
如果想拿到其他字段,就必须在Access-Control-Expose-Headers
里面指定。请注意:列表中没有
Content-Length
header!
该 header 包含完整的响应长度。因此,如果我们正在下载某些内容,并希望跟踪进度百分比,则需要额外的权限才能访问该 header。
(2)非简单请求过程
“预检” 请求
非简单请求的 CORS 请求,会在正式通信之前,增加一次 HTTP 查询请求,称为“预检”请求(preflight)。
浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些 HTTP 方法和头信息字段。只有得到肯定答复,浏览器才会发出正式的请求,否则就报错。
这是为了防止这些新增的请求,对传统的没有 CORS 支持的服务器形成压力,给服务器一个提前拒绝的机会,这样可以防止服务器收到大量DELETE
和PUT
请求,这些传统的表单不可能跨域发出的请求。
预检请求使用 OPTIONS
方法,它没有 body,但是有三个 header(包括 Origin
字段)
Access-Control-Request-Method
该字段是必须的。
列出浏览器的 CORS 请求会用到哪些非简单 HTTP 请求的方法。Access-Control-Request-Headers
该字段可选。
一个逗号分隔的字符串,指定浏览器 CORS 请求会额外发送的头信息字段。
预检请求的回应
服务器收到“预检”请求以后,检查了Origin
、Access-Control-Request-Method
和Access-Control-Request-Headers
字段以后,确认允许跨源请求,就可以做出回应。
如果服务器否定了“预检”请求,会返回一个正常的 HTTP 回应,但是没有任何 CORS 相关的头信息字段,或者明确表示请求不符合条件。
这时,浏览器就会认定,服务器不同意预检请求,因此触发一个错误,被 XMLHttpRequest
对象的onerror
回调函数捕获。
如果服务器同意处理请求,那么它会进行响应,此响应的状态码应该为 200,没有 body,具有 header
Access-Control-Allow-Methods
该字段必需。
它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法。
注意,返回的是所有支持的方法,而不单是浏览器请求的那个方法。这是为了避免多次“预检”请求。Access-Control-Allow-Headers
如果浏览器请求包括Access-Control-Request-Headers
字段,则Access-Control-Allow-Headers
字段是必需的。
一个逗号分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在“预检”中请求的字段。Access-Control-Allow-Credentials
该字段与简单请求时的含义相同。Access-Control-Max-Age
该字段可选。
用来指定本次预检请求的有效期,单位为秒。
即允许缓存该条回应的时间,在此期间,不用发出另一条预检请求。
CORS 中 Cookie 相关问题
在 CORS 请求中,如果想要传递 Cookie,就要满足以下三个条件:
- 在请求中设置
withCredentials
默认情况下在跨域请求,浏览器是不带 Cookie 的。但是我们可以通过设置 withCredentials
来进行传递 Cookie
// 原生 xml 的设置方式
const xhr = new XMLHttpRequest();
xhr.withCredentials = true;
// axios 设置方式
axios.defaults.withCredentials = true;
- Access-Control-Allow-Credentials 设置为
true
- Access-Control-Allow-Origin 设置为非
*
postMessage 接口处理跨域窗口通信问题
概述
iframe + postMessage,postMessage 允许不同的 window 页面中发送消息,一般只有 iframe 和 window.open 能获取其他页面的 window 对象(后者会造成URL 的改变,一般不用)。
一般由【被访问方】建立一个专门用于跨域通信的页面,【访问方】在一个隐藏的 iframe 中打开这个页面,双方通过
postMessage
来互相通讯,这个方案的安全性也比较好。
页面也可以通过设置 X-Frame-Options 来限制自己被其他网页通过 iframe 嵌套的场景
X-Frame-Options: DENY // 完全禁止任何网页嵌套
X-Frame-Options: SAMEORIGIN // 只允许同源网页嵌套
站点可以通过确保网站没有被嵌入到别人的站点里面,从而避免点击劫持攻击。
备注: Content-Security-Policy
CSP内容安全策略中, HTTP 响应头有一个 frame-ancestors
指令,支持这一指令的浏览器已经废弃了 X-Frame-Options
响应头。
窗口引用
要调用另一个窗口的方法或者访问另一个窗口的内容,我们应该首先拥有对其的引用。
对于弹窗,我们有两个引用:
- 从打开窗口的(opener)窗口:
window.open
—— 打开一个新的窗口,并返回对它的引用, - 从弹窗:
window.opener
—— 是从弹窗中对打开此弹窗的窗口(opener)的引用。
对于 iframe,我们可以使用以下方式访问父/子窗口:
window.frames
:一个嵌套的 window 对象的集合window.parent
和window.top
是对父窗口和顶级窗口的引用iframe.contentWindow
是<iframe>
标签内的 window 对象
但是,只有在同源的情况下,父窗口和子窗口才能通信;如果跨域,就无法拿到对方的 DOM。
postMessage
接口允许跨窗口通信,不论这两个窗口是否同源。
这个接口有两个部分。
postMessage
方法
发送方窗口需要调用接收方窗口的 postMessage
方法,即
targetWindow.postMessage(data, targetOrigin);
若 targetOrigin
不是 *
,则浏览器会检查接收方窗口 targetWindow
是否具有源 targetOrigin
。
message
事件
父窗口和子窗口都可以通过 message
事件,监听对方的消息。
为
message
事件绑定监听函数应使用addEventListener
原始事件模型(DOM0 级)window.onmessage
不起作用message
事件的参数是事件对象event
,提供以下三个属性:event.origin
:发送方窗口的源,可进行发送方的来源过滤event.source
:对发送方窗口的引用,可将结果返回给发送方event.data
:消息内容,可为任何对象(IE 只支持字符串)
WebSocket
WebSocket,本身不属于HTTP协议,所以也就没有对应的 HTTP 相关的安全问题
不过如果仅作为一个跨域方案使用,代价很大,需仔细考虑。
WebSocket 是一种通信协议,使用ws://
(非加密)和wss://
(加密)作为协议前缀。该协议不实行同源政策,只要服务器支持,就可以通过它进行跨源通信。
下面是一个例子,浏览器发出的 WebSocket 请求的头信息(摘自维基百科)。
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com
上面代码中,有一个字段是Origin
,表示该请求的请求源(origin),即发自哪个域名。
正是因为有了 Origin
这个字段,所以 WebSocket 才没有实行同源政策。
因为服务器可以根据这个字段,判断是否许可本次通信。
如果该域名在白名单内,服务器就会做出如下回应。
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat
无法限制来访域名的跨域方案
这类方案要么是仅仅适用于完全公开的、无鉴权的数据,要么就是必须配合服务端安全的限制来完成。(有一定的安全隐患,但并非不可解)
JSONP
JSONP 的实现思路是利用 <script>
标签没有跨域限制,通过 <script>
标签的 src 属性,发送带有 callback 参数的 GET 请求,服务端将接口返回数据拼凑到 callback 函数中,返回给浏览器,浏览器解析执行,从而前端拿到 callback 函数返回的数据。
该方法主要问题是,无法区分调用方,大部分使用的情况下要求服务方完全忽略 Cookie,如果涉及用户的登录鉴权信息,只能从 GET 请求的参数中获取,同时 JSONP 方案的调用方必须通过某种方案来获取用户身份信息,才能保证安全。
JSONP
“JSONP (JSON with padding)” 协议。
原理:<script>
标签没有跨域限制,可具有任何域的 src
属性。
- 特点是简单易用,没有兼容性问题,老式浏览器全部支持,服务端改造非常小
- 问题是无法区分调用方,仅支持 GET 方法
- 网页添加一个
<script>
元素,向服务器请求一个脚本,这不受同源政策限制,可以跨域请求。<script src="http://api.foo.com?callback=bar"></script>
请求的脚本网址的 callback
参数(?callback=bar
),用来告诉服务器,客户端的回调函数名称(bar
)。
服务器收到请求后,拼接一个字符串,将 JSON 数据放在函数名里面,作为字符串返回(
bar({...})
)。客户端会将服务器返回的字符串,作为代码解析,因为浏览器认为,这是
<script>
标签请求的脚本内容。
这时,客户端只要定义了bar()
函数,就能在该函数体内,拿到服务器返回的 JSON 数据。
例子:首先,网页动态插入 <script>
元素,由它向跨域网址发出请求。
function addScriptTag(src) {
const script = document.createElement('script');
script.type = 'text/javascript';
script.setAttribute('type', 'text/javascript');
script.src = src;
document.body.appendChild(script);
}
window.onload = function () {
addScriptTag('http://example.com/ip?callback=foo');
}
function foo(data) {
console.log('Your public IP address is: ' + data.ip);
};
上面代码通过动态添加 <script>
元素,向服务器 example.com
发出请求。注意,该请求的查询字符串有一个 callback
参数,用来指定回调函数的名字,这对于 JSONP 是必需的。
服务器收到这个请求以后,会将数据放在回调函数的参数位置返回。
foo({
'ip': '8.8.8.8'
});
由于 <script>
元素请求的脚本,直接作为代码运行。这时,只要浏览器定义了 foo
函数,该函数就会立即调用。作为参数的 JSON 数据被视为 JavaScript 对象,而不是字符串,因此避免了使用 JSON.parse
的步骤。
const jsonp = ({ url, params, callbackName }) => {
const generateUrl = () => {
let dataSrc = '';
for (let key of Object.keys(params)) {
dataSrc += `${key}=${params[key]}&`;
}
dataSrc += `callback=${callbackName}`
return `${url}?${dataSrc}`;
}
return new Promise((resolve, reject) => {
const scriptEle = document.createElement('script')
scriptEle.src = generateUrl()
document.body.appendChild(scriptEle)
window[callbackName] = data => {
resolve(data)
document.removeChild(scriptEle)
};
})
}
URL 传参跨域
URL 传参跨域、表单提交跨域、(ABA的方式)通过 URL 或表单 来把信息传递回访问方,回到 A 域名就可以随便访问了,可以通过 document.referrer 进行一定的限制,比 JSONP 好一些,但方案实现起来比较复杂。
使用表单
其中一种和其他服务器通信的方法是在那里提交一个 <form>
。人们将它提交到 <iframe>
,只是为了停留在当前页面,像这样:
<!-- 表单目标 -->
<iframe name="iframe"></iframe>
<!-- 表单可以由 JavaScript 动态生成并提交 -->
<form target="iframe" method="POST" action="http://another.com/…">
...
</form>
因此,即使没有网络方法,也可以向其他网站发出 GET/POST 请求,因为表单可以将数据发送到任何地方。但是由于禁止从其他网站访问 <iframe>
中的内容,因此就无法读取响应。
片段识别符
片段标识符(fragment identifier)指的是,URL 的#
号后面的部分,比如http://example.com/x.html#fragment
的#fragment
。如果只是改变片段标识符,页面不会重新刷新。
父窗口可以把信息,写入子窗口的片段标识符。
const src = originURL + '#' + data;
document.getElementById('myIFrame').src = src;
上面代码中,父窗口把所要传递的信息,写入 iframe 窗口的片段标识符。
子窗口通过监听 hashchange
事件得到通知。
window.onhashchange = checkMessage;
function checkMessage() {
const message = window.location.hash;
// ...
}
同样的,子窗口也可以改变父窗口的片段标识符。
parent.location.href = target + '#' + hash;
服务端代理跨域
服务端代理跨域(严格来说不算跨域方案),对于前端来说只是请求了一下本域名的 URL,方案显然没有安全问题。
但是会对服务器形成额外的压力,基本不会在高流量的场景下使用。
实际上一般只适用于访问公开数据,因为鉴权信息是无法携带的。
不该用的方法
window.name + iframe
window.name 属性的独特之处:name 值在不同的页面(甚至不同域名)加载后依旧存在,并且可以支持非常长的 name 值(2 MB)。
window.name 通过在 iframe(一般动态创建)中加载跨域 HTML 文件来起作用。然后,HTML 文件将传递给请求者的字符串内容赋值给 window.name。然后,请求者可以检索 window.name 值作为响应。
window.name,所有页面通用的,可以跨域,但是没有任何的安全保障,访问者和被访问者的身份都无法验证,而且有潜在冲突的风险,甚至会被窃听,绝对不可使用。
1)a.html:(domain1.com/a.html)
var proxy = function(url, callback) {
var state = 0;
var iframe = document.createElement('iframe');
// 加载跨域页面
iframe.src = url;
// onload事件会触发2次,第1次加载跨域页,并留存数据于window.name
iframe.onload = function() {
if (state === 1) {
// 第2次onload(同域proxy页)成功后,读取同域window.name中数据
callback(iframe.contentWindow.name);
destoryFrame();
} else if (state === 0) {
// 第1次onload(跨域页)成功后,切换到同域代理页面
iframe.contentWindow.location = 'http://www.domain1.com/proxy.html';
state = 1;
}
};
document.body.appendChild(iframe);
// 获取数据以后销毁这个iframe,释放内存;这也保证了安全(不被其他域frame js访问)
function destoryFrame() {
iframe.contentWindow.document.write('');
iframe.contentWindow.close();
document.body.removeChild(iframe);
}
};
// 请求跨域b页面数据
proxy('http://www.domain2.com/b.html', function(data){
alert(data);
});
2)proxy.html:(domain1.com/proxy.html)
中间代理页,与a.html同域,内容为空即可。
3)b.html:(domain2.com/b.html)
<script>
window.name = 'This is domain2 data!';
</script>