Skip to content

Commit 90820b0

Browse files
authored
[ProxyPoolPlugin] Avoid remote proxy of private IP requests (#833)
* Avoid proxy of requests to private IP within `ProxyPoolPlugin` * Fix tests * spell fix
1 parent 921f2b5 commit 90820b0

File tree

7 files changed

+38
-29
lines changed

7 files changed

+38
-29
lines changed

proxy/http/handler.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -189,27 +189,20 @@ async def handle_events(
189189
return False
190190

191191
def handle_data(self, data: memoryview) -> Optional[bool]:
192+
"""Handles incoming data from client."""
192193
if data is None:
193194
logger.debug('Client closed connection, tearing down...')
194195
self.work.closed = True
195196
return True
196-
197197
try:
198-
# HttpProtocolHandlerPlugin.on_client_data
199-
# Can raise HttpProtocolException to tear down the connection
200-
for plugin in self.plugins.values():
201-
optional_data = plugin.on_client_data(data)
202-
if optional_data is None:
203-
break
204-
data = optional_data
205198
# Don't parse incoming data any further after 1st request has completed.
206199
#
207200
# This specially does happen for pipeline requests.
208201
#
209202
# Plugins can utilize on_client_data for such cases and
210203
# apply custom logic to handle request data sent after 1st
211204
# valid request.
212-
if data and self.request.state != httpParserStates.COMPLETE:
205+
if self.request.state != httpParserStates.COMPLETE:
213206
# Parse http request
214207
#
215208
# TODO(abhinavsingh): Remove .tobytes after parser is
@@ -229,6 +222,14 @@ def handle_data(self, data: memoryview) -> Optional[bool]:
229222
plugin_.client._conn = upgraded_sock
230223
elif isinstance(upgraded_sock, bool) and upgraded_sock is True:
231224
return True
225+
else:
226+
# HttpProtocolHandlerPlugin.on_client_data
227+
# Can raise HttpProtocolException to tear down the connection
228+
for plugin in self.plugins.values():
229+
optional_data = plugin.on_client_data(data)
230+
if optional_data is None:
231+
break
232+
data = optional_data
232233
except HttpProtocolException as e:
233234
logger.debug('HttpProtocolException raised')
234235
response: Optional[memoryview] = e.response(self.request)

proxy/http/parser/parser.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from typing import TypeVar, Optional, Dict, Type, Tuple, List
1616

1717
from ...common.constants import DEFAULT_DISABLE_HEADERS, COLON, DEFAULT_ENABLE_PROXY_PROTOCOL
18-
from ...common.constants import HTTP_1_1, HTTP_1_0, SLASH, CRLF
18+
from ...common.constants import HTTP_1_1, SLASH, CRLF
1919
from ...common.constants import WHITESPACE, DEFAULT_HTTP_PORT
2020
from ...common.utils import build_http_request, build_http_response, find_http_line, text_
2121
from ...common.flag import flags
@@ -271,7 +271,7 @@ def _process_body(self, raw: bytes) -> Tuple[bool, bytes]:
271271
self.body = self.chunk.body
272272
self.state = httpParserStates.COMPLETE
273273
more = False
274-
elif b'content-length' in self.headers:
274+
elif self.content_expected:
275275
self.state = httpParserStates.RCVING_BODY
276276
if self.body is None:
277277
self.body = b''
@@ -283,13 +283,15 @@ def _process_body(self, raw: bytes) -> Tuple[bool, bytes]:
283283
self.state = httpParserStates.COMPLETE
284284
more, raw = len(raw) > 0, raw[total_size - received_size:]
285285
else:
286-
# HTTP/1.0 scenario only
287-
assert self.version == HTTP_1_0
288286
self.state = httpParserStates.RCVING_BODY
289287
# Received a packet without content-length header
290288
# and no transfer-encoding specified.
291289
#
290+
# This can happen for both HTTP/1.0 and HTTP/1.1 scenarios.
291+
# Currently, we consume the remaining buffer as body.
292+
#
292293
# Ref https://github.com/abhinavsingh/proxy.py/issues/398
294+
#
293295
# See TestHttpParser.test_issue_398 scenario
294296
self.body = raw
295297
more, raw = False, b''

proxy/http/plugin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ async def read_from_descriptors(self, r: Readables) -> bool:
9494

9595
@abstractmethod
9696
def on_client_data(self, raw: memoryview) -> Optional[memoryview]:
97+
"""Called only after original request has been completely received."""
9798
return raw # pragma: no cover
9899

99100
@abstractmethod

proxy/http/proxy/plugin.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ def handle_client_data(
121121
Essentially, if you return None from within before_upstream_connection,
122122
be prepared to handle_client_data and not handle_client_request.
123123
124+
Only called after initial request from client has been received.
125+
124126
Raise HttpRequestRejected to tear down the connection
125127
Return None to drop the connection
126128
"""

proxy/http/proxy/server.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -903,7 +903,6 @@ def wrap_client(self) -> bool:
903903
def emit_request_complete(self) -> None:
904904
if not self.flags.enable_events:
905905
return
906-
907906
assert self.request.port
908907
self.event_queue.publish(
909908
request_id=self.uid.hex,
@@ -924,7 +923,6 @@ def emit_request_complete(self) -> None:
924923
def emit_response_events(self, chunk_size: int) -> None:
925924
if not self.flags.enable_events:
926925
return
927-
928926
if self.response.state == httpParserStates.COMPLETE:
929927
self.emit_response_complete()
930928
elif self.response.state == httpParserStates.RCVING_BODY:
@@ -935,7 +933,6 @@ def emit_response_events(self, chunk_size: int) -> None:
935933
def emit_response_headers_complete(self) -> None:
936934
if not self.flags.enable_events:
937935
return
938-
939936
self.event_queue.publish(
940937
request_id=self.uid.hex,
941938
event_name=eventNames.RESPONSE_HEADERS_COMPLETE,
@@ -948,7 +945,6 @@ def emit_response_headers_complete(self) -> None:
948945
def emit_response_chunk_received(self, chunk_size: int) -> None:
949946
if not self.flags.enable_events:
950947
return
951-
952948
self.event_queue.publish(
953949
request_id=self.uid.hex,
954950
event_name=eventNames.RESPONSE_CHUNK_RECEIVED,
@@ -962,7 +958,6 @@ def emit_response_chunk_received(self, chunk_size: int) -> None:
962958
def emit_response_complete(self) -> None:
963959
if not self.flags.enable_events:
964960
return
965-
966961
self.event_queue.publish(
967962
request_id=self.uid.hex,
968963
event_name=eventNames.RESPONSE_COMPLETE,

proxy/plugin/proxy_pool.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010
"""
1111
import random
1212
import logging
13+
import ipaddress
1314

1415
from typing import Dict, List, Optional, Any
1516

1617
from ..common.flag import flags
18+
from ..common.utils import text_
1719

1820
from ..http import Url, httpMethods
1921
from ..http.parser import HttpParser
@@ -78,15 +80,22 @@ def before_upstream_connection(
7880
) -> Optional[HttpParser]:
7981
"""Avoids establishing the default connection to upstream server
8082
by returning None.
83+
84+
TODO(abhinavsingh): Ideally connection to upstream proxy endpoints
85+
must be bootstrapped within it's own re-usable and garbage collected pool,
86+
to avoid establishing a new upstream proxy connection for each client request.
87+
88+
See :class:`~proxy.core.connection.pool.ConnectionPool` which is a work
89+
in progress for SSL cache handling.
8190
"""
82-
# TODO(abhinavsingh): Ideally connection to upstream proxy endpoints
83-
# must be bootstrapped within it's own re-usable and gc'd pool, to avoid establishing
84-
# a fresh upstream proxy connection for each client request.
85-
#
86-
# See :class:`~proxy.core.connection.pool.ConnectionPool` which is a work
87-
# in progress for SSL cache handling.
88-
#
89-
# Implement your own logic here e.g. round-robin, least connection etc.
91+
# We don't want to send private IP requests to remote proxies
92+
try:
93+
if ipaddress.ip_address(text_(request.host)).is_private:
94+
return request
95+
except ValueError:
96+
pass
97+
# Choose a random proxy from the pool
98+
# TODO: Implement your own logic here e.g. round-robin, least connection etc.
9099
endpoint = random.choice(self.flags.proxy_pool)[0].split(':', 1)
91100
if endpoint[0] == 'localhost' and endpoint[1] == '8899':
92101
return request

tests/http/test_http_proxy_tls_interception.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,8 @@ async def asyncReturnBool(val: bool) -> bool:
145145
# Assert our mocked plugins invocations
146146
self.plugin.return_value.get_descriptors.assert_called()
147147
self.plugin.return_value.write_to_descriptors.assert_called_with([])
148-
self.plugin.return_value.on_client_data.assert_called_with(
149-
connect_request,
150-
)
148+
# on_client_data is only called after initial request has completed
149+
self.plugin.return_value.on_client_data.assert_not_called()
151150
self.plugin.return_value.on_request_complete.assert_called()
152151
self.plugin.return_value.read_from_descriptors.assert_called_with([
153152
self._conn.fileno(),

0 commit comments

Comments
 (0)