Skip to content

Commit 5b77910

Browse files
authored
Merge pull request #7289 from chrahunt/bugfix/send-client-cert
Send client certificate when using --trusted-host
2 parents 44c8cac + 3125c32 commit 5b77910

File tree

7 files changed

+302
-2
lines changed

7 files changed

+302
-2
lines changed

news/7207.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix not sending client certificates when using ``--trusted-host``.

src/pip/_internal/network/session.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,8 +212,9 @@ def close(self):
212212
class InsecureHTTPAdapter(HTTPAdapter):
213213

214214
def cert_verify(self, conn, url, verify, cert):
215-
conn.cert_reqs = 'CERT_NONE'
216-
conn.ca_certs = None
215+
super(InsecureHTTPAdapter, self).cert_verify(
216+
conn=conn, url=url, verify=False, cert=cert
217+
)
217218

218219

219220
class PipSession(requests.Session):

tests/conftest.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from pip._internal.main import main as pip_entry_point
1515
from tests.lib import DATA_DIR, SRC_DIR, TestData
16+
from tests.lib.certs import make_tls_cert, serialize_cert, serialize_key
1617
from tests.lib.path import Path
1718
from tests.lib.scripttest import PipTestEnvironment
1819
from tests.lib.venv import VirtualEnvironment
@@ -385,3 +386,21 @@ def in_memory_pip():
385386
def deprecated_python():
386387
"""Used to indicate whether pip deprecated this python version"""
387388
return sys.version_info[:2] in [(2, 7)]
389+
390+
391+
@pytest.fixture(scope="session")
392+
def cert_factory(tmpdir_factory):
393+
def factory():
394+
# type: () -> str
395+
"""Returns path to cert/key file.
396+
"""
397+
output_path = Path(str(tmpdir_factory.mktemp("certs"))) / "cert.pem"
398+
# Must be Text on PY2.
399+
cert, key = make_tls_cert(u"localhost")
400+
with open(str(output_path), "wb") as f:
401+
f.write(serialize_cert(cert))
402+
f.write(serialize_key(key))
403+
404+
return str(output_path)
405+
406+
return factory

tests/functional/test_install.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import glob
33
import os
44
import shutil
5+
import ssl
56
import sys
67
import textwrap
78
from os.path import curdir, join, pardir
@@ -30,6 +31,12 @@
3031
from tests.lib.filesystem import make_socket_file
3132
from tests.lib.local_repos import local_checkout
3233
from tests.lib.path import Path
34+
from tests.lib.server import (
35+
file_response,
36+
make_mock_server,
37+
package_page,
38+
server_running,
39+
)
3340

3441
skip_if_python2 = pytest.mark.skipif(PY2, reason="Non-Python 2 only")
3542
skip_if_not_python2 = pytest.mark.skipif(not PY2, reason="Python 2 only")
@@ -1729,3 +1736,39 @@ def test_install_yanked_file_and_print_warning(script, data):
17291736
assert expected_warning in result.stderr, str(result)
17301737
# Make sure a "yanked" release is installed
17311738
assert 'Successfully installed simple-3.0\n' in result.stdout, str(result)
1739+
1740+
1741+
@pytest.mark.parametrize("install_args", [
1742+
(),
1743+
("--trusted-host", "localhost"),
1744+
])
1745+
def test_install_sends_client_cert(install_args, script, cert_factory, data):
1746+
cert_path = cert_factory()
1747+
ctx = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
1748+
ctx.load_cert_chain(cert_path, cert_path)
1749+
ctx.load_verify_locations(cafile=cert_path)
1750+
ctx.verify_mode = ssl.CERT_REQUIRED
1751+
1752+
server = make_mock_server(ssl_context=ctx)
1753+
server.mock.side_effect = [
1754+
package_page({
1755+
"simple-3.0.tar.gz": "/files/simple-3.0.tar.gz",
1756+
}),
1757+
file_response(str(data.packages / "simple-3.0.tar.gz")),
1758+
]
1759+
1760+
url = "https://{}:{}/simple".format(server.host, server.port)
1761+
1762+
args = ["install", "-vvv", "--cert", cert_path, "--client-cert", cert_path]
1763+
args.extend(["--index-url", url])
1764+
args.extend(install_args)
1765+
args.append("simple")
1766+
1767+
with server_running(server):
1768+
script.pip(*args)
1769+
1770+
assert server.mock.call_count == 2
1771+
for call_args in server.mock.call_args_list:
1772+
environ, _ = call_args.args
1773+
assert "SSL_CLIENT_CERT" in environ
1774+
assert environ["SSL_CLIENT_CERT"]

