浏览器基础知识-浏览器同源策略

0 阅读16分钟

什么是同源策略?(重点)

概念

同源策略,限制了从同一个源加载的文档或脚本如何与另一个源的资源进行交互。这是浏览器的一个用于隔离潜在恶意文件的重要的安全机制。

同源指的是:协议(protocol)、域名(domain)、端口号(port)必须一致。

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

同源政策主要限制了三个方面

  1. 当前域下的 JS 脚本不能够访问其他域下的 Cookie、LocalStorage 和 IndexDB。
  2. 当前域下的 JS 脚本不能够操作访问操作其他域下的 DOM。
  3. 当前域下 AJAX 无法发送跨域请求。

同源政策的目的

主要是为了保证用户的信息安全,它只是对 JS 脚本的一种限制,并不是对浏览器的限制,对于一般的 img、或者 script 脚本请求都不会有跨域的限制,这是因为这些操作都不会通过响应结果来进行可能出现安全问题的操作。

如何解决跨域问题?(重点)

CORS

跨域资源共享(CORS) 是一种机制,它使用额外的 HTTP 头来告诉浏览器,让运行在一个 Origin(Domain)上的 Web 应用被准许访问来自不同源服务器上的指定的资源。当一个资源从与该资源本身所在的服务器不同的协议、域或端口请求一个资源时,资源会发起一个跨域 HTTP 请求。

CORS 需要浏览器和服务器同时支持,整个 CORS 过程都是浏览器完成的,无需用户参与,因此实现 CORS 的关键就是服务器,只要服务器实现了 CORS 请求,就可以跨源通信了。

浏览器将 CORS 分为简单请求和非简单请求。

简单请求

它不会触发 CORS 预检请求。

若该请求满足以下两个条件,就可以看作是简单请求。

  • 请求方法是以下三种方法之一
    • HEAD
    • GET
    • POST
  • 手动设置的 HTTP 的头信息不超出以下字段:
    • Accept
    • Accept-Language
    • Content-Language
    • Last-Event-ID/Range:Last-Event-ID 是 EventSource API 在自动重连时发送的请求头,不常用。
    • Content-Type 只限于三个值:application/x-www-form-urlencoded、multipart/form-data、text/plain(因为这三种类型是 HTML 表单元素 <form> 原生支持提交的。如果你设置为 application/json,即使它看起来像个普通的头,但因为传统的 HTML 表单无法发送 JSON,浏览器认为这是一个"不安全"的扩展行为,会触发预检)
    • 像 DPR、Downlink、Save-Data、Viewport-Width、Width 等也可以被接受,但它们不常见。

简单请求过程

对于简单请求,浏览器会直接发出 CORS 请求,它会在请求的头信息中增加一个 Origin 字段,该字段用来说明本次请求来自哪个源(协议+域名+端口),服务器会根据这个值来决定是否同意这次请求。如果 Origin 指定的域名在许可范围之内,服务器返回的响应就会多出以下信息头:

Access-Control-Allow-Origin: http://api.bob.com  // 和 Origin 一致
Access-Control-Allow-Credentials: true   // 表示是否允许发送 Cookie
Access-Control-Expose-Headers: FooBar   // 指定返回其他字段的值
Content-Type: text/html; charset=utf-8   // 表示文档类型

如果 Origin 指定的域名不在许可范围之内,服务器会返回一个正常的 HTTP 回应,浏览器发现没有上面的 Access-Control-Allow-Origin 头部信息,就知道出错了。这个错误无法通过状态码识别,因为返回的状态码可能是 200。

在简单请求中,在服务器内,至少需要设置字段:Access-Control-Allow-Origin

非简单请求

不满足简单请求的条件,就属于非简单请求了。

非简单请求是对服务器有特殊要求的请求,比如请求方法为 DELETE 或者 PUT 等。

非简单请求的 CORS 请求会在正式通信之前进行一次 HTTP 查询请求,称为预检请求。浏览器会询问服务器,当前所在的网页是否在服务器允许访问的范围内,以及可以使用哪些 HTTP 请求方式和头信息字段,只有得到肯定的回复,才会进行正式的 HTTP 请求,否则就会报错。

预检请求使用的请求方法是 OPTIONS,表示这个请求是来询问的。它的头信息中的关键字段是 Origin,表示请求来自哪个源。除此之外,头信息中还包括两个字段:

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

