跨域

摩森特沃 2021年08月29日 686次浏览

声明:本文多处直接引用了文末的参考链接中的内容,如有侵权,请联系删除

跨域问题的由来

相信很多人都或多或少了解过跨域问题,尤其在现如今前后端分离大行其道的时候。

你在本地开发一个前端项目,这个项目是通过 tomcat 直接运行的,端口是8080,而服务端是通过 spring boot 提供的,端口号是8088。

当你调用一个服务端接口时,很可能得到类似下面这样的一个错误:

跨域问题

然后在发送请求的地方debug,会发现返回的 response 是 undefined 的,并且 message 消息中只有一个"Network Error"。

看到这里你应该要知道,你遇到跨域问题了。

但是你需要明确的一点是,这个请求已经发出去了,服务端也接收到并处理了,但是返回的响应结果不是浏览器想要的结果,所以浏览器将这个响应的结果给拦截了,这就是为什么你看到的response是undefined。

浏览器的同源策略

那浏览器为什么会将服务端返回的结果拦截掉呢?

这就需要我们了解浏览器基于安全方面的考虑,而引入的 同源策略(same-origin policy) 了。

早在1995年,Netscape 公司就在浏览器中引入了“同源策略”。最初的 “同源策略”,主要是限制Cookie的访问,A网页设置的 Cookie,B网页无法访问,除非B网页和A网页是“同源”的。

那么怎么确定两个网页是不是“同源”呢,所谓“同源”就是指"协议+域名+端口"三者相同,即便两个不同的域名指向同一个ip地址,也非同源。

web地址构成

对于http://store.company.com/dir/page.html 这个域而言,
协议是 http://, ·
域名是 store.compay.com,
端口号是默认端口 80,
其域名检测的结果如下:

http://store.company.com/dir2/other.html,同源
http://store.company.com/dir/inner/another.html,同源
https://store.company.com/secure.html,协议不同,不同源
http://store.company.com:81/dir/etc.html,端口不同,不同源
http://news.company.com/dir/other.html,域名不同,不同源

浏览器同源策略,是为了在用户打开网站时保护网站自身的 Cookie、Storage、和服务器等隐私数据,如果没有同源策略,意味着:当用户同时打开黑客网站 hacker.com 和银行网站 bank.com 时,hanker.com 中的 JavaScript 脚本可以随意访问 bank.com 域内的 Cookie 等数据,可以随意利用 JavaScript 在 bank.com 中输入表单并提交表单,可以随意获取 bank.com 的 Cookie 数据后向 bank.com 的服务器发送 Ajax 请求,这样的结果就是 bank.com 的数据会被随意窃取

同源策略限制哪些行为

上面说了 同源策略是一个安全机制,他本质是限制了从一个源加载的文档或脚本如何与来自另一个源的资源进行交互,这是一个用于隔离潜在恶意文件的重要安全机制。

随着互联网的发展,"同源策略"越来越严格,不仅限于Cookie的读取。目前,如果非同源,共有三种行为受到限制。

  • Cookie、LocalStorage 和 IndexDB 无法读取。
  • DOM 无法获得。
  • 请求的响应被拦截。

虽然这些限制是必要的,但是有时很不方便,合理的用途也会受到影响,所以为了能够获取非“同源”的资源,就有了跨域资源共享。

跨域资源共享

看到这里你应该明白,为什么文章开头的请求会被拦截了,原因就是请求的源和服务端的源不是“同源”,而服务端又没有设置允许的跨域资源共享,所以请求的响应被浏览器给拦截掉了。

目前主流的跨域解决机制是使用 CORS,它是W3C的标准,前端代码几乎不需要做任何改动,浏览器就可以自动完成,全称是"跨域资源共享"(Cross Origin Resource Sharing),它允许浏览器向跨源服务器,发出 XMLHttpRequest 请求,从而克服了只能发送同源请求的限制。

CORS实现机制

跨域资源共享机制

