From 9853509716bc04ece06b24006fff07cd3c96b8e5 Mon Sep 17 00:00:00 2001 From: derekpierre Date: Tue, 5 Oct 2021 11:54:46 -0400 Subject: [PATCH] Make CORS allow origins opt-in by default (instead of opt-out and having '*' as the default). Ensure that empty string for --allow-origins equates to cors not enabled. Add tests for CORS CLI option. --- deploy/docker/porter/docker-compose.yml | 9 ++- nucypher/cli/commands/porter.py | 25 +++++-- nucypher/cli/literature.py | 8 +- nucypher/utilities/porter/porter.py | 28 ++++--- tests/acceptance/cli/test_porter.py | 98 +++++++++++++++++++++++-- 5 files changed, 138 insertions(+), 30 deletions(-) diff --git a/deploy/docker/porter/docker-compose.yml b/deploy/docker/porter/docker-compose.yml index 5c7372562..28cfb1830 100644 --- a/deploy/docker/porter/docker-compose.yml +++ b/deploy/docker/porter/docker-compose.yml @@ -16,7 +16,8 @@ services: - ~/.local/share/nucypher:/nucypher command: ["nucypher", "porter", "run", "--provider", "${WEB3_PROVIDER_URI}", - "--network", "${NUCYPHER_NETWORK}"] + "--network", "${NUCYPHER_NETWORK}", + "--allow-origins", "${PORTER_CORS_ALLOW_ORIGINS}"] # empty string if env var not defined which translates to CORS not enabled by default porter-https: restart: on-failure @@ -33,7 +34,8 @@ services: "--provider", "${WEB3_PROVIDER_URI}", "--network", "${NUCYPHER_NETWORK}", "--tls-key-filepath", "/etc/porter/tls/key.pem", - "--tls-certificate-filepath", "/etc/porter/tls/cert.pem"] + "--tls-certificate-filepath", "/etc/porter/tls/cert.pem", + "--allow-origins", "${PORTER_CORS_ALLOW_ORIGINS}"] # empty string if env var not defined which translates to CORS not enabled by default porter-https-auth: restart: on-failure @@ -52,4 +54,5 @@ services: "--network", "${NUCYPHER_NETWORK}", "--tls-key-filepath", "/etc/porter/tls/key.pem", "--tls-certificate-filepath", "/etc/porter/tls/cert.pem", - "--basic-auth-filepath", "/etc/porter/auth/htpasswd"] + "--basic-auth-filepath", "/etc/porter/auth/htpasswd", + "--allow-origins", "${PORTER_CORS_ALLOW_ORIGINS}"] # empty string if env var not defined which translates to CORS not enabled by default diff --git a/nucypher/cli/commands/porter.py b/nucypher/cli/commands/porter.py index 5d11421d1..53726054f 100644 --- a/nucypher/cli/commands/porter.py +++ b/nucypher/cli/commands/porter.py @@ -21,8 +21,13 @@ import click from nucypher.blockchain.eth.networks import NetworksInventory from nucypher.characters.lawful import Ursula from nucypher.cli.config import group_general_config -from nucypher.cli.literature import BOTH_TLS_KEY_AND_CERTIFICATION_MUST_BE_PROVIDED, PORTER_RUN_MESSAGE, \ - BASIC_AUTH_REQUIRES_HTTPS +from nucypher.cli.literature import ( + PORTER_BASIC_AUTH_ENABLED, + PORTER_BASIC_AUTH_REQUIRES_HTTPS, + PORTER_BOTH_TLS_KEY_AND_CERTIFICATION_MUST_BE_PROVIDED, + PORTER_CORS_ALLOWED_ORIGINS, + PORTER_RUN_MESSAGE, +) from nucypher.cli.options import ( option_network, option_provider_uri, @@ -34,7 +39,6 @@ from nucypher.cli.options import ( from nucypher.cli.types import NETWORK_PORT from nucypher.cli.utils import setup_emitter, get_registry from nucypher.config.constants import TEMPORARY_DOMAIN -from nucypher.utilities.porter.control.interfaces import PorterInterface from nucypher.utilities.porter.porter import Porter @@ -58,7 +62,7 @@ def porter(): @click.option('--tls-certificate-filepath', help="Pre-signed TLS certificate filepath", type=click.Path(dir_okay=False, exists=True, path_type=Path)) @click.option('--tls-key-filepath', help="TLS private key filepath", type=click.Path(dir_okay=False, exists=True, path_type=Path)) @click.option('--basic-auth-filepath', help="htpasswd filepath for basic authentication", type=click.Path(dir_okay=False, exists=True, resolve_path=True, path_type=Path)) -@click.option('--allow-origins', help="The CORS origin(s) to allow requests from - allows all origins by default", type=click.STRING, required=False, default="*") +@click.option('--allow-origins', help="The CORS origin(s) string to allow requests from - used as the value for the 'Access-Control-Allow-Origin' response header; not configured by default", type=click.STRING) @click.option('--dry-run', '-x', help="Execute normally without actually starting Porter", is_flag=True) @click.option('--eager', help="Start learning and scraping the network before starting up other services", is_flag=True, default=True) def run(general_config, @@ -81,14 +85,14 @@ def run(general_config, # HTTP/HTTPS if bool(tls_key_filepath) ^ bool(tls_certificate_filepath): raise click.BadOptionUsage(option_name='--tls-key-filepath, --tls-certificate-filepath', - message=BOTH_TLS_KEY_AND_CERTIFICATION_MUST_BE_PROVIDED) + message=PORTER_BOTH_TLS_KEY_AND_CERTIFICATION_MUST_BE_PROVIDED) is_https = (tls_key_filepath and tls_certificate_filepath) # check authentication if basic_auth_filepath and not is_https: raise click.BadOptionUsage(option_name='--basic-auth-filepath', - message=BASIC_AUTH_REQUIRES_HTTPS) + message=PORTER_BASIC_AUTH_REQUIRES_HTTPS) if federated_only: if not teacher_uri: @@ -138,12 +142,17 @@ def run(general_config, if not federated_only: emitter.message(f"Provider: {provider_uri}", color='green') + # firm up falsy status (i.e. change specified empty string to None) + allow_origins = allow_origins if allow_origins else None + if allow_origins: + emitter.message(PORTER_CORS_ALLOWED_ORIGINS.format(allow_origins=allow_origins), color='green') + if basic_auth_filepath: - emitter.message("Basic Authentication enabled", color='green') + emitter.message(PORTER_BASIC_AUTH_ENABLED, color='green') controller = PORTER.make_web_controller(crash_on_error=False, htpasswd_filepath=basic_auth_filepath, - cors_origins=allow_origins) + cors_allow_origins=allow_origins) http_scheme = "https" if is_https else "http" message = PORTER_RUN_MESSAGE.format(http_scheme=http_scheme, http_port=http_port) emitter.message(message, color='green', bold=True) diff --git a/nucypher/cli/literature.py b/nucypher/cli/literature.py index 1c4f95d9a..8f9818f62 100644 --- a/nucypher/cli/literature.py +++ b/nucypher/cli/literature.py @@ -722,6 +722,10 @@ SUCCESSFUL_MANUALLY_SAVE_METADATA = "Successfully saved node metadata to {metada PORTER_RUN_MESSAGE = "Running Porter Web Controller at {http_scheme}://127.0.0.1:{http_port}" -BOTH_TLS_KEY_AND_CERTIFICATION_MUST_BE_PROVIDED = "Both --tls-key-filepath and --tls-certificate-filepath must be provided to launch porter with TLS; only one specified" +PORTER_BASIC_AUTH_ENABLED = "Basic Authentication enabled" -BASIC_AUTH_REQUIRES_HTTPS = "Basic authentication can only be used with HTTPS. --tls-key-filepath and --tls-certificate-filepath must also be provided" +PORTER_CORS_ALLOWED_ORIGINS = "CORS Allow Origins: {allow_origins}" + +PORTER_BOTH_TLS_KEY_AND_CERTIFICATION_MUST_BE_PROVIDED = "Both --tls-key-filepath and --tls-certificate-filepath must be provided to launch porter with TLS; only one specified" + +PORTER_BASIC_AUTH_REQUIRES_HTTPS = "Basic authentication can only be used with HTTPS. --tls-key-filepath and --tls-certificate-filepath must also be provided" diff --git a/nucypher/utilities/porter/porter.py b/nucypher/utilities/porter/porter.py index 5901d798d..f32554de5 100644 --- a/nucypher/utilities/porter/porter.py +++ b/nucypher/utilities/porter/porter.py @@ -200,7 +200,10 @@ the Pipe for nucypher network operations self.controller = controller return controller - def make_web_controller(self, crash_on_error: bool = False, htpasswd_filepath: Path = None, cors_origins: str = '*'): + def make_web_controller(self, + crash_on_error: bool = False, + htpasswd_filepath: Path = None, + cors_allow_origins: str = None): controller = WebController(app_name=self.APP_NAME, crash_on_error=crash_on_error, interface=self._interface_class(porter=self)) @@ -209,18 +212,25 @@ the Pipe for nucypher network operations # Register Flask Decorator porter_flask_control = controller.make_control_transport() - try: - from flask_cors import CORS - from flask_htpasswd import HtPasswdAuth - except ImportError: - raise ImportError('Porter installation is required - run "pip install nucypher[porter]" and try again.') + # CORS origins + if cors_allow_origins: + try: + from flask_cors import CORS + except ImportError: + raise ImportError('Porter installation is required for to specify CORS origins ' + '- run "pip install nucypher[porter]" and try again.') - # CORS - porter_flask_control.config['CORS_ORIGINS'] = cors_origins - _ = CORS(app=porter_flask_control) + porter_flask_control.config['CORS_ORIGINS'] = cors_allow_origins + _ = CORS(app=porter_flask_control) # Basic Auth if htpasswd_filepath: + try: + from flask_htpasswd import HtPasswdAuth + except ImportError: + raise ImportError('Porter installation is required for basic authentication ' + '- run "pip install nucypher[porter]" and try again.') + porter_flask_control.config['FLASK_HTPASSWD_PATH'] = str(htpasswd_filepath.absolute()) # ensure basic auth required for all endpoints porter_flask_control.config['FLASK_AUTH_ALL'] = True diff --git a/tests/acceptance/cli/test_porter.py b/tests/acceptance/cli/test_porter.py index d3e563bee..ad7238a15 100644 --- a/tests/acceptance/cli/test_porter.py +++ b/tests/acceptance/cli/test_porter.py @@ -22,9 +22,10 @@ import pytest from nucypher.characters.lawful import Ursula from nucypher.cli.literature import ( - PORTER_RUN_MESSAGE, - BOTH_TLS_KEY_AND_CERTIFICATION_MUST_BE_PROVIDED, - BASIC_AUTH_REQUIRES_HTTPS + PORTER_BASIC_AUTH_ENABLED, + PORTER_BASIC_AUTH_REQUIRES_HTTPS, + PORTER_BOTH_TLS_KEY_AND_CERTIFICATION_MUST_BE_PROVIDED, + PORTER_RUN_MESSAGE, PORTER_CORS_ALLOWED_ORIGINS ) from nucypher.cli.main import nucypher_cli from nucypher.config.constants import TEMPORARY_DOMAIN @@ -96,7 +97,7 @@ def test_federated_porter_cli_run_tls_filepath_and_certificate(click_runner, '--tls-key-filepath', tempfile_path) # only tls-key provided result = click_runner.invoke(nucypher_cli, porter_run_command, catch_exceptions=False) assert result.exit_code != 0 # both --tls-key-filepath and --tls-certificate-filepath must be provided for TLS - assert BOTH_TLS_KEY_AND_CERTIFICATION_MUST_BE_PROVIDED in result.output + assert PORTER_BOTH_TLS_KEY_AND_CERTIFICATION_MUST_BE_PROVIDED in result.output porter_run_command = ('porter', 'run', '--dry-run', @@ -105,7 +106,7 @@ def test_federated_porter_cli_run_tls_filepath_and_certificate(click_runner, '--tls-certificate-filepath', tempfile_path) # only certificate provided result = click_runner.invoke(nucypher_cli, porter_run_command, catch_exceptions=False) assert result.exit_code != 0 # both --tls-key-filepath and --tls-certificate-filepath must be provided for TLS - assert BOTH_TLS_KEY_AND_CERTIFICATION_MUST_BE_PROVIDED in result.output + assert PORTER_BOTH_TLS_KEY_AND_CERTIFICATION_MUST_BE_PROVIDED in result.output # # tls-key and certificate filepaths must exist @@ -156,6 +157,56 @@ def test_federated_cli_run_https(click_runner, federated_ursulas, temp_dir_path, assert PORTER_RUN_MESSAGE.format(http_scheme="https", http_port=Porter.DEFAULT_PORT) in result.output +def test_federated_cli_run_https_with_cors_origin(click_runner, + federated_ursulas, + temp_dir_path, + federated_teacher_uri): + tls_key_path = Path(temp_dir_path) / 'key.pem' + _write_random_data(tls_key_path) + certificate_file_path = Path(temp_dir_path) / 'fullchain.pem' + _write_random_data(certificate_file_path) + + allow_origins = "nucypher.com" + + porter_run_command = ('porter', 'run', + '--dry-run', + '--federated-only', + '--teacher', federated_teacher_uri, + '--tls-key-filepath', tls_key_path, + '--tls-certificate-filepath', certificate_file_path, + '--allow-origins', allow_origins) + result = click_runner.invoke(nucypher_cli, porter_run_command, catch_exceptions=False) + assert result.exit_code == 0 + assert PORTER_RUN_MESSAGE.format(http_scheme="https", http_port=Porter.DEFAULT_PORT) in result.output + assert PORTER_CORS_ALLOWED_ORIGINS.format(allow_origins=allow_origins) in result.output + + +def test_federated_cli_run_https_with_empty_string_cors_origin(click_runner, + federated_ursulas, + temp_dir_path, + federated_teacher_uri): + tls_key_path = Path(temp_dir_path) / 'key.pem' + _write_random_data(tls_key_path) + certificate_file_path = Path(temp_dir_path) / 'fullchain.pem' + _write_random_data(certificate_file_path) + + empty_string_allow_origins = "" + + porter_run_command = ('porter', 'run', + '--dry-run', + '--federated-only', + '--teacher', federated_teacher_uri, + '--tls-key-filepath', tls_key_path, + '--tls-certificate-filepath', certificate_file_path, + '--allow-origins', empty_string_allow_origins) + result = click_runner.invoke(nucypher_cli, porter_run_command, catch_exceptions=False) + assert result.exit_code == 0 + assert PORTER_RUN_MESSAGE.format(http_scheme="https", http_port=Porter.DEFAULT_PORT) in result.output + # empty string translates to CORS not being enabled - empty origin string provides wild card comparison + # with just header + assert PORTER_CORS_ALLOWED_ORIGINS.format(allow_origins='') not in result.output + + def test_federated_cli_run_https_basic_auth(click_runner, federated_ursulas, federated_teacher_uri, @@ -175,7 +226,7 @@ def test_federated_cli_run_https_basic_auth(click_runner, '--basic-auth-filepath', basic_auth_file) result = click_runner.invoke(nucypher_cli, porter_run_command, catch_exceptions=False) assert result.exit_code == 0 - assert "Basic Authentication enabled" in result.output + assert PORTER_BASIC_AUTH_ENABLED in result.output def test_blockchain_porter_cli_run_simple(click_runner, @@ -271,6 +322,37 @@ def test_blockchain_porter_cli_run_https(click_runner, result = click_runner.invoke(nucypher_cli, porter_run_command, catch_exceptions=False) assert result.exit_code == 0 assert PORTER_RUN_MESSAGE.format(http_scheme="https", http_port=Porter.DEFAULT_PORT) in result.output + # no CORS configured by default; empty origin string provides wild card comparison with just header + assert PORTER_CORS_ALLOWED_ORIGINS.format(allow_origins='') not in result.output + + +def test_blockchain_porter_cli_run_https_with_cors_origin(click_runner, + blockchain_ursulas, + testerchain, + agency_local_registry, + temp_dir_path, + blockchain_teacher_uri): + tls_key_path = Path(temp_dir_path) / 'key.pem' + _write_random_data(tls_key_path) + certificate_file_path = Path(temp_dir_path) / 'fullchain.pem' + _write_random_data(certificate_file_path) + + allow_origins = "*" + + porter_run_command = ('porter', 'run', + '--dry-run', + '--network', TEMPORARY_DOMAIN, + '--provider', TEST_PROVIDER_URI, + '--registry-filepath', agency_local_registry.filepath, + '--teacher', blockchain_teacher_uri, + '--tls-key-filepath', tls_key_path, + '--tls-certificate-filepath', certificate_file_path, + '--allow-origins', allow_origins) + + result = click_runner.invoke(nucypher_cli, porter_run_command, catch_exceptions=False) + assert result.exit_code == 0 + assert PORTER_RUN_MESSAGE.format(http_scheme="https", http_port=Porter.DEFAULT_PORT) in result.output + assert PORTER_CORS_ALLOWED_ORIGINS.format(allow_origins=allow_origins) in result.output def test_blockchain_porter_cli_run_https_basic_auth(click_runner, @@ -297,7 +379,7 @@ def test_blockchain_porter_cli_run_https_basic_auth(click_runner, result = click_runner.invoke(nucypher_cli, porter_run_command, catch_exceptions=False) assert result.exit_code != 0 - assert BASIC_AUTH_REQUIRES_HTTPS in result.output + assert PORTER_BASIC_AUTH_REQUIRES_HTTPS in result.output # Basic Auth porter_run_command = ('porter', 'run', @@ -312,7 +394,7 @@ def test_blockchain_porter_cli_run_https_basic_auth(click_runner, result = click_runner.invoke(nucypher_cli, porter_run_command, catch_exceptions=False) assert result.exit_code == 0 - assert "Basic Authentication enabled" in result.output + assert PORTER_BASIC_AUTH_ENABLED in result.output def _write_random_data(filepath: Path):