DNS rebinding protection: check host header

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
Daniel Garnier-Moiroux 2026-02-05 16:12:04 +01:00 • committed by Daniel Garnier-Moiroux
commit f6c3fde7f4
5 changed files with 551 additions and 119 deletions

View file

@ -12,10 +12,11 @@ import io.modelcontextprotocol.util.Assert;
/** /**
* Default implementation of {@link ServerTransportSecurityValidator} that validates the * Default implementation of {@link ServerTransportSecurityValidator} that validates the
* Origin header against a list of allowed origins. * Origin and Host headers against lists of allowed values.
* *
* <p> * <p>
* Supports exact matches and wildcard port patterns (e.g., "http://example.com:*"). * Supports exact matches and wildcard port patterns (e.g., "http://example.com:*" for
* origins, "example.com:*" for hosts).
* *
* @author Daniel Garnier-Moiroux * @author Daniel Garnier-Moiroux
* @see ServerTransportSecurityValidator * @see ServerTransportSecurityValidator
@ -25,28 +26,48 @@ public final class DefaultServerTransportSecurityValidator implements ServerTran
private static final String ORIGIN_HEADER = "Origin"; private static final String ORIGIN_HEADER = "Origin";
private static final String HOST_HEADER = "Host";
private final List<String> allowedOrigins; private final List<String> allowedOrigins;
private final List<String> allowedHosts;
/** /**
* Creates a new validator with the specified allowed origins. * Creates a new validator with the specified allowed origins and hosts.
* @param allowedOrigins List of allowed origin patterns. Supports exact matches * @param allowedOrigins List of allowed origin patterns. Supports exact matches
* (e.g., "http://example.com:8080") and wildcard ports (e.g., "http://example.com:*") * (e.g., "http://example.com:8080") and wildcard ports (e.g., "http://example.com:*")
* @param allowedHosts List of allowed host patterns. Supports exact matches (e.g.,
* "example.com:8080") and wildcard ports (e.g., "example.com:*")
*/ */
private DefaultServerTransportSecurityValidator(List<String> allowedOrigins) { private DefaultServerTransportSecurityValidator(List<String> allowedOrigins, List<String> allowedHosts) {
Assert.notNull(allowedOrigins, "allowedOrigins must not be null"); Assert.notNull(allowedOrigins, "allowedOrigins must not be null");
Assert.notNull(allowedHosts, "allowedHosts must not be null");
this.allowedOrigins = allowedOrigins; this.allowedOrigins = allowedOrigins;
this.allowedHosts = allowedHosts;
} }
@Override @Override
public void validateHeaders(Map<String, List<String>> headers) throws ServerTransportSecurityException { public void validateHeaders(Map<String, List<String>> headers) throws ServerTransportSecurityException {
boolean missingHost = true;
for (Map.Entry<String, List<String>> entry : headers.entrySet()) { for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
if (ORIGIN_HEADER.equalsIgnoreCase(entry.getKey())) { if (ORIGIN_HEADER.equalsIgnoreCase(entry.getKey())) {
List<String> values = entry.getValue(); List<String> values = entry.getValue();
if (values != null && !values.isEmpty()) { if (values == null || values.isEmpty()) {
validateOrigin(values.get(0)); throw new ServerTransportSecurityException(403, "Invalid Origin header");
} }
break; validateOrigin(values.get(0));
} }
else if (HOST_HEADER.equalsIgnoreCase(entry.getKey())) {
missingHost = false;
List<String> values = entry.getValue();
if (values == null || values.isEmpty()) {
throw new ServerTransportSecurityException(421, "Invalid Host header");
}
validateHost(values.get(0));
}
}
if (!allowedHosts.isEmpty() && missingHost) {
throw new ServerTransportSecurityException(421, "Invalid Host header");
} }
} }
@ -79,6 +100,37 @@ public final class DefaultServerTransportSecurityValidator implements ServerTran
throw new ServerTransportSecurityException(403, "Invalid Origin header"); throw new ServerTransportSecurityException(403, "Invalid Origin header");
} }
/**
* Validates a single host value against the allowed hosts.
* @param host The host header value, or null if not present
* @throws ServerTransportSecurityException if the host is not allowed
*/
private void validateHost(String host) throws ServerTransportSecurityException {
if (allowedHosts.isEmpty()) {
return;
}
// Host is required
if (host == null || host.isBlank()) {
throw new ServerTransportSecurityException(421, "Invalid Host header");
}
for (String allowed : allowedHosts) {
if (allowed.equals(host)) {
return;
}
else if (allowed.endsWith(":*")) {
// Wildcard port pattern: "example.com:*"
String baseHost = allowed.substring(0, allowed.length() - 2);
if (host.equals(baseHost) || host.startsWith(baseHost + ":")) {
return;
}
}
}
throw new ServerTransportSecurityException(421, "Invalid Host header");
}
/** /**
* Creates a new builder for constructing a DefaultServerTransportSecurityValidator. * Creates a new builder for constructing a DefaultServerTransportSecurityValidator.
* @return A new builder instance * @return A new builder instance
@ -94,6 +146,8 @@ public final class DefaultServerTransportSecurityValidator implements ServerTran
private final List<String> allowedOrigins = new ArrayList<>(); private final List<String> allowedOrigins = new ArrayList<>();
private final List<String> allowedHosts = new ArrayList<>();
/** /**
* Adds an allowed origin pattern. * Adds an allowed origin pattern.
* @param origin The origin to allow (e.g., "http://localhost:8080" or * @param origin The origin to allow (e.g., "http://localhost:8080" or
@ -116,12 +170,33 @@ public final class DefaultServerTransportSecurityValidator implements ServerTran
return this; return this;
} }
/**
* Adds an allowed host pattern.
* @param host The host to allow (e.g., "localhost:8080" or "example.com:*")
* @return this builder instance
*/
public Builder allowedHost(String host) {
this.allowedHosts.add(host);
return this;
}
/**
* Adds multiple allowed host patterns.
* @param hosts The hosts to allow
* @return this builder instance
*/
public Builder allowedHosts(List<String> hosts) {
Assert.notNull(hosts, "hosts must not be null");
this.allowedHosts.addAll(hosts);
return this;
}
/** /**
* Builds the validator instance. * Builds the validator instance.
* @return A new DefaultServerTransportSecurityValidator * @return A new DefaultServerTransportSecurityValidator
*/ */
public DefaultServerTransportSecurityValidator build() { public DefaultServerTransportSecurityValidator build() {
return new DefaultServerTransportSecurityValidator(allowedOrigins); return new DefaultServerTransportSecurityValidator(allowedOrigins, allowedHosts);
} }
} }

