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:
parent
a47920ca07
commit
96c9bbe745
2 changed files with 1804 additions and 30 deletions
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in a new issue