Repository: bokeh/bokeh

Issue:
Bug --host '*' wildcard result in HTTP "403: Forbidden" error
According to the [documentation](http://bokeh.pydata.org/en/latest/docs/user_guide/cli.html#network-configuration) using `bokeh serve --host '*'` should accept connections on any IP address and port (possibly in combination with the `--address 0.0.0.0` flag).

However when I try for instance by adding an alternative IP address to the local host network interface:

```
sudo ip address add 192.168.99.37/24 dev wlan0
```

then launching `bokeh` with:

```
bokeh serve --address 0.0.0.0 --port 5006 --host '*'
```

and trying to access http://192.168.99.37:5006/ result in the following HTTP 403 page:

```
403: Forbidden
```

This bug is annoying when deploying services with docker orchestration tools such as docker-compose or kubernetes it is not always possible to know the public ip address that will be used to access the service ahead of time and configuring a public DNS is too cumbersome when in development mode.



Discussion:

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
@@ -102,10 +102,24 @@
 accept requests from ``localhost:<port>`` where ``<port>`` is the port
 that the server is configured to listen on (by default: {DEFAULT_PORT}).
 
-If an asterix ``*`` is used in the host value (for example ``--host *``) then
-it will be treated as a wildcard.  As a warning, using permissive host values
-like ``*`` may be insecure and open your application to HTTP host header
-attacks.
+If an asterix ``*`` is used in the host value then it will be treated as a
+wildcard:
+
+.. code-block:: sh
+
+    bokeh serve app_script.py --address=0.0.0.0 --host='*'
+
+Using the wildcard can be helpful when testing applications that are deployed
+with cloud orchestration tools and when the public endpoint is not known ahead
+of time: for instance if the public IP is dynamically allocated during the
+deployment process and no public DNS has been configured for the testing
+environment.
+
+As a warning, using permissive host values like ``*`` may be insecure and open
+your application to HTTP host header attacks. Production deployments should
+always set the ``--host`` flag to use the DNS name of the public endpoint such
+as a TLS-enabled load balancer or reverse proxy that serves the application to
+the end users.
 
 Also note that the host whitelist applies to all request handlers,
 including any extra ones added to extend the Bokeh server.
diff --git a/bokeh/server/server.py b/bokeh/server/server.py
--- a/bokeh/server/server.py
+++ b/bokeh/server/server.py
@@ -24,6 +24,15 @@ def _create_hosts_whitelist(host_list, port):
 
     hosts = []
     for host in host_list:
+        if '*' in host:
+            log.warning('Host wildcard %r can expose the application to HTTP '
+                        'host header attacks. Host wildcard should only be '
+                        'used for testing purpose.', host)
+        if host == '*':
+            # do not append the :80 port suffix in that case: any port is
+            # accepted
+            hosts.append(host)
+            continue
         parts = host.split(':')
         if len(parts) == 1:
             if parts[0] == "":
@@ -53,7 +62,7 @@ class Server(object):
 
     def __init__(self, applications, **kwargs):
         log.info("Starting Bokeh server version %s" % __version__)
-        
+
         if isinstance(applications, Application):
             self._applications = { '/' : applications }
         else:
diff --git a/bokeh/server/tornado.py b/bokeh/server/tornado.py
--- a/bokeh/server/tornado.py
+++ b/bokeh/server/tornado.py
@@ -31,21 +31,51 @@
 def match_host(host, pattern):
     """ Match host against pattern
 
-    >>> match_host('192.168.0.1', '192.168.0.1')
+    >>> match_host('192.168.0.1:80', '192.168.0.1:80')
     True
+    >>> match_host('192.168.0.1:80', '192.168.0.1')
+    True
+    >>> match_host('192.168.0.1:80', '192.168.0.1:8080')
+    False
     >>> match_host('192.168.0.1', '192.168.0.2')
     False
     >>> match_host('192.168.0.1', '192.168.*.*')
     True
     >>> match_host('alice', 'alice')
     True
+    >>> match_host('alice:80', 'alice')
+    True
     >>> match_host('alice', 'bob')
     False
     >>> match_host('foo.example.com', 'foo.example.com.net')
     False
     >>> match_host('alice', '*')
     True
+    >>> match_host('alice', '*:*')
+    True
+    >>> match_host('alice:80', '*')
+    True
+    >>> match_host('alice:80', '*:80')
+    True
+    >>> match_host('alice:8080', '*:80')
+    False
+
     """
+    if ':' in host:
+        host, host_port = host.rsplit(':', 1)
+    else:
+        host_port = None
+
+    if ':' in pattern:
+        pattern, pattern_port = pattern.rsplit(':', 1)
+        if pattern_port == '*':
+            pattern_port = None
+    else:
+        pattern_port = None
+
+    if pattern_port is not None and host_port != pattern_port:
+        return False
+
     host = host.split('.')
     pattern = pattern.split('.')
 


Test patch:
diff --git a/bokeh/server/tests/test_tornado.py b/bokeh/server/tests/test_tornado.py
--- a/bokeh/server/tests/test_tornado.py
+++ b/bokeh/server/tests/test_tornado.py
@@ -42,9 +42,26 @@ def test_check_whitelist_accepts_implicit_port_80():
 
 def test_check_whitelist_accepts_all_on_star():
     assert True == tornado.check_whitelist("192.168.0.1", ['*'])
+    assert True == tornado.check_whitelist("192.168.0.1:80", ['*'])
+    assert True == tornado.check_whitelist("192.168.0.1:5006", ['*'])
+    assert True == tornado.check_whitelist("192.168.0.1:80", ['*:80'])
+    assert False == tornado.check_whitelist("192.168.0.1:80", ['*:81'])
+    assert True == tornado.check_whitelist("192.168.0.1:5006", ['*:*'])
     assert True == tornado.check_whitelist("192.168.0.1", ['192.168.0.*'])
+    assert True == tornado.check_whitelist("192.168.0.1:5006", ['192.168.0.*'])
     assert False == tornado.check_whitelist("192.168.1.1", ['192.168.0.*'])
     assert True == tornado.check_whitelist("foobarbaz", ['*'])
+    assert True == tornado.check_whitelist("192.168.0.1", ['192.168.0.*'])
+    assert False == tornado.check_whitelist("192.168.1.1", ['192.168.0.*'])
+    assert False == tornado.check_whitelist("192.168.0.1", ['192.168.0.*:5006'])
+    assert True == tornado.check_whitelist("192.168.0.1", ['192.168.0.*:80'])
+    assert True == tornado.check_whitelist("foobarbaz", ['*'])
+    assert True == tornado.check_whitelist("foobarbaz", ['*:*'])
+    assert True == tornado.check_whitelist("foobarbaz", ['*:80'])
+    assert False == tornado.check_whitelist("foobarbaz", ['*:5006'])
+    assert True == tornado.check_whitelist("foobarbaz:5006", ['*'])
+    assert True == tornado.check_whitelist("foobarbaz:5006", ['*:*'])
+    assert True == tornado.check_whitelist("foobarbaz:5006", ['*:5006'])
 
 # tried to use capsys to test what's actually logged and it wasn't
 # working, in the meantime at least this tests that log_stats