本文为 Tomcat 中 HTTP 400 Bad Request 的常见原因以及示例 后续实际案例。详细介绍了Web容器使用的协议版本不同导致的问题。
背景
应用服务器的变化,通常会给原本正常运行的服务带来一些意想不到的问题。当我们升级应用服务器时,不同应用服务器之间会有些细微的不同,这些细微的差别有时候会带来前后行为的不同:旧版本能够正常工作,新版本则不能。
这类问题通常很难定位。 一个产线的应用迁移,需要考虑行为不同的地方太多了。是不是代码逻辑有问题了?是不是数据库的缓存没有被加载? 是不是流量切换配置不工作了? 各种各种的可能都让我们很难定位到应用服务器。出现相关的问题就像猜谜。和所有的解密过程一样,都需要抽丝剥茧的耐心和决心。
下面,我就用两个典型例子来说明。这篇文章将消息描述第一个例子。
莫名其妙的400错误
当我们更换了新版本的应用服务器后,发现一些客户端报出了之前正常返回结果的请求报出了400的状态码。
众所周知,400 Bad Request一般发生于客户端发送了一条异常请求,有两种类型:Bad Request错误的请求和Invalid Hostname不存在的域名。而在这个例子里面,报出的异常类型为Bad Request,而异常请求一般有两种情况:
语义有误,当前请求无法被服务器理解。
请求参数有误。
但是这两种情况都太过宽泛,而且更换应用服务器之前请求都是正常返回,更换服务器不可能更换请求的参数和语义。
所以我们的第一要务是要分辨出到底是哪些请求报出了400的错误呢,这些请求有什么共性吗?
什么样的请求会产生400
我们尝试比较一下成功的请求和失败的请求有什么不同。比较400和200的请求,我们发现一个规律。我们观察到,从返回200状态码的请求的URL路径中并没有携带Host地址,类似于这样:
GET /svc/request HTTP/1.1
Host: sample1.com
HTTP/1.1 200
但当URL路径携带了完整的Host地址时,请求进入新应用就会返回400状态码:
GET http://sample1.com:80/svc/request HTTP/1.1
Host: sample1.com
HTTP/1.1 400
可以看到以上两个请求中,请求头中都有Host字段,并传入的都是正确的值。然而这个时候,我们发现了一个细节,在返回状态码为400的请求中,请求头Host中的值是不带“:80”端口信息的,但在URL路径中,Host则携带了端口号。
那么有没有可能是URL路径和Request Header中Host不匹配造成的400状态码呢?我们将两者统一了之后再进行尝试,发现无论是否携带端口,只要两者是一致的,就能请求成功。
GET http://sample1.com:80/svc/request HTTP/1.1
Host: sample1bc.com:80
HTTP/1.1 200
GET http://sample1.com/svc/request HTTP/1.1
Host: sample1.com
HTTP/1.1 200
那么我们基本上可以确定,返回400状态码的请求都是URL路径与请求头的Host不一致的。那接下来的问题是,这些URL和Header中Host不一致的请求是新出现的还是原本就有的呢?如果原本就有,那么为什么应用服务器升级前没出现同样的问题呢?
所以我们回退了版本,再尝试了一下错误的请求,发现确实在旧版本的应用服务器上成功返回了请求,状态码为200。
GET http://sample1.com:80/svc/request HTTP/1.1
Host: sample1.com
HTTP/1.1 200
根本原因
通过查询规范,HTTP1.1 RFC2616规定 Header Host的值和URI的Host值可以不一样,请参考3.2.3章 《URI Comparison》。HTTP1.1 RFC7230则严格不允许,请参考5.5章 《Effective Request URI》。
于是我们查看了旧版本的应用服务器,发现它使用的是较为早期的RFC2616协议,而新应用服务器则使用的是RFC7230协议。
对于RFC 2616和RFC 7230的实现,我们可以参考开源项目Tomcat的源码中的Http11Processor类中的部分代码。
// The requirements of RFC 2616 are being
// applied. If the host header and the request
// line do not agree, the request line takes
// precedence
hostValueMB headers.setValueC'host");
hostValueMB.setBytes(uriB, uriBCStart + pos, slashPos pos);
} else {
// The requirements of RFC 7230 are being
// applied. If the host header and the request
// line do not agree, trigger a 400 response.
response.setStatus(400);
setErrorState(Errorstate.CLOSE_CLEAN, null);
if (log.isDebugEnabled()) {
log,debug(sm.getString("httpllprocessor. request. inconsistentHosts'*)); }
}
}
从代码中很容易就可以看出两种协议的不同实现,RFC2616会忽略请求头中原有的字段,将URL路径中的Host放到请求头的字段中。而RFC7230严格不允许Header与URL中的Host不匹配,则会返回400的状态码。源码中protocol.getAllowHostHeaderMismatch()默认值即为false,说明Tomcat实现的是RFC7230协议。
原理了解清楚了之后,解决方法自然就明确了。RFC7230协议强校验Host不一致一定有它的历史原因,那么理论上请求都应该遵循RFC7230的规范。那么要使URL中的Host和Header中的Host一致,我们可以在客户端修改,也可以在路由中进行修改。修改后,这个问题就被完美解决了。