一个谜之CORS Bug的调试过程

这学期的Object Oriented Design课程要求以组为单位做一个项目,我们的项目前端使用Vuejs,后端使用Spring Boot,通过REST API通信。

有一天前端的朋友告诉我她在调试的时候遇到了一个问题。她在本地运行Vue项目,连接位于AWS上的API。这是一个跨域请求,浏览器会首先发送一个Preflight请求来预检。奇怪的是虽然Preflight请求正常返回了200,但是后面的请求却并没有继续进行。

* Preflight request – 术语表 | MDN

我试图在我的电脑上复现这个问题,然而我的电脑上却一切正常,完全不能重现。

经过各种尝试之后找到了一个突破口——出现问题的这个接口是通过Cloudflare和nginx转发的,而换成直接从Docker暴露出来的8080端口则一切正常。

分析请求

第一步,先来对比成功的和失败的请求的header中的属性。稳定版Chrome默认是不显示status为200的Preflight请求的,需要Canary版Chrome才行。

我按照朋友给我发来的截图通过cURL构造出请求,并从我自己的Chrome调试器中将成功的请求导出为cURL。分别运行一次,结果如图:

左侧的是失败的请求,右侧是成功的请求。

发现原因在于失败的请求的响应头中没有包含access-control-allow-origin和access-control-allow-method这两个属性(图中右下刷红),所以这个Preflight请求所得到的结果相当于是无效的。

定位问题

接下来我们需要定位问题所在,我的服务器上的环境如下:

Browser -> Cloudflare:443 -> nginx:80 -> Docker:8080 -> Spring Boot:8080
                            |------------------- AWS EC2----------------|

但是为了方便调试,其中每一个节点都是可以从公网访问的,因此我将其划分为三个场景,分别进行测试,下文中提到的“场景x”均对应下表中的编号。

场景:

编号入口upstream
1https://api.app.comcloudflare:443->nginx:80>docker:8080->spring boot:8080
2http://aws.app.comnginx:80>docker:8080->spring boot:8080
3http://aws.app.com:8080docker:8080->spring boot:8080

我使用cURL分别测试三种场景,结果如图

这样我们可以将问题定位到nginx。

但是回顾上面的第一次测试,我们可以发现nginx并不是影响问题的唯一变量。另一个变量是请求的origin和referer。

排除掉干扰因素Cloudflare和Docker后,我们将这个问题的变量控制在两个:请求是否经过nginx,以及客户端端口号是否为8080。

得到如下表格:

经过nginx不经过nginx
origin端口为8080
origin端口为8081

分析nginx

我们着重分析经过nginx的这两个场景。

首先分析从浏览器到nginx的这段路程。

浏览器发起CORS Preflight请求时,origin是localhost:8080或8081, host是aws.app.com。这段请求到达nginx时,将被按照配置转发给upstream,也就是http://localhost:8080/。

浏览器到nginx的这段路程不应当出现问题,所以接下来分析nginx到Spring Boot的这段路程。

我使用的nginx配置如下,nginx将会为请求中加入两个与真实IP地址相关的属性,header的其他部分都会原封不动地转发给upstream,也就是说,referer和Origin都应当保持不变。

server {
    listen 80;
    listen  [::]:80;
    server_name api.app.com aws.app.com;

    location / {
            proxy_pass  http://localhost:8080;
            proxy_redirect     http://localhost:8080/ /;
            proxy_set_header   X-Real-IP        $remote_addr;
            proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;
        }
}

为了搞清楚数据包在经过nginx转发之后发生了哪些变化,我使用cURL测试了场景2和3,同时使用tshark在服务器上抓包,结果如下:

场景2:

场景3:

我们可以看出场景2和3中Request header中的Host的值并不相同,说明header中的Host属性被nginx修改了。

经过对比可以发现,request header中的Host属性从原本的aws.app.com变成了localhost:8080,另外多了两个和真实IP地址相关的属性。

因为nginx影响的只有Host属性,客户端端口号影响的只有Origin,可以得出Host及Origin就是影响该问题结果的两个变量。(我又做了一些实验,证明referrer对结果没有影响,这里省略)

去除header中对结果不造成影响的属性之后,请求可以被精简为:

  curl -v 'http://localhost:8080/api/v1/auth' \
  -X 'OPTIONS' \
  -H 'access-control-request-method: POST' \
  -H 'origin: http://localhost:8080' \

进一步实验发现,当Host与origin相同时,Spring Boot没有返回我们所需要的那些属性,导致Preflight request失败。

