Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ open class KustoBaseApiClient(
init {
// Validate endpoint is trusted unless security checks are skipped
// Note: dmUrl might be empty/null in some test scenarios (e.g., mocked clients)
// The null check is required for Java interop - Java callers can pass null despite Kotlin's non-null type
// The null check is required for Java interop - Java callers can pass null despite Kotlin's
// non-null type
if (!skipSecurityChecks && dmUrl != null && dmUrl.isNotBlank()) {
KustoTrustedEndpoints.validateTrustedEndpoint(dmUrl)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,37 @@ package com.microsoft.azure.kusto.ingest.v2.auth.endpoints

/**
* Represents a matching rule for endpoint validation.
*
* @param suffix The suffix or hostname to match
* @param exact If true, the candidate must exactly match the suffix. If false, candidate must end with the suffix.
* @param exact If true, the candidate must exactly match the suffix. If false,
* candidate must end with the suffix.
*/
data class MatchRule(
val suffix: String,
val exact: Boolean,
) {
data class MatchRule(val suffix: String, val exact: Boolean) {
val suffixLength: Int
get() = suffix.length
}

/**
* Result of a match operation.
*
* @param isMatch Whether the candidate matched
* @param matchedRule The rule that matched, or null if no match
*/
data class MatchResult(
val isMatch: Boolean,
val matchedRule: MatchRule?,
)
data class MatchResult(val isMatch: Boolean, val matchedRule: MatchRule?)

/**
* A fast suffix matcher that efficiently matches hostnames against a set of rules.
* Uses a map indexed by suffix tail for O(1) lookup.
* A fast suffix matcher that efficiently matches hostnames against a set of
* rules. Uses a map indexed by suffix tail for O(1) lookup.
*/
class FastSuffixMatcher private constructor(
class FastSuffixMatcher
private constructor(
private val suffixLength: Int,
private val rules: Map<String, List<MatchRule>>,
) {
companion object {
/**
* Creates a new matcher with the provided matching rules.
*
* @param rules One or more matching rules to apply when match is called
* @return FastSuffixMatcher
*/
Expand All @@ -50,7 +49,9 @@ class FastSuffixMatcher private constructor(
val processedRules = mutableMapOf<String, MutableList<MatchRule>>()
for (rule in rules) {
val suffix = rule.suffix.takeLast(minRuleLength).lowercase()
processedRules.getOrPut(suffix) { mutableListOf() }.add(rule.copy())
processedRules
.getOrPut(suffix) { mutableListOf() }
.add(rule.copy())
}

return FastSuffixMatcher(minRuleLength, processedRules)
Expand All @@ -59,6 +60,7 @@ class FastSuffixMatcher private constructor(
/**
* Creates a new matcher with the provided matching rules, extending an
* existing matcher.
*
* @param existing An existing matcher whose rules are to be baseline
* @param rules One or more matching rules to apply when match is called
* @return FastSuffixMatcher
Expand All @@ -75,21 +77,22 @@ class FastSuffixMatcher private constructor(
return existing
}

val combinedRules =
rules + existing.rules.values.flatten()
val combinedRules = rules + existing.rules.values.flatten()
return create(combinedRules)
}
}

/**
* Checks if a candidate string matches any of the rules.
*
* @param candidate A string to match to the list of match rules
* @return true if at least one of the rules matched
*/
fun isMatch(candidate: String): Boolean = match(candidate).isMatch

/**
* Matches an input string to the list of match rules.
*
* @param candidate A string to match
* @return MatchResult with match status and the matched rule if any
*/
Expand All @@ -113,4 +116,4 @@ class FastSuffixMatcher private constructor(

return MatchResult(false, null)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,27 @@ import java.net.URI
import java.net.URISyntaxException

/**
* A helper class to determine which DNS names are "well-known/trusted"
* Kusto endpoints. Untrusted endpoints might require additional configuration
* before they can be used, for security reasons.
* A helper class to determine which DNS names are "well-known/trusted" Kusto
* endpoints. Untrusted endpoints might require additional configuration before
* they can be used, for security reasons.
*/
object KustoTrustedEndpoints {
private val logger = LoggerFactory.getLogger(KustoTrustedEndpoints::class.java)
private val logger =
LoggerFactory.getLogger(KustoTrustedEndpoints::class.java)

/**
* Global flag to enable/disable endpoint validation.
* When false, untrusted endpoints will only log a warning instead of
* throwing an exception.
* Global flag to enable/disable endpoint validation. When false, untrusted
* endpoints will only log a warning instead of throwing an exception.
*/
@JvmField
@Volatile
var enableWellKnownKustoEndpointsValidation: Boolean = true

private val matchers: MutableMap<String, FastSuffixMatcher> = mutableMapOf()

@Volatile
private var additionalMatcher: FastSuffixMatcher? = null
@Volatile private var additionalMatcher: FastSuffixMatcher? = null

@Volatile
private var overrideMatcher: ((String) -> Boolean)? = null
@Volatile private var overrideMatcher: ((String) -> Boolean)? = null

// Default login endpoint for public cloud
private const val DEFAULT_PUBLIC_LOGIN_ENDPOINT =
Expand All @@ -44,7 +42,8 @@ object KustoTrustedEndpoints {
try {
val endpointsData = WellKnownKustoEndpointsData.getInstance()

endpointsData.allowedEndpointsByLogin.forEach { (loginEndpoint, allowedEndpoints) ->
endpointsData.allowedEndpointsByLogin.forEach {
(loginEndpoint, allowedEndpoints) ->
val rules = mutableListOf<MatchRule>()

// Add suffix rules (exact = false)
Expand All @@ -58,7 +57,8 @@ object KustoTrustedEndpoints {
}

if (rules.isNotEmpty()) {
matchers[loginEndpoint.lowercase()] = FastSuffixMatcher.create(rules)
matchers[loginEndpoint.lowercase()] =
FastSuffixMatcher.create(rules)
}
}

Expand All @@ -74,6 +74,7 @@ object KustoTrustedEndpoints {

/**
* Sets an override policy for endpoint validation.
*
* @param matcher Rules that determine if a hostname is a valid/trusted
* Kusto endpoint (replaces existing rules)
*/
Expand All @@ -83,13 +84,11 @@ object KustoTrustedEndpoints {

/**
* Adds additional trusted hosts to the matcher.
*
* @param rules A set of rules
* @param replace If true, nullifies the last added rules
*/
fun addTrustedHosts(
rules: List<MatchRule>?,
replace: Boolean,
) {
fun addTrustedHosts(rules: List<MatchRule>?, replace: Boolean) {
if (rules.isNullOrEmpty()) {
if (replace) {
additionalMatcher = null
Expand All @@ -98,14 +97,20 @@ object KustoTrustedEndpoints {
}

additionalMatcher =
FastSuffixMatcher.create(if (replace) null else additionalMatcher, rules)
FastSuffixMatcher.create(
if (replace) null else additionalMatcher,
rules,
)
}

/**
* Validates that the endpoint is trusted.
*
* @param uri Kusto endpoint URI string
* @param loginEndpoint The login endpoint to check against (optional, defaults to public cloud)
* @throws KustoClientInvalidConnectionStringException if endpoint is not trusted
* @param loginEndpoint The login endpoint to check against (optional,
* defaults to public cloud)
* @throws KustoClientInvalidConnectionStringException if endpoint is not
* trusted
*/
fun validateTrustedEndpoint(
uri: String,
Expand All @@ -114,29 +119,34 @@ object KustoTrustedEndpoints {
try {
validateTrustedEndpoint(URI(uri), loginEndpoint)
} catch (ex: URISyntaxException) {
throw KustoClientInvalidConnectionStringException(uri, ex.message ?: "Invalid URI", ex)
throw KustoClientInvalidConnectionStringException(
uri,
ex.message ?: "Invalid URI",
ex,
)
}
}

/**
* Validates that the endpoint is trusted.
*
* @param uri Kusto endpoint URI
* @param loginEndpoint The login endpoint to check against
* @throws KustoClientInvalidConnectionStringException if endpoint is not trusted
* @throws KustoClientInvalidConnectionStringException if endpoint is not
* trusted
*/
fun validateTrustedEndpoint(
uri: URI,
loginEndpoint: String,
) {
fun validateTrustedEndpoint(uri: URI, loginEndpoint: String) {
val host = uri.host ?: uri.toString()
validateHostnameIsTrusted(host, loginEndpoint)
}

/**
* Validates that a hostname is trusted.
*
* @param hostname The hostname to validate
* @param loginEndpoint The login endpoint to check against
* @throws KustoClientInvalidConnectionStringException if hostname is not trusted
* @throws KustoClientInvalidConnectionStringException if hostname is not
* trusted
*/
private fun validateHostnameIsTrusted(
hostname: String,
Expand Down Expand Up @@ -184,9 +194,7 @@ object KustoTrustedEndpoints {
)
}

/**
* Checks if the hostname is a local/loopback address.
*/
/** Checks if the hostname is a local/loopback address. */
private fun isLocalAddress(hostname: String): Boolean {
val lowerHost = hostname.lowercase()
return lowerHost == "localhost" ||
Expand All @@ -198,6 +206,7 @@ object KustoTrustedEndpoints {

/**
* Checks if a hostname is trusted without throwing an exception.
*
* @param hostname The hostname to check
* @param loginEndpoint The login endpoint to check against
* @return true if the hostname is trusted
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@
package com.microsoft.azure.kusto.ingest.v2.auth.endpoints

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable as KSerializable
import kotlinx.serialization.json.Json
import org.slf4j.LoggerFactory
import kotlinx.serialization.Serializable as KSerializable

/**
* Data class representing the structure of WellKnownKustoEndpoints.json
*/
/** Data class representing the structure of WellKnownKustoEndpoints.json */
@KSerializable
data class AllowedEndpoints(
@SerialName("AllowedKustoSuffixes")
Expand All @@ -20,39 +18,49 @@ data class AllowedEndpoints(

@KSerializable
data class WellKnownKustoEndpointsData(
@SerialName("_Comments")
val comments: List<String> = emptyList(),
@SerialName("_Comments") val comments: List<String> = emptyList(),
@SerialName("AllowedEndpointsByLogin")
val allowedEndpointsByLogin: Map<String, AllowedEndpoints> = emptyMap(),
) {
companion object {
private val logger = LoggerFactory.getLogger(WellKnownKustoEndpointsData::class.java)

@Volatile
private var instance: WellKnownKustoEndpointsData? = null
private val logger =
LoggerFactory.getLogger(WellKnownKustoEndpointsData::class.java)

@Volatile private var instance: WellKnownKustoEndpointsData? = null

private val json = Json {
ignoreUnknownKeys = true
isLenient = true
}

fun getInstance(): WellKnownKustoEndpointsData {
return instance ?: synchronized(this) {
instance ?: readInstance().also { instance = it }
}
return instance
?: synchronized(this) {
instance ?: readInstance().also { instance = it }
}
}

private fun readInstance(): WellKnownKustoEndpointsData {
return try {
val resourceStream = WellKnownKustoEndpointsData::class.java
.getResourceAsStream("/WellKnownKustoEndpoints.json")
?: throw RuntimeException("WellKnownKustoEndpoints.json not found in classpath")

val content = resourceStream.bufferedReader().use { it.readText() }
val resourceStream =
WellKnownKustoEndpointsData::class
.java
.getResourceAsStream(
"/WellKnownKustoEndpoints.json",
)
?: throw RuntimeException(
"WellKnownKustoEndpoints.json not found in classpath",
)

val content =
resourceStream.bufferedReader().use { it.readText() }
json.decodeFromString<WellKnownKustoEndpointsData>(content)
} catch (ex: Exception) {
logger.error("Failed to read WellKnownKustoEndpoints.json", ex)
throw RuntimeException("Failed to read WellKnownKustoEndpoints.json", ex)
throw RuntimeException(
"Failed to read WellKnownKustoEndpoints.json",
ex,
)
}
}
}
Expand Down
Loading
Loading