Contents

AWS API-Gateway no reply when http delete with chunked

HTTP DELETE with header Transfer-Encoding:chunked导致AWS API-Gateway empty reply

背景

  • client:spring-cloud-netflix-zuul里SimpleHostRoutingFilter的ApacheHttpClient

  • server:AWS API-Gateway REST-Private类型,同时non-proxy integration

  • 现象:client http delete request 到 server后,server只回应了ACK,没有HTTP response,直到client timeout。但是curl delete可以成功得到response

  • 猜测:HttpClient与curl发出的HTTP request有区别,进而抓包准备分析数据

分析步骤

准备分析所需数据,下面是简要步骤

tcpdump

1
sudo tcpdump -i eth0 -vv -s0 host xxx.host.com -w xxx.pcap

curl

1
2
3
4
5
export SSLKEYLOGFILE=~/key.log

curl -iv 'https://api/any' \
  -X 'DELETE' \
  -H 'accept: application/json, text/plain, */*'

java

1
java -Djavax.net.debug=ssl,keygen -jar xxx.jar

从java log里解析client nonce与master secert:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def extract_data_from_line(line):
  m = re.match('\d+:([ 0-9A-F]{51}) .*', line)
  if m:
    return m.group(1).replace(' ', '')
  else:
    raise line

# CONNECTION KEYGEN:
# Client Nonce:
# 0000: 57 3D 1A E1 B4 A1 F2 6C   D1 5F D4 BB DF 90 7B 88  W=.....l._......
# 0010: DD 52 57 6F 76 E9 5E D0   75 91 03 FB 19 31 8A 1B  .RWov.^.u....1..
# Server Nonce:
# 0000: 57 3D 1A E1 15 25 D8 7C   B1 1F DD E5 C1 D1 F2 75  W=...%.........u
# 0010: 21 7E 92 B9 3D F6 8A 87   5F 46 CE 61 F8 17 25 BD  !...=..._F.a..%.
# Master Secret:
# 0000: D9 17 9B 11 2F B8 9D 0D   0A 42 C7 34 0E 4A 0B 4B  ..../....B.4.J.K
# 0010: 2F 94 F1 CC C0 63 93 19   17 03 D4 9A E8 03 6F D8  /....c........o.
# 0020: D2 66 CF D1 4E E2 8E 3F   E6 9D 41 3D E9 26 CD EB  .f..N..?..A=.&..

# 最终key.log文件内容: CLIENT_RANDOM cn ms
# 其中cn和ms计算如下
# cn = extract_data_from_line(line1)+extract_data_from_line(line2)
# ms = extract_data_from_line(line1)+extract_data_from_line(line2)+extract_data_from_line(line3)

wireshark

import pcap后,需要配置(Pre)-Master-Secret log filename。Go to Settings / Protocols / SSL / (Pre)-Master-Secret log filename

diff and experiment

对比HTTP request如下:

https://raw.githubusercontent.com/Fedomn/misc-blog-assets/master/java-delete-vs-curl-delete-http-req-diff.png

看出了什么吗?

  • HttpClient:包含Transfer-Encoding: chunked头,并以一个empty chunk结束包(0\r\n)
  • curl:正常结束包(\r\n)

通过手动设置chunked,让curl复现了timeout:

1
2
3
4
5
6
7
export SSLKEYLOGFILE=~/key.log

curl -iv 'https://api/any' \
  -X 'DELETE' \
  -H 'accept: application/json, text/plain, */*' \
  -H 'Transfer-Encoding: chunked' \
  -d ""

抓包结果如上图左边HttpClient一样,以empty chunk结束包,然后成功timeout。

至此,client端已经通过curl复现出了,应该是DELETE with chunked header产生的影响。同时也测试了POST/PUT with chunked header,都可以正确得到response。如下图,是一个POST request with chunked header,并正确收到了response:

https://raw.githubusercontent.com/Fedomn/misc-blog-assets/master/http-post-with-chunked-header.png

猜测

猜测:HTTP DELETE request with chunked header and empty chunked body 会导致AWS API-Gateway no reply

由于API-Gateway是个黑盒,cloudwatch的日志也都是应用层以上的,没法完整的看到所有header(如content-length必须在真正执行发送时才知道)。同时也没法确定,API-Gateway异常时发生在Method Request阶段,还是Integration Request阶段。

通过搜索,发现API-Gateway Important Notes:

The following table lists the headers that may be dropped, remapped, or otherwise modified when sent to your integration endpoint or sent back by your integration endpoint.

Header nameRequest (http/http_proxy/lambda)Response (http/http_proxy/lambda)
Content-LengthPassthrough (generated based on body)Passthrough
Transfer-EncodingDropped/Dropped/ExceptionDropped

