CORS(跨域资源共享)使用额外的HTTP头部来告诉浏览器,允许运行在origin(domain)上的Web应用访问来自不同源服务器上的指定资源。
浏览器访问一个web应用,这个web应用会发很多的跨域请求,例如加载不同源的JS/CSS脚本,或者加载不同源图片等。但是并没有发现请求的异常,这些资源是可以正常返回的。而通过JS发送的跨域HTTP请求却时常得到错误,所以跨域请求很常见,但是浏览器对于请求跨域的限制却只存在于脚本发送的HTTP请求(Ajax/Fetch)。
同源限制在安全上是有其必要性的,例如可以很轻松规避CSRF攻击。
通过上面的描述可以看出来同源策略的限制存在于浏览器端,而CORS策略用额外的HTTP请求头字段来告诉浏览器,该资源是允许被当前域上的web应用跨域访问的。
CORS的具体步骤
那么具体CORS是怎么做的呢?
- 浏览器察觉请求跨域没有发起请求?
- 浏览器发起跨域请求没有正常返回结果?
上面说到“通过额外的HTTP头告诉浏览器源上的web应用被允许访问来自不同源服务器上的指定资源”。告诉浏览器的自然是通过服务端的响应携带的额外的HTTP头,所以可以看出来请求是发送了的。那么服务端是携带了标识当前源允许访问的该资源的HTTP头?如果允许的话自然是请求一切正常,不允许的话浏览器则会报错并且不会将请求结果返回给请求的发起方,也就是Ajax/Fetch代码,并且代码中获取不到是哪一步出了错只能在浏览器中看到错误日志(为了安全)。
那么就只是这样吗?跨域请求失败是因为浏览器正常发送了请求,服务端正常响应了请求,然后浏览器发觉响应头中没有允许跨域的标识,然后拦截返回结果并报错。
并不完全是这样,这只是CORS的一部分,这部分被称为简单请求。
既然有简单请求就有非简单请求。非简单请求的具体步骤和上面描述的简单请求很不一样,会在发送真正的请求之前发送一个预检请求(preflight request)询问服务端是否允许当前源跨域访问该资源,允许则继续发送真正的请求,否则直接报错。
所以上面的两种做法都被应用到CORS策略中:一种是拦截请求的返回结果,一种是不发送真正的跨域请求。
简单请求
简单请求并不会发送预检请求,而是直接发送真正的请求。简单请求必须要全部满足下面的条件:
- 使用下列方法之一:
- GET
- HEAD
- POST
- 不得人为设置该集合之外的其他首部字段。该集合为:
- Accep
- Accept-Language
- Content-Language
- Content-Type(需要注意额外的限制)
- DPR
- Downlink
- Save-Data
- Viewport-Width
- Width
- Content-Type 的值仅限于下列三者之一:(这个集合中没有application/json)
- text/plain
- multipart/form-data
- application/x-www-form-urlencoded
- 请求中的任意XMLHttpRequestUpload`对象均没有注册任何事件监听器;XMLHttpRequestUpload对象可以使用 XMLHttpRequest.upload 属性访问。
- 请求中没有使用ReadableStream对象。
下面我们将从源http://dev.jd.com:9091访问http://dev.jd.com:9090的资源。
如果没有使用CORS显然会被浏览器的同源策略限制从而报错,如下:
分别查看请求头:
GET /corsget HTTP/1.1
Host: dev.jd.com:9090
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1
Accept: */*
Origin: http://dev.jd.com:9091
Referer: http://dev.jd.com:9091/index.html
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
通过请求头可以看出来其中包含了很多简单请求定义的字段以外的字段,但是却并没有触发预检请求,这是因为这些头字段并不是人为设置的。
响应头:
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 16
ETag: W/"10-oV4hJxRVSENxc/wX8+mA4/Pe4tA"
Date: Mon, 06 Apr 2020 09:45:36 GMT
Connection: keep-alive
修改服务端程序,让响应头带上标识,告诉浏览器允许源http://dev.jd.com:9091上的web应用访问不同源(http://dev.jd.com:9090)上的资源/corsget。
请求头同上,响应头如下:
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8
Content-Length: 16
ETag: W/"10-oV4hJxRVSENxc/wX8+mA4/Pe4tA"
Date: Mon, 06 Apr 2020 09:58:06 GMT
Connection: keep-alive
可以看到多了一个Access-Control-Allow-Origin这个字段,该字段表示被允许访问该资源的不同源,这里指定了*表示告诉浏览器任何源都可以访问该资源。
通过观察还可以发现请求头中有字段Origin正好对应的是就是http://dev.jd.com:9091web应用所在的源。
非简单请求
上面简单请求的条件只要有一个没有满足就会变成非简单请求。非简单请求会首先发送一个预检请求询问服务端是否允许跨域,服务端允许后才会发送真正的请求。
注:chrome的network面板中看不到预检请求,可以查看 chrome://flags/#out-of-blink-cors 配置,改成 disabled 后重启 Chrome ,或者换个浏览器Firefox是可以看到的,目前是74.0版本。
预检的请求头:
OPTIONS /corsget HTTP/1.1
Host: dev.jd.com:9090
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Access-Control-Request-Method: GET
Access-Control-Request-Headers: content-type
Origin: http://dev.jd.com:9091
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1
Accept: */*
Referer: http://dev.jd.com:9091/index.html
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
通过人为修改请求头的content-type为application/json将原本的简单请求变成了非简单请求,因为application/json并不在简单请求的三种content-type中。
这样就需要首先发送一个预检请求。
可以看到请求头中有如下字段:
-
Access-Control-Request-Method: GET
用于预检请求,将实际发送的请求的method告知服务器
-
Access-Control-Request-Headers: content-type
用于预检请求,将实际发送的请求头(不满足简单请求条件)告知给服务器
-
Origin: http://dev.jd.com:9091
告诉服务器请求源
预检响应头:
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: OPTIONS
Access-Control-Allow-Headers: Content-Type
Content-Type: text/html; charset=utf-8
Content-Length: 0
ETag: W/"0-2jmj7l5rSw0yVb/vlWAYkK/YBwk"
Date: Mon, 06 Apr 2020 10:57:40 GMT
Connection: keep-alive
可以看到如下字段:
-
Access-Control-Allow-Origin: *
告诉浏览器任何源都可访问该资源
-
Access-Control-Allow-Methods: OPTIONS
告诉浏览器options被允许跨域访问该资源。options并不在简单请求被允许的method中,但是预检请求确是options,所以需要指定options被允许。 -
Access-Control-Allow-Headers: Content-Type
告诉浏览器三个Content-Type值之外的Content-Type值被允许跨域访问该资源。因为application/json并不在简单请求的三个content-type中。
然后发送实际的get请求,请求头如下:
GET /corsget HTTP/1.1
Host: dev.jd.com:9090
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Origin: http://dev.jd.com:9091
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1
Content-Type: application/json
Accept: */*
Referer: http://dev.jd.com:9091/index.html
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
实际请求的响应头如下:
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8
Content-Length: 16
ETag: W/"10-oV4hJxRVSENxc/wX8+mA4/Pe4tA"
Date: Mon, 06 Apr 2020 10:57:40 GMT
Connection: keep-alive
可以看到实际请求的响应头中并没有复杂的访问控制类型的HTTP头,只有一个Access-Control-Allow-Origin: *。在实际请求的响应头中这个字段是必须的,如果没有这个头部即使预检通过了,实际发送的请求还是会失败,返回的结果还是会被浏览器拦截,并不会返回给脚本,并报错。
跨域请求和凭证
Ajax和Fetch的跨域请求默认不会携带凭证。可以通过Ajax/Fetch的设置让发送请求的时候带上凭证,但是如果服务端并不允许携带凭证的跨域请求,那么理所当然的跨域请求会失败。
Ajax: xhr.withCredentials = true
Fetch: fetch(url, {credentials: 'include', mode: 'cors'})
当服务端设置 Access-Control-Allow-Credentials: true 允许跨域请求携带凭证的时候对于Access-Control-Allow-Origin还有一个限制,就是值不能为*。
那么我们就需要在后端设置上指定允许携带凭证跨域的源,如果有多个怎么办呢?因为Access-Control-Allow-Origin这个字段并不能设置多个值,可以通过代码获取请求头的Origin来判断是否允许获取该资源,允许的话将Origin值设置给响应的HTTP头字段Access-Control-Allow-Origin即可。
仔细观察就可以发现Access-Control-Allow-Credentials: true这个响应头在预检和实际请求的响应头中被返回了两次(一次都不能少,否则会报错,一样是跨域错误),但是之前设置的Access-Control-Allow-Headers: Content-Type只有在预检的时候才会被返回。
其实关于Access-Control的HTTP头还有一个是控制预检请求的缓存时间的,就是Access-Control-Max-Age单位是秒。
所以初步猜测Access-Control-Allow-Credentials这个响应头并不能被缓存?
问题:
脚本会发送跨域请求,Origin指向的是HTML所在源还是脚本所在源呢?
参考:
- HTTP访问控制(CORS)
- Server-Side Access Control
- Fetch