-
Notifications
You must be signed in to change notification settings - Fork 206
fix: enable DNS rebinding protection by default #659
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
fe89031
a3ee7fd
8b7b725
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,139 @@ | ||||||||||||||||||||||||||||||||||||
| package io.modelcontextprotocol.kotlin.sdk.server | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| import io.ktor.http.ContentType | ||||||||||||||||||||||||||||||||||||
| import io.ktor.http.HttpHeaders | ||||||||||||||||||||||||||||||||||||
| import io.ktor.http.HttpStatusCode | ||||||||||||||||||||||||||||||||||||
| 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(']') | ||||||||||||||||||||||||||||||||||||
| val tail = if (end > 0) hostHeader.substring(end + 1) else "" | ||||||||||||||||||||||||||||||||||||
| if (end > 0 && (tail.isEmpty() || tail.startsWith(':'))) { | ||||||||||||||||||||||||||||||||||||
| hostHeader.substring(0, end + 1) | ||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||
| null | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+52
to
+55
|
||||||||||||||||||||||||||||||||||||
| if (end > 0 && (tail.isEmpty() || tail.startsWith(':'))) { | |
| hostHeader.substring(0, end + 1) | |
| } else { | |
| null | |
| if (end <= 0) { | |
| null | |
| } else if (tail.isEmpty()) { | |
| hostHeader.substring(0, end + 1) | |
| } else if (tail[0] != ':') { | |
| null | |
| } else { | |
| val port = tail.substring(1) | |
| if (port.isNotEmpty() && port.all { it.isDigit() }) { | |
| hostHeader.substring(0, end + 1) | |
| } else { | |
| null | |
| } |
Copilot
AI
Mar 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In extractHostname, the non-IPv6 branch uses substringBefore(':'), which will treat malformed Host headers like localhost: or example.com:abc as valid (hostname extracted as localhost / example.com). If the intent is RFC-7230 strictness (as the KDoc says), validate that when a : is present the port part is non-empty and all digits; otherwise return null.
| else -> hostHeader.substringBefore(':').ifEmpty { null } | |
| else -> { | |
| val colonIndex = hostHeader.indexOf(':') | |
| if (colonIndex < 0) { | |
| hostHeader | |
| } else { | |
| val host = hostHeader.substring(0, colonIndex) | |
| val port = hostHeader.substring(colonIndex + 1) | |
| if (host.isNotEmpty() && port.isNotEmpty() && port.all { it.isDigit() }) { | |
| host | |
| } else { | |
| null | |
| } | |
| } | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you add a test for this case?
Copilot
AI
Mar 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
DnsRebindingProtectionConfig KDoc says it defaults to [LOCALHOST_ALLOWED_HOSTS], but that constant is internal, so it’s not visible to consumers and may produce a broken link in generated docs. Consider making the default constant public or spelling out the default hostnames directly in the KDoc.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's keep processed configuration issutable:
pluginConfig.allowedHosts.map { ... }.toImmutableSet()
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Transformations should be limited to validation and the lowercasing of plugin configuration. They should not conceal configuration errors which should be addressed by the user. For example, replace extractHostname with validateHostname and fail on error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's keep processed configuration immutable
| val origins: Set<String>? = pluginConfig.allowedOrigins?.mapTo(mutableSetOf()) { it.lowercase() } | |
| val origins: Set<String>? = pluginConfig.allowedOrigins?.map { it.lowercase() }?.toImmutableSet() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would parse origin and use truncate everything except hostname. we may use "hostname*" (* for excluded characters)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, let's also parse Url from the Origin header using standard Url parser or use Ktor's parseUrl(...), because reimplementing RFC is hard. Then extract the hostname, e.g.:
fun extractHostFromOrigin(origin: String): String? {
return runCatching {
// Url is Ktor's built-in parser
Url(origin).host.lowercase()
}.getOrNull()
}There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Error response leaks raw Host/Origin header values
Category: Security | Severity: Medium
HostValidation.kt:110 — "Invalid Host header: $hostHeader" and line 118 — "Invalid Origin header: $origin" echo attacker-controlled values directly into the JSON-RPC error response. While these are not rendered in a browser HTML context (Content-Type is application/json), reflecting unsanitized input is a code smell that can become exploitable if response bodies are logged to HTML dashboards or if content-type sniffing occurs.
Recommendation: Omit the raw header value from the error message, or truncate/sanitize it: call.rejectDnsValidation("Host header is not in the allowed hosts list")
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
HostValidation.kt:117 compares the Origin header as a raw lowercase string. The Origin header format is scheme://host[:port]. A full string match means allowedOrigins = listOf("http://localhost:3000") won't match HTTP://localhost:3000 (uppercase scheme). The hosts comparison correctly lowercases, but origins also need this — and do get it (line 103/115 both lowercase). However, there's no normalization of trailing slashes, default port stripping, or scheme decomposition.
Recommendation: Add a KDoc note on allowedOrigins that comparison is exact (case-insensitive) string match with no URL normalization.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
claude suggested to use the same code as in typescript sdk (-32000)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
RPCError.ErrorCode.CONNECTION_CLOSED is semantically incorrect — the connection isn't closed, the request is forbidden. A more appropriate code would be a custom error or INVALID_REQUEST (-32600). This also means monitoring/alerting that keys on CONNECTION_CLOSED will conflate real connection drops with blocked DNS rebinding attempts.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,14 +35,24 @@ 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_ALLOWED_HOSTS]. | ||
| * @param allowedOrigins origins allowed in the `Origin` header, or `null` to skip origin validation. | ||
|
Comment on lines
37
to
+40
|
||
| * @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, | ||
|
Comment on lines
+50
to
+51
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. allowedHosts with null vs empty list have different semantics — not tested. Category: Coverage gap In KtorServer.kt:93, allowedHosts ?: LOCALHOST_ALLOWED_HOSTS means null falls back to localhost defaults, but emptyList() would mean "reject everything". The DnsRebindingProtectionConfig KDoc documents this (line 69: "An empty list will reject all requests"), but there's no test verifying this critical behavioral difference. Test to add:
Comment on lines
+50
to
+51
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Our objective is to verify that requests are accepted only from whitelisted hosts or from any host in the case of |
||
| block: ServerSSESession.() -> Server, | ||
| ) { | ||
| route(path) { | ||
| mcp(block) | ||
| mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, block) | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -53,12 +63,20 @@ 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_ALLOWED_HOSTS]. | ||
| * @param allowedOrigins origins allowed in the `Origin` header, or `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) { | ||
|
|
@@ -70,6 +88,13 @@ public fun Route.mcp(block: ServerSSESession.() -> Server) { | |
| ) | ||
| } | ||
|
|
||
| if (enableDnsRebindingProtection) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this pattern repeats multiple times, can be private utility method |
||
| install(DnsRebindingProtection) { | ||
| this.allowedHosts = allowedHosts ?: LOCALHOST_ALLOWED_HOSTS | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would rename the argument to avoid |
||
| allowedOrigins?.let { this.allowedOrigins = it } | ||
| } | ||
| } | ||
|
|
||
| val transportManager = TransportManager<SseServerTransport>() | ||
|
|
||
| sse { | ||
|
|
@@ -86,20 +111,32 @@ public fun Route.mcp(block: ServerSSESession.() -> Server) { | |
| * over [Server-Sent Events (SSE) Transport](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse) | ||
| * and sets up routing with the provided configuration block. | ||
| * | ||
| * @param enableDnsRebindingProtection whether to install [DnsRebindingProtection] on this route. Defaults to `true`. | ||
| * @param allowedHosts hostnames allowed in the `Host` header. Defaults to [LOCALHOST_ALLOWED_HOSTS]. | ||
| * @param allowedOrigins origins allowed in the `Origin` header, or `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, | ||
| ) { | ||
| 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, | ||
| ) { | ||
|
|
@@ -109,6 +146,13 @@ private fun Application.mcpStreamableHttp( | |
|
|
||
| routing { | ||
| route(path) { | ||
| if (enableDnsRebindingProtection) { | ||
| install(DnsRebindingProtection) { | ||
| this.allowedHosts = allowedHosts ?: LOCALHOST_ALLOWED_HOSTS | ||
| allowedOrigins?.let { this.allowedOrigins = it } | ||
| } | ||
| } | ||
|
|
||
| sse { | ||
| val transport = existingStreamableTransport(call, transportManager) ?: return@sse | ||
| transport.handleRequest(this, call) | ||
|
|
@@ -140,10 +184,11 @@ private fun Application.mcpStreamableHttp( | |
| * Simple request/response pairs are returned as JSON (not SSE streams). | ||
| * | ||
| * @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 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_ALLOWED_HOSTS]. | ||
| * @param allowedOrigins A list of origins allowed to perform cross-origin requests (CORS). | ||
| * If `null`, no restrictions are applied. | ||
| * 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) | ||
|
|
@@ -153,34 +198,45 @@ private fun Application.mcpStreamableHttp( | |
| @Suppress("LongParameterList") | ||
| public fun Application.mcpStreamableHttp( | ||
| path: String = "/mcp", | ||
| enableDnsRebindingProtection: Boolean = false, | ||
| enableDnsRebindingProtection: Boolean = true, | ||
| allowedHosts: List<String>? = null, | ||
| allowedOrigins: List<String>? = null, | ||
| eventStore: EventStore? = null, | ||
| block: RoutingContext.() -> Server, | ||
| ) { | ||
| mcpStreamableHttp( | ||
| path = path, | ||
| enableDnsRebindingProtection = enableDnsRebindingProtection, | ||
| allowedHosts = allowedHosts, | ||
| allowedOrigins = allowedOrigins, | ||
| configuration = StreamableHttpServerTransport.Configuration( | ||
| enableDnsRebindingProtection = enableDnsRebindingProtection, | ||
| allowedHosts = allowedHosts, | ||
| allowedOrigins = allowedOrigins, | ||
| eventStore = eventStore, | ||
| enableJsonResponse = true, | ||
| ), | ||
| block = block, | ||
| ) | ||
| } | ||
|
|
||
| @Suppress("LongParameterList") | ||
| private fun Application.mcpStatelessStreamableHttp( | ||
| path: String = "/mcp", | ||
| enableDnsRebindingProtection: Boolean, | ||
| allowedHosts: List<String>?, | ||
| allowedOrigins: List<String>?, | ||
| configuration: StreamableHttpServerTransport.Configuration, | ||
| block: RoutingContext.() -> Server, | ||
| ) { | ||
| install(SSE) | ||
|
|
||
| routing { | ||
| route(path) { | ||
| if (enableDnsRebindingProtection) { | ||
| install(DnsRebindingProtection) { | ||
| this.allowedHosts = allowedHosts ?: LOCALHOST_ALLOWED_HOSTS | ||
| allowedOrigins?.let { this.allowedOrigins = it } | ||
| } | ||
| } | ||
|
|
||
| post { | ||
| mcpStatelessStreamableHttpEndpoint( | ||
| configuration = configuration, | ||
|
|
@@ -213,9 +269,10 @@ private fun Application.mcpStatelessStreamableHttp( | |
| * Simple request/response pairs are returned as JSON (not SSE streams). | ||
| * | ||
| * @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_ALLOWED_HOSTS]. | ||
| * @param allowedOrigins A list of allowed origins for CORS. 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. | ||
|
|
@@ -224,18 +281,18 @@ private fun Application.mcpStatelessStreamableHttp( | |
| @Suppress("LongParameterList") | ||
| public fun Application.mcpStatelessStreamableHttp( | ||
| path: String = "/mcp", | ||
| enableDnsRebindingProtection: Boolean = false, | ||
| enableDnsRebindingProtection: Boolean = true, | ||
| allowedHosts: List<String>? = null, | ||
| allowedOrigins: List<String>? = null, | ||
| eventStore: EventStore? = null, | ||
| block: RoutingContext.() -> Server, | ||
| ) { | ||
| mcpStatelessStreamableHttp( | ||
| path = path, | ||
| enableDnsRebindingProtection = enableDnsRebindingProtection, | ||
| allowedHosts = allowedHosts, | ||
| allowedOrigins = allowedOrigins, | ||
| configuration = StreamableHttpServerTransport.Configuration( | ||
| enableDnsRebindingProtection = enableDnsRebindingProtection, | ||
| allowedHosts = allowedHosts, | ||
| allowedOrigins = allowedOrigins, | ||
| eventStore = eventStore, | ||
| enableJsonResponse = true, | ||
| ), | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
when input is "[]", indexOf(']') returns 1, tail is empty, and the function returns "[]". While this won't match any real allowedHosts entry (so no bypass), an attacker probing the parser with malformed headers gets inconsistent treatment (other malformed inputs return null). The TypeScript SDK rejects empty brackets.
Recommendation: Add a guard: if (end <= 1) null (bracket pair must contain at least one character).
Test to add: