Repository: StackStorm/st2

Issue:
[security] MongoDB's credentials are exposed in the logs if using url-style host to connect to a replica set
Please, add a condition to verify if `db_host` has `://` in it, then show the line without the password, if any.

https://github.com/StackStorm/st2/blob/master/st2common/st2common/models/db/__init__.py#L79

Thanks!


Discussion:
Thanks for reporting this - we will have a look. In the mean time, you can use "supply password as a separate argument" (default value) as a work-around (if that works for your use-case).

Edit: Just saw you are using replica set, so supplying password as argument won't work.
@Kami I am not the best python developer and haven't really debugged the code, but I am not sure if it's going to honor a separately provided password variable:

https://github.com/MongoEngine/mongoengine/blob/master/mongoengine/connection.py#L88

Can you confirm?
Oh I think it will. It iterates to find them in the provided url string, and overwrites if it finds one.
Yes, removing username and password from the URL helps. Anyway, might be a good idea to hide it if user decided to use a one-line url as a db_host. Or at least document it somewhere.
> Anyway, might be a good idea to hide it if user decided to use a one-line url as a db_host. Or at least document it somewhere.

Yeah, agreed, will push that change, but separate parameter is still preferred because same thing with logging could also happens in other places which we can't directly control.

Resolving patch:
diff --git a/st2common/st2common/models/db/__init__.py b/st2common/st2common/models/db/__init__.py
--- a/st2common/st2common/models/db/__init__.py
+++ b/st2common/st2common/models/db/__init__.py
@@ -20,6 +20,7 @@
 
 import six
 import mongoengine
+from pymongo import uri_parser
 from pymongo.errors import OperationFailure
 
 from st2common import log as logging
@@ -76,8 +77,29 @@ def get_model_classes():
 def db_setup(db_name, db_host, db_port, username=None, password=None, ensure_indexes=True,
              ssl=False, ssl_keyfile=None, ssl_certfile=None,
              ssl_cert_reqs=None, ssl_ca_certs=None, ssl_match_hostname=True):
-    LOG.info('Connecting to database "%s" @ "%s:%s" as user "%s".',
-             db_name, db_host, db_port, str(username))
+
+    if '://' in db_host:
+        # Hostname is provided as a URI string. Make sure we don't log the password in case one is
+        # included as part of the URI string.
+        uri_dict = uri_parser.parse_uri(db_host)
+        username_string = uri_dict.get('username', username) or username
+
+        if uri_dict.get('username', None) and username:
+            # Username argument has precedence over connection string username
+            username_string = username
+
+        hostnames = get_host_names_for_uri_dict(uri_dict=uri_dict)
+
+        if len(uri_dict['nodelist']) > 1:
+            host_string = '%s (replica set)' % (hostnames)
+        else:
+            host_string = hostnames
+    else:
+        host_string = '%s:%s' % (db_host, db_port)
+        username_string = username
+
+    LOG.info('Connecting to database "%s" @ "%s" as user "%s".' % (db_name, host_string,
+                                                                   str(username_string)))
 
     ssl_kwargs = _get_ssl_kwargs(ssl=ssl, ssl_keyfile=ssl_keyfile, ssl_certfile=ssl_certfile,
                                  ssl_cert_reqs=ssl_cert_reqs, ssl_ca_certs=ssl_ca_certs,
@@ -378,3 +400,13 @@ def _process_datetime_range_filters(self, filters, order_by=None):
                 order_by_list = [sort_key] + order_by_list
 
         return filters, order_by_list
+
+
+def get_host_names_for_uri_dict(uri_dict):
+    hosts = []
+
+    for host, port in uri_dict['nodelist']:
+        hosts.append('%s:%s' % (host, port))
+
+    hosts = ','.join(hosts)
+    return hosts


Test patch:
diff --git a/st2common/tests/unit/test_db.py b/st2common/tests/unit/test_db.py
--- a/st2common/tests/unit/test_db.py
+++ b/st2common/tests/unit/test_db.py
@@ -23,6 +23,7 @@
 from st2common.transport.publishers import PoolPublisher
 from st2common.util import schema as util_schema
 from st2common.util import reference
+from st2common.models.db import db_setup
 from st2common.util import date as date_utils
 from st2common.exceptions.db import StackStormDBObjectNotFoundError
 from st2common.models.db.trigger import TriggerTypeDB, TriggerDB, TriggerInstanceDB
@@ -47,6 +48,72 @@ def test_check_connect(self):
         expected_str = "host=['%s:%s']" % (cfg.CONF.database.host, cfg.CONF.database.port)
         self.assertTrue(expected_str in str(client), 'Not connected to desired host.')
 
+    @mock.patch('st2common.models.db.mongoengine', mock.Mock())
+    @mock.patch('st2common.models.db.LOG')
+    def test_db_setup_connecting_info_logging(self, mock_log):
+        # Verify that password is not included in the log message
+        db_name = 'st2'
+        db_port = '27017'
+        username = 'user_st2'
+        password = 'pass_st2'
+
+        # 1. Password provided as separate argument
+        db_host = 'localhost'
+        username = 'user_st2'
+        password = 'pass_st2'
+        db_setup(db_name=db_name, db_host=db_host, db_port=db_port, username=username,
+                 password=password)
+
+        expected_message = 'Connecting to database "st2" @ "localhost:27017" as user "user_st2".'
+        actual_message = mock_log.info.call_args_list[0][0][0]
+        self.assertEqual(expected_message, actual_message)
+
+        # 2. Password provided as part of uri string (single host)
+        db_host = 'mongodb://user_st22:pass_st22@127.0.0.2:5555'
+        username = None
+        password = None
+        db_setup(db_name=db_name, db_host=db_host, db_port=db_port, username=username,
+                 password=password)
+
+        expected_message = 'Connecting to database "st2" @ "127.0.0.2:5555" as user "user_st22".'
+        actual_message = mock_log.info.call_args_list[1][0][0]
+        self.assertEqual(expected_message, actual_message)
+
+        # 3. Password provided as part of uri string (single host) - username
+        # provided as argument has precedence
+        db_host = 'mongodb://user_st210:pass_st23@127.0.0.2:5555'
+        username = 'user_st23'
+        password = None
+        db_setup(db_name=db_name, db_host=db_host, db_port=db_port, username=username,
+                 password=password)
+
+        expected_message = 'Connecting to database "st2" @ "127.0.0.2:5555" as user "user_st23".'
+        actual_message = mock_log.info.call_args_list[2][0][0]
+        self.assertEqual(expected_message, actual_message)
+
+        # 4. Just host provided in the url string
+        db_host = 'mongodb://127.0.0.2:5555'
+        username = 'user_st24'
+        password = 'foobar'
+        db_setup(db_name=db_name, db_host=db_host, db_port=db_port, username=username,
+                 password=password)
+
+        expected_message = 'Connecting to database "st2" @ "127.0.0.2:5555" as user "user_st24".'
+        actual_message = mock_log.info.call_args_list[3][0][0]
+        self.assertEqual(expected_message, actual_message)
+
+        # 5. Multiple hosts specified as part of connection uri
+        db_host = 'mongodb://user6:pass6@host1,host2,host3'
+        username = None
+        password = 'foobar'
+        db_setup(db_name=db_name, db_host=db_host, db_port=db_port, username=username,
+                 password=password)
+
+        expected_message = ('Connecting to database "st2" @ "host1:27017,host2:27017,host3:27017 '
+                            '(replica set)" as user "user6".')
+        actual_message = mock_log.info.call_args_list[4][0][0]
+        self.assertEqual(expected_message, actual_message)
+
 
 @mock.patch.object(PoolPublisher, 'publish', mock.MagicMock())
 class ReactorModelTest(DbTestCase):