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
2 changed files with 1804 additions and 30 deletions

View file

@ -0,0 +1,197 @@
/*
* Copyright 2026-2026 the original author or authors.
*/
package io.modelcontextprotocol.server.transport;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* @author Daniel Garnier-Moiroux
*/
class DefaultServerTransportSecurityValidatorTests {
private static final ServerTransportSecurityException INVALID_ORIGIN = new ServerTransportSecurityException(403,
"Invalid Origin header");
private final DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder()
.allowedOrigin("http://localhost:8080")
.build();
@Test
void builder() {
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);
}
@Nested
class WildcardPort {
private final DefaultServerTransportSecurityValidator wildcardValidator = DefaultServerTransportSecurityValidator
.builder()
.allowedOrigin("http://localhost:*")
.build();
@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.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();
}
@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();
}
}
private static Map<String, List<String>> originHeader(String origin) {
return Map.of("Origin", List.of(origin));
}
}

View file

@ -0,0 +1,292 @@
/*
* Copyright 2026-2026 the original author or authors.
*/
package io.modelcontextprotocol.server.transport;
import java.net.URI;
import java.net.http.HttpRequest;
import java.time.Duration;
import java.util.stream.Stream;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.spec.McpSchema;
import jakarta.servlet.http.HttpServlet;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.LifecycleState;
import org.apache.catalina.startup.Tomcat;
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 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")
class ServerTransportSecurityIntegrationTests {
private static final String DISALLOWED_ORIGIN = "https://malicious.example.com";
@Parameter
private static Transport transport;
private static Tomcat tomcat;
private static String baseUrl;
@BeforeParameterizedClassInvocation
static void createTransportAndStartTomcat(Transport transport) {
var port = TomcatTestUtil.findAvailablePort();
baseUrl = "http://localhost:" + port;
startTomcat(transport.servlet(), port);
}
@AfterAll
static void afterAll() {
stopTomcat();
}
private McpSyncClient mcpClient;
private final TestRequestCustomizer requestCustomizer = new TestRequestCustomizer();
@BeforeEach
void setUp() {
mcpClient = transport.createMcpClient(baseUrl, requestCustomizer);
}
@AfterEach
void tearDown() {
mcpClient.close();
}
@Test
void originAllowed() {
requestCustomizer.setOriginHeader(baseUrl);
var result = mcpClient.initialize();
var tools = mcpClient.listTools();
assertThat(result.protocolVersion()).isNotEmpty();
assertThat(tools.tools()).isEmpty();
}
@Test
void noOrigin() {
requestCustomizer.setOriginHeader(null);
var result = mcpClient.initialize();
var tools = mcpClient.listTools();
assertThat(result.protocolVersion()).isNotEmpty();
assertThat(tools.tools()).isEmpty();
}
@Test
void connectOriginNotAllowed() {
requestCustomizer.setOriginHeader(DISALLOWED_ORIGIN);
assertThatThrownBy(() -> mcpClient.initialize());
}
@Test
void messageOriginNotAllowed() {
requestCustomizer.setOriginHeader(baseUrl);
mcpClient.initialize();
requestCustomizer.setOriginHeader(DISALLOWED_ORIGIN);
assertThatThrownBy(() -> mcpClient.listTools());
}
// ----------------------------------------------------
// Tomcat management
// ----------------------------------------------------
private static void startTomcat(jakarta.servlet.Servlet servlet, int port) {
tomcat = TomcatTestUtil.createTomcatServer("", port, servlet);
try {
tomcat.start();
assertThat(tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED);
}
catch (Exception e) {
throw new RuntimeException("Failed to start Tomcat", e);
}
}
private static void stopTomcat() {
if (tomcat != null) {
try {
tomcat.stop();
tomcat.destroy();
}
catch (LifecycleException e) {
throw new RuntimeException("Failed to stop Tomcat", e);
}
}
}
// ----------------------------------------------------
// 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, TestRequestCustomizer requestCustomizer);
HttpServlet servlet();
}
/**
* SSE-based transport.
*/
static class Sse implements Transport {
private final HttpServletSseServerTransportProvider transport;
public Sse() {
transport = HttpServletSseServerTransportProvider.builder()
.messageEndpoint("/mcp/message")
.securityValidator(
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build())
.build();
McpServer.sync(transport)
.serverInfo("test-server", "1.0.0")
.capabilities(McpSchema.ServerCapabilities.builder().tools(true).build())
.build();
}
@Override
public McpSyncClient createMcpClient(String baseUrl, TestRequestCustomizer requestCustomizer) {
var transport = HttpClientSseClientTransport.builder(baseUrl)
.httpRequestCustomizer(requestCustomizer)
.jsonMapper(McpJsonMapper.getDefault())
.build();
return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build();
}
@Override
public HttpServlet servlet() {
return transport;
}
}
static class StreamableHttp implements Transport {
private final HttpServletStreamableServerTransportProvider transport;
public StreamableHttp() {
transport = HttpServletStreamableServerTransportProvider.builder()
.securityValidator(
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build())
.build();
McpServer.sync(transport)
.serverInfo("test-server", "1.0.0")
.capabilities(McpSchema.ServerCapabilities.builder().tools(true).build())
.build();
}
@Override
public McpSyncClient createMcpClient(String baseUrl, TestRequestCustomizer requestCustomizer) {
var transport = HttpClientStreamableHttpTransport.builder(baseUrl)
.httpRequestCustomizer(requestCustomizer)
.jsonMapper(McpJsonMapper.getDefault())
.openConnectionOnStartup(true)
.build();
return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build();
}
@Override
public HttpServlet servlet() {
return transport;
}
}
static class Stateless implements Transport {
private final HttpServletStatelessServerTransport transport;
public Stateless() {
transport = HttpServletStatelessServerTransport.builder()
.securityValidator(
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build())
.build();
McpServer.sync(transport)
.serverInfo("test-server", "1.0.0")
.capabilities(McpSchema.ServerCapabilities.builder().tools(true).build())
.build();
}
@Override
public McpSyncClient createMcpClient(String baseUrl, TestRequestCustomizer requestCustomizer) {
var transport = HttpClientStreamableHttpTransport.builder(baseUrl)
.httpRequestCustomizer(requestCustomizer)
.jsonMapper(McpJsonMapper.getDefault())
.openConnectionOnStartup(true)
.build();
return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build();
}
@Override
public HttpServlet servlet() {
return transport;
}
}
static class TestRequestCustomizer implements McpSyncHttpClientRequestCustomizer {
private String originHeader = null;
@Override
public void customize(HttpRequest.Builder builder, String method, URI endpoint, String body,
McpTransportContext context) {
if (originHeader != null) {
builder.header("Origin", originHeader);
}
}
public void setOriginHeader(String originHeader) {
this.originHeader = originHeader;
}
}
}