drop Transfer-Encoding这是一个proxy需要做的事,没啥问题。参考补充知识里的 Transfer-Encoding与proxy

继续基于Transfer-Encoding搜索发现一些和我们类似的案例:stream response,times out if response is too large,chunked transfer encoding,

结合我们上面的测试结论,大胆推测以下几点(除去lambda):

  • 推测1:关于drop Transfer-Encoding: chunked问题。API-Gateway应该是先buffer request,再发送给integration endpoint。 而后续API-Gateway是否还要 加这个header取决于它的逻辑。至少对于proxy来说,这个header不能原封不动的retransmit。buffer也带来的大文件问题(API-Gateway support default payload = 10MB) 和 stream 问题,看起来目前都没支持,所以就有和lambda结合的骚操作。
  • 推测2:关于只有DELETE timeout,REST API-Gateway对待DELETE request with chunked payload时,由于rfc2616没有明确禁止delete带payload,所以不同的HTTP实现处理方式不一样,有的会ignore DELETE payload body,有的会reject请求。新的规范rfc7231里也表示了,sending a payload body on a DELETE request might cause some existing implementations to reject the request。可能API-Gateway就是这个implementation。StackOverflow资料
  • 推测3:rfc7230里规定了message body length如何定义,针对有payload语义的HTTP method,Content-Length或Transfer-Encoding必须存在,需要标识何时结束message。并且对invalid request,server有411、400去返回client。对invalid response,proxy会502给client,proxy会向server close conn废弃收到的response。而API-Gateway对于invalid request的empty reply的行为有些奇怪,要么是某种考虑的正常行为,要么是触发异常直接no reply。

下面是通过源码分析chunked header在client和server的处理方式。

Httpclient加入chunked header

本地debug,定位到 apache RequestContent。代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
if (request instanceof HttpEntityEnclosingRequest) {
  // Must specify a transfer encoding or a content length
  if (entity.isChunked() || entity.getContentLength() < 0) {
      if (ver.lessEquals(HttpVersion.HTTP_1_0)) {
          throw new ProtocolException("Chunked transfer encoding not allowed for " + ver);
      }
      request.addHeader(HTTP.TRANSFER_ENCODING, HTTP.CHUNK_CODING);
  } else {
      request.addHeader(HTTP.CONTENT_LEN, Long.toString(entity.getContentLength()));
  }
}

由于SimpleHostRoutingFilter里requestEntity默认chunked=false,而delete的对应的content-length = -1,从而造成了底层库加入了这个header。至于为什么有content-length<0这个逻辑,推测有些content-length>Long.MAX_VALUE,必须通过chunked传输。

而且这段逻辑没有区分HTTP method,从这个角度与抓包信息理解,HTTP method只是语义标识,而HTTP payload如何传输,是chunk coding还是content lenght,则是由HttpEntityEnclosingRequest自己控制。

解决方式很简单,不让它传这个头,参考RibbonApacheHttpRequest里

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
if (this.context.getRequestEntity() != null) {
  final BasicHttpEntity entity;
  entity = new BasicHttpEntity();
  entity.setContent(this.context.getRequestEntity());
  // if the entity contentLength isn't set, transfer-encoding will be set
  // to chunked in org.apache.http.protocol.RequestContent. See gh-1042
  Long contentLength = this.context.getContentLength();
  if ("GET".equals(this.context.getMethod())
      && (contentLength == null || contentLength < 0)) {
    entity.setContentLength(0);
  }
  else if (contentLength != null) {
    entity.setContentLength(contentLength);
  }
  builder.setEntity(entity);
}

针对转发的DELETE请求,如果获取到的content-length<0,手动set成0。

golang与tomcat处理chunked request