当一个资源(origin)通过脚本向另一个资源(host)发起请求,而被请求的资源(host)和请求源(origin)是不同的源时(协议、域名、端口不全部相同),浏览器就会发起一个跨域 HTTP 请求,并且浏览器会自动将当前资源的域添加在请求头中一个叫 Origin 的 Header 中。

当然了,有三个标签本身就是允许跨域加载资源的:

  • <img src=XXX>
  • <link href=XXX>
  • <script src=XXX>

比如某个网站的首页 http://domain-a.com/index.html 通过 <img src="http://domain-b.com/image.jpg" /> 来加载其他域上的图片,除此之外还有诸如通过 CDN 节点引入css和js文件的方式。

出于安全原因,浏览器限制从脚本内发起的跨域 HTTP 请求。例如,XMLHttpRequest 和 Fetch API 遵循同源策略。也就是说使用这些 API 的 Web 应用程序只能从加载应用程序的同一个域请求 HTTP 资源,除非响应报文中包含了正确 CORS 响应头。

通过在响应报文中设置额外的 HTTP 响应头来告诉浏览器,运行在某个 origin 上的 Web 应用被准许访问来自不同源服务器上的资源,此时浏览器就不会将该响应拦截掉了。

额外的 HTTP 响应头

响应头是否必须含义
Access-Control-Allow-Origin该字段表示,服务端接收哪些来源的域的请求
Access-Control-Allow-Credentials是否可以向服务端发送Cookie,默认是 false
Access-Control-Expose-Headers可以向请求额外暴露的响应头

其中只有 Access-Control-Allow-Origin 是必须的,该响应头的值可以是请求的 Origin 的值,也可以是 * ,表示服务端接收所有来源的请求。

当浏览器发起 CORS 请求时,默认只能获得6个响应头的值:

Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma

如果还需要返回其他的响应头给前端,则可以通过在 Access-Control-Expose-Headers 中指定。

CORS的两种请求类型

CORS有两种类型的请求,分别是:简单请求(simple request)和非简单请求(not-so-simple request)

只要同时满足以下两大条件,就属于简单请求:

  • 请求方法是以下三种方法之一:
    • HEAD
    • GET
    • POST
  • HTTP的头信息不超出以下几种字段:
    • Accept
    • Accept-Language
    • Content-Language
    • Last-Event-ID
    • Content-Type:只限于三个值 application/x-www-form-urlencoded 、multipart/form-data、text/plain

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

CORS 有两种不同类型的请求的原因

CORS 规范要求,对那些可能对服务器数据产生副作用的 HTTP 请求方法(特别是 GET 以外的 HTTP 请求,或者搭配某些 MIME 类型的 POST 请求),浏览器必须首先使用 OPTIONS 方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨域请求。

服务器确认允许之后,浏览器才能发起实际的 HTTP 请求。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(包括 Cookies 和 HTTP 认证相关的数据)。

CORS 的工作流程

  • 首先浏览器判断请求是简单请求还是非简单请求,如果是非简单请求,那么在进行真正的请求之前,浏览器会先使用 OPTIONS 方法发送一个预检请求 (preflight request),OPTIONS 是 HTTP/1.1 协议中定义的方法,用以从服务器获取更多信息
// 预检请求的方法不会对服务器资源产生影响,预检请求中同时携带了下面几个首部字段
Access-Control-Request-Method: 这个字段表明了请求的方法
Access-Control-Request-Headers: 这个字段表明了这个请求的 Headers
Origin: 这个字段表明了请求发出的域
  • 服务端收到预检请求后,会以 Access-Control-* response headers 的形式对客户端进行回复,以下字段都需要在服务端添加,所以从实践的角度来讲添加 CORS 支持基本上是 server 端的工作
Access-Control-Allow-Origin: 能够被允许发出这个请求的域名,也可以使用 * 来表明允许所有域名,特别注意啊:如果要发送Cookie,Access-Control-Allow-Origin 就不能设为星号,必须指定明确的、与请求网页一致的域名
Access-Control-Allow-Methods: 用逗号分隔的被允许的请求方法的列表
Access-Control-Allow-Headers: 用逗号分隔的被允许的请求头部字段的列表
Access-Control-Max-Age: 这个 preflight 能被缓存的最长时间,在缓存时间内,同一个请求不会再次发出 preflight 请求
  • 发送真实请求,并携带正确的Header信息

