Add Origin header validation

- Fixes #695
- Does not implement Host header validation yet

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
Daniel Garnier-Moiroux 2026-02-04 18:56:23 +01:00 • committed by Daniel Garnier-Moiroux
commit 96c9bbe745
4 changed files with 1804 additions and 30 deletions

View file

@ -1,5 +1,5 @@
/*
* Copyright 2025-2025 the original author or authors.
* Copyright 2025-2026 the original author or authors.
*/
package io.modelcontextprotocol.server.transport;
@ -7,6 +7,7 @@ package io.modelcontextprotocol.server.transport;
import java.io.IOException;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import io.modelcontextprotocol.common.McpTransportContext;
@ -132,6 +133,11 @@ public class WebFluxSseServerTransportProvider implements McpServerTransportProv
*/
private KeepAliveScheduler keepAliveScheduler;
/**
* Security validator for validating HTTP requests.
*/
private final ServerTransportSecurityValidator securityValidator;
/**
* Constructs a new WebFlux SSE server transport provider instance.
* @param jsonMapper The ObjectMapper to use for JSON serialization/deserialization of
@ -144,22 +150,26 @@ public class WebFluxSseServerTransportProvider implements McpServerTransportProv
* @param keepAliveInterval The interval for sending keep-alive pings to clients.
* @param contextExtractor The context extractor to use for extracting MCP transport
* context from HTTP requests. Must not be null.
* @param securityValidator The security validator for validating HTTP requests.
* @throws IllegalArgumentException if either parameter is null
*/
private WebFluxSseServerTransportProvider(McpJsonMapper jsonMapper, String baseUrl, String messageEndpoint,
String sseEndpoint, Duration keepAliveInterval,
McpTransportContextExtractor<ServerRequest> contextExtractor) {
McpTransportContextExtractor<ServerRequest> contextExtractor,
ServerTransportSecurityValidator securityValidator) {
Assert.notNull(jsonMapper, "ObjectMapper must not be null");
Assert.notNull(baseUrl, "Message base path must not be null");
Assert.notNull(messageEndpoint, "Message endpoint must not be null");
Assert.notNull(sseEndpoint, "SSE endpoint must not be null");
Assert.notNull(contextExtractor, "Context extractor must not be null");
Assert.notNull(securityValidator, "Security validator must not be null");
this.jsonMapper = jsonMapper;
this.baseUrl = baseUrl;
this.messageEndpoint = messageEndpoint;
this.sseEndpoint = sseEndpoint;
this.contextExtractor = contextExtractor;
this.securityValidator = securityValidator;
this.routerFunction = RouterFunctions.route()
.GET(this.sseEndpoint, this::handleSseConnection)
.POST(this.messageEndpoint, this::handleMessage)
@ -273,6 +283,14 @@ public class WebFluxSseServerTransportProvider implements McpServerTransportProv
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down");
}
try {
Map<String, List<String>> headers = request.headers().asHttpHeaders();
this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
return ServerResponse.status(e.getStatusCode()).bodyValue(e.getMessage());
}
McpTransportContext transportContext = this.contextExtractor.extract(request);
return ServerResponse.ok()
@ -332,6 +350,14 @@ public class WebFluxSseServerTransportProvider implements McpServerTransportProv
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down");
}
try {
Map<String, List<String>> headers = request.headers().asHttpHeaders();
this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
return ServerResponse.status(e.getStatusCode()).bodyValue(e.getMessage());
}
if (request.queryParam("sessionId").isEmpty()) {
return ServerResponse.badRequest().bodyValue(new McpError("Session ID missing in message endpoint"));
}
@ -436,6 +462,8 @@ public class WebFluxSseServerTransportProvider implements McpServerTransportProv
private McpTransportContextExtractor<ServerRequest> contextExtractor = (
serverRequest) -> McpTransportContext.EMPTY;
private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
/**
* Sets the McpJsonMapper to use for JSON serialization/deserialization of MCP
* messages.
@ -513,6 +541,18 @@ public class WebFluxSseServerTransportProvider implements McpServerTransportProv
return this;
}
/**
* Sets the security validator for validating HTTP requests.
* @param securityValidator The security validator to use. Must not be null.
* @return this builder instance
* @throws IllegalArgumentException if securityValidator is null
*/
public Builder securityValidator(ServerTransportSecurityValidator securityValidator) {
Assert.notNull(securityValidator, "Security validator must not be null");
this.securityValidator = securityValidator;
return this;
}
/**
* Builds a new instance of {@link WebFluxSseServerTransportProvider} with the
* configured settings.
@ -522,7 +562,7 @@ public class WebFluxSseServerTransportProvider implements McpServerTransportProv
public WebFluxSseServerTransportProvider build() {
Assert.notNull(messageEndpoint, "Message endpoint must be set");
return new WebFluxSseServerTransportProvider(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper,
baseUrl, messageEndpoint, sseEndpoint, keepAliveInterval, contextExtractor);
baseUrl, messageEndpoint, sseEndpoint, keepAliveInterval, contextExtractor, securityValidator);
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright 2025-2025 the original author or authors.
* Copyright 2025-2026 the original author or authors.
*/
package io.modelcontextprotocol.server.transport;
@ -24,6 +24,7 @@ import reactor.core.publisher.Mono;
import java.io.IOException;
import java.util.List;
import java.util.Map;
/**
* Implementation of a WebFlux based {@link McpStatelessServerTransport}.
@ -46,15 +47,23 @@ public class WebFluxStatelessServerTransport implements McpStatelessServerTransp
private volatile boolean isClosing = false;
/**
* Security validator for validating HTTP requests.
*/
private final ServerTransportSecurityValidator securityValidator;
private WebFluxStatelessServerTransport(McpJsonMapper jsonMapper, String mcpEndpoint,
McpTransportContextExtractor<ServerRequest> contextExtractor) {
McpTransportContextExtractor<ServerRequest> contextExtractor,
ServerTransportSecurityValidator securityValidator) {
Assert.notNull(jsonMapper, "jsonMapper must not be null");
Assert.notNull(mcpEndpoint, "mcpEndpoint must not be null");
Assert.notNull(contextExtractor, "contextExtractor must not be null");
Assert.notNull(securityValidator, "Security validator must not be null");
this.jsonMapper = jsonMapper;
this.mcpEndpoint = mcpEndpoint;
this.contextExtractor = contextExtractor;
this.securityValidator = securityValidator;
this.routerFunction = RouterFunctions.route()
.GET(this.mcpEndpoint, this::handleGet)
.POST(this.mcpEndpoint, this::handlePost)
@ -96,6 +105,14 @@ public class WebFluxStatelessServerTransport implements McpStatelessServerTransp
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down");
}
try {
Map<String, List<String>> headers = request.headers().asHttpHeaders();
this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
return ServerResponse.status(e.getStatusCode()).bodyValue(e.getMessage());
}
McpTransportContext transportContext = this.contextExtractor.extract(request);
List<MediaType> acceptHeaders = request.headers().asHttpHeaders().getAccept();
@ -160,6 +177,8 @@ public class WebFluxStatelessServerTransport implements McpStatelessServerTransp
private McpTransportContextExtractor<ServerRequest> contextExtractor = (
serverRequest) -> McpTransportContext.EMPTY;
private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
private Builder() {
// used by a static method
}
@ -205,6 +224,18 @@ public class WebFluxStatelessServerTransport implements McpStatelessServerTransp
return this;
}
/**
* Sets the security validator for validating HTTP requests.
* @param securityValidator The security validator to use. Must not be null.
* @return this builder instance
* @throws IllegalArgumentException if securityValidator is null
*/
public Builder securityValidator(ServerTransportSecurityValidator securityValidator) {
Assert.notNull(securityValidator, "Security validator must not be null");
this.securityValidator = securityValidator;
return this;
}
/**
* Builds a new instance of {@link WebFluxStatelessServerTransport} with the
* configured settings.
@ -214,7 +245,7 @@ public class WebFluxStatelessServerTransport implements McpStatelessServerTransp
public WebFluxStatelessServerTransport build() {
Assert.notNull(mcpEndpoint, "Message endpoint must be set");
return new WebFluxStatelessServerTransport(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper,
mcpEndpoint, contextExtractor);
mcpEndpoint, contextExtractor, securityValidator);
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright 2025-2025 the original author or authors.
* Copyright 2025-2026 the original author or authors.
*/
package io.modelcontextprotocol.server.transport;
@ -36,6 +36,7 @@ import reactor.core.publisher.Mono;
import java.io.IOException;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
@ -67,17 +68,24 @@ public class WebFluxStreamableServerTransportProvider implements McpStreamableSe
private KeepAliveScheduler keepAliveScheduler;
/**
* Security validator for validating HTTP requests.
*/
private final ServerTransportSecurityValidator securityValidator;
private WebFluxStreamableServerTransportProvider(McpJsonMapper jsonMapper, String mcpEndpoint,
McpTransportContextExtractor<ServerRequest> contextExtractor, boolean disallowDelete,
Duration keepAliveInterval) {
Duration keepAliveInterval, ServerTransportSecurityValidator securityValidator) {
Assert.notNull(jsonMapper, "JsonMapper must not be null");
Assert.notNull(mcpEndpoint, "Message endpoint must not be null");
Assert.notNull(contextExtractor, "Context extractor must not be null");
Assert.notNull(securityValidator, "Security validator must not be null");
this.jsonMapper = jsonMapper;
this.mcpEndpoint = mcpEndpoint;
this.contextExtractor = contextExtractor;
this.disallowDelete = disallowDelete;
this.securityValidator = securityValidator;
this.routerFunction = RouterFunctions.route()
.GET(this.mcpEndpoint, this::handleGet)
.POST(this.mcpEndpoint, this::handlePost)
@ -166,6 +174,14 @@ public class WebFluxStreamableServerTransportProvider implements McpStreamableSe
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down");
}
try {
Map<String, List<String>> headers = request.headers().asHttpHeaders();
this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
return ServerResponse.status(e.getStatusCode()).bodyValue(e.getMessage());
}
McpTransportContext transportContext = this.contextExtractor.extract(request);
return Mono.defer(() -> {
@ -221,6 +237,14 @@ public class WebFluxStreamableServerTransportProvider implements McpStreamableSe
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down");
}
try {
Map<String, List<String>> headers = request.headers().asHttpHeaders();
this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
return ServerResponse.status(e.getStatusCode()).bodyValue(e.getMessage());
}
McpTransportContext transportContext = this.contextExtractor.extract(request);
List<MediaType> acceptHeaders = request.headers().asHttpHeaders().getAccept();
@ -310,6 +334,14 @@ public class WebFluxStreamableServerTransportProvider implements McpStreamableSe
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down");
}
try {
Map<String, List<String>> headers = request.headers().asHttpHeaders();
this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
return ServerResponse.status(e.getStatusCode()).bodyValue(e.getMessage());
}
McpTransportContext transportContext = this.contextExtractor.extract(request);
return Mono.defer(() -> {
@ -410,6 +442,8 @@ public class WebFluxStreamableServerTransportProvider implements McpStreamableSe
private Duration keepAliveInterval;
private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
private Builder() {
// used by a static method
}
@ -477,6 +511,18 @@ public class WebFluxStreamableServerTransportProvider implements McpStreamableSe
return this;
}
/**
* Sets the security validator for validating HTTP requests.
* @param securityValidator The security validator to use. Must not be null.
* @return this builder instance
* @throws IllegalArgumentException if securityValidator is null
*/
public Builder securityValidator(ServerTransportSecurityValidator securityValidator) {
Assert.notNull(securityValidator, "Security validator must not be null");
this.securityValidator = securityValidator;
return this;
}
/**
* Builds a new instance of {@link WebFluxStreamableServerTransportProvider} with
* the configured settings.
@ -487,7 +533,7 @@ public class WebFluxStreamableServerTransportProvider implements McpStreamableSe
Assert.notNull(mcpEndpoint, "Message endpoint must be set");
return new WebFluxStreamableServerTransportProvider(
jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, mcpEndpoint, contextExtractor,
disallowDelete, keepAliveInterval);
disallowDelete, keepAliveInterval, securityValidator);
}
}

View file

@ -0,0 +1,291 @@
/*
* Copyright 2026-2026 the original author or authors.
*/
package io.modelcontextprotocol.security;
import java.time.Duration;
import java.util.stream.Stream;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport;
import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.TestUtil;
import io.modelcontextprotocol.server.transport.DefaultServerTransportSecurityValidator;
import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider;
import io.modelcontextprotocol.server.transport.WebFluxStatelessServerTransport;
import io.modelcontextprotocol.server.transport.WebFluxStreamableServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.BeforeParameterizedClassInvocation;
import org.junit.jupiter.params.Parameter;
import org.junit.jupiter.params.ParameterizedClass;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import reactor.core.publisher.Mono;
import reactor.netty.DisposableServer;
import reactor.netty.http.server.HttpServer;
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.ExchangeFunction;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Named.named;
import static org.junit.jupiter.params.provider.Arguments.arguments;
/**
* Test the header security validation for all transport types.
*
* @author Daniel Garnier-Moiroux
*/
@ParameterizedClass
@MethodSource("transports")
public class WebFluxServerTransportSecurityIntegrationTests {
private static final String DISALLOWED_ORIGIN = "https://malicious.example.com";
@Parameter
private static Transport transport;
private static DisposableServer httpServer;
private static String baseUrl;
@BeforeParameterizedClassInvocation
static void createTransportAndStartServer(Transport transport) {
var port = TestUtil.findAvailablePort();
baseUrl = "http://localhost:" + port;
startServer(transport.routerFunction(), port);
}
@AfterAll
static void afterAll() {
stopServer();
}
private McpSyncClient mcpClient;
private final TestOriginHeaderExchangeFilterFunction exchangeFilterFunction = new TestOriginHeaderExchangeFilterFunction();
@BeforeEach
void setUp() {
mcpClient = transport.createMcpClient(baseUrl, exchangeFilterFunction);
}
@AfterEach
void tearDown() {
mcpClient.close();
}
@Test
void originAllowed() {
exchangeFilterFunction.setOriginHeader(baseUrl);
var result = mcpClient.initialize();
var tools = mcpClient.listTools();
assertThat(result.protocolVersion()).isNotEmpty();
assertThat(tools.tools()).isEmpty();
}
@Test
void noOrigin() {
exchangeFilterFunction.setOriginHeader(null);
var result = mcpClient.initialize();
var tools = mcpClient.listTools();
assertThat(result.protocolVersion()).isNotEmpty();
assertThat(tools.tools()).isEmpty();
}
@Test
void connectOriginNotAllowed() {
exchangeFilterFunction.setOriginHeader(DISALLOWED_ORIGIN);
assertThatThrownBy(() -> mcpClient.initialize());
}
@Test
void messageOriginNotAllowed() {
exchangeFilterFunction.setOriginHeader(baseUrl);
mcpClient.initialize();
exchangeFilterFunction.setOriginHeader(DISALLOWED_ORIGIN);
assertThatThrownBy(() -> mcpClient.listTools());
}
// ----------------------------------------------------
// Server management
// ----------------------------------------------------
private static void startServer(RouterFunction<?> routerFunction, int port) {
HttpHandler httpHandler = RouterFunctions.toHttpHandler(routerFunction);
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
httpServer = HttpServer.create().port(port).handle(adapter).bindNow();
}
private static void stopServer() {
if (httpServer != null) {
httpServer.disposeNow();
}
}
// ----------------------------------------------------
// Transport servers to test
// ----------------------------------------------------
/**
* All transport types we want to test. We use a {@link MethodSource} rather than a
* {@link org.junit.jupiter.params.provider.ValueSource} to provide a readable name.
*/
static Stream<Arguments> transports() {
//@formatter:off
return Stream.of(
arguments(named("SSE", new Sse())),
arguments(named("Streamable HTTP", new StreamableHttp())),
arguments(named("Stateless", new Stateless()))
);
//@formatter:on
}
/**
* Represents a server transport we want to test, and how to create a client for the
* resulting MCP Server.
*/
interface Transport {
McpSyncClient createMcpClient(String baseUrl, TestOriginHeaderExchangeFilterFunction customizer);
RouterFunction<?> routerFunction();
}
/**
* SSE-based transport.
*/
static class Sse implements Transport {
private final WebFluxSseServerTransportProvider transportProvider;
public Sse() {
transportProvider = WebFluxSseServerTransportProvider.builder()
.messageEndpoint("/mcp/message")
.securityValidator(
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build())
.build();
McpServer.sync(transportProvider)
.serverInfo("test-server", "1.0.0")
.capabilities(McpSchema.ServerCapabilities.builder().tools(true).build())
.build();
}
@Override
public McpSyncClient createMcpClient(String baseUrl,
TestOriginHeaderExchangeFilterFunction exchangeFilterFunction) {
var transport = WebFluxSseClientTransport
.builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction))
.jsonMapper(McpJsonMapper.getDefault())
.build();
return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build();
}
@Override
public RouterFunction<?> routerFunction() {
return transportProvider.getRouterFunction();
}
}
static class StreamableHttp implements Transport {
private final WebFluxStreamableServerTransportProvider transportProvider;
public StreamableHttp() {
transportProvider = WebFluxStreamableServerTransportProvider.builder()
.securityValidator(
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build())
.build();
McpServer.sync(transportProvider)
.serverInfo("test-server", "1.0.0")
.capabilities(McpSchema.ServerCapabilities.builder().tools(true).build())
.build();
}
@Override
public McpSyncClient createMcpClient(String baseUrl,
TestOriginHeaderExchangeFilterFunction exchangeFilterFunction) {
var transport = WebClientStreamableHttpTransport
.builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction))
.jsonMapper(McpJsonMapper.getDefault())
.openConnectionOnStartup(true)
.build();
return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build();
}
@Override
public RouterFunction<?> routerFunction() {
return transportProvider.getRouterFunction();
}
}
static class Stateless implements Transport {
private final WebFluxStatelessServerTransport transportProvider;
public Stateless() {
transportProvider = WebFluxStatelessServerTransport.builder()
.securityValidator(
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build())
.build();
McpServer.sync(transportProvider)
.serverInfo("test-server", "1.0.0")
.capabilities(McpSchema.ServerCapabilities.builder().tools(true).build())
.build();
}
@Override
public McpSyncClient createMcpClient(String baseUrl,
TestOriginHeaderExchangeFilterFunction exchangeFilterFunction) {
var transport = WebClientStreamableHttpTransport
.builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction))
.jsonMapper(McpJsonMapper.getDefault())
.openConnectionOnStartup(true)
.build();
return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build();
}
@Override
public RouterFunction<?> routerFunction() {
return transportProvider.getRouterFunction();
}
}
static class TestOriginHeaderExchangeFilterFunction implements ExchangeFilterFunction {
private String origin = null;
public void setOriginHeader(String origin) {
this.origin = origin;
}
@Override
public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
var updatedRequest = ClientRequest.from(request).header("origin", this.origin).build();
return next.exchange(updatedRequest);
}
}
}