Skip to content

Commit 14b118a

Browse files
Merge remote-tracking branch 'upstream/release_25.0' into merge_25.0_into_25.1_oct
2 parents 66d0de4 + 7b269cb commit 14b118a

3 files changed

Lines changed: 141 additions & 9 deletions

File tree

lib/galaxy/config/sample/datatypes_conf.xml.sample

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@
143143
<datatype extension="las" type="galaxy.datatypes.binary:Binary" mimetype="application/vnd.las" subclass="true" display_in_upload="true" description="The LAS (LASer) format is a file format designed for the interchange and archiving of Lidar point cloud data." description_url="https://www.loc.gov/preservation/digital/formats/fdd/fdd000418.shtml">
144144
<infer_from suffix="las" />
145145
</datatype>
146-
<datatype extension="laz" type="galaxy.datatypes.binary:Binary" mimetype="application/vnd.las" subclass="true" display_in_upload="true" description="LAZ is an open format for lossless compression of LAS." description_url="https://downloads.rapidlasso.de/doc/laszip.pdf">
146+
<datatype extension="laz" type="galaxy.datatypes.binary:Binary" mimetype="application/vnd.laszip" subclass="true" display_in_upload="true" description="LAZ is an open format for lossless compression of LAS." description_url="https://downloads.rapidlasso.de/doc/laszip.pdf">
147147
<infer_from suffix="laz" />
148148
</datatype>
149149
<datatype extension="d3_hierarchy" type="galaxy.datatypes.text:Json" mimetype="application/json" subclass="true" display_in_upload="true"/>

lib/galaxy/webapps/galaxy/api/proxy.py

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
"""
44

55
import logging
6-
from urllib.parse import urlparse
6+
from urllib.parse import (
7+
urljoin,
8+
urlparse,
9+
)
710

811
import httpx
912
from fastapi import (
@@ -36,6 +39,7 @@
3639
)
3740

3841
ALLOWED_SCHEMES = ("https", "http")
42+
MAX_REDIRECTS = 5
3943

4044

4145
def is_valid_url(url: str) -> bool:
@@ -60,12 +64,9 @@ async def proxy(self, request: Request, url: str = URLQueryParam, trans: Provide
6064
if trans.anonymous:
6165
raise UserRequiredException("Anonymous users are not allowed to access this endpoint")
6266

63-
if not is_valid_url(url):
64-
raise RequestParameterInvalidException("Invalid URL format.")
65-
66-
validate_uri_access(url, trans.user_is_admin, trans.app.config.fetch_url_allowlist_ips)
67+
self._validate_url_and_access(url, trans)
6768

68-
headers = {}
69+
headers: dict[str, str] = {}
6970
if "range" in request.headers:
7071
headers["Range"] = self._validate_range_header(request.headers["range"])
7172

@@ -74,9 +75,8 @@ async def proxy(self, request: Request, url: str = URLQueryParam, trans: Provide
7475
timeout = httpx.Timeout(10.0, connect=60.0)
7576

7677
client = httpx.AsyncClient(timeout=timeout)
77-
response = None
7878
try:
79-
response = await client.request(method=request.method, url=url, headers=headers, follow_redirects=True)
79+
response = await self._handle_redirects_validation(request, url, trans, headers, client)
8080

8181
if request.method == "GET":
8282
# Return a streaming response for GET requests
@@ -113,6 +113,55 @@ async def stream_with_cleanup():
113113
await response.aclose()
114114
await client.aclose()
115115

116+
async def _handle_redirects_validation(
117+
self,
118+
request: Request,
119+
url: str,
120+
trans: ProvidesUserContext,
121+
headers: dict[str, str],
122+
client: httpx.AsyncClient,
123+
) -> httpx.Response:
124+
"""Handle redirects manually to validate each redirect URL."""
125+
response = None
126+
current_url = url
127+
redirect_count = 0
128+
129+
while redirect_count <= MAX_REDIRECTS:
130+
response = await client.request(
131+
method=request.method, url=current_url, headers=headers, follow_redirects=False
132+
)
133+
134+
if self._is_redirect_response(response):
135+
redirect_count += 1
136+
if redirect_count > MAX_REDIRECTS:
137+
raise RequestParameterInvalidException("Too many redirects")
138+
139+
redirect_location = response.headers["location"]
140+
141+
# Handle relative URLs by resolving them against the current URL
142+
redirect_url = urljoin(current_url, redirect_location)
143+
144+
self._validate_url_and_access(redirect_url, trans)
145+
146+
# Close current response and follow the validated redirect
147+
await response.aclose()
148+
current_url = redirect_url
149+
else:
150+
# Not a redirect, we have our final response
151+
break
152+
153+
assert response is not None, "Response should not be None after redirect loop"
154+
return response
155+
156+
def _validate_url_and_access(self, url: str, trans: ProvidesUserContext):
157+
if not is_valid_url(url):
158+
raise RequestParameterInvalidException("Invalid URL format.")
159+
160+
validate_uri_access(url, trans.user_is_admin, trans.app.config.fetch_url_allowlist_ips)
161+
162+
def _is_redirect_response(self, response: httpx.Response) -> bool:
163+
return response.status_code in (301, 302, 303, 307, 308) and "location" in response.headers
164+
116165
def _validate_range_header(self, range_header: str) -> str:
117166
"""
118167
Validate the Range header format and values.