服务器在收到浏览器的预检请求之后,会根据头信息的三个字段来进行判断,如果返回的头信息在中有 Access-Control-Allow-Origin 这个字段就是允许跨域请求,如果没有,就是不同意这个预检请求,就会报错。

服务器回应的 CORS 的字段如下:

Access-Control-Allow-Origin: http://xxx.com  // 允许跨域的源地址
Access-Control-Allow-Methods: GET, POST, PUT // 服务器支持的所有跨域请求的方法
Access-Control-Allow-Headers: X-Custom-Header  // 服务器支持的所有头信息字段
Access-Control-Allow-Credentials: true   // 表示是否允许发送 Cookie
Access-Control-Max-Age: 1728000  // 用来指定本次预检请求的有效期,单位为秒

只要服务器通过了预检请求,在以后每次的 CORS 请求都会自带一个 Origin 头信息字段。服务器的回应,也都会有一个 Access-Control-Allow-Origin 头信息字段。

在非简单请求中,至少需要设置以下字段:

  • Access-Control-Allow-Origin
  • Access-Control-Allow-Methods
  • Access-Control-Allow-Headers

减少 OPTIONS 请求次数

OPTIONS 请求次数过多就会损耗页面加载的性能,降低用户体验度,所以尽量要减少 OPTIONS 请求次数。可以后端在请求的返回头部添加 Access-Control-Max-Age:number,它表示预检请求的返回结果可以被缓存多久,单位是秒。该字段只对完全一样的 URL 的缓存设置生效,所以设置了缓存时间,在这个时间范围内,再次发送请求就不需要进行预检请求了。

CORS 中 Cookie 相关问题

在 CORS 请求中,如果想要传递 Cookie,就要满足以下三个条件:

  1. 在请求中设置 withCredentials 默认情况下在跨域请求,浏览器是不带 Cookie 的,但是我们可以通过设置 withCredentials 来进行传递 Cookie。
// 原生 xml 的设置方式
var xhr = new XMLHttpRequest();
xhr.withCredentials = true;

// axios 设置方式
axios.defaults.withCredentials = true;
  1. Access-Control-Allow-Credentials 设置为 true。

  2. Access-Control-Allow-Origin 设置为非 * 即必须是具体的域名,因为 * 代表任何网站都可以访问这个资源,如果允许在这种情况下还发送Cookie,会导致严重的安全漏洞(恶意网站可以轻易获取用户的登录状态、用户的会话可能被窃取)。

JSONP

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

浏览器之所以会解析执行 callback,是因为浏览器加载 script 脚本后,会自动执行获取到的 JS 代码,而服务器返回的就是合法的 JS 代码。

缺点

  1. 具有局限性, 仅支持 GET 方法,因为 script 标签只能 GET。
  2. 不安全,可能会遭受 XSS 攻击。因为浏览器无条件信任并执行从服务器返回的任何 JS 代码。
  3. 需要服务器配合,服务器必须支持 JSONP 格式。

原生 JS 实现

<script>
    var script = document.createElement('script');
    script.type = 'text/javascript';
    // 传参一个回调函数名给后端,方便后端返回时执行这个在前端定义的回调函数
    script.src = 'http://www.domain2.com:8080/login?user=admin&callback=jsonpCallback';
    document.head.appendChild(script);
    // 回调执行函数
    function jsonpCallback(res) {
        alert(JSON.stringify(res));
    }
 </script>

服务端返回如下(返回时即执行全局函数),返回的不是 JSON,而是一段 JS 代码:

jsonpCallback({"success": true, "user": "admin"})

Vue Axios 实现

this.$http = axios;
this.$http.jsonp('http://www.domain2.com:8080/login', {
    params: {},
    jsonp: 'jsonpCallback'
}).then((res) => {
    console.log(res); 
})

后端 NodeJs 代码:

var querystring = require('querystring');
var http = require('http');
var server = http.createServer();
server.on('request', function(req, res) {
    var params = querystring.parse(req.url.split('?')[1]);
    var fn = params.callback;
    // JSONP 返回设置
    res.writeHead(200, { 'Content-Type': 'text/javascript' });
    res.write(fn + '(' + JSON.stringify(params) + ')');
    res.end();
});
server.listen('8080');
console.log('Server is running at port 8080...');

postMessage 跨域

