Skip to content

Commit c447ac6

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

File tree

2 files changed

+230
-8
lines changed

2 files changed

+230
-8
lines changed

src/stac_auth_proxy/middleware/ProcessLinksMiddleware.py

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,43 @@
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+
"""
31+
if ":" in netloc:
32+
if netloc.startswith("["):
33+
# IPv6 with port: [::1]:8080
34+
end_bracket = netloc.rfind("]")
35+
if end_bracket != -1:
36+
return netloc[: end_bracket + 1]
37+
# Regular hostname with port: localhost:8080
38+
return netloc.split(":", 1)[0]
39+
return netloc
40+
41+
42+
def _hostnames_match(hostname1: str, hostname2: str) -> bool:
43+
"""
44+
Check if two hostnames match, ignoring case and port.
45+
46+
Args:
47+
hostname1: First hostname (may include port)
48+
hostname2: Second hostname (may include port)
49+
50+
Returns:
51+
True if hostnames match (case-insensitive, ignoring port)
52+
53+
"""
54+
return _extract_hostname(hostname1).lower() == _extract_hostname(hostname2).lower()
55+
56+
2057
@dataclass
2158
class ProcessLinksMiddleware(JsonResponseMiddleware):
2259
"""
@@ -70,10 +107,14 @@ def _update_link(
70107

71108
parsed_link = urlparse(link["href"])
72109

73-
if parsed_link.netloc not in [
74-
request_url.netloc,
75-
upstream_url.netloc,
76-
]:
110+
link_hostname = _extract_hostname(parsed_link.netloc)
111+
request_hostname = _extract_hostname(request_url.netloc)
112+
upstream_hostname = _extract_hostname(upstream_url.netloc)
113+
114+
if not (
115+
_hostnames_match(link_hostname, request_hostname)
116+
or _hostnames_match(link_hostname, upstream_hostname)
117+
):
77118
logger.debug(
78119
"Ignoring link %s because it is not for an endpoint behind this proxy (%s or %s)",
79120
link["href"],
@@ -94,10 +135,14 @@ def _update_link(
94135
return
95136

96137
# 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-
)
138+
link_matches_upstream = _hostnames_match(
139+
parsed_link.netloc, upstream_url.netloc
140+
)
141+
parsed_link = parsed_link._replace(netloc=request_url.netloc)
142+
if link_matches_upstream:
143+
# Link hostname matches upstream: also replace scheme with request URL's scheme
144+
parsed_link = parsed_link._replace(scheme=request_url.scheme)
145+
# If link matches request hostname, scheme is preserved (handles https://localhost:443 -> http://localhost)
101146

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

tests/test_process_links.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,3 +597,180 @@ 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+
{
662+
"rel": "items",
663+
"href": "https://localhost:443/collections/test/items",
664+
},
665+
],
666+
[
667+
"http://localhost/stac/collections",
668+
"http://localhost/stac/",
669+
"https://localhost/stac/collections/test/items",
670+
],
671+
),
672+
# localhost:PORT with upstream path
673+
(
674+
"http://eoapi-stac:8080/api",
675+
"/stac",
676+
"localhost",
677+
[
678+
{"rel": "self", "href": "http://localhost:8080/api/collections"},
679+
],
680+
[
681+
"http://localhost/stac/collections",
682+
],
683+
),
684+
# Request host with port should still work (port removed in rewrite)
685+
(
686+
"http://eoapi-stac:8080",
687+
"/stac",
688+
"localhost:80",
689+
[
690+
{"rel": "self", "href": "http://localhost:8080/collections"},
691+
],
692+
[
693+
"http://localhost:80/stac/collections",
694+
],
695+
),
696+
],
697+
)
698+
def test_transform_localhost_with_port(
699+
upstream_url, root_path, request_host, input_links, expected_links
700+
):
701+
"""Test transforming links with localhost:PORT (any port number)."""
702+
middleware = ProcessLinksMiddleware(
703+
app=None, upstream_url=upstream_url, root_path=root_path
704+
)
705+
request_scope = {
706+
"type": "http",
707+
"path": "/test",
708+
"headers": [
709+
(b"host", request_host.encode()),
710+
(b"content-type", b"application/json"),
711+
],
712+
}
713+
714+
data = {"links": input_links}
715+
transformed = middleware.transform_json(data, Request(request_scope))
716+
717+
for i, expected in enumerate(expected_links):
718+
assert transformed["links"][i]["href"] == expected
719+
720+
721+
def test_localhost_with_port_preserves_other_hostnames():
722+
"""Test that links with other hostnames are not transformed."""
723+
middleware = ProcessLinksMiddleware(
724+
app=None,
725+
upstream_url="http://eoapi-stac:8080",
726+
root_path="/stac",
727+
)
728+
request_scope = {
729+
"type": "http",
730+
"path": "/test",
731+
"headers": [
732+
(b"host", b"localhost"),
733+
(b"content-type", b"application/json"),
734+
],
735+
}
736+
737+
data = {
738+
"links": [
739+
{"rel": "external", "href": "http://example.com:8080/collections"},
740+
{"rel": "other", "href": "http://other-host:3000/collections"},
741+
]
742+
}
743+
744+
transformed = middleware.transform_json(data, Request(request_scope))
745+
746+
# External hostnames should remain unchanged
747+
assert transformed["links"][0]["href"] == "http://example.com:8080/collections"
748+
assert transformed["links"][1]["href"] == "http://other-host:3000/collections"
749+
750+
751+
def test_localhost_with_port_upstream_service_name_still_works():
752+
"""Test that upstream service name matching still works."""
753+
middleware = ProcessLinksMiddleware(
754+
app=None,
755+
upstream_url="http://eoapi-stac:8080",
756+
root_path="/stac",
757+
)
758+
request_scope = {
759+
"type": "http",
760+
"path": "/test",
761+
"headers": [
762+
(b"host", b"localhost"),
763+
(b"content-type", b"application/json"),
764+
],
765+
}
766+
767+
data = {
768+
"links": [
769+
{"rel": "self", "href": "http://eoapi-stac:8080/collections"},
770+
]
771+
}
772+
773+
transformed = middleware.transform_json(data, Request(request_scope))
774+
775+
# Upstream service name should be rewritten to request hostname
776+
assert transformed["links"][0]["href"] == "http://localhost/stac/collections"

0 commit comments

Comments
 (0)