简单请求处理流程

非简单请求处理流程

怎样实现CORS

知道了CORS的实现机制之后,我们就可以解决遇到的CORS的问题了。

通过JSONP

JSON实现原理

JSONP 本质不是 Ajax 请求,是 script 标签请求。JSONP 请求本质上是利用了 “Ajax 请求会受到同源策略限制,而 script 标签请求不会” 这一点来绕过同源策略。JSONP请求一定需要对方的服务器做支持才可以。

一个简单的 JSONP 实现:

function msg(content){
  alert(content)
}

function jsonp(req){
    var script = document.createElement('script');
    var url = req.url + '?callback=' + req.callbackName;
    script.src = url;
    document.getElementsByTagName('head')[0].appendChild(script); 
}

jsonp({url:'http://www.domain.com/say', callbackName:msg})

服务端代码:

// server.js
let express = require('express')
let app = express()
app.get('/say', function(req, res) {
  let { callback } = req.query
  // 返回的内容是脚本的内容
  res.end(`${callback}('I am server data')`)
})
app.listen(3000)

JSONP实现步骤

  • 客户端发送 script 请求,参数中带着处理返回数据的回调函数的名字 (通常是 callback),如请求 script 的 url 是:http://www.test.com?callback=foo
  • 服务端收到请求,以回调函数名和返回数据组成立即执行函数的字符串,比如:其中 callback 的值是客户端发来的回调函数的名字,假设回调函数的名字是 foo (如第一条所示),返回脚本的内容就是:foo(‘I am server data’)
  • 客户端收到 JavaScript 脚本内容后,立即执行脚本,这样就实现了获取跨域服务器数据的目的

很明显,由于 JSONP 技术本质上利用了 script 脚本请求,所以只能实现 GET 跨域请求,这也是 JSONP 跨域的最大限制。由于 server 产生的响应为 json 数据的包装(故称之为 jsonp,即 json padding),形如:foo ({“name”:“Jack”})

使用JSONP的注意事项

  • CORS 请求失败会产生错误,但是为了安全,在 JavaScript 代码层面是无法获知到底具体是哪里出了问题。只能查看浏览器的控制台以得知具体是哪里出现了错误
  • Ajax 请求会自动附带上 Cookie 值用于身份验证。但是对于跨域请求,浏览器不会发送这些身份凭证信息。如果想要在跨域请求中发送 Cookie 中的信息,需要设置特殊的标志位
  • 在 XMLHttpRequest 中,在发送跨域请求前必须要设置 withCredentials 字段为 true
  • 在 Fetch 中,在发送跨域请求前必须要设置 credentials 值为 include (其默认值为 same-origin,即只在同域发送身份验证信息)
  • 即使客户端在发送跨域请求时携带了 withCredentials 或者 credentials 字段并赋了正确的值,如果服务器端的响应中未携带 Access-Control-Allow-Credentials: true,客户端依然得不到返回值

JSONP和AJAX

很多人以为 JSONP 请求是 Ajax 请求,想这种误解是来源于 jQuery 的 API,在 jQuery 中发送 JSONP 是如此简单:

$.ajax({
    url:'http://外域.com/xxx.php',
    dataType:"jsonp",
    jsonp: "callback",
    jsonpCallback:"ooo",
    success:function(data){
        console.log(data);
    }
});

看起来似乎就是一个 Ajax 请求,但实际上它和 Ajax 请求完全没有一点关系

虽然JSONP 和 AJAX 都是客户端向服务器端发送请求,从服务器端获取数据的方式。但 AJAX 属于同源策略,JSONP 属于非同源策略(支持跨域请求)。JSONP优点是简单兼容性好,可用于解决主流浏览器的跨域数据访问的问题。缺点是仅支持 GET 方法具有局限性,不安全可能会遭受XSS攻击。

利用反向代理服务器

