Skip to content

Commit f692b9e

Browse files
committed
make sure that /prefix and /prefix/ are handled the same
adds Path(/prefix) rule to PathPrefix(/prefix/) so that we don't get redirect loops on /prefix
1 parent 932967e commit f692b9e

File tree

4 files changed

+72
-13
lines changed

4 files changed

+72
-13
lines changed

jupyterhub_traefik_proxy/traefik_utils.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,33 @@ def validate(self, obj, value):
2222

2323

2424
def generate_rule(routespec):
25+
# validate assumption that routespecs always end with '/'
26+
assert routespec.endswith("/")
2527
routespec = unquote(routespec)
28+
29+
# traefik won't match /proxy/path to /proxy/path/
30+
# so strip trailing slash for consistent behavior
2631
if routespec.startswith("/"):
2732
# Path-based route, e.g. /proxy/path/
28-
rule = f"PathPrefix(`{routespec}`)"
33+
host = ""
34+
path = routespec
2935
else:
3036
# Host-based routing, e.g. host.tld/proxy/path/
31-
host, path_prefix = routespec.split("/", 1)
32-
rule = f"Host(`{host}`) && PathPrefix(`/{path_prefix}`)"
37+
host, slash, path = routespec.partition("/")
38+
path = slash + path
39+
40+
path_no_slash = path.rstrip("/")
41+
42+
path_rule = f"PathPrefix(`{path}`)"
43+
if path_no_slash:
44+
# include exact Path('/prefix') so that both /prefix/ and /prefix
45+
# are served correctly
46+
path_rule = f"( {path_rule} || Path(`{path_no_slash}`) )"
47+
48+
if host:
49+
rule = f"Host(`{host}`) && {path_rule}"
50+
else:
51+
rule = path_rule
3352
return rule
3453

3554

tests/test_proxy.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,27 @@ async def _wait_for_deletion():
336336
await test_route_exist(spec, extra_backends[i])
337337

338338

339+
async def test_trailing_slash(proxy, launch_backends):
340+
proxy_url = proxy.public_url.rstrip("/")
341+
routespec = "/path/prefix/"
342+
default_backend, target_backend = await launch_backends(2)
343+
target_port = urlparse(target_backend).port
344+
default_port = urlparse(default_backend).port
345+
346+
await proxy.add_route("/", default_backend, {})
347+
await proxy.add_route(routespec, target_backend, {})
348+
349+
port = await utils.get_responding_backend_port(proxy_url, "/path/prefix/")
350+
assert port == target_port
351+
port = await utils.get_responding_backend_port(proxy_url, "/path/prefix")
352+
assert port == target_port
353+
port = await utils.get_responding_backend_port(proxy_url, "/path/prefixnoslash")
354+
assert port == default_port
355+
356+
for route in ["/", routespec]:
357+
await proxy.delete_route(route)
358+
359+
339360
async def test_get_all_routes(proxy, launch_backends):
340361
# initial state: no routes
341362
routes = await proxy.get_all_routes()

tests/test_traefik_utils.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,20 @@ def test_atomic_writing_recovery(tmpdir):
7676

7777
# didn't leave any residue
7878
assert tmpdir.listdir() == [testfile]
79+
80+
81+
@pytest.mark.parametrize(
82+
"routespec, expected_rule",
83+
[
84+
("/", "PathPrefix(`/`)"),
85+
("/path/prefix/", "( PathPrefix(`/path/prefix/`) || Path(`/path/prefix`) )"),
86+
("host/", "Host(`host`) && PathPrefix(`/`)"),
87+
(
88+
"host/path/prefix/",
89+
"Host(`host`) && ( PathPrefix(`/path/prefix/`) || Path(`/path/prefix`) )",
90+
),
91+
],
92+
)
93+
def test_generate_rule(routespec, expected_rule):
94+
rule = traefik_utils.generate_rule(routespec)
95+
assert rule == expected_rule

tests/utils.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,18 +58,20 @@ async def check_host_up_http(url):
5858
async def get_responding_backend_port(traefik_url, path):
5959
"""Check if traefik followed the configuration and routed the
6060
request to the right backend"""
61-
if not path.endswith("/"):
62-
path += "/"
61+
62+
headers = {}
6363

6464
if not path.startswith("/"):
65-
req = HTTPRequest(
66-
traefik_url + "".join("/" + path.split("/", 1)[1]),
67-
method="GET",
68-
headers={"Host": path.split("/")[0]},
69-
validate_cert=False,
70-
)
71-
else:
72-
req = HTTPRequest(traefik_url + path, validate_cert=False)
65+
host, slash, path = path.partition("/")
66+
path = slash + path
67+
headers["Host"] = host
68+
69+
req = HTTPRequest(
70+
traefik_url + path,
71+
headers=headers,
72+
validate_cert=False,
73+
follow_redirects=True,
74+
)
7375

7476
try:
7577
resp = await AsyncHTTPClient().fetch(req)

0 commit comments

Comments
 (0)