跨域

345 阅读7分钟

同源策略控制了不同源之间的交互,不同源之间的请求就会出现跨域,同源指:协议、域名、端口号必须一致

严格的说,浏览器并不是拒绝所有的跨域请求,实际上拒绝的是跨域的读操作。浏览器的同源限制策略是这样执行的:

  • 通常浏览器允许进行跨域写操作(Cross-origin writes),如链接,重定向;
  • 通常浏览器允许跨域资源嵌入(Cross-origin embedding),如 img、script 标签;
  • 通常浏览器不允许跨域读操作(Cross-origin reads)。

下面为允许跨域资源嵌入的示例,即一些不受同源策略影响的标签示例:

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

常用的跨域方式有以下几种:JSONP、CORS、postMessage、代理服务器

1、JSONP

利用script标签不受跨域限制而形成的一种方案。

function jsonp({url, params, callback}){
  return new Promise((resolve, reject)=>{
    // 创建一个script
    let script = document.createElement('script')

    // 前后台统一一个回调函数名称用来接收
    window[callback] = function(data){
      resolve(data);
      document.body.removeChild(script)
    }
    
    // 解析URL
    const paramsStr = Object.entries({...params, callback}).map(item=>`${item[0]}=${item[1]}`).join('&')
    const queryStr = url.split('?')[1]
    if(queryStr == null || queryStr === ''){
      url += '?' + paramsStr
    }else{
      url += '&' + paramsStr
    }

    script.src = url
    document.body.appendChild(script)
  })
}

【JSONP的优缺点】

  • 优点:兼容性好(兼容低版本IE)
  • 缺点:
    • 1、JSONP只支持GET请求;
    • 2、XMLHttpRequest相对于JSONP有着更好的错误处理机制;
    • 3、不安全,容易受XSS攻击。
2、CORS(跨域资源共享)

跨域资源共享标准新增了一组 HTTP 首部字段,允许服务器声明哪些源站有权限访问哪些资源。

规范要求,对那些可能对服务器数据产生副作用的 HTTP 请求方法(特别是 GET 以外的 HTTP 请求,或者搭配某些 MIME 类型的 POST 请求),浏览器必须首先使用 OPTIONS 方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨域请求。服务器确认允许之后,才发起实际的 HTTP 请求。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(包括 Cookies 和 HTTP 认证相关数据)。

浏览器将CORS跨域请求分为简单请求和非简单请求;

只要同时满足一下两个条件,就属于简单请求,不同时满足下面的两个条件,就属于非简单请求,浏览器对这两种的处理,是不一样的。

  • 使用下列方法之一

    • head
    • get
    • post
  • 请求的Heder是

    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type: 只限于三个值:
      • application/x-www-form-urlencoded
      • multipart/form-data
      • text/plain
1、简单请求

对于简单请求,浏览器直接发出CORS请求。具体来说,就是头信息之中,增加一个Origin字段。

1、非简单请求

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

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

  • 一旦服务器通过了预检请求,以后每次浏览器正常的CORS请求,就都跟简单请求一样,会有一个Origin头信息字段。服务器的回应,也都会有一个Access-Control-Allow-Origin头信息字段;

  • CORS请求默认不发送Cookie和HTTP认证信息,如果要把Cookie发到服务器,一方面需要服务器同意,设置响应头Access-Control-Allow-Credentials: true,另一方面在客户端发出请求的时候也要进行一些设置;

// XHR
var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://example.com/', true); 
xhr.withCredentials = true; 
xhr.send(null);

// Fetch
fetch(url, {
  credentials: 'include'  
})
  1. 如果设置请求头'Content-Type': 'application/x-www-form-urlencoded',这种情况则为简单请求; 会有跨域问题,直接设置 响应头 Access-Control-Allow-Origin为*, 或者具体的域名;注意如果设置响应头Access-Control-Allow-Credentials为true,表示要发送cookie,则此时Access-Control-Allow-Origin的值不能设置为星号,必须指定明确的,与请求网页一致的域名。
const login = ctx => {
    const req = ctx.request.body;
    const userName = req.userName;
    ctx.set('Access-Control-Allow-Origin', '*');
    ctx.response.body = {
        data: {},
        msg: '登陆成功'
    };
}
  1. 如果设置请求头'Content-Type': 'application/json',这种情况则为非简单请求 处理OPTIONS请求,服务端可以单独写一个路由,来处理login的OPTIONS的请求
app.use(route.options('/login', ctx => {
    ctx.set('Access-Control-Allow-Origin', '*');
    ctx.set('Access-Control-Allow-Headers', 'Content-Type');
    ctx.status = 204;
}));

大家都知道前端调用服务端的时候,会调用很多个接口,并且每个接口处理跨域请求的逻辑是完全一样的,我们可以把这部分抽离出来,作为一个中间件;

具体CORS的详细原理,推荐 @木子星兮CORS原理及@koa/cors源码解析

