Skip to content

Commit efd1cac

Browse files
Restrict request handling to DEFAULT_ALLOWED_URL_SCHEMES (#1002)
* Raise `HttpProtocolException` if request line scheme do not match `DEFAULT_ALLOWED_URL_SCHEMES` * ignore WPS329 * Fix tests * Pin to 4.3.2 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Test coverage for exception handling * type ignore Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent d046cea commit efd1cac

File tree

9 files changed

+74
-15
lines changed

9 files changed

+74
-15
lines changed

docs/requirements.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
myst-parser[linkify] >= 0.15.2
22
setuptools-scm >= 6.3.2
3-
Sphinx >= 4.3.0
3+
Sphinx == 4.3.2
44
furo >= 2021.11.15
55
sphinxcontrib-apidoc >= 0.3.0
66
sphinxcontrib-towncrier >= 0.2.0a0

proxy/common/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ def _env_threadless_compliant() -> bool:
9494
DEFAULT_EVENTS_QUEUE = None
9595
DEFAULT_ENABLE_STATIC_SERVER = False
9696
DEFAULT_ENABLE_WEB_SERVER = False
97+
DEFAULT_ALLOWED_URL_SCHEMES = [HTTP_PROTO, HTTPS_PROTO]
9798
DEFAULT_IPV4_HOSTNAME = ipaddress.IPv4Address('127.0.0.1')
9899
DEFAULT_IPV6_HOSTNAME = ipaddress.IPv6Address('::1')
99100
DEFAULT_KEY_FILE = None

proxy/core/event/dispatcher.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def handle_event(self, ev: Dict[str, Any]) -> None:
6161
})
6262
elif ev['event_name'] == eventNames.UNSUBSCRIBE:
6363
# send ack
64-
print('unsubscription request ack sent')
64+
logger.info('unsubscription request ack sent')
6565
self.subscribers[ev['event_payload']['sub_id']].send({
6666
'event_name': eventNames.UNSUBSCRIBED,
6767
})

proxy/http/handler.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -277,10 +277,14 @@ def _parse_first_request(self, data: memoryview) -> bool:
277277
# memoryview compliant
278278
try:
279279
self.request.parse(data.tobytes())
280-
except Exception:
280+
except HttpProtocolException as e: # noqa: WPS329
281+
self.work.queue(BAD_REQUEST_RESPONSE_PKT)
282+
raise e
283+
except Exception as e:
284+
self.work.queue(BAD_REQUEST_RESPONSE_PKT)
281285
raise HttpProtocolException(
282286
'Error when parsing request: %r' % data.tobytes(),
283-
)
287+
) from e
284288
if not self.request.is_complete:
285289
return False
286290
# Discover which HTTP handler plugin is capable of

proxy/http/parser/parser.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,11 @@ def del_headers(self, headers: List[bytes]) -> None:
149149
for key in headers:
150150
self.del_header(key.lower())
151151

152-
def set_url(self, url: bytes) -> None:
152+
def set_url(self, url: bytes, allowed_url_schemes: Optional[List[bytes]] = None) -> None:
153153
"""Given a request line, parses it and sets line attributes a.k.a. host, port, path."""
154-
self._url = Url.from_bytes(url)
154+
self._url = Url.from_bytes(
155+
url, allowed_url_schemes=allowed_url_schemes,
156+
)
155157
self._set_line_attributes()
156158

157159
@property
@@ -204,7 +206,7 @@ def body_expected(self) -> bool:
204206
"""Returns true if content or chunked response is expected."""
205207
return self._content_expected or self._is_chunked_encoded
206208

207-
def parse(self, raw: bytes) -> None:
209+
def parse(self, raw: bytes, allowed_url_schemes: Optional[List[bytes]] = None) -> None:
208210
"""Parses HTTP request out of raw bytes.
209211
210212
Check for `HttpParser.state` after `parse` has successfully returned."""
@@ -217,7 +219,10 @@ def parse(self, raw: bytes) -> None:
217219
if self.state >= httpParserStates.HEADERS_COMPLETE:
218220
more, raw = self._process_body(raw)
219221
elif self.state == httpParserStates.INITIALIZED:
220-
more, raw = self._process_line(raw)
222+
more, raw = self._process_line(
223+
raw,
224+
allowed_url_schemes=allowed_url_schemes,
225+
)
221226
else:
222227
more, raw = self._process_headers(raw)
223228
# When server sends a response line without any header or body e.g.
@@ -345,7 +350,11 @@ def _process_headers(self, raw: bytes) -> Tuple[bool, bytes]:
345350
break
346351
return len(raw) > 0, raw
347352

348-
def _process_line(self, raw: bytes) -> Tuple[bool, bytes]:
353+
def _process_line(
354+
self,
355+
raw: bytes,
356+
allowed_url_schemes: Optional[List[bytes]] = None,
357+
) -> Tuple[bool, bytes]:
349358
while True:
350359
parts = raw.split(CRLF, 1)
351360
if len(parts) == 1:
@@ -363,7 +372,9 @@ def _process_line(self, raw: bytes) -> Tuple[bool, bytes]:
363372
self.method = parts[0]
364373
if self.method == httpMethods.CONNECT:
365374
self._is_https_tunnel = True
366-
self.set_url(parts[1])
375+
self.set_url(
376+
parts[1], allowed_url_schemes=allowed_url_schemes,
377+
)
367378
self.version = parts[2]
368379
self.state = httpParserStates.LINE_RCVD
369380
break

