CORS — 详解 & 实战

WEB开发们都知道,出于安全原因,浏览器有个同源策略,不同源的客户端脚本在没有明确授权的情况下,不能读写对方资源。一个HTTP请求的URL的协议、域名、端口三者中的任何一个与当前源不同,则视为跨域请求。如果不做处理,我们看到chrome抛出一个错误:


cross-origin-request-error

而在实际的场景中,我们会有很多情况下需要进行跨域请求,所以跨域解决方案和其原理几乎是WEB开发必须要掌握的知识。下面列举几种常见的跨域方案:

跨域的几种解决方案

  • JSONP:这是跨域请求的一个经典方案,其主要原理是通过JS动态创建<script>标签获取指定资源,然后前后端约定一个callback来获取json数据,<script><iframe>这些具有src属性的标签都是可直接跨域获取资源的,这种方式其实只是巧妙地绕过跨域限制,而且有其局限性,比如很明显的,只能发送GET请求,而且要判断请求是否失败也比较棘手。
  • Proxy代理:由于同源策略只是浏览器的限制,服务器端并没有这个限制,所以只要A域客户端将请求发送一个代理服务器,然后由代理服务器去请求B域服务器就行了,比如前后端分离的工程,本地调试的时候我们启用nodejs代理服务、线上部署通过nginx代理转发等,都属于这个跨域模式。同样的,这个本质上也只是绕过浏览器的跨域限制而已。
  • CORS(Cross-Origin Resource Sharing):跨域资源共享标准,本文重点研究对象。

CORS初尝试

假设现在服务端有个获取股票列表的接口,并已设置允许跨域(后文将介绍如何设置),其中
客户端地址:http://localhost:3000
服务端地址:http://localhost:7001

页面上设置了个按钮用以获取股票列表:


获取股票列表的前端代码:

axios({
  url: 'http://localhost:7001/api/getStocks',
}).then((res) => {
  const data = res.data;
  this.setState((prevState) => ({
    list: prevState.list.concat(data.data),
  }));
});

此时发送的请求状态为:


可以看到请求直接成功并返回了数据,乍看之下除了Response Headers多了一些Access-Control-Allow-*字段外,和普通请求没什么区别。
过了段时间,出于安全角度考虑,现在要对这个接口进行token验证,,所以增加了一个请求头字段 access-token

axios({
  url: 'http://localhost:7001/api/getCounts',
  headers: {
    'access-token': 'abcdefg',
  },
}).then((res) => {
  const data = res.data;
  this.setState({
    count: data.data,
  });
});

这时再查看请求的发送情况,奇怪的事情出现了,现在浏览器竟然发出去了两个请求!查看之后,会发现第一个请求方法为OPTIONS,状态码为204,什么数据都没有返回!第二个请求才是我们真正想要的请求,GET请求,且状态码为200,将股票列表返回了:

第一个请求

第二个请求

所以第一个OPTIONS请求是什么?为什么会发送这个请求?

CORS工作原理

CORS新增了一组 HTTP 首部字段,允许服务器声明哪些源站有权限访问哪些资源。另外,规范要求,对那些可能对服务器数据产生副作用的 HTTP 请求,浏览器必须首先使用 OPTIONS 方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨域请求。预检请求头中Access-Control-Request-Method字段告诉服务器实际请求的方法,Access-Control-Request-Headers字段告知服务器实际请求中需要携带的自定义参数。服务器确认允许之后,才发起实际的 HTTP 请求。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(包括 Cookies 和 HTTP 认证相关数据)。

简单请求和非简单请求

一般把无需发送OPTIONS的请求叫做简单请求,把需要发送OPTIONS的请求称为非简单请求复杂请求

其中简单请求必须满足以下几个条件(不满足所有下面条件的即为非简单请求):

  1. 请求方式只限于 GET、 HEAD、POST;
  2. 除以下头部信息外,不能自定义其他请求头字段 :
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type(需要注意额外的限制)
    • Last-Event-ID
  3. Content-Type 的值只限于以下三种:
    • text/plain
    • multipart/form-data
    • application/x-www-form-urlencoded

附带身份凭证的请求

CORS (还有Fetch )的一个有趣特性是,可以基于 HTTP cookies 和 HTTP 认证信息发送身份凭证。一般而言,对于跨域 XMLHttpRequestFetch请求,浏览器不会发送身份凭证信息。如果要发送凭证信息,需要设置某个特殊标志位,例如我们的代码 axios 中可以加入withCredentials字段表示跨域请求时需要携带凭证:

axios({
  url: 'http://localhost:7001/api/getStocks',
  withCredentials: true, // 设置携带凭证
}).then((res) => {
  const data = res.data;
  this.setState((prevState) => ({
    list: prevState.list.concat(data.data),
  }));
});

此时我们发送一个简单请求会发现一个奇怪的事情:



