Repository: RasaHQ/rasa

Issue:
Allow running rasa server using HTTPS
**Description of Problem**:
<!-- Short overview of the current situation.
Why is this feature needed? Please link any relevant
[forum](https://forum.rasa.com) threads here. -->
To secure the communication, it should be possible to start the Rasa server using SSL

**Overview of the Solution**:
<!-- What would a possible solution look like?
Describe, without going too low into technical details,
what changes need to happen during implementation of this feature. -->
Certificates should be provided by the user and the server should load these on startup and switch to HTTPS instead of HTTP.


Discussion:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 9aa5510161d..9598fd31da7 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -12,6 +12,8 @@ This project adheres to `Semantic Versioning`_ starting with version 1.0.
 
 Added
 -----
+- SSL support for ``rasa run`` command. Certificate can be specified using
+  ``--ssl-certificate`` and ``--ssl-keyfile``.
 
 Changed
 -------
diff --git a/docs/user-guide/running-the-server.rst b/docs/user-guide/running-the-server.rst
index 99bd1766958..97c0c9229ba 100644
--- a/docs/user-guide/running-the-server.rst
+++ b/docs/user-guide/running-the-server.rst
@@ -118,6 +118,28 @@ You can also configure the Rasa server to fetch your model from a remote storage
 The model will be downloaded and stored in a temporary directory on your local storage system.
 For more information see :ref:`cloud-storage`.
 
+.. _server_ssl:
+
+Configuring SSL / HTTPS
+-----------------------
+
+By default the Rasa server is using HTTP for its communication. To secure the
+communication with SSL, you need to provide a valid certificate and the corresponding
+private key file.
+
+You can specify these files as part of the ``rasa run`` command:
+
+.. code-block:: bash
+
+    rasa run --ssl-certificate myssl.crt --ssl-keyfile myssl.key
+
+If you encrypted your keyfile with a password during creation, you need to add
+this password to the command:
+
+.. code-block:: bash
+
+    rasa run --ssl-certificate myssl.crt --ssl-keyfile myssl.key --ssl-password mypassword
+
 
 .. _server_security:
 
diff --git a/rasa/cli/arguments/run.py b/rasa/cli/arguments/run.py
index d9928aa6708..68dc85a74fa 100644
--- a/rasa/cli/arguments/run.py
+++ b/rasa/cli/arguments/run.py
@@ -60,6 +60,18 @@ def add_server_arguments(parser: argparse.ArgumentParser):
         "--remote-storage",
         help="Set the remote location where your Rasa model is stored, e.g. on AWS.",
     )
