Skip to content

Commit 07ae8c6

Browse files
committed
Fixed link rewriting for localhost:PORT.
1 parent 6396563 commit 07ae8c6

File tree

2 files changed

+223
-8
lines changed

2 files changed

+223
-8
lines changed

src/stac_auth_proxy/middleware/ProcessLinksMiddleware.py

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,41 @@
1717
logger = logging.getLogger(__name__)
1818

1919

20+
def _extract_hostname(netloc: str) -> str:
21+
"""
22+
Extract hostname from netloc, ignoring port number.
23+
24+
Args:
25+
netloc: Network location string (e.g., "localhost:8080" or "example.com")
26+
27+
Returns:
28+
Hostname without port (e.g., "localhost" or "example.com")
29+
"""
30+
if ':' in netloc:
31+
if netloc.startswith('['):
32+
# IPv6 with port: [::1]:8080
33+
end_bracket = netloc.rfind(']')
34+
if end_bracket != -1:
35+
return netloc[:end_bracket + 1]
36+
# Regular hostname with port: localhost:8080
37+
return netloc.split(':', 1)[0]
38+
return netloc
39+
40+
41+
def _hostnames_match(hostname1: str, hostname2: str) -> bool:
42+
"""
43+
Check if two hostnames match, ignoring case and port.
44+
45+
Args:
46+
hostname1: First hostname (may include port)
47+
hostname2: Second hostname (may include port)
48+
49+
Returns:
50+
True if hostnames match (case-insensitive, ignoring port)
51+
"""
52+
return _extract_hostname(hostname1).lower() == _extract_hostname(hostname2).lower()
53+
54+
2055
@dataclass
2156
class ProcessLinksMiddleware(JsonResponseMiddleware):
2257
"""
@@ -70,10 +105,14 @@ def _update_link(
70105

71106
parsed_link = urlparse(link["href"])
72107

73-
if parsed_link.netloc not in [
74-
request_url.netloc,
75-
upstream_url.netloc,
76-
]:
108+
link_hostname = _extract_hostname(parsed_link.netloc)
109+
request_hostname = _extract_hostname(request_url.netloc)
110+
upstream_hostname = _extract_hostname(upstream_url.netloc)
111+
112+
if not (
113+
_hostnames_match(link_hostname, request_hostname)
114+
or _hostnames_match(link_hostname, upstream_hostname)
115+
):
77116
logger.debug(
78117
"Ignoring link %s because it is not for an endpoint behind this proxy (%s or %s)",
79118
link["href"],
@@ -94,10 +133,12 @@ def _update_link(
94133
return
95134

96135
# Replace the upstream host with the client's host
97-
if parsed_link.netloc == upstream_url.netloc:
98-
parsed_link = parsed_link._replace(netloc=request_url.netloc)._replace(
99-
scheme=request_url.scheme
100-
)
136+
link_matches_upstream = _hostnames_match(parsed_link.netloc, upstream_url.netloc)
137+
parsed_link = parsed_link._replace(netloc=request_url.netloc)
138+
if link_matches_upstream:
139+
# Link hostname matches upstream: also replace scheme with request URL's scheme
140+
parsed_link = parsed_link._replace(scheme=request_url.scheme)
141+
# If link matches request hostname, scheme is preserved (handles https://localhost:443 -> http://localhost)
101142

102143
# Remove the upstream prefix from the link path
103144
if upstream_url.path != "/" and parsed_link.path.startswith(upstream_url.path):

tests/test_process_links.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,3 +597,177 @@ def test_transform_with_forwarded_headers(headers, expected_base_url):
597597
# but not include the forwarded path in the response URLs
598598
assert transformed["links"][0]["href"] == f"{expected_base_url}/proxy/collections"
599599
assert transformed["links"][1]["href"] == f"{expected_base_url}/proxy"
600+
601+
602+
@pytest.mark.parametrize(
603+
"upstream_url,root_path,request_host,input_links,expected_links",
604+
[
605+
# Basic localhost:PORT rewriting (common port 8080)
606+
(
607+
"http://eoapi-stac:8080",
608+
"/stac",
609+
"localhost",
610+
[
611+
{"rel": "data", "href": "http://localhost:8080/collections"},
612+
],
613+
[
614+
"http://localhost/stac/collections",
615+
],
616+
),
617+
# Standard HTTP port
618+
(
619+
"http://eoapi-stac:8080",
620+
"/stac",
621+
"localhost",
622+
[
623+
{"rel": "self", "href": "http://localhost:80/collections"},
624+
],
625+
[
626+
"http://localhost/stac/collections",
627+
],
628+
),
629+
# HTTPS port
630+
(
631+
"http://eoapi-stac:8080",
632+
"/stac",
633+
"localhost",
634+
[
635+
{"rel": "self", "href": "https://localhost:443/collections"},
636+
],
637+
[
638+
"https://localhost/stac/collections",
639+
],
640+
),
641+
# Arbitrary port
642+
(
643+
"http://eoapi-stac:8080",
644+
"/stac",
645+
"localhost",
646+
[
647+
{"rel": "self", "href": "http://localhost:3000/collections"},
648+
],
649+
[
650+
"http://localhost/stac/collections",
651+
],
652+
),
653+
# Multiple links with different ports
654+
(
655+
"http://eoapi-stac:8080",
656+
"/stac",
657+
"localhost",
658+
[
659+
{"rel": "self", "href": "http://localhost:8080/collections"},
660+
{"rel": "root", "href": "http://localhost:80/"},
661+
{"rel": "items", "href": "https://localhost:443/collections/test/items"},
662+
],
663+
[
664+
"http://localhost/stac/collections",
665+
"http://localhost/stac/",
666+
"https://localhost/stac/collections/test/items",
667+
],
668+
),
669+
# localhost:PORT with upstream path
670+
(
671+
"http://eoapi-stac:8080/api",
672+
"/stac",
673+
"localhost",
674+
[
675+
{"rel": "self", "href": "http://localhost:8080/api/collections"},
676+
],
677+
[
678+
"http://localhost/stac/collections",
679+
],
680+
),
681+
# Request host with port should still work (port removed in rewrite)
682+
(
683+
"http://eoapi-stac:8080",
684+
"/stac",
685+
"localhost:80",
686+
[
687+
{"rel": "self", "href": "http://localhost:8080/collections"},
688+
],
689+
[
690+
"http://localhost:80/stac/collections",
691+
],
692+
),
693+
],
694+
)
695+
def test_transform_localhost_with_port(
696+
upstream_url, root_path, request_host, input_links, expected_links
697+
):
698+
"""Test transforming links with localhost:PORT (any port number)."""
699+
middleware = ProcessLinksMiddleware(
700+
app=None, upstream_url=upstream_url, root_path=root_path
701+
)
702+
request_scope = {
703+
"type": "http",
704+
"path": "/test",
705+
"headers": [
706+
(b"host", request_host.encode()),
707+
(b"content-type", b"application/json"),
708+
],
709+
}
710+
711+
data = {"links": input_links}
712+
transformed = middleware.transform_json(data, Request(request_scope))
713+
714+
for i, expected in enumerate(expected_links):
715+
assert transformed["links"][i]["href"] == expected
716+
717+
718+
def test_localhost_with_port_preserves_other_hostnames():
719+
"""Test that links with other hostnames are not transformed."""
720+
middleware = ProcessLinksMiddleware(
721+
app=None,
722+
upstream_url="http://eoapi-stac:8080",
723+
root_path="/stac",
724+
)
725+
request_scope = {
726+
"type": "http",
727+
"path": "/test",
728+
"headers": [
729+
(b"host", b"localhost"),
730+
(b"content-type", b"application/json"),
731+
],
732+
}
733+
734+
data = {
735+
"links": [
736+
{"rel": "external", "href": "http://example.com:8080/collections"},
737+
{"rel": "other", "href": "http://other-host:3000/collections"},
738+
]
739+
}
740+
741+
transformed = middleware.transform_json(data, Request(request_scope))
742+
743+
# External hostnames should remain unchanged
744+
assert transformed["links"][0]["href"] == "http://example.com:8080/collections"
745+
assert transformed["links"][1]["href"] == "http://other-host:3000/collections"
746+
747+
748+
def test_localhost_with_port_upstream_service_name_still_works():
749+
"""Test that upstream service name matching still works."""
750+
middleware = ProcessLinksMiddleware(
751+
app=None,
752+
upstream_url="http://eoapi-stac:8080",
753+
root_path="/stac",
754+
)
755+
request_scope = {
756+
"type": "http",
757+
"path": "/test",
758+
"headers": [
759+
(b"host", b"localhost"),
760+
(b"content-type", b"application/json"),
761+
],
762+
}
763+
764+
data = {
765+
"links": [
766+
{"rel": "self", "href": "http://eoapi-stac:8080/collections"},
767+
]
768+
}
769+
770+
transformed = middleware.transform_json(data, Request(request_scope))
771+
772+
# Upstream service name should be rewritten to request hostname
773+
assert transformed["links"][0]["href"] == "http://localhost/stac/collections"

0 commit comments

Comments
 (0)