Skip to content

Commit 6d4e41c

Browse files
committed
fix(security): add URL scheme validation to prevent credential interception
AWS credentials must be transmitted over HTTPS to prevent interception via man-in-the-middle attacks. This adds validation to reject HTTP endpoints for remote hosts while allowing HTTP for localhost during local development. - Add validate_endpoint_url() function to utils.py - Integrate validation into get_service_name_and_region_from_endpoint() - Integrate validation into aws_iam_streamablehttp_client() - Add comprehensive tests for URL scheme validation 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent d48aa77 commit 6d4e41c

File tree

4 files changed

+180
-5
lines changed

4 files changed

+180
-5
lines changed

mcp_proxy_for_aws/client.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client
2323
from mcp.shared.message import SessionMessage
2424
from mcp_proxy_for_aws.sigv4_helper import SigV4HTTPXAuth
25+
from mcp_proxy_for_aws.utils import validate_endpoint_url
2526
from typing import Optional
2627

2728

@@ -53,7 +54,8 @@ def aws_iam_streamablehttp_client(
5354
authentication via SigV4 signing. Use with 'async with' to manage the connection lifecycle.
5455
5556
Args:
56-
endpoint: The URL of the MCP server to connect to. Must be a valid HTTP/HTTPS URL.
57+
endpoint: The URL of the MCP server to connect to. Must use HTTPS for remote endpoints
58+
(HTTP is allowed for localhost during development).
5759
aws_service: The name of the AWS service the MCP server is hosted on, e.g. "bedrock-agentcore".
5860
aws_region: The AWS region name of the MCP server, e.g. "us-west-2".
5961
aws_profile: The AWS profile to use for authentication.
@@ -81,6 +83,9 @@ def aws_iam_streamablehttp_client(
8183
"""
8284
logger.debug('Preparing AWS IAM MCP client for endpoint: %s', endpoint)
8385

86+
# Validate URL scheme for security - AWS credentials must be transmitted over HTTPS
87+
validate_endpoint_url(endpoint)
88+
8489
if credentials is not None:
8590
creds = credentials
8691
region = aws_region

mcp_proxy_for_aws/utils.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,45 @@
2727
logger = logging.getLogger(__name__)
2828

2929

30+
def validate_endpoint_url(endpoint: str, allow_localhost_http: bool = True) -> None:
31+
"""Validate endpoint URL scheme for secure credential transmission.
32+
33+
AWS credentials are sent via SigV4 signing headers, so endpoints must use HTTPS
34+
to prevent credential interception via man-in-the-middle attacks.
35+
36+
Args:
37+
endpoint: The endpoint URL to validate
38+
allow_localhost_http: Whether to allow HTTP for localhost (default: True)
39+
40+
Raises:
41+
ValueError: If URL scheme is not HTTPS (or HTTP for allowed localhost)
42+
"""
43+
parsed = urlparse(endpoint)
44+
45+
if not parsed.scheme:
46+
raise ValueError(
47+
f"Invalid endpoint URL '{endpoint}': missing URL scheme. "
48+
"Use https:// prefix for secure connections."
49+
)
50+
51+
if parsed.scheme == 'https':
52+
return # Valid
53+
54+
if parsed.scheme == 'http':
55+
if allow_localhost_http and parsed.hostname in ('localhost', '127.0.0.1', '::1'):
56+
return # Allow HTTP for local development
57+
raise ValueError(
58+
f"Invalid endpoint URL '{endpoint}': HTTP is not allowed for remote endpoints. "
59+
"AWS credentials must be transmitted over HTTPS to prevent interception. "
60+
"Use https:// instead."
61+
)
62+
63+
raise ValueError(
64+
f"Invalid endpoint URL '{endpoint}': unsupported scheme '{parsed.scheme}'. "
65+
"Only HTTPS is supported for secure credential transmission."
66+
)
67+
68+
3069
def create_transport_with_sigv4(
3170
url: str,
3271
service: str,
@@ -78,16 +117,23 @@ def get_service_name_and_region_from_endpoint(endpoint: str) -> Tuple[str, str]:
78117
"""Extract service name and region from an endpoint URL.
79118
80119
Args:
81-
endpoint: The endpoint URL to parse
120+
endpoint: The endpoint URL to parse (must use HTTPS for remote endpoints)
82121
83122
Returns:
84123
Tuple of (service_name, region). Either value may be empty string if not found.
85124
125+
Raises:
126+
ValueError: If endpoint URL does not use HTTPS (except localhost for development)
127+
86128
Notes:
129+
- Validates URL scheme is HTTPS before parsing
87130
- Matches bedrock-agentcore endpoints (gateway and runtime)
88131
- Matches AWS API Gateway endpoints (service.region.api.aws)
89132
- Falls back to extracting first hostname segment as service name
90133
"""
134+
# Validate URL scheme for security
135+
validate_endpoint_url(endpoint)
136+
91137
# Parse AWS service from endpoint URL
92138
parsed = urlparse(endpoint)
93139
hostname = parsed.hostname or ''

tests/unit/test_client.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,3 +279,39 @@ async def test_credentials_parameter_bypasses_boto3_session(mock_streams):
279279
pass
280280

281281
mock_boto.assert_not_called()
282+
283+
284+
@pytest.mark.asyncio
285+
async def test_http_endpoint_raises_security_error():
286+
"""Test that HTTP endpoints raise ValueError for security.
287+
288+
AWS credentials must be transmitted over HTTPS to prevent interception.
289+
This test verifies the client rejects HTTP endpoints for remote hosts.
290+
"""
291+
with pytest.raises(ValueError, match='HTTP is not allowed'):
292+
async with aws_iam_streamablehttp_client(
293+
endpoint='http://example.com/mcp',
294+
aws_service='bedrock-agentcore',
295+
aws_region='us-west-2',
296+
):
297+
pass
298+
299+
300+
@pytest.mark.asyncio
301+
async def test_http_localhost_endpoint_allowed(mock_session, mock_streams):
302+
"""Test that HTTP localhost endpoints are allowed for local development."""
303+
mock_read, mock_write, mock_get_session = mock_streams
304+
305+
with patch('boto3.Session', return_value=mock_session):
306+
with patch('mcp_proxy_for_aws.client.streamablehttp_client') as mock_stream_client:
307+
mock_stream_client.return_value.__aenter__ = AsyncMock(
308+
return_value=(mock_read, mock_write, mock_get_session)
309+
)
310+
mock_stream_client.return_value.__aexit__ = AsyncMock(return_value=None)
311+
312+
# Should not raise - localhost HTTP is allowed for development
313+
async with aws_iam_streamablehttp_client(
314+
endpoint='http://localhost:8080/mcp',
315+
aws_service='bedrock-agentcore',
316+
):
317+
pass

tests/unit/test_utils.py

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,98 @@
2020
create_transport_with_sigv4,
2121
determine_aws_region,
2222
determine_service_name,
23+
validate_endpoint_url,
2324
)
2425
from unittest.mock import MagicMock, patch
2526

2627

28+
class TestValidateEndpointUrl:
29+
"""Test cases for validate_endpoint_url function."""
30+
31+
def test_https_url_passes(self):
32+
"""HTTPS URLs should pass validation."""
33+
validate_endpoint_url('https://example.com/mcp')
34+
# No exception = pass
35+
36+
def test_https_with_port_passes(self):
37+
"""HTTPS URLs with explicit port should pass."""
38+
validate_endpoint_url('https://example.com:443/mcp')
39+
40+
def test_https_aws_endpoints_pass(self):
41+
"""HTTPS URLs for AWS endpoints should pass."""
42+
aws_endpoints = [
43+
'https://test-service.us-west-2.api.aws/mcp',
44+
'https://bedrock-agentcore.us-east-1.amazonaws.com',
45+
'https://gateway.bedrock-agentcore.us-west-2.amazonaws.com/mcp',
46+
]
47+
for endpoint in aws_endpoints:
48+
validate_endpoint_url(endpoint) # No exception = pass
49+
50+
def test_http_remote_raises_error(self):
51+
"""HTTP URLs for remote hosts should raise ValueError."""
52+
with pytest.raises(ValueError) as exc_info:
53+
validate_endpoint_url('http://example.com/mcp')
54+
assert 'HTTP is not allowed' in str(exc_info.value)
55+
assert 'HTTPS' in str(exc_info.value)
56+
57+
def test_http_remote_error_includes_url(self):
58+
"""Error message should include the invalid URL."""
59+
with pytest.raises(ValueError) as exc_info:
60+
validate_endpoint_url('http://remote-server.example.com/mcp')
61+
assert 'remote-server.example.com' in str(exc_info.value)
62+
63+
def test_http_localhost_allowed(self):
64+
"""HTTP URLs for localhost should pass by default."""
65+
validate_endpoint_url('http://localhost:8080/mcp')
66+
validate_endpoint_url('http://127.0.0.1:8080/mcp')
67+
validate_endpoint_url('http://[::1]:8080/mcp')
68+
69+
def test_http_localhost_without_port_allowed(self):
70+
"""HTTP URLs for localhost without port should pass."""
71+
validate_endpoint_url('http://localhost/mcp')
72+
validate_endpoint_url('http://127.0.0.1/mcp')
73+
74+
def test_http_localhost_disallowed_when_strict(self):
75+
"""HTTP localhost should fail when allow_localhost_http=False."""
76+
with pytest.raises(ValueError) as exc_info:
77+
validate_endpoint_url('http://localhost:8080/mcp', allow_localhost_http=False)
78+
assert 'HTTP is not allowed' in str(exc_info.value)
79+
80+
def test_missing_scheme_raises_error(self):
81+
"""URLs without scheme should raise ValueError."""
82+
with pytest.raises(ValueError) as exc_info:
83+
validate_endpoint_url('example.com/mcp')
84+
assert 'missing URL scheme' in str(exc_info.value)
85+
86+
def test_unsupported_scheme_ftp_raises_error(self):
87+
"""FTP scheme should raise ValueError."""
88+
with pytest.raises(ValueError) as exc_info:
89+
validate_endpoint_url('ftp://example.com/mcp')
90+
assert 'unsupported scheme' in str(exc_info.value)
91+
assert 'ftp' in str(exc_info.value)
92+
93+
def test_unsupported_scheme_file_raises_error(self):
94+
"""File scheme should raise ValueError."""
95+
with pytest.raises(ValueError) as exc_info:
96+
validate_endpoint_url('file:///path/to/file')
97+
assert 'unsupported scheme' in str(exc_info.value)
98+
assert 'file' in str(exc_info.value)
99+
100+
def test_unsupported_scheme_ws_raises_error(self):
101+
"""WebSocket scheme should raise ValueError."""
102+
with pytest.raises(ValueError) as exc_info:
103+
validate_endpoint_url('ws://example.com/mcp')
104+
assert 'unsupported scheme' in str(exc_info.value)
105+
assert 'ws' in str(exc_info.value)
106+
107+
def test_unsupported_scheme_wss_raises_error(self):
108+
"""Secure WebSocket scheme should raise ValueError."""
109+
with pytest.raises(ValueError) as exc_info:
110+
validate_endpoint_url('wss://example.com/mcp')
111+
assert 'unsupported scheme' in str(exc_info.value)
112+
assert 'wss' in str(exc_info.value)
113+
114+
27115
class TestCreateTransportWithSigv4:
28116
"""Test cases for create_transport_with_sigv4 function (line 129)."""
29117

@@ -183,15 +271,15 @@ def test_validate_service_name_without_service_failure(self):
183271
assert '--service argument' in str(exc_info.value)
184272

185273
def test_validate_service_name_invalid_url_failure(self):
186-
"""Test validation with invalid URL."""
274+
"""Test validation with invalid URL raises scheme validation error."""
187275
endpoint = 'not-a-url'
188276

189277
with pytest.raises(ValueError) as exc_info:
190278
determine_service_name(endpoint)
191279

192-
assert 'Could not determine AWS service name' in str(exc_info.value)
280+
# URL validation now catches this first with a scheme error
281+
assert 'missing URL scheme' in str(exc_info.value)
193282
assert endpoint in str(exc_info.value)
194-
assert '--service argument' in str(exc_info.value)
195283

196284

197285
class TestDetermineRegion:

0 commit comments

Comments
 (0)