postMessage 是 HTML5 XMLHttpRequest Level 2 中的 API,且是为数不多可以跨域操作的 window 属性之一,它可用于解决以下方面的问题:

  1. 页面和其打开的新窗口的数据传递。
  2. 多窗口之间消息传递。
  3. 页面与嵌套的 iframe 消息传递。
  4. 上面三个场景的跨域数据传递。

用法

postMessage(data,origin) 方法接受两个参数:

  1. data: HTML5 规范支持任意基本类型或可复制的对象,但部分浏览器只支持字符串,所以传参时最好用 JSON.stringify() 序列化。
  2. origin:协议+主机+端口号,也可以设置为 *,表示可以传递给任意窗口,如果要指定和当前窗口同源的话设置为 /。

domain1 跟 iframe 里的 domain2 进行数据传递

<!-- domain1.html -->
<iframe id="iframe" src="http://www.domain2.com/b.html" style="display:none;"></iframe>
<script>       
    var iframe = document.getElementById('iframe');
    iframe.onload = function() {
        var data = {
            name: 'aym'
        };
        // 只向 domain2 传送跨域数据
        iframe.contentWindow.postMessage(JSON.stringify(data), 'http://www.domain2.com');
    };
    // 接受 domain2 返回数据
    window.addEventListener('message', function(e) {
        alert('data from domain2 ---> ' + e.data);
    }, false);
</script>

<!-- domain2.html -->
<script>
    // 接收 domain1 的数据
    window.addEventListener('message', function(e) {
        alert('data from domain1 ---> ' + e.data);
        var data = JSON.parse(e.data);
        if (data) {
            data.number = 16;
            // 处理后再发回 domain1
            window.parent.postMessage(JSON.stringify(data), 'http://www.domain1.com');
        }
    }, false);
</script>

Nginx 代理跨域

Nginx 代理跨域,实质和 CORS 跨域原理一样,通过配置文件设置请求响应头 Access-Control-Allow-Origin 等字段。

配置解决 iconfont 跨域

浏览器跨域访问 JS、CSS、IMG 等常规静态资源被同源策略许可,但 iconfont 字体文件(eot|otf|ttf|woff|svg)例外,此时可在 Nginx 的静态资源服务器中加入以下配置:

location / {
  add_header Access-Control-Allow-Origin *;
}

反向代理接口跨域

原理:同源策略仅是针对浏览器的安全策略,服务器端调用 HTTP 接口只是使用 HTTP 协议,不需要同源策略,也就不存在跨域问题。

实现思路:通过 Nginx 配置一个代理服务器域名与 domain1 相同,做跳板机,反向代理访问 domain2 接口,并且可以顺便修改 Cookie 中 Domain 信息,方便当前域 Cookie 写入,实现跨域访问。

Nginx 具体配置如下:

# proxy 服务器
server {
    listen       81;
    server_name  www.domain1.com;
    location / {
        proxy_pass   http://www.domain2.com:8080;  # 反向代理
        proxy_cookie_domain www.domain2.com www.domain1.com;
        # 修改 domain2 返回的 Cookie 里域名为 domain1
        index  index.html index.htm;
        # 当用 webpack-dev-server 等中间件代理接口访问 nignx 时,此时无浏览器参与,
        # 故没有同源限制,下面的跨域配置可不启用
        add_header Access-Control-Allow-Origin http://www.domain1.com;
        # 当前端只跨域不带 Cookie 时,Access-Control-Allow-Origin 可为 *
        add_header Access-Control-Allow-Credentials true;
    }
}

Node 中间件代理跨域

Node 中间件实现跨域代理,原理大致与 Nginx 相同,都是通过启动一个代理服务器,实现数据的转发,也可以通过设置 cookieDomainRewrite 参数修改响应头中 Cookie 中域名,实现当前域的 Cookie 写入,方便接口登录认证。

非 Vue 框架的跨域

使用 Express + http-proxy-middleware 搭建一个 proxy 服务器。

// 前端代码
var xhr = new XMLHttpRequest();
xhr.withCredentials = true; // 前端开关:浏览器是否读写 Cookie
xhr.open('get', 'http://www.domain1.com:3000/login?user=admin', true); // 访问 http-proxy-middleware 代理服务器
xhr.send();

