Skip to content

Commit b0e6fc4

Browse files
committed
followup: separate proxy connection setup logic to a low-level connect_proxy() method
This commit separates the logic that sets up the connection to the proxy server to a separate connect_proxy() method. This method is provided to users as a low-level API they can use similarly to the connect() method. The connect_proxy() will handle the connection establishment to the proxy server and performs the CONNECT request to setup a TCP tunnel to a https protected host. Similar to the connect() method, it is then up to the user to take care of the details that are relevant when using a proxy (i.e use absolute uris for http requests and perform a TLS handshake for https connections). There's also a new test case that verifies the CONNECT request is used properly to establish a tunnel to the remote server when TLS is used. Due to the limitations of the test framework, this case only considers the format of the outgoing CONNECT request and how the code handles errors sent by the proxy. Testing a full TLS tunnel is unfortunately not possible with the tools the test framework provides as it would require a real reverse proxy or a method of forwarding the TCP connection after the CONNECT request is received to a real web server that can talk TLS.
1 parent 5c96e1f commit b0e6fc4

File tree

3 files changed

+151
-44
lines changed

3 files changed

+151
-44
lines changed

README.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Production ready.
2222

2323
* [new](#new)
2424
* [connect](#connect)
25+
* [connect_proxy](#connect_proxy)
2526
* [set_timeout](#set_timeout)
2627
* [set_timeouts](#set_timeouts)
2728
* [ssl_handshake](#ssl_handshake)
@@ -158,6 +159,24 @@ An optional Lua table can be specified as the last argument to this method to sp
158159
* `pool`
159160
: Specifies a custom name for the connection pool being used. If omitted, then the connection pool name will be generated from the string template `<host>:<port>` or `<unix-socket-path>`.
160161

162+
## connect_proxy
163+
164+
`syntax: ok, err = httpc:connect_proxy(proxy_uri, scheme, host, port)`
165+
166+
Attempts to connect to the web server through the given proxy server. The method accepts the following arguments:
167+
168+
* `proxy_uri` - Full URI of the proxy server to use (e.g. `http://proxy.example.com:3128/`). Note: Only `http` protocol is supported.
169+
* `scheme` - The protocol to use between the proxy server and the remote host (`http` or `https`). If `https` is specified as the scheme, `connect_proxy()` makes a `CONNECT` request to establish a TCP tunnel to the remote host through the proxy server.
170+
* `host` - The hostname of the remote host to connect to.
171+
* `port` - The port of the remote host to connect to.
172+
173+
If an error occurs during the connection attempt, this method returns `nil` with a string describing the error. If the connection was successfully established, the method returns `1`.
174+
175+
There's a few key points to keep in mind when using this api:
176+
177+
* If the scheme is `https`, you need to perform the TLS handshake with the remote server manually using the `ssl_handshake()` method before sending any requests through the proxy tunnel.
178+
* If the scheme is `http`, you need to ensure that the requests you send through the connections conforms to [RFC 7230](https://tools.ietf.org/html/rfc7230) and especially [Section 5.3.2.](https://tools.ietf.org/html/rfc7230#section-5.3.2) which states that the request target must be in absolute form. In practice, this means that when you use `send_request()`, the `path` must be an absolute URI to the resource (e.g. `http://example.com/index.html` instead of just `/index.html`).
179+
161180
## set_timeout
162181

163182
`syntax: httpc:set_timeout(time)`
@@ -244,7 +263,7 @@ When the request is successful, `res` will contain the following fields:
244263
* `status` The status code.
245264
* `reason` The status reason phrase.
246265
* `headers` A table of headers. Multiple headers with the same field name will be presented as a table of values.
247-
* `has_body` A boolean flag indicating if there is a body to be read.
266+
* `has_body` A boolean flag indicating if there is a body to be read.
248267
* `body_reader` An iterator function for reading the body in a streaming fashion.
249268
* `read_body` A method to read the entire body into a string.
250269
* `read_trailers` A method to merge any trailers underneath the headers, after reading the body.
@@ -420,7 +439,7 @@ local res, err = httpc:request{
420439
}
421440
```
422441

423-
If `sock` is specified,
442+
If `sock` is specified,
424443

425444
# Author
426445

lib/resty/http.lua

Lines changed: 78 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -787,7 +787,6 @@ function _M.request_pipeline(self, requests)
787787
return responses
788788
end
789789

790-
791790
function _M.request_uri(self, uri, params)
792791
params = tbl_copy(params or {}) -- Take by value
793792

@@ -801,60 +800,50 @@ function _M.request_uri(self, uri, params)
801800
if not params.query then params.query = query end
802801

803802
-- See if we should use a proxy to make this request
804-
local proxy_host, proxy_port
805803
local proxy_uri = self:get_proxy_uri(scheme, host)
806-
if proxy_uri then
807-
local parsed_proxy_uri, err = self:parse_uri(proxy_uri, false)
808-
if not parsed_proxy_uri then
809-
return nil, err
810-
end
811804

812-
proxy_host, proxy_port = parsed_proxy_uri[2], parsed_proxy_uri[3]
805+
-- Make the connection either through the proxy or directly
806+
-- to the remote host
807+
local c, err
808+
809+
if proxy_uri then
810+
c, err = self:connect_proxy(proxy_uri, scheme, host, port)
811+
else
812+
c, err = self:connect(host, port)
813813
end
814814

815-
local c, err = self:connect(proxy_host or host, proxy_port or port)
816815
if not c then
817816
return nil, err
818817
end
819818

820-
if proxy_uri and scheme == "https" then
821-
-- Make a CONNECT request to create a tunnel to the destination through
822-
-- the proxy
823-
local destination = host .. ":" .. port
824-
local res, err = self:request({
825-
method = "CONNECT",
826-
path = destination,
827-
headers = {
828-
["Host"] = destination
829-
}
830-
})
831-
832-
if not res then
833-
return nil, err
834-
end
835-
836-
if res.status < 200 or res.status > 299 then
837-
return nil, "failed to establish a tunnel through a proxy: " .. res.status
819+
if proxy_uri then
820+
if scheme == "http" then
821+
-- When a proxy is used, the target URI must be in absolute-form
822+
-- (RFC 7230, Section 5.3.2.). That is, it must be an absolute URI
823+
-- to the remote resource with the scheme, host and an optional port
824+
-- in place.
825+
--
826+
-- Since _format_request() constructs the request line by concatenating
827+
-- params.path and params.query together, we need to modify the path
828+
-- to also include the scheme, host and port so that the final form
829+
-- in conformant to RFC 7230.
830+
if port == 80 then
831+
params.path = scheme .. "://" .. host .. path
832+
else
833+
params.path = scheme .. "://" .. host .. ":" .. port .. path
834+
end
838835
end
839836

840-
-- don't keep this connection alive as the next request could target
841-
-- any host and re-using the tunnel for that is not possible
842-
self.keepalive = false
843-
end
844-
845-
if proxy_uri and scheme == "http" then
846-
-- http proxies expect to see the full URI in the request line
847-
if port == 80 then
848-
params.path = scheme .. "://" .. host .. path
849-
else
850-
params.path = scheme .. "://" .. host .. ":" .. port .. path
837+
if scheme == "https" then
838+
-- don't keep this connection alive as the next request could target
839+
-- any host and re-using the proxy tunnel for that is not possible
840+
self.keepalive = false
851841
end
852-
end
853842

854-
if proxy_uri then
855-
-- self:connect() set the host and port to point to the proxy server. As
843+
-- self:connect_uri() set the host and port to point to the proxy server. As
856844
-- the connection to the proxy has been established, set the host and port
857-
-- to point to the actual remote endpoint at the other end of the tunnel
845+
-- to point to the actual remote endpoint at the other end of the tunnel to
846+
-- ensure the correct Host header added to the requests.
858847
self.host = host
859848
self.port = port
860849
end
@@ -1016,4 +1005,51 @@ function _M.get_proxy_uri(self, scheme, host)
10161005
end
10171006

10181007

1008+
function _M.connect_proxy(self, proxy_uri, scheme, host, port)
1009+
-- Parse the provided proxy URI
1010+
local parsed_proxy_uri, err = self:parse_uri(proxy_uri, false)
1011+
if not parsed_proxy_uri then
1012+
return nil, err
1013+
end
1014+
1015+
-- Check that the scheme is http (https is not supported for
1016+
-- connections between the client and the proxy)
1017+
local proxy_scheme = parsed_proxy_uri[1]
1018+
if proxy_scheme ~= "http" then
1019+
return nil, "protocol " .. proxy_scheme .. " not supported for proxy connections"
1020+
end
1021+
1022+
-- Make the connection to the given proxy
1023+
local proxy_host, proxy_port = parsed_proxy_uri[2], parsed_proxy_uri[3]
1024+
local c, err = self:connect(proxy_host, proxy_port)
1025+
if not c then
1026+
return nil, err
1027+
end
1028+
1029+
if scheme == "https" then
1030+
-- Make a CONNECT request to create a tunnel to the destination through
1031+
-- the proxy. The request-target and the Host header must be in the
1032+
-- authority-form of RFC 7230 Section 5.3.3. See also RFC 7231 Section
1033+
-- 4.3.6 for more details about the CONNECT request
1034+
local destination = host .. ":" .. port
1035+
local res, err = self:request({
1036+
method = "CONNECT",
1037+
path = destination,
1038+
headers = {
1039+
["Host"] = destination
1040+
}
1041+
})
1042+
1043+
if not res then
1044+
return nil, err
1045+
end
1046+
1047+
if res.status < 200 or res.status > 299 then
1048+
return nil, "failed to establish a tunnel through a proxy: " .. res.status
1049+
end
1050+
end
1051+
1052+
return c, nil
1053+
end
1054+
10191055
return _M

t/16-http-proxy.t

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,3 +245,55 @@ GET /lua
245245
--- no_error_log
246246
[error]
247247
[warn]
248+
249+
=== TEST 6: request_uri makes a proper CONNECT request when proxying https resources
250+
--- http_config eval: $::HttpConfig
251+
--- config
252+
location /lua {
253+
content_by_lua_block {
254+
local http = require "resty.http"
255+
local httpc = http.new()
256+
httpc:set_proxy_options({
257+
http_proxy = "http://127.0.0.1:12345",
258+
https_proxy = "http://127.0.0.1:12345"
259+
})
260+
261+
-- Slight Hack: temporarily change the module global user agent to make it
262+
-- predictable for this test case
263+
local ua = http._USER_AGENT
264+
http._USER_AGENT = "test_ua"
265+
local res, err = httpc:request_uri("https://127.0.0.1/target?a=1&b=2")
266+
http._USER_AGENT = ua
267+
268+
if not err then
269+
-- The proxy request should fail as the TCP server listening returns
270+
-- 403 response. We cannot really test the success case here as that
271+
-- would require an actual reverse proxy to be implemented through
272+
-- the limited functionality we have available in the raw TCP sockets
273+
ngx.log(ngx.ERR, "unexpected success")
274+
return
275+
end
276+
277+
ngx.status = 403
278+
ngx.say(err)
279+
}
280+
}
281+
--- tcp_listen: 12345
282+
--- tcp_query eval_stdout
283+
# Note: The incoming request contains CRLF line endings and print needs to
284+
# be used here to get the same line breaks to the expected request
285+
print "CONNECT 127.0.0.1:443 HTTP/1.1\r\nUser-Agent: test_ua\r\nHost: 127.0.0.1:443\r\n\r\n"
286+
287+
# The reply cannot be successful or otherwise the client would start
288+
# to do a TLS handshake with the proxied host and that we cannot
289+
# do with these sockets
290+
--- tcp_reply
291+
HTTP/1.1 403 Forbidden
292+
Connection: close
293+
294+
--- request
295+
GET /lua
296+
--- error_code: 403
297+
--- no_error_log
298+
[error]
299+
[warn]

0 commit comments

Comments
 (0)