浏览器同源策略和跨域方法

Clloz · · 474次浏览 ·

前言

了解浏览器的同源策略和各种跨域方式是所有前端都必须熟练掌握的知识,因为在开发的过程中遇到跨域请求是常有的事情,包括我们自己 mock 数据的时候也可能遇到跨域的问题,如果不能理解同源策略那么每次遇到跨域都可能不能快速解决。

同源策略

同源策略是浏览器的策略,会在请求从服务返回的时候检查响应头中的 Access-Control-Allow-Origin 和 请求头中的 origin 是否匹配,如果不匹配则报错。

origin

我们使用浏览器浏览网页的时候,大多数情况都是通过 http 请求去访问对应主机( host )上的资源( resource ),一般同一个主机同一个端口同一个协议就会被认为是一个源,一般我们会说同协议同域名同端口的请求浏览器会认为是同源的请求。可能很多人刚看到这个策略的时候跟我有一样的想法,为什么是同一个域名而不是同一个 IP 呢?在 MDN英文文档里面写的是 host 也就是主机,要更好的理解什么是源我们要从服务器的角度来理解。我们的服务器上用来处理 http 请求的一般是 web 服务器,比如 apapcenginx 等,在 web 服务器的配置中我们会配置我们的网站域名和根目录一般默认绑定到 80 端口,比如 /var/www/htmlweb 服务器接收到 http 请求对应目录的资源的时候就会去我们绑定的目录搜索对应的资源。但是一个 web 服务器下面可以绑定多个主机,我们可以用虚拟主机来做域名和目录的映射,如下

<VirtualHost 127.0.0.1:80>
    ServerAdmin yourname@domain.com
    DocumentRoot "E:/server110.com/wordpress-latest"
    ServerName server110.com
    ServerAlias www.server110.com
    ErrorLog "logs/wplatest.com-error.log"
    CustomLog "logs/server110.com-access.log" combined
</VirtualHost>
<VirtualHost 127.0.0.2:80>
    ServerAdmin yourname@domain.com
    DocumentRoot "E:/server110.com/wordpress-2.9.2"
    ServerName server110.com
    ServerAlias www.server110.com
    ErrorLog "logs/server110.com-error.log"
    CustomLog "logs/server110.com-access.log" combined
</VirtualHost>

web 服务器接受到请求的时候会看看是请求头中的 host 参数,在根据配置文件到对应的目录寻找资源。正因为这个原因,同源的定义就是 same-host,同一个主机。而 web 配置目录的方法不止虚拟主机一种方式,还可以利用不同的端口进行映射,比如网站 a 的目录 /var/www/a 映射到 80 端口,而另一个网站 b 的目录 /var/www/b 的目录映射到 8080 端口,配置方法就是把上面的配置文件中的端口改成自己需要的。我们在往上购买的虚拟主机,大部分都是通过这种办法来配置多个网站的,也就是说这些网站的 IP 地址都是相同的,但是他们的拥有者不同,这也就是浏览器要对源之间的互动进行限制的原因。最后就是 httphttps,这两者如果不同,那么通信的过程都是不相同的,浏览器自然是不允许的,而且一般网站配置了 https 那么所有的资源请求都会是 https ,一般不会出现混用。

根据上面的规则我们举个是否同源的例子,以我的域名 https://www.clloz.com 为例,我这个域名解析到了我阿里云主机的 ipweb服务器根据配置文件可以知道该 host 的请求去对应的文件夹取资源,比如有用户请求 https://www.clloz.com/index.html, 那么服务器就会返回这个页面。如果这个 index.html 中的脚本发送如下请求,我们可以判断是否同源:

URL 结果 原因
https://www.clloz.com/study/test.html 成功 只有路径不同
http://www.clloz.com/test.json 失败 协议不同
https://www.clloz.com:8080 失败 端口不同
https://test.clloz.com/test.json 失败 域名不同
https://clloz.com/test.json 失败 域名不同

|URL|结果|原因|

主机和域名的区别:一般来说我们申请一个域名是一个二级域名比如 clloz.com(也有认为顶级域和二级域之间还有一级域,阿里云就是这样的方式),顶级域名就是就是域名最后的那个部分,比如我们常见的 .com .cn .org .edu 等,顶级域名前面一个就是二级域名比如我的域名中的 clloz,以此类推。当我们购买了一个域名以后,我们可以为其添加主机记录进行解析,比如我可以添加一个 www 的主机记录解析到我的服务器 ip,也可以添加一个 test 主机记录解析到 http://www.clloz.com:8080,这些添加了主机记录的能访问到服务器上具体文件的域名就称为 host 主机名,在我们发送请求的时候,二者可以混用。

