Repository: bokeh/bokeh

Issue:
Support environment variable in addition to --allow-websocket-origin
Hey folks! 

This is more of a feature request

I'm running bokeh in docker in multiple environment and being able to specify an environment variable `BOKEH_ALLOWED_WS_ORIGIN` with a list of domains instead of using `--allow-websocket-origin` would make my life easier. 

Docker does not support environment variables in `CMD` and that would force me to write a small wrapper script to run bokeh, which is not ideal.

I can work on a PR if you think that's something worth supporting. I wanted to get your opinions on this first.



Discussion:
@kuuji I'd be happy to have a PR that adds that, my only comment is that it should go through the existing [`bokeh.settings` machinery](https://github.com/bokeh/bokeh/blob/master/bokeh/settings.py) and that ideally it would always return a list (so probably add a new `_get_list` that splits on whitespace or whatever separator you decide on)
Will do. Thanks for the advice @bryevdv 

Resolving patch:
diff --git a/bokeh/command/subcommands/serve.py b/bokeh/command/subcommands/serve.py
--- a/bokeh/command/subcommands/serve.py
+++ b/bokeh/command/subcommands/serve.py
@@ -122,14 +122,16 @@
 
 By default, cross site connections to the Bokeh server websocket are not
 allowed. You can enable websocket connections originating from additional
-hosts by specifying them with the ``--allow-websocket-origin`` option:
+hosts by specifying them with the ``BOKEH_ALLOW_WS_ORIGIN`` environment variable
+or the ``--allow-websocket-origin`` option:
 
 .. code-block:: sh
 
     bokeh serve app_script.py --allow-websocket-origin foo.com:8081
 
 It is possible to specify multiple allowed websocket origins by adding
-the ``--allow-websocket-origin`` option multiple times.
+the ``--allow-websocket-origin`` option multiple times and to provide a
+comma separated list of hosts to ``BOKEH_ALLOW_WS_ORIGIN``
 
 The Bokeh server can also add an optional prefix to all URL paths.
 This can often be useful in conjunction with "reverse proxy" setups.
diff --git a/bokeh/server/views/ws.py b/bokeh/server/views/ws.py
--- a/bokeh/server/views/ws.py
+++ b/bokeh/server/views/ws.py
@@ -20,6 +20,7 @@
 from ...protocol.receiver import Receiver
 
 from bokeh.util.session_id import check_session_id_signature
+from bokeh.settings import settings
 
 # This is an undocumented API purely for harvesting low level messages
 # for testing. When needed it will be set by the testing machinery, and
@@ -65,14 +66,16 @@ def check_origin(self, origin):
         origin_host = parsed_origin.netloc.lower()
 
         allowed_hosts = self.application.websocket_origins
+        if settings.allowed_ws_origin():
+            allowed_hosts = set(settings.allowed_ws_origin())
 
         allowed = check_whitelist(origin_host, allowed_hosts)
         if allowed:
             return True
         else:
             log.error("Refusing websocket connection from Origin '%s'; \
-                      use --allow-websocket-origin=%s to permit this; currently we allow origins %r",
-                      origin, origin_host, allowed_hosts)
+                      use --allow-websocket-origin=%s or set BOKEH_ALLOW_WS_ORIGIN=%s to permit this; currently we allow origins %r",
+                      origin, origin_host, origin_host, allowed_hosts)
             return False
 
     def open(self):
diff --git a/bokeh/settings.py b/bokeh/settings.py
--- a/bokeh/settings.py
+++ b/bokeh/settings.py
@@ -8,6 +8,12 @@
 ''' Control global configuration options with environment variables.
 A global settings object that other parts of Bokeh can refer to.
 
+``BOKEH_ALLOW_WS_ORIGIN`` --- List of websocket origins allowed to access bokeh.
+
+  Comma separated list of domains that need to access bokeh websocket interface.
+  This can also be provided using the --allow-websocket-origin parameter.
+
+  Note: This option overrides the --allow-websocket-origin flag
 
 ``BOKEH_BROWSER`` --- What browser to use when opening plots.
 
@@ -118,6 +124,7 @@
 
 .. _webbrowser: https://docs.python.org/2/library/webbrowser.html
 '''
+
 #-----------------------------------------------------------------------------
 # Boilerplate
 #-----------------------------------------------------------------------------
@@ -208,6 +215,16 @@ def _get_bool(self, key, default, dev=None):
 
         return value
 
