结者跨域问题

1 背景

近期在做新项目过程中,碰到跨域配置不生效的问题,并且也不是第一次碰到了,每次都需要花费一定的时间处理,没有形成结论性的方法。在过去的工作中,你可能知道跨域,也许也能说个一二,但是你真的懂跨域了吗?

回答三个问题:

  1. 跨域限制存在的目的是什么
  2. 跨域限制到底在保护谁,在限制谁
  3. 都说是浏览器限制,那关后台什么事

跨域问题,对于程序员可以说是无人不知无人不晓的问题,但是由于我们总是站在巨人的肩膀上,因此可能会忽略其中的原理。

希望这篇文章助你理解跨域原理,从此不再重复踩坑。

2 为什么需要跨域

跨源资源共享(CORS,或通俗地译为跨域资源共享)是一种基于 HTTP 头的机制,该机制通过允许服务器标示除了它自己以外的其他源(域、协议或端口),使得浏览器允许这些源访问加载自己的资源。跨源资源共享还通过一种机制来检查服务器是否会允许要发送的真实请求,该机制通过浏览器发起一个到服务器托管的跨源资源的"预检"请求。在预检中,浏览器发送的头中标示有 HTTP 方法和真实请求中会用到的头。

这段话摘自mozilla开发者文档,描述什么是跨域资源共享。用人话就是浏览器通过在http请求头中加入一些配置,然后使用预检请求发送到服务器,服务器根据预检请求头进行校验是否放行。本质就是应用http请求头扩展出的一个功能

2.1 解决什么问题

主要是为了保护浏览器网页客户端的用户数据安全。

出于安全性考虑,浏览器通过各种限制,例如域名、端口等,浏览器认为页面和后台服务在同一个域名下表示的是同一个主机,因此提供的后台服务是可信任的,允许页面访问这个后台资源。这是对用户的最基础的保护机制。

2.2 举个例子

正常场景: 用户登录到a服务https://aaa.com,可以查看所有的用户信息。

攻击情景: 假设用户打开了一个恶意网站https://www.xxxx.com,它试图通过在用户浏览器中运行的js来窃取用户信息。如果没有跨域限制,https://www.xxxx.com 的js代码可以发送跨域请求,到https:/aaa.com 请求用户数据(假设没有鉴权的接口),然后将这些数据发送到它自己的服务器,这样就可以窃取用户的敏感信息。

跨域限制情景: 存在跨域限制,则浏览器会阻止https://www.xxxx.com的js代码直接发出请求到https://aaa.com

允许跨域情景: 现在a服务开发者申请了一个平台管理b的域名https://bbb.com,他需要能跨域访问到a服务,但是会被浏览器跨域限制,这时就需要在a服务的后台添加https://bbb.com,表示后台资源允许平台管理b进行跨域访问。

2.3 到底是浏览器限制还是后台限制

跨域限制并不是浏览器单方面完成的,是双方共同完成的。

上述例子中,跨域限制,浏览器对js代码中发起的非简单请求 ,进行同源比较,也就是浏览器的同源策略 ,对于不满足同源的请求,先发起预检请求,这是一个OPTION类型的请求,请求头带有发起的源地址,后台在收到请求后对请求资源进行校验,如果后台允许该跨域请求,将源设置到响应头中返回,浏览器收到响应,发现后台服务允许此次跨域请求,这时浏览器才发起真实的POST类型的请求。

整个过程中,不难看出,浏览器的工作主要包括:

  1. 非简单请求判断
  2. 同源策略比较
  3. 发起预检请求
  4. 发起真实请求,或被跨域限制

后台的工作主要包括:

  1. 拦截包含特定头的OPTION请求
  2. 匹配校验允许的源地址
  3. 设置响应头

上述过程,仅是http协议的运用,意味着,都是在三次握手建立连接之后实现的。

通过mozill对开发者文档中cors介绍的图,回顾整个过程。

3 跨域实现的落地

3.1 简单请求

浏览器是否发起预检请求,根据当前请求是否为非简单请求来定的,那么哪些属于简单请求呢?

使用下列方法之一:

  1. GET
  2. HEAD
  3. POST