可以得出,虽然nginx引发了这个问题,但实际上nginx并不是问题的源头,我们需要进一步探究Spring Boot是如何处理Preflight请求的。

分析Spring框架

回到Spring框架上,在打了无数断点之后终于定位到控制CORS的代码位于org.springframework.web.filter.CorsFilter(Spring真的太复杂了,一个请求的stack trace打了好几屏

@Override
protected void doFilterInternal(...) throws ServletException, IOException {

	CorsConfiguration corsConfiguration = this.configSource.getCorsConfiguration(request);
	boolean isValid = this.processor.processRequest(corsConfiguration, request, response);
	if (!isValid || CorsUtils.isPreFlightRequest(request)) {
		return;
	}
	filterChain.doFilter(request, response);
}

这段代码主要执行了下面两个逻辑

  1. 将应用的CORS配置(通常来自于Controller中的注解)与HTTP header中的属性比较,判断这个CORS请求是否合法,同时装配response。最后返回一个boolean。
  2. 检查这个请求是否是一个Preflight请求,返回一个boolean。

其实这里的逻辑我没太看懂,但是最重要的是response需要在这段函数中被加入我们所需要的header属性,然后继续传给Filter Chain执行后面的filters,返回的结果是真是假反而并不太重要。

接下来我们研究processRequest这个函数。这个函数的逻辑如下:

@Override
@SuppressWarnings("resource")
public boolean processRequest(...) throws IOException {

	......

	if (!CorsUtils.isCorsRequest(request)) {
		return true;
	}

	......
	
	return handleInternal(new ServletServerHttpRequest(request), new ServletServerHttpResponse(response), config, preFlightRequest);
}

在正常情况下,response会在最后一行的handleInternal函数中被加入我们需要的那些header属性。然而,当host == origin时,函数在isCorsRequest那里就直接return了。也就是说,函数在还没有执行到我们需要的那段代码之前就已经返回了,所以返回的header中缺少我们需要的那几个属性。

我们继续研究isCorsRequest这个函数。

public static boolean isCorsRequest(HttpServletRequest request) {
	String origin = request.getHeader(HttpHeaders.ORIGIN);
	if (origin == null) {
		return false;
	}
	UriComponents originUrl = UriComponentsBuilder.fromOriginHeader(origin).build();
	String scheme = request.getScheme();
	String host = request.getServerName();
	int port = request.getServerPort();
	return !(ObjectUtils.nullSafeEquals(scheme, originUrl.getScheme()) &&
			ObjectUtils.nullSafeEquals(host, originUrl.getHost()) &&
			getPort(scheme, port) == getPort(originUrl.getScheme(), originUrl.getPort()));
}

这个函数看起来复杂,但是实际上做的只有一件事情:对比origin中和请求本身的scheme(http/https),host和port,如果两个请求中的这三个属性一致,则判定这不是一个CORS请求。

结论

当在localhost:8080运行Vuejs,连接位于https://api.app.com的API,当请求通过nginx时,由于配置不当,nginx并没有保留header中原本的host属性,而是改为了Spring Boot应用在内网中的主机名(localhost:8080)。

Spring Boot收到请求之后,由于请求头中的Host和Origin相同,判定这不是一个CORS请求,所以并没有返回access-control-allow-origin和access-control-allow-method这两个属性,导致浏览器认为这不是一个合法的跨域请求,所以后面的请求没有继续进行。

而我在试着复现这个问题时,由于我的电脑上的8080端口已经被占用了,所以Vue默认启动在了8081端口,因此没能复现问题。

解决方法很简单,在nginx配置中加入这一句,使其保留请求头中原本的Host属性即可。

proxy_set_header Host            $host;
Show CommentsClose Comments

3 Comments

  • Nroy
    Posted May 7, 2020 at 11:14 0Likes
    Google Chrome 81.0.4044.138 Google Chrome 81.0.4044.138 Windows 10 x64 Edition Windows 10 x64 Edition

    哇,你这个问题研究的有点深,绕了好大一圈

  • Harry Chen
    Posted May 1, 2020 at 00:54 0Likes
    Google Chrome 81.0.4044.129 Google Chrome 81.0.4044.129 Windows 10 x64 Edition Windows 10 x64 Edition

    其实如果后端能精确匹配 Host 才提供服务的话,这类问题的诊断会快很多

    • Frank
      Posted May 1, 2020 at 01:04 0Likes
      Safari 13.1 Safari 13.1 Mac OS X  10.15.4 Mac OS X 10.15.4

      按理说生产环境是应该检查的…不过是课程项目就没管那么多。

Leave a comment