Skip to content

Commit c38418c

Browse files
authored
Merge pull request #1403 from tkan145/THREESCALE-9542-chunked-request
[THREESCALE-9542] Part 2: Add support to proxy request with Transfer-Encoding: chunked
2 parents 956850d + b494ca8 commit c38418c

File tree

10 files changed

+2528
-33
lines changed

10 files changed

+2528
-33
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1313

1414
- Fixed CVE-2023-44487 (HTTP/2 Rapid Reset) [PR #1417](https://github.com/3scale/apicast/pull/1417) [THREESCALE-10224](https://issues.redhat.com/browse/THREESCALE-10224)
1515

16+
- Fixed issue where the proxy policy could not handle requests with "Transfer-Encoding: chunked" header [PR #1403](https://github.com/3scale/APIcast/pull/1403) [THREESCALE-9542](https://issues.redhat.com/browse/THREESCALE-9542)
17+
1618
### Added
1719

1820
- Detect number of CPU shares when running on Cgroups V2 [PR #1410](https://github.com/3scale/apicast/pull/1410) [THREESCALE-10167](https://issues.redhat.com/browse/THREESCALE-10167)

gateway/src/apicast/http_proxy.lua

Lines changed: 121 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,29 @@
11
local format = string.format
2+
local tostring = tostring
3+
local ngx_flush = ngx.flush
4+
local ngx_get_method = ngx.req.get_method
5+
local ngx_http_version = ngx.req.http_version
6+
local ngx_send_headers = ngx.send_headers
27

38
local resty_url = require "resty.url"
49
local resty_resolver = require 'resty.resolver'
510
local http_proxy = require 'resty.http.proxy'
611
local file_reader = require("resty.file").file_reader
12+
local file_size = require("resty.file").file_size
13+
local client_body_reader = require("resty.http.request_reader").get_client_body_reader
14+
local send_response = require("resty.http.response_writer").send_response
715
local concat = table.concat
816

917
local _M = { }
1018

19+
local http_methods_with_body = {
20+
POST = true,
21+
PUT = true,
22+
PATCH = true
23+
}
24+
25+
local DEFAULT_CHUNKSIZE = 32 * 1024
26+
1127
function _M.reset()
1228
_M.resolver = resty_resolver
1329
_M.http_backend = require('resty.http_ng.backend.resty')
@@ -41,15 +57,50 @@ local function absolute_url(uri)
4157
)
4258
end
4359

44-
local function forward_https_request(proxy_uri, proxy_auth, uri, skip_https_connect)
45-
-- This is needed to call ngx.req.get_body_data() below.
46-
ngx.req.read_body()
60+
local function forward_https_request(proxy_uri, uri, proxy_opts)
61+
local body, err
62+
local sock
63+
local opts = proxy_opts or {}
64+
local req_method = ngx_get_method()
65+
local encoding = ngx.req.get_headers()["Transfer-Encoding"]
66+
local is_chunked = encoding and encoding:lower() == "chunked"
67+
local content_type = ngx.req.get_headers()["Content-Type"]
68+
local content_type_is_urlencoded = content_type and content_type:lower() == "application/x-www-form-urlencoded"
69+
local raw = false
70+
71+
if http_methods_with_body[req_method] then
72+
73+
-- When the content type is "application/x-www-form-urlencoded" the body is always pre-read.
74+
-- See: gateway/src/apicast/configuration/service.lua:214
75+
--
76+
-- Due to this, ngx.req.socket() will fail with "request body already exists" error or return
77+
-- socket but hang on read in case of raw socket. Therefore, we only retrieve body from the
78+
-- socket if the content type is not "application/x-www-form-urlencoded"
79+
if opts.request_unbuffered and ngx_http_version() == 1.1 and not content_type_is_urlencoded then
80+
if is_chunked then
81+
-- The default ngx reader does not support chunked request
82+
-- so we will need to get the raw request socket and manually
83+
-- decode the chunked request
84+
sock, err = ngx.req.socket(true)
85+
raw = true
86+
else
87+
sock, err = ngx.req.socket()
88+
end
4789

48-
local request = {
49-
uri = uri,
50-
method = ngx.req.get_method(),
51-
headers = ngx.req.get_headers(0, true),
52-
path = format('%s%s%s', ngx.var.uri, ngx.var.is_args, ngx.var.query_string or ''),
90+
if not sock then
91+
ngx.log(ngx.ERR, "unable to obtain request socket: ", err)
92+
return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
93+
end
94+
95+
body = client_body_reader(sock, DEFAULT_CHUNKSIZE, is_chunked)
96+
else
97+
-- TODO: Due to ngx.req.read_body(). The current implementation will not work with grpc service
98+
-- See: https://github.com/3scale/APIcast/pull/1419
99+
-- Should we get the body from socket by default and only read buffered body if
100+
-- "Content-Type: application/x-www-form-urlencoded"?
101+
--
102+
-- This is needed to call ngx.req.get_body_data() below.
103+
ngx.req.read_body()
53104

54105
-- We cannot use resty.http's .get_client_body_reader().
55106
-- In POST requests with HTTPS, the result of that call is nil, and it
@@ -60,26 +111,55 @@ local function forward_https_request(proxy_uri, proxy_auth, uri, skip_https_conn
60111
-- read and need to be cached in a local file. This request will return
61112
-- nil, so after this we need to read the temp file.
62113
-- https://github.com/openresty/lua-nginx-module#ngxreqget_body_data
63-
body = ngx.req.get_body_data(),
64-
proxy_uri = proxy_uri,
65-
proxy_auth = proxy_auth
66-
}
114+
body = ngx.req.get_body_data()
115+
116+
if not body then
117+
local temp_file_path = ngx.req.get_body_file()
118+
ngx.log(ngx.INFO, "HTTPS Proxy: Request body is bigger than client_body_buffer_size, read the content from path='", temp_file_path, "'")
119+
120+
if temp_file_path then
121+
body, err = file_reader(temp_file_path)
122+
if err then
123+
ngx.log(ngx.ERR, "HTTPS proxy: Failed to read temp body file, err: ", err)
124+
ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
125+
end
126+
127+
if is_chunked then
128+
-- If the body is smaller than "client_boby_buffer_size" the Content-Length header is
129+
-- set by openresty based on the size of the buffer. However, when the body is rendered
130+
-- to a file, we will need to calculate and manually set the Content-Length header based
131+
-- on the file size
132+
local contentLength, err = file_size(temp_file_path)()
133+
if err then
134+
ngx.log(ngx.ERR, "HTTPS proxy: Failed to set content length, err: ", err)
135+
ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
136+
end
137+
138+
ngx.req.set_header("Content-Length", tostring(contentLength))
139+
end
140+
end
141+
end
67142

68-
if not request.body then
69-
local temp_file_path = ngx.req.get_body_file()
70-
ngx.log(ngx.INFO, "HTTPS Proxy: Request body is bigger than client_body_buffer_size, read the content from path='", temp_file_path, "'")
71-
72-
if temp_file_path then
73-
local body, err = file_reader(temp_file_path)
74-
if err then
75-
ngx.log(ngx.ERR, "HTTPS proxy: Failed to read temp body file, err: ", err)
76-
ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
77-
end
78-
request.body = body
143+
-- The whole request is buffered with chunked encoding removed, so remove the Transfer-Encoding: chunked
144+
-- header, otherwise the upstream won't be able to read the body as it expected chunk encoded
145+
-- body
146+
if is_chunked then
147+
ngx.req.set_header("Transfer-Encoding", nil)
79148
end
149+
end
80150
end
81151

82-
local httpc, err = http_proxy.new(request, skip_https_connect)
152+
local request = {
153+
uri = uri,
154+
method = ngx.req.get_method(),
155+
headers = ngx.req.get_headers(0, true),
156+
path = format('%s%s%s', ngx.var.uri, ngx.var.is_args, ngx.var.query_string or ''),
157+
body = body,
158+
proxy_uri = proxy_uri,
159+
proxy_auth = opts.proxy_auth
160+
}
161+
162+
local httpc, err = http_proxy.new(request, opts.skip_https_connect)
83163

84164
if not httpc then
85165
ngx.log(ngx.ERR, 'could not connect to proxy: ', proxy_uri, ' err: ', err)
@@ -91,8 +171,16 @@ local function forward_https_request(proxy_uri, proxy_auth, uri, skip_https_conn
91171
res, err = httpc:request(request)
92172

93173
if res then
94-
httpc:proxy_response(res)
95-
httpc:set_keepalive()
174+
if opts.request_unbuffered and raw then
175+
local bytes, err = send_response(sock, res, DEFAULT_CHUNKSIZE)
176+
if not bytes then
177+
ngx.log(ngx.ERR, "failed to send response: ", err)
178+
return sock:send("HTTP/1.1 502 Bad Gateway")
179+
end
180+
else
181+
httpc:proxy_response(res)
182+
httpc:set_keepalive()
183+
end
96184
else
97185
ngx.log(ngx.ERR, 'failed to proxy request to: ', proxy_uri, ' err : ', err)
98186
return ngx.exit(ngx.HTTP_BAD_GATEWAY)
@@ -145,7 +233,13 @@ function _M.request(upstream, proxy_uri)
145233
return
146234
elseif uri.scheme == 'https' then
147235
upstream:rewrite_request()
148-
forward_https_request(proxy_uri, proxy_auth, uri, upstream.skip_https_connect)
236+
local proxy_opts = {
237+
proxy_auth = proxy_auth,
238+
skip_https_connect = upstream.skip_https_connect,
239+
request_unbuffered = upstream.request_unbuffered
240+
}
241+
242+
forward_https_request(proxy_uri, uri, proxy_opts)
149243
return ngx.exit(ngx.OK) -- terminate phase
150244
else
151245
ngx.log(ngx.ERR, 'could not connect to proxy: ', proxy_uri, ' err: ', 'invalid request scheme')
Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,97 @@
11
# APICast Request Unbuffered
22

3-
This policy allows to disable request buffering
3+
## Description
4+
5+
When enable this policy will dymanically sets the [`proxy_request_buffering: off`](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_request_buffering
6+
) directive per service.
7+
8+
## Technical details
9+
10+
By default, NGINX reads the entire request body into memory or buffers large requests to disk before forwarding them to the upstream server. Reading bodies can become expensive, especially when sending requests containing large payloads.
11+
12+
For example, when the client sends 10GB, NGINX will buffer the entire 10GB to disk before sending anything to the upstream server.
13+
14+
When the `request_unbuffered` is in the chain, request buffering is disabled, sending the request body to the proxied server immediately upon receiving it. This can help minimize time spent sending data to a service and disk I/O for requests with big body. However, there are caveats and corner cases applied, [**Caveats**](#caveats)
15+
16+
The policy also provides a consistent behavior across multiple scenarios like:
17+
18+
```
19+
- APIcast <> upstream HTTP 1.1 plain
20+
- APIcast <> upstream TLS
21+
- APIcast <> HTTP Proxy (env var) <> upstream HTTP 1.1 plain
22+
- APIcast <> HTTP Proxy (policy) <> upstream HTTP 1.1 plain
23+
- APIcast <> HTTP Proxy (camel proxy) <> upstream HTTP 1.1 plain
24+
- APIcast <> HTTP Proxy (env var) <> upstream TLS
25+
- APIcast <> HTTP Proxy (policy) <> upstream TLS
26+
- APIcast <> HTTP Proxy (camel proxy) <> upstream TLS
27+
```
28+
29+
## Why don't we also support disable response buffering?
30+
31+
The response buffering is enabled by default in NGINX (the [`proxy_buffering: on`]() directive). It does this to shield the backend against slow clients ([slowloris attack](https://en.wikipedia.org/wiki/Slowloris_(computer_security))).
32+
33+
If the `proxy_buffering` is disabled, the upstream server keeps the connection open until all data is received by the client. NGINX [advises](https://www.nginx.com/blog/avoiding-top-10-nginx-configuration-mistakes/#proxy_buffering-off) against disabling `proxy_buffering` as it will potentially waste upstream server resources.
34+
35+
## Why does upstream receive a "Content-Length" header when the original request is sent with "Transfer-Encoding: chunked"
36+
37+
For a request with "small" body that fits into [`client_body_buffer_size`](https://nginx.org/en/docs/http/ngx_http_core_module.html#client_body_buffer_size) and with header "Transfer-Encoding: chunked", NGINX will always read and know the length of the body. Then it will send the request to upstream with the "Content-Length" header.
38+
39+
If a client uses chunked transfer encoding with HTTP/1.0, NGINX will always buffer the request body
440

541
## Example configuration
642

743
```
8-
{
9-
"name": "request_unbuffered",
10-
"version": "builtin",
11-
"configuration": {}
12-
}
44+
"policy_chain": [
45+
{
46+
"name": "request_unbuffered",
47+
"version": "builtin",
48+
},
49+
{
50+
"name": "apicast.policy.apicast"
51+
}
52+
]
53+
```
54+
55+
Use with Proxy policy
56+
57+
```
58+
"policy_chain": [
59+
{
60+
"name": "request_unbuffered",
61+
"version": "builtin",
62+
},
63+
{
64+
"name": "apicast.policy.http_proxy",
65+
"configuration": {
66+
"all_proxy": "http://foo:[email protected]:8888/",
67+
"https_proxy": "http://192.168.15.103:8888/",
68+
"http_proxy": "http://192.168.15.103:8888/"
69+
}
70+
}
71+
]
72+
```
73+
74+
Use with Camel Proxy policy
75+
76+
```
77+
"policy_chain": [
78+
{
79+
"name": "request_unbuffered",
80+
"version": "builtin",
81+
},
82+
{
83+
"name": "apicast.policy.camel",
84+
"configuration": {
85+
"http_proxy": "http://192.168.15.103:8080/",
86+
"https_proxy": "http://192.168.15.103:8443/",
87+
"all_proxy": "http://192.168.15.103:8080/"
88+
}
89+
}
90+
]
1391
```
1492

93+
## Caveats
94+
95+
- APIcast allows defining of mapping rules based on request content. For example, `POST /some_path?a_param={a_value}` will match a request like `POST "http://apicast_host:8080/some_path"` with a form URL-encoded body like: `a_param=abc`, requests with `Content-type: application/x-www-form-urlencoded` will always be buffered regardless of the
96+
`request_unbuffered` policy is enabled or not.
97+
- Disable request buffering could potentially expose the backend to [slowloris attack](https://en.wikipedia.org/wiki/Slowloris_(computer_security)). Therefore, we recommend to only use this policy when needed.

gateway/src/apicast/upstream.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ function _M:call(context)
241241
self:set_skip_https_connect_on_proxy();
242242
end
243243

244+
self.request_unbuffered = context.request_unbuffered
244245
http_proxy.request(self, proxy_uri)
245246
else
246247
local err = self:rewrite_request()

gateway/src/resty/file.lua

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
local co_yield = coroutine._yield
2+
local co_wrap = coroutine._wrap
23
local open = io.open
34

45
local co_wrap_iter = require("resty.coroutines").co_wrap_iter
@@ -28,4 +29,22 @@ function _M.file_reader(filename)
2829
end)
2930
end
3031

32+
function _M.file_size(filename)
33+
return co_wrap(function()
34+
local handle, err = open(filename)
35+
36+
if err then
37+
return nil, err
38+
end
39+
40+
local current = handle:seek()
41+
local size = handle:seek("end")
42+
43+
handle:seek("set", current)
44+
handle:close()
45+
46+
return size
47+
end)
48+
end
49+
3150
return _M

0 commit comments

Comments
 (0)