lib/galaxy_test/api/test_proxy.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
from unittest.mock import (
2+
AsyncMock,
3+
MagicMock,
4+
patch,
5+
)
6+
17
import pytest
28

39
from galaxy.util.unittest_utils import skip_if_github_down
@@ -88,3 +94,80 @@ def test_proxy_handles_encoding(self):
8894
assert len(response.content) > 0
8995
# Verify content-encoding header was properly filtered out (no double decompression)
9096
assert "content-encoding" not in response.headers
97+
98+
@patch("galaxy.webapps.galaxy.api.proxy.httpx.AsyncClient")
99+
def test_proxy_validates_redirects(self, mock_client_class):
100+
"""Test that redirects are validated."""
101+
# Create mock responses - redirect to a local file (invalid scheme)
102+
redirect_response = MagicMock()
103+
redirect_response.status_code = 302
104+
redirect_response.headers = {"location": "file://internal-server/secret-files"}
105+
redirect_response.aclose = AsyncMock()
106+
107+
# Setup mock client
108+
mock_client = MagicMock()
109+
mock_client.request = AsyncMock(return_value=redirect_response)
110+
mock_client.aclose = AsyncMock()
111+
mock_client_class.return_value = mock_client
112+
113+
# Attempt to proxy a URL that redirects to file:// (should be blocked)
114+
response = self._get("proxy?url=https://evil.com/redirect")
115+
116+
# Should fail with 400 Bad Request due to invalid redirect URL scheme
117+
self._assert_status_code_is(response, 400)
118+
assert "Invalid URL format" in response.json()["err_msg"]
119+
120+
@patch("galaxy.webapps.galaxy.api.proxy.httpx.AsyncClient")
121+
def test_proxy_follows_valid_redirects(self, mock_client_class):
122+
"""Test that valid redirects are followed after validation."""
123+
# Create mock responses
124+
redirect_response = MagicMock()
125+
redirect_response.status_code = 301
126+
redirect_response.headers = {"location": "https://example.com/final"}
127+
redirect_response.aclose = AsyncMock()
128+
129+
final_response = MagicMock()
130+
final_response.status_code = 200
131+
final_response.headers = {"content-type": "text/plain"}
132+
final_response.aclose = AsyncMock()
133+
134+
# Create async generator for streaming
135+
async def mock_stream():
136+
yield b"test content"
137+
138+
final_response.aiter_bytes = mock_stream
139+
140+
# Setup mock client to return redirect first, then final response
141+
mock_client = MagicMock()
142+
mock_client.request = AsyncMock(side_effect=[redirect_response, final_response])
143+
mock_client.aclose = AsyncMock()
144+
mock_client_class.return_value = mock_client
145+
146+
# Proxy a URL that redirects to a valid external URL
147+
response = self._get("proxy?url=https://example.com/redirect")
148+
149+
# Should succeed and follow the redirect
150+
self._assert_status_code_is_ok(response)
151+
assert b"test content" in response.content
152+
153+
@patch("galaxy.webapps.galaxy.api.proxy.httpx.AsyncClient")
154+
def test_proxy_blocks_too_many_redirects(self, mock_client_class):
155+
"""Test that excessive redirects are blocked to prevent redirect loops."""
156+
# Create a mock response that always redirects
157+
redirect_response = MagicMock()
158+
redirect_response.status_code = 302
159+
redirect_response.headers = {"location": "https://example.com/loop"}
160+
redirect_response.aclose = AsyncMock()
161+
162+
# Setup mock client
163+
mock_client = MagicMock()
164+
mock_client.request = AsyncMock(return_value=redirect_response)
165+
mock_client.aclose = AsyncMock()
166+
mock_client_class.return_value = mock_client
167+
168+
# Attempt to proxy a URL that loops redirects
169+
response = self._get("proxy?url=https://example.com/loop")
170+
171+
# Should fail with 400 Bad Request due to too many redirects
172+
self._assert_status_code_is(response, 400)
173+
assert "Too many redirects" in response.json()["err_msg"]

0 commit comments

Comments
 (0)