Skip to content

Commit 633d3b3

Browse files
cloudvision/api: add cacert param to AsyncCVClient initialisation
Previously async client would only work with self-signed certificates or with the certificates that are CA-signed with the system's trusted CA. It got the host certificate with `ssl.get_server_certificate(host, port)` and used it for creating the connection, which could potentially be exploited if DNS entry for the host had been tampered. By adding `cacert` parameters, we at provide an option to verify if the cert received from the host is signed by the provided CA. This change includes: - Add `cacert` and `insecure` params to `from_token` and `from_user_credentials` - Pass serviceCACert to AsyncCVCient when using ctx.getAsyncClient in actions - Add couple of test cases that verify various uses of the new parameters Change-Id: I9ea1162893913818e1a2d870f52ddaf136412647
1 parent 6a97fcd commit 633d3b3

5 files changed

Lines changed: 423 additions & 75 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ import asyncio
9494
from cloudvision.api.client import AsyncCVClient
9595
from cloudvision.api.arista.inventory.v1 import DeviceServiceStub, DeviceStreamRequest
9696

97-
async def get_auditlog():
97+
async def get_devices():
9898
client = AsyncCVClient.from_token('<your service account token>', 'your-cvp.io')
9999

100100
# get channel
@@ -107,7 +107,7 @@ async def get_auditlog():
107107
async for item in service.get_all(DeviceStreamRequest()):
108108
print(item)
109109

