parent
2542c9cae1
commit
ee6561c185
13 changed files with 1189 additions and 59 deletions
|
|
@ -2713,6 +2713,7 @@ public abstract interface annotation class io/modelcontextprotocol/kotlin/sdk/ty
|
|||
}
|
||||
|
||||
public final class io/modelcontextprotocol/kotlin/sdk/types/McpException : java/lang/Exception {
|
||||
public fun <init> (I)V
|
||||
public fun <init> (ILjava/lang/String;)V
|
||||
public fun <init> (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;)V
|
||||
public fun <init> (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;Ljava/lang/Throwable;)V
|
||||
|
|
@ -3310,6 +3311,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/RPCError$ErrorCode {
|
|||
public static final field METHOD_NOT_FOUND I
|
||||
public static final field PARSE_ERROR I
|
||||
public static final field REQUEST_TIMEOUT I
|
||||
public static final field RESOURCE_NOT_FOUND I
|
||||
}
|
||||
|
||||
public final class io/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest {
|
||||
|
|
@ -4731,3 +4733,36 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/WithMeta$DefaultImpl
|
|||
public static fun get_meta (Lio/modelcontextprotocol/kotlin/sdk/types/WithMeta;)Lkotlinx/serialization/json/JsonObject;
|
||||
}
|
||||
|
||||
public final class io/modelcontextprotocol/kotlin/sdk/utils/MatchResult {
|
||||
public fun <init> (Ljava/util/Map;I)V
|
||||
public fun equals (Ljava/lang/Object;)Z
|
||||
public final fun getScore ()I
|
||||
public final fun getVariables ()Ljava/util/Map;
|
||||
public fun hashCode ()I
|
||||
public fun toString ()Ljava/lang/String;
|
||||
}
|
||||
|
||||
public final class io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher : io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher {
|
||||
public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher$Companion;
|
||||
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;)V
|
||||
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;I)V
|
||||
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;II)V
|
||||
public synthetic fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V
|
||||
public static final fun getFactory ()Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;
|
||||
public fun getResourceTemplate ()Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;
|
||||
public fun match (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/utils/MatchResult;
|
||||
}
|
||||
|
||||
public final class io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher$Companion {
|
||||
public final fun getFactory ()Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;
|
||||
}
|
||||
|
||||
public abstract interface class io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher {
|
||||
public abstract fun getResourceTemplate ()Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;
|
||||
public abstract fun match (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/utils/MatchResult;
|
||||
}
|
||||
|
||||
public abstract interface class io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory {
|
||||
public abstract fun create (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;)Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import kotlinx.serialization.json.Json
|
|||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
|
|
@ -312,6 +313,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
|
|||
return
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught", "InstanceOfCheckForException")
|
||||
try {
|
||||
val result = handler(request, RequestHandlerExtra())
|
||||
logger.trace { "Request handled successfully: ${request.method} (id: ${request.id})" }
|
||||
|
|
@ -322,19 +324,20 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
|
|||
result = result ?: EmptyResult(),
|
||||
),
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (cause: Throwable) {
|
||||
logger.error(cause) { "Error handling request: ${request.method} (id: ${request.id})" }
|
||||
|
||||
try {
|
||||
transport?.send(
|
||||
JSONRPCError(
|
||||
id = request.id,
|
||||
error = RPCError(
|
||||
code = RPCError.ErrorCode.INTERNAL_ERROR,
|
||||
message = cause.message ?: "Internal error",
|
||||
),
|
||||
),
|
||||
)
|
||||
val rpcError = if (cause is McpException) {
|
||||
RPCError(code = cause.code, message = cause.message.orEmpty(), data = cause.data)
|
||||
} else {
|
||||
RPCError(code = RPCError.ErrorCode.INTERNAL_ERROR, message = cause.message ?: "Internal error")
|
||||
}
|
||||
transport?.send(JSONRPCError(id = request.id, error = rpcError))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (sendError: Throwable) {
|
||||
logger.error(sendError) {
|
||||
"Failed to send error response for request: ${request.method} (id: ${request.id})"
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@ import kotlin.jvm.JvmOverloads
|
|||
*
|
||||
* @property code The MCP/JSON‑RPC error code.
|
||||
* @property data Optional additional error payload as a JSON element; `null` when not provided.
|
||||
* @param message The error message.
|
||||
* @param message The error message. Used verbatim as [Exception.message] — no error code prefix is prepended.
|
||||
* Defaults to `"MCP error $code"` when not provided.
|
||||
* @param cause The original cause.
|
||||
*/
|
||||
public class McpException @JvmOverloads public constructor(
|
||||
public val code: Int,
|
||||
message: String,
|
||||
message: String = "MCP error $code",
|
||||
public val data: JsonElement? = null,
|
||||
cause: Throwable? = null,
|
||||
) : Exception("MCP error $code: $message", cause)
|
||||
) : Exception(message, cause)
|
||||
|
|
|
|||
|
|
@ -251,6 +251,9 @@ public data class RPCError(val code: Int, val message: String, val data: JsonEle
|
|||
/** Request timed out */
|
||||
public const val REQUEST_TIMEOUT: Int = -32001
|
||||
|
||||
/** Resource not found */
|
||||
public const val RESOURCE_NOT_FOUND: Int = -32002
|
||||
|
||||
// Standard JSON-RPC 2.0 error codes
|
||||
|
||||
/** Invalid JSON was received */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.utils
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.ktor.http.decodeURLPart
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplate
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlin.jvm.JvmOverloads
|
||||
import kotlin.jvm.JvmStatic
|
||||
|
||||
// Max URL/path length to prevent DoS or unexpected large payloads.
|
||||
private const val MAX_URL_LENGTH = 2048
|
||||
|
||||
// Max template/uri depth to prevent overly nested or complex templates.
|
||||
private const val MAX_DEPTH = 50
|
||||
|
||||
// Literal segments are more specific than variable captures.
|
||||
private const val LITERAL_MATCH_SCORE = 2
|
||||
private const val VARIABLE_MATCH_SCORE = 1
|
||||
|
||||
/**
|
||||
* A [ResourceTemplateMatcher] that matches resource URIs against an RFC 6570 Level 1
|
||||
* URI template by splitting both the URI and the template on `/` and comparing each
|
||||
* segment in order.
|
||||
*
|
||||
* ### Supported template syntax
|
||||
*
|
||||
* Only RFC 6570 **Level 1** is supported: simple `{variable}` expressions where the
|
||||
* entire path segment is a single variable. Operator expressions (`{+var}`, `{#var}`,
|
||||
* `{.var}`, `{/var}`, etc.) and multi-variable expressions (`{a,b}`) are **not**
|
||||
* recognized — segments containing them are treated as literals.
|
||||
*
|
||||
* ### Matching rules
|
||||
*
|
||||
* - The URI and template must have the same number of `/`-delimited segments.
|
||||
* - Literal segments must match exactly (after percent-decoding the URI segment).
|
||||
* - `{variable}` segments capture the percent-decoded URI segment value.
|
||||
* - Query strings and fragments (`?`, `#`) are **not** stripped — they become part of
|
||||
* the captured variable value for the segment that contains them.
|
||||
*
|
||||
* ### Specificity scoring
|
||||
*
|
||||
* When multiple templates match the same URI, each matched literal segment contributes
|
||||
* 2 points and each variable capture contributes 1 point. The highest-scoring match wins.
|
||||
*
|
||||
* ### Safety limits
|
||||
*
|
||||
* - URIs longer than [maxUriLength] characters are rejected.
|
||||
* - Templates and URIs with more than [maxDepth] segments are rejected.
|
||||
*
|
||||
* ### Security contract for handler authors
|
||||
*
|
||||
* Values in [MatchResult.variables] are attacker-controlled strings extracted from
|
||||
* the incoming URI. They are percent-decoded (one pass only) before being returned.
|
||||
* Handlers **must** treat them as untrusted input and validate or sanitize them
|
||||
* before using them to construct file paths, database queries, downstream URLs, or
|
||||
* any other security-sensitive operation.
|
||||
*
|
||||
* In particular:
|
||||
* - A decoded value may contain `/`, `..`, null bytes (`\u0000`), `?`, or `#`.
|
||||
* - `%252F` in the URI becomes `%2F` in the variable — exactly one decode pass is applied.
|
||||
*
|
||||
* ### Platform limitations
|
||||
*
|
||||
* Dot-segment normalization (resolving `..` and `.` in the URI path) is performed
|
||||
* on JVM and JS targets using the platform-native URI parser. On native and WASM targets
|
||||
* no normalizer is available, so dot-segment traversal is **not** mitigated at this
|
||||
* layer — handlers on those targets must normalize paths themselves.
|
||||
*
|
||||
* @param resourceTemplate The resource template to match against. Must follow RFC 6570 Level 1 syntax.
|
||||
* @param maxUriLength Maximum allowed length for incoming URIs. Defaults to 2048.
|
||||
* @param maxDepth Maximum allowed segment count for the template/uri. Defaults to 50.
|
||||
*
|
||||
* @property resourceTemplate The resource template against which resource URIs will be matched.
|
||||
*/
|
||||
public class PathSegmentTemplateMatcher @JvmOverloads constructor(
|
||||
override val resourceTemplate: ResourceTemplate,
|
||||
private val maxDepth: Int = MAX_DEPTH,
|
||||
private val maxUriLength: Int = MAX_URL_LENGTH,
|
||||
) : ResourceTemplateMatcher {
|
||||
|
||||
public companion object {
|
||||
/**
|
||||
* A [ResourceTemplateMatcherFactory] that creates [PathSegmentTemplateMatcher] instances
|
||||
* with default limits. Pass this to [io.modelcontextprotocol.kotlin.sdk.server.ServerOptions]
|
||||
* to use path-segment matching, or supply a custom factory to override the matching strategy.
|
||||
*/
|
||||
@JvmStatic
|
||||
public val factory: ResourceTemplateMatcherFactory = ResourceTemplateMatcherFactory {
|
||||
PathSegmentTemplateMatcher(it)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
private val logger = KotlinLogging.logger {}
|
||||
}
|
||||
|
||||
private val templateParts: List<String>
|
||||
|
||||
// Maps segment index to variable name; indices absent from this map are literal segments.
|
||||
private val variableIndices: Map<Int, String>
|
||||
|
||||
init {
|
||||
val template = resourceTemplate.uriTemplate
|
||||
require(template.isNotBlank()) { "Resource template cannot be blank" }
|
||||
templateParts = template.trim('/').split("/")
|
||||
require(templateParts.size <= maxDepth) {
|
||||
"Template is too complex (max depth=$maxDepth)"
|
||||
}
|
||||
|
||||
val vars = mutableMapOf<Int, String>()
|
||||
for (i in templateParts.indices) {
|
||||
val segment = templateParts[i]
|
||||
if (segment.startsWith("{") && segment.endsWith("}")) {
|
||||
val name = segment.removeSurrounding("{", "}").trim()
|
||||
require(name.isNotEmpty()) { "Invalid variable name in template: $segment" }
|
||||
vars[i] = name
|
||||
}
|
||||
}
|
||||
variableIndices = vars.toImmutableMap()
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
override fun match(resourceUri: String): MatchResult? {
|
||||
if (resourceUri.length > maxUriLength) {
|
||||
logger.debug { "URL is too long (max=$maxUriLength)" }
|
||||
return null
|
||||
}
|
||||
|
||||
// Resolve dot-segments (e.g. /a/../b → /a/b) before splitting.
|
||||
// Prevents traversal attacks on JVM/JS; no-op on native/WASM targets.
|
||||
val normalized = normalizeUri(resourceUri)
|
||||
|
||||
val urlParts = normalized.trim('/').split("/")
|
||||
if (urlParts.size > maxDepth) {
|
||||
logger.debug { "URI has too many segments (max=$maxDepth)" }
|
||||
return null
|
||||
}
|
||||
if (urlParts.size != templateParts.size) return null
|
||||
|
||||
val variables = mutableMapOf<String, String>()
|
||||
var score = 0
|
||||
|
||||
for (i in templateParts.indices) {
|
||||
val urlSegment = urlParts[i].decodeURLPart()
|
||||
val variableName = variableIndices[i]
|
||||
if (variableName != null) {
|
||||
variables[variableName] = urlSegment
|
||||
score += VARIABLE_MATCH_SCORE
|
||||
} else if (templateParts[i] == urlSegment) {
|
||||
score += LITERAL_MATCH_SCORE
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return MatchResult(variables = variables.toImmutableMap(), score = score)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.utils
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplate
|
||||
|
||||
/**
|
||||
* Represents the result of a successful template match.
|
||||
*
|
||||
* A higher [score] indicates a more specific match. Implementations must ensure that
|
||||
* literal segment matches contribute more to the score than variable captures, so that
|
||||
* a fully literal template (e.g., `users/profile`) always outscores a parameterized
|
||||
* template (e.g., `users/{id}`) for the same URI.
|
||||
*
|
||||
* @property variables A mapping of variable names in the template to their matched values in the URL.
|
||||
* @property score A non-negative measure of match specificity — higher means more literal segments matched.
|
||||
*/
|
||||
public class MatchResult(public val variables: Map<String, String>, public val score: Int) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is MatchResult) return false
|
||||
return score == other.score && variables == other.variables
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = 31 * variables.hashCode() + score
|
||||
|
||||
override fun toString(): String = "MatchResult(variables=$variables, score=$score)"
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches resource URIs against a [ResourceTemplate].
|
||||
*
|
||||
* Implementations parse a URI template once at construction time and then match
|
||||
* candidate URIs against that template via [match]. The returned [MatchResult.score]
|
||||
* must reflect match specificity so that a selection algorithm can prefer the most
|
||||
* specific template when multiple templates match the same URI.
|
||||
*/
|
||||
public interface ResourceTemplateMatcher {
|
||||
|
||||
public val resourceTemplate: ResourceTemplate
|
||||
|
||||
/**
|
||||
* Matches a given resource URI against the defined resource template.
|
||||
*
|
||||
* @param resourceUri The resource URI to be matched.
|
||||
* @return A [MatchResult] containing the mapping of variables and a match score
|
||||
* if the URI matches the template, or null if no match is found.
|
||||
*/
|
||||
public fun match(resourceUri: String): MatchResult?
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory interface for creating instances of [ResourceTemplateMatcher].
|
||||
*
|
||||
* A [ResourceTemplateMatcher] is used to match resource URIs against a given
|
||||
* [ResourceTemplate], which adheres to the RFC 6570 URI Template specification.
|
||||
* This factory abstracts the creation process of a matcher, allowing different
|
||||
* implementations to define custom matching logic or safeguards (e.g., security
|
||||
* measures or restrictions on template complexity).
|
||||
*/
|
||||
public fun interface ResourceTemplateMatcherFactory {
|
||||
/**
|
||||
* Creates a resource template matcher for the given resource template.
|
||||
*
|
||||
* @param resourceTemplate The resource template to create a matcher for.
|
||||
* @return A [ResourceTemplateMatcher] instance that can match URIs against the provided template.
|
||||
*/
|
||||
public fun create(resourceTemplate: ResourceTemplate): ResourceTemplateMatcher
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.utils
|
||||
|
||||
/**
|
||||
* Normalizes [uri] by resolving dot-segments (`/a/b/../c` → `/a/c`) using the
|
||||
* platform-native URI parser.
|
||||
*
|
||||
* On JVM uses [java.net.URI.normalize]. On JS uses the browser/Node.js `URL` API.
|
||||
* On native and WASM targets [uri] is returned unchanged — no platform normalizer
|
||||
* is available, so callers on those targets do not receive dot-segment resolution.
|
||||
*
|
||||
* Returns [uri] unchanged if it cannot be parsed or normalized.
|
||||
*
|
||||
* **Security note**: call this before splitting a URI into segments to prevent
|
||||
* dot-segment traversal attacks (e.g. `public/../private/secret`).
|
||||
*/
|
||||
internal expect fun normalizeUri(uri: String): String
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.utils
|
||||
|
||||
import io.kotest.assertions.withClue
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.shouldNotBe
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplate
|
||||
import kotlin.test.Test
|
||||
|
||||
/**
|
||||
* Security regression tests for [PathSegmentTemplateMatcher].
|
||||
*
|
||||
* Every test **fails** when an attack succeeds — green = defended.
|
||||
*
|
||||
* Findings addressed:
|
||||
* - F1: Exactly one percent-decode pass (no double-decoding).
|
||||
* - F2: Variable values are attacker-controlled; handlers must validate them.
|
||||
* - F3: URI segment depth cap prevents allocation amplification.
|
||||
* - F4: Dot-segment normalization blocks traversal on JVM/JS (no-op on native/WASM).
|
||||
* - F5: Double-slash — collapsed by JVM normalization or causes size mismatch on native/WASM.
|
||||
*/
|
||||
class PathSegmentTemplateMatcherSecurityTest {
|
||||
|
||||
// region: F4: Dot-segment traversal
|
||||
|
||||
/**
|
||||
* URIs containing `..` or `.` path segments.
|
||||
*
|
||||
* On JVM/JS: [normalizeUri] resolves these to their canonical target path, so the
|
||||
* traversal URI correctly routes to the same template the normalized path would match.
|
||||
* On native/WASM: [normalizeUri] is a no-op — the extra segments produce a size mismatch
|
||||
* and the match returns null (different reason, same safety outcome).
|
||||
*/
|
||||
private data class TraversalCase(val label: String, val template: String, val uri: String)
|
||||
|
||||
private val traversalNoMatchCases = listOf(
|
||||
TraversalCase(
|
||||
label = "single .. traversal to reach private template",
|
||||
template = "app://host/private/{name}",
|
||||
uri = "app://host/public/../private/secret",
|
||||
),
|
||||
TraversalCase(
|
||||
label = "double .. traversal",
|
||||
template = "app://host/admin/{name}",
|
||||
uri = "app://host/a/b/../../admin/config",
|
||||
),
|
||||
TraversalCase(
|
||||
label = ". current-segment in path",
|
||||
template = "app://host/users/{id}",
|
||||
uri = "app://host/./users/42",
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `dot-segment traversal resolves to correct target without raw path markers in variables`() {
|
||||
// On JVM/JS: normalizeUri() resolves `..` and `.` before segment matching.
|
||||
// → The traversal URI correctly routes to the normalized target template.
|
||||
// On native/WASM: no normalization — extra segments cause a size mismatch → null.
|
||||
// In all cases, captured variable values must not contain raw traversal markers.
|
||||
for ((label, template, uri) in traversalNoMatchCases) {
|
||||
withClue("traversal '$label': uri='$uri', template='$template'") {
|
||||
val result = matcher(template).match(uri)
|
||||
result?.variables?.values?.forEach { value ->
|
||||
withClue("variable value '$value' must not contain raw '..' or '.' traversal marker") {
|
||||
value.contains("..") shouldBe false
|
||||
value shouldNotBe "."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dot-segment URI matches the template it normalizes to`() {
|
||||
// "public/../users/42" normalizes to "users/42" on JVM/JS, no-op on native/WASM.
|
||||
// On JVM/JS this must match; the test is platform-conditional via expect/actual.
|
||||
val result = matcher("users/{id}").match("users/./42")
|
||||
// After normalization "users/./42" → "users/42" (JVM/JS) or stays 3 segments (native/WASM).
|
||||
// Either null (native/WASM — 3 segments vs 2) or a valid match (JVM/JS) is acceptable.
|
||||
// What must NOT happen is a wrong-template match. We check the value if it matches.
|
||||
result?.variables?.get("id") shouldBe result?.let { "42" }
|
||||
}
|
||||
|
||||
// endregion
|
||||
// region: F1: Single percent-decode pass (no double-decoding)
|
||||
|
||||
/**
|
||||
* %25 is the encoding of '%'. %252F = "%2F" after one decode pass — must NOT become "/".
|
||||
* If two decode passes were applied, %252F → %2F → /, enabling path traversal.
|
||||
*/
|
||||
@Test
|
||||
fun `double-encoded percent-sign is decoded only once`() {
|
||||
val result = matcher("files/{path}").match("files/%252Fetc%252Fpasswd")
|
||||
result.shouldNotBeNull {
|
||||
// One decode pass: %25 → %, %2F → /... but only %25 is decoded first.
|
||||
// %252F → %2F (the %25 decodes to %, leaving literal "%2F")
|
||||
withClue("double-encoded %2F must not become a slash after one decode pass") {
|
||||
variables["path"]?.contains('/') shouldBe false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `percent-encoded slash percent-2F in variable is decoded to slash`() {
|
||||
// %2F IS decoded (one pass) — the result is "/" in the variable value.
|
||||
// This documents the known behavior: handlers receive decoded values and must
|
||||
// validate that path separators are not present when constructing file paths.
|
||||
val result = matcher("files/{path}").match("files/..%2Fetc%2Fpasswd")
|
||||
result.shouldNotBeNull {
|
||||
withClue("decoded %2F becomes a path separator — handler must validate") {
|
||||
variables["path"] shouldBe "../etc/passwd"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null byte percent-00 is decoded and passed to handler`() {
|
||||
// %00 decodes to the null character. Handlers using C-string APIs or file paths
|
||||
// must reject values containing null bytes.
|
||||
val result = matcher("items/{id}").match("items/foo%00bar")
|
||||
result.shouldNotBeNull {
|
||||
withClue("decoded null byte is present — handler must reject") {
|
||||
variables["id"] shouldBe "foo\u0000bar"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pct-encoded identity bypass`() {
|
||||
// %69 = 'i'. "adm%69n" decodes to "admin". If a handler compares the raw URI
|
||||
// against a deny-list of "admin", it must use the decoded value.
|
||||
val result = matcher("svc/{role}").match("svc/adm%69n")
|
||||
result.shouldNotBeNull {
|
||||
variables["role"] shouldBe "admin"
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
// region: F3: URI segment depth cap
|
||||
|
||||
@Test
|
||||
fun `URI exceeding MAX_DEPTH segments returns null`() {
|
||||
// 51 slash-separated single-char segments → well over the depth cap of 50
|
||||
val deepUri = (1..51).joinToString("/") { "x" }
|
||||
matcher("{a}").match(deepUri) shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `URI at exactly MAX_DEPTH segments is not rejected by depth cap`() {
|
||||
// A URI with exactly 50 segments must be evaluated normally (still non-matching
|
||||
// against a 1-segment template, but not rejected by the depth guard itself).
|
||||
val atLimit = (1..50).joinToString("/") { "x" }
|
||||
// Template has 1 segment, URI has 50 — size mismatch, not depth rejection.
|
||||
// Both code paths return null; this test verifies no exception is thrown.
|
||||
matcher("{id}").match(atLimit) shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `many-segment URI against many templates completes quickly`() {
|
||||
val deepUri = (1..49).joinToString("/") { "seg$it" }
|
||||
val matchers = (1..100).map { matcher("{v$it}") }
|
||||
// Must not allocate or compute excessively — just verify it finishes.
|
||||
matchers.forEach { it.match(deepUri) }
|
||||
}
|
||||
|
||||
// endregion
|
||||
// region: F5: Double-slash does not produce spurious matches
|
||||
|
||||
@Test
|
||||
fun `double-slash in URI path is normalized on JVM or causes size mismatch elsewhere`() {
|
||||
// On JVM: URI("users//42").normalize() collapses "//" to "/" → "users/42" → matches.
|
||||
// On native/WASM: no normalization — split produces 3 segments vs 2 in template → null.
|
||||
// Either outcome is safe: no empty-segment injection reaches the handler unchecked.
|
||||
val result = matcher("users/{id}").match("users//42")
|
||||
// If matched (JVM), the captured value must be the legitimate path segment.
|
||||
result?.variables?.get("id") shouldBe result?.let { "42" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `double-slash in scheme authority path is normalized on JVM or causes size mismatch elsewhere`() {
|
||||
// On JVM: URI("app://host//injected").normalize() collapses the path "//" to "/"
|
||||
// → "app://host/injected" → matches "app://host/{id}".
|
||||
// On native/WASM: no normalization — extra empty segment causes size mismatch → null.
|
||||
val result = matcher("app://host/{id}").match("app://host//injected")
|
||||
// If matched (JVM), the captured value must be the legitimate path segment.
|
||||
result?.variables?.get("id") shouldBe result?.let { "injected" }
|
||||
}
|
||||
|
||||
// endregion
|
||||
// region: Query string and fragment pass-through
|
||||
|
||||
@Test
|
||||
fun `query string and fragment are captured in variable values - handlers must validate`() {
|
||||
// PathSegmentTemplateMatcher splits on '/' only and does not strip query
|
||||
// strings or fragments. They are captured verbatim inside the variable value.
|
||||
// Handlers MUST reject or strip '?' and '#' before using values in file paths,
|
||||
// database queries, or any other security-sensitive operation.
|
||||
|
||||
// Segment split of "api://host/foo?bar=baz" → ["api:", "", "host", "foo?bar=baz"] (4).
|
||||
// Template "api://host/{id}" → ["api:", "", "host", "{id}"] (4).
|
||||
// → Matches on all platforms; query string ends up in the variable value.
|
||||
val queryResult = matcher("api://host/{id}").match("api://host/foo?bar=baz")
|
||||
queryResult.shouldNotBeNull {
|
||||
variables["id"] shouldBe "foo?bar=baz"
|
||||
}
|
||||
|
||||
// Fragment is also captured verbatim — same reasoning.
|
||||
val fragmentResult = matcher("api://host/{id}").match("api://host/foo#section")
|
||||
fragmentResult.shouldNotBeNull {
|
||||
variables["id"] shouldBe "foo#section"
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
// region: Helpers
|
||||
|
||||
private fun matcher(uriTemplate: String): PathSegmentTemplateMatcher = PathSegmentTemplateMatcher(
|
||||
resourceTemplate = ResourceTemplate(uriTemplate, "Test"),
|
||||
)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.utils
|
||||
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.matchers.maps.shouldBeEmpty
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplate
|
||||
import kotlin.test.Test
|
||||
|
||||
class PathSegmentTemplateMatcherTest {
|
||||
|
||||
// region: Construction
|
||||
|
||||
@Test
|
||||
fun `should throw on blank template`() {
|
||||
shouldThrow<IllegalArgumentException> {
|
||||
matcher(" ")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should throw on empty variable name`() {
|
||||
shouldThrow<IllegalArgumentException> {
|
||||
matcher("users/{}")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should throw when depth exceeds maxTemplateDepth`() {
|
||||
val deep = "a/b/c/d/e/f/g/h/i/j/k" // 11 segments
|
||||
shouldThrow<IllegalArgumentException> {
|
||||
matcher(deep, maxDepth = 10)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should accept template at exactly maxTemplateDepth`() {
|
||||
val atLimit = "a/b/c/d/e/f/g/h/i/j" // 10 segments
|
||||
matcher(atLimit, maxDepth = 10) // must not throw
|
||||
}
|
||||
|
||||
// endregion
|
||||
// region: Basic matching
|
||||
|
||||
@Test
|
||||
fun `should return null for URI with fewer segments than template`() {
|
||||
matcher("users/{id}/posts").match("users/42") shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return null for URI with more segments than template`() {
|
||||
matcher("users/{id}").match("users/42/extra") shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should match all-literal template`() {
|
||||
val result = matcher("users/profile").match("users/profile")
|
||||
result.shouldNotBeNull {
|
||||
variables.shouldBeEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return null when literal segment does not match`() {
|
||||
matcher("users/profile").match("users/settings") shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should extract single variable`() {
|
||||
val result = matcher("users/{id}").match("users/42")
|
||||
result.shouldNotBeNull {
|
||||
variables["id"] shouldBe "42"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should extract multiple variables`() {
|
||||
val result = matcher("users/{userId}/posts/{postId}").match("users/alice/posts/99")
|
||||
result.shouldNotBeNull {
|
||||
variables["userId"] shouldBe "alice"
|
||||
variables["postId"] shouldBe "99"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should match template with scheme`() {
|
||||
val result = matcher("test://items/{id}").match("test://items/42")
|
||||
result.shouldNotBeNull {
|
||||
variables["id"] shouldBe "42"
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
// region:Scoring
|
||||
|
||||
@Test
|
||||
fun `all-literal template scores higher than parameterized template for same URI`() {
|
||||
val literal = matcher("users/profile").match("users/profile")!!
|
||||
val parameterized = matcher("users/{id}").match("users/profile")!!
|
||||
(literal.score > parameterized.score) shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `score increases with number of segments`() {
|
||||
val short = matcher("a/b").match("a/b")!!
|
||||
val long = matcher("a/b/c").match("a/b/c")!!
|
||||
(long.score > short.score) shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-literal two-segment template score is 4`() {
|
||||
// 2 literal segments × LITERAL_MATCH_SCORE(2) = 4
|
||||
matcher("users/profile").match("users/profile")!!.score shouldBe 4
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one-variable two-segment template score is 3`() {
|
||||
// 1 literal × 2 + 1 variable × 1 = 3
|
||||
matcher("users/{id}").match("users/42")!!.score shouldBe 3
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-variable template scores one per segment`() {
|
||||
// 2 variables × VARIABLE_MATCH_SCORE(1) = 2
|
||||
matcher("{a}/{b}").match("x/y")!!.score shouldBe 2
|
||||
}
|
||||
|
||||
// endregion
|
||||
// region:URL decoding
|
||||
|
||||
@Test
|
||||
fun `should URL-decode percent-encoded variable value`() {
|
||||
val result = matcher("search/{query}").match("search/hello%20world")
|
||||
result.shouldNotBeNull {
|
||||
variables["query"] shouldBe "hello world"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should URL-decode percent-encoded literal segment before comparing`() {
|
||||
// %66 decodes to 'f', so "pro%66ile" == "profile" after decoding — it matches
|
||||
matcher("users/profile").match("users/pro%66ile").shouldNotBeNull()
|
||||
}
|
||||
|
||||
// endregion
|
||||
// region:Length guard
|
||||
|
||||
@Test
|
||||
fun `should return null when URI exceeds maxUrlLength`() {
|
||||
val longUri = "a/" + "x".repeat(2048)
|
||||
matcher("a/{id}", maxUrlLength = 2048).match(longUri) shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should match URI at exactly maxUrlLength`() {
|
||||
// URI of exactly maxUrlLength characters must be accepted
|
||||
val uri = "a/" + "x".repeat(2046) // length = 2048
|
||||
matcher("a/{id}", maxUrlLength = 2048).match(uri).shouldNotBeNull()
|
||||
}
|
||||
|
||||
// endregion
|
||||
// region:Edge cases
|
||||
|
||||
@Test
|
||||
fun `should match root-level single segment template`() {
|
||||
val result = matcher("{id}").match("42")
|
||||
result.shouldNotBeNull {
|
||||
variables["id"] shouldBe "42"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should treat leading and trailing slashes as equivalent`() {
|
||||
val result = matcher("/users/{id}/").match("/users/7/")
|
||||
result.shouldNotBeNull {
|
||||
variables["id"] shouldBe "7"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should capture empty string for single-segment variable when URI is empty`() {
|
||||
// "".trim('/').split("/") == [""] — one segment — so {id} captures ""
|
||||
val result = matcher("{id}").match("")
|
||||
result.shouldNotBeNull {
|
||||
variables["id"] shouldBe ""
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `factory creates matcher equal to direct construction`() {
|
||||
val template = ResourceTemplate("items/{id}", "Items")
|
||||
val fromFactory = PathSegmentTemplateMatcher.factory.create(template)
|
||||
val direct = PathSegmentTemplateMatcher(template)
|
||||
|
||||
val uriToMatch = "items/99"
|
||||
fromFactory.match(uriToMatch) shouldBe direct.match(uriToMatch)
|
||||
}
|
||||
|
||||
// endregion
|
||||
// region: MatchResult equality
|
||||
|
||||
@Test
|
||||
fun `MatchResult equals by value`() {
|
||||
val a = MatchResult(mapOf("id" to "1"), score = 3)
|
||||
val b = MatchResult(mapOf("id" to "1"), score = 3)
|
||||
a shouldBe b
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MatchResult not equal when score differs`() {
|
||||
val a = MatchResult(mapOf("id" to "1"), score = 3)
|
||||
val b = MatchResult(mapOf("id" to "1"), score = 2)
|
||||
(a == b) shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MatchResult not equal when variables differ`() {
|
||||
val a = MatchResult(mapOf("id" to "1"), score = 3)
|
||||
val b = MatchResult(mapOf("id" to "2"), score = 3)
|
||||
(a == b) shouldBe false
|
||||
}
|
||||
|
||||
// endregion
|
||||
// region: Helpers
|
||||
|
||||
private fun matcher(
|
||||
uriTemplate: String,
|
||||
maxUrlLength: Int = 2048,
|
||||
maxDepth: Int = 10,
|
||||
): PathSegmentTemplateMatcher = PathSegmentTemplateMatcher(
|
||||
resourceTemplate = ResourceTemplate(uriTemplate, "Test"),
|
||||
maxUriLength = maxUrlLength,
|
||||
maxDepth = maxDepth,
|
||||
)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.utils
|
||||
|
||||
private external class URL(url: String) {
|
||||
val href: String
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes [uri] via the browser/Node.js `URL` API, resolving dot segments and
|
||||
* normalizing the scheme, host, and path. Returns [uri] unchanged if it cannot be
|
||||
* parsed (e.g. relative URIs or schemes unsupported by the runtime).
|
||||
*/
|
||||
internal actual fun normalizeUri(uri: String): String = try {
|
||||
URL(uri).href
|
||||
} catch (_: Throwable) {
|
||||
uri
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.utils
|
||||
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
|
||||
/**
|
||||
* Normalizes [uri] by resolving dot segments (e.g. `/a/b/../c` → `/a/c`) via [URI.normalize].
|
||||
* Returns [uri] unchanged if it is not a valid URI.
|
||||
*/
|
||||
internal actual fun normalizeUri(uri: String): String = try {
|
||||
URI(uri).normalize().toString()
|
||||
} catch (_: URISyntaxException) {
|
||||
uri
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.utils
|
||||
/** No platform-native URI normalizer is available on native targets; [uri] is returned unchanged. */
|
||||
internal actual fun normalizeUri(uri: String): String = uri
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.utils
|
||||
|
||||
/** No platform-native URI normalizer is available on WASM targets; [uri] is returned unchanged. */
|
||||
internal actual fun normalizeUri(uri: String): String = uri
|
||||
Loading…
Reference in a new issue