Skip to content

Commit 127b8b4

Browse files
committed
fix: enforce trusted-endpoint validation for all authentication methods
1 parent 5a8a712 commit 127b8b4

3 files changed

Lines changed: 120 additions & 16 deletions

File tree

‎azure-kusto-data/azure/kusto/data/client_base.py‎

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from requests import Response, Session
1010

1111
from azure.kusto.data._cloud_settings import CloudSettings
12-
from azure.kusto.data._token_providers import CloudInfoTokenProvider
1312
from .client_details import ClientDetails
1413
from .client_request_properties import ClientRequestProperties
1514
from .exceptions import KustoServiceError, KustoThrottlingError, KustoApiError
@@ -78,16 +77,20 @@ def set_proxy(self, proxy_url: str):
7877

7978
def validate_endpoint(self):
8079
if not self._endpoint_validated and self._aad_helper is not None:
81-
if isinstance(self._aad_helper.token_provider, CloudInfoTokenProvider):
82-
endpoint = CloudSettings.get_cloud_info_for_cluster(
83-
self._kusto_cluster,
84-
self._aad_helper.token_provider._proxy_dict,
85-
self._session if isinstance(self._session, Session) else None,
86-
).login_endpoint
87-
well_known_kusto_endpoints.validate_trusted_endpoint(
88-
self._kusto_cluster,
89-
endpoint,
90-
)
80+
# Trusted-endpoint validation must run for every authentication method. Gating it on the
81+
# token provider type let token-based and callback-based flows send the Authorization
82+
# header to an arbitrary host named in the connection string.
83+
# The login endpoint is resolved lazily because doing so contacts the cluster itself.
84+
well_known_kusto_endpoints.validate_trusted_endpoint(
85+
self._kusto_cluster,
86+
lambda: (
87+
CloudSettings.get_cloud_info_for_cluster(
88+
self._kusto_cluster,
89+
self._aad_helper.token_provider._proxy_dict,
90+
self._session if isinstance(self._session, Session) else None,
91+
).login_endpoint
92+
),
93+
)
9194
self._endpoint_validated = True
9295

9396
@staticmethod

‎azure-kusto-data/azure/kusto/data/kusto_trusted_endpoints.py‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import copy
2-
from typing import List, Dict
2+
from typing import Callable, List, Dict, Union
33
from urllib.parse import urlparse
44

55
from azure.kusto.data.helpers import get_string_tail_lower_case
@@ -73,18 +73,30 @@ def add_trusted_hosts(self, rules, replace):
7373

7474
self._additional_matcher = create_fast_suffix_matcher_from_existing(rules, None if replace else self._additional_matcher)
7575

76-
def validate_trusted_endpoint(self, endpoint: str, login_endpoint: str):
76+
def validate_trusted_endpoint(self, endpoint: str, login_endpoint: Union[str, Callable[[], str]]):
77+
"""Validates that the endpoint is trusted.
78+
79+
`login_endpoint` may be a callable, in which case it is only resolved if the built-in
80+
per-cloud allow lists actually have to be consulted. Resolving it may require contacting
81+
the cluster itself, so callers should pass a callable to avoid reaching out to hosts that
82+
can never be trusted.
83+
"""
7784
hostname = urlparse(endpoint).hostname
7885
self.validate_hostname_is_trusted(hostname if hostname is not None else endpoint, login_endpoint)
7986

