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 responsetimes out if response is too largechunked 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 抓包及分析