tests/lib/certs.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from datetime import datetime, timedelta
2+
3+
from cryptography import x509
4+
from cryptography.hazmat.backends import default_backend
5+
from cryptography.hazmat.primitives import hashes, serialization
6+
from cryptography.hazmat.primitives.asymmetric import rsa
7+
from cryptography.x509.oid import NameOID
8+
9+
from pip._internal.utils.typing import MYPY_CHECK_RUNNING
10+
11+
if MYPY_CHECK_RUNNING:
12+
from typing import Text, Tuple
13+
14+
15+
def make_tls_cert(hostname):
16+
# type: (Text) -> Tuple[x509.Certificate, rsa.RSAPrivateKey]
17+
key = rsa.generate_private_key(
18+
public_exponent=65537,
19+
key_size=2048,
20+
backend=default_backend()
21+
)
22+
subject = issuer = x509.Name([
23+
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
24+
])
25+
cert = (
26+
x509.CertificateBuilder()
27+
.subject_name(subject)
28+
.issuer_name(issuer)
29+
.public_key(key.public_key())
30+
.serial_number(x509.random_serial_number())
31+
.not_valid_before(datetime.utcnow())
32+
.not_valid_after(datetime.utcnow() + timedelta(days=10))
33+
.add_extension(
34+
x509.SubjectAlternativeName([x509.DNSName(hostname)]),
35+
critical=False,
36+
)
37+
.sign(key, hashes.SHA256(), default_backend())
38+
)
39+
return cert, key
40+
41+
42+
def serialize_key(key):
43+
# type: (rsa.RSAPrivateKey) -> bytes
44+
return key.private_bytes(
45+
encoding=serialization.Encoding.PEM,
46+
format=serialization.PrivateFormat.TraditionalOpenSSL,
47+
encryption_algorithm=serialization.NoEncryption(),
48+
)
49+
50+
51+
def serialize_cert(cert):
52+
# type: (x509.Certificate) -> bytes
53+
return cert.public_bytes(serialization.Encoding.PEM)