let express = require('express');
let app = express();
let whiteList = ['http://localhost:3000']

app.use(function(req, res, next){
  let origin = req.headers.origin;
  
  if(whiteList.includes(origin)){
    //设置那个源可以访问我,参数为 * 时,允许任何人访问,但是不可以和 cookie 凭证的响应头共同使用
    res.setHeader('Access-Control-Allow-Origin', origin);
    //允许带有name的请求头的可以访问
    res.setHeader('Access-Control-Allow-Headers','name');
    // 设置哪些请求方法可访问
    res.setHeader('Access-Control-Allow-Methods', 'PUT');
    // 设置带cookie请求时允许访问
    res.setHeader('Access-Control-Allow-Credentials', true);
    // 后台改了前端传的name请示头后,再传回去时浏览器会认为不安全,所以要设置下面这个 
    res.setHeader('Access-Control-Expose-Headers','name');
    // 预检的存活时间-options请示
    res.setHeader('Access-Control-Max-Age',3)
    // 设置当预请求发来请求时,不做任何处理
    if(req.method === 'OPTIONS'){
      res.end();//OPTIONS请示不做任何处理
    }
  }
  next();
});
3、postMessage

window.postMessage() 方法被调用时,会在所有页面脚本执行完毕之后向目标窗口派发一个MessageEvent消息。

// a.html
<iframe src="http://localhost:4000/b.html" id="frame" onload="load()"></iframe>
<script>
function load(params){
  let frame = document.getElementById('frame');
  //获取iframe中的窗口,给iframe里嵌入的window发消息
  frame.contentWindow.postMessage('hello','http://localhost:4000')
  // 接收b.html回过来的消息
  window.onmessage = function(e){
    console.log(e.data)
  }
}
</script>
// b.html
<script>
//监听a.html发来的消息
window.onmessage = function(e){
    console.log(e.data)
    //给发送源回消息
    e.source.postMessage('nice to meet you',e.origin)
}
</script>
4、代理服务器

因为跨域问题是浏览器端的问题,服务器端是没有跨域问题的,所以可以开启一个同源的代理服务器转发数据给目标服务器,再接受目标服务器返回的数据,最终返回给客户端

1、Nginx

Nginx (engine x) 是一个高性能的HTTP和反向代理服务器,也是一个IMAP/POP3/SMTP服务器。

// client.html
let xhr = new XMLHttpRequest;
xhr.open('get', 'http://localhost/a.json', true);
xhr.onreadystatechange = function() {
  if(xhr.readyState === 4){
    if(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304 ){
      console.log(xhr.response);
    }
  }
}
// server.js
let express = require('express');
let app = express();
app.use(express.static(__dirname));
app.listen(3000);

// nginx.conf
location / {// 代表输入/时默认去打开root目录下的html文件夹
  root html;
  index index.html index.htm;
}

location ~.*\.json{//代表输入任意.json后去打开json文件夹
  root json;
  add_header "Access-Control-Allow-Origin" "*";
}
2、http-proxy-middleware

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

vue框架

利用 node + webpack + webpack-dev-server 代理接口跨域。在开发环境下,由于 Vue 渲染服务和接口代理服务都是 webpack-dev-server,所以页面与代理接口之间不再跨域,无须设置 Headers 跨域信息了。

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

非vue框架的跨域

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>nginx跨域</title>
</head>
<body>
  <script>
    var xhr = new XMLHttpRequest();

    // 前端开关:浏览器是否读写 cookie
    xhr.withCredentials = true;

    // 访问 http-proxy-middleware 代理服务器
    xhr.open('get', 'http://www.proxy1.com:3000/login?user=admin', true);
    xhr.send();
  </script>
</body>
</html>
// 中间代理服务器
var express = require("express");
var proxy = require("http-proxy-middleware");
var app = express();

app.use(
  "/",
  proxy({
    // 代理跨域目标接口
    target: "http://www.proxy2.com:8080",
    changeOrigin: true,

    // 修改响应头信息,实现跨域并允许带 cookie
    onProxyRes: function(proxyRes, req, res) {
      res.header("Access-Control-Allow-Origin", "http://www.proxy1.com");
      res.header("Access-Control-Allow-Credentials", "true");
    },

    // 修改响应信息中的 cookie 域名
    cookieDomainRewrite: "www.proxy1.com" // 可以为 false,表示不修改
  })
);

app.listen(3000);
// 服务器
var http = require("http");
var server = http.createServer();
var qs = require("querystring");

server.on("request", function(req, res) {
  var params = qs.parse(req.url.substring(2));

  // 向前台写 cookie
  res.writeHead(200, {
      "Set-Cookie": "l=a123456;Path=/;Domain=www.proxy2.com;HttpOnly" // HttpOnly:脚本无法读取
  });

  res.write(JSON.stringify(params));
  res.end();
});

server.listen("8080");

感谢 @木子星兮 / @An_an 提供的优质文章

CORS原理及@koa/cors源码解析

跨域总结