并且仅包含下列范围内的请求头:

  1. Accept
  2. Accept-Language
  3. Content-Language
  4. Content-Type
    1. text/plain
    2. multipart/form-data
    3. application/x-www-form-urlencoded
  5. Range

意味着使用json格式的POST请求,不属于简单请求。

3.2 同源策略

在确定为非简单请求之后,还需要进行同源策略比较,判断ajax请求是否存在跨域访问。那么怎么样才算跨域呢?

如果两个 URL 的协议、主机和端口都相同的话,则这两个 URL 是同源的。这个方案也被称为"协议/主机/端口元组"。

与 URL store.company.com/dir/page.ht... 的源进行对比的示例:

  1. store.company.com/dir2/other.... 同源 只有路径不同
  2. store.company.com/dir/inner/a... 同源 只有路径不同
  3. store.company.com/secure.html 失败 协议不同
  4. store.company.com:81/dir/etc.htm... 失败 端口不同(http:// 默认端口是 80)
  5. news.company.com/dir/other.h... 失败 主机不同

3.3 跨域相关HTTP标头字段

浏览器与后台交互过程中采用请求头中的字段进行校验和限制。

3.3.1 请求头字段

预检请求过程中,浏览器会根据实际请求先转换为OPTION请求,并带上以下字段。

  1. Origin,用来表示请求发起的源地址
  2. Access-Control-Request-Method,实际请求的方法类型
  3. ccess-Control-Request-Headers,实际请求所携带的请求头字段

3.3.2 响应头字段

如果后台允许跨域请求,就设置以下响应头,告诉浏览器允许该域名跨域访问。

  1. Access-Control-Allow-Origin,告诉浏览器允许跨域访问的源,如果没有则浏览器报出跨域限制cors的错误信息
  2. Access-Control-Allow-Methods,允许使用的请求方法
  3. Access-Control-Allow-Headers,允许接受请求送入的请求头
  4. Access-Control-Expose-Headers,告诉浏览器允许js代码拿到的响应头
  5. Access-Control-Allow-Credentials,是否允许浏览器读取 response 的内容
  6. Access-Control-Max-Age,告诉浏览器预检请求的结果在多少秒内有效

4 SpringBoot中的跨域

让我们站在"站在巨人肩膀上的巨人"的肩膀上。

支持跨域的四种配置方式

  1. Bean方式
  2. 覆写WebMvcConfigurer
  3. 注解指定
  4. 过滤器手动设置响应头

4.1 方式一:Bean方式

直接将bean注入到容器中。

java 复制代码
/**
 * 跨域配置方式一
 */
@Configuration
public class ProcessCorsConfig {

    @Bean("ProcessCorsFilter")
    public FilterRegistrationBean corsFilter() {
        // 1.添加CORS配置信息
        CorsConfiguration config = new CorsConfiguration();
        // 1) 允许的域
        config.addAllowedOriginPattern("*.aaa.com");
        // 2) 是否发送Cookie信息
        config.setAllowCredentials(true);
        // 3) 允许的请求方式
        config.addAllowedMethod(HttpMethod.OPTIONS);
        config.addAllowedMethod(HttpMethod.HEAD);
        config.addAllowedMethod(HttpMethod.PATCH);
        config.addAllowedMethod(HttpMethod.OPTIONS);
        config.addAllowedMethod(HttpMethod.GET);
        config.addAllowedMethod(HttpMethod.POST);
        config.addAllowedMethod(HttpMethod.PUT);
        config.addAllowedMethod(HttpMethod.DELETE);
        config.setMaxAge(3600L);
        // 4) 允许的头信息
        config.addAllowedHeader("*");
        // 5) 允许浏览器js获取的响应头
        config.addExposedHeader("Content-disposition");

        // 2.拦截所有路径的请求
        UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
        configSource.registerCorsConfiguration("/**", config);

        FilterRegistrationBean bean = new FilterRegistrationBean(new CorsFilter(configSource));
        // 过滤器顺序设置到最前面
        bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
        return bean;
    }
}

4.2 方式二:覆写WebMvcConfigurer

添加cors配置

java 复制代码
@Slf4j
@Configuration
public class WebConfig implements WebMvcConfigurer {

    /**
     * 跨域配置方式二
     */
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")  
                .allowCredentials(true)
                .allowedOriginPatterns("*.aaa.com")
                .allowedMethods("GET", "POST", "PUT", "DELETE")
                .allowedHeaders("*")
                .exposedHeaders("Content-disposition");
    }
}

