学习链接

阮一峰:同源限制(👍👍👍)

阮一峰:CORS 通信(👍👍👍)

鉴定一下网络热门面试题:讲讲JS如何跨域(👍👍👍)

A Clarification About the Same-Origin Policy

同站 和 同源 你理解清楚了么?

跨域的请求在服务端会不会真正执行?

现代 JavaScript 教程:跨窗口通信

现代JavaScript 教程:资源加载:onload,onerror

现代 JavaScript 教程:Fetch:跨源请求

浏览器跨域问题与服务器中的 CORS

九种跨域方式实现原理(完整版)

(整理的还是有些冗余,再慢慢精进吧)

跨域问题

跨域问题其实就是浏览器的同源策略造成的。

同源策略

同源:具有相同的协议、域名和端口号的两个 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-urlencodedmultipart/form-datatext/plain

凡是不同时满足上面两个条件,就属于非简单请求。

一句话,简单请求就是简单的 HTTP 方法与简单的 HTTP 头信息的结合。

这样划分的原因是,表单在历史上一直可以跨域发出请求。

简单请求就是表单请求,浏览器沿袭了传统的处理方式,不把行为复杂化,否则开发者可能转而使用表单,规避 CORS 的限制。

对于非简单请求,浏览器会采用新的处理方式。

(1)简单请求过程:

对于简单请求,浏览器直接发出 CORS 请求。

自动在头信息之中,添加一个 Origin 字段,用来说明本次请求来自哪个源(协议 + 域名 + 端口)。

服务器根据这个值,决定是否同意这次请求。

如果 Origin 指定的源,不在许可范围内,服务器会返回一个正常的 HTTP 回应。

浏览器发现,这个回应的头信息没有包含Access-Control-Allow-Origin字段,就知道出错了,从而抛出一个错误,被 XMLHttpRequestonerror 回调函数捕获。

注意,这种错误无法通过状态码识别,因为 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-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma
    如果想拿到其他字段,就必须在 Access-Control-Expose-Headers 里面指定。

    请注意:列表中没有 Content-Length header!

该 header 包含完整的响应长度。因此,如果我们正在下载某些内容,并希望跟踪进度百分比,则需要额外的权限才能访问该 header。

(2)非简单请求过程

“预检” 请求

非简单请求的 CORS 请求,会在正式通信之前,增加一次 HTTP 查询请求,称为“预检”请求(preflight)。

浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些 HTTP 方法和头信息字段。只有得到肯定答复,浏览器才会发出正式的请求,否则就报错。

这是为了防止这些新增的请求,对传统的没有 CORS 支持的服务器形成压力,给服务器一个提前拒绝的机会,这样可以防止服务器收到大量DELETEPUT请求,这些传统的表单不可能跨域发出的请求。

预检请求使用 OPTIONS 方法,它没有 body,但是有三个 header(包括 Origin 字段)

  • Access-Control-Request-Method
    该字段是必须的。
    列出浏览器的 CORS 请求会用到哪些非简单 HTTP 请求的方法。

  • Access-Control-Request-Headers
    该字段可选。
    一个逗号分隔的字符串,指定浏览器 CORS 请求会额外发送的头信息字段。

预检请求的回应

服务器收到“预检”请求以后,检查了OriginAccess-Control-Request-MethodAccess-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

  1. // 原生 xml 的设置方式
  2. const xhr = new XMLHttpRequest();
  3. xhr.withCredentials = true;
  4. // axios 设置方式
  5. 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 嵌套的场景

  1. X-Frame-Options: DENY // 完全禁止任何网页嵌套
  2. 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.parentwindow.top 是对父窗口和顶级窗口的引用
  • iframe.contentWindow<iframe> 标签内的 window 对象

但是,只有在同源的情况下,父窗口和子窗口才能通信;如果跨域,就无法拿到对方的 DOM。

postMessage 接口允许跨窗口通信,不论这两个窗口是否同源。

这个接口有两个部分。

postMessage 方法

发送方窗口需要调用接收方窗口postMessage 方法,即

  1. 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 请求的头信息(摘自维基百科)。

  1. GET /chat HTTP/1.1
  2. Host: server.example.com
  3. Upgrade: websocket
  4. Connection: Upgrade
  5. Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
  6. Sec-WebSocket-Protocol: chat, superchat
  7. Sec-WebSocket-Version: 13
  8. Origin: http://example.com

上面代码中,有一个字段是Origin,表示该请求的请求源(origin),即发自哪个域名。

正是因为有了 Origin 这个字段,所以 WebSocket 才没有实行同源政策

因为服务器可以根据这个字段,判断是否许可本次通信

如果该域名在白名单内,服务器就会做出如下回应。

  1. HTTP/1.1 101 Switching Protocols
  2. Upgrade: websocket
  3. Connection: Upgrade
  4. Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
  5. Sec-WebSocket-Protocol: chat

无法限制来访域名的跨域方案

