mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-07 10:04:07 +00:00
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:
@@ -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",
|
||||||
|
@@ -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)")
|
||||||
|
|
||||||
|
@@ -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.
|
||||||
|
|
||||||
|
@@ -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(
|
||||||
|
Reference in New Issue
Block a user