同源策略是浏览器需要遵循的标准,而如果是服务器向服务器请求就无需遵循同源策略。所以通过反向代理服务器可以有效的解决跨域问题,代理服务器需要做以下几个步骤:

  • 接受客户端的请求
  • 将请求转发给实际的服务器
  • 将服务器的响应结果返回给客户端

Nginx就是类似的反向代理服务器,可以通过配置Nginx代理来解决跨域问题。

服务端支持CORS

最安全的还是服务端来设置允许哪些来源的请求,即服务端在接收到请求之后,对允许的请求源设置 Access-Control-Allow-Origin 的响应头。

在Java后端,实现CORS跨域请求有以下几种方式

  • 返回新的CorsFilter
  • 重写 WebMvcConfigurer
  • 使用注解 @CrossOrigin
  • 手动设置响应头 (HttpServletResponse)
  • 自定web filter 实现跨域

注意:

  • CorFilter / WebMvConfigurer / @CrossOrigin 需要 SpringMVC 4.2以上版本才支持,对应springBoot 1.3版本以上
  • 上面前两种方式属于全局 CORS 配置,后两种属于局部 CORS配置。如果使用了局部跨域是会覆盖全局跨域的规则,所以可以通过 @CrossOrigin 注解来进行细粒度更高的跨域资源控制。
  • 无论哪种方案,最终目的都是修改响应头,向响应头中添加浏览器所要求的数据,进而实现跨域

以下为实现CORS跨域请求的几种方式的详细介绍

返回新的 CorsFilter(全局跨域)

在任意配置类,返回一个 新的 CorsFilter Bean ,并添加映射路径和具体的CORS配置路径。

@Configuration
public class GlobalCorsConfig {
    @Bean
    public CorsFilter corsFilter() {
        //1. 添加 CORS配置信息
        CorsConfiguration config = new CorsConfiguration();
        //放行哪些原始域
        config.addAllowedOrigin("*");
        //是否发送 Cookie
        config.setAllowCredentials(true);
        //放行哪些请求方式
        config.addAllowedMethod("*");
        //放行哪些原始请求头部信息
        config.addAllowedHeader("*");
        //暴露哪些头部信息
        config.addExposedHeader("*");
        //2. 添加映射路径
        UrlBasedCorsConfigurationSource corsConfigurationSource = new UrlBasedCorsConfigurationSource();
        corsConfigurationSource.registerCorsConfiguration("/**",config);
        //3. 返回新的CorsFilter
        return new CorsFilter(corsConfigurationSource);
    }
}

重写 WebMvcConfigurer(全局跨域)

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                //是否发送Cookie
                .allowCredentials(true)
                //放行哪些原始域
                .allowedOrigins("*")
                .allowedMethods(new String[]{"GET", "POST", "PUT", "DELETE"})
                .allowedHeaders("*")
                .exposedHeaders("*");
    }
}

使用注解 (局部跨域)

  • 在控制器(类上)上使用注解 @CrossOrigin:
/**
 * 在类上加注解
 */
@CrossOrigin({"http://127.0.0.1:9528", "http://localhost:9528"})
@RestController
public class UserController {

}
  • 在方法上使用注解 @CrossOrigin:
@RestController
public class UserController {
    @Resource
    private UserFacade userFacade;
    /**
     * 在方法上加注解
     */
    @GetMapping(ApiConstant.Urls.GET_USER_INFO)
    @CrossOrigin({"http://127.0.0.1:9528", "http://localhost:9528"})
    public PojoResult<UserDTO> getUserInfo() {
        return userFacade.getUserInfo();
    }
}

手动设置响应头(局部跨域)

使用 HttpServletResponse 对象添加响应头(Access-Control-Allow-Origin)来授权原始域,这里 Origin的值也可以设置为 “*”,表示全部放行

@RequestMapping("/index")
public String index(HttpServletResponse response) {
    response.addHeader("Access-Allow-Control-Origin","*");
    return "index";
}

使用自定义filter实现跨域

  • 首先编写一个过滤器,可以起名字为MyCorsFilter.java
package com.mesnac.aop;

import java.io.IOException;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
@Component
public class MyCorsFilter implements Filter {

