Repository: jupyterhub/jupyterhub

Issue:
Cross site scripting false positive
A load balancer in front of my jupyterhub service is adding a :443 into the host header.   This is undesired but valid according to the http spec and causes a false positive when performing the cross site scripting check.  This pull request modifies the value read from the host header to strip the :443 or :80 if a load balancer has added it.   In our case it is an azure load balancer and we cant control it.

https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.23

Host = "Host" ":" host [ ":" **port** ] ; Section 3.2.2

Error seen in logs during false positive:

[W 2020-04-17 11:51:29.470 JupyterHub base:60] Blocking Cross Origin API request.  Referer: https://jupyter.myurl.fi/hub/spawn-pending/user, Host: **jupyter.myurl.fi:443**/hub/
[W 2020-04-17 11:51:29.471 JupyterHub log:174] 403 GET /hub/api/users/xxx/server/progress (@myip) 6.40ms


Discussion:
Seems like a reasonable change to make given it is part of the spec.

Having to "parse" the value of the `Host` header manually with `.endswith()` triggers my "seems fragile and like it might create hard to debug weirdness" senses. Have you investigated if there is something "ready made" that we can use to split the header value into "hostname" and "port"? I don't think https://docs.python.org/3/library/urllib.parse.html contains the answer but it is the equivalent for handling URLs.

Alternatively what do you think of `hostname, port = host_value.split(":", maxsplit=1)` as a way to also handle other ports?
I updated the code based on your comments.   I still make changes only to host_headers with 80 and 443 in them.   For example, if someone wants to run on port 8080 they still could.  

It is not in the spec but standard behavior of many clients and servers will truncate the 80 and 443 from a request.    For example if you curl -v https://www.google.com:443, their host header will be www.google.com without the 443.
I'm slightly concerned by why this is necessary. Do you know if it's Tornado or JupyterHub that's stripping the `80` or `443` in the first place causing the comparison here to fail?

For instance there's a very unlikely edge case where someone runs a `http:80` and `http:443` (***not*** `https:443`) on the same server. These should be treated as different hosts, but here they'd be treated the same. This isn't necessary a blocker to this PR, but I thought I mention it as this this change could affect security so we should at least state our assumptions upfront.
@manics  Jupyterhub was not stripping the 80/443 from the hosts header so the comparison against what should have been equal referer failed.   My code strips the 80/443 so that the comparison can succeed.  
This check compares the value of the referrer and the value of the host headers. Do we understand why the referrer doesn't contain a port but the host header does? That is how I understand the situation you are in @scivm, correct?
In addition to the above, could you also confirm it's an Azure Load Balancer and not an Azure Application Gateway which includes load balancing? I've been searching Google since this sounds like something that would affect other applications.
@betatim I believe it is a standard behavior to remove 80 and 443 ports by clients and servers.  The referer header likely had the port removed in the browser.  It was not every request where the issue was manifesting either.  It would happen during the poll for a user workspace starting and also in the hub control panel when you tried to shutdown your server.

@manics I confirmed that it is Azure Application Gateway which has load balancing and firewall built in.  The load balancer is in default configuration.  The firewall implements a standard OWASP rule set (there are configurable versions but we are using 3.0).  We found for jupyterhub we needed to disable 920230-Multiple URL Encoding Detected, 942370-Detects classic SQL injection probings 2/2 for jupyterhub to work.  We are investigating these and they could easily be false positives.  We also closed off the firewall to own country now so it will be easier to test without an attack going on.
Could you try modifying the `X-Forwarded-For` header as suggested here:
https://azure.microsoft.com/en-gb/blog/rewrite-http-headers-with-azure-application-gateway/

That might give you a workaround whilst we figure out whether this is OK to merge.
> Do we understand why the referrer doesn't contain a port but the host header does?

Referer is set by the browser, the original post says that a Load Balancer is modifying the Host header (in a technically valid, but not normal or common way).

What we really want to check is Origin here, which includes protocol (and thereby port) and why our checks here are fiddly. Unfortunately, browsers don't consistently send Origin (some send Origin for all cross-origin requests, others—firefox—*don't* send them for all cross-origin requests). And we specifically want to use this check to block requests originating from single-user servers, which are not cross-origin and thus usually lack an Origin header. Aside: I think it was a big mistake by browser folks to not send Origin for *all* requests.

