validate proxy.extra_routes

- add trailing slash if missing, and warn
- raise if leading slash is wrong (must not be present with host routing, must be present otherwise)
This commit is contained in:
Min RK
2022-07-08 09:42:47 -07:00
parent 8a44748324
commit c289a422c3
4 changed files with 149 additions and 13 deletions

View File

@@ -2,6 +2,8 @@
# 1. start/stop servers, and # 1. start/stop servers, and
# 2. access the server API # 2. access the server API
c = get_config() # noqa
c.JupyterHub.load_roles = [ c.JupyterHub.load_roles = [
{ {
"name": "launcher", "name": "launcher",

View File

@@ -711,11 +711,14 @@ class JupyterHub(Application):
""", """,
).tag(config=True) ).tag(config=True)
def _subdomain_host_changed(self, name, old, new): @validate("subdomain_host")
def _validate_subdomain_host(self, proposal):
new = proposal.value
if new and '://' not in new: if new and '://' not in new:
# host should include '://' # host should include '://'
# if not specified, assume https: You have to be really explicit about HTTP! # if not specified, assume https: You have to be really explicit about HTTP!
self.subdomain_host = 'https://' + new new = 'https://' + new
return new
domain = Unicode(help="domain name, e.g. 'example.com' (excludes protocol, port)") domain = Unicode(help="domain name, e.g. 'example.com' (excludes protocol, port)")

View File

@@ -23,12 +23,23 @@ import signal
import time import time
from functools import wraps from functools import wraps
from subprocess import Popen from subprocess import Popen
from urllib.parse import quote from urllib.parse import quote, urlparse
from weakref import WeakKeyDictionary from weakref import WeakKeyDictionary
from tornado.httpclient import AsyncHTTPClient, HTTPError, HTTPRequest from tornado.httpclient import AsyncHTTPClient, HTTPError, HTTPRequest
from tornado.ioloop import PeriodicCallback from tornado.ioloop import PeriodicCallback
from traitlets import Any, Bool, Dict, Instance, Integer, Unicode, default, observe from traitlets import (
Any,
Bool,
Dict,
Instance,
Integer,
TraitError,
Unicode,
default,
observe,
validate,
)
from traitlets.config import LoggingConfigurable from traitlets.config import LoggingConfigurable
from jupyterhub.traitlets import Command from jupyterhub.traitlets import Command
@@ -111,7 +122,8 @@ class Proxy(LoggingConfigurable):
) )
extra_routes = Dict( extra_routes = Dict(
{}, key_trait=Unicode(),
value_trait=Unicode(),
config=True, config=True,
help=""" help="""
Additional routes to be maintained in the proxy. Additional routes to be maintained in the proxy.
@@ -130,6 +142,51 @@ class Proxy(LoggingConfigurable):
""", """,
) )
@validate("extra_routes")
def _validate_extra_routes(self, proposal):
extra_routes = {}
# check routespecs for leading/trailing slashes
for routespec, target in proposal.value.items():
if not isinstance(routespec, str):
raise TraitError(
f"Proxy.extra_routes keys must be str, got {routespec!r}"
)
if not isinstance(target, str):
raise TraitError(
f"Proxy.extra_routes values must be str, got {target!r}"
)
if not routespec.endswith("/"):
# trailing / is unambiguous, so we can add it
self.log.warning(
f"Adding missing trailing '/' to c.Proxy.extra_routes {routespec} -> {routespec}/"
)
routespec += "/"
if self.app.subdomain_host:
# subdomain routing must _not_ start with /
if routespec.startswith("/"):
raise ValueError(
f"Proxy.extra_routes missing host component in {routespec} (must not have leading '/') when using `JupyterHub.subdomain_host = {self.app.subdomain_host!r}`"
)
else:
# no subdomains, must start with /
# this is ambiguous with host routing, so raise instead of warn
if not routespec.startswith("/"):
raise ValueError(
f"Proxy.extra_routes routespec {routespec} missing leading '/'."
)
# validate target URL?
target_url = urlparse(target.lower())
if target_url.scheme not in {"http", "https"} or not target_url.netloc:
raise ValueError(
f"Proxy.extra_routes target {routespec}={target!r} doesn't look like a URL (should have http[s]://...)"
)
extra_routes[routespec] = target
return extra_routes
def start(self): def start(self):
"""Start the proxy. """Start the proxy.