4.3 方式三:注解指定

可在Controller类或方法上使用。

java 复制代码
@Slf4j
@RestController
@RequestMapping("/demo")
// 跨域配置方式三
@CrossOrigin(origins = "*")
public class TestController {

    // 跨域配置方式三
    @CrossOrigin(origins = "*")
    @PostMapping(value = "/post")
    public String post(@RequestBody UserInfoDTO dto) throws Exception {
        ...
    }

}

4.4 方式四:使用过滤器手动设置响应头

这种方式,很淳朴,没有花里胡哨的做法,很直观的展现了跨域限制功能在http协议上的运用。

java 复制代码
@Component
@WebFilter("/*")
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CustCorsFilter implements Filter {

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
        HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;

        httpResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "https://aaa.com");
        httpResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "GET,POST,PUT,OPTIONS,DELETE");
        httpResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "*");
        httpResponse.setHeader(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, "Content-disposition");
        httpResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");

        // 如果是预检请求直接返回204
        if (Objects.equals(httpRequest.getMethod(), HttpMethod.OPTIONS.name())) {
            httpResponse.setStatus(HttpStatus.NO_CONTENT.value());
        } else {
            filterChain.doFilter(servletRequest, servletResponse);
        }
    }
    
}

5 如何模拟跨域

5.1 ajax模拟

打开chrome -> 进入www.bilibili.com/ -> F12 -> Console

这里假设bilibili这个网站的js脚本进行了跨域请求。

js 复制代码
var xhr = new XMLHttpRequest();
xhr.open('POST', 'http://localhost:42323/demo/post');
xhr.setRequestHeader("Content-Type", "application/json");
var params = {
    "userName": "liong",
    "age": 18
};
xhr.send(JSON.stringify(params));
xhr.onload = function (e) {
    if (xhr.readyState === 4) {
        if (xhr.status === 200) {
            console.log(xhr.responseText);
        } else {
            console.error(xhr.statusText);
        }
    }
};

在跨域限制的情况

允许跨域后

5.2 http客户端

无论你是使用postman还是postwoman,只要在header加上origin,并指定你想模拟的源地址。

如果发生跨域,返回"Invalid CORS request"。

这串字符是由SpringFramework返回的。

java 复制代码
/**
 * Invoked when one of the CORS checks failed.
 * The default implementation sets the response status to 403 and writes
 * "Invalid CORS request" to the response.
 */
protected void rejectRequest(ServerHttpResponse response) throws IOException {
	response.setStatusCode(HttpStatus.FORBIDDEN);
	response.getBody().write("Invalid CORS request".getBytes(StandardCharsets.UTF_8));
	response.flush();
}

6 低版本的Spring如何支持跨域模式匹配

回到文章开头提到的,跨域配置不生效的问题。 如果你使用的是SpringBoot 2.3及以下的版本,或者Spring 5.2及以下的版本,你会发现CorsConfiguration并没有addAllowedOriginPattern()这个方法。

而当你使用config.addAllowedOrigin("*.bilibili.com");时,踩坑就开始了。

在旧版本的org.springframework.web.cors.CorsConfiguration#checkOrigin中,仅仅只是使用不区分大小写的比较,因此配置*.bilibili.com总是不会生效的。addAllowedOrigin()并不支持模式匹配,意味着你只能设置为*,或者指定为完整的地址https://bilibili.com。当指定为*时,将存在跨域请求的风险。

从源码看低版本spring使用equalsIgnoreCase()进行比较,并没有使用模式匹配。

java 复制代码
/**
 * Check the origin of the request against the configured allowed origins.
 * @param requestOrigin the origin to check
 * @return the origin to use for the response, or {@code null} which
 * means the request origin is not allowed
 */
