一个谜之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;



3 responses to “一个谜之CORS Bug的调试过程”

  1. Google Chrome 81.0.4044.138 Google Chrome 81.0.4044.138 Windows 10 x64 Edition Windows 10 x64 Edition

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

  2. Google Chrome 81.0.4044.129 Google Chrome 81.0.4044.129 Windows 10 x64 Edition Windows 10 x64 Edition

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

    • Safari 13.1 Safari 13.1 Mac OS X  10.15.4 Mac OS X 10.15.4

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

Leave a Reply to Nroy Cancel reply

Your email address will not be published.