View file

@ -22,6 +22,9 @@ class DefaultServerTransportSecurityValidatorTests {
private static final ServerTransportSecurityException INVALID_ORIGIN = new ServerTransportSecurityException(403, private static final ServerTransportSecurityException INVALID_ORIGIN = new ServerTransportSecurityException(403,
"Invalid Origin header"); "Invalid Origin header");
private static final ServerTransportSecurityException INVALID_HOST = new ServerTransportSecurityException(421,
"Invalid Host header");
private final DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder() private final DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder()
.allowedOrigin("http://localhost:8080") .allowedOrigin("http://localhost:8080")
.build(); .build();
@ -31,161 +34,370 @@ class DefaultServerTransportSecurityValidatorTests {
assertThatCode(() -> DefaultServerTransportSecurityValidator.builder().build()).doesNotThrowAnyException(); assertThatCode(() -> DefaultServerTransportSecurityValidator.builder().build()).doesNotThrowAnyException();
assertThatThrownBy(() -> DefaultServerTransportSecurityValidator.builder().allowedOrigins(null).build()) assertThatThrownBy(() -> DefaultServerTransportSecurityValidator.builder().allowedOrigins(null).build())
.isInstanceOf(IllegalArgumentException.class); .isInstanceOf(IllegalArgumentException.class);
} assertThatThrownBy(() -> DefaultServerTransportSecurityValidator.builder().allowedHosts(null).build())
.isInstanceOf(IllegalArgumentException.class);
@Test
void originHeaderMissing() {
assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException();
}
@Test
void originHeaderListEmpty() {
assertThatCode(() -> validator.validateHeaders(Map.of("Origin", List.of()))).doesNotThrowAnyException();
}
@Test
void caseInsensitive() {
var headers = Map.of("origin", List.of("http://localhost:8080"));
assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void exactMatch() {
var headers = originHeader("http://localhost:8080");
assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void differentPort() {
var headers = originHeader("http://localhost:3000");
assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN);
}
@Test
void differentHost() {
var headers = originHeader("http://example.com:8080");
assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN);
}
@Test
void differentScheme() {
var headers = originHeader("https://localhost:8080");
assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN);
} }
@Nested @Nested
class WildcardPort { class OriginHeader {
private final DefaultServerTransportSecurityValidator wildcardValidator = DefaultServerTransportSecurityValidator
.builder()
.allowedOrigin("http://localhost:*")
.build();
@Test @Test
void anyPortWithWildcard() { void originHeaderMissing() {
var headers = originHeader("http://localhost:3000"); assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException();
assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException();
} }
@Test @Test
void noPortWithWildcard() { void originHeaderListEmpty() {
var headers = originHeader("http://localhost"); assertThatThrownBy(() -> validator.validateHeaders(Map.of("Origin", List.of()))).isEqualTo(INVALID_ORIGIN);
assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException();
} }
@Test @Test
void differentPortWithWildcard() { void caseInsensitive() {
var headers = originHeader("http://localhost:8080"); var headers = Map.of("origin", List.of("http://localhost:8080"));
assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void differentHostWithWildcard() {
var headers = originHeader("http://example.com:3000");
assertThatThrownBy(() -> wildcardValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN);
}
@Test
void differentSchemeWithWildcard() {
var headers = originHeader("https://localhost:3000");
assertThatThrownBy(() -> wildcardValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN);
}
}
@Nested
class MultipleOrigins {
DefaultServerTransportSecurityValidator multipleOriginsValidator = DefaultServerTransportSecurityValidator
.builder()
.allowedOrigin("http://localhost:8080")
.allowedOrigin("http://example.com:3000")
.allowedOrigin("http://myapp.com:*")
.build();
@Test
void matchingOneOfMultiple() {
var headers = originHeader("http://example.com:3000");
assertThatCode(() -> multipleOriginsValidator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void matchingWildcardInMultiple() {
var headers = originHeader("http://myapp.com:9999");
assertThatCode(() -> multipleOriginsValidator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void notMatchingAny() {
var headers = originHeader("http://malicious.example.com:1234");
assertThatThrownBy(() -> multipleOriginsValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN);
}
}
@Nested
class BuilderTests {
@Test
void shouldAddMultipleOriginsWithAllowedOriginsMethod() {
DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder()
.allowedOrigins(List.of("http://localhost:8080", "http://example.com:*"))
.build();
var headers = originHeader("http://example.com:3000");
assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException();
} }
@Test @Test
void shouldCombineAllowedOriginMethods() { void exactMatch() {
DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder() var headers = originHeader("http://localhost:8080");
.allowedOrigin("http://localhost:8080")
.allowedOrigins(List.of("http://example.com:*", "http://test.com:3000")) assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void differentPort() {
var headers = originHeader("http://localhost:3000");
assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN);
}
@Test
void differentHost() {
var headers = originHeader("http://example.com:8080");
assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN);
}
@Test
void differentScheme() {
var headers = originHeader("https://localhost:8080");
assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN);
}
@Nested
class WildcardPort {
private final DefaultServerTransportSecurityValidator wildcardValidator = DefaultServerTransportSecurityValidator
.builder()
.allowedOrigin("http://localhost:*")
.build(); .build();
assertThatCode(() -> validator.validateHeaders(originHeader("http://localhost:8080"))) @Test
.doesNotThrowAnyException(); void anyPortWithWildcard() {
assertThatCode(() -> validator.validateHeaders(originHeader("http://example.com:9999"))) var headers = originHeader("http://localhost:3000");
.doesNotThrowAnyException();
assertThatCode(() -> validator.validateHeaders(originHeader("http://test.com:3000"))) assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException();
.doesNotThrowAnyException(); }
@Test
void noPortWithWildcard() {
var headers = originHeader("http://localhost");
assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void differentPortWithWildcard() {
var headers = originHeader("http://localhost:8080");
assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void differentHostWithWildcard() {
var headers = originHeader("http://example.com:3000");
assertThatThrownBy(() -> wildcardValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN);
}
@Test
void differentSchemeWithWildcard() {
var headers = originHeader("https://localhost:3000");
assertThatThrownBy(() -> wildcardValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN);
}
}
@Nested
class MultipleOrigins {
DefaultServerTransportSecurityValidator multipleOriginsValidator = DefaultServerTransportSecurityValidator
.builder()
.allowedOrigin("http://localhost:8080")
.allowedOrigin("http://example.com:3000")
.allowedOrigin("http://myapp.example.com:*")
.build();
@Test
void matchingOneOfMultiple() {
var headers = originHeader("http://example.com:3000");
assertThatCode(() -> multipleOriginsValidator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void matchingWildcardInMultiple() {
var headers = originHeader("http://myapp.example.com:9999");
assertThatCode(() -> multipleOriginsValidator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void notMatchingAny() {
var headers = originHeader("http://malicious.example.com:1234");
assertThatThrownBy(() -> multipleOriginsValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN);
}
}
@Nested
class BuilderTests {
@Test
void shouldAddMultipleOriginsWithAllowedOriginsMethod() {
DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder()
.allowedOrigins(List.of("http://localhost:8080", "http://example.com:*"))
.build();
var headers = originHeader("http://example.com:3000");
assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void shouldCombineAllowedOriginMethods() {
DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder()
.allowedOrigin("http://localhost:8080")
.allowedOrigins(List.of("http://example.com:*", "http://test.com:3000"))
.build();
assertThatCode(() -> validator.validateHeaders(originHeader("http://localhost:8080")))
.doesNotThrowAnyException();
assertThatCode(() -> validator.validateHeaders(originHeader("http://example.com:9999")))
.doesNotThrowAnyException();
assertThatCode(() -> validator.validateHeaders(originHeader("http://test.com:3000")))
.doesNotThrowAnyException();
}
}
}
@Nested
class HostHeader {
private final DefaultServerTransportSecurityValidator hostValidator = DefaultServerTransportSecurityValidator
.builder()
.allowedHost("localhost:8080")
.build();
@Test
void notConfigured() {
assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException();
}
@Test
void missing() {
assertThatThrownBy(() -> hostValidator.validateHeaders(new HashMap<>())).isEqualTo(INVALID_HOST);
}
@Test
void listEmpty() {
assertThatThrownBy(() -> hostValidator.validateHeaders(Map.of("Host", List.of()))).isEqualTo(INVALID_HOST);
}
@Test
void caseInsensitive() {
var headers = Map.of("host", List.of("localhost:8080"));
assertThatCode(() -> hostValidator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void exactMatch() {
var headers = hostHeader("localhost:8080");
assertThatCode(() -> hostValidator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void differentPort() {
var headers = hostHeader("localhost:3000");
assertThatThrownBy(() -> hostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST);
}
@Test
void differentHost() {
var headers = hostHeader("example.com:8080");
assertThatThrownBy(() -> hostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST);
}
@Nested
class HostWildcardPort {
private final DefaultServerTransportSecurityValidator wildcardHostValidator = DefaultServerTransportSecurityValidator
.builder()
.allowedHost("localhost:*")
.build();
@Test
void anyPort() {
var headers = hostHeader("localhost:3000");
assertThatCode(() -> wildcardHostValidator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void noPort() {
var headers = hostHeader("localhost");
assertThatCode(() -> wildcardHostValidator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void differentHost() {
var headers = hostHeader("example.com:3000");
assertThatThrownBy(() -> wildcardHostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST);
}
}
@Nested
class MultipleHosts {
DefaultServerTransportSecurityValidator multipleHostsValidator = DefaultServerTransportSecurityValidator
.builder()
.allowedHost("example.com:3000")
.allowedHost("myapp.example.com:*")
.build();
@Test
void exactMatch() {
var headers = hostHeader("example.com:3000");
assertThatCode(() -> multipleHostsValidator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void wildcard() {
var headers = hostHeader("myapp.example.com:9999");
assertThatCode(() -> multipleHostsValidator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void differentHost() {
var headers = hostHeader("malicious.example.com:3000");
assertThatThrownBy(() -> multipleHostsValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST);
}
@Test
void differentPort() {
var headers = hostHeader("localhost:8080");
assertThatThrownBy(() -> multipleHostsValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST);
}
}
@Nested
class HostBuilderTests {
@Test
void multipleHosts() {
DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder()
.allowedHosts(List.of("localhost:8080", "example.com:*"))
.build();
assertThatCode(() -> validator.validateHeaders(hostHeader("example.com:3000")))
.doesNotThrowAnyException();
assertThatCode(() -> validator.validateHeaders(hostHeader("localhost:8080")))
.doesNotThrowAnyException();
}
@Test
void combined() {
DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder()
.allowedHost("localhost:8080")
.allowedHosts(List.of("example.com:*", "test.com:3000"))
.build();
assertThatCode(() -> validator.validateHeaders(hostHeader("localhost:8080")))
.doesNotThrowAnyException();
assertThatCode(() -> validator.validateHeaders(hostHeader("example.com:9999")))
.doesNotThrowAnyException();
assertThatCode(() -> validator.validateHeaders(hostHeader("test.com:3000"))).doesNotThrowAnyException();
}
}
}
@Nested
class CombinedOriginAndHostValidation {
private final DefaultServerTransportSecurityValidator combinedValidator = DefaultServerTransportSecurityValidator
.builder()
.allowedOrigin("http://localhost:*")
.allowedHost("localhost:*")
.build();
@Test
void bothValid() {
var header = headers("http://localhost:8080", "localhost:8080");
assertThatCode(() -> combinedValidator.validateHeaders(header)).doesNotThrowAnyException();
}
@Test
void originValidHostInvalid() {
var header = headers("http://localhost:8080", "malicious.example.com:8080");
assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_HOST);
}
@Test
void originInvalidHostValid() {
var header = headers("http://malicious.example.com:8080", "localhost:8080");
assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_ORIGIN);
}
@Test
void originMissingHostValid() {
// Origin missing is OK (same-origin request)
var header = headers(null, "localhost:8080");
assertThatCode(() -> combinedValidator.validateHeaders(header)).doesNotThrowAnyException();
}
@Test
void originValidHostMissing() {
// Host missing is NOT OK when allowedHosts is configured
var header = headers("http://localhost:8080", null);
assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_HOST);
} }
} }
@ -194,4 +406,19 @@ class DefaultServerTransportSecurityValidatorTests {
return Map.of("Origin", List.of(origin)); return Map.of("Origin", List.of(origin));
} }
private static Map<String, List<String>> hostHeader(String host) {
return Map.of("Host", List.of(host));
}
private static Map<String, List<String>> headers(String origin, String host) {
var map = new HashMap<String, List<String>>();
if (origin != null) {
map.put("Origin", List.of(origin));
}
if (host != null) {
map.put("Host", List.of(host));
}
return map;
}
} }

View file

@ -58,6 +58,8 @@ public class WebFluxServerTransportSecurityIntegrationTests {
private static final String DISALLOWED_ORIGIN = "https://malicious.example.com"; private static final String DISALLOWED_ORIGIN = "https://malicious.example.com";
private static final String DISALLOWED_HOST = "malicious.example.com:8080";
@Parameter @Parameter
private static Transport transport; private static Transport transport;
@ -79,7 +81,7 @@ public class WebFluxServerTransportSecurityIntegrationTests {
private McpSyncClient mcpClient; private McpSyncClient mcpClient;
private final TestOriginHeaderExchangeFilterFunction exchangeFilterFunction = new TestOriginHeaderExchangeFilterFunction(); private final TestHeaderExchangeFilterFunction exchangeFilterFunction = new TestHeaderExchangeFilterFunction();
@BeforeEach @BeforeEach
void setUp() { void setUp() {
@ -125,6 +127,29 @@ public class WebFluxServerTransportSecurityIntegrationTests {
assertThatThrownBy(() -> mcpClient.listTools()); assertThatThrownBy(() -> mcpClient.listTools());
} }
@Test
void hostAllowed() {
// Host header is set by default by WebClient to the request URI host
var result = mcpClient.initialize();
var tools = mcpClient.listTools();
assertThat(result.protocolVersion()).isNotEmpty();
assertThat(tools.tools()).isEmpty();
}
@Test
void connectHostNotAllowed() {
exchangeFilterFunction.setHostHeader(DISALLOWED_HOST);
assertThatThrownBy(() -> mcpClient.initialize());
}
@Test
void messageHostNotAllowed() {
mcpClient.initialize();
exchangeFilterFunction.setHostHeader(DISALLOWED_HOST);
assertThatThrownBy(() -> mcpClient.listTools());
}
// ---------------------------------------------------- // ----------------------------------------------------
// Server management // Server management
// ---------------------------------------------------- // ----------------------------------------------------
@ -165,7 +190,7 @@ public class WebFluxServerTransportSecurityIntegrationTests {
*/ */
interface Transport { interface Transport {
McpSyncClient createMcpClient(String baseUrl, TestOriginHeaderExchangeFilterFunction customizer); McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction customizer);
RouterFunction<?> routerFunction(); RouterFunction<?> routerFunction();
@ -181,8 +206,10 @@ public class WebFluxServerTransportSecurityIntegrationTests {
public Sse() { public Sse() {
transportProvider = WebFluxSseServerTransportProvider.builder() transportProvider = WebFluxSseServerTransportProvider.builder()
.messageEndpoint("/mcp/message") .messageEndpoint("/mcp/message")
.securityValidator( .securityValidator(DefaultServerTransportSecurityValidator.builder()
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build()) .allowedOrigin("http://localhost:*")
.allowedHost("localhost:*")
.build())
.build(); .build();
McpServer.sync(transportProvider) McpServer.sync(transportProvider)
.serverInfo("test-server", "1.0.0") .serverInfo("test-server", "1.0.0")
@ -191,8 +218,7 @@ public class WebFluxServerTransportSecurityIntegrationTests {
} }
@Override @Override
public McpSyncClient createMcpClient(String baseUrl, public McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction exchangeFilterFunction) {
TestOriginHeaderExchangeFilterFunction exchangeFilterFunction) {
var transport = WebFluxSseClientTransport var transport = WebFluxSseClientTransport
.builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction)) .builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction))
.jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper())
@ -213,8 +239,10 @@ public class WebFluxServerTransportSecurityIntegrationTests {
public StreamableHttp() { public StreamableHttp() {
transportProvider = WebFluxStreamableServerTransportProvider.builder() transportProvider = WebFluxStreamableServerTransportProvider.builder()
.securityValidator( .securityValidator(DefaultServerTransportSecurityValidator.builder()
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build()) .allowedOrigin("http://localhost:*")
.allowedHost("localhost:*")
.build())
.build(); .build();
McpServer.sync(transportProvider) McpServer.sync(transportProvider)
.serverInfo("test-server", "1.0.0") .serverInfo("test-server", "1.0.0")
@ -223,8 +251,7 @@ public class WebFluxServerTransportSecurityIntegrationTests {
} }
@Override @Override
public McpSyncClient createMcpClient(String baseUrl, public McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction exchangeFilterFunction) {
TestOriginHeaderExchangeFilterFunction exchangeFilterFunction) {
var transport = WebClientStreamableHttpTransport var transport = WebClientStreamableHttpTransport
.builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction)) .builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction))
.jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper())
@ -246,8 +273,10 @@ public class WebFluxServerTransportSecurityIntegrationTests {
public Stateless() { public Stateless() {
transportProvider = WebFluxStatelessServerTransport.builder() transportProvider = WebFluxStatelessServerTransport.builder()
.securityValidator( .securityValidator(DefaultServerTransportSecurityValidator.builder()
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build()) .allowedOrigin("http://localhost:*")
.allowedHost("localhost:*")
.build())
.build(); .build();
McpServer.sync(transportProvider) McpServer.sync(transportProvider)
.serverInfo("test-server", "1.0.0") .serverInfo("test-server", "1.0.0")
@ -256,8 +285,7 @@ public class WebFluxServerTransportSecurityIntegrationTests {
} }
@Override @Override
public McpSyncClient createMcpClient(String baseUrl, public McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction exchangeFilterFunction) {
TestOriginHeaderExchangeFilterFunction exchangeFilterFunction) {
var transport = WebClientStreamableHttpTransport var transport = WebClientStreamableHttpTransport
.builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction)) .builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction))
.jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper())
@ -273,18 +301,30 @@ public class WebFluxServerTransportSecurityIntegrationTests {
} }
static class TestOriginHeaderExchangeFilterFunction implements ExchangeFilterFunction { static class TestHeaderExchangeFilterFunction implements ExchangeFilterFunction {
private String origin = null; private String origin = null;
private String host = null;
public void setOriginHeader(String origin) { public void setOriginHeader(String origin) {
this.origin = origin; this.origin = origin;
} }
public void setHostHeader(String host) {
this.host = host;
}
@Override @Override
public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) { public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
var updatedRequest = ClientRequest.from(request).header("origin", this.origin).build(); var builder = ClientRequest.from(request);
return next.exchange(updatedRequest); if (this.origin != null) {
builder.header("Origin", this.origin);
}
if (this.host != null) {
builder.header("Host", this.host);
}
return next.exchange(builder.build());
} }
} }

View file

@ -63,6 +63,8 @@ public class ServerTransportSecurityIntegrationTests {
private static final String DISALLOWED_ORIGIN = "https://malicious.example.com"; private static final String DISALLOWED_ORIGIN = "https://malicious.example.com";
private static final String DISALLOWED_HOST = "malicious.example.com:8080";
@Parameter @Parameter
private static Class<?> configClass; private static Class<?> configClass;
@ -90,6 +92,7 @@ public class ServerTransportSecurityIntegrationTests {
void setUp() { void setUp() {
mcpClient = tomcatServer.appContext().getBean(McpSyncClient.class); mcpClient = tomcatServer.appContext().getBean(McpSyncClient.class);
requestCustomizer = tomcatServer.appContext().getBean(TestRequestCustomizer.class); requestCustomizer = tomcatServer.appContext().getBean(TestRequestCustomizer.class);
requestCustomizer.reset();
} }
@AfterEach @AfterEach
@ -131,6 +134,29 @@ public class ServerTransportSecurityIntegrationTests {
assertThatThrownBy(() -> mcpClient.listTools()); assertThatThrownBy(() -> mcpClient.listTools());
} }
@Test
void hostAllowed() {
// Host header is set by default by HttpClient to the request URI host
var result = mcpClient.initialize();
var tools = mcpClient.listTools();
assertThat(result.protocolVersion()).isNotEmpty();
assertThat(tools.tools()).isEmpty();
}
@Test
void connectHostNotAllowed() {
requestCustomizer.setHostHeader(DISALLOWED_HOST);
assertThatThrownBy(() -> mcpClient.initialize());
}
@Test
void messageHostNotAllowed() {
mcpClient.initialize();
requestCustomizer.setHostHeader(DISALLOWED_HOST);
assertThatThrownBy(() -> mcpClient.listTools());
}
// ---------------------------------------------------- // ----------------------------------------------------
// Tomcat management // Tomcat management
// ---------------------------------------------------- // ----------------------------------------------------
@ -195,7 +221,10 @@ public class ServerTransportSecurityIntegrationTests {
@Bean @Bean
DefaultServerTransportSecurityValidator validator() { DefaultServerTransportSecurityValidator validator() {
return DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build(); return DefaultServerTransportSecurityValidator.builder()
.allowedOrigin("http://localhost:*")
.allowedHost("localhost:*")
.build();
} }
} }
@ -318,18 +347,32 @@ public class ServerTransportSecurityIntegrationTests {
private String originHeader = null; private String originHeader = null;
private String hostHeader = null;
@Override @Override
public void customize(HttpRequest.Builder builder, String method, URI endpoint, String body, public void customize(HttpRequest.Builder builder, String method, URI endpoint, String body,
McpTransportContext context) { McpTransportContext context) {
if (originHeader != null) { if (originHeader != null) {
builder.header("Origin", originHeader); builder.header("Origin", originHeader);
} }
if (hostHeader != null) {
builder.header("Host", hostHeader);
}
} }
public void setOriginHeader(String originHeader) { public void setOriginHeader(String originHeader) {
this.originHeader = originHeader; this.originHeader = originHeader;
} }
public void setHostHeader(String hostHeader) {
this.hostHeader = hostHeader;
}
public void reset() {
this.originHeader = null;
this.hostHeader = null;
}
} }
} }

View file

@ -48,6 +48,8 @@ class ServerTransportSecurityIntegrationTests {
private static final String DISALLOWED_ORIGIN = "https://malicious.example.com"; private static final String DISALLOWED_ORIGIN = "https://malicious.example.com";
private static final String DISALLOWED_HOST = "malicious.example.com:8080";
@Parameter @Parameter
private static Transport transport; private static Transport transport;
@ -73,6 +75,7 @@ class ServerTransportSecurityIntegrationTests {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
requestCustomizer.reset();
mcpClient = transport.createMcpClient(baseUrl, requestCustomizer); mcpClient = transport.createMcpClient(baseUrl, requestCustomizer);
} }
@ -115,6 +118,29 @@ class ServerTransportSecurityIntegrationTests {
assertThatThrownBy(() -> mcpClient.listTools()); assertThatThrownBy(() -> mcpClient.listTools());
} }
@Test
void hostAllowed() {
// Host header is set by default by HttpClient to the request URI host
var result = mcpClient.initialize();
var tools = mcpClient.listTools();
assertThat(result.protocolVersion()).isNotEmpty();
assertThat(tools.tools()).isEmpty();
}
@Test
void connectHostNotAllowed() {
requestCustomizer.setHostHeader(DISALLOWED_HOST);
assertThatThrownBy(() -> mcpClient.initialize());
}
@Test
void messageHostNotAllowed() {
mcpClient.initialize();
requestCustomizer.setHostHeader(DISALLOWED_HOST);
assertThatThrownBy(() -> mcpClient.listTools());
}
// ---------------------------------------------------- // ----------------------------------------------------
// Tomcat management // Tomcat management
// ---------------------------------------------------- // ----------------------------------------------------
@ -182,8 +208,10 @@ class ServerTransportSecurityIntegrationTests {
public Sse() { public Sse() {
transport = HttpServletSseServerTransportProvider.builder() transport = HttpServletSseServerTransportProvider.builder()
.messageEndpoint("/mcp/message") .messageEndpoint("/mcp/message")
.securityValidator( .securityValidator(DefaultServerTransportSecurityValidator.builder()
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build()) .allowedOrigin("http://localhost:*")
.allowedHost("localhost:*")
.build())
.build(); .build();
McpServer.sync(transport) McpServer.sync(transport)
.serverInfo("test-server", "1.0.0") .serverInfo("test-server", "1.0.0")
@ -213,8 +241,10 @@ class ServerTransportSecurityIntegrationTests {
public StreamableHttp() { public StreamableHttp() {
transport = HttpServletStreamableServerTransportProvider.builder() transport = HttpServletStreamableServerTransportProvider.builder()
.securityValidator( .securityValidator(DefaultServerTransportSecurityValidator.builder()
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build()) .allowedOrigin("http://localhost:*")
.allowedHost("localhost:*")
.build())
.build(); .build();
McpServer.sync(transport) McpServer.sync(transport)
.serverInfo("test-server", "1.0.0") .serverInfo("test-server", "1.0.0")
@ -245,8 +275,10 @@ class ServerTransportSecurityIntegrationTests {
public Stateless() { public Stateless() {
transport = HttpServletStatelessServerTransport.builder() transport = HttpServletStatelessServerTransport.builder()
.securityValidator( .securityValidator(DefaultServerTransportSecurityValidator.builder()
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build()) .allowedOrigin("http://localhost:*")
.allowedHost("localhost:*")
.build())
.build(); .build();
McpServer.sync(transport) McpServer.sync(transport)
.serverInfo("test-server", "1.0.0") .serverInfo("test-server", "1.0.0")
@ -275,18 +307,33 @@ class ServerTransportSecurityIntegrationTests {
private String originHeader = null; private String originHeader = null;
private String hostHeader = null;
@Override @Override
public void customize(HttpRequest.Builder builder, String method, URI endpoint, String body, public void customize(HttpRequest.Builder builder, String method, URI endpoint, String body,
McpTransportContext context) { McpTransportContext context) {
if (originHeader != null) { if (originHeader != null) {
builder.header("Origin", originHeader); builder.header("Origin", originHeader);
} }
if (hostHeader != null) {
// HttpClient normally sets Host automatically, but we can override it
builder.header("Host", hostHeader);
}
} }
public void setOriginHeader(String originHeader) { public void setOriginHeader(String originHeader) {
this.originHeader = originHeader; this.originHeader = originHeader;
} }
public void setHostHeader(String hostHeader) {
this.hostHeader = hostHeader;
}
public void reset() {
this.originHeader = null;
this.hostHeader = null;
}
} }
} }