public String checkOrigin(@Nullable String requestOrigin) {
		if (!StringUtils.hasText(requestOrigin)) {
			return null;
		}
		if (ObjectUtils.isEmpty(this.allowedOrigins)) {
			return null;
		}

		if (this.allowedOrigins.contains(ALL)) {
			if (this.allowCredentials != Boolean.TRUE) {
				return ALL;
			}
			else {
				return requestOrigin;
			}
		}
		for (String allowedOrigin : this.allowedOrigins) {
			if (requestOrigin.equalsIgnoreCase(allowedOrigin)) {
				return requestOrigin;
			}
		}

		return null;
	}

那么如何让低版本的Spring项目支持跨域的模式匹配呢? 有两种办法:

  1. 升级Spring版本
  2. 自定义添加这个能力

6.1 升级SpringBoot版本

支持addAllowedOriginPattern()的方法,考虑升级版本。

SpringBoot 2.4及以上 ,及SpringBoot 2.4对应的Spring 5.3及以上

简单粗暴,但是需要考虑企业、各业务方的实际情况,是否会影响其他组件依赖,还有依赖冲突的问题。

6.2 自定义覆写CORS配置类

另一种方式,就是重写CorsConfiguration的checkOrigin()方法。

通过Spring 5.3以上版本的源码实现,可以创建如下类,PatternCorsConfiguration继承CorsConfiguration。

java 复制代码
public class PatternCorsConfiguration extends CorsConfiguration {

    public PatternCorsConfiguration() {
        super();
    }

    private List<OriginPattern> allowedOriginPatterns;

    public CorsConfiguration setAllowedOriginPatterns(@Nullable List<String> allowedOriginPatterns) {
        if (allowedOriginPatterns == null) {
            this.allowedOriginPatterns = null;
        }
        else {
            this.allowedOriginPatterns = new ArrayList<>(allowedOriginPatterns.size());
            for (String patternValue : allowedOriginPatterns) {
                addAllowedOriginPattern(patternValue);
            }
        }
        return this;
    }
    
    public List<String> getAllowedOriginPatterns() {
        if (this.allowedOriginPatterns == null) {
            return null;
        }
        return this.allowedOriginPatterns.stream()
                .map(OriginPattern::getDeclaredPattern)
                .collect(Collectors.toList());
    }

    public void addAllowedOriginPattern(@Nullable String originPattern) {
        if (originPattern == null) {
            return;
        }
        if (this.allowedOriginPatterns == null) {
            this.allowedOriginPatterns = new ArrayList<>(4);
        }
        originPattern = trimTrailingSlash(originPattern);
        this.allowedOriginPatterns.add(new OriginPattern(originPattern));
    }

	// 主要目的重写这个方法,让其支持模式匹配
    @Override
    public String checkOrigin(@Nullable String origin) {
        if (!StringUtils.hasText(origin)) {
            return null;
        }
        String originToCheck = trimTrailingSlash(origin);
        List<String> allowedOrigins = this.getAllowedOrigins();
        if (!ObjectUtils.isEmpty(allowedOrigins)) {
            if (allowedOrigins.contains(ALL)) {
                validateAllowCredentials();
                return ALL;
            }
            for (String allowedOrigin : allowedOrigins) {
                if (originToCheck.equalsIgnoreCase(allowedOrigin)) {
                    return origin;
                }
            }
        }
        if (!ObjectUtils.isEmpty(this.allowedOriginPatterns)) {
            for (OriginPattern p : this.allowedOriginPatterns) {
                if (p.getDeclaredPattern().equals(ALL) || p.getPattern().matcher(originToCheck).matches()) {
                    return origin;
                }
            }
        }
        return null;
    }

    private String trimTrailingSlash(String origin) {
        return (origin.endsWith("/") ? origin.substring(0, origin.length() - 1) : origin);
    }