golang版本1.14,net/http/server.go#1822里c.readRequest(ctx)是一切的入口,最终到net/http/server.go#966里readRequest(c.bufr, keepHostHeader) 返回校验完的request。其中主要方法是:func readTransfer(msg interface{}, r *bufio.Reader) (err error) {}

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// msg is *Request or *Response.
func readTransfer(msg interface{}, r *bufio.Reader) (err error) {
  // Unify input
  isResponse := false
  switch rr := msg.(type) {
  case *Request:
    t.Header = rr.Header
    t.RequestMethod = rr.Method
    t.ProtoMajor = rr.ProtoMajor
    t.ProtoMinor = rr.ProtoMinor
    // Transfer semantics for Requests are exactly like those for
    // Responses with status code 200, responding to a GET method
    t.StatusCode = 200
    t.Close = rr.Close
  }

  //...
  
  // Prepare body reader. ContentLength < 0 means chunked encoding
  // or close connection when finished, since multipart is not supported yet
  switch {
  case chunked(t.TransferEncoding):
  if noResponseBodyExpected(t.RequestMethod) || !bodyAllowedForStatus(t.StatusCode) {
    t.Body = NoBody
  } else {
    t.Body = &body{src: internal.NewChunkedReader(r), hdr: msg, r: r, closing: t.Close}
  }

  //...
}

对chunked的请求content-length=-1,最终会走到26行,通过chunkedReader继续去读buffer,参考net/http/internal/chunked.go。所以,go http server不会强制限制DELETE body的问题。

我们在看下Tomcat里Http11Processor处理方式,入口在Http11Processor#343 prepareRequest,处理transfer-encoding位置 Http11Processor#721。如下主要部分:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// Parse transfer-encoding header
if (http11) {
  MessageBytes transferEncodingValueMB = headers.getValue("transfer-encoding");
  if (transferEncodingValueMB != null) {
    List<String> encodingNames = new ArrayList<>();
    if (TokenList.parseTokenList(headers.values("transfer-encoding"), encodingNames)) {
      for (String encodingName : encodingNames) {
        // "identity" codings are ignored
        addInputFilter(inputFilters, encodingName);
      }
    } else {
      // Invalid transfer encoding
      badRequest("http11processor.request.invalidTransferEncoding");
    }
  }
}

// Parse content-length header
long contentLength = -1;
try {
  contentLength = request.getContentLengthLong();
} catch (NumberFormatException e) {
  badRequest("http11processor.request.nonNumericContentLength");
} catch (IllegalArgumentException e) {
  badRequest("http11processor.request.multipleContentLength");
}
if (contentLength >= 0) {
  if (contentDelimitation) {
    // contentDelimitation being true at this point indicates that
    // chunked encoding is being used but chunked encoding should
    // not be used with a content length. RFC 2616, section 4.4,
    // bullet 3 states Content-Length must be ignored in this case -
    // so remove it.
    headers.removeHeader("content-length");
    request.setContentLength(-1);
  } else {
    inputBuffer.addActiveFilter(inputFilters[Constants.IDENTITY_FILTER]);
    contentDelimitation = true;
  }
}

在处理transfer-encoding和content-length和golang相似,也都没有 限定到DELETE请求的判断逻辑。

补充知识

Transfer-Encoding与proxy

HTTP headers根据用途分类:

  • General Headers:req和rsp都可以有的头。如:Date、Cache-Control、Connection
  • Request Headers:如:Accept、User-Agent等。Content-Length只在有body的req中(如POST)
  • Response Headers:如:Age、Location、Server等。
  • Entity Headers:用来描述message body。通常和req和rsp一起使用,如Content-Length、Content-Encoding、Content-Language。

End-to-end headers:These headers must be transmitted to the final recipient of the message: the server for a request, or the client for a response. Intermediate proxies must retransmit these headers unmodified and caches must store them.

Hop-by-hop headers:These headers are meaningful only for a single transport-level connection, and must not be retransmitted by proxies or cached. Note that only hop-by-hop headers may be set using the Connection general header.

Transfer-Encoding是个Hop-by-hop header,也就说:它只作用于proxy,即client到proxy的第一跳;它不能被proxy重传到下一跳。

而一个proxy该如何处理这些Hop-by-hop header呢,参考解释了proxy如何做

其中第1点就是,Remove Hop-by-hop Headers。因为proxy在向下一跳转发是,必须要把当前请求收完处理好。避免引入安全问题。还有就是7和8点,proxy应该对req/rsp支持chunking;对于chunking合理的buffer也是避免不了的,但是也要通过某种方式,避免下一跳等待req/rsp太久;有些reverse proxy会buffer req/rsp,当然也就限制了大文件和stream。

Response chunked问题

我们知道,Chunked transfer encoding是HTTP/1.1里一种streaming data transfer机制,每一块chunk都是独立发送与接收的,最后会进行TCP segment重组,拼接成完整的payload。也就是下图中的TCP segment of a reassembled PDU,最后一个是empty chunk

https://raw.githubusercontent.com/Fedomn/misc-blog-assets/master/tcp-segment-reassembled-pdu.png

从图上看出,这个两个chunk的payload之和都没到1K,chunk没有带来优势,反而增加了一次TCP通信,而且一旦chunk也代表这个TCP conn必须保持以用来接收后续数据。

细想一下response是server主动发起的,我们认为它是安全的。request的情况下,如果每个client都是chunked request给server,server占用的TCP conn就会逐步被蚕食,无法响应其它的请求。

Tomcat bug : parsing request headers fail

https://raw.githubusercontent.com/Fedomn/misc-blog-assets/master/tomcat-bug-parse-headers-two-tcp-package.png

root-cause如下:

一个HTTP请求,拆成了2个TCP包,其中第一个包的最后一位只有’\r’ -> 0d ,然后’\n’ -> 0a 在下一个TCP包里 最终导致了,Tomcat会对这个HTTP请求 报400

详情看:Tomcat parsing request headers fail bug

1
2
3
4
5
6
7
tcpdump -i eth0 -vv -s0 -w xxx.pcap

# 查看tcp package
tcpdump -qns 0 -X -r xxx.pcap

# 终端安装tshark在docker里分析pcap文件
tshark -r xxx.pcap -V

Nginx + Tomcat issues

nginx的proxy_http_version默认是1.0,这就造成了一些奇怪的问题,下面的示例来源于client->nignx->tomcat处理file upload的Request时候。

现象nginx在proxy一个file upload请求时候,没有携带token,tomcat在请求初期毫秒级内,识别到了auth failed,并以http1.1 response了401,并connection: close。但client仍然hang住,直到60s后,tomcat才回了Nginx一个Fin的no data的tcp packet。

而在将proxy_http_version改成1.1后,上面的问题就消失了,client没有hang住,立马收到了401。抓包发现,tomcat还是response了两个tcp packet,第一个是http1.1, transfer-encoding: chunked, connection: close,第二个packet是chunked结束标准0\r\n。

所以,定义处理如下几个问题:

a. 为什么tomcat隔了60s才回了fin结束,并且针对正常的rest api,tomcat可以正常快速response,两个tcp packet,第二个是fin表明关闭连接。

b. 为什么协议改成1.1,就没有60s了,tomcat处理http response成chunked,并正确结束http请求。

c. nginx作为proxy是否buffer了entire file,这样会不会有DDoS问题

d. tomcat是否buffer完整个file,才会进入auth filter校验token,是否会有DDoS问题

第一个问题,60s像是tomcat或linux网络层,主动断了connection。默认情况下,nginx用http1.0请求,connection: close。后端处理完成后就主动关闭连接,所以 TIME_WAIT 在tomcat。

同时我们要知道判断一个http请求如何结束的标志有两种:content-length/chunked和server主动close connection。所以tomcat在处理http1.0时,采用了close connection。但是,它处理rest api正常,但处理file upload时multipart/form-data确hang住了(参考g-nginx->gateway-1.pcap)。猜测tomcat在处理file upload时,虽然提前返回了401 http rsp,但在后续的close connection时hang了60s。可能这是一个bug。

netstat -n | awk '/^tcp/ {++S$NF} END {for(a in S) print a, Sa}'

ss -ant | awk 'NR>1 {++s$1} END {for(k in s) print k,sk}'

然而,在tomcat和nginx里watch netstat状态发现,它们都会一直处在established 60s,然后tomcat状态转为time_wati,nginx这个连接消失。所以,进一步思考是不是tomcat response的HTTP包有问题。查询g-nginx->gateway-1.pcap发现,tomcat在一个tcp包里,包含了完整的http协议内容,但是没有content-length,也没有transfer-encoding:chunked,所以猜测Nginx作为一个proxy,没法正确确认收到了完整的http请求,也就没法结束这个tcp connection,直到tomcat的connection timeout。

第二个问题,http version 1.1后,就没有这个问题了,因为1.1后,tomcat处理response为chunked,并且能够快速发出HTTP ending包来结束请求。

同时引入新问题,正常的api request而不是file upload,tomcat在处理http1.0 connection close,也是会tcp segment完了后直接time_wait。这样大量的短连接,也会产生DDoS的问题,所以才会有尽量使用1.1的说法,防止tomcat time_wait过多,建立不了新连接。

第三个问题,nginx或者说是 一个透明代理,它是否该buffer请求,默认nginx的proxy_request_buffering是开启的,它同时也有个buffer size去控制。如果是一个大文件,nginx如果buffer它会很消耗资源,所以一般proxy都会有max file size limitation。而且buffer也会提高一些性能(复用connection或合并tcp packet之类)。所以,在file upload的情况下,该不该buffer?安全角度还是性能角度,这需要trade off。

第四个问题,tomcat并不是receive entire file后才会进入auth filter的,这个结论的前提是file upload。所以猜测,tomcat对于http body的接收是个stream,如果你不去get它,它就不会阻塞住,通过仍然能处理header。所以,只要你不去显示的get body,tomcat就不会有大文件消耗资源的问题。

reference

HTTP RFCs文档

Man Page of tcpdump

Decoding any Java-originated SSL Connection with Pre-Shared Master Secret

DELETE request with empty body results 411 on zuul proxies #1894

TLS v1.2 handshake overview

Wireshark: Sample Pcaps

Wireshark 抓包理解 HTTPS 请求流程

TLS/SSL抓包常见方法

浅析TLS 1.2协议

HTTPS抓包了解TLS握手流程

tcpdump/wireshark 抓包及分析