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
* Origin header against a list of allowed origins.
* Origin and Host headers against lists of allowed values.
*
* <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
* @see ServerTransportSecurityValidator
@ -25,28 +26,48 @@ public final class DefaultServerTransportSecurityValidator implements ServerTran
private static final String ORIGIN_HEADER = "Origin";
private static final String HOST_HEADER = "Host";
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
* (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(allowedHosts, "allowedHosts must not be null");
this.allowedOrigins = allowedOrigins;
this.allowedHosts = allowedHosts;
}
@Override
public void validateHeaders(Map<String, List<String>> headers) throws ServerTransportSecurityException {
boolean missingHost = true;
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
if (ORIGIN_HEADER.equalsIgnoreCase(entry.getKey())) {
List<String> values = entry.getValue();
if (values != null && !values.isEmpty()) {
validateOrigin(values.get(0));
if (values == null || values.isEmpty()) {
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");
}
/**
* 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.
* @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> allowedHosts = new ArrayList<>();
/**
* Adds an allowed origin pattern.
* @param origin The origin to allow (e.g., "http://localhost:8080" or
@ -116,12 +170,33 @@ public final class DefaultServerTransportSecurityValidator implements ServerTran
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.
* @return A new DefaultServerTransportSecurityValidator
*/
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,
"Invalid Origin header");
private static final ServerTransportSecurityException INVALID_HOST = new ServerTransportSecurityException(421,
"Invalid Host header");
private final DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder()
.allowedOrigin("http://localhost:8080")
.build();
@ -31,161 +34,370 @@ class DefaultServerTransportSecurityValidatorTests {
assertThatCode(() -> DefaultServerTransportSecurityValidator.builder().build()).doesNotThrowAnyException();
assertThatThrownBy(() -> DefaultServerTransportSecurityValidator.builder().allowedOrigins(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);
assertThatThrownBy(() -> DefaultServerTransportSecurityValidator.builder().allowedHosts(null).build())
.isInstanceOf(IllegalArgumentException.class);
}
@Nested
class WildcardPort {
private final DefaultServerTransportSecurityValidator wildcardValidator = DefaultServerTransportSecurityValidator
.builder()
.allowedOrigin("http://localhost:*")
.build();
class OriginHeader {
@Test
void anyPortWithWildcard() {
var headers = originHeader("http://localhost:3000");
assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException();
void originHeaderMissing() {
assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException();
}
@Test
void noPortWithWildcard() {
var headers = originHeader("http://localhost");
assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException();
void originHeaderListEmpty() {
assertThatThrownBy(() -> validator.validateHeaders(Map.of("Origin", List.of()))).isEqualTo(INVALID_ORIGIN);
}
@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.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");
void caseInsensitive() {
var headers = Map.of("origin", List.of("http://localhost:8080"));
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"))
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
class WildcardPort {
private final DefaultServerTransportSecurityValidator wildcardValidator = DefaultServerTransportSecurityValidator
.builder()
.allowedOrigin("http://localhost:*")
.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();
@Test
void anyPortWithWildcard() {
var headers = originHeader("http://localhost:3000");
assertThatCode(() -> wildcardValidator.validateHeaders(headers)).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));
}
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_HOST = "malicious.example.com:8080";
@Parameter
private static Transport transport;
@ -79,7 +81,7 @@ public class WebFluxServerTransportSecurityIntegrationTests {
private McpSyncClient mcpClient;
private final TestOriginHeaderExchangeFilterFunction exchangeFilterFunction = new TestOriginHeaderExchangeFilterFunction();
private final TestHeaderExchangeFilterFunction exchangeFilterFunction = new TestHeaderExchangeFilterFunction();
@BeforeEach
void setUp() {
@ -125,6 +127,29 @@ public class WebFluxServerTransportSecurityIntegrationTests {
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
// ----------------------------------------------------
@ -165,7 +190,7 @@ public class WebFluxServerTransportSecurityIntegrationTests {
*/
interface Transport {
McpSyncClient createMcpClient(String baseUrl, TestOriginHeaderExchangeFilterFunction customizer);
McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction customizer);
RouterFunction<?> routerFunction();
@ -181,8 +206,10 @@ public class WebFluxServerTransportSecurityIntegrationTests {
public Sse() {
transportProvider = WebFluxSseServerTransportProvider.builder()
.messageEndpoint("/mcp/message")
.securityValidator(
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build())
.securityValidator(DefaultServerTransportSecurityValidator.builder()
.allowedOrigin("http://localhost:*")
.allowedHost("localhost:*")
.build())
.build();
McpServer.sync(transportProvider)
.serverInfo("test-server", "1.0.0")
@ -191,8 +218,7 @@ public class WebFluxServerTransportSecurityIntegrationTests {
}
@Override
public McpSyncClient createMcpClient(String baseUrl,
TestOriginHeaderExchangeFilterFunction exchangeFilterFunction) {
public McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction exchangeFilterFunction) {
var transport = WebFluxSseClientTransport
.builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction))
.jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper())
@ -213,8 +239,10 @@ public class WebFluxServerTransportSecurityIntegrationTests {
public StreamableHttp() {
transportProvider = WebFluxStreamableServerTransportProvider.builder()
.securityValidator(
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build())
.securityValidator(DefaultServerTransportSecurityValidator.builder()
.allowedOrigin("http://localhost:*")
.allowedHost("localhost:*")
.build())
.build();
McpServer.sync(transportProvider)
.serverInfo("test-server", "1.0.0")
@ -223,8 +251,7 @@ public class WebFluxServerTransportSecurityIntegrationTests {
}
@Override
public McpSyncClient createMcpClient(String baseUrl,
TestOriginHeaderExchangeFilterFunction exchangeFilterFunction) {
public McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction exchangeFilterFunction) {
var transport = WebClientStreamableHttpTransport
.builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction))
.jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper())
@ -246,8 +273,10 @@ public class WebFluxServerTransportSecurityIntegrationTests {
public Stateless() {
transportProvider = WebFluxStatelessServerTransport.builder()
.securityValidator(
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build())
.securityValidator(DefaultServerTransportSecurityValidator.builder()
.allowedOrigin("http://localhost:*")
.allowedHost("localhost:*")
.build())
.build();
McpServer.sync(transportProvider)
.serverInfo("test-server", "1.0.0")
@ -256,8 +285,7 @@ public class WebFluxServerTransportSecurityIntegrationTests {
}
@Override
public McpSyncClient createMcpClient(String baseUrl,
TestOriginHeaderExchangeFilterFunction exchangeFilterFunction) {
public McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction exchangeFilterFunction) {
var transport = WebClientStreamableHttpTransport
.builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction))
.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 host = null;
public void setOriginHeader(String origin) {
this.origin = origin;
}
public void setHostHeader(String host) {
this.host = host;
}
@Override
public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
var updatedRequest = ClientRequest.from(request).header("origin", this.origin).build();
return next.exchange(updatedRequest);
var builder = ClientRequest.from(request);
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_HOST = "malicious.example.com:8080";
@Parameter
private static Class<?> configClass;
@ -90,6 +92,7 @@ public class ServerTransportSecurityIntegrationTests {
void setUp() {
mcpClient = tomcatServer.appContext().getBean(McpSyncClient.class);
requestCustomizer = tomcatServer.appContext().getBean(TestRequestCustomizer.class);
requestCustomizer.reset();
}
@AfterEach
@ -131,6 +134,29 @@ public class ServerTransportSecurityIntegrationTests {
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
// ----------------------------------------------------
@ -195,7 +221,10 @@ public class ServerTransportSecurityIntegrationTests {
@Bean
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 hostHeader = null;
@Override
public void customize(HttpRequest.Builder builder, String method, URI endpoint, String body,
McpTransportContext context) {
if (originHeader != null) {
builder.header("Origin", originHeader);
}
if (hostHeader != null) {
builder.header("Host", hostHeader);
}
}
public void setOriginHeader(String 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_HOST = "malicious.example.com:8080";
@Parameter
private static Transport transport;
@ -73,6 +75,7 @@ class ServerTransportSecurityIntegrationTests {
@BeforeEach
void setUp() {
requestCustomizer.reset();
mcpClient = transport.createMcpClient(baseUrl, requestCustomizer);
}
@ -115,6 +118,29 @@ class ServerTransportSecurityIntegrationTests {
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
// ----------------------------------------------------
@ -182,8 +208,10 @@ class ServerTransportSecurityIntegrationTests {
public Sse() {
transport = HttpServletSseServerTransportProvider.builder()
.messageEndpoint("/mcp/message")
.securityValidator(
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build())
.securityValidator(DefaultServerTransportSecurityValidator.builder()
.allowedOrigin("http://localhost:*")
.allowedHost("localhost:*")
.build())
.build();
McpServer.sync(transport)
.serverInfo("test-server", "1.0.0")
@ -213,8 +241,10 @@ class ServerTransportSecurityIntegrationTests {
public StreamableHttp() {
transport = HttpServletStreamableServerTransportProvider.builder()
.securityValidator(
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build())
.securityValidator(DefaultServerTransportSecurityValidator.builder()
.allowedOrigin("http://localhost:*")
.allowedHost("localhost:*")
.build())
.build();
McpServer.sync(transport)
.serverInfo("test-server", "1.0.0")
@ -245,8 +275,10 @@ class ServerTransportSecurityIntegrationTests {
public Stateless() {
transport = HttpServletStatelessServerTransport.builder()
.securityValidator(
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build())
.securityValidator(DefaultServerTransportSecurityValidator.builder()
.allowedOrigin("http://localhost:*")
.allowedHost("localhost:*")
.build())
.build();
McpServer.sync(transport)
.serverInfo("test-server", "1.0.0")
@ -275,18 +307,33 @@ class ServerTransportSecurityIntegrationTests {
private String originHeader = null;
private String hostHeader = null;
@Override
public void customize(HttpRequest.Builder builder, String method, URI endpoint, String body,
McpTransportContext context) {
if (originHeader != null) {
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) {
this.originHeader = originHeader;
}
public void setHostHeader(String hostHeader) {
this.hostHeader = hostHeader;
}
public void reset() {
this.originHeader = null;
this.hostHeader = null;
}
}
}