|
| 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