+    server_arguments.add_argument(
+        "--ssl-certificate",
+        help="Set the SSL Certificate to create a TLS secured server.",
+    )
+    server_arguments.add_argument(
+        "--ssl-keyfile", help="Set the SSL Keyfile to create a TLS secured server."
+    )
+    server_arguments.add_argument(
+        "--ssl-password",
+        help="If your ssl-keyfile is protected by a password, you can specify it "
+        "using this paramer.",
+    )
 
     channel_arguments = parser.add_argument_group("Channels")
     channel_arguments.add_argument(
diff --git a/rasa/core/constants.py b/rasa/core/constants.py
index 7d6034534d9..be27b95b3b1 100644
--- a/rasa/core/constants.py
+++ b/rasa/core/constants.py
@@ -1,8 +1,8 @@
 DEFAULT_SERVER_PORT = 5005
 
-DEFAULT_SERVER_FORMAT = "http://localhost:{}"
+DEFAULT_SERVER_FORMAT = "{}://localhost:{}"
 
-DEFAULT_SERVER_URL = DEFAULT_SERVER_FORMAT.format(DEFAULT_SERVER_PORT)
+DEFAULT_SERVER_URL = DEFAULT_SERVER_FORMAT.format("http", DEFAULT_SERVER_PORT)
 
 DEFAULT_NLU_FALLBACK_THRESHOLD = 0.0
 
diff --git a/rasa/core/run.py b/rasa/core/run.py
index bf1c2c9d21a..4aecdc8d602 100644
--- a/rasa/core/run.py
+++ b/rasa/core/run.py
@@ -123,7 +123,7 @@ def configure_app(
             """Small wrapper to shut down the server once cmd io is done."""
             await asyncio.sleep(1)  # allow server to start
             await console.record_messages(
-                server_url=constants.DEFAULT_SERVER_FORMAT.format(port)
+                server_url=constants.DEFAULT_SERVER_FORMAT.format("http", port)
             )
 
             logger.info("Killing Sanic server now.")
@@ -147,7 +147,12 @@ def serve_application(
     endpoints: Optional[AvailableEndpoints] = None,
     remote_storage: Optional[Text] = None,
     log_file: Optional[Text] = None,
+    ssl_certificate: Optional[Text] = None,
+    ssl_keyfile: Optional[Text] = None,
+    ssl_password: Optional[Text] = None,
 ):
+    from rasa import server
+
     if not channel and not credentials:
         channel = "cmdline"
 
@@ -165,9 +170,12 @@ def serve_application(
         log_file=log_file,
     )
 
+    ssl_context = server.create_ssl_context(ssl_certificate, ssl_keyfile, ssl_password)
+    protocol = "https" if ssl_context else "http"
+
     logger.info(
         "Starting Rasa server on "
-        "{}".format(constants.DEFAULT_SERVER_FORMAT.format(port))
+        "{}".format(constants.DEFAULT_SERVER_FORMAT.format(protocol, port))
     )
 
     app.register_listener(
@@ -183,7 +191,7 @@ def serve_application(
 
     update_sanic_log_level(log_file)
 
-    app.run(host="0.0.0.0", port=port)
+    app.run(host="0.0.0.0", port=port, ssl=ssl_context)
 
 
 # noinspection PyUnusedLocal
diff --git a/rasa/core/training/interactive.py b/rasa/core/training/interactive.py
index 3f8b92e4964..24171380cdd 100644
--- a/rasa/core/training/interactive.py
+++ b/rasa/core/training/interactive.py
@@ -1329,7 +1329,9 @@ def _print_help(skip_visualization: bool) -> None:
     """Print some initial help message for the user."""
 
     if not skip_visualization:
-        visualization_url = DEFAULT_SERVER_FORMAT.format(DEFAULT_SERVER_PORT + 1)
+        visualization_url = DEFAULT_SERVER_FORMAT.format(
+            "http", DEFAULT_SERVER_PORT + 1
+        )
         visualization_help = "Visualisation at {}/visualization.html.".format(
             visualization_url
         )
diff --git a/rasa/server.py b/rasa/server.py
index cafb3f91834..ae850180ebf 100644
--- a/rasa/server.py
+++ b/rasa/server.py
@@ -5,6 +5,7 @@ import traceback
 from functools import wraps, reduce
 from inspect import isawaitable
 from typing import Any, Callable, List, Optional, Text, Union
+from ssl import SSLContext
 
 from sanic import Sanic, response
 from sanic.request import Request
@@ -210,6 +211,25 @@ async def authenticate(request: Request):
     )
 
 
+def create_ssl_context(
+    ssl_certificate: Optional[Text],
+    ssl_keyfile: Optional[Text],
+    ssl_password: Optional[Text],
+) -> Optional[SSLContext]:
+    """Create a SSL context (for the sanic server) if a proper certificate is passed."""
+
+    if ssl_certificate:
+        import ssl
+
+        ssl_context = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH)
+        ssl_context.load_cert_chain(
+            ssl_certificate, keyfile=ssl_keyfile, password=ssl_password
+        )
+        return ssl_context
+    else:
+        return None
+
+
 def _create_emulator(mode: Optional[Text]) -> NoEmulator:
     """Create emulator for specified mode.
     If no emulator is specified, we will use the Rasa NLU format."""


Test patch:
diff --git a/tests/cli/test_rasa_run.py b/tests/cli/test_rasa_run.py
index 8ae0638c7df..cba9cb7fb26 100644
--- a/tests/cli/test_rasa_run.py
+++ b/tests/cli/test_rasa_run.py
@@ -18,9 +18,11 @@ def test_run_help(run):
     help_text = """usage: rasa run [-h] [-v] [-vv] [--quiet] [-m MODEL] [--log-file LOG_FILE]
                 [--endpoints ENDPOINTS] [-p PORT] [-t AUTH_TOKEN]
                 [--cors [CORS [CORS ...]]] [--enable-api]
-                [--remote-storage REMOTE_STORAGE] [--credentials CREDENTIALS]
-                [--connector CONNECTOR] [--jwt-secret JWT_SECRET]
-                [--jwt-method JWT_METHOD]
+                [--remote-storage REMOTE_STORAGE]
+                [--ssl-certificate SSL_CERTIFICATE]
+                [--ssl-keyfile SSL_KEYFILE] [--ssl-password SSL_PASSWORD]
+                [--credentials CREDENTIALS] [--connector CONNECTOR]
+                [--jwt-secret JWT_SECRET] [--jwt-method JWT_METHOD]
                 {actions} ... [model-as-positional-argument]"""
 
     lines = help_text.split("\n")
diff --git a/tests/cli/test_rasa_shell.py b/tests/cli/test_rasa_shell.py
index 756220d9b7e..b614dace782 100644
--- a/tests/cli/test_rasa_shell.py
+++ b/tests/cli/test_rasa_shell.py
@@ -5,6 +5,8 @@ def test_shell_help(run):
                   [--endpoints ENDPOINTS] [-p PORT] [-t AUTH_TOKEN]
                   [--cors [CORS [CORS ...]]] [--enable-api]
                   [--remote-storage REMOTE_STORAGE]
+                  [--ssl-certificate SSL_CERTIFICATE]
+                  [--ssl-keyfile SSL_KEYFILE] [--ssl-password SSL_PASSWORD]
                   [--credentials CREDENTIALS] [--connector CONNECTOR]
                   [--jwt-secret JWT_SECRET] [--jwt-method JWT_METHOD]
                   {nlu} ... [model-as-positional-argument]"""
diff --git a/tests/cli/test_rasa_x.py b/tests/cli/test_rasa_x.py
index d8aab8446a9..b7567c1cb65 100644
--- a/tests/cli/test_rasa_x.py
+++ b/tests/cli/test_rasa_x.py
@@ -12,7 +12,9 @@ def test_x_help(run):
               [--production] [--rasa-x-port RASA_X_PORT] [--log-file LOG_FILE]
               [--endpoints ENDPOINTS] [-p PORT] [-t AUTH_TOKEN]
               [--cors [CORS [CORS ...]]] [--enable-api]
-              [--remote-storage REMOTE_STORAGE] [--credentials CREDENTIALS]
+              [--remote-storage REMOTE_STORAGE]
+              [--ssl-certificate SSL_CERTIFICATE] [--ssl-keyfile SSL_KEYFILE]
+              [--ssl-password SSL_PASSWORD] [--credentials CREDENTIALS]
               [--connector CONNECTOR] [--jwt-secret JWT_SECRET]
               [--jwt-method JWT_METHOD]"""