// 中间件服务器代码
var express = require('express');
var proxy = require('http-proxy-middleware');
var app = express();
app.use('/', proxy({
    // 代理跨域目标接口
    target: 'http://www.domain2.com:8080',
    changeOrigin: true,
    // 修改响应头信息,实现跨域并允许带 Cookie
    onProxyRes: function(proxyRes, req, res) {
        res.header('Access-Control-Allow-Origin', 'http://www.domain1.com');
        res.header('Access-Control-Allow-Credentials', 'true');
    },
    // 修改响应信息中的 Cookie 域名
    cookieDomainRewrite: 'www.domain1.com'  // 可以为 false,表示不修改
}));
app.listen(3000);
console.log('Proxy server is listen at port 3000...');

Vue 框架的跨域

Node + Vue + Webpack + webpack-dev-server 搭建的项目,跨域请求接口,直接修改 webpack.config.js 配置。

开发环境下,vue 渲染服务和接口代理服务都是 webpack-dev-server 同一个,所以页面与代理接口之间不再跨域。

// webpack.config.js 部分配置
module.exports = {
    entry: {},
    module: {},
    ...
    devServer: {
        historyApiFallback: true,
        proxy: [{
            context: '/login',
            target: 'http://www.domain2.com:8080',  // 代理跨域目标接口
            changeOrigin: true,
            secure: false,  // 当代理某些 https 服务报错时用
            cookieDomainRewrite: 'www.domain1.com'  // 可以为 false,表示不修改
        }],
        noInfo: true
    }
}

document.domain + iframe 跨域

此方案仅限主域相同、子域不同的跨域应用场景。

实现原理:两个页面都通过 JS 强制设置 document.domain 为基础主域,就实现了同域。

<!-- 父窗口 -->
<iframe id="iframe" src="http://child.domain.com/b.html"></iframe>
<script>
    document.domain = 'domain.com';
    var user = 'admin';
</script>

<!-- 子窗口 -->
<script>
    document.domain = 'domain.com';
    // 获取父窗口中变量
    console.log('get js data from parent ---> ' + window.parent.user);
</script>

location.hash + iframe 跨域

实现原理:a 欲与 b 跨域相互通信,通过中间页 c 来实现。三个页面,不同域之间利用 iframe 的 location.hash 传值,相同域之间直接 JS 访问来通信。

<!-- a.html -->
<iframe id="iframe" src="http://www.domain2.com/b.html" style="display:none;"></iframe>
<script>
    var iframe = document.getElementById('iframe');
    // 向 b.html 传 hash 值
    setTimeout(function() {
        iframe.src = iframe.src + '#user=admin';
    }, 1000);
    
    // 开放给同域 c.html 的回调方法
    function onCallback(res) {
        alert('data from c.html ---> ' + res);
    }
</script>

<!-- b.html -->
<iframe id="iframe" src="http://www.domain1.com/c.html" style="display:none;"></iframe>
<script>
    var iframe = document.getElementById('iframe');
    // 监听 a.html 传来的 hash 值,再传给 c.html
    window.onhashchange = function () {
        iframe.src = iframe.src + location.hash;
    };
</script>

<!-- c.html -->
<script>
    // 监听b.html传来的hash值
    window.onhashchange = function () {
        // 再通过操作同域 a.html 的 JS 回调,将结果传回
        window.parent.parent.onCallback('hello: ' + location.hash.replace('#user=', ''));
    };
</script>

window.name + iframe 跨域

window.name 属性的独特之处: name 值在不同的页面(甚至不同域名)加载后依旧存在,并且可以支持非常长的 name 值(2MB)。

通过 iframe 的 src 属性由外域转向本地域,跨域数据即由 iframe 的 window.name 从外域传递到本地域。这个就巧妙地绕过了浏览器的跨域访问限制,但同时它又是安全操作。

// 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);
});

// proxy.html
中间代理页,与a.html同域,内容为空即可。

// b.html
<script>
    window.name = 'This is domain2 data!';
</script>

WebSocket 协议跨域

WebSocket protocol 是 HTML5 一种新的协议。它实现了浏览器与服务器全双工通信,同时允许跨域通讯,是 server push 技术的一种很好的实现。

原生 WebSocket API 使用起来不太方便,我们使用 Socket.io,它很好地封装了 WebSocket 接口,提供了更简单、灵活的接口,也对不支持 WebSocket 的浏览器提供了向下兼容。

