fix: enable DNS rebinding protection by default (#659)

Add `DnsRebindingProtection` Ktor route-scoped plugin and enable it by
default across all http transports

## Motivation and Context
DNS rebinding protection was disabled by default
(`enableDnsRebindingProtection = false`), and sse endpoints
(`Route.mcp()`) had zero protection. Same vulnerability as typescript
sdk
[GHSA-w48q-cv73-mx4w](https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/GHSA-w48q-cv73-mx4w)

## How Has This Been Tested?
all tests pass

## Breaking Changes
- `Route.mcp()`, `Application.mcp()` signatures changed (new
`enableDnsRebindingProtection`, `allowedHosts`, `allowedOrigins`
params), binary incompatible on JVM
- `enableDnsRebindingProtection` default changed from `false` to `true`
on `mcpStreamableHttp`/`mcpStatelessStreamableHttp`
- Default allowed hosts: `localhost`, `127.0.0.1`, `[::1]` — opt out
with `enableDnsRebindingProtection = false`
- Host validation is now port-agnostic (matches ts sdk)
- Origin validation now allows missing `Origin` header (non-browser
clients)
- `Configuration.enableDnsRebindingProtection`, `.allowedHosts`,
`.allowedOrigins` deprecated in favor of the plugin

## Types of changes
- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update

## Checklist
- [x] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [x] My code follows the repository's style guidelines
- [x] New and existing tests pass locally
- [x] I have added appropriate error handling
- [x] I have added or updated documentation as needed
This commit is contained in:
Pavel Gorgulov 2026-05-05 11:05:59 +02:00 • committed by GitHub
commit 5cb3c1de12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 703 additions and 35 deletions

View file

@ -12,8 +12,6 @@ fun main() {
val port = System.getenv("MCP_PORT")?.toIntOrNull() ?: 3001
embeddedServer(CIO, port = port) {
mcpStreamableHttp(
enableDnsRebindingProtection = true,
allowedHosts = listOf("localhost", "127.0.0.1", "localhost:$port", "127.0.0.1:$port"),
eventStore = InMemoryEventStore(),
) {
createConformanceServer()

View file

@ -32,16 +32,31 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/ClientConnection$De
public static synthetic fun ping$default (Lio/modelcontextprotocol/kotlin/sdk/server/ClientConnection;Lio/modelcontextprotocol/kotlin/sdk/types/PingRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
}
public final class io/modelcontextprotocol/kotlin/sdk/server/DnsRebindingProtectionConfig {
public fun <init> ()V
public final fun getAllowedHosts ()Ljava/util/List;
public final fun getAllowedOrigins ()Ljava/util/List;
public final fun setAllowedHosts (Ljava/util/List;)V
public final fun setAllowedOrigins (Ljava/util/List;)V
}
public abstract interface class io/modelcontextprotocol/kotlin/sdk/server/EventStore {
public abstract fun getStreamIdForEventId (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun replayEventsAfter (Ljava/lang/String;Lkotlin/jvm/functions/Function3;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun storeEvent (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
public final class io/modelcontextprotocol/kotlin/sdk/server/HostValidationKt {
public static final fun getDnsRebindingProtection ()Lio/ktor/server/application/RouteScopedPlugin;
}
public final class io/modelcontextprotocol/kotlin/sdk/server/KtorServerKt {
public static final fun mcp (Lio/ktor/server/application/Application;Lkotlin/jvm/functions/Function1;)V
public static final fun mcp (Lio/ktor/server/routing/Route;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
public static final fun mcp (Lio/ktor/server/routing/Route;Lkotlin/jvm/functions/Function1;)V
public static final fun mcp (Lio/ktor/server/application/Application;ZLjava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function1;)V
public static final fun mcp (Lio/ktor/server/routing/Route;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function1;)V
public static final fun mcp (Lio/ktor/server/routing/Route;ZLjava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function1;)V
public static synthetic fun mcp$default (Lio/ktor/server/application/Application;ZLjava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
public static synthetic fun mcp$default (Lio/ktor/server/routing/Route;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
public static synthetic fun mcp$default (Lio/ktor/server/routing/Route;ZLjava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
public static final fun mcpStatelessStreamableHttp (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;)V
public static synthetic fun mcpStatelessStreamableHttp$default (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
public static final fun mcpStreamableHttp (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;)V

View file

@ -0,0 +1,185 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.parseUrl
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.RouteScopedPlugin
import io.ktor.server.application.createRouteScopedPlugin
import io.ktor.server.request.header
import io.ktor.server.response.respondText
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError
import io.modelcontextprotocol.kotlin.sdk.types.McpJson
import io.modelcontextprotocol.kotlin.sdk.types.RPCError
/**
* Default list of hostnames allowed for localhost DNS rebinding protection.
* Matches the TypeScript SDK's `localhostAllowedHostnames()`.
*/
internal val LOCALHOST_ALLOWED_HOSTS: List<String> = listOf("localhost", "127.0.0.1", "[::1]")
/**
* Characters that are valid in a URL but must not appear in an HTTP `Host` header.
* Rejecting them prevents the parser from accepting malformed values
* (e.g. `evil.com@localhost`, `host/path`) that a generic URL parser would silently allow.
*/
private val FORBIDDEN_HOST_CHARS: CharArray = charArrayOf('/', '@', '?', '#')
/**
* Extracts the hostname from a Host header value, stripping the port.
*
* Only accepts the strict `host [ ":" port ]` / `"[" ipv6 "]" [ ":" port ]`
* format defined by RFC 7230. Values containing URL-only characters
* (`/`, `@`, `?`, `#`) or whitespace are rejected.
*
* Examples:
* - `"localhost:3000"` → `"localhost"`
* - `"127.0.0.1:8080"` → `"127.0.0.1"`
* - `"[::1]:3000"` → `"[::1]"`
* - `"example.com"` → `"example.com"`
* - `"evil.com@localhost"` → `null`
*
* @return the hostname, or `null` if the value is blank, malformed, or contains forbidden characters.
*/
internal fun extractHostname(hostHeader: String): String? = when {
hostHeader.isBlank() -> null
hostHeader.any { it in FORBIDDEN_HOST_CHARS || it.isWhitespace() } -> null
hostHeader.startsWith("[") -> {
val end = hostHeader.indexOf(']')
when {
end <= 1 -> null
isValidPortSuffix(hostHeader.substring(end + 1)) -> hostHeader.substring(0, end + 1)
else -> null
}
}
else -> {
val colon = hostHeader.indexOf(':')
when {
colon < 0 -> hostHeader
colon == 0 -> null
isValidPortSuffix(hostHeader.substring(colon)) -> hostHeader.substring(0, colon)
else -> null
}
}
}
/**
* Checks that [tail] is either empty (no port part) or `":"` followed by zero or more digits,
* matching RFC 7230 §3.2.3 where `port = *DIGIT`. Non-empty non-digit characters are rejected.
*/
private fun isValidPortSuffix(tail: String): Boolean = when {
tail.isEmpty() -> true
tail[0] != ':' -> false
else -> tail.substring(1).all { it.isDigit() }
}
/**
* Extracts the hostname from an `Origin` header value.
*
* Uses Ktor's [parseUrl] to avoid reimplementing RFC 6454 parsing.
* Scheme, port, userinfo, and path are discarded — only the host matters for
* DNS-rebinding protection. The result is **not** normalized to lower-case;
* callers should apply [String.lowercase] if they need case-insensitive comparison.
*
* @return the hostname, or `null` if [origin] cannot be parsed or has no host part.
*/
internal fun extractOriginHost(origin: String): String? = parseUrl(origin)?.host?.takeIf(String::isNotEmpty)
/**
* Configuration for the [DnsRebindingProtection] Ktor route-scoped plugin.
*
* @property allowedHosts List of hostnames allowed in the `Host` header.
* Comparison is port-agnostic and case-insensitive.
* Defaults to `localhost`, `127.0.0.1`, `[::1]`.
* An empty list will reject **all** requests.
* @property allowedOrigins Optional list of allowed `Origin` values. Entries are parsed as URLs
* (via [parseUrl]) and compared by **hostname only** — scheme and port are ignored.
* If `null`, origin validation is disabled.
* If configured, requests **with** an `Origin` header whose hostname is not in the list
* are rejected, but requests **without** an `Origin` header are allowed (non-browser clients).
*/
public class DnsRebindingProtectionConfig {
public var allowedHosts: List<String> = LOCALHOST_ALLOWED_HOSTS
public var allowedOrigins: List<String>? = null
}
/**
* Ktor route-scoped plugin that validates `Host` and `Origin` headers
* to protect against DNS rebinding attacks.
*
* Install on a route to intercept all requests **before** handlers:
* ```kotlin
* route("/mcp") {
* install(DnsRebindingProtection) {
* allowedHosts = listOf("myapp.com", "localhost")
* }
* // handlers...
* }
* ```
*/
public val DnsRebindingProtection: RouteScopedPlugin<DnsRebindingProtectionConfig> =
createRouteScopedPlugin(
"MCP-DnsRebindingProtection",
::DnsRebindingProtectionConfig,
) {
val hosts: Set<String> = pluginConfig.allowedHosts.mapTo(mutableSetOf()) {
extractHostname(it)?.lowercase()
?: error("Invalid host in DnsRebindingProtection.allowedHosts: '$it'")
}
val originHosts: Set<String>? = pluginConfig.allowedOrigins?.mapTo(mutableSetOf()) {
extractOriginHost(it)?.lowercase()
?: error("Invalid origin in DnsRebindingProtection.allowedOrigins: '$it'")
}
onCall { call ->
val hostHeader = call.request.header(HttpHeaders.Host)
val hostname = hostHeader?.let { extractHostname(it) }?.lowercase()
if (hostname == null) {
call.rejectDnsValidation("Invalid Host header: (malformed or missing)")
return@onCall
}
if (hostname !in hosts) {
call.rejectDnsValidation("Invalid Host: $hostname")
return@onCall
}
if (originHosts != null) {
val originHeader = call.request.header(HttpHeaders.Origin)
// Allow requests without Origin (non-browser clients cannot perform DNS rebinding)
if (originHeader != null) {
val originHost = extractOriginHost(originHeader)?.lowercase()
if (originHost == null) {
call.rejectDnsValidation("Invalid Origin header: (unparseable)")
return@onCall
}
if (originHost !in originHosts) {
call.rejectDnsValidation("Invalid Origin host: $originHost")
return@onCall
}
}
}
}
}
/**
* Responds with a 403 Forbidden JSON-RPC error without requiring ContentNegotiation.
*/
private suspend fun ApplicationCall.rejectDnsValidation(message: String) {
val error = JSONRPCError(
id = null,
error = RPCError(
code = -32_000,
message = message,
),
)
respondText(
McpJson.encodeToString(error),
ContentType.Application.Json,
HttpStatusCode.Forbidden,
)
}

View file

@ -37,14 +37,26 @@ private val logger = KotlinLogging.logger {}
* Use [Application.mcp] if you want SSE to be installed automatically.
*
* @param path the URL path to register the SSE endpoint.
* @param enableDnsRebindingProtection whether to install [DnsRebindingProtection] on this route. Defaults to `true`.
* @param allowedHosts hostnames allowed in the `Host` header. Defaults to `localhost`, `127.0.0.1`, `[::1]`.
* @param allowedOrigins origins allowed in the `Origin` header, compared by hostname only
* (scheme and port are ignored). Requests without an `Origin` header are allowed.
* Pass `null` to skip origin validation.
* @param block factory block with access to the [ServerSSESession]
* that creates and returns the [Server] to handle the connection.
* @throws IllegalStateException if the [SSE] plugin is not installed.
*/
@KtorDsl
public fun Route.mcp(path: String, block: ServerSSESession.() -> Server) {
@Suppress("LongParameterList")
public fun Route.mcp(
path: String,
enableDnsRebindingProtection: Boolean = true,
allowedHosts: List<String>? = null,
allowedOrigins: List<String>? = null,
block: ServerSSESession.() -> Server,
) {
route(path) {
mcp(block)
mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, block)
}
}
@ -55,12 +67,22 @@ public fun Route.mcp(path: String, block: ServerSSESession.() -> Server) {
* **Precondition:** the [SSE] plugin must be installed on the application before calling this function.
* Use [Application.mcp] if you want SSE to be installed automatically.
*
* @param enableDnsRebindingProtection whether to install [DnsRebindingProtection] on this route. Defaults to `true`.
* @param allowedHosts hostnames allowed in the `Host` header. Defaults to `localhost`, `127.0.0.1`, `[::1]`.
* @param allowedOrigins origins allowed in the `Origin` header, compared by hostname only
* (scheme and port are ignored). Requests without an `Origin` header are allowed.
* Pass `null` to skip origin validation.
* @param block factory block with access to the [ServerSSESession]
* that creates and returns the [Server] to handle the connection.
* @throws IllegalStateException if the [SSE] plugin is not installed.
*/
@KtorDsl
public fun Route.mcp(block: ServerSSESession.() -> Server) {
public fun Route.mcp(
enableDnsRebindingProtection: Boolean = true,
allowedHosts: List<String>? = null,
allowedOrigins: List<String>? = null,
block: ServerSSESession.() -> Server,
) {
try {
plugin(SSE)
} catch (e: MissingApplicationPluginException) {
@ -72,6 +94,8 @@ public fun Route.mcp(block: ServerSSESession.() -> Server) {
)
}
installDnsRebindingProtection(enableDnsRebindingProtection, allowedHosts, allowedOrigins)
val transportManager = TransportManager<SseServerTransport>()
sse {
@ -91,21 +115,35 @@ public fun Route.mcp(block: ServerSSESession.() -> Server) {
* Automatically installs [ContentNegotiation][io.ktor.server.plugins.contentnegotiation.ContentNegotiation]
* with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson] and [SSE].
*
* @param enableDnsRebindingProtection whether to install [DnsRebindingProtection] on this route. Defaults to `true`.
* @param allowedHosts hostnames allowed in the `Host` header. Defaults to `localhost`, `127.0.0.1`, `[::1]`.
* @param allowedOrigins origins allowed in the `Origin` header, compared by hostname only
* (scheme and port are ignored). Requests without an `Origin` header are allowed.
* Pass `null` to skip origin validation.
* @param block factory block with access to the [ServerSSESession]
* that creates and returns the [Server] to handle the connection.
*/
@KtorDsl
public fun Application.mcp(block: ServerSSESession.() -> Server) {
public fun Application.mcp(
enableDnsRebindingProtection: Boolean = true,
allowedHosts: List<String>? = null,
allowedOrigins: List<String>? = null,
block: ServerSSESession.() -> Server,
) {
installMcpContentNegotiation()
install(SSE)
routing {
mcp(block)
mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, block)
}
}
@Suppress("LongParameterList")
private fun Application.mcpStreamableHttp(
path: String = "/mcp",
enableDnsRebindingProtection: Boolean,
allowedHosts: List<String>?,
allowedOrigins: List<String>?,
configuration: StreamableHttpServerTransport.Configuration,
block: RoutingContext.() -> Server,
) {
@ -116,6 +154,8 @@ private fun Application.mcpStreamableHttp(
routing {
route(path) {
installDnsRebindingProtection(enableDnsRebindingProtection, allowedHosts, allowedOrigins)
// Set Mcp-Session-Id on GET responses before Ktor's sse {} commits headers.
intercept(ApplicationCallPipeline.Plugins) {
if (context.request.httpMethod == HttpMethod.Get) {
@ -160,10 +200,12 @@ private fun Application.mcpStreamableHttp(
* with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson] and [SSE].
*
* @param path The base path for the MCP Streamable HTTP endpoint. Defaults to "/mcp".
* @param enableDnsRebindingProtection Enables DNS rebinding attack protection for the endpoint. Defaults to false.
* @param allowedHosts A list of hostnames allowed to access the endpoint. If `null`, no restrictions are applied.
* @param allowedOrigins A list of origins allowed to perform cross-origin requests (CORS).
* If `null`, no restrictions are applied.
* @param enableDnsRebindingProtection Enables DNS rebinding attack protection for the endpoint. Defaults to `true`.
* @param allowedHosts A list of hostnames allowed to access the endpoint.
* If `null` and DNS rebinding protection is enabled, defaults to `localhost`, `127.0.0.1`, `[::1]`.
* @param allowedOrigins A list of allowed `Origin` header values, compared by hostname only
* (scheme and port are ignored). Requests without an `Origin` header are allowed.
* If `null`, origin validation is disabled.
* @param eventStore An optional [EventStore] instance to enable resumable event stream functionality.
* Allows storing and replaying events.
* @param block factory block with access to the [RoutingContext] (for reading request headers)
@ -172,7 +214,7 @@ private fun Application.mcpStreamableHttp(
@KtorDsl
public fun Application.mcpStreamableHttp(
path: String = "/mcp",
enableDnsRebindingProtection: Boolean = false,
enableDnsRebindingProtection: Boolean = true,
allowedHosts: List<String>? = null,
allowedOrigins: List<String>? = null,
eventStore: EventStore? = null,
@ -180,10 +222,10 @@ public fun Application.mcpStreamableHttp(
) {
mcpStreamableHttp(
path = path,
enableDnsRebindingProtection = enableDnsRebindingProtection,
allowedHosts = allowedHosts,
allowedOrigins = allowedOrigins,
configuration = StreamableHttpServerTransport.Configuration(
enableDnsRebindingProtection = enableDnsRebindingProtection,
allowedHosts = allowedHosts,
allowedOrigins = allowedOrigins,
eventStore = eventStore,
enableJsonResponse = true,
),
@ -191,8 +233,12 @@ public fun Application.mcpStreamableHttp(
)
}
@Suppress("LongParameterList")
private fun Application.mcpStatelessStreamableHttp(
path: String = "/mcp",
enableDnsRebindingProtection: Boolean,
allowedHosts: List<String>?,
allowedOrigins: List<String>?,
configuration: StreamableHttpServerTransport.Configuration,
block: RoutingContext.() -> Server,
) {
@ -201,6 +247,8 @@ private fun Application.mcpStatelessStreamableHttp(
routing {
route(path) {
installDnsRebindingProtection(enableDnsRebindingProtection, allowedHosts, allowedOrigins)
post {
mcpStatelessStreamableHttpEndpoint(
configuration = configuration,
@ -236,9 +284,12 @@ private fun Application.mcpStatelessStreamableHttp(
* with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson] and [SSE].
*
* @param path The URL path where the server listens for incoming JSON-RPC requests. Defaults to "/mcp".
* @param enableDnsRebindingProtection Determines whether DNS rebinding protection is enabled. Defaults to `false`.
* @param allowedHosts A list of allowed hostnames. If null, host filtering is disabled.
* @param allowedOrigins A list of allowed origins for CORS. If null, origin filtering is disabled.
* @param enableDnsRebindingProtection Determines whether DNS rebinding protection is enabled. Defaults to `true`.
* @param allowedHosts A list of allowed hostnames. If `null` and DNS rebinding protection is enabled,
* defaults to `localhost`, `127.0.0.1`, `[::1]`.
* @param allowedOrigins A list of allowed `Origin` header values, compared by hostname only
* (scheme and port are ignored). Requests without an `Origin` header are allowed.
* If `null`, origin validation is disabled.
* @param eventStore An optional [EventStore] implementation to provide resumability and event replay support.
* @param block factory block with access to the [RoutingContext] (for reading request headers)
* that creates and returns the [Server] to handle the connection.
@ -246,7 +297,7 @@ private fun Application.mcpStatelessStreamableHttp(
@KtorDsl
public fun Application.mcpStatelessStreamableHttp(
path: String = "/mcp",
enableDnsRebindingProtection: Boolean = false,
enableDnsRebindingProtection: Boolean = true,
allowedHosts: List<String>? = null,
allowedOrigins: List<String>? = null,
eventStore: EventStore? = null,
@ -254,10 +305,10 @@ public fun Application.mcpStatelessStreamableHttp(
) {
mcpStatelessStreamableHttp(
path = path,
enableDnsRebindingProtection = enableDnsRebindingProtection,
allowedHosts = allowedHosts,
allowedOrigins = allowedOrigins,
configuration = StreamableHttpServerTransport.Configuration(
enableDnsRebindingProtection = enableDnsRebindingProtection,
allowedHosts = allowedHosts,
allowedOrigins = allowedOrigins,
eventStore = eventStore,
enableJsonResponse = true,
),
@ -395,3 +446,11 @@ private suspend fun RoutingContext.streamableTransport(
return transport
}
private fun Route.installDnsRebindingProtection(enabled: Boolean, hosts: List<String>?, origins: List<String>?) {
if (!enabled) return
install(DnsRebindingProtection) {
allowedHosts = hosts ?: LOCALHOST_ALLOWED_HOSTS
origins?.let { allowedOrigins = it }
}
}

View file

@ -136,8 +136,20 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
*/
public class Configuration(
public val enableJsonResponse: Boolean = false,
@Deprecated(
message = "Use install(DnsRebindingProtection) on your Ktor route instead",
level = DeprecationLevel.WARNING,
)
public val enableDnsRebindingProtection: Boolean = false,
@Deprecated(
message = "Use install(DnsRebindingProtection) on your Ktor route instead",
level = DeprecationLevel.WARNING,
)
public val allowedHosts: List<String>? = null,
@Deprecated(
message = "Use install(DnsRebindingProtection) on your Ktor route instead",
level = DeprecationLevel.WARNING,
)
public val allowedOrigins: List<String>? = null,
public val eventStore: EventStore? = null,
public val retryInterval: Duration? = null,
@ -300,7 +312,12 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
}
/**
* Handles an incoming HTTP request, whether GET, POST or DELETE
* Handles an incoming HTTP request, whether GET, POST or DELETE.
*
* **Note:** this method does not perform DNS rebinding protection on its own.
* When using this transport outside the [mcpStreamableHttp] / [mcpStatelessStreamableHttp]
* DSL, install the [DnsRebindingProtection] plugin on your Ktor route
* (or apply equivalent middleware) to validate `Host` / `Origin` headers.
*/
public suspend fun handleRequest(session: ServerSSESession?, call: ApplicationCall) {
validateHeaders(call)?.let { reason ->
@ -651,10 +668,13 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
if (!configuration.enableDnsRebindingProtection) return null
configuration.allowedHosts?.let { hosts ->
val hostHeader = call.request.headers[HttpHeaders.Host]?.lowercase()
val allowedHostsLowercase = hosts.map { it.lowercase() }
val hostHeader = call.request.headers[HttpHeaders.Host]
val hostname = hostHeader?.let { extractHostname(it) }?.lowercase()
val allowedHostsLowercase = hosts.map {
extractHostname(it)?.lowercase() ?: it.lowercase()
}
if (hostHeader == null || hostHeader !in allowedHostsLowercase) {
if (hostname == null || hostname !in allowedHostsLowercase) {
return "Invalid Host header: $hostHeader"
}
}
@ -663,7 +683,8 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
val originHeader = call.request.headers[HttpHeaders.Origin]?.lowercase()
val allowedOriginsLowercase = origins.map { it.lowercase() }
if (originHeader == null || originHeader !in allowedOriginsLowercase) {
// Allow requests without Origin (non-browser clients cannot perform DNS rebinding)
if (originHeader != null && originHeader !in allowedOriginsLowercase) {
return "Invalid Origin header: $originHeader"
}
}

View file

@ -0,0 +1,382 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.kotest.assertions.ktor.client.shouldHaveStatus
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.server.application.install
import io.ktor.server.response.respondText
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import io.ktor.server.sse.SSE
import io.ktor.server.testing.ApplicationTestBuilder
import io.ktor.server.testing.testApplication
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import org.junit.jupiter.params.provider.ValueSource
import kotlin.test.Test
class DnsRebindingProtectionTest {
companion object {
@JvmStatic
fun invalidHostCases(): List<Arguments> = listOf(
Arguments.of("", "Invalid Host header: (malformed or missing)"),
Arguments.of("evil.com", "Invalid Host: evil.com"),
)
@JvmStatic
fun extractHostnameAcceptCases(): List<Arguments> = listOf(
Arguments.of("localhost", "localhost"),
Arguments.of("localhost:3000", "localhost"),
Arguments.of("127.0.0.1:8080", "127.0.0.1"),
Arguments.of("[::1]", "[::1]"),
Arguments.of("[::1]:3000", "[::1]"),
Arguments.of("localhost:", "localhost"),
Arguments.of("[::1]:", "[::1]"),
)
@JvmStatic
fun extractHostnameRejectCases(): List<String> = listOf(
"", // empty
"evil.com@localhost", // userinfo
"evil.com/path", // path
"evil.com?q=1", // query
"evil.com#frag", // fragment
"[::1", // malformed IPv6
"[]", // empty brackets
"[]:3000", // empty brackets + port
" localhost", // leading whitespace
"localhost ", // trailing whitespace
"localhost\t:80", // embedded tab
"localhost:abc", // non-numeric port
"localhost:-1", // negative port
"[::1]:abc", // non-numeric IPv6 port
":80", // leading colon
)
@JvmStatic
fun extractOriginHostAcceptCases(): List<Arguments> = listOf(
Arguments.of("http://example.com", "example.com"),
Arguments.of("http://example.com:8080", "example.com"),
Arguments.of("https://Example.COM", "Example.COM"),
Arguments.of("https://example.com", "example.com"),
)
@JvmStatic
fun extractOriginHostRejectCases(): List<String> = listOf(
"example.com", // no scheme
"not-a-url", // unparseable
"", // empty
)
}
private fun testWithPlugin(
config: DnsRebindingProtectionConfig.() -> Unit = {},
test: suspend ApplicationTestBuilder.() -> Unit,
): Unit = testApplication {
application {
install(SSE)
routing {
route("/mcp") {
install(DnsRebindingProtection, config)
post { call.respondText("ok") }
}
}
}
test()
}
@ParameterizedTest
@ValueSource(strings = ["localhost", "127.0.0.1", "[::1]", "localhost:3000", "[::1]:8080"])
fun `plugin accepts valid Host header`(hostHeader: String) = testWithPlugin {
val response = client.post("/mcp") {
header(HttpHeaders.Host, hostHeader)
}
response.shouldHaveStatus(HttpStatusCode.OK)
response.bodyAsText() shouldBe "ok"
}
@ParameterizedTest
@MethodSource("invalidHostCases")
fun `plugin rejects invalid Host header`(hostHeader: String, expectedBody: String) = testWithPlugin {
val response = client.post("/mcp") {
header(HttpHeaders.Host, hostHeader)
}
response.shouldHaveStatus(HttpStatusCode.Forbidden)
response.bodyAsText() shouldContain expectedBody
}
@Test
fun `plugin does not echo raw malformed Host in rejection`() = testWithPlugin {
val response = client.post("/mcp") {
header(HttpHeaders.Host, "evil.com@localhost")
}
response.shouldHaveStatus(HttpStatusCode.Forbidden)
response.bodyAsText() shouldNotContain "evil.com@localhost"
response.bodyAsText() shouldContain "malformed or missing"
}
@Test
fun `plugin allows request without Origin header`() = testWithPlugin(
config = { allowedOrigins = listOf("http://localhost:3000") },
) {
val response = client.post("/mcp") {
header(HttpHeaders.Host, "localhost")
}
response.shouldHaveStatus(HttpStatusCode.OK)
response.bodyAsText() shouldBe "ok"
}
@Test
fun `plugin rejects disallowed Origin`() = testWithPlugin(
config = { allowedOrigins = listOf("http://localhost:3000") },
) {
val response = client.post("/mcp") {
header(HttpHeaders.Host, "localhost")
header(HttpHeaders.Origin, "http://evil.com")
}
response.shouldHaveStatus(HttpStatusCode.Forbidden)
response.bodyAsText() shouldContain "Invalid Origin host: evil.com"
}
@Test
fun `plugin allows matching Origin`() = testWithPlugin(
config = { allowedOrigins = listOf("http://localhost:3000") },
) {
val response = client.post("/mcp") {
header(HttpHeaders.Host, "localhost")
header(HttpHeaders.Origin, "http://localhost:3000")
}
response.shouldHaveStatus(HttpStatusCode.OK)
response.bodyAsText() shouldBe "ok"
}
@Test
fun `plugin allows Origin with different port but same hostname`() = testWithPlugin(
config = { allowedOrigins = listOf("http://localhost:3000") },
) {
val response = client.post("/mcp") {
header(HttpHeaders.Host, "localhost")
header(HttpHeaders.Origin, "http://localhost:9999")
}
response.shouldHaveStatus(HttpStatusCode.OK)
response.bodyAsText() shouldBe "ok"
}
@Test
fun `plugin allows Origin with different scheme but same hostname`() = testWithPlugin(
config = { allowedOrigins = listOf("http://localhost:3000") },
) {
val response = client.post("/mcp") {
header(HttpHeaders.Host, "localhost")
header(HttpHeaders.Origin, "https://localhost:3000")
}
response.shouldHaveStatus(HttpStatusCode.OK)
response.bodyAsText() shouldBe "ok"
}
@Test
fun `plugin rejects unparseable Origin header`() = testWithPlugin(
config = { allowedOrigins = listOf("http://localhost:3000") },
) {
val response = client.post("/mcp") {
header(HttpHeaders.Host, "localhost")
header(HttpHeaders.Origin, "not-a-url")
}
response.shouldHaveStatus(HttpStatusCode.Forbidden)
response.bodyAsText() shouldContain "Invalid Origin header: (unparseable)"
}
@Test
fun `plugin with custom allowedHosts accepts matching host`() = testWithPlugin(
config = { allowedHosts = listOf("myapp.com") },
) {
val response = client.post("/mcp") {
header(HttpHeaders.Host, "myapp.com:443")
}
response.shouldHaveStatus(HttpStatusCode.OK)
response.bodyAsText() shouldBe "ok"
}
@Test
fun `plugin with custom allowedHosts rejects non-matching host`() = testWithPlugin(
config = { allowedHosts = listOf("myapp.com") },
) {
val response = client.post("/mcp") {
header(HttpHeaders.Host, "localhost")
}
response.shouldHaveStatus(HttpStatusCode.Forbidden)
}
@Test
fun `host validation is case insensitive`() = testWithPlugin(
config = { allowedHosts = listOf("MyApp.COM") },
) {
val response = client.post("/mcp") {
header(HttpHeaders.Host, "myapp.com")
}
response.shouldHaveStatus(HttpStatusCode.OK)
response.bodyAsText() shouldBe "ok"
}
@Test
fun `origin validation is case insensitive`() = testWithPlugin(
config = {
allowedHosts = listOf("localhost")
allowedOrigins = listOf("https://MyApp.COM")
},
) {
val response = client.post("/mcp") {
header(HttpHeaders.Host, "localhost")
header(HttpHeaders.Origin, "https://myapp.com")
}
response.shouldHaveStatus(HttpStatusCode.OK)
response.bodyAsText() shouldBe "ok"
}
@Test
fun `plugin with empty allowedHosts rejects all requests`() = testWithPlugin(
config = { allowedHosts = emptyList() },
) {
val localhostResponse = client.post("/mcp") {
header(HttpHeaders.Host, "localhost")
}
localhostResponse.shouldHaveStatus(HttpStatusCode.Forbidden)
localhostResponse.bodyAsText() shouldContain "Invalid Host: localhost"
val otherResponse = client.post("/mcp") {
header(HttpHeaders.Host, "myapp.com")
}
otherResponse.shouldHaveStatus(HttpStatusCode.Forbidden)
otherResponse.bodyAsText() shouldContain "Invalid Host: myapp.com"
}
@Test
fun `plugin fails to install when allowedHosts contains an invalid host`() {
val ex = shouldThrow<IllegalStateException> {
testWithPlugin(
config = { allowedHosts = listOf("https://example.com") },
) {
client.post("/mcp") { header(HttpHeaders.Host, "example.com") }
}
}
ex.message shouldContain "https://example.com"
}
@Test
fun `plugin fails to install when allowedOrigins contains an invalid origin`() {
val ex = shouldThrow<IllegalStateException> {
testWithPlugin(
config = { allowedOrigins = listOf("not-a-url") },
) {
client.post("/mcp") { header(HttpHeaders.Host, "localhost") }
}
}
ex.message shouldContain "not-a-url"
}
@Test
fun `Route mcp with DNS protection enabled rejects non-localhost`() = testApplication {
application {
install(SSE)
routing {
mcp { testServer() }
}
}
val response = client.post("/") {
header(HttpHeaders.Host, "evil.com")
contentType(ContentType.Application.Json)
}
response.shouldHaveStatus(HttpStatusCode.Forbidden)
}
@Test
fun `Route mcp with DNS protection disabled allows any host`() = testApplication {
application {
install(SSE)
routing {
mcp(enableDnsRebindingProtection = false) { testServer() }
}
}
val response = client.post("/") {
header(HttpHeaders.Host, "evil.com")
contentType(ContentType.Application.Json)
}
// Not 403 — the request reaches the handler (may get 400 for missing sessionId, etc.)
response.shouldHaveStatus(HttpStatusCode.BadRequest)
}
@Test
fun `mcpStreamableHttp rejects non-localhost Host by default`() = testApplication {
application {
mcpStreamableHttp { testServer() }
}
val response = client.post("/mcp") {
header(HttpHeaders.Host, "evil.com")
contentType(ContentType.Application.Json)
}
response.shouldHaveStatus(HttpStatusCode.Forbidden)
}
@Test
fun `mcpStatelessStreamableHttp rejects non-localhost Host by default`() = testApplication {
application {
mcpStatelessStreamableHttp { testServer() }
}
val response = client.post("/mcp") {
header(HttpHeaders.Host, "evil.com")
contentType(ContentType.Application.Json)
}
response.shouldHaveStatus(HttpStatusCode.Forbidden)
}
// -- extractHostname unit tests --
@ParameterizedTest
@MethodSource("extractHostnameAcceptCases")
fun `extractHostname accepts valid host`(input: String, expected: String) {
extractHostname(input) shouldBe expected
}
@ParameterizedTest
@MethodSource("extractHostnameRejectCases")
fun `extractHostname rejects invalid host`(input: String) {
extractHostname(input) shouldBe null
}
// -- extractOriginHost unit tests --
@ParameterizedTest
@MethodSource("extractOriginHostAcceptCases")
fun `extractOriginHost extracts hostname`(input: String, expected: String) {
extractOriginHost(input) shouldBe expected
}
@ParameterizedTest
@MethodSource("extractOriginHostRejectCases")
fun `extractOriginHost returns null for invalid origin`(input: String) {
extractOriginHost(input) shouldBe null
}
private fun testServer(): Server = Server(
serverInfo = Implementation(name = "test-server", version = "1.0.0"),
options = ServerOptions(capabilities = ServerCapabilities()),
)
}

View file

@ -24,9 +24,9 @@ class KtorApplicationExtensionsTest : AbstractKtorExtensionsTest() {
* added to the same application.
*/
@Test
fun `Application mcp should installs SSE and coexist with other routes`() = testApplication {
fun `Application mcp should install SSE and coexist with other routes`() = testApplication {
application {
mcp { testServer() }
mcp(enableDnsRebindingProtection = false) { testServer() }
routing {
get("/health") { call.respondText("healthy") }

View file

@ -61,7 +61,7 @@ class KtorRouteExtensionsTest : AbstractKtorExtensionsTest() {
route("/api/mcp") {
get("/test") { call.respondText("test-endpoint") }
mcp { testServer() }
mcp(enableDnsRebindingProtection = false) { testServer() }
}
}
}
@ -91,7 +91,7 @@ class KtorRouteExtensionsTest : AbstractKtorExtensionsTest() {
route("/v1") {
route("/services") {
route("/mcp") {
mcp { testServer() }
mcp(enableDnsRebindingProtection = false) { testServer() }
}
}
}
@ -112,7 +112,7 @@ class KtorRouteExtensionsTest : AbstractKtorExtensionsTest() {
routing {
route("/api") {
mcp("/mcp-endpoint") { testServer() }
mcp("/mcp-endpoint", enableDnsRebindingProtection = false) { testServer() }
}
}
}

View file

@ -477,6 +477,7 @@ class StreamableHttpServerTransportTest {
// Step 1: Initialize session via POST
val initResponse = client.post(mcpPath) {
header(HttpHeaders.Host, "localhost")
addStreamableHeaders()
setBody(buildInitializeRequestPayload())
}
@ -485,6 +486,7 @@ class StreamableHttpServerTransportTest {
// Step 2: Open first GET SSE stream
client.prepareGet(mcpPath) {
header(HttpHeaders.Host, "localhost")
header(HttpHeaders.Accept, ContentType.Text.EventStream.toString())
header(MCP_SESSION_ID_HEADER, sessionId)
header("mcp-protocol-version", LATEST_PROTOCOL_VERSION)
@ -495,6 +497,7 @@ class StreamableHttpServerTransportTest {
// Step 3: Open a second GET — the transport closes the old session
// and the new stream takes over.
client.prepareGet(mcpPath) {
header(HttpHeaders.Host, "localhost")
header(HttpHeaders.Accept, ContentType.Text.EventStream.toString())
header(MCP_SESSION_ID_HEADER, sessionId)
header("mcp-protocol-version", LATEST_PROTOCOL_VERSION)
@ -528,6 +531,7 @@ class StreamableHttpServerTransportTest {
// Step 1: Initialize session via POST
val initResponse = client.post(mcpPath) {
header(HttpHeaders.Host, "localhost")
addStreamableHeaders()
setBody(buildInitializeRequestPayload())
}
@ -536,6 +540,7 @@ class StreamableHttpServerTransportTest {
// Step 2: Open and then close a GET SSE stream
client.prepareGet(mcpPath) {
header(HttpHeaders.Host, "localhost")
header(HttpHeaders.Accept, ContentType.Text.EventStream.toString())
header(MCP_SESSION_ID_HEADER, sessionId)
header("mcp-protocol-version", LATEST_PROTOCOL_VERSION)
@ -547,6 +552,7 @@ class StreamableHttpServerTransportTest {
// Step 3: Immediately reconnect — the transport should close the stale
// stream and allow the new one.
client.prepareGet(mcpPath) {
header(HttpHeaders.Host, "localhost")
header(HttpHeaders.Accept, ContentType.Text.EventStream.toString())
header(MCP_SESSION_ID_HEADER, sessionId)
header("mcp-protocol-version", LATEST_PROTOCOL_VERSION)
@ -578,6 +584,7 @@ class StreamableHttpServerTransportTest {
// Step 1: Initialize session via POST
val initResponse = client.post(mcpPath) {
header(HttpHeaders.Host, "localhost")
addStreamableHeaders()
setBody(buildInitializeRequestPayload())
}
@ -586,6 +593,7 @@ class StreamableHttpServerTransportTest {
// Step 2: Open GET SSE stream with session ID
client.prepareGet(mcpPath) {
header(HttpHeaders.Host, "localhost")
header(HttpHeaders.Accept, ContentType.Text.EventStream.toString())
header(MCP_SESSION_ID_HEADER, sessionId)
header("mcp-protocol-version", LATEST_PROTOCOL_VERSION)