这学期的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 |
1 | https://api.app.com | cloudflare:443->nginx:80>docker:8080->spring boot:8080 |
2 | http://aws.app.com | nginx:80>docker:8080->spring boot:8080 |
3 | http://aws.app.com:8080 | docker: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);
}
这段代码主要执行了下面两个逻辑
- 将应用的CORS配置(通常来自于Controller中的注解)与HTTP header中的属性比较,判断这个CORS请求是否合法,同时装配response。最后返回一个boolean。
- 检查这个请求是否是一个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;
发表回复/Leave a Reply