Skip to content

Commit e7aa8a2

Browse files
authored
TLS Interception Cert Generation (#362)
* Use common.pki for interception certificate generation * Fix tests * Dont use certificate fields that we dont need, it leads to certificate generation error on Ubuntu * Prepare for v2.2.0 * npm audit fix
1 parent ab08901 commit e7aa8a2

File tree

9 files changed

+116
-78
lines changed

9 files changed

+116
-78
lines changed

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -795,8 +795,7 @@ response from the server. Start `proxy.py` as:
795795
```
796796
797797
798-
> :note: **MacOS users** also need to pass explicit CA file path
799-
> needed for validation of peer certificates. See --ca-file flag.
798+
[![NOTE](https://img.shields.io/static/v1?label=MacOS&message=note&color=yellow)](https://github.com/abhinavsingh/proxy.py#flags) Also provide explicit CA bundle path needed for validation of peer certificates. See `--ca-file` flag.
800799
801800
802801
Verify TLS interception using `curl`
@@ -1327,7 +1326,7 @@ usage: pki.py [-h] [--password PASSWORD] [--private-key-path PRIVATE_KEY_PATH]
13271326
[--public-key-path PUBLIC_KEY_PATH] [--subject SUBJECT]
13281327
action
13291328
1330-
proxy.py v2.1.2 : PKI Utility
1329+
proxy.py v2.2.0 : PKI Utility
13311330
13321331
positional arguments:
13331332
action Valid actions: remove_passphrase, gen_private_key,
@@ -1518,7 +1517,7 @@ usage: proxy [-h] [--backlog BACKLOG] [--basic-auth BASIC_AUTH]
15181517
[--static-server-dir STATIC_SERVER_DIR] [--threadless]
15191518
[--timeout TIMEOUT] [--version]
15201519
1521-
proxy.py v2.1.2
1520+
proxy.py v2.2.0
15221521
15231522
optional arguments:
15241523
-h, --help show this help message and exit

dashboard/package-lock.json

Lines changed: 19 additions & 43 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dashboard/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"eslint-plugin-node": "^10.0.0",
3838
"eslint-plugin-promise": "^4.2.1",
3939
"eslint-plugin-standard": "^4.0.1",
40-
"http-server": "^0.12.1",
40+
"http-server": "^0.12.3",
4141
"jasmine": "^3.5.0",
4242
"jasmine-ts": "^0.3.0",
4343
"jquery": "^3.5.0",

helper/homebrew/stable/proxy.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ class Proxy < Formula
55
Network monitoring, controls & Application development, testing, debugging."
66
homepage "https://github.com/abhinavsingh/proxy.py"
77
url "https://github.com/abhinavsingh/proxy.py/archive/master.zip"
8-
version "2.0.0"
8+
version "2.1.2"
99

1010
depends_on "python"
1111

proxy/common/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@
88
:copyright: (c) 2013-present by Abhinav Singh and contributors.
99
:license: BSD, see LICENSE for more details.
1010
"""
11-
VERSION = (2, 1, 2)
11+
VERSION = (2, 2, 0)
1212
__version__ = '.'.join(map(str, VERSION[0:3]))

proxy/http/proxy/server.py

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,13 @@
88
:copyright: (c) 2013-present by Abhinav Singh and contributors.
99
:license: BSD, see LICENSE for more details.
1010
"""
11+
import logging
1112
import threading
12-
import subprocess
1313
import os
1414
import ssl
1515
import socket
1616
import time
1717
import errno
18-
import logging
1918
from typing import Optional, List, Union, Dict, cast, Any, Tuple
2019

2120
from .plugin import HttpProxyBasePlugin
@@ -28,6 +27,7 @@
2827
from ...common.types import HasFileno
2928
from ...common.constants import PROXY_AGENT_HEADER_VALUE
3029
from ...common.utils import build_http_response, text_
30+
from ...common.pki import gen_public_key, gen_csr, sign_csr
3131

3232
from ...core.event import eventNames
3333
from ...core.connection import TcpServerConnection, TcpConnectionUninitializedException
@@ -279,7 +279,8 @@ def on_request_complete(self) -> Union[socket.socket, bool]:
279279
'BrokenPipeError when wrapping client')
280280
return True
281281
except OSError as e:
282-
logger.exception('OSError when wrapping client', exc_info=e)
282+
logger.exception(
283+
'OSError when wrapping client', exc_info=e)
283284
return True
284285
# Update all plugin connection reference
285286
for plugin in self.plugins.values():
@@ -342,6 +343,57 @@ def access_log(self) -> None:
342343
self.response.total_size,
343344
connection_time_ms))
344345

346+
def gen_ca_signed_certificate(self, cert_file_path: str) -> None:
347+
'''CA signing key (default) is used for generating a public key
348+
for common_name, if one already doesn't exist. Using generated
349+
public key a CSR request is generated, which is then signed by
350+
CA key and secret. Again this process only happen if signed
351+
certificate doesn't already exist.
352+
353+
returns signed certificate path.'''
354+
assert(self.request.host and self.flags.ca_cert_dir and self.flags.ca_signing_key_file and
355+
self.flags.ca_key_file and self.flags.ca_cert_file)
356+
public_key_path = os.path.join(self.flags.ca_cert_dir,
357+
'{0}.{1}'.format(text_(self.request.host), 'pub'))
358+
private_key_path = self.flags.ca_signing_key_file
359+
private_key_password = ''
360+
subject = '/CN={0}'.format(text_(self.request.host))
361+
alt_subj_names = [text_(self.request.host), ]
362+
validity_in_days = 365 * 2
363+
timeout = 10
364+
365+
# Generate a public key for the common name
366+
if not os.path.isfile(public_key_path):
367+
logger.debug('Generating public key %s', public_key_path)
368+
resp = gen_public_key(public_key_path=public_key_path, private_key_path=private_key_path,
369+
private_key_password=private_key_password, subject=subject, alt_subj_names=alt_subj_names,
370+
validity_in_days=validity_in_days, timeout=timeout)
371+
assert(resp is True)
372+
373+
csr_path = os.path.join(self.flags.ca_cert_dir,
374+
'{0}.{1}'.format(text_(self.request.host), 'csr'))
375+
376+
# Generate a CSR request for this common name
377+
if not os.path.isfile(csr_path):
378+
logger.debug('Generating CSR %s', csr_path)
379+
resp = gen_csr(csr_path=csr_path, key_path=private_key_path, password=private_key_password,
380+
crt_path=public_key_path, timeout=timeout)
381+
assert(resp is True)
382+
383+
ca_key_path = self.flags.ca_key_file
384+
ca_key_password = ''
385+
ca_crt_path = self.flags.ca_cert_file
386+
serial = self.uid.int
387+
388+
# Sign generated CSR
389+
if not os.path.isfile(cert_file_path):
390+
logger.debug('Signing CSR %s', cert_file_path)
391+
resp = sign_csr(csr_path=csr_path, crt_path=cert_file_path, ca_key_path=ca_key_path,
392+
ca_key_password=ca_key_password, ca_crt_path=ca_crt_path,
393+
serial=str(serial), alt_subj_names=alt_subj_names,
394+
validity_in_days=validity_in_days, timeout=timeout)
395+
assert(resp is True)
396+
345397
@staticmethod
346398
def generated_cert_file_path(ca_cert_dir: str, host: str) -> str:
347399
return os.path.join(ca_cert_dir, '%s.pem' % host)
@@ -359,21 +411,7 @@ def generate_upstream_certificate(
359411
self.flags.ca_cert_dir, text_(self.request.host))
360412
with self.lock:
361413
if not os.path.isfile(cert_file_path):
362-
logger.debug('Generating certificates %s', cert_file_path)
363-
# TODO: Parse subject from certificate
364-
# Currently we only set CN= field for generated certificates.
365-
gen_cert = subprocess.Popen(
366-
['openssl', 'req', '-new', '-key', self.flags.ca_signing_key_file, '-subj',
367-
f'/C=/ST=/L=/O=/OU=/CN={ text_(self.request.host) }'],
368-
stdout=subprocess.PIPE,
369-
stderr=subprocess.PIPE)
370-
sign_cert = subprocess.Popen(
371-
['openssl', 'x509', '-req', '-days', '365', '-CA', self.flags.ca_cert_file, '-CAkey',
372-
self.flags.ca_key_file, '-set_serial', str(self.uid.int), '-out', cert_file_path],
373-
stdin=gen_cert.stdout,
374-
stderr=subprocess.PIPE)
375-
# TODO: Ensure sign_cert success.
376-
sign_cert.communicate(timeout=10)
414+
self.gen_ca_signed_certificate(cert_file_path)
377415
return cert_file_path
378416

379417
def wrap_server(self) -> None:

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"""
1111
from setuptools import setup, find_packages
1212

13-
VERSION = (2, 1, 2)
13+
VERSION = (2, 2, 0)
1414
__version__ = '.'.join(map(str, VERSION[0:3]))
1515
__description__ = '''⚡⚡⚡Fast, Lightweight, Pluggable, TLS interception capable proxy server
1616
focused on Network monitoring, controls & Application development, testing, debugging.'''

tests/http/test_http_proxy_tls_interception.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,18 @@ class TestHttpProxyTlsInterception(unittest.TestCase):
3030
@mock.patch('ssl.wrap_socket')
3131
@mock.patch('ssl.create_default_context')
3232
@mock.patch('proxy.http.proxy.server.TcpServerConnection')
33-
@mock.patch('subprocess.Popen')
33+
@mock.patch('proxy.http.proxy.server.gen_public_key')
34+
@mock.patch('proxy.http.proxy.server.gen_csr')
35+
@mock.patch('proxy.http.proxy.server.sign_csr')
3436
@mock.patch('selectors.DefaultSelector')
3537
@mock.patch('socket.fromfd')
3638
def test_e2e(
3739
self,
3840
mock_fromfd: mock.Mock,
3941
mock_selector: mock.Mock,
40-
mock_popen: mock.Mock,
42+
mock_sign_csr: mock.Mock,
43+
mock_gen_csr: mock.Mock,
44+
mock_gen_public_key: mock.Mock,
4145
mock_server_conn: mock.Mock,
4246
mock_ssl_context: mock.Mock,
4347
mock_ssl_wrap: mock.Mock) -> None:
@@ -46,11 +50,17 @@ def test_e2e(
4650

4751
self.mock_fromfd = mock_fromfd
4852
self.mock_selector = mock_selector
49-
self.mock_popen = mock_popen
53+
self.mock_sign_csr = mock_sign_csr
54+
self.mock_gen_csr = mock_gen_csr
55+
self.mock_gen_public_key = mock_gen_public_key
5056
self.mock_server_conn = mock_server_conn
5157
self.mock_ssl_context = mock_ssl_context
5258
self.mock_ssl_wrap = mock_ssl_wrap
5359

60+
self.mock_sign_csr.return_value = True
61+
self.mock_gen_csr.return_value = True
62+
self.mock_gen_public_key.return_value = True
63+
5464
ssl_connection = mock.MagicMock(spec=ssl.SSLSocket)
5565
self.mock_ssl_context.return_value.wrap_socket.return_value = ssl_connection
5666
self.mock_ssl_wrap.return_value = mock.MagicMock(spec=ssl.SSLSocket)
@@ -118,6 +128,7 @@ def mock_connection() -> Any:
118128
fd=self._conn.fileno,
119129
events=selectors.EVENT_READ,
120130
data=None), selectors.EVENT_READ)], ]
131+
121132
self.protocol_handler.run_once()
122133

123134
# Assert our mocked plugins invocations
@@ -142,8 +153,9 @@ def mock_connection() -> Any:
142153
self.assertEqual(plain_connection.setblocking.call_count, 2)
143154
self.mock_ssl_context.return_value.wrap_socket.assert_called_with(
144155
plain_connection, server_hostname=host)
145-
# TODO: Assert Popen arguments, piping, success condition
146-
self.assertEqual(self.mock_popen.call_count, 2)
156+
self.assertEqual(self.mock_sign_csr.call_count, 1)
157+
self.assertEqual(self.mock_gen_csr.call_count, 1)
158+
self.assertEqual(self.mock_gen_public_key.call_count, 1)
147159
self.assertEqual(ssl_connection.setblocking.call_count, 1)
148160
self.assertEqual(
149161
self.mock_server_conn.return_value._conn,

tests/plugin/test_http_proxy_plugins_with_tls_interception.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,23 +33,33 @@ class TestHttpProxyPluginExamplesWithTlsInterception(unittest.TestCase):
3333
@mock.patch('ssl.wrap_socket')
3434
@mock.patch('ssl.create_default_context')
3535
@mock.patch('proxy.http.proxy.server.TcpServerConnection')
36-
@mock.patch('subprocess.Popen')
36+
@mock.patch('proxy.http.proxy.server.gen_public_key')
37+
@mock.patch('proxy.http.proxy.server.gen_csr')
38+
@mock.patch('proxy.http.proxy.server.sign_csr')
3739
@mock.patch('selectors.DefaultSelector')
3840
@mock.patch('socket.fromfd')
3941
def setUp(self,
4042
mock_fromfd: mock.Mock,
4143
mock_selector: mock.Mock,
42-
mock_popen: mock.Mock,
44+
mock_sign_csr: mock.Mock,
45+
mock_gen_csr: mock.Mock,
46+
mock_gen_public_key: mock.Mock,
4347
mock_server_conn: mock.Mock,
4448
mock_ssl_context: mock.Mock,
4549
mock_ssl_wrap: mock.Mock) -> None:
4650
self.mock_fromfd = mock_fromfd
4751
self.mock_selector = mock_selector
48-
self.mock_popen = mock_popen
52+
self.mock_sign_csr = mock_sign_csr
53+
self.mock_gen_csr = mock_gen_csr
54+
self.mock_gen_public_key = mock_gen_public_key
4955
self.mock_server_conn = mock_server_conn
5056
self.mock_ssl_context = mock_ssl_context
5157
self.mock_ssl_wrap = mock_ssl_wrap
5258

59+
self.mock_sign_csr.return_value = True
60+
self.mock_gen_csr.return_value = True
61+
self.mock_gen_public_key.return_value = True
62+
5363
self.fileno = 10
5464
self._addr = ('127.0.0.1', 54382)
5565
self.flags = Flags(
@@ -126,7 +136,10 @@ def send(raw: bytes) -> int:
126136
)
127137
self.protocol_handler.run_once()
128138

129-
self.mock_popen.assert_called()
139+
self.assertEqual(self.mock_sign_csr.call_count, 1)
140+
self.assertEqual(self.mock_gen_csr.call_count, 1)
141+
self.assertEqual(self.mock_gen_public_key.call_count, 1)
142+
130143
self.mock_server_conn.assert_called_once_with('uni.corn', 443)
131144
self.server.connect.assert_called()
132145
self.assertEqual(

0 commit comments

Comments
 (0)