  public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
    HttpServletResponse response = (HttpServletResponse) res;
    response.setHeader("Access-Control-Allow-Origin", "*");
    response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
    response.setHeader("Access-Control-Max-Age", "3600");
    response.setHeader("Access-Control-Allow-Headers", "x-requested-with,content-type");
    chain.doFilter(req, res);
  }
  public void init(FilterConfig filterConfig) {}
  public void destroy() {}
}
  • 在web.xml中配置这个过滤器,使其生效
<!-- 跨域访问 START-->
<filter>
 <filter-name>CorsFilter</filter-name>
 <filter-class>com.mesnac.aop.MyCorsFilter</filter-class>
</filter>
<filter-mapping>
 <filter-name>CorsFilter</filter-name>
 <url-pattern>/*</url-pattern>
</filter-mapping>
<!-- 跨域访问 END  -->

其他扩展

同站和Cookie

同源策略作为浏览器的安全基石,以协议+域名+端口三者来作为判断依据,限制比较严格,相对而言,Cookie中的“同站”判断就比较宽松:只要两个 URL 的 eTLD+1 相同即可,不需要考虑协议和端口。其中,eTLD 表示有效顶级域名,注册于 Mozilla 维护的公共后缀列表(Public Suffix List)中,例如,.com、.co、.uk、.github.io 等。而 eTLD+1 则表示,有效顶级域名+二级域名,例如taobao.com等

举几个例子,www.taobao.com和www.baidu.com是跨站,www.a.taobao.com和www.b.taobao.com是同站,a.github.io和b.github.io是跨站(注意是跨站)

Cookie可以跨二级域名来访问(同站),跨站时会受到浏览器SameSite的约束,SameSite的取值范围和含义如下

  • Strict:Strict 最为严格,完全禁止第三方 Cookie,跨站点时,任何情况下都不会发送 Cookie。换言之,只有当前网页的 URL 与请求同站时,才会带上 Cookie
Set-Cookie: CookieName=CookieValue; SameSite=Strict;
// 这个规则过于严格,可能造成非常不好的用户体验。
// 比如,当前网页有一个 GitHub 链接,用户点击跳转就不会带有 GitHub 的 Cookie,跳转过去总是未登陆状态
  • Lax:允许部分第三方请求携带 Cookie,大多数情况也是不发送第三方 Cookie,但是导航到目标网址的 Get 请求除外
Set-Cookie: CookieName=CookieValue; SameSite=Lax;

导航到目标网址的 GET 请求,只包括三种情况:链接,预加载请求,GET 表单。详见下表

请求类型示例正常情况Lax
链接<a href="..."></a>发送 Cookie发送 Cookie
预加载<link rel="prerender" href="..."/>发送 Cookie发送 Cookie
GET 表单<form method="GET" action="...">发送 Cookie发送 Cookie
POST 表单<form method="POST" action="...">发送 Cookie不发送
iframe<iframe src="..."></iframe>发送 Cookie不发送
AJAX$.get("...")发送 Cookie不发送
Image<img src="...">发送 Cookie不发送

设置了 Strict 或 Lax 以后,基本就杜绝了 CSRF 攻击。当然,前提是用户浏览器支持 SameSite 属性

  • None:无论是否同站都会发送 Cookie,但设置为None时必须同时将 secure 属性设置为true,这也意味着你的后端服务域名必需使用https协议访问,否则无效
Set-Cookie: widget_session=abc123; SameSite=None; Secure

跨域与跨站

跨站一定跨域,跨域不一定跨站

withCredentials和sameSite的冲突

withCredentials属性值和samesite值会出现冲突,此时以samesite的属性值为主

例如:在a.demo2.com域名下,ajax请求a.demo.com的api,此时是跨站且跨域的,需要设置withCredentials才能带上cookie,但如果此时cookie中的samesite值为Lax或Strict,此ajax请求是不能携带cookie的

一级域名、二级域名、三级域名

一般来说就是比如www.baidu.com:.com是一级域名;baidu是二级域名;www是三级域名。其后同理

引用参考