Opportunistic TLS with python asyncio

Recently I was implementing an HTTPS proxy that was doing a man in the middle attack similar to what https://mitmproxy.org/ does. This server upgrades TCP connection to TLS on HTTP CONNECT method. While researching how to do this with asyncio I came across this python issue https://bugs.python.org/issue23749.

After reading the discussion it was clear that asyncio.sslproto.SSLProtocol has TLS/SSL implementation. Also Yury Selivanov posted a recent patch that allows us to wrap application protocol inside SSLProtocol: https://hg.python.org/cpython/rev/3e6739e5c2d0.

Eventually, I ended up with a prototype:

# https://github.com/benoitc/http-parser/
from http_parser.parser import HttpParser


class HttpServerProtocol(asyncio.Protocol):
    def __init__(self) -> None:
        super().__init__()
        self._http_parser = HttpParser()
        self._transport = None

    def request_received(self) -> None:
        self._transport.write(
            b'HTTP/1.1 200 OK\r\n'
            b'Content-Length: 2\r\n'
            b'Connection: close\r\n\r\n'
            b'OK'
        )
        self._transport.close()

    def data_received(self, data: bytes) -> None:
        self._http_parser.execute(data, len(data))
        if self._http_parser.is_message_complete():
            self.request_received()

    def connection_made(self, transport) -> None:
        self._transport = transport

    def close(self):
        self._transport.close()


class HttpsServerProtocol(HttpServerProtocol):
    def __init__(self, loop) -> None:
        super().__init__()

        self._over_tls = False
        self._tls_proto = asyncio.sslproto.SSLProtocol(
            loop, HttpServerProtocol(), make_tls_context(), None,
            server_side=True
        )

    def connect_received(self) -> None:
        self._over_tls = True
        self._tls_proto.connection_made(self._transport)

    def request_received(self) -> None:
        if self._http_parser.get_method() == 'CONNECT':
            self._transport.write(b'HTTP/1.1 200 OK\r\n\r\n')
            self.connect_received()
        else:
            super().request_received()

    def data_received(self, data: bytes) -> None:
        if self._over_tls:
            self._tls_proto.data_received(data)
        else:
            super().data_received(data)


async def start_server(loop):
    return await loop.create_server(
        lambda: HttpsServerProtocol(loop), '0.0.0.0', 8082, backlog=65535)


def make_tls_context():
    ctx = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
    ctx.load_cert_chain('example.com.crt', 'https.key.pem')
    return ctx


def main():
    loop = asyncio.new_event_loop()
    loop.run_until_complete(start_server(loop))
    loop.run_forever()
    loop.close()

Initially I have HttpServerProtocol which receives data stream, parses HTTP messages and calls request_received which returns just a dummy response:

➜  ~ curl http://localhost:8082/any/path -v
> GET /any/path HTTP/1.1
> User-Agent: curl/7.38.0
> Host: localhost:8082
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Length: 2
< Connection: close
<

OK

Then I have HttpsServerProtocol which is just the extension of HttpServerProtocol but also is capable of upgrading connection to TLS. The way it works is, it overrides request_received() method which checks if HTTP request is CONNECT. If it is, HttpsServerProtocol tells SSLProtocol to handle a new connection:

def connect_received(self) -> None:
    self._over_tls = True
    self._tls_proto.connection_made(self._transport)

Then SSLProtocol does the TLS handshake and data encryption/decryption:

➜  ~ curl --proxy localhost:8082 https://dummy.org -k -v
> CONNECT dummy.org:443 HTTP/1.1
> Host: dummy.org:443
> User-Agent: curl/7.38.0
> Proxy-Connection: Keep-Alive
>
< HTTP/1.1 200 OK
<
* Proxy replied OK to CONNECT request
* successfully set certificate verify locations:
*   CAfile: none
  CApath: /etc/ssl/certs
* SSLv3, TLS handshake, Client hello (1):
* SSLv3, TLS handshake, Server hello (2):
* SSLv3, TLS handshake, CERT (11):
* SSLv3, TLS handshake, Server key exchange (12):
* SSLv3, TLS handshake, Server finished (14):
* SSLv3, TLS handshake, Client key exchange (16):
* SSLv3, TLS change cipher, Client hello (1):
* SSLv3, TLS handshake, Finished (20):
* SSLv3, TLS change cipher, Client hello (1):
* SSLv3, TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-RSA-AES256-GCM-SHA384
* Server certificate:
*        subject: CN=dummy.org
*        start date: 2017-02-02 08:28:42 GMT
*        expire date: 2017-02-02 08:28:42 GMT
*        issuer: CN=development ca
*        SSL certificate verify result: unable to get local issuer certificate (20), continuing anyway.
> GET / HTTP/1.1
> User-Agent: curl/7.38.0
> Host: dummy.org
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Length: 2
< Connection: close
<
OK

When SSLProtocol receives some data, it decrypts it and passes to HttpServerProtocol.data_received(). And from now on we're communicating over TLS.

Comments