前言
很早之前,我经常在网上搜索问题的时候看见「跨域」这个名词。当时很好奇,就找了一些文章了解了一下含义。知道了是浏览器的一种安全措施,当前域的脚本不能访问其他域的资源。但是,对于「跨域」的理解也仅限于此了。后来在前后端分离的时候知道属于跨域了,但在一些脚手架的设置下,稀里糊涂的跳过了这个坑。现在回过头看,脑袋里依旧是糊糊涂涂,半知半解。为了后面的学习,有必要仔细梳理关于「跨域」的知识了。
域是什么?
学习就是要好奇,多提问。没有人是十全十美的,也没有人是生而知之的。只有不断地进步,才能变得越来越有自信地应该面对绝大多数困难。既然说的是「跨域」,那么这个词的中心「域」是什么呢?
URL结构.png
在此之前,我们先看看上图,解构了URL的组成。URL是由三部分组成的:协议名,服务器名和域名。
其中域名还可以细分很多子级别:顶级域名(一级域名)、二级域名和三级域名。域名级别是网址分类的一个标准,包括顶级域名、二级域名等。一个完整的域名由二个或二个以上部分组成,各部分之间用英文的句号"."来分隔,倒数第一个"."的右边部分称为顶级域名(TLD,也称为一级域名,包含一个合法字符串,和一个域名后缀),顶级域名的左边部分字符串到下个"."为止称为二级域名(SLD),二级域名的左边部分称为三级域名,以此类推,每一级的域名控制它下一级域名的分配。
- 顶级域名:又叫一级域名,是指域名的最后一段,或紧跟在“点”符号后面的部分。例如
.com
表示商业性的机构和公司,.gov
表示政府部门。 - 二级域名:指顶级域名之下的域名,在国际顶级域名下,它是指域名注册人的网上名称,例如ibm,baidu,microsoft等;在国家顶级域名下,它是表示注册企业类别的符号,例如com,edu,gov,net等。
- 三级域名:二级域名的子域名,特征是包含三个“.”,一般来说三级域名都是免费的。
我们知道域名的诞生是因为IP太难记了,而对应的域名更容易被人类记住,其中IP地址和域名的映射关系转化工作是由DNS服务器做的。那么在IP地址中,我们会有一个端口号。那么URL中为什么没有了呢?答案是依旧存在,不过被隐藏了。当你访问域名的时候,服务器会指向一个IP,并且默认了一个端口。例如Vue项目中默认的端口号是8080,那么我们将Vue项目部署到服务器上后。通过域名访问这个Vue项目,最后指向的端口就是8080。只不过在域名层面上,将端口号藏起来了。
如果两个URL的协议、服务器名、域名和端口号其中一个不一样,那么就说这两个URL是不同域的。举个例子,http://www.baidu.com
和https://www.baidu.com
的协议不一样,所以他们属于不同域。http://www.baidu.com
和http://pan.baidu.com
的服务器名不一样,他们也是不同域的。再看一个例子:http://www.baidu.com/index.html
和http://www.baidu.com
和http://www.baidu.com/home
是不是属于同一个域的?答案是属于。因为这三个URL的协议、服务器名、域名和端口号都是一样的,不同的是文件的路径。可以类比都是在同一个文件夹下面的不同文件,尽管文件不同,但是都是属于一个文件夹。所以他们是同一域的。
跨域是什么?
我们已经了解到了域是什么,如何判别是否跨域。那么新的问题来了,跨域是什么意思?什么场景下会出现跨域呢?
多年来,来自某个网站的脚本无法访问另一个网站的内容。我们先从身边找起来,如果我们对任意网站发起 fetch
请求,那可能会出现失败情况。
例如,我们尝试获取 http://example.com
:
try {
await fetch('http://example.com');
} catch(err) {
alert(err); // 无法获取
}
上面的代码运行结果是失败的,我们无法访问http://example.com
的资源。因为运行的脚本所在的域和访问的资源不在一个域中。这个简单有力的规则是互联网安全的基础。例如,来自 hacker.com
页面的脚本无法访问 gmail.com
上的用户邮箱。基于这样的规则,人们感到很安全。假设能够能够访问其他域的资源的话,那么当你在网页上登录了你的支付宝账号。网页的恶意脚本就能访问你的支付宝信息,会造成很大的金融危险。
浏览器的跨源限制可以保护互联网免受恶意黑客攻击。
在很早的时候,JavaScript 只是一种装饰网页的玩具语言而已,它并没有任何特殊的执行网络请求的方法。但是随着前端的快速发展,网络开发人员需要更多的控制权。例如在前后端分离的项目中,我们需要在本地进行开发。本地的项目运行地址在http:localhost:8080
,后端接口地址198.167.3.12:8080/manage/role/list
。很明显,这两个URL不属于同一个域。那么如果直接在前端项目中调用后端接口,接口会报错,说跨域了。同理,当项目部署在服务器上,前端项目和后端项目不可能处于同一域(至少端口号是不同的)。这个时候就需要一些方法来绕过跨域,实现资源共享。
跨域是谁设置的?——幕后的黑手
经常在看跨域的博文时,会忽略一个问题:跨域是谁设置的,是谁进行判断是否跨越,并进行拦截的?答案很简单,就在上面小节猴子那个,但是经常会被笔者和读者忽略。上面我们讲了跨源限制是浏览器用来保护互联网免受恶意黑客伤害的措施。所以可以知道,这句话的主语是——浏览器。
总结一下:跨域资源限制是浏览器对网络请求进行的限制,目的是为了互联网安全。所以我们说跨域,隐藏的背景环境是在浏览器中运行的脚本。在服务器中,是不存在跨域的限制的。因为服务器中根本就不存在浏览器这个角色。所以后面讲的如何跨域的方法,其中一个就是利用同域的服务器进行接口请求转发。
浏览器如何知道请求跨源了?
这里梳理两个名词:跨域和跨源。跨源指的是网络请求,访问了不同域的资源。例如常见的ajax请求后端接口获取数据,就属于跨源请求。跨域是一种行为描述,跨源请求因为请求不同域的资源,这个行为叫做跨域。
那么浏览器如何知道一个请求是否跨源呢?秘密就在请求的header中。如果一个请求是跨源的,浏览器始终会向其添加 Origin
头。例如,如果我们从 https://javascript.info/page
请求 https://anywhere.com/request
,请求头就类似于:
GET /request
Host: anywhere.com
Origin: https://javascript.info
...
正如你所见,Origin
包含完整的源(domain/protocol/port),没有路径。服务器收到这个请求后,会检查header,如果发现存在Origin
头,就知道这个请求是跨源请求。
如何进行跨源请求?
时间是一直往前流逝的,世界也是不断的发展的。我们从最原始的时期开始,那个时候前端还没有和后端分离,移动互联网还没有兴起。但是偶尔还是需要进行跨源请求的,那么该如何做呢?那时的开发人员首先从手头现有的工具开始琢磨,首先想到了HTML中自带的表单标签。form
标签的action
特性可以设置为一个链接,目的是在表单提交后自动将表单数据发送到指定的链接。它进行了跨域,但是没有受到浏览器的跨源限制。这不是一个粗糙的解决跨源限制的方案嘛!想到这个点子的开发人员,激动得一拍大腿。老子真牛逼,叉会腰炫耀一下.jpg。所以第一种跨源请求的方法是使用forms。
在HTML中提交一个<form>
,可以与其他服务器通信 。人们将它提交到 <iframe>
,目的只是为了仍然留在当前页面,像这样:
<!-- form 目标 -->
<iframe name="iframe"></iframe>
<!-- form 可以使用 JavaScript 动态生成并提交 -->
<form target="iframe" method="POST" action="http://another.com/…">
...
</form>
因此,即使没有网络方法,它也可以向其他网站发起一个 GET/POST请求。但是由于禁止从其他网页读取 `` 的内容,因此就无法读取响应。
正如所见,forms 可以在任意位置发送数据,但是不能接受响应内容。确切地说,还是有一些技巧能够解决这个问题的(iframe 和页面中都需要添加特殊脚本),不过我们还是让这些老古董代码不要再出现了吧。
花开两朵,各表一枝。另一位机灵的开发人员也激动得一拍大腿:我想到啦!HTML中<script>
标签的src
特性不就是可以是任意url吗,这不也是一条跨源请求的路子嘛!我真牛逼🐂。这就是第二个方法使用<script> 标签。
当使用<script src="http://another.com/…">
标签时,脚本元素可以有来自任何域的任何 src
值。但同样 —— 无法访问此类脚本的原始内容。如果 another.com
试图公开这种访问的数据,则使用所谓的“JSONP(JSON with padding)”协议。
假设我们需要以这种方式从 http://another.com
站点获取数据:
- 首先,我们提前声明一个全局函数来接收数据,例如
gotWeather
。
// 1. 声明处理数据的函数
function gotWeather({ temperature, humidity }) {
alert(`temperature: ${temperature}, humidity: ${humidity}`);
}
- 然后我们创建属性为
src="http://another.com/weather.json?callback=gotWeather"
的 `` 标签,请注意我们的函数名是作为它的callback
参数。
let script = document.createElement('script');
script.src = `http://another.com/weather.json?callback=gotWeather`;
document.body.append(script);
- 服务器动态生成一个名为
gotWeather(...)
的脚本,脚本内包含我们想要接收的数据。
// 期望从服务器获取到的结果类似于此:
gotWeather({
temperature: 25,
humidity: 78
});
- 当远端脚本加载并执行的时候,
gotWeather
函数被调用,并且因为它是我们的函数,我们就有需要的数据了。
这是可行的,并且不违反安全规定,因为双方网站都接受这种传递数据的方式。既然双方网站都同意这种行为,那么它肯定不是网络攻击了。现在仍然有提供这种访问的服务,因为即使是非常旧的浏览器也依然可行。
CORS(跨域资源共享)
历史的潮流滚滚而来,不久之后,出现了具体的网络处理方法,例如 XMLHttpRequest
。在互联网高速发展的同时,原来使用**<form>和<script>**进行跨源请求太过于原始与麻烦了,需要更先进方便的方法来进行跨源请求。起初,跨源请求是被禁止的。但是由于长时间的讨论,跨源请求最终被允许:除非服务器明确允许,否则不会添加任何功能。并且制定了对应的W3C标准——CORS,全称是「跨域资源共享」(Cross-origin resource sharing)。它允许浏览器向跨源服务器,发出XMLHttpRequest
请求,从而克服了AJAX只能同源使用的限制。
简单请求
浏览器将CORS请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。只要同时满足以下两大条件,就属于简单请求。
顾名思义,简单请求很简单,所以我们先从它开始。 一个 简单请求 是指满足下列条件的请求:
- 简单请求方法:GET, POST 或 HEAD
- 简单请求头 — 仅允许自定义下列请求头:
Accept
,Accept-Language
,Content-Language
,Content-Type
的值为application/x-www-form-urlencoded
,multipart/form-data
或text/plain
.
任何其他的请求都被视为“非简单请求(non-simple)”。例如,具有 PUT
方法或者 API-Key
HTTP 头的请求就不是简单请求了。
本质区别在于,可以使用 <form>
或者<script>
进行“简单请求”,而无需任何特殊方法。
所以,即使是非常旧的服务器也能很好地接收简单请求。与此相反,使用非标准头,或者说比如 DELETE
这样的方法就不能以这种方式创建。在很长一段时间内,JavaScript 都不能建立这样的请求。所以,旧的服务器可能会认为此类请求来自具有特权的来源,“因为网页无法发送它们”。
当我们试图建立非简单请求时,浏览器发送一个特殊的“预检(preflight)”请求到服务器 —— 是否接受这类跨源请求吗?并且,除非服务器明确通过头确认,否则非简单请求不会被发送。现在,我们将详细介绍它们。所有这些都有一个目的 —— 那就是确保只有来自服务器的明确许可才能访问新的跨源功能。
用于简单请求的CORS
如果一个请求是跨源的,浏览器始终会向其添加 Origin
头。例如,如果我们从 https://javascript.info/page
请求 https://anywhere.com/request
,请求头就类似于:
GET /request
Host: anywhere.com
Origin: https://javascript.info
...
正如你所见,Origin
包含完整的源(domain/protocol/port),没有路径。
服务器可以检查 Origin
,如果同意接受这样的请求,就会在响应中添加一个特殊的头 Access-Control-Allow-Origin
。该头包含了允许的源(在我们示例中是 https://javascript.info
),或者星号 *
。然后响应成功,否则报错。
浏览器在这里扮演受信任的中间人角色:
- 它确保通过跨域请求发送正确的
Origin
。 - 如果在响应中检查出正确的
Access-Control-Allow-Origin
,如果是,则 JavaScript 能正常访问(目标资源),否则被禁止并报错。
这是一个得到服务器许可的响应示例:
200 OK
Content-Type:text/html; charset=UTF-8
Access-Control-Allow-Origin: https://javascript.info
这里要注意的一点就是,对于跨源请求,默认情况下Javascript只能访问"简单响应头"。
Cache-Control
Content-Language
Content-Type
Expires
Last-Modified
Pragma
如果想要访问更多的响应头,需要服务器在响应头中列出 Access-Control-Expose-Headers
。
非简单请求
我们可以使用任何HTTP方法:不仅仅是GET/POST
,也可以是PATCH
,DELETE
及其他。之前,没有人能够设想网页也能够做出这样的请求。所以可能存在有些网络服务视非标准方法为一个信号:“这不是浏览器”。他们可以检查访问权限时将其考虑在内。
因此,为了避免误解,任何“非标准”请求——在过去无法完成,浏览器不会立即发出此类请求。在它发送请求前,会先发送“预检请求”来获取权限。预检请求使用OPTIONS
方法,并且没有body。
Access-COntrol-Request-Method
头带有请求方法Access-Control-Request-Headers
头提供以逗号分隔的飞溅的HTTP头列表
如果服务器统一请求,那么它响应状态码应该为200,没有body。
- 响应头
Access-Control-Allow-Methods
必须具有允许的方法。 - 响应头
Access-Control-Allow-Headers
必须具有允许的头列表。 - 另外,响应头
Access-Control-Max-Age
可以指定缓存此权限的秒数。因此,浏览器不必为满足给定权限的后续请求发送预检。
服务器代理
说过了CORS后,还有一种方法可以用来跨源请求。想一下,如果我们请求的时候还是用前端的域名,然后有个东西帮我们把这个请求转发到真正的后端域名上,不就避免跨域了吗?这时候,Nginx出场了。 Nginx配置:
server{
# 监听9099端口
listen 9099;
# 域名是localhost
server_name localhost;
#凡是localhost:9099/api这个样子的,都转发到真正的服务端地址http://localhost:9871
location ^~ /api {
proxy_pass http://localhost:9871;
}
}
前端就不用干什么事情了,除了写接口,也没后端什么事情了
// 请求的时候直接用回前端这边的域名http://localhost:9099,这就不会跨域,然后Nginx监听到凡是localhost:9099/api这个样子的,都转发到真正的服务端地址http://localhost:9871
fetch('http://localhost:9099/api/iframePost', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
msg: 'helloIframePost'
})
})
Nginx转发的方式似乎很方便!但这种使用也是看场景的,如果后端接口是一个公共的API,比如一些公共服务获取天气什么的,前端调用的时候总不能让运维去配置一下Nginx,如果兼容性没问题(IE 10或者以上),CROS才是更通用的做法吧。