为什么要有同源策略

其实上面解释源的时候就已经能够明白为什么浏览器要使用同源策略了,我们来看看文档和历史。MDN 的解释如下 The same-origin policy is a critical security mechanism that restricts how a document or script loaded from one origin can interact with a resource from another origin. It helps isolate potentially malicious documents, reducing possible attack vectors. 大概意思就是同源策略限制了一个源上的文档或者脚本和另一个源上的资源互动的方式。主要的目的是为用户的安全,隔离潜在恶意文件的重要安全机制。

同源策略最早有网景在1995年引入,现在所有的浏览器都实行这个策略。最早同源是为了针对客户端上保存状态的 cookie。为了解决 http 协议无状态带来的用户状态无法保存的情况引入了 cookie,如果不同源的网站能够共享 cookie 会带来非常严重的安全问题,比如我们登录了某个支付网站或者网上银行没有登出,这时候点进了一个危险的网页,这个网页可以利用我们的 cookie 去登录,这是非常危险的,所以最早的同源策略就是针对这样的情况,每个源之间的 cookie 都是独立的(父域子域可以共享,后面会说)。但是随着 web 的发展,网站提供的服务越来越多,越来越复杂,也出现了更多的攻击手段,所以为了安全,浏览器不得不提升同源策略覆盖的范围。

安全和灵活的矛盾

同源策略确实提高了网站的安全性,让攻击者攻击网站的难度提高,用户也不会因为误点恶意链接而遭受损失,但是对于开发者来说,多个子系统之间的互动是必要的,浏览器一刀切的同源策略有时候会带来很大的麻烦,从这方面看安全性和交互的灵活性是一对矛盾。所以浏览器在同源策略的制定上还是对交互做了一定的妥协,比如我们都知道的直接用链接嵌入其他源中的 cssjsimage,父域和子域之间可以共享 cookie等。

跨源交互细节

为了解决跨域导致的跨源交互不便,浏览器制定了跨源交互的规则,通常情况下:
1. 允许跨源写( cross-origin write ),比如我们可以直接在脚本中发出 GET 请求直接跳转页面,以及在页面上直接用 submit 按钮提交表单并跳转。经过测试,用 XMLHttpRequest 对象给后台发送文件也不会被同源策略拦截。
2. 允许跨域资源嵌入:
3. 不允许跨源读取资源