tests/lib/server.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import os
2+
import signal
3+
import ssl
4+
import threading
5+
from contextlib import contextmanager
6+
from textwrap import dedent
7+
8+
from mock import Mock
9+
from pip._vendor.contextlib2 import nullcontext
10+
from werkzeug.serving import WSGIRequestHandler
11+
from werkzeug.serving import make_server as _make_server
12+
13+
from pip._internal.utils.typing import MYPY_CHECK_RUNNING
14+
15+
if MYPY_CHECK_RUNNING:
16+
from types import TracebackType
17+
from typing import (
18+
Any, Callable, Dict, Iterable, List, Optional, Text, Tuple, Type, Union
19+
)
20+
21+
from werkzeug.serving import BaseWSGIServer
22+
23+
Environ = Dict[str, str]
24+
Status = str
25+
Headers = Iterable[Tuple[str, str]]
26+
ExcInfo = Tuple[Type[BaseException], BaseException, TracebackType]
27+
Write = Callable[[bytes], None]
28+
StartResponse = Callable[[Status, Headers, Optional[ExcInfo]], Write]
29+
Body = List[bytes]
30+
Responder = Callable[[Environ, StartResponse], Body]
31+
32+
class MockServer(BaseWSGIServer):
33+
mock = Mock() # type: Mock
34+
35+
# Applies on Python 2 and Windows.
36+
if not hasattr(signal, "pthread_sigmask"):
37+
# We're not relying on this behavior anywhere currently, it's just best
38+
# practice.
39+
blocked_signals = nullcontext
40+
else:
41+
@contextmanager
42+
def blocked_signals():
43+
"""Block all signals for e.g. starting a worker thread.
44+
"""
45+
old_mask = signal.pthread_sigmask(
46+
signal.SIG_SETMASK, range(1, signal.NSIG)
47+
)
48+
try:
49+
yield
50+
finally:
51+
signal.pthread_sigmask(signal.SIG_SETMASK, old_mask)
52+
53+
54+
class RequestHandler(WSGIRequestHandler):
55+
def make_environ(self):
56+
environ = super(RequestHandler, self).make_environ()
57+
58+
# From pallets/werkzeug#1469, will probably be in release after
59+
# 0.16.0.
60+
try:
61+
# binary_form=False gives nicer information, but wouldn't be
62+
# compatible with what Nginx or Apache could return.
63+
peer_cert = self.connection.getpeercert(binary_form=True)
64+
if peer_cert is not None:
65+
# Nginx and Apache use PEM format.
66+
environ["SSL_CLIENT_CERT"] = ssl.DER_cert_to_PEM_cert(
67+
peer_cert,
68+
)
69+
except ValueError:
70+
# SSL handshake hasn't finished.
71+
self.server.log("error", "Cannot fetch SSL peer certificate info")
72+
except AttributeError:
73+
# Not using TLS, the socket will not have getpeercert().
74+
pass
75+
76+
return environ
77+
78+
79+
def mock_wsgi_adapter(mock):
80+
# type: (Callable[[Environ, StartResponse], Responder]) -> Responder
81+
"""Uses a mock to record function arguments and provide
82+
the actual function that should respond.
83+
"""
84+
def adapter(environ, start_response):
85+
# type: (Environ, StartResponse) -> Body
86+
responder = mock(environ, start_response)
87+
return responder(environ, start_response)
88+
89+
return adapter
90+
91+
92+
def make_mock_server(**kwargs):
93+
# type: (Any) -> MockServer
94+
kwargs.setdefault("request_handler", RequestHandler)
95+
96+
mock = Mock()
97+
app = mock_wsgi_adapter(mock)
98+
server = _make_server("localhost", 0, app=app, **kwargs)
99+
server.mock = mock
100+
return server
101+
102+
103+
@contextmanager
104+
def server_running(server):
105+
# type: (BaseWSGIServer) -> None
106+
thread = threading.Thread(target=server.serve_forever)
107+
thread.daemon = True
108+
with blocked_signals():
109+
thread.start()
110+
try:
111+
yield
112+
finally:
113+
server.shutdown()
114+
thread.join()
115+
116+
117+
# Helper functions for making responses in a declarative way.
118+
119+
120+
def text_html_response(text):
121+
# type: (Text) -> Responder
122+
def responder(environ, start_response):
123+
# type: (Environ, StartResponse) -> Body
124+
start_response("200 OK", [
125+
("Content-Type", "text/html; charset=UTF-8"),
126+
])
127+
return [text.encode('utf-8')]
128+
129+
return responder
130+
131+
132+
def html5_page(text):
133+
# type: (Union[Text, str]) -> Text
134+
return dedent(u"""
135+
<!DOCTYPE html>
136+
<html>
137+
<body>
138+
{}
139+
</body>
140+
</html>
141+
""").strip().format(text)
142+
143+
144+
def index_page(spec):
145+
# type: (Dict[str, str]) -> Responder
146+
def link(name, value):
147+
return '<a href="{}">{}</a>'.format(
148+
value, name
149+
)
150+
151+
links = ''.join(link(*kv) for kv in spec.items())
152+
return text_html_response(html5_page(links))
153+
154+
155+
def package_page(spec):
156+
# type: (Dict[str, str]) -> Responder
157+
def link(name, value):
158+
return '<a href="{}">{}</a>'.format(
159+
value, name
160+
)
161+
162+
links = ''.join(link(*kv) for kv in spec.items())
163+
return text_html_response(html5_page(links))
164+
165+
166+
def file_response(path):
167+
# type: (str) -> Responder
168+
def responder(environ, start_response):
169+
# type: (Environ, StartResponse) -> Body
170+
size = os.stat(path).st_size
171+
start_response(
172+
"200 OK", [
173+
("Content-Type", "application/octet-stream"),
174+
("Content-Length", str(size)),
175+
],
176+
)
177+
178+
with open(path, 'rb') as f:
179+
return [f.read()]
180+
181+
return responder

tools/requirements/tests.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
cryptography==2.8
12
freezegun
23
mock
34
pretend
@@ -12,4 +13,5 @@ pyyaml
1213
setuptools>=39.2.0 # Needed for `setuptools.wheel.Wheel` support.
1314
scripttest
1415
https://github.com/pypa/virtualenv/archive/master.zip#egg=virtualenv
16+
werkzeug==0.16.0
1517
wheel

0 commit comments

Comments
 (0)