<!-- 前端代码 -->
<div>user input:<input type="text"></div>
<script src="https://cdn.bootcss.com/socket.io/2.2.0/socket.io.js"></script>
<script>
    var socket = io('http://www.domain2.com:8080');
    // 连接成功处理
    socket.on('connect', function() {
        // 监听服务端消息
        socket.on('message', function(msg) {
            console.log('data from server: ---> ' + msg); 
        });
        // 监听服务端关闭
        socket.on('disconnect', function() { 
            console.log('Server socket has closed.'); 
        });
    });
    document.getElementsByTagName('input')[0].onblur = function() {
        socket.send(this.value);
    };
</script>
// NodeJS socket 后台
var http = require('http');
var socket = require('socket.io');
var server = http.createServer(function(req, res) {
    res.writeHead(200, {
        'Content-type': 'text/html'
    });
    res.end();
});
server.listen('8080');
console.log('Server is running at port 8080...');
// 监听 socket 连接
socket.listen(server).on('connection', function(client) {
    // 接收信息
    client.on('message', function(msg) {
        client.send('hello:' + msg);
        console.log('data from client: ---> ' + msg);
    });
    // 断开处理
    client.on('disconnect', function() {
        console.log('Client socket has closed.'); 
    });
});

正向代理和反向代理的区别

正向代理

客户端想获得一个服务器的数据,但是因为种种原因无法直接获取。于是客户端设置了一个代理服务器,并且指定目标服务器,之后代理服务器向目标服务器转交请求并将获得的内容发送给客户端。

这样本质上起到了对真实服务器隐藏真实客户端的目的。

实现正向代理需要修改客户端,比如修改浏览器配置。客户端需要明确知道代理服务器的存在,并主动把请求发给它。最常见的配置方式是在浏览器设置-网络设置-配置代理中设置代理类型/代理地址/代理端口。

反向代理

服务器为了能够将工作负载分到多个服务器来提高网站性能(负载均衡)等目的,当其受到请求后,会首先根据转发规则来确定请求应该被转发到哪个服务器上,然后将请求转发到对应的真实服务器上。

这样本质上起到了对客户端隐藏真实服务器的作用。

一般使用反向代理后,需要通过修改 DNS 让域名解析到代理服务器 IP,这时浏览器无法察觉到真正服务器的存在,当然也就不需要修改配置了。

区别

正向代理和反向代理的结构是一样的,都是 client-proxy-server 的结构,它们主要的区别就在于中间这个 proxy 是哪一方设置的。

在正向代理中,proxy 是 client 设置的,用来隐藏 client。

而在反向代理中,proxy 是 server 设置的,用来隐藏 server。

使用场景:

  • 正向代理:用于访问无法直接访问的资源、缓存、客户端访问控制(公司内网限制外网)。
  • 反向代理:用于负载均衡、高可用性、SSL 加速、统一网关入口(微服务架构)。

Nginx 的概念及其工作原理

Nginx 是一款轻量级的 Web 服务器,也可以用于反向代理、负载平衡和 HTTP 缓存等。Nginx 使用异步事件驱动的方法来处理请求,是一款面向性能设计的 HTTP 服务器。

传统的 Web 服务器如 Apache 是 process-based 模型的,而 Nginx 是基于 event-driven 模型的。正是这个主要的区别带给了 Nginx 在性能上的优势。

Nginx 架构的最顶层是一个 master process,这个 master process 用于产生其他的 worker process,这一点和 Apache 非常像,但是 Nginx 的 worker process 可以同时处理大量的HTTP请求,而每个 Apache process 只能处理一个。

总结

本文详细介绍了浏览器同源策略的概念、限制范围以及解决跨域问题的多种方法:

  1. 同源策略:浏览器的安全机制,限制不同源之间的资源交互,要求协议、域名、端口号一致。
  2. 跨域解决方案
    • CORS:最常用的跨域方案,通过 HTTP 头部实现,支持所有 HTTP 方法
    • JSONP:利用 script 标签无跨域限制的特性,仅支持 GET 方法
    • postMessage:HTML5 API,适用于窗口间通信
    • Nginx 代理:通过服务器端代理实现跨域
    • NodeJS 中间件:开发环境常用的跨域解决方案
    • document.domain:仅适用于主域相同的场景
    • location.hash + iframe:通过 URL hash 传递数据
    • window.name + iframe:利用 window.name 持久化特性
    • WebSocket:全双工通信,天然支持跨域
  3. 代理服务器:介绍了正向代理和反向代理的区别,以及 Nginx 的工作原理。

选择跨域解决方案时,应根据具体场景和需求选择合适的方法,CORS 是目前最推荐的跨域方案,具有安全性高、功能完整的特点。