proxy/http/url.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@
1313
http
1414
url
1515
"""
16-
from typing import Optional, Tuple
16+
from typing import List, Optional, Tuple
1717

18-
from ..common.constants import COLON, SLASH, AT
18+
from ..common.constants import COLON, DEFAULT_ALLOWED_URL_SCHEMES, SLASH, AT
1919
from ..common.utils import text_
2020

21+
from .exception import HttpProtocolException
22+
2123

2224
class Url:
2325
"""``urllib.urlparse`` doesn't work for proxy.py, so we wrote a simple URL.
@@ -59,7 +61,7 @@ def __str__(self) -> str:
5961
return url
6062

6163
@classmethod
62-
def from_bytes(cls, raw: bytes) -> 'Url':
64+
def from_bytes(cls, raw: bytes, allowed_url_schemes: Optional[List[bytes]] = None) -> 'Url':
6365
"""A URL within proxy.py core can have several styles,
6466
because proxy.py supports both proxy and web server use cases.
6567
@@ -95,6 +97,10 @@ def from_bytes(cls, raw: bytes) -> 'Url':
9597
if len(parts) == 2:
9698
scheme = parts[0]
9799
rest = parts[1]
100+
if scheme not in (allowed_url_schemes or DEFAULT_ALLOWED_URL_SCHEMES):
101+
raise HttpProtocolException(
102+
'Invalid scheme received in the request line %r' % raw,
103+
)
98104
else:
99105
rest = raw[len(SLASH + SLASH):]
100106
if scheme is not None or starts_with_double_slash:

tests/http/parser/test_http_parser.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,7 @@ def test_parses_icap_protocol(self) -> None:
827827
b'I am posting this information.\r\n' +
828828
b'0\r\n' +
829829
b'\r\n',
830+
allowed_url_schemes=[b'icap'],
830831
)
831832
self.assertEqual(self.parser.method, b'REQMOD')
832833
assert self.parser._url is not None

tests/http/test_protocol_handler.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from proxy.common.version import __version__
2727
from proxy.http.responses import (
2828
BAD_GATEWAY_RESPONSE_PKT, PROXY_AUTH_FAILED_RESPONSE_PKT,
29-
PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT,
29+
PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT, BAD_REQUEST_RESPONSE_PKT,
3030
)
3131
from proxy.common.constants import (
3232
CRLF, PLUGIN_HTTP_PROXY, PLUGIN_PROXY_AUTH, PLUGIN_WEB_SERVER,
@@ -114,6 +114,34 @@ async def test_proxy_authentication_failed(self) -> None:
114114
PROXY_AUTH_FAILED_RESPONSE_PKT,
115115
)
116116

117+
@pytest.mark.asyncio # type: ignore[misc]
118+
async def test_proxy_bails_out_for_unknown_schemes(self) -> None:
119+
mock_selector_for_client_read(self)
120+
self._conn.recv.return_value = CRLF.join([
121+
b'REQMOD icap://icap-server.net/server?arg=87 ICAP/1.0',
122+
b'Host: icap-server.net',
123+
CRLF,
124+
])
125+
await self.protocol_handler._run_once()
126+
self.assertEqual(
127+
self.protocol_handler.work.buffer[0],
128+
BAD_REQUEST_RESPONSE_PKT,
129+
)
130+
131+
@pytest.mark.asyncio # type: ignore[misc]
132+
async def test_proxy_bails_out_for_sip_request_lines(self) -> None:
133+
mock_selector_for_client_read(self)
134+
self._conn.recv.return_value = CRLF.join([
135+
b'OPTIONS sip:nm SIP/2.0',
136+
b'Accept: application/sdp',
137+
CRLF,
138+
])
139+
await self.protocol_handler._run_once()
140+
self.assertEqual(
141+
self.protocol_handler.work.buffer[0],
142+
BAD_REQUEST_RESPONSE_PKT,
143+
)
144+
117145

118146
class TestHttpProtocolHandler(Assertions):
119147

tests/http/test_url.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import unittest
1212

1313
from proxy.http import Url
14+
from proxy.http.exception import HttpProtocolException
1415

1516

1617
class TestUrl(unittest.TestCase):
@@ -145,10 +146,17 @@ def test_no_scheme_suffix(self) -> None:
145146
self.assertEqual(url.password, None)
146147

147148
def test_any_scheme_suffix(self) -> None:
148-
url = Url.from_bytes(b'icap://example-server.net/server?arg=87')
149+
url = Url.from_bytes(
150+
b'icap://example-server.net/server?arg=87',
151+
allowed_url_schemes=[b'icap'],
152+
)
149153
self.assertEqual(url.scheme, b'icap')
150154
self.assertEqual(url.hostname, b'example-server.net')
151155
self.assertEqual(url.port, None)
152156
self.assertEqual(url.remainder, b'/server?arg=87')
153157
self.assertEqual(url.username, None)
154158
self.assertEqual(url.password, None)
159+
160+
def test_assert_raises_for_unknown_schemes(self) -> None:
161+
with self.assertRaises(HttpProtocolException):
162+
Url.from_bytes(b'icap://example-server.net/server?arg=87')

0 commit comments

Comments
 (0)