80-
def validate_hostname_is_trusted(self, hostname: str, login_endpoint: str):
87+
def validate_hostname_is_trusted(self, hostname: str, login_endpoint: Union[str, Callable[[], str]]):
8188
if _is_local_address(hostname):
8289
return
8390
if self._override_matcher is not None:
8491
if self._override_matcher(hostname):
8592
return
86-
else:
87-
matcher = self._matchers.get(login_endpoint.lower())
93+
elif any(matcher.is_match(hostname) for matcher in self._matchers.values()):
94+
# Only resolve the login endpoint once the hostname is known to appear in at least one
95+
# cloud's allow list. Resolving it may contact the cluster, so doing it first would let
96+
# an untrusted connection string drive a request to an arbitrary host before it is
97+
# rejected.
98+
resolved_login_endpoint = login_endpoint() if callable(login_endpoint) else login_endpoint
99+
matcher = self._matchers.get(resolved_login_endpoint.lower())
88100
if matcher is not None and matcher.is_match(hostname):
89101
return
90102

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License
3+
"""Trusted-endpoint validation must be enforced for every authentication method.
4+
5+
Token-based and callback-based authentication used to bypass validation entirely, which let a
6+
connection string send its Authorization header to an arbitrary host.
7+
"""
8+
9+
import asyncio
10+
from unittest.mock import patch
11+
12+
import pytest
13+
14+
from azure.kusto.data import KustoClient, KustoConnectionStringBuilder
15+
from azure.kusto.data.exceptions import KustoClientInvalidConnectionStringException
16+
from azure.kusto.data.kusto_trusted_endpoints import MatchRule, well_known_kusto_endpoints
17+
18+
UNTRUSTED_HOST = "https://kusto.attacker.example.com"
19+
TRUSTED_HOST = "https://somecluster.kusto.windows.net"
20+
TOKEN = "a token that must never leave the machine"
21+
22+
23+
def _bypassing_auth_kcsbs(cluster: str):
24+
"""Connection strings whose token providers do not derive from CloudInfoTokenProvider."""
25+
return {
26+
"user_token": KustoConnectionStringBuilder.with_aad_user_token_authentication(cluster, TOKEN),
27+
"application_token": KustoConnectionStringBuilder.with_aad_application_token_authentication(cluster, TOKEN),
28+
"token_provider": KustoConnectionStringBuilder.with_token_provider(cluster, lambda: TOKEN),
29+
"async_token_provider": KustoConnectionStringBuilder.with_async_token_provider(cluster, lambda: asyncio.sleep(0, result=TOKEN)),
30+
}
31+
32+
33+
@pytest.fixture(params=["user_token", "application_token", "token_provider", "async_token_provider"])
34+
def bypassing_auth_name(request):
35+
return request.param
36+
37+
38+
class TestEndpointValidation:
39+
def test_untrusted_host_is_rejected_for_token_based_auth(self, bypassing_auth_name):
40+
kcsb = _bypassing_auth_kcsbs(UNTRUSTED_HOST)[bypassing_auth_name]
41+
with KustoClient(kcsb) as client:
42+
with pytest.raises(KustoClientInvalidConnectionStringException):
43+
client.execute_query("PythonTest", "Deft")
44+
45+
def test_untrusted_host_is_rejected_before_any_network_call(self, bypassing_auth_name):
46+
"""Validation must not contact the untrusted host, otherwise it is an SSRF primitive."""
47+
kcsb = _bypassing_auth_kcsbs(UNTRUSTED_HOST)[bypassing_auth_name]
48+
with patch("requests.get") as mock_get, patch("requests.Session.get") as mock_session_get, patch("requests.Session.post") as mock_post:
49+
with KustoClient(kcsb) as client:
50+
with pytest.raises(KustoClientInvalidConnectionStringException):
51+
client.execute_query("PythonTest", "Deft")
52+
assert not mock_get.called
53+
assert not mock_session_get.called
54+
assert not mock_post.called
55+
56+
def test_explicitly_trusted_host_needs_no_cloud_metadata(self):
57+
"""Hosts trusted via add_trusted_hosts must not require the metadata endpoint."""
58+
try:
59+
well_known_kusto_endpoints.add_trusted_hosts([MatchRule("kusto.attacker.example.com", True)], False)
60+
resolved = []
61+
62+
def resolver():
63+
resolved.append(True)
64+
return "https://login.microsoftonline.com"
65+
66+
well_known_kusto_endpoints.validate_trusted_endpoint(UNTRUSTED_HOST, resolver)
67+
assert not resolved
68+
finally:
69+
well_known_kusto_endpoints.add_trusted_hosts(None, True)
70+
71+
def test_login_endpoint_resolved_only_for_allow_listed_hosts(self):
72+
resolved = []
73+
74+
def resolver():
75+
resolved.append(True)
76+
return "https://login.microsoftonline.com"
77+
78+
well_known_kusto_endpoints.validate_trusted_endpoint(TRUSTED_HOST, resolver)
79+
assert resolved
80+
81+
resolved.clear()
82+
with pytest.raises(KustoClientInvalidConnectionStringException):
83+
well_known_kusto_endpoints.validate_trusted_endpoint(UNTRUSTED_HOST, resolver)
84+
assert not resolved
85+
86+
def test_plain_string_login_endpoint_still_supported(self):
87+
well_known_kusto_endpoints.validate_trusted_endpoint(TRUSTED_HOST, "https://login.microsoftonline.com")
88+
with pytest.raises(KustoClientInvalidConnectionStringException):
89+
well_known_kusto_endpoints.validate_trusted_endpoint(UNTRUSTED_HOST, "https://login.microsoftonline.com")

0 commit comments

Comments
 (0)