Since we want to check origin, we should be getting the *protocol* as well and resolving the port if over-specified:

```python
proto = self.requests.protocol
host = self.request.get_header('Host')
hostname, _, port = host.partition(":")
# elide over-specified default ports
if proto == 'https' and port == '443':
    port = ''
if proto == 'http' and port == '80':
    port = ''
if port:
    port = ':' + port

origin = f"{proto}://{hostname}{port}"
```

Comparison could be done with parsed URL objects, in which case we want to check protocol, host, port, path prefix:

```python
host_url = urlparse(f"{origin}{self.hub.base_url}")
referer_url = urlparse(referer)
if referer_url.scheme != host_url.scheme:
    # fail
if referer_url.hostname != host_url.hostname:
    # fail
# resolve default ports (no need for elide block above if we do this)
referer_port = referer_url.port or (443 if referer_url.scheme == 'https' else 80)
host_port = host_url.port or (443 if host_url.scheme == 'https' else 80)
if referer_port != host_port:
    # fail
if not referer_url.path.startswith(host_url.path):
    # fail
```

Resolving patch:
diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py
--- a/jupyterhub/apihandlers/base.py
+++ b/jupyterhub/apihandlers/base.py
@@ -58,7 +58,8 @@ def check_referer(self):
 
         - allow unspecified host/referer (e.g. scripts)
         """
-        host = self.request.headers.get(self.app.forwarded_host_header or "Host")
+        host_header = self.app.forwarded_host_header or "Host"
+        host = self.request.headers.get(host_header)
         referer = self.request.headers.get("Referer")
 
         # If no header is provided, assume it comes from a script/curl.
@@ -70,13 +71,24 @@ def check_referer(self):
             self.log.warning("Blocking API request with no referer")
             return False
 
-        host_path = url_path_join(host, self.hub.base_url)
-        referer_path = referer.split('://', 1)[-1]
-        if not (referer_path + '/').startswith(host_path):
+        proto = self.request.protocol
+        full_host = f"{proto}://{host}{self.hub.base_url}"
+        host_url = urlparse(full_host)
+        referer_url = urlparse(referer)
+        # resolve default ports for http[s]
+        referer_port = referer_url.port or (
+            443 if referer_url.scheme == 'https' else 80
+        )
+        host_port = host_url.port or (443 if host_url.scheme == 'https' else 80)
+        if (
+            referer_url.scheme != host_url.scheme
+            or referer_url.hostname != host_url.hostname
+            or referer_port != host_port
+            or not (referer_url.path + "/").startswith(host_url.path)
+        ):
             self.log.warning(
-                "Blocking Cross Origin API request.  Referer: %s, Host: %s",
-                referer,
-                host_path,
+                f"Blocking Cross Origin API request.  Referer: {referer},"
+                " {host_header}: {host}, Host URL: {full_host}",
             )
             return False
         return True


Test patch:
diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py
--- a/jupyterhub/tests/test_api.py
+++ b/jupyterhub/tests/test_api.py
@@ -9,6 +9,7 @@
 from unittest import mock
 from urllib.parse import quote
 from urllib.parse import urlparse
+from urllib.parse import urlunparse
 
 from pytest import fixture
 from pytest import mark
@@ -65,7 +66,15 @@ async def test_auth_api(app):
     assert r.status_code == 403
 
 
-async def test_cors_checks(request, app):
+@mark.parametrize(
+    "content_type, status",
+    [
+        ("text/plain", 403),
+        # accepted, but invalid
+        ("application/json; charset=UTF-8", 400),
+    ],
+)
+async def test_post_content_type(app, content_type, status):
     url = ujoin(public_host(app), app.hub.base_url)
     host = urlparse(url).netloc
     # add admin user
@@ -74,42 +83,6 @@ async def test_cors_checks(request, app):
         user = add_user(app.db, name='admin', admin=True)
     cookies = await app.login_user('admin')
 
-    r = await api_request(
-        app, 'users', headers={'Authorization': '', 'Referer': 'null'}, cookies=cookies
-    )
-    assert r.status_code == 403
-
-    r = await api_request(
-        app,
-        'users',
-        headers={
-            'Authorization': '',
-            'Referer': 'http://attack.com/csrf/vulnerability',
-        },
-        cookies=cookies,
-    )
-    assert r.status_code == 403
-
-    r = await api_request(
-        app,
-        'users',
-        headers={'Authorization': '', 'Referer': url, 'Host': host},
-        cookies=cookies,
-    )
-    assert r.status_code == 200
-
-    r = await api_request(
-        app,
-        'users',
-        headers={
-            'Authorization': '',
-            'Referer': ujoin(url, 'foo/bar/baz/bat'),
-            'Host': host,
-        },
-        cookies=cookies,
-    )
-    assert r.status_code == 200
-
     r = await api_request(
         app,
         'users',
@@ -117,24 +90,62 @@ async def test_cors_checks(request, app):
         data='{}',
         headers={
             "Authorization": "",
-            "Content-Type": "text/plain",
+            "Content-Type": content_type,
         },
         cookies=cookies,
     )
-    assert r.status_code == 403
+    assert r.status_code == status
 
-    r = await api_request(
-        app,
-        'users',
-        method='post',
-        data='{}',
-        headers={
-            "Authorization": "",
-            "Content-Type": "application/json; charset=UTF-8",
-        },
-        cookies=cookies,
-    )
-    assert r.status_code == 400  # accepted, but invalid
+
+@mark.parametrize(
+    "host, referer, status",
+    [
+        ('$host', '$url', 200),
+        (None, None, 200),
+        (None, 'null', 403),
+        (None, 'http://attack.com/csrf/vulnerability', 403),
+        ('$host', {"path": "/user/someuser"}, 403),
+        ('$host', {"path": "{path}/foo/bar/subpath"}, 200),
+        # mismatch host
+        ("mismatch.com", "$url", 403),
+        # explicit host, matches
+        ("fake.example", {"netloc": "fake.example"}, 200),
+        # explicit port, matches implicit port
+        ("fake.example:80", {"netloc": "fake.example"}, 200),
+        # explicit port, mismatch
+        ("fake.example:81", {"netloc": "fake.example"}, 403),
+        # implicit ports, mismatch proto
+        ("fake.example", {"netloc": "fake.example", "scheme": "https"}, 403),
+    ],
+)
+async def test_cors_check(request, app, host, referer, status):
+    url = ujoin(public_host(app), app.hub.base_url)
+    real_host = urlparse(url).netloc
+    if host == "$host":
+        host = real_host
+
+    if referer == '$url':
+        referer = url
+    elif isinstance(referer, dict):
+        parsed_url = urlparse(url)
+        # apply {}
+        url_ns = {key: getattr(parsed_url, key) for key in parsed_url._fields}
+        for key, value in referer.items():
+            referer[key] = value.format(**url_ns)
+        referer = urlunparse(parsed_url._replace(**referer))
+
+    # disable default auth header, cors is for cookie auth
+    headers = {"Authorization": ""}
+    if host is not None:
+        headers['X-Forwarded-Host'] = host
+    if referer is not None:
+        headers['Referer'] = referer
+
+    # add admin user
+    user = find_user(app.db, 'admin')
+    if user is None:
+        user = add_user(app.db, name='admin', admin=True)
+    cookies = await app.login_user('admin')
 
     # test custom forwarded_host_header behavior
     app.forwarded_host_header = 'X-Forwarded-Host'
@@ -148,28 +159,10 @@ def reset_header():
     r = await api_request(
         app,
         'users',
-        headers={
-            'Authorization': '',
-            'Referer': url,
-            'Host': host,
-            'X-Forwarded-Host': 'example.com',
-        },
-        cookies=cookies,
-    )
-    assert r.status_code == 403
-
-    r = await api_request(
-        app,
-        'users',
-        headers={
-            'Authorization': '',
-            'Referer': url,
-            'Host': host,
-            'X-Forwarded-Host': host,
-        },
+        headers=headers,
         cookies=cookies,
     )
-    assert r.status_code == 200
+    assert r.status_code == status
 
 
 # --------------