110-
asyncio.run(get_auditlog())
110+
asyncio.run(get_devices())
111111
```
112112

113113
## CloudVision Connector

cloudvision/api/client.py

Lines changed: 100 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,18 @@
22
# Use of this source code is governed by the Apache License 2.0
33
# that can be found in the COPYING file.
44

5-
import requests
5+
import urllib3
6+
import pathlib
67
import ssl
78
from grpclib import client, events
8-
import tempfile
9+
from base64 import b64encode
10+
11+
12+
class UnableToAuthenticateException(Exception):
13+
"""
14+
Is thrown when unable to authenticate using username and password
15+
"""
16+
pass
917

1018

1119
class AsyncCVClient:
@@ -69,49 +77,115 @@ def __init__(self, token, ssl_context, host, port=443, username=None):
6977
self._channel_stack = []
7078

7179
@classmethod
72-
def from_token(cls, token, host, port=443, username=None):
80+
def _get_ssl_context(cls, host, port=443, cacert=None, insecure=False):
81+
if not insecure:
82+
if not cacert:
83+
# This would be the case for on prem deployments as they will have self-signed
84+
# certificates. This won't save from bogus cert, but at least it verify that
85+
# the cert didn't expired and the hostname is right
86+
cadata = ssl.get_server_certificate((host, port))
87+
else:
88+
cacert = pathlib.Path(cacert)
89+
with cacert.open("r") as f:
90+
cadata = f.read()
91+
92+
context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH,
93+
cadata=cadata)
94+
context.set_alpn_protocols(["h2"])
95+
else:
96+
context = ssl.create_default_context()
97+
context.check_hostname = False
98+
context.verify_mode = ssl.CERT_NONE
99+
return context
100+
101+
@classmethod
102+
def from_token(cls, token, host, port=443, username=None, insecure=False, cacert=None):
73103
"""
74104
If you would like to use service accounts, you can create them in CloudVision UI
75105
https://my-cloudvision-instance.io/cv/setting/aaa-service-accounts
76106
77107
Generate a token for service account and pass it to this method to get an instance of
78108
the client.
79109
80-
:rtype: AsyncCVClient
81-
"""
82-
cadata = ssl.get_server_certificate((host, port))
110+
.. note::
111+
With default parameters, it would assume that the host has a self-signed certificate and
112+
it will fetch it and verify hostname and expiry. It is recommended that you use
113+
CA signed certificate in your CloudVision deployment, so you'd either add this CA to
114+
the list of trusted CAs, or provide it's certificate via `cacert` parameter
115+
116+
.. DANGER::
117+
Avoid setting `insecure=True` as this would disable certificate check
118+
119+
.. versionchanged:: 1.27.2
120+
Added `insecure` and `cacert` parameters
121+
122+
:param host: CloudVision hostname
83123
84-
context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH,
85-
cadata=cadata)
86-
context.set_alpn_protocols(["h2"])
124+
:param insecure:
125+
skip certificate verification
87126
88-
return cls(token=token, ssl_context=context, port=port, host=host, username=username)
127+
:param cacert:
128+
path to CA certificate to use for verifying the host's certificate
129+
:type cacert: :class:`pathlib.Path` or str
130+
131+
:rtype: AsyncCVClient
132+
"""
133+
ssl_context = cls._get_ssl_context(host, port, cacert=cacert, insecure=insecure)
134+
return cls(token=token, ssl_context=ssl_context, port=port, host=host, username=username)
89135

90136
@classmethod
91-
def from_user_credentials(cls, username, password, host, port=443):
137+
def from_user_credentials(cls, username, password, host, port=443,
138+
insecure=False, cacert=None):
92139
"""
93140
Use usename and password to authenticate in CloudVision
94141
95-
:rtype: AsyncCVClient
96-
"""
97-
cadata = ssl.get_server_certificate((host, port))
142+
.. note::
143+
With default parameters, it would assume that the host has a self-signed certificate and
144+
it will fetch it and verify hostname and expiry. It is recommended that you use
145+
CA signed certificate in your CloudVision deployment, so you'd either add this CA to
146+
the list of trusted CAs, or provide it's certificate via `cacert` parameter
98147
99-
with tempfile.NamedTemporaryFile("a+") as fw:
100-
fw.write(cadata)
101-
fw.flush()
102148
103-
r = requests.post(
104-
'https://' + host + '/cvpservice/login/authenticate.do',
105-
auth=(username, password), verify=fw.name)
149+
.. DANGER::
150+
Avoid setting `insecure=True` as this would disable certificate check
106151
107-
r.raise_for_status()
108-
token = r.json()['sessionId']
152+
.. versionchanged:: 1.27.2
153+
Added `insecure` and `cacert` parameters
109154
110-
context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH,
111-
cadata=cadata)
112-
context.set_alpn_protocols(["h2"])
155+
:param host: CloudVision hostname
156+
157+
:param insecure:
158+
skip certificate verification
159+
160+
:param cacert:
161+
path to CA certificate to use for verifying the host's certificate
162+
:type cacert: :class:`pathlib.Path` or str
163+
164+
:rtype: AsyncCVClient
165+
:raises UnableToAuthenticateException:
166+
"""
113167

114-
return cls(token=token, ssl_context=context, port=port, host=host, username=username)
168+
headers = {
169+
"Authorization": f"Basic {b64encode(":".join((username, password)).encode()).decode()}",
170+
}
171+
try:
172+
context = cls._get_ssl_context(host, port, cacert=cacert, insecure=insecure)
173+
with urllib3.PoolManager(ssl_context=context) as pool:
174+
resp = pool.request('POST',
175+
f'https://{host}:{port}/cvpservice/login/authenticate.do',
176+
headers=headers)
177+
if resp.status != 200:
178+
raise Exception(f"Status code {resp.status}: {resp.read()}")
179+
data = resp.json()
180+
except Exception as e:
181+
raise UnableToAuthenticateException(
182+
f"Unable to authenticate using user and password. Cause: {e}") from e
183+
184+
# Apparently we can't reuse ssl context, so creating a new one
185+
context = cls._get_ssl_context(host, port, cacert=cacert, insecure=insecure)
186+
187+
return cls(token=data['sessionId'], ssl_context=context, port=port,
188+
host=host, username=username)
115189

116190
def _init_channel(self):
117191
"""

cloudvision/cvlib/context.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ def getAsyncApiClient(self, stub):
253253
if not self.__asyncServiceChann:
254254
self.__asyncServiceChann = AsyncCVClient.from_token(
255255
token=self.user.token, username=self.user.username,
256-
host=host, port=port
256+
host=host, port=port, cacert=self.connections.serviceCACert
257257
)._init_channel()
258258

259259
return stub(self.__asyncServiceChann)

test/api/test_async_client.py

Lines changed: 143 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,64 +3,161 @@
33
# that can be found in the COPYING file.
44

55
import ssl
6-
import asyncio
76
import functools
7+
import tempfile
88

9+
import urllib3
910
import pytest
10-
from grpclib import utils, server
11-
import pytest_asyncio
12-
from cloudvision.api.arista.inventory import v1 as inventory
13-
from cloudvision.api.client import AsyncCVClient
11+
from cloudvision.api.client import AsyncCVClient, UnableToAuthenticateException
1412
from pathlib import Path
1513

16-
TEST_TOKEN = 'test'
17-
THIS_DIR = Path(__file__).parent
18-
TEST_DIR = THIS_DIR.parent
19-
TEST_DATA_DIR = Path.joinpath(TEST_DIR, "test_data")
14+
from . import utils
2015

16+
pytestmark = [pytest.mark.filterwarnings(
17+
"ignore:Unverified HTTPS request is being made to host 'localhost'")]
2118

22-
class MockInventoryService(inventory.DeviceServiceBase):
2319

24-
async def _call_rpc_handler_server_stream(self, handler, stream, request):
25-
assert stream.metadata['authorization'] == f'Bearer {TEST_TOKEN}'
26-
return await super()._call_rpc_handler_server_stream(handler, stream, request)
20+
@pytest.fixture
21+
def tmp_dir_factory():
22+
with tempfile.TemporaryDirectory() as td:
23+
counter = 1
24+
td = Path(td)
2725

28-
async def get_all(self, device_stream_request):
29-
for i in range(3):
30-
yield inventory.DeviceStreamResponse(
31-
value=inventory.Device(
32-
key=inventory.DeviceKey(device_id=f'device-{i}')
33-
)
34-
)
26+
def factory():
27+
nonlocal counter
28+
d = td / str(counter)
29+
d.mkdir()
30+
counter += 1
31+
return d
3532

33+
yield factory
3634

37-
@pytest_asyncio.fixture
38-
async def grpc_server(unused_tcp_port_factory):
39-
invService = MockInventoryService()
40-
srv = server.Server([invService])
4135

42-
context = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH)
43-
context.load_cert_chain(certfile=Path.joinpath(TEST_DATA_DIR, "cert.pem"),
44-
keyfile=Path.joinpath(TEST_DATA_DIR, "key.pem"))
45-
with utils.graceful_exit([server]):
46-
async with srv:
47-
port = unused_tcp_port_factory()
48-
await srv.start('localhost', port, ssl=context)
49-
yield 'localhost', port
36+
@pytest.mark.asyncio
37+
async def test_self_signed(tmp_dir_factory, unused_tcp_port_factory):
38+
certs = utils.create_self_signed_cert(tmp_dir_factory())
39+
async with utils.grpc_server(unused_tcp_port_factory(), certs) as (host, port):
40+
f = functools.partial(AsyncCVClient.from_token, utils.TEST_TOKEN, host=host,
41+
port=port)
42+
await utils.assert_grpc_response(f)
43+
44+
45+
@pytest.mark.asyncio
46+
async def test_self_signed_insecure(tmp_dir_factory, unused_tcp_port_factory):
47+
certs = utils.create_self_signed_cert(tmp_dir_factory())
48+
49+
async with utils.grpc_server(unused_tcp_port_factory(), certs) as (host, port):
50+
f = functools.partial(AsyncCVClient.from_token, utils.TEST_TOKEN, host=host,
51+
port=port, insecure=True)
52+
await utils.assert_grpc_response(f)
53+
54+
55+
@pytest.mark.asyncio
56+
async def test_self_signed_insecure_wrong_host(tmp_dir_factory, unused_tcp_port_factory):
57+
certs = utils.create_self_signed_cert(tmp_dir_factory(), hostname='example.org')
58+
async with utils.grpc_server(unused_tcp_port_factory(), certs) as (host, port):
59+
f = functools.partial(AsyncCVClient.from_token, utils.TEST_TOKEN, host=host,
60+
port=port, insecure=True)
61+
await utils.assert_grpc_response(f)
62+
63+
64+
@pytest.mark.asyncio
65+
async def test_self_signed_wrong_host(tmp_dir_factory, unused_tcp_port_factory):
66+
certs = utils.create_self_signed_cert(tmp_dir_factory(), hostname='example.org')
67+
async with utils.grpc_server(unused_tcp_port_factory(), certs) as (host, port):
68+
f = functools.partial(AsyncCVClient.from_token, utils.TEST_TOKEN, host=host,
69+
port=port)
70+
with pytest.raises(ssl.SSLCertVerificationError):
71+
await utils.assert_grpc_response(f)
72+
73+
74+
@pytest.mark.asyncio
75+
async def test_ca_cert_provided(tmp_dir_factory, unused_tcp_port_factory):
76+
certs = utils.create_ca_signed_certs(tmp_dir_factory())
77+
async with utils.grpc_server(unused_tcp_port_factory(), certs) as (host, port):
78+
f = functools.partial(AsyncCVClient.from_token, utils.TEST_TOKEN, host=host,
79+
port=port, cacert=certs.cacert)
80+
81+
await utils.assert_grpc_response(f)
82+
83+
84+
@pytest.mark.asyncio
85+
async def test_bogus_ca_cert(tmp_dir_factory, unused_tcp_port_factory):
86+
realCerts = utils.create_ca_signed_certs(tmp_dir_factory())
87+
bogusCerts = utils.create_ca_signed_certs(tmp_dir_factory())
88+
89+
async with utils.grpc_server(unused_tcp_port_factory(), bogusCerts) as (host, port):
90+
f = functools.partial(AsyncCVClient.from_token, utils.TEST_TOKEN, host=host,
91+
port=port, cacert=realCerts.cacert)
92+
with pytest.raises(ssl.SSLCertVerificationError):
93+
await utils.assert_grpc_response(f)
5094

5195

5296
@pytest.mark.asyncio
53-
async def test_token_auth(grpc_server):
54-
host, port = grpc_server
55-
callable = functools.partial(AsyncCVClient.from_token, TEST_TOKEN, host=host, port=port)
56-
# Need to run this in executor, otherwise it would block the event loop forever
57-
client = await asyncio.get_running_loop().run_in_executor(None, callable)
58-
with client as channel:
59-
stub = inventory.DeviceServiceStub(channel)
60-
result = []
61-
async for device in stub.get_all(inventory.DeviceStreamRequest(), timeout=10):
62-
result.append(device)
63-
64-
assert len(result) == 3
65-
assert set([dev.value.key.device_id for dev in result]) == \
66-
{'device-0', 'device-1', 'device-2'}
97+
async def test_insecure(tmp_dir_factory, unused_tcp_port_factory):
98+
bogusCerts = utils.create_ca_signed_certs(tmp_dir_factory())
99+
100+
async with utils.grpc_server(unused_tcp_port_factory(), bogusCerts) as (host, port):
101+
f = functools.partial(AsyncCVClient.from_token, utils.TEST_TOKEN, host=host,
102+
port=port, insecure=True)
103+
await utils.assert_grpc_response(f)
104+
105+
106+
@pytest.mark.asyncio
107+
async def test_user_password_sefl_signed(tmp_dir_factory, unused_tcp_port_factory):
108+
certs = utils.create_self_signed_cert(tmp_dir_factory())
109+
port = unused_tcp_port_factory()
110+
async with utils.http_server(port=port, certs=certs):
111+
client = AsyncCVClient.from_user_credentials(username=utils.USERNAME,
112+
password=utils.PASSWORD, host='localhost',
113+
port=port)
114+
assert client.token == utils.TEST_TOKEN
115+
116+
117+
@pytest.mark.asyncio
118+
async def test_user_password_with_ca(tmp_dir_factory, unused_tcp_port_factory):
119+
certs = utils.create_ca_signed_certs(tmp_dir_factory())
120+
port = unused_tcp_port_factory()
121+
async with utils.http_server(port=port, certs=certs):
122+
client = AsyncCVClient.from_user_credentials(username=utils.USERNAME,
123+
password=utils.PASSWORD, host='localhost',
124+
port=port, cacert=certs.cacert)
125+
assert client.token == utils.TEST_TOKEN
126+
127+
128+
@pytest.mark.asyncio
129+
async def test_user_password_wrong_ca_cert(tmp_dir_factory, unused_tcp_port_factory):
130+
realCert = utils.create_ca_signed_certs(tmp_dir_factory())
131+
bogusCert = utils.create_ca_signed_certs(tmp_dir_factory())
132+
133+
port = unused_tcp_port_factory()
134+
async with utils.http_server(port=port, certs=bogusCert):
135+
with pytest.raises(UnableToAuthenticateException):
136+
AsyncCVClient.from_user_credentials(username=utils.USERNAME,
137+
password=utils.PASSWORD, host='localhost',
138+
port=port, cacert=realCert.cacert)
139+
140+
141+
@pytest.mark.asyncio
142+
async def test_user_password_wrong_ca_cert_insecure(tmp_dir_factory, unused_tcp_port_factory):
143+
bogusCert = utils.create_ca_signed_certs(tmp_dir_factory())
144+
145+
port = unused_tcp_port_factory()
146+
async with utils.http_server(port=port, certs=bogusCert):
147+
client = AsyncCVClient.from_user_credentials(username=utils.USERNAME,
148+
password=utils.PASSWORD, host='localhost',
149+
port=port,
150+
insecure=True)
151+
assert client.token == utils.TEST_TOKEN
152+
153+
154+
@pytest.mark.asyncio
155+
async def test_user_password_wrong_password(tmp_dir_factory, unused_tcp_port_factory):
156+
certs = utils.create_ca_signed_certs(tmp_dir_factory())
157+
158+
port = unused_tcp_port_factory()
159+
async with utils.http_server(port=port, certs=certs):
160+
with pytest.raises(UnableToAuthenticateException):
161+
AsyncCVClient.from_user_credentials(username=utils.USERNAME,
162+
password='wrong', host='localhost',
163+
port=port, cacert=certs.cacert)

0 commit comments

Comments
 (0)