跨域嵌入的方式:

  • <script src="..."></script> 标签嵌入跨域脚本
  • <link rel="stylesheet" href="..."> 标签嵌入CSS
  • <img>嵌入图片
  • <video><audio> 嵌入多媒体资源
  • <object>, <embed><applet>的插件。
  • @font-face 引入的字体。一些浏览器允许跨域字体( cross-origin fonts),一些需要同源字体(same-origin fonts
  • <frame><iframe> 载入的任何资源。站点可以使用 X-Frame-Options 消息头来阻止这种形式的跨域交互。

浏览器的具体同源策略没有找到标准的文档,不过大致的思路就是我们可以向不同源的发送信息,不可以从不同的源接收信息,我把上面的内容和查到的规则整理如下:

  1. 对于嵌入到页面的 ifram (如果 X-Frame-Options 允许),无法访问 iframe 的文档,也就是不能操作 DOM 对象。
  2. css 文件可以通过 link 标签嵌入或者 @import 方式引入,可能需要 Content-type 支持。
  3. form 表单,action 可以使用跨源 URL,利用表单的提交可以将表单中的数据写入跨源目标。
  4. 可以用 img 标签嵌入图像,但是无法读取图像的数据(例如 canvas 使用 JavaScript 将跨源图像加载到元素中),如果需要读取图像,需要为图片所在服务器开启 cors,并且为图片加上属性 crossOrigin=anonymous,其实是和开启 corsajax 请求没有区别。CORS_enabled_image
  5. 可以使用 videoaudio 元素嵌入跨源视频和音频。
  6. 可以嵌入跨源脚本; 但是,可能会阻止对某些API的访问,例如跨源的 ajax 或者 fetch 请求。根据我的测试,用 ajax 对跨源接口发送文件并不会触发同源策略,能够成功发送。
  7. 存储在浏览器中的数据,如 localStorageIndexedDB,以源进行分割。每个源都拥有自己单独的存储空间,一个源中的 Javascript 脚本不能对属于其它源的数据进行读写操作。
  8. 一个页面可以为本域和任何父域设置 cookie,只要是父域不是公共后缀( public suffix )即可。

对于嵌入图片的读取可以测试如下代码:

<!-- 嵌入一张跨域的google logo -->
<img crossorigin="anonymous" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png" alt="">
<script>
    function main() {
    var img = document.querySelector('img');
    img.onload = function () {
        var canvas = document.createElement("canvas");
        canvas.width = img.width;
        canvas.height = img.height;

        // Copy the image contents to the canvas
        var ctx = canvas.getContext("2d");
        ctx.drawImage(img, 0, 0);

        var dataURL = canvas.toDataURL("image/png");

        var data = dataURL.replace(/^data:image\/(png|jpg);base64,/, "");
        console.log(data);
    }
}
main()
</script>

执行这个 HTML 浏览器会告诉你跨域了,解决的方式就是给图片加上 crossorigin="anonymous" 属性,并且图片所在服务器要开启 cors

对于 ajax 发送的文件,大家可以用 nodejs 写一个简单的服务端,前端用 formdata 发送即可,并不会被浏览器拦击。

跨域方法

同源策略我们已经掌握,但是浏览器的这种一刀切的做法有时候会为开发带来不便。特别是在有多个子系统的网站中,需要跨域通信的情况肯定会多,我们会把各个子系统布置在不同的主机上,所以如何饶考同源策略进行跨域请求,是每个前端必须熟练掌握的。

JSONP

JSONP就是利用同源策略中允许跨域资源嵌入的这条规定来进行跨域请求的,script 标签请求的脚本会立即执行,那么只要请求中传给后端一个函数名,后端将函数名和数据拼接成执行函数的字符串返回给前端,浏览器解析的时候就相当于直接执行这个带参数的函数。
前端代码:

<body>
    <script>
        function success(data) {
            console.log(data);
        }
    </script>

    <script src="http://localhost:8080/test?callback=success"></script>
</body>

后端代码:

var http = require('http')
var url = require('url')

var routes = {
    '/test': function (req, res) {
        var cb_str = url.parse(req.url, true).search
        res.writeHead(200, 'Ok')
        var cb = cb_str.split('=')[1]
        console.log(cb)
        res.write(cb + `({result: "success"})`)
        res.end()
    }
}

var server = http.createServer(function (req, res) {
    var pathObj = url.parse(req.url, true)
    var handleFn = routes[pathObj.pathname]
    if (handleFn) {
        console.log(pathObj)
        handleFn(req, res)
    }
})

server.listen(8080)
console.log('server on 8080...')

前端嵌入的 script 标签在请求的时候带上了函数名 success 作为请求参数,后端接收到请求后将前端需要的数据 {result: "success"} 连带函数名拼接成 success({result: "success"}) 返回给浏览器,浏览器会直接将返回的字符串当作 js 执行,由于我们前面已经定义了 success 函数,所以这段代码会直接给 success 函数带上参数执行,这样就实现了跨域请求。

JSONP 只能发送 GET 请求

利用 form 提交跨域请求

由于 form 表单的功能是把数据发送给对应 action,所以并没有被同源策略限制,所以我们可以用在脚本中创建 form 并提交的方法来和跨域接口进行通信,用这种方法我们可以发送 GETPOST 请求,但是我们没法接收服务器返回的数据,不过可以利用设置 formtarget 到一个空的 iframe 并监听 iframeload 事件来确定请求是否发送成功。

CORS

跨域资源共享(CORS) 是一种机制,它使用额外的 HTTP 头来告诉浏览器 让运行在一个 origin(domain) 上的 Web应用被准许访问来自不同源服务器上的指定的资源。

CORS 需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于 IE10

整个 CORS 通信过程,都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS 通信与同源的AJAX通信没有差别,代码完全一样。浏览器一旦发现AJAX 请求跨源,就会自动添加一些附加的头信息,有时还会多出一次附加的请求,但用户不会有感觉。

因此,实现 CORS 通信的关键是服务器。只要服务器实现了 CORS 接口,就可以跨源通信。

浏览器将 CORS 请求分成两类:简单请求( simple request )和非简单请求( not-so-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
简单请求

对于简单请求,前端什么都不需要做,浏览器会自动在我们的请求头中加一个字段 origin 向后端说明我们的源,服务器根据这个字段来决定是否同意该请求,如果 Origin 指定的源,不在许可范围内,服务器会返回一个正常的 HTTP 回应。浏览器发现,这个回应的头信息没有包含 Access-Control-Allow-Origin 字段,就知道出错了,从而抛出一个错误,被 XMLHttpRequestonerror 回调函数捕获。注意,这种错误无法通过状态码识别,因为HTTP回应的状态码有可能是 200

如果服务器同意该次跨域请求,那么在响应头中会多出以下字段

  1. Access-Control-Allow-Origin :指定了允许访问该资源的外域 URI。对于不需要携带身份凭证的请求,服务器可以指定该字段的值为通配符,表示允许来自所有域的请求。
  2. Access-Control-Allow-Credentials : 该字段可选。它的值是一个布尔值,表示是否允许发送 Cookie。默认情况下,Cookie 不包括在 CORS 请求之中。设为 true,即表示服务器明确许可, Cookie 可以包含在请求中,一起发给服务器。这个值也只能设为 true,如果服务器不要浏览器发送 Cookie,删除该字段即可。该字段为 true 的时候,Access-Control-Allow-Origin 必须为一个具体的值,不能设为通配符,并且需要前端配合设置 xhr.withCredentials = true;
  3. Access-Control-Expose-Headers: 该字段可选。CORS 请求时,XMLHttpRequest 对象的 getResponseHeader() 方法只能拿到6个基本字段:Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma。如果想拿到其他字段,就必须在Access-Control-Expose-Headers里面指定。如 Access-Control-Expose-Headers: X-My-Custom-Header, X-Another-Custom-Header

简单请求的前后端示例代码如下:

//前端请求
document.cookie = 'name=clloz';
var xhr = new XMLHttpRequest()
xhr.open('get', 'http://localhost:8080/test', true)
xhr.withCredentials = true; //请求想要发送cookie必须设置withCreadentials
xhr.onload = function () {
    console.log(xhr.responseText);
}
xhr.send();

//后端代码
var http = require('http')
var url = require('url')
var querystring = require('querystring');
var util = require('util');

var routes = {
    '/test': function (req, res) {
        console.log(req.method)
        if (req.method === 'GET') {
            console.log(req.headers.cookie)
            res.setHeader('Access-Control-Allow-Origin', 'http://localhost:8081')
            res.setHeader('Access-Control-Allow-Credentials', true) //允许前端发送cookie
            res.writeHead(200, 'Ok')
            res.write(`success`)
            res.end()
        }
    }
}

var server = http.createServer(function (req, res) {
    var pathObj = url.parse(req.url, true)
    var handleFn = routes[pathObj.pathname]
    if (handleFn) {
        handleFn(req, res)
    }
})

server.listen(8080)
console.log('server on 8080...')
非简单请求

非简单请求是那种对服务器有特殊要求的请求,比如请求方法是 PUTDELETE,或者 Content-Type 字段的类型是 application/json

非简单请求的 CORS请求,会在正式通信之前,增加一次 HTTP 查询请求,称为”预检”请求( preflight )。浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些 HTTP 方法和头信息字段。只有得到肯定答复,浏览器才会发出正式的 XMLHttpRequest 请求,否则就报错。

“预检”请求用的请求方法是 OPTIONS,表示这个请求是用来询问的。头信息里面,关键字段是 Origin,表示请求来自哪个源。除了 Origin 字段,”预检”请求的头信息包括两个特殊字段。

  1. Access-Control-Request-Method:该字段是必须的,用来列出浏览器的CORS请求会用到哪些HTTP方法
  2. Access-Control-Request-Headers:该字段是一个逗号分隔的字符串,指定浏览器CORS请求会额外发送的头信息字段

服务器收到”预检”请求以后,检查了OriginAccess-Control-Request-MethodAccess-Control-Request-Headers 字段以后,确认允许跨源请求,就可以做出回应。如果服务器否定了”预检”请求,会返回一个正常的 HTTP 回应,但是没有任何 CORS 相关的头信息字段。这时,浏览器就会认定,服务器不同意预检请求,因此触发一个错误,被 XMLHttpRequest 对象的 onerror 回调函数捕获。控制台会打印出如下的报错信息。通过的预检请求,服务器响应头中会有如下字段:

  1. Access-Control-Allow-Methods:该字段必需,它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法。注意,返回的是所有支持的方法,而不单是浏览器请求的那个方法。这是为了避免多次”预检”请求。
  2. Access-Control-Allow-Headers:如果浏览器请求包括 Access-Control-Request-Headers 字段,则 Access-Control-Allow-Headers 字段是必需的。它也是一个逗号分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在”预检”中请求的字段。
  3. Access-Control-Allow-Credentials: 和简单请求中相同。
  4. Access-Control-Max-Age : 该字段可选,用来指定本次预检请求的有效期,单位为秒。

如果服务器通过了预检请求,在有效期内的正常的CORS请求,就都跟简单请求一样,会有一个 Origin 头信息字段。服务器的回应,也都会有一个 Access-Control-Allow-Origin 头信息字段。

非简单请求的示例代码如下:

//前端代码
var json = {
    name: 'clloz',
    age: '27',
    sex: 'male'
}
document.cookie = 'name=clloz';
var xhr = new XMLHttpRequest()
xhr.open('post', 'http://localhost:8080/test', true)
xhr.setRequestHeader('content-type', 'application/json')
xhr.withCredentials = true;
xhr.onload = function () {
    console.log(xhr.responseText);
}
xhr.send(json);

//后端代码
var http = require('http')
var url = require('url')
var querystring = require('querystring');
var util = require('util');

var routes = {
    '/test': function (req, res) {
        console.log(req.method)
        if (req.method === 'GET') {
            console.log(req.headers.cookie)
            res.setHeader('Access-Control-Allow-Origin', 'http://localhost:8081')
            res.setHeader('Access-Control-Allow-Credentials', true)
            res.writeHead(200, 'Ok')
            res.write(`success`)
            res.end()
        } else {
            var post = '';
            req.on('data', function (chunk) {
                post += chunk;
            });
            req.on('end', function () {
                res.setHeader('Access-Control-Allow-Origin', 'http://localhost:8081')
                res.setHeader('Access-Control-Allow-Credentials', true)
                res.setHeader('Access-Control-Request-Method', 'PUT,POST,GET,DELETE,OPTIONS')
                res.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, t')
                res.end('success');
            });
        }
    }
}

var server = http.createServer(function (req, res) {
    var pathObj = url.parse(req.url, true)
    var handleFn = routes[pathObj.pathname]
    if (handleFn) {
        handleFn(req, res)
    }
})

server.listen(8080)
console.log('server on 8080...')

代理

同源策略只是浏览器的限制,对于服务器上的 web 服务器是没有影响的,所以当我们需要请求跨域资源的时候,可以先向同源的 web 服务器提交请求,由 web 服务器再向对应的服务器请求到数据后返回给前端。

postMessage

window.postMessage() 方法可以安全地实现跨源通信。window.postMessage() 方法被调用时,会在所有页面脚本执行完毕之后(e.g., 在该方法之后设置的事件、之前设置的timeout 事件,etc.)向目标窗口派发一个 MessageEvent 消息。 该 MessageEvent消息有四个属性需要注意: message 属性表示该 message 的类型; data 属性为 window.postMessage 的第一个参数;origin 属性表示调用window.postMessage() 方法时调用页面的当前状态; source 属性记录调用 window.postMessage() 方法的窗口信息。

http-server 启动两个服务来测试,分别为 localhost:8080localhost:8081:

<!-- localhost:8080 -->
<body>
    <button>btn</button>
    <iframe name="myframe" src="http://localhost:8081" frameborder="1"></iframe>
    <script>
        window.addEventListener('message', function (e) {
    if (e.origin === 'http://localhost:8081') {
        console.log(e.data)
    }

})

var iframe = window.frames['myframe']

var btn = document.querySelector('button')
btn.addEventListener('click', function () {
    iframe.postMessage('this is 8080', 'http://localhost:8081')
})
    </script>
</body>


<!-- localhost:8081 -->
<body>
    this is frame!
    <script>
        window.addEventListener("message", function(e) {
            if (e.origin === "http://localhost:8080") {
                console.log(e.data);
                e.source.postMessage("this is 8081", e.origin);
            }
        });
    </script>
</body>

点击第一个页面的按钮,会向第二页面发送消息,第二个页面收到消息会立即返回。

window.domain

2.document.domain
这种方式只适合主域名相同,但子域名不同的iframe跨域。
比如主域名是http://clloz.com,子域名是http://test.crossdomain.com,这种情况下给两个页面指定一下document.domaindocument.domain = clloz.com就可以访问各自的window对象了。

参考文章

same-origin policy
浏览器同源策略
CORS-MDN
CORS-阮一峰
不要再问我跨域的问题了


Clloz

人生をやり直す

发表评论

电子邮件地址不会被公开。 必填项已用*标注

我不是机器人*

EA PLAYER &

历史记录 [ 注意:部分数据仅限于当前浏览器 ]清空

      00:00/00:00