这类方案要么是仅仅适用于完全公开的、无鉴权的数据,要么就是必须配合服务端安全的限制来完成。(有一定的安全隐患,但并非不可解)

JSONP

JSONP 的实现思路是利用 <script> 标签没有跨域限制,通过 <script> 标签的 src 属性,发送带有 callback 参数的 GET 请求,服务端将接口返回数据拼凑到 callback 函数中,返回给浏览器,浏览器解析执行,从而前端拿到 callback 函数返回的数据。

该方法主要问题是,无法区分调用方,大部分使用的情况下要求服务方完全忽略 Cookie,如果涉及用户的登录鉴权信息,只能从 GET 请求的参数中获取,同时 JSONP 方案的调用方必须通过某种方案来获取用户身份信息,才能保证安全。

JSONP

“JSONP (JSON with padding)” 协议。

原理:<script> 标签没有跨域限制,可具有任何域的 src 属性。

  • 特点是简单易用,没有兼容性问题,老式浏览器全部支持,服务端改造非常小
  • 问题是无法区分调用方,仅支持 GET 方法
  1. 网页添加一个 <script> 元素,向服务器请求一个脚本,这不受同源政策限制,可以跨域请求。
    1. <script src="http://api.foo.com?callback=bar"></script>


请求的脚本网址的 callback 参数(?callback=bar),用来告诉服务器,客户端的回调函数名称(bar)。

  1. 服务器收到请求后,拼接一个字符串,将 JSON 数据放在函数名里面,作为字符串返回(bar({...}))。

  2. 客户端会将服务器返回的字符串,作为代码解析,因为浏览器认为,这是 <script> 标签请求的脚本内容。
    这时,客户端只要定义了bar()函数,就能在该函数体内,拿到服务器返回的 JSON 数据。

例子:首先,网页动态插入 <script> 元素,由它向跨域网址发出请求。

  1. function addScriptTag(src) {
  2. const script = document.createElement('script');
  3. script.type = 'text/javascript';
  4. script.setAttribute('type', 'text/javascript');
  5. script.src = src;
  6. document.body.appendChild(script);
  7. }
  8. window.onload = function () {
  9. addScriptTag('http://example.com/ip?callback=foo');
  10. }
  11. function foo(data) {
  12. console.log('Your public IP address is: ' + data.ip);
  13. };

上面代码通过动态添加 <script> 元素,向服务器 example.com 发出请求。注意,该请求的查询字符串有一个 callback 参数,用来指定回调函数的名字,这对于 JSONP 是必需的。

服务器收到这个请求以后,会将数据放在回调函数的参数位置返回。

  1. foo({
  2. 'ip': '8.8.8.8'
  3. });

由于 <script> 元素请求的脚本,直接作为代码运行。这时,只要浏览器定义了 foo 函数,该函数就会立即调用。作为参数的 JSON 数据被视为 JavaScript 对象,而不是字符串,因此避免了使用 JSON.parse 的步骤。

  1. const jsonp = ({ url, params, callbackName }) => {
  2. const generateUrl = () => {
  3. let dataSrc = '';
  4. for (let key of Object.keys(params)) {
  5. dataSrc += `${key}=${params[key]}&`;
  6. }
  7. dataSrc += `callback=${callbackName}`
  8. return `${url}?${dataSrc}`;
  9. }
  10. return new Promise((resolve, reject) => {
  11. const scriptEle = document.createElement('script')
  12. scriptEle.src = generateUrl()
  13. document.body.appendChild(scriptEle)
  14. window[callbackName] = data => {
  15. resolve(data)
  16. document.removeChild(scriptEle)
  17. };
  18. })
  19. }

URL 传参跨域

URL 传参跨域、表单提交跨域、(ABA的方式)通过 URL 或表单 来把信息传递回访问方,回到 A 域名就可以随便访问了,可以通过 document.referrer 进行一定的限制,比 JSONP 好一些,但方案实现起来比较复杂。

使用表单

其中一种和其他服务器通信的方法是在那里提交一个 <form>。人们将它提交到 <iframe>,只是为了停留在当前页面,像这样:

  1. <!-- 表单目标 -->
  2. <iframe name="iframe"></iframe>
  3. <!-- 表单可以由 JavaScript 动态生成并提交 -->
  4. <form target="iframe" method="POST" action="http://another.com/…">
  5. ...
  6. </form>

因此,即使没有网络方法,也可以向其他网站发出 GET/POST 请求,因为表单可以将数据发送到任何地方。但是由于禁止从其他网站访问 <iframe> 中的内容,因此就无法读取响应。

片段识别符

片段标识符(fragment identifier)指的是,URL 的#号后面的部分,比如http://example.com/x.html#fragment#fragment。如果只是改变片段标识符,页面不会重新刷新。

父窗口可以把信息,写入子窗口的片段标识符。

  1. const src = originURL + '#' + data;
  2. 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>