diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SseClientTransport.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SseClientTransport.kt index 0295eb9a3..56d8272f7 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SseClientTransport.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SseClientTransport.kt @@ -11,6 +11,7 @@ import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpHeaders +import io.ktor.http.Url import io.ktor.http.append import io.ktor.http.isSuccess import io.ktor.http.protocolWithAuthority @@ -142,12 +143,21 @@ public class SseClientTransport( /** * Resolves and completes [endpoint] based on [eventData]. - * Uses full URLs as-is, treats absolute paths as origin-relative, - * and relative paths as relative to [baseUrl]. + * Uses full URLs as-is, but rejects those whose origin differs from the SSE connection origin, + * treats absolute paths as origin-relative, and relative paths as relative to [baseUrl]. */ private fun handleEndpoint(eventData: String) { try { val endpointUrl = if (eventData.startsWith("http://") || eventData.startsWith("https://")) { + val endpointOrigin = Url(eventData) + if (!endpointOrigin.hasSameOrigin()) { + val error = IllegalArgumentException( + "Endpoint origin ${endpointOrigin.protocolWithAuthority} does not match connection origin $origin", + ) + _onError(error) + endpoint.completeExceptionally(error) + return + } eventData } else if (eventData.startsWith("/")) { origin + eventData @@ -163,6 +173,13 @@ public class SseClientTransport( } } + /** + * Returns true when [this]'s origin (scheme, host, and port) matches the SSE connection's origin. + * The comparison uses the authority string, so default ports are ignored (e.g. `http://host:80` + * and `http://host` are considered the same origin). + */ + private fun Url.hasSameOrigin(): Boolean = protocolWithAuthority == origin + private suspend fun handleMessage(data: String) { try { val message = McpJson.decodeFromString(data) diff --git a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse/SseClientTransportTest.kt b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse/SseClientTransportTest.kt index 319ba2a82..367e71f44 100644 --- a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse/SseClientTransportTest.kt +++ b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse/SseClientTransportTest.kt @@ -10,6 +10,7 @@ import io.modelcontextprotocol.kotlin.sdk.client.SseClientTransport import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification import kotlinx.coroutines.test.runTest import kotlin.test.Test +import kotlin.test.assertFailsWith import kotlin.time.Duration.Companion.seconds class SseClientTransportTest { @@ -67,12 +68,38 @@ class SseClientTransportTest { } @Test - fun `full url endpoint is used as-is`() = runTest { + fun `full url endpoint with a different origin is rejected`() = runTest { // Given val sseUrl = "http://example.com/api/mcp/sse" // And - val endpointEvent = "https://example.com/messages?sessionId=abc" + val endpointEvent = "https://evil.example.com/messages?sessionId=abc" + + // And + val engine = CapturingSseClientEngine(endpoint = endpointEvent) + val transport = sseTransport(sseUrl, engine) + + // When + val exception = assertFailsWith { + transport.start() + } + + // Then + exception.message shouldBe "Endpoint origin https://evil.example.com does not match connection origin http://example.com" + engine.capturedPosts shouldHaveSize 0 + + // Cleanup + transport.close() + engine.close() + } + + @Test + fun `full url endpoint with the same origin is used as-is`() = runTest { + // Given + val sseUrl = "http://example.com/api/mcp/sse" + + // And + val endpointEvent = "http://example.com/messages?sessionId=abc" // And val engine = CapturingSseClientEngine(endpoint = endpointEvent) @@ -85,7 +112,7 @@ class SseClientTransportTest { // Then val capturedPosts = engine.capturedPosts capturedPosts shouldHaveSize 1 - capturedPosts[0].url.toString() shouldBe "https://example.com/messages?sessionId=abc" + capturedPosts[0].url.toString() shouldBe "http://example.com/messages?sessionId=abc" // Cleanup transport.close()