+    def _get_list(self, key, default, dev=None):
+        value = self._get(key)
+
+        if value is None:
+            value = self._dev_or_default(default, dev)
+        else:
+            value = self._get(key, self._dev_or_default(default, dev)).split(',')
+
+        return value
+
     def browser(self, default=None):
         ''' Set the default browser that Bokeh should use to show documents
         with.
@@ -380,6 +397,9 @@ def phantomjs_path(self, default=None):
     def ignore_filename(self):
         return self._get_bool("IGNORE_FILENAME", False)
 
+    def allowed_ws_origin(self, default=None):
+        return self._get_list("ALLOW_WS_ORIGIN", default)
+
 #-----------------------------------------------------------------------------
 # Code
 #-----------------------------------------------------------------------------


Test patch:
diff --git a/bokeh/tests/test_client_server.py b/bokeh/tests/test_client_server.py
--- a/bokeh/tests/test_client_server.py
+++ b/bokeh/tests/test_client_server.py
@@ -5,6 +5,7 @@
 
 import logging
 import bokeh.document as document
+import os
 from bokeh.application import Application
 from bokeh.application.handlers import FunctionHandler
 from bokeh.client import pull_session, push_session, ClientSession
@@ -127,14 +128,32 @@ def test_allow_websocket_origin(self):
         with ManagedServerLoop(application, allow_websocket_origin=["example.com"]) as server:
             self.check_http_ok_socket_ok(server, origin="http://example.com:80")
 
+        # allow good origin from environment variable
+        with ManagedServerLoop(application) as server:
+            os.environ["BOKEH_ALLOW_WS_ORIGIN"] = "example.com"
+            self.check_http_ok_socket_ok(server, origin="http://example.com:80")
+            del os.environ["BOKEH_ALLOW_WS_ORIGIN"]
+
         # allow good origin with port
         with ManagedServerLoop(application, allow_websocket_origin=["example.com:8080"]) as server:
             self.check_http_ok_socket_ok(server, origin="http://example.com:8080")
 
+        # allow good origin with port from environment variable
+        with ManagedServerLoop(application) as server:
+            os.environ["BOKEH_ALLOW_WS_ORIGIN"] = "example.com:8080"
+            self.check_http_ok_socket_ok(server, origin="http://example.com:8080")
+            del os.environ["BOKEH_ALLOW_WS_ORIGIN"]
+
         # allow good origin header with an implicit 80
         with ManagedServerLoop(application, allow_websocket_origin=["example.com"]) as server:
             self.check_http_ok_socket_ok(server, origin="http://example.com")
 
+        # allow good origin header with an implicit 80
+        with ManagedServerLoop(application) as server:
+            os.environ["BOKEH_ALLOW_WS_ORIGIN"] = "example.com"
+            self.check_http_ok_socket_ok(server, origin="http://example.com")
+            del os.environ["BOKEH_ALLOW_WS_ORIGIN"]
+
         # block non-Host origins by default even if no extra origins specified
         with ManagedServerLoop(application) as server:
             self.check_http_ok_socket_blocked(server, origin="http://example.com:80")
@@ -147,10 +166,22 @@ def test_allow_websocket_origin(self):
         with ManagedServerLoop(application, allow_websocket_origin=["example.com"]) as server:
             self.check_http_ok_socket_blocked(server, origin="http://foobar.com:80")
 
+        # block bad origin from environment variable
+        with ManagedServerLoop(application) as server:
+            os.environ["BOKEH_ALLOW_WS_ORIGIN"] = "example.com"
+            self.check_http_ok_socket_blocked(server, origin="http://foobar.com:80")
+            del os.environ["BOKEH_ALLOW_WS_ORIGIN"]
+
         # block bad origin port
         with ManagedServerLoop(application, allow_websocket_origin=["example.com:8080"]) as server:
             self.check_http_ok_socket_blocked(server, origin="http://example.com:8081")
 
+        # block bad origin port from environment variable
+        with ManagedServerLoop(application) as server:
+            os.environ["BOKEH_ALLOW_WS_ORIGIN"] = "example.com:8080"
+            self.check_http_ok_socket_blocked(server, origin="http://example.com:8081")
+            del os.environ["BOKEH_ALLOW_WS_ORIGIN"]
+
     def test_push_document(self):
         application = Application()
         with ManagedServerLoop(application) as server:
diff --git a/bokeh/tests/test_settings.py b/bokeh/tests/test_settings.py
new file mode 100644
--- /dev/null
+++ b/bokeh/tests/test_settings.py
@@ -0,0 +1,71 @@
+#-----------------------------------------------------------------------------
+# Copyright (c) 2012 - 2017, Anaconda, Inc. All rights reserved.
+#
+# Powered by the Bokeh Development Team.
+#
+# The full license is in the file LICENSE.txt, distributed with this software.
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Boilerplate
+#-----------------------------------------------------------------------------
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import pytest ; pytest
+
+#-----------------------------------------------------------------------------
+# Imports
+#-----------------------------------------------------------------------------
+
+# Standard library imports
+import logging
+import os
+
+# External imports
+
+# Bokeh imports
+from bokeh._testing.util.api import verify_all
+
+# Module under test
+import bokeh.settings as bs
+
+#-----------------------------------------------------------------------------
+# Setup
+#-----------------------------------------------------------------------------
+
+ALL = (
+    'settings',
+)
+
+logging.basicConfig(level=logging.DEBUG)
+
+#-----------------------------------------------------------------------------
+# General API
+#-----------------------------------------------------------------------------
+
+Test___all__ = verify_all(bs, ALL)
+
+#-----------------------------------------------------------------------------
+# Dev API
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Private API
+#-----------------------------------------------------------------------------
+
+class Test__get_list(object):
+    def test_single(self):
+        os.environ["BOKEH_FOO"] = "foo"
+        result = bs.settings._get_list("FOO", None)
+        assert result == ["foo"]
+        del os.environ["BOKEH_FOO"]
+
+    def test_multiple(self):
+        os.environ["BOKEH_FOO"] = "foo,bar,foobar"
+        result = bs.settings._get_list("FOO", None)
+        assert result == ["foo", "bar", "foobar"]
+        del os.environ["BOKEH_FOO"]
+
+    def test_default(self):
+        result = bs.settings._get_list("FOO", None)
+        assert result is None