跨域那些事
一、(Why 跨域)浏览器同源策略
1.1 同源的定义
一个完整的 url 的各个部分如下:
同源:两个 URL 的协议、主机名(host)、端口(port)一致。
1.2 同源策略
注:同源是浏览器的限制,也就是说后端之间相互调用是没有同源的限制的。
同源策略限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互。
注意的是同源策略仅适用于脚本,这意味着某网站可以通过相应的HTML标签(a、img、iframe、script 等有 src 属性的标签)访问不同来源网站上的图像、CSS和动态加载脚本等资源。
二、跨域资源共享(Cors)
CORS 是一个 W3C 标准,全称是"跨域资源共享"(Cross-origin resource sharing)。
- 它允许浏览器向跨源服务器,发出
XMLHttpRequest
请求,从而克服了AJAX只能同源使用的限制。 - 它允许服务端来指定哪些主机可以从这个服务端加载资源。所以,要想实现Cors机制的跨域请求,是需要浏览器和服务器同时支持的。
浏览器将 CORS 请求分成两类:
- 简单请求(simple request)
- 非简单请求(not-so-simple request)
2.1 Host、Referer、Origin 请求头
请求头 | 格式 | 描述 |
---|---|---|
Host | 域名 + 端口 | 浏览器访问远程主机的 ip:port |
Referer | 协议+域名+端口+路径+参数 | 当前请求的来源页面的地址,服务端一般使用 Referer 首部识别访问来源,可能会以此进行统计分析、日志记录以及缓存优化等 |
Origin | 协议+域名+端口 | 标识请求来自于哪里,常见于 Cors 请求和同域POST请求 |
可以看到 Referer 与 Origin 功能相似,前者一般用于统计和阻止盗链,后者用于 CORS 请求。但是还是有几点不同:
- 只有跨域请求或者同域 POST请求才会携带 Origin 请求头;而 Referer 只要浏览器能获取到都会携带。
- 若浏览器获取不到请求来源地址时,Referer 头不会发送,但 Origin 头依然会发送,只不过值为 null(为了标识这是个 cors 请求呀)。
注:在下列几种情况下,获取不到请求的来源地址
- 来源页面协议为File或者Data URI(如页面从本地打开的)
- 来源页面是Https,而目标URL是http
- 浏览器地址栏直接输入网址访问,或者通过浏览器的书签直接访问
- 使用JS的location.href跳转
2.2 Cors 简单请求
2.2.1 定义
请求不是以更新(添加、修改和删除)资源为目的,服务端对请求的处理不会导致自身维护资源的改变。对于简单跨域资源请求来说,浏览器将两个步骤(取得授权和获取资源)合二为一,由于不涉及到资源的改变,所以不会带来任何副作用。
服务端接收到跨域简单请求时,还是会处理这个请求并返回(响应),但是当响应头中没有
Access-Control-Allow-Origin
属性,或者属性的值不符合当前 Origin 时浏览器会丢弃这个响应。
对于一个请求,必须同时符合如下要求才被划为简单请求:
(1) 请求方法是以下三种方法之一:
- HEAD
- GET
- POST
(2)HTTP的头信息不超出以下几种字段:
- Accept
- Accept-Language
- Content-Language
- Last-Event-ID
- Content-Type,其中它的值必须如下其一:
- application/x-www-form-urlencoded(表单提交)
- multipart/form-data(表单提交)
- text/plain(GET 请求,包括 ajax 发起的 GET 请求)
注:这是为了兼容表单(form),因为历史上表单一直可以发出跨域请求。AJAX 的跨域设计就是,只要表单可以发,AJAX 就可以直接发。
除此之外的请求都为非简单请求(也可称为复杂请求)。非简单请求可能对服务端资源改变,因此Cors规定浏览器在发出此类请求之前必须有一个“预检(Preflight)”机制,这也就是我们熟悉的OPTIONS
请求。
2.2.2 请求流程
-
若浏览器发送的是个跨域请求,http请求中就会携带一个名为Origin的头表明自己的“位置”,如
Origin: http://localhost:5432
-
服务端接到请求后,就可以根据传过来的Origin头做逻辑,决定是否要将资源共享给这个源喽。而这个决定通过响应头
Access-Control-Allow-Origin
来承载,它的value值可以是任意值,有如下情况:- 有此头,值有如下可能情况:
- 值为
*
,通配符,允许所有的Origin共享此资源 - 值为http://localhost:5432(也就是和Origin相同),共享给此Origin
- 值不是http://localhost:5432(也就是和Origin不相同),不共享给此Origin
- 值为
- 有此头,值有如下可能情况:
-
- 无此头:不共享给此origin
-
浏览器接收到Response响应后,会去提取
Access-Control-Allow-Origin
这个头。然后根据上述规则来决定要接收此响应内容还是拒绝
注:
- Access-Control-Allow-Origin 响应头只能有1个(不能添加多个)。
- value值写为
http://aa.com,http://bb.com
这种也属于一个而非两个值,此时这两个来源地址都不会生效。
2.2.3 示例
前端 ajax 发起跨域 get 请求:
// 跨域请求
$.get("http://localhost:8080/cors", function (result) {
$("#content").append(result).append("<br/>");
});
服务端结果:
INFO ...CorsServlet - 收到请求:/cors,方法:GET, Origin头:http://localhost:63342
浏览器结果:
若想让请求正常,只需在服务端响应头里“加点料”就成:
...
resp.setHeader("Access-Control-Allow-Origin","http://localhost:63342");
resp.getWriter().write("hello cors...");
...
再次请求,结果成功:
对于简单请求来讲,服务端只需要设置Access-Control-Allow-Origin这个一个头即可,一个即可。
2.3 非简单请求
2.3.1 定义
由于非简单请求可能对服务端资源改变,因此Cors规定浏览器在发出此类请求之前必须有一个“预检(Preflight)”机制,这也就是我们熟悉的OPTIONS
请求。
这个请求很特殊,它不包含主体(无请求参数、请求体等),主要就是将一些凭证、授权相关的辅助信息放在请求头里交给服务器去做决策。因此它除了携带 Origin 请求头外,还会额外携带如下两个请求头:
- Access-Control-Request-Method:真正请求的方法
- Access-Control-Request-Headers:真正请求的会额外发送的头信息字段
服务端在接收到此类请求后,就可以根据其值做逻辑决策啦。如果服务器允许预检请求,则响应 200 状态码(但还需浏览器根据响应头自行判断),否则返回 400 或者 403 状态码。
预检请求的响应中(可能)会包含如下响应头:
- Access-Control-Allow-Origin:允许访问的源(Origin)
- Access-Control-Allow-Methods:允许实际请求的Http方法们
- Access-Control-Allow-Headers:允许实际请求的请求头们
- Access-Control-Max-Age:允许浏览器缓存此结果多久,单位:秒。有了缓存,以后就不用每次请求都发送预检请求啦
- Access-Control-Expose-Headers:实际请求允许暴露给前端的响应头
2.3.2 请求流程
预检请求完成后,有个关键点,便是浏览器拿到预检请求的响应后的处理逻辑,这里描述如下:
- 先通过自己的Origin匹配预检响应中的Access-Control-Allow-Origin的值,若不匹配就结束请求,若匹配就继续下一步验证。
- 拿到预检响应中的Access-Control-Allow-Methods头。若此头不存在,则进行下一步,若存在则校验预检请求头Access-Control-Request-Method的值是否在此列表中,在其内继续下一步,否则失败
- 拿到预检响应中的Access-Control-Request-Headers头。同请求头中的Access-Control-Allow-Headers值记性比较,全部包含在内则匹配成功,否则失败
以上全部匹配成功,就代表预检成功,可以开始发送正式请求了。
2.3.3 示例
非简单请求的模拟非常简单,随便打破一个简单请求的约束即可。比如我们先在上面get请求的基础上自定义个请求头:
$.ajax({
type: "get",
url: "http://localhost:8080/cors",
headers: {secret:"kkjtjnbgjlfrfgv",token: "abc123"}
});
服务端代码:
@Slf4j
@WebServlet(urlPatterns = "/cors")
public class CorsServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String requestURI = req.getRequestURI();
String method = req.getMethod();
String originHeader = req.getHeader("Origin");
log.info("收到请求:{},方法:{}, Origin头:{}", requestURI, method, originHeader);
resp.setHeader("Access-Control-Allow-Origin","http://localhost:63342");
resp.setHeader("Access-Control-Expose-Headers","token,secret");
resp.setHeader("Access-Control-Allow-Headers","token,secret"); // 一般来讲,让此头的值是上面那个的【子集】(或相同)
resp.getWriter().write("hello cors...");
}
}
点击按钮,浏览器发送请求,结果为:
服务端没有任何日志输出,也就是说浏览器并未把实际请求发出去。什么原因?查看OPTIONS请求的返回一看便知:
根本原因:OPTIONS 的响应头里并未含有任何跨域相关信息,虽然预检通过(注意:这个预检是通过的哟),但预检的结果经浏览器判断此跨域实际请求不能发出,所以给拦下来了。
从代码层面问题就出现在resp.setHeader(xxx,xxx)
放在了处理实际方法的Get方法上,显然不对嘛,应该放在doOptions()
方法里才行:
@Override
protected void doOptions(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
super.doOptions(req, resp);
resp.setHeader("Access-Control-Allow-Origin","http://localhost:63342");
resp.setHeader("Access-Control-Expose-Headers","token,secret");
resp.setHeader("Access-Control-Allow-Headers","token,secret"); // 一般来讲,让此头的值是上面那个的【子集】(或相同)
}
在此运行,一切正常:
值得特别注意的是:设置跨域的响应头这块代码,在处理真实请求的doGet里也必须得有,否则服务端处理了,浏览器“不认”也是会出跨域错误的。
注:Access-Control-Allow-Headers 请求头里必须包含你的请求的自定义的 Header(标准的header不需要包含),否则依旧跨域失败哦。
2.4 其他
2.4.1 响应头授权
Access-Control-Expose-Headers 响应头规定了哪些响应头可以暴露给前端,默认情况下以下 6 个响应头无需特别的显示指定就支持:
- Cache-Control
- Content-Language
- Content-Type
- Expires
- Last-Modified
- Pragma
若不在此值里面的头将不会返回给前端(其实返回了,只是浏览器让其对前端不可见了而已,对JavaScript也不可见哦)。
但是,这种细粒度控制 header 的机制对简单请求是无效的,只针对于非简单请求(也叫复杂请求)。
2.4.2 Access-Control-Max-Age使用细节
Access-Control-Max-Age 用于控制浏览器缓存预检请求结果的时间,这里存在一些使用细节你需要注意:
-
若浏览器禁用了缓存,也就是勾选了
Disable cache
,那么此属性无效。也就说每次都还得发送OPTIONS请求 -
判断此缓存结果的因素有两个:
-
- 必须是同一URL(也就是Origin相同才会去找对应的缓存)
- header变化了,也会重新去发OPTIONS请求
2.4.3 Access-Control-Allow-Origin 支持多 Origin
https://mp.weixin.qq.com/s/IJIL2UwDACG5POojY37WZg
三、跨域 Cookie 共享
3.1 Cookie 的域 & 路径
我觉得 Cookie 的域更像是“站”的概念,只要是同站的就会发送过去。
-
domain:创建此 cookie 的服务器主机名(or域名),服务端设置。
- 但是不能将其设置为服务器所属域之外的域。
-
- 注:端口和域无关,也就是说Cookie的域是不包括端口的。
-
path:域下的哪些目录可以访问此cookie,默认为/,表示所有目录均可访问此cookie
3.2 跨域 Cookie 共享的关键点
默认情况下,浏览器是不会去为你保存下跨域请求响应的Cookie的。跨域请求的Response响应了即使有Set-Cookie
响应头(且有值),浏览器收到后也是不会保存此cookie的。
为了实现跨域 Cookie 共享需要满足以下几个条件:
-
前后端的主机名(或根域名)必须相同
-
服务的响应头中需要携带
Access-Control-Allow-Credentials
,并且值为 true。resp.setHeader("Access-Control-Allow-Credentials", "true");
-
浏览器发起 ajax 请求时需要指定 withCredentials 为 true。
$.ajax({ url: "http://localhost:8080/corscookie", type: "GET", xhrFields: { withCredentials: true }, crossDomain: true });
-
响应头中的 Access-Control-Allow-Origin 值不能是通配符
*
,必须是指定的域名。
Access-Control-Allow-Credentials
该字段可选。它的值是一个布尔值,表示是否允许发送Cookie。默认情况下,Cookie不包括在CORS请求之中。设为
true
,即表示服务器明确许可,Cookie可以包含在请求中,一起发给服务器。这个值也只能设为true
,如果服务器不要浏览器发送Cookie,删除该字段即可。withCredentials 属性
上面说到,CORS请求默认不发送Cookie和HTTP认证信息。如果要把Cookie发到服务器,一方面要服务器同意,指定
Access-Control-Allow-Credentials
字段。另一方面,开发者必须在AJAX请求中打开
withCredentials
属性。var xhr = new XMLHttpRequest(); xhr.withCredentials = true;
否则,即使服务器同意发送Cookie,浏览器也不会发送。或者,服务器要求设置Cookie,浏览器也不会处理。
但是,如果省略
withCredentials
设置,有的浏览器还是会一起发送Cookie。这时,可以显式关闭withCredentials
。xhr.withCredentials = false;
需要注意的是,如果要发送Cookie,
Access-Control-Allow-Origin
就不能设为星号,必须指定明确的、与请求网页一致的域名。同时,Cookie依然遵循同源政策,只有用服务器域名设置的Cookie才会上传,其他域名的Cookie并不会上传,且(跨源)原网页代码中的document.cookie
也无法读取服务器域名下的Cookie。
四、SameSite
强调:SameSite 与跨域 Cookie 没有关系,SameSite 的作用是来明确 Cookie 的使用范围。
举两个例子:
-
52 的前端调用 192 的后台的登录接口,能否戴上 Cookie?
答:不能,因为不满足跨域 Cookie 的条件:“前后端的主机名(或根域名)必须相同”
-
52 的前端中嵌入了一个 192 的前端,调用 192 后台(端口不同)的登录接口,能否戴上 Cookie?
- 没有 SameSite 限制:可以,因为请求的来源是 192,满足跨域 Cookie 的概念。
- 有 SameSite 的限制:不可以,默认策略为 Lax,限制了 iframe 的行为,他会和地址栏上的地址进行对比,判断是否同站,同站才能戴上。
同域:两个 url 的协议 + 主机名 + 端口一致
同站:请求 url 的主机名(或根域名)和地址栏中的相同即可,不需要考虑协议和端口
有了 SameSite 之后,只有同站的请求才会携带 Cookie,其他的请求参见下表:
3.1 为啥要搞个 SameSite
为了防止 csrf 攻击:https://www.ruanyifeng.com/blog/2019/09/cookie-samesite.html
- 在 A 网站登录了
- B 网站有一个诱导链接,是 A 网站的转账接口
- 一不小心点击了,把 cookie 带过去,认证成功,从而转账成功
参考资料
https://mp.weixin.qq.com/s/dynx7wrSINYFKZgGPcD3zQ
https://www.ruanyifeng.com/blog/2016/04/cors.html
https://www.ruanyifeng.com/blog/2019/09/cookie-samesite.html
问答题
1、为什么要解决跨域?浏览器同源策略。
2、为什么浏览器会有同源策略?防止脚本访问其他域的敏感信息(调用接口的时候 cookie 会携带过去,这样他就能拿到另一个域的资源)。
3、Cors 是什么?跨域资源共享,因为有些资源我是希望暴露给其他域访问的,所以制定了一个规范,可以让服务器端指定那些来源的允许获取我们的资源。
4、已经有了同源策略,那么 SameSite 的作用是什么呢?限制了只有发出请求的站点与地址栏相同才能携带 Cookie,防止 form 和 iframe 导致的 csrf 问题。
- 站:192.168.1.1
5、为什么 SameSite 限制的是“站”而不是“域”呢?因为他限制的是 Cookie(同源限制的是所有跨域请求),所以要从 Cookie 的角度想问题,Cookie 的域 == 站