diff --git a/bin/nest-server b/bin/nest-server index 9d8734b1b8..4c226a37c7 100755 --- a/bin/nest-server +++ b/bin/nest-server @@ -56,7 +56,7 @@ start() { if [ "${DAEMON}" -eq 0 ]; then echo "Use CTRL + C to stop this service." if [ "${STDOUT}" -eq 1 ]; then - echo "-------------------------------------------------" + echo "-----------------------------------------------------" fi fi diff --git a/doc/htmldoc/connect_nest/nest_server.rst b/doc/htmldoc/connect_nest/nest_server.rst index 3657e6c16e..c873d93fc6 100644 --- a/doc/htmldoc/connect_nest/nest_server.rst +++ b/doc/htmldoc/connect_nest/nest_server.rst @@ -91,6 +91,20 @@ As an alternative to a native installation, NEST Server is available from the NEST Docker image. Please check out the corresponding :ref:`installation instructions ` for more details. +.. _sec_server_vars: + +Set environment variables for security options +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +NEST Server comes with a number of access restrictions that are meant to protect your +computer. After careful consideration, each of the restrictions can be disabled by setting +a corresponding environment variable. + +* ``NEST_SERVER_DISABLE_AUTH``: By default, the NEST Server requires a NESTServerAuth tokens. Setting this variable to ``1`` disables this restriction. A token is automatically created and printed to the console by NEST Server upon start-up. If needed, a custom token can be set using the environment variable ``NEST_SERVER_ACCESS_TOKEN`` +* ``NEST_SERVER_CORS_ORIGINS``: By default, the NEST Server only allows requests from localhost (see `CORS `_). Other hosts can be explicitly allowed by supplying them in the form `http://host_or_ip`` to this variable. +* ``NEST_SERVER_ENABLE_EXEC_CALL``: By default, NEST Server only allows calls to its PyNEST-like API. If the use-case requires the execution of scripts via the ``/exec`` route, this variable can be set to ``1``. PLEASE BE AWARE THAT THIS OPENS YOUR COMPUTER TO REMOTE CODE EXECUTION. +* ``NEST_SERVER_DISABLE_RESTRICTION``: By default, NEST Server runs all code passed to the ``/exec`` route through RestrictedPython to sanitize it. To disable this mechanism, this variable can be set to ``1``. For increased security, code passed in this way only allows explictly whitelisted modules to be imported. To import modules, the variable ``NEST_SERVER_MODULES`` can be set to a standard Python import line like this: + ``NEST_SERVER_MODULES='import nest; import scipy as sp; from numpy import random'`` Run NEST Server ~~~~~~~~~~~~~~~ diff --git a/doc/htmldoc/whats_new/v3.6/index.rst b/doc/htmldoc/whats_new/v3.6/index.rst index 25ed2fe769..8bcdd470e5 100644 --- a/doc/htmldoc/whats_new/v3.6/index.rst +++ b/doc/htmldoc/whats_new/v3.6/index.rst @@ -38,3 +38,10 @@ the property `volume_transmitter` of the synapse's common properties: | ) | ) | | | | +--------------------------------------------------+--------------------------------------------------+ + + +Changes in NEST Server +---------------------- + +We improved the security in NEST Server. Now to use NEST Server, users can modify the security options. +See :ref:`section on setting these varialbles ` in our NEST Server guide. diff --git a/pynest/nest/server/hl_api_server.py b/pynest/nest/server/hl_api_server.py index 672c5c04a1..13ced00441 100644 --- a/pynest/nest/server/hl_api_server.py +++ b/pynest/nest/server/hl_api_server.py @@ -23,27 +23,41 @@ import importlib import inspect import io +import logging import os import sys import time import traceback from copy import deepcopy +import flask import nest import RestrictedPython from flask import Flask, jsonify, request -from flask_cors import CORS, cross_origin +from flask.logging import default_handler +from flask_cors import CORS from werkzeug.exceptions import abort from werkzeug.wrappers import Response -MODULES = os.environ.get("NEST_SERVER_MODULES", "nest").split(",") -RESTRICTION_OFF = bool(os.environ.get("NEST_SERVER_RESTRICTION_OFF", False)) -EXCEPTION_ERROR_STATUS = 400 +# This ensures that the logging information shows up in the console running the server, +# even when Flask's event loop is running. +root = logging.getLogger() +root.addHandler(default_handler) + + +def get_boolean_environ(env_key, default_value="false"): + env_value = os.environ.get(env_key, default_value) + return env_value.lower() in ["yes", "true", "t", "1"] -if RESTRICTION_OFF: - msg = "NEST Server runs without a RestrictedPython trusted environment." - print(f"***\n*** WARNING: {msg}\n***") +_default_origins = "http://localhost" +ACCESS_TOKEN = os.environ.get("NEST_SERVER_ACCESS_TOKEN", "") +AUTH_DISABLED = get_boolean_environ("NEST_SERVER_DISABLE_AUTH") +CORS_ORIGINS = os.environ.get("NEST_SERVER_CORS_ORIGINS", _default_origins).split(",") +EXEC_CALL_ENABLED = get_boolean_environ("NEST_SERVER_ENABLE_EXEC_CALL") +MODULES = os.environ.get("NEST_SERVER_MODULES", "import nest") +RESTRICTION_DISABLED = get_boolean_environ("NEST_SERVER_DISABLE_RESTRICTION") +EXCEPTION_ERROR_STATUS = 400 __all__ = [ "app", @@ -54,11 +68,116 @@ ] app = Flask(__name__) -CORS(app) +# Inform client-side user agents that they should not attempt to call our server from any +# non-whitelisted domain. +CORS(app, origins=CORS_ORIGINS, methods=["GET", "POST"]) mpi_comm = None +def _check_security(): + """ + Checks the security level of the NEST Server instance. + """ + + msg = [] + if AUTH_DISABLED: + msg.append("AUTH:\tThe authorization settings are disabled.") + if "*" in CORS_ORIGINS: + msg.append("CORS:\tThe allowed origins are not restricted.") + if EXEC_CALL_ENABLED: + msg.append("EXEC CALL:\tThe exec route is enables and scripts can be executed.") + if RESTRICTION_DISABLED: + msg.append("RESTRICTION: The execution of scripts is not protected by RestrictedPython.") + + if len(msg) > 0: + print( + "WARNING: You chose to disable important access restrictions!\n" + " This allows other computers to execute code on this machine as the current user!\n" + " Be sure you understand the implications of these settings and take" + " appropriate measures to protect your runtime environment!" + ) + print("\n - ".join([" "] + msg) + "\n") + + +@app.before_request +def _setup_auth(): + """ + Authentication function that generates and validates the NESTServerAuth header with a + bearer token. + + Cleans up references to itself and the running `app` from this module, as it may be + accessible when the code execution sandbox fails. + """ + try: + # Import the modules inside of the auth function, so that if they fail the auth + # returns a forbidden error. + import gc # noqa + import hashlib # noqa + import inspect # noqa + import time # noqa + + # Find our reference to the current function in the garbage collector. + frame = inspect.currentframe() + code = frame.f_code + globs = frame.f_globals + functype = type(lambda: 0) + funcs = [] + for func in gc.get_referrers(code): + if type(func) is functype: + if getattr(func, "__code__", None) is code: + if getattr(func, "__globals__", None) is globs: + funcs.append(func) + if len(funcs) > 1: + return ("Unauthorized", 403) + self = funcs[0] + + # Use the salted hash (unless `PYTHONHASHSEED` is fixed) of the location of this + # function in the Python heap and the current timestamp to create a SHA512 hash. + if not hasattr(self, "_hash"): + if ACCESS_TOKEN: + self._hash = ACCESS_TOKEN + else: + hasher = hashlib.sha512() + hasher.update(str(hash(id(self))).encode("utf-8")) + hasher.update(str(time.perf_counter()).encode("utf-8")) + self._hash = hasher.hexdigest()[:48] + if not AUTH_DISABLED: + print(f" Access token to NEST Server: {self._hash}") + print(" Add this to the headers: {'NESTServerAuth': ''}\n") + + if request.method == "OPTIONS": + return + + # The first time we hit the line below is when below the function definition we + # call `setup_auth` without any Flask request existing yet, so the function errors + # and exits here after generating and storing the auth hash. + auth = request.headers.get("NESTServerAuth", None) + # We continue here the next time this function is called, before the Flask app + # handles the first request. At that point we also remove this module's reference + # to the running app. + try: + del globals()["app"] + except KeyError: + pass + # Things get more straightforward here: Every time a request is handled, compare + # the NESTServerAuth header to the hash, with a constant-time algorithm to avoid + # timing attacks. + if not (AUTH_DISABLED or auth == self._hash): + return ("Unauthorized", 403) + # DON'T LINT! Intentional bare except clause! Even `KeyboardInterrupt` and + # `SystemExit` exceptions should not bypass authentication! + except Exception: # noqa + return ("Unauthorized", 403) + + +print(80 * "*") +_check_security() +_setup_auth() +del _setup_auth +print(80 * "*") + + @app.route("/", methods=["GET"]) def index(): return jsonify( @@ -76,7 +195,7 @@ def do_exec(args, kwargs): locals_ = dict() response = dict() - if RESTRICTION_OFF: + if RESTRICTION_DISABLED: with Capturing() as stdout: globals_ = globals().copy() globals_.update(get_modules_from_env()) @@ -104,7 +223,7 @@ def do_exec(args, kwargs): except Exception as e: for line in traceback.format_exception(*sys.exc_info()): print(line, flush=True) - abort(Response(str(e), EXCEPTION_ERROR_STATUS)) + flask.abort(EXCEPTION_ERROR_STATUS, str(e)) def log(call_name, msg): @@ -159,13 +278,18 @@ def do_call(call_name, args=[], kwargs={}): @app.route("/exec", methods=["GET", "POST"]) -@cross_origin() def route_exec(): """Route to execute script in Python.""" - args, kwargs = get_arguments(request) - response = do_call("exec", args, kwargs) - return jsonify(response) + if EXEC_CALL_ENABLED: + args, kwargs = get_arguments(request) + response = do_call("exec", args, kwargs) + return jsonify(response) + else: + flask.abort( + 403, + "The route `/exec` has been disabled. Please contact the server administrator.", + ) # -------------------------- @@ -178,14 +302,12 @@ def route_exec(): @app.route("/api", methods=["GET"]) -@cross_origin() def route_api(): """Route to list call functions in NEST.""" return jsonify(nest_calls) @app.route("/api/", methods=["GET", "POST"]) -@cross_origin() def route_api_call(call): """Route to call function in NEST.""" print(f"\n{'='*40}\n", flush=True) @@ -281,7 +403,7 @@ def func_wrapper(call, args, kwargs): except Exception as e: for line in traceback.format_exception(*sys.exc_info()): print(line, flush=True) - abort(Response(str(e), EXCEPTION_ERROR_STATUS)) + flask.abort(EXCEPTION_ERROR_STATUS, str(e)) return func_wrapper