View File

@@ -6,6 +6,7 @@ from subprocess import Popen
from urllib.parse import quote, urlparse from urllib.parse import quote, urlparse
import pytest import pytest
from traitlets import TraitError
from traitlets.config import Config from traitlets.config import Config
from ..utils import url_path_join as ujoin from ..utils import url_path_join as ujoin
@@ -193,23 +194,96 @@ async def test_check_routes(app, username, disable_check_routes):
assert before == after assert before == after
async def test_extra_routes(app): @pytest.mark.parametrize(
"routespec",
[
'/has%20space/foo/',
'/missing-trailing/slash',
'/has/@/',
'/has/' + quote('üñîçø∂é'),
'host.name/path/',
'other.host/path/no/slash',
],
)
async def test_extra_routes(app, routespec):
proxy = app.proxy proxy = app.proxy
# When using host_routing, it's up to the admin to # When using host_routing, it's up to the admin to
# provide routespecs that have a domain in them. # provide routespecs that have a domain in them.
# We don't explicitly validate that here. # We don't explicitly validate that here.
if app.subdomain_host: if app.subdomain_host and routespec.startswith("/"):
route_spec = 'example.com/test-extra-routes/' routespec = 'example.com/' + routespec
else: elif not app.subdomain_host and not routespec.startswith("/"):
route_spec = '/test-extra-routes/' pytest.skip("requires subdomains")
validated_routespec = routespec
if not routespec.endswith("/"):
validated_routespec = routespec + "/"
target = 'http://localhost:9999/test' target = 'http://localhost:9999/test'
proxy.extra_routes = {route_spec: target} proxy.extra_routes = {routespec: target}
await proxy.check_routes(app.users, app._service_map) await proxy.check_routes(app.users, app._service_map)
routes = await app.proxy.get_all_routes() routes = await app.proxy.get_all_routes()
assert route_spec in routes print(routes)
assert routes[route_spec]['target'] == target assert validated_routespec in routes
assert routes[validated_routespec]['target'] == target
assert routes[validated_routespec]['data']['extra']
@pytest.mark.parametrize(
"needs_subdomain, routespec, expected",
[
(False, "/prefix/", "/prefix/"),
(False, "/prefix", "/prefix/"),
(False, "prefix/", ValueError),
(True, "/prefix/", ValueError),
(True, "example.com/prefix/", "example.com/prefix/"),
(True, "example.com/prefix", "example.com/prefix/"),
(False, 100, TraitError),
],
)
def test_extra_routes_validate_routespec(
request, app, needs_subdomain, routespec, expected
):
save_host = app.subdomain_host
request.addfinalizer(lambda: setattr(app, "subdomain_host", save_host))
if needs_subdomain:
app.subdomain_host = "localhost.jovyan.org"
else:
app.subdomain_host = ""
proxy = app.proxy
extra_routes = {routespec: "https://127.0.0.1"}
if isinstance(expected, type) and issubclass(expected, BaseException):
with pytest.raises(expected):
proxy.extra_routes = extra_routes
return
proxy.extra_routes = extra_routes
assert list(proxy.extra_routes) == [expected]
@pytest.mark.parametrize(
"target, expected",
[
("http://host", "http://host"),
("https://host", "https://host"),
("/missing-host", ValueError),
("://missing-scheme", ValueError),
(100, TraitError),
],
)
def test_extra_routes_validate_target(app, target, expected):
proxy = app.proxy
routespec = "/prefix/"
if app.subdomain_host:
routespec = f"host.tld{routespec}"
extra_routes = {routespec: target}
if isinstance(expected, type) and issubclass(expected, BaseException):
with pytest.raises(expected):
proxy.extra_routes = extra_routes
return
proxy.extra_routes = extra_routes
assert list(proxy.extra_routes.values()) == [expected]
@pytest.mark.parametrize( @pytest.mark.parametrize(