明明请求已经返回了数据,但是页面上并没有渲染出来,事实上此时Chrome浏览器已经在控制台出现了报错信息:


这是因为如果跨域请求想要附带身份凭证,必须在服务端设置Access-Control-Allow-Credentialstrue,否则浏览器将不会把响应内容返回给请求的发送者。
另外,对于附带身份凭证的请求,服务器不得设置Access-Control-Allow-Origin的值为*

CORS响应头字段

注:以下例子为NodeJs中Egg框架的设置方法(事实上,Egg框架中你会选择egg-cors插件进行跨域设置),不同语言和框架请参照各自的文档。

1. Access-Control-Allow-Origin

语法为:Access-Control-Allow-Origin: <origin> | *,其中origin参数的值指定了允许访问该资源的外域 URI,如果跨域请求中携带了cookie,则不能指定其值为*。如:

ctx.set('Access-Control-Allow-Origin', 'http://localhost:3000');
2. Access-Control-Allow-Methods

语法为:Access-Control-Allow-Methods: <method>[, <method>]*,用于预检请求的响应。其指明了实际请求所允许使用的 HTTP 方法。如:

ctx.set('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS, DELETE');
3. Access-Control-Allow-Headers

语法为:Access-Control-Allow-Headers: <field-name>[, <field-name>]*,用于预检请求的响应。其指明了实际请求中允许携带的首部字段。如:

ctx.set('Access-Control-Allow-Headers', 'Content-Type, access-token');
4. Access-Control-Allow-Credentials

指定了当浏览器的credentials设置为true时是否允许浏览器读取response的内容。当用在对preflight预检请求的响应中时,它指定了实际的请求是否可以使用credentials。请注意:简单GET请求不会被预检;如果对此类请求的响应中不包含该字段,这个响应将被忽略掉,并且浏览器也不会将相应内容返回给网页。
如:

ctx.set('Access-Control-Allow-Credentials', true);
5. Access-Control-Expose-Headers

在跨域访问时,XMLHttpRequest对象的getResponseHeader()方法只能拿到一些最基本的响应头:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。如果要访问其他头,则需要服务器设置本响应头。如:

ctx.set('Access-Control-Expose-Headers', 'access-token');
6. Access-Control-Max-Age

语法为:Access-Control-Max-Age: <delta-seconds>,指定了preflight请求的结果能够被缓存多久(单位:秒)。在有效时间内,浏览器无须为同一请求再次发起预检请求。请注意,浏览器自身维护了一个最大有效时间,如果该首部字段的值超过了最大有效时间,将不会生效。如:

ctx.set('Access-Control-Max-Age', 86400);  // 86400秒内,即24小时内都有效

CORS请求头字段

1. Origin

origin 参数的值为源站 URI。它不包含任何路径信息,只是服务器名称。

2. Access-Control-Request-Method

用于预检请求。其作用是,将实际请求所使用的 HTTP 方法告诉服务器。

3. Access-Control-Request-Headers

用于预检请求。其作用是,将实际请求所携带的首部字段告诉服务器。

源码(Egg框架)

  1. router:
'use strict';

module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
  router.get('/api/getStocks', controller.home.getStocks);
};
  1. controller:
'use strict';

const Controller = require('egg').Controller;

class HomeController extends Controller {
  async index() {
    this.ctx.body = 'hello world';
  }

  async getStocks() {
    const { ctx } = this;
    const stocks = [{
      name: '上证指数',
      code: '1A0001'
    }, {
      name: '万科A',
      code: '000002'
    }, {
      name: '滨江集团',
      code: '002244'
    }];
    ctx.body = {
      code: 0,
      message: 'success',
      data: stocks,
    };
  }
}

module.exports = HomeController;

  1. config/plugin
'use strict';

/** @type Egg.EggPlugin */
exports.validate = {
  enable: true,
  package: 'egg-validate',
};

exports.cors = {
  enable: true,
  package: 'egg-cors',
}
  1. config/config.default
'use strict';

/**
 * @param {Egg.EggAppInfo} appInfo app info
 */
module.exports = appInfo => {
  /**
   * built-in config
   * @type {Egg.EggAppConfig}
   **/
  const config = exports = {};
  config.keys = appInfo.name + '_1574314669249_9332';
  config.middleware = ['errorHandler'];
  
  config.cors = {
    origin: 'http://localhost:3000',
    allowMethods: 'GET, HEAD, PUT, POST, DELETE, PATCH, OPTIONS',
    allowHeaders: 'access-token',
    credentials: true,
  };

  config.security = {
    // 关闭csrf验证
    csrf: {
      enable: false,
    },
    // 白名单
    domainWhiteList: ['*']
  };

  const userConfig = {
    myAppName: 'cors',
  };

  return {
    ...config,
    ...userConfig,
  };
};

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342