Skip to content

Commit fd838ca

Browse files
DEFAULT_CA_FILE is now certifi/cacert.pem (#691)
* Add FAQ: OSError when wrapping client for TLS Interception * Silence exception log for several valid "cert verification failed" by client during tls interception * Lint checks * Move exception handling within wrap_server/wrap_client methods * Lint fixes * Use certifi/cacert.pem as default --ca-file flag value * Address tests after DEFAULT_CA_FILE change * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 8a46337 commit fd838ca

File tree

8 files changed

+182
-46
lines changed

8 files changed

+182
-46
lines changed

README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
- [Docker image not working on MacOS](#docker-image-not-working-on-macos)
101101
- [ValueError: filedescriptor out of range in select](#valueerror-filedescriptor-out-of-range-in-select)
102102
- [None:None in access logs](#nonenone-in-access-logs)
103+
- [OSError when wrapping client for TLS Interception](#oserror-when-wrapping-client-for-tls-interception)
103104
- [Plugin Developer and Contributor Guide](#plugin-developer-and-contributor-guide)
104105
- [High level architecture](#high-level-architecture)
105106
- [Everything is a plugin](#everything-is-a-plugin)
@@ -1721,6 +1722,52 @@ few obvious ones include:
17211722
1. Client established a connection but never completed the request.
17221723
2. A plugin returned a response prematurely, avoiding connection to upstream server.
17231724

1725+
## OSError when wrapping client for TLS Interception
1726+
1727+
With `TLS Interception` on, you might occasionally see following exceptions:
1728+
1729+
```console
1730+
2021-11-06 23:33:34,540 - pid:91032 [E] server.intercept:678 - OSError when wrapping client
1731+
Traceback (most recent call last):
1732+
...[redacted]...
1733+
...[redacted]...
1734+
...[redacted]...
1735+
ssl.SSLError: [SSL: TLSV1_ALERT_UNKNOWN_CA] tlsv1 alert unknown ca (_ssl.c:997)
1736+
...[redacted]... - CONNECT oauth2.googleapis.com:443 - 0 bytes - 272.08 ms
1737+
```
1738+
1739+
Some clients can throw `TLSV1_ALERT_UNKNOWN_CA` if they cannot verify the certificate of the server
1740+
because it is signed by an unknown issuer CA. Which is the case when we are doing TLS interception.
1741+
This can be for a variety of reasons e.g. certificate pinning etc.
1742+
1743+
Another exception you might see is `CERTIFICATE_VERIFY_FAILED`:
1744+
1745+
```console
1746+
2021-11-06 23:36:02,002 - pid:91033 [E] handler.handle_readables:293 - Exception while receiving from client connection <socket.socket fd=28, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 8899), raddr=('127.0.0.1', 51961)> with reason SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self signed certificate in certificate chain (_ssl.c:997)')
1747+
Traceback (most recent call last):
1748+
...[redacted]...
1749+
...[redacted]...
1750+
...[redacted]...
1751+
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self signed certificate in certificate chain (_ssl.c:997)
1752+
...[redacted]... - CONNECT init.push.apple.com:443 - 0 bytes - 892.99 ms
1753+
```
1754+
1755+
In future, we might support serving original HTTPS content for such clients while still
1756+
performing TLS interception in the background. This will keep the clients happy without
1757+
impacting our ability to TLS intercept. Unfortunately, this feature is currently not available.
1758+
1759+
Another example with `SSLEOFError` exception:
1760+
1761+
```console
1762+
2021-11-06 23:46:40,446 - pid:91034 [E] server.intercept:678 - OSError when wrapping client
1763+
Traceback (most recent call last):
1764+
...[redacted]...
1765+
...[redacted]...
1766+
...[redacted]...
1767+
ssl.SSLEOFError: EOF occurred in violation of protocol (_ssl.c:997)
1768+
...[redacted]... - CONNECT stock.adobe.io:443 - 0 bytes - 685.32 ms
1769+
```
1770+
17241771
# Plugin Developer and Contributor Guide
17251772

17261773
## High level architecture

proxy/common/constants.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import time
1313
import secrets
1414
import pathlib
15+
import sysconfig
1516
import ipaddress
1617

1718
from typing import List
@@ -23,6 +24,10 @@
2324
# /path/to/proxy.py/proxy folder
2425
PROXY_PY_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
2526

27+
# Path to virtualenv/lib/python3.X/site-packages
28+
PROXY_PY_SITE_PACKAGES = sysconfig.get_path('purelib')
29+
assert PROXY_PY_SITE_PACKAGES
30+
2631
CRLF = b'\r\n'
2732
COLON = b':'
2833
WHITESPACE = b' '
@@ -46,7 +51,9 @@
4651
DEFAULT_CA_KEY_FILE = None
4752
DEFAULT_CA_SIGNING_KEY_FILE = None
4853
DEFAULT_CERT_FILE = None
49-
DEFAULT_CA_FILE = None
54+
DEFAULT_CA_FILE = pathlib.Path(
55+
PROXY_PY_SITE_PACKAGES,
56+
) / 'certifi' / 'cacert.pem'
5057
DEFAULT_CLIENT_RECVBUF_SIZE = DEFAULT_BUFFER_SIZE
5158
DEFAULT_DEVTOOLS_WS_PATH = b'/devtools'
5259
DEFAULT_DISABLE_HEADERS: List[bytes] = []

proxy/core/acceptor/threadless.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,22 @@ def selected_events(self) -> Generator[
7474
Tuple[Readables, Writables],
7575
None, None,
7676
]:
77+
assert self.selector is not None
7778
events: Dict[socket.socket, int] = {}
7879
for work in self.works.values():
79-
events.update(work.get_events())
80-
assert self.selector is not None
81-
for fd in events:
82-
self.selector.register(fd, events[fd])
80+
worker_events = work.get_events()
81+
events.update(worker_events)
82+
for fd in worker_events:
83+
# Can throw ValueError: Invalid file descriptor: -1
84+
#
85+
# Work classes must handle the exception and shutdown
86+
# gracefully otherwise this will result in bringing down the
87+
# entire threadless process
88+
#
89+
# This is only possible when work.get_events pass
90+
# an invalid file descriptor. Example, because of bad
91+
# exception handling within the work implementation class.
92+
self.selector.register(fd, worker_events[fd])
8393
ev = self.selector.select(timeout=1)
8494
readables = []
8595
writables = []

proxy/http/proxy/server.py

Lines changed: 104 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,9 @@
6666
flags.add_argument(
6767
'--ca-file',
6868
type=str,
69-
default=DEFAULT_CA_FILE,
70-
help='Default: None. Provide path to custom CA file for peer certificate validation. '
71-
'Specially useful on MacOS.',
69+
default=str(DEFAULT_CA_FILE),
70+
help='Default: ' + str(DEFAULT_CA_FILE) +
71+
'. Provide path to custom CA bundle for peer certificate verification',
7272
)
7373
flags.add_argument(
7474
'--ca-signing-key-file',
@@ -658,49 +658,122 @@ def generate_upstream_certificate(
658658

659659
def intercept(self) -> Union[socket.socket, bool]:
660660
# Perform SSL/TLS handshake with upstream
661-
self.wrap_server()
661+
teardown = self.wrap_server()
662+
if teardown:
663+
raise HttpProtocolException(
664+
'Exception when wrapping server for interception',
665+
)
662666
# Generate certificate and perform handshake with client
667+
# wrap_client also flushes client data before wrapping
668+
# sending to client can raise, handle expected exceptions
669+
teardown = self.wrap_client()
670+
if teardown:
671+
raise HttpProtocolException(
672+
'Exception when wrapping client for interception',
673+
)
674+
# Update all plugin connection reference
675+
# TODO(abhinavsingh): Is this required?
676+
for plugin in self.plugins.values():
677+
plugin.client._conn = self.client.connection
678+
return self.client.connection
679+
680+
def wrap_server(self) -> bool:
681+
assert self.upstream is not None
682+
assert isinstance(self.upstream.connection, socket.socket)
683+
try:
684+
self.upstream.wrap(text_(self.request.host), self.flags.ca_file)
685+
except ssl.SSLCertVerificationError: # Server raised certificate verification error
686+
# When --disable-interception-on-ssl-cert-verification-error flag is on,
687+
# we will cache such upstream hosts and avoid intercepting them for future
688+
# requests.
689+
logger.warning(
690+
'ssl.SSLCertVerificationError: ' +
691+
'Server raised cert verification error for upstream: {0}'.format(
692+
self.upstream.addr[0],
693+
),
694+
)
695+
return True
696+
except ssl.SSLError as e:
697+
if e.reason == 'SSLV3_ALERT_HANDSHAKE_FAILURE':
698+
logger.warning(
699+
'{0}: '.format(e.reason) +
700+
'Server raised handshake alert failure for upstream: {0}'.format(
701+
self.upstream.addr[0],
702+
),
703+
)
704+
else:
705+
logger.exception(
706+
'SSLError when wrapping client for upstream: {0}'.format(
707+
self.upstream.addr[0],
708+
), exc_info=e,
709+
)
710+
return True
711+
assert isinstance(self.upstream.connection, ssl.SSLSocket)
712+
return False
713+
714+
def wrap_client(self) -> bool:
715+
assert self.upstream is not None and self.flags.ca_signing_key_file is not None
716+
assert isinstance(self.upstream.connection, ssl.SSLSocket)
663717
try:
664-
# wrap_client also flushes client data before wrapping
665-
# sending to client can raise, handle expected exceptions
666-
self.wrap_client()
718+
# TODO: Perform async certificate generation
719+
generated_cert = self.generate_upstream_certificate(
720+
cast(Dict[str, Any], self.upstream.connection.getpeercert()),
721+
)
722+
self.client.wrap(self.flags.ca_signing_key_file, generated_cert)
667723
except subprocess.TimeoutExpired as e: # Popen communicate timeout
668724
logger.exception(
669725
'TimeoutExpired during certificate generation', exc_info=e,
670726
)
671727
return True
728+
except ssl.SSLCertVerificationError: # Client raised certificate verification error
729+
# When --disable-interception-on-ssl-cert-verification-error flag is on,
730+
# we will cache such upstream hosts and avoid intercepting them for future
731+
# requests.
732+
logger.warning(
733+
'ssl.SSLCertVerificationError: ' +
734+
'Client raised cert verification error for upstream: {0}'.format(
735+
self.upstream.addr[0],
736+
),
737+
)
738+
return True
739+
except ssl.SSLEOFError as e:
740+
logger.warning(
741+
'ssl.SSLEOFError {0} when wrapping client for upstream: {1}'.format(
742+
str(e), self.upstream.addr[0],
743+
),
744+
)
745+
return True
746+
except ssl.SSLError as e:
747+
if e.reason in ('TLSV1_ALERT_UNKNOWN_CA', 'UNSUPPORTED_PROTOCOL'):
748+
logger.warning(
749+
'{0}: '.format(e.reason) +
750+
'Client raised cert verification error for upstream: {0}'.format(
751+
self.upstream.addr[0],
752+
),
753+
)
754+
else:
755+
logger.exception(
756+
'OSError when wrapping client for upstream: {0}'.format(
757+
self.upstream.addr[0],
758+
), exc_info=e,
759+
)
760+
return True
672761
except BrokenPipeError:
673762
logger.error(
674-
'BrokenPipeError when wrapping client',
763+
'BrokenPipeError when wrapping client for upstream: {0}'.format(
764+
self.upstream.addr[0],
765+
),
675766
)
676767
return True
677768
except OSError as e:
678769
logger.exception(
679-
'OSError when wrapping client', exc_info=e,
770+
'OSError when wrapping client for upstream: {0}'.format(
771+
self.upstream.addr[0],
772+
), exc_info=e,
680773
)
681774
return True
682-
# Update all plugin connection reference
683-
# TODO(abhinavsingh): Is this required?
684-
for plugin in self.plugins.values():
685-
plugin.client._conn = self.client.connection
686-
return self.client.connection
687-
688-
def wrap_server(self) -> None:
689-
assert self.upstream is not None
690-
assert isinstance(self.upstream.connection, socket.socket)
691-
self.upstream.wrap(text_(self.request.host), self.flags.ca_file)
692-
assert isinstance(self.upstream.connection, ssl.SSLSocket)
693-
694-
def wrap_client(self) -> None:
695-
assert self.upstream is not None and self.flags.ca_signing_key_file is not None
696-
assert isinstance(self.upstream.connection, ssl.SSLSocket)
697-
generated_cert = self.generate_upstream_certificate(
698-
cast(Dict[str, Any], self.upstream.connection.getpeercert()),
699-
)
700-
self.client.wrap(self.flags.ca_signing_key_file, generated_cert)
701-
logger.debug(
702-
'TLS interception using %s', generated_cert,
703-
)
775+
logger.debug('TLS intercepting using %s', generated_cert)
776+
return False
704777

705778
#
706779
# Event emitter callbacks

proxy/plugin/cache/store/disk.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
'--cache-dir',
2828
type=str,
2929
default=tempfile.gettempdir(),
30-
help='Default: A temporary directory. Flag only applicable when cache plugin is used with on-disk storage.',
30+
help='Default: A temporary directory. ' +
31+
'Flag only applicable when cache plugin is used with on-disk storage.',
3132
)
3233

3334

proxy/plugin/reverse_proxy.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,7 @@
1212
import random
1313
import socket
1414
import logging
15-
import sysconfig
1615

17-
from pathlib import Path
1816
from typing import List, Optional, Tuple, Any
1917
from urllib import parse as urlparse
2018

@@ -29,11 +27,6 @@
2927

3028
logger = logging.getLogger(__name__)
3129

32-
# We need CA bundle to verify TLS connection to upstream servers
33-
PURE_LIB = sysconfig.get_path('purelib')
34-
assert PURE_LIB
35-
CACERT_PEM_PATH = Path(PURE_LIB) / 'certifi' / 'cacert.pem'
36-
3730

3831
# TODO: ReverseProxyPlugin and ProxyPoolPlugin are implementing
3932
# a similar behavior. Abstract that particular logic out into its
@@ -135,7 +128,7 @@ def handle_request(self, request: HttpParser) -> None:
135128
self.upstream.wrap(
136129
text_(
137130
url.hostname,
138-
), ca_file=str(CACERT_PEM_PATH),
131+
), ca_file=str(self.flags.ca_file),
139132
)
140133
self.upstream.queue(memoryview(request.build()))
141134
except ConnectionRefusedError:

proxy/proxy.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,10 @@ def main(
514514
# configuration etc.
515515
#
516516
# TODO: Python shell within running proxy.py environment?
517+
#
518+
# TODO: Pid watcher which watches for processes started
519+
# by proxy.py core. May be alert or restart those processes
520+
# on failure.
517521
while True:
518522
time.sleep(1)
519523
except KeyboardInterrupt:

tests/http/test_http_proxy_tls_interception.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from typing import Any
1818
from unittest import mock
19+
from proxy.common.constants import DEFAULT_CA_FILE
1920

2021
from proxy.core.connection import TcpClientConnection, TcpServerConnection
2122
from proxy.http.handler import HttpProtocolHandler
@@ -168,7 +169,7 @@ def mock_connection() -> Any:
168169
)
169170

170171
self.mock_ssl_context.assert_called_with(
171-
ssl.Purpose.SERVER_AUTH, cafile=None,
172+
ssl.Purpose.SERVER_AUTH, cafile=str(DEFAULT_CA_FILE),
172173
)
173174
# self.assertEqual(self.mock_ssl_context.return_value.options,
174175
# ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 |

0 commit comments

Comments
 (0)