diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java index 725a67aee..7a39c0504 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java @@ -89,7 +89,7 @@ protected void validateOrigin(String origin) throws ServerTransportSecurityExcep else if (allowed.endsWith(":*")) { // Wildcard port pattern: "http://example.com:*" String baseOrigin = allowed.substring(0, allowed.length() - 2); - if (origin.equals(baseOrigin) || origin.startsWith(baseOrigin + ":")) { + if (origin.equals(baseOrigin) || matchesWithPort(origin, baseOrigin)) { return; } } @@ -121,7 +121,7 @@ private void validateHost(String host) throws ServerTransportSecurityException { else if (allowed.endsWith(":*")) { // Wildcard port pattern: "example.com:*" String baseHost = allowed.substring(0, allowed.length() - 2); - if (host.equals(baseHost) || host.startsWith(baseHost + ":")) { + if (host.equals(baseHost) || matchesWithPort(host, baseHost)) { return; } } @@ -130,6 +130,28 @@ else if (allowed.endsWith(":*")) { throw new ServerTransportSecurityException(421, "Invalid Host header"); } + /** + * Checks that {@code actual} is {@code allowed} followed by {@code ":"} and a valid + * port: 1 to 5 ASCII digits, in the range 1-65535. + */ + private static boolean matchesWithPort(String actual, String allowed) { + if (!actual.startsWith(allowed + ":")) { + return false; + } + String port = actual.substring(allowed.length() + 1); + if (port.isEmpty() || port.length() > 5) { + return false; + } + for (int i = 0; i < port.length(); i++) { + char c = port.charAt(i); + if (c < '0' || c > '9') { + return false; + } + } + int portNumber = Integer.parseInt(port); + return portNumber >= 1 && portNumber <= 65535; + } + /** * Creates a new builder for constructing a DefaultServerTransportSecurityValidator. * @return A new builder instance diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidatorTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidatorTests.java index 659dfeb2d..7fab7b85e 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidatorTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidatorTests.java @@ -10,6 +10,8 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -118,6 +120,25 @@ void differentPortWithWildcard() { assertThatCode(() -> wildcardValidator.validate(accessor)).doesNotThrowAnyException(); } + @ParameterizedTest + @ValueSource(strings = { "http://localhost:1", "http://localhost:65535" }) + void boundaryPortsWithWildcard(String origin) { + var accessor = originAccessor(origin); + + assertThatCode(() -> wildcardValidator.validate(accessor)).doesNotThrowAnyException(); + } + + @ParameterizedTest + @ValueSource(strings = { "http://localhost:", "http://localhost:evil.com", "http://localhost:3000.evil.com", + "http://localhost:@evil.com", "http://localhost:80a", "http://localhost: 80", + "http://localhost:3000/path", "http://localhost:0", "http://localhost:65536", + "http://localhost:123456", "http://localhost:-1", "http://localhost:+80" }) + void invalidPortWithWildcard(String origin) { + var accessor = originAccessor(origin); + + assertThatThrownBy(() -> wildcardValidator.validate(accessor)).isEqualTo(INVALID_ORIGIN); + } + @Test void differentHostWithWildcard() { var accessor = originAccessor("http://example.com:3000"); @@ -273,6 +294,33 @@ void noPort() { assertThatCode(() -> wildcardHostValidator.validate(accessor)).doesNotThrowAnyException(); } + @ParameterizedTest + @ValueSource(strings = { "localhost:1", "localhost:65535" }) + void boundaryPorts(String host) { + var accessor = hostAccessor(host); + + assertThatCode(() -> wildcardHostValidator.validate(accessor)).doesNotThrowAnyException(); + } + + @ParameterizedTest + @ValueSource(strings = { "localhost:", "localhost:evil.com", "localhost:3000.evil.com", + "localhost:@evil.com", "localhost:80a", "localhost: 80", "localhost:3000/path", "localhost:0", + "localhost:65536", "localhost:123456", "localhost:-1", "localhost:+80" }) + void invalidPort(String host) { + var accessor = hostAccessor(host); + + assertThatThrownBy(() -> wildcardHostValidator.validate(accessor)).isEqualTo(INVALID_HOST); + } + + @ParameterizedTest + @ValueSource(strings = { "127.0.0.1:evil.com", "127.0.0.1:80.evil.com", "127.0.0.1:@evil.com" }) + void nonNumericPortIpv4(String host) { + var ipValidator = DefaultServerTransportSecurityValidator.builder().allowedHost("127.0.0.1:*").build(); + + assertThatThrownBy(() -> ipValidator.validate(hostAccessor(host))).isEqualTo(INVALID_HOST); + assertThatCode(() -> ipValidator.validate(hostAccessor("127.0.0.1:8080"))).doesNotThrowAnyException(); + } + @Test void differentHost() { var accessor = hostAccessor("example.com:3000");