Repository: ckan/ckan

Issue:
CKAN version is exposed to the public
**CKAN version**

2.8, 2.9

**Describe the bug**

The exact version of CKAN is visible to the public in the status_show API call and as a header on all emails. This makes reconnaissance by malicious third parties easier.

**Steps to reproduce**

Visit /api/action/status_show

**Expected behavior**

There should be an option to hide the CKAN version in use.

**Additional details**

Will raise a pull request soon.



Discussion:

Resolving patch:
diff --git a/ckan/lib/mailer.py b/ckan/lib/mailer.py
--- a/ckan/lib/mailer.py
+++ b/ckan/lib/mailer.py
@@ -13,8 +13,7 @@
 from email.message import EmailMessage
 from email import utils
 
-from ckan.common import config
-import ckan.common
+from ckan.common import _, config
 
 
 import ckan
@@ -22,8 +21,6 @@
 import ckan.lib.helpers as h
 from ckan.lib.base import render
 
-from ckan.common import _
-
 log = logging.getLogger(__name__)
 AttachmentWithType = Union[
     Tuple[str, IO[str], str],
@@ -70,7 +67,8 @@ def _mail_recipient(
     msg['From'] = _("%s <%s>") % (sender_name, mail_from)
     msg['To'] = u"%s <%s>" % (recipient_name, recipient_email)
     msg['Date'] = utils.formatdate(time())
-    msg['X-Mailer'] = "CKAN %s" % ckan.__version__
+    if not config.get_value('ckan.hide_version'):
+        msg['X-Mailer'] = "CKAN %s" % ckan.__version__
     if reply_to and reply_to != '':
         msg['Reply-to'] = reply_to
 
diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py
--- a/ckan/logic/action/get.py
+++ b/ckan/logic/action/get.py
@@ -1900,7 +1900,7 @@ def package_search(context: Context, data_dict: DataDict) -> ActionResult.Packag
         include_private = asbool(data_dict.pop('include_private', False))
         include_drafts = asbool(data_dict.pop('include_drafts', False))
         include_deleted = asbool(data_dict.pop('include_deleted', False))
-        
+
         if not include_private:
             data_dict['fq'] = '+capacity:public ' + data_dict['fq']
 
@@ -2435,15 +2435,17 @@ def status_show(context: Context, data_dict: DataDict) -> ActionResult.StatusSho
     '''
     extensions = config.get_value('ckan.plugins')
 
-    return {
+    site_info = {
         'site_title': config.get_value('ckan.site_title'),
         'site_description': config.get_value('ckan.site_description'),
         'site_url': config.get_value('ckan.site_url'),
-        'ckan_version': ckan.__version__,
         'error_emails_to': config.get_value('email_to'),
         'locale_default': config.get_value('ckan.locale_default'),
         'extensions': extensions,
     }
+    if not config.get_value('ckan.hide_version') or authz.is_sysadmin(context['user']):
+        site_info['ckan_version'] = ckan.__version__
+    return site_info
 
 
 def vocabulary_list(


Test patch:
diff --git a/ckan/tests/lib/test_mailer.py b/ckan/tests/lib/test_mailer.py
--- a/ckan/tests/lib/test_mailer.py
+++ b/ckan/tests/lib/test_mailer.py
@@ -34,6 +34,39 @@ def get_email_subject(self, msg):
 class TestMailer(MailerBase):
     def test_mail_recipient(self, mail_server):
         user = factories.User()
+
+        msgs = mail_server.get_smtp_messages()
+        assert msgs == []
+
+        # send email
+        test_email = {
+            "recipient_name": "Bob",
+            "recipient_email": user["email"],
+            "subject": "Meeting",
+            "body": "The meeting is cancelled.\n",
+            "headers": {"header1": "value1"},
+        }
+        mailer.mail_recipient(**test_email)
+
+        # check it went to the mock smtp server
+        msgs = mail_server.get_smtp_messages()
+        assert len(msgs) == 1
+        msg = msgs[0]
+        assert msg[1] == config["smtp.mail_from"]
+        assert msg[2] == [test_email["recipient_email"]]
+        assert list(test_email["headers"].keys())[0] in msg[3], msg[3]
+        assert list(test_email["headers"].values())[0] in msg[3], msg[3]
+        assert test_email["subject"] in msg[3], msg[3]
+        assert "X-Mailer" in msg[3], "Missing X-Mailer header"
+        expected_body = self.mime_encode(
+            test_email["body"], test_email["recipient_name"]
+        )
+        assert expected_body in msg[3]
+
+    @pytest.mark.ckan_config('ckan.hide_version', True)
+    def test_mail_recipient_hiding_mailer(self, mail_server):
+        user = factories.User()
+
         msgs = mail_server.get_smtp_messages()
         assert msgs == []
 
@@ -57,6 +90,8 @@ def test_mail_recipient(self, mail_server):
         assert list(test_email["headers"].values())[0] in msg[3], msg[3]
         assert test_email["subject"] in msg[3], msg[3]
         assert msg[3].startswith('Content-Type: text/plain'), msg[3]
+        assert "X-Mailer" not in msg[3], \
+            "Should have skipped X-Mailer header"
         expected_body = self.mime_encode(
             test_email["body"], test_email["recipient_name"]
         )
diff --git a/ckan/tests/logic/action/test_get.py b/ckan/tests/logic/action/test_get.py
--- a/ckan/tests/logic/action/test_get.py
+++ b/ckan/tests/logic/action/test_get.py
@@ -2917,6 +2917,36 @@ def test_status_show(self):
         assert type(status["extensions"]) == list
         assert status["extensions"] == ["stats"]
 
+    @pytest.mark.ckan_config("ckan.plugins", "stats")
+    @pytest.mark.ckan_config('ckan.hide_version', True)
+    def test_status_show_hiding_version(self):
+
+        status = helpers.call_action("status_show")
+
+        assert "ckan_version" not in status, "Should have skipped CKAN version"
+        assert status["site_url"] == "http://test.ckan.net"
+        assert status["site_title"] == "CKAN"
+        assert status["site_description"] == ""
+        assert status["locale_default"] == "en"
+
+        assert type(status["extensions"]) == list
+        assert status["extensions"] == ["stats"]
+
+    @pytest.mark.ckan_config("ckan.plugins", "stats")
+    @pytest.mark.ckan_config('ckan.hide_version', True)
+    def test_status_show_version_to_sysadmins(self):
+        sysadmin = factories.Sysadmin()
+        status = helpers.call_action("status_show", context={"user": sysadmin["name"]})
+
+        assert status["ckan_version"] == __version__
+        assert status["site_url"] == "http://test.ckan.net"
+        assert status["site_title"] == "CKAN"
+        assert status["site_description"] == ""
+        assert status["locale_default"] == "en"
+
+        assert type(status["extensions"]) == list
+        assert status["extensions"] == ["stats"]
+
 
 class TestJobList(helpers.FunctionalRQTestBase):
     def test_all_queues(self):