    private void validateAllowCredentials() {
        if (Boolean.TRUE.equals(this.getAllowCredentials()) &&
                this.getAllowedOrigins() != null && this.getAllowedOrigins().contains(ALL)) {

            throw new IllegalArgumentException(
                    "When allowCredentials is true, allowedOrigins cannot contain the special value \"*\" " +
                            "since that cannot be set on the \"Access-Control-Allow-Origin\" response header. " +
                            "To allow credentials to a set of origins, list them explicitly " +
                            "or consider using \"allowedOriginPatterns\" instead.");
        }
    }

    private static class OriginPattern {

        private static final Pattern PORTS_PATTERN = Pattern.compile("(.*):\\[(\\*|\\d+(,\\d+)*)]");

        private final String declaredPattern;

        private final Pattern pattern;

        OriginPattern(String declaredPattern) {
            this.declaredPattern = declaredPattern;
            this.pattern = initPattern(declaredPattern);
        }

        private static Pattern initPattern(String patternValue) {
            String portList = null;
            Matcher matcher = PORTS_PATTERN.matcher(patternValue);
            if (matcher.matches()) {
                patternValue = matcher.group(1);
                portList = matcher.group(2);
            }

            patternValue = "\\Q" + patternValue + "\\E";
            patternValue = patternValue.replace("*", "\\E.*\\Q");

            if (portList != null) {
                patternValue += (portList.equals(ALL) ? "(:\\d+)?" : ":(" + portList.replace(',', '|') + ")");
            }

            return Pattern.compile(patternValue);
        }

        public String getDeclaredPattern() {
            return this.declaredPattern;
        }

        public Pattern getPattern() {
            return this.pattern;
        }

        @Override
        public boolean equals(Object other) {
            if (this == other) {
                return true;
            }
            if (other == null || !getClass().equals(other.getClass())) {
                return false;
            }
            return ObjectUtils.nullSafeEquals(
                    this.declaredPattern, ((OriginPattern) other).declaredPattern);
        }

        @Override
        public int hashCode() {
            return this.declaredPattern.hashCode();
        }

        @Override
        public String toString() {
            return this.declaredPattern;
        }
    }

}

使用方式,只需要将CorsConfiguration替换为PatternCorsConfiguration,就能使用我们新增的的addAllowedOriginPattern()

java 复制代码
@Configuration
public class ProcessCorsConfig {

    @Bean("ProcessCorsFilter")
    public FilterRegistrationBean corsFilter() {
        // 1.添加CORS配置信息
        // CorsConfiguration config = new CorsConfiguration();
        PatternCorsConfiguration config = new PatternCorsConfiguration();
        // 1) 允许的域
        // config.addAllowedOrigin("*");
        config.addAllowedOriginPattern("*.bilibili.com");
        // 2) 是否发送Cookie信息
        config.setAllowCredentials(true);
        ...

7 参考文档

一文弄懂 CORS 跨域(前端+后端代码实例讲解)

zhuanlan.zhihu.com/p/118381660...

mozilla-跨域资源共享

developer.mozilla.org/zh-CN/docs/...

mozilla-同源策略

developer.mozilla.org/zh-CN/docs/...

Maven中央仓库(进行spring版本比较)

s01.oss.sonatype.org

相关推荐
拉玛干36 分钟前
社团周报系统可行性研究-web后端框架对比-springboot,django,gin
数据库·python·spring·golang
程序员大金3 小时前
基于SSM+Vue+MySQL的酒店管理系统
前端·vue.js·后端·mysql·spring·tomcat·mybatis
努力的布布3 小时前
Spring源码-从源码层面讲解声明式事务的运行流程
java·spring
程序员大金3 小时前
基于SpringBoot的旅游管理系统
java·vue.js·spring boot·后端·mysql·spring·旅游
爱上语文3 小时前
Springboot三层架构
java·开发语言·spring boot·spring·架构
你知道“铁甲小宝”吗丶5 小时前
【第33章】Spring Cloud之SkyWalking服务链路追踪
java·spring boot·spring·spring cloud·skywalking
听封6 小时前
Thymeleaf 的创建
java·spring boot·spring·maven
huapiaoy6 小时前
Spring mvc
java·spring·mvc
你知道“铁甲小宝”吗丶7 小时前
【第34章】Spring Cloud之SkyWalking分布式日志
java·spring boot·spring·spring cloud·skywalking