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

View file

@ -0,0 +1,132 @@
/*
* Copyright 2026-2026 the original author or authors.
*/
package io.modelcontextprotocol.server.transport;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import io.modelcontextprotocol.util.Assert;
/**
* Default implementation of {@link ServerTransportSecurityValidator} that validates the
* Origin header against a list of allowed origins.
*
* <p>
* Supports exact matches and wildcard port patterns (e.g., "http://example.com:*").
*
* @author Daniel Garnier-Moiroux
* @see ServerTransportSecurityValidator
* @see ServerTransportSecurityException
*/
public class DefaultServerTransportSecurityValidator implements ServerTransportSecurityValidator {
private static final String ORIGIN_HEADER = "Origin";
private static final ServerTransportSecurityException INVALID_ORIGIN = new ServerTransportSecurityException(403,
"Invalid Origin header");
private final List<String> allowedOrigins;
/**
* Creates a new validator with the specified allowed origins.
* @param allowedOrigins List of allowed origin patterns. Supports exact matches
* (e.g., "http://example.com:8080") and wildcard ports (e.g., "http://example.com:*")
*/
public DefaultServerTransportSecurityValidator(List<String> allowedOrigins) {
Assert.notNull(allowedOrigins, "allowedOrigins must not be null");
this.allowedOrigins = allowedOrigins;
}
@Override
public void validateHeaders(Map<String, List<String>> headers) throws ServerTransportSecurityException {
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));
}
break;
}
}
}
/**
* Validates a single origin value against the allowed origins. Subclasses can
* override this method to customize origin validation logic.
* @param origin The origin header value, or null if not present
* @throws ServerTransportSecurityException if the origin is not allowed
*/
protected void validateOrigin(String origin) throws ServerTransportSecurityException {
// Origin absent = no validation needed (same-origin request)
if (origin == null || origin.isBlank()) {
return;
}
for (String allowed : allowedOrigins) {
if (allowed.equals(origin)) {
return;
}
else if (allowed.endsWith(":*")) {
// Wildcard port pattern: "http://example.com:*"
String baseOrigin = allowed.substring(0, allowed.length() - 2);
if (origin.equals(baseOrigin) || origin.startsWith(baseOrigin + ":")) {
return;
}
}
}
throw INVALID_ORIGIN;
}
/**
* Creates a new builder for constructing a DefaultServerTransportSecurityValidator.
* @return A new builder instance
*/
public static Builder builder() {
return new Builder();
}
/**
* Builder for creating instances of {@link DefaultServerTransportSecurityValidator}.
*/
public static class Builder {
private final List<String> allowedOrigins = new ArrayList<>();
/**
* Adds an allowed origin pattern.
* @param origin The origin to allow (e.g., "http://localhost:8080" or
* "http://example.com:*")
* @return this builder instance
*/
public Builder allowedOrigin(String origin) {
this.allowedOrigins.add(origin);
return this;
}
/**
* Adds multiple allowed origin patterns.
* @param origins The origins to allow
* @return this builder instance
*/
public Builder allowedOrigins(List<String> origins) {
Assert.notNull(origins, "origins must not be null");
this.allowedOrigins.addAll(origins);
return this;
}
/**
* Builds the validator instance.
* @return A new DefaultServerTransportSecurityValidator
*/
public DefaultServerTransportSecurityValidator build() {
return new DefaultServerTransportSecurityValidator(allowedOrigins);
}
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright 2024 - 2024 the original author or authors.
* Copyright 2024 - 2026 the original author or authors.
*/
package io.modelcontextprotocol.server.transport;
@ -8,6 +8,9 @@ import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.Duration;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@ -142,6 +145,11 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
*/
private KeepAliveScheduler keepAliveScheduler;
/**
* Security validator for validating HTTP requests.
*/
private final ServerTransportSecurityValidator securityValidator;
/**
* Creates a new HttpServletSseServerTransportProvider instance with a custom SSE
* endpoint.
@ -153,23 +161,25 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
* @param keepAliveInterval The interval for keep-alive pings, or null to disable
* keep-alive functionality
* @param contextExtractor The extractor for transport context from the request.
* @deprecated Use the builder {@link #builder()} instead for better configuration
* options.
* @param securityValidator The security validator for validating HTTP requests.
*/
private HttpServletSseServerTransportProvider(McpJsonMapper jsonMapper, String baseUrl, String messageEndpoint,
String sseEndpoint, Duration keepAliveInterval,
McpTransportContextExtractor<HttpServletRequest> contextExtractor) {
McpTransportContextExtractor<HttpServletRequest> contextExtractor,
ServerTransportSecurityValidator securityValidator) {
Assert.notNull(jsonMapper, "JsonMapper must not be null");
Assert.notNull(messageEndpoint, "messageEndpoint must not be null");
Assert.notNull(sseEndpoint, "sseEndpoint 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;
if (keepAliveInterval != null) {
@ -246,6 +256,15 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
return;
}
try {
Map<String, List<String>> headers = extractHeaders(request);
this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
return;
}
response.setContentType("text/event-stream");
response.setCharacterEncoding(UTF_8);
response.setHeader("Cache-Control", "no-cache");
@ -311,6 +330,15 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
return;
}
try {
Map<String, List<String>> headers = extractHeaders(request);
this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
return;
}
// Get the session ID from the request parameter
String sessionId = request.getParameter("sessionId");
if (sessionId == null) {
@ -411,6 +439,21 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
}
}
/**
* Extracts all headers from the HTTP servlet request into a map.
* @param request The HTTP servlet request
* @return A map of header names to their values
*/
private Map<String, List<String>> extractHeaders(HttpServletRequest request) {
Map<String, List<String>> headers = new HashMap<>();
Enumeration<String> names = request.getHeaderNames();
while (names.hasMoreElements()) {
String name = names.nextElement();
headers.put(name, Collections.list(request.getHeaders(name)));
}
return headers;
}
/**
* Cleans up resources when the servlet is being destroyed.
* <p>
@ -547,6 +590,8 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
private Duration keepAliveInterval;
private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
/**
* Sets the JsonMapper implementation to use for serialization/deserialization. If
* not specified, a JacksonJsonMapper will be created from the configured
@ -621,6 +666,18 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
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 HttpServletSseServerTransportProvider with the
* configured settings.
@ -633,7 +690,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
}
return new HttpServletSseServerTransportProvider(
jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, baseUrl, messageEndpoint, sseEndpoint,
keepAliveInterval, contextExtractor);
keepAliveInterval, contextExtractor, securityValidator);
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright 2024-2024 the original author or authors.
* Copyright 2024-2026 the original author or authors.
*/
package io.modelcontextprotocol.server.transport;
@ -7,6 +7,11 @@ package io.modelcontextprotocol.server.transport;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -58,15 +63,23 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
private volatile boolean isClosing = false;
/**
* Security validator for validating HTTP requests.
*/
private final ServerTransportSecurityValidator securityValidator;
private HttpServletStatelessServerTransport(McpJsonMapper jsonMapper, String mcpEndpoint,
McpTransportContextExtractor<HttpServletRequest> contextExtractor) {
McpTransportContextExtractor<HttpServletRequest> 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;
}
@Override
@ -122,6 +135,15 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
return;
}
try {
Map<String, List<String>> headers = extractHeaders(request);
this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
return;
}
McpTransportContext transportContext = this.contextExtractor.extract(request);
String accept = request.getHeader(ACCEPT);
@ -209,6 +231,21 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
writer.flush();
}
/**
* Extracts all headers from the HTTP servlet request into a map.
* @param request The HTTP servlet request
* @return A map of header names to their values
*/
private Map<String, List<String>> extractHeaders(HttpServletRequest request) {
Map<String, List<String>> headers = new HashMap<>();
Enumeration<String> names = request.getHeaderNames();
while (names.hasMoreElements()) {
String name = names.nextElement();
headers.put(name, Collections.list(request.getHeaders(name)));
}
return headers;
}
/**
* Cleans up resources when the servlet is being destroyed.
* <p>
@ -243,6 +280,8 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
private McpTransportContextExtractor<HttpServletRequest> contextExtractor = (
serverRequest) -> McpTransportContext.EMPTY;
private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
private Builder() {
// used by a static method
}
@ -288,6 +327,18 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
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 HttpServletStatelessServerTransport} with the
* configured settings.
@ -297,7 +348,7 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
public HttpServletStatelessServerTransport build() {
Assert.notNull(mcpEndpoint, "Message endpoint must be set");
return new HttpServletStatelessServerTransport(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper,
mcpEndpoint, contextExtractor);
mcpEndpoint, contextExtractor, securityValidator);
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright 2024-2024 the original author or authors.
* Copyright 2024-2026 the original author or authors.
*/
package io.modelcontextprotocol.server.transport;
@ -9,7 +9,11 @@ import java.io.IOException;
import java.io.PrintWriter;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
@ -119,6 +123,11 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
*/
private KeepAliveScheduler keepAliveScheduler;
/**
* Security validator for validating HTTP requests.
*/
private final ServerTransportSecurityValidator securityValidator;
/**
* Constructs a new HttpServletStreamableServerTransportProvider instance.
* @param jsonMapper The JsonMapper to use for JSON serialization/deserialization of
@ -127,19 +136,24 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
* messages via HTTP. This endpoint will handle GET, POST, and DELETE requests.
* @param disallowDelete Whether to disallow DELETE requests on the endpoint.
* @param contextExtractor The extractor for transport context from the request.
* @param keepAliveInterval The interval for keep-alive pings. If null, no keep-alive
* will be scheduled.
* @param securityValidator The security validator for validating HTTP requests.
* @throws IllegalArgumentException if any parameter is null
*/
private HttpServletStreamableServerTransportProvider(McpJsonMapper jsonMapper, String mcpEndpoint,
boolean disallowDelete, McpTransportContextExtractor<HttpServletRequest> contextExtractor,
Duration keepAliveInterval) {
Duration keepAliveInterval, ServerTransportSecurityValidator securityValidator) {
Assert.notNull(jsonMapper, "JsonMapper must not be null");
Assert.notNull(mcpEndpoint, "MCP 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.disallowDelete = disallowDelete;
this.contextExtractor = contextExtractor;
this.securityValidator = securityValidator;
if (keepAliveInterval != null) {
@ -246,6 +260,15 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
return;
}
try {
Map<String, List<String>> headers = extractHeaders(request);
this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
return;
}
List<String> badRequestErrors = new ArrayList<>();
String accept = request.getHeader(ACCEPT);
@ -373,6 +396,15 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
return;
}
try {
Map<String, List<String>> headers = extractHeaders(request);
this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
return;
}
List<String> badRequestErrors = new ArrayList<>();
String accept = request.getHeader(ACCEPT);
@ -536,6 +568,15 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
return;
}
try {
Map<String, List<String>> headers = extractHeaders(request);
this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
return;
}
if (this.disallowDelete) {
response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
return;
@ -586,6 +627,21 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
return;
}
/**
* Extracts all headers from the HTTP servlet request into a map.
* @param request The HTTP servlet request
* @return A map of header names to their values
*/
private Map<String, List<String>> extractHeaders(HttpServletRequest request) {
Map<String, List<String>> headers = new HashMap<>();
Enumeration<String> names = request.getHeaderNames();
while (names.hasMoreElements()) {
String name = names.nextElement();
headers.put(name, Collections.list(request.getHeaders(name)));
}
return headers;
}
/**
* Sends an SSE event to a client with a specific ID.
* @param writer The writer to send the event through
@ -774,6 +830,8 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
private Duration keepAliveInterval;
private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
/**
* Sets the JsonMapper to use for JSON serialization/deserialization of MCP
* messages.
@ -833,6 +891,18 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
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 HttpServletStreamableServerTransportProvider}
* with the configured settings.
@ -843,7 +913,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set");
return new HttpServletStreamableServerTransportProvider(
jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, mcpEndpoint, disallowDelete,
contextExtractor, keepAliveInterval);
contextExtractor, keepAliveInterval, securityValidator);
}
}

View file

@ -0,0 +1,48 @@
/*
* Copyright 2026-2026 the original author or authors.
*/
package io.modelcontextprotocol.server.transport;
/**
* Exception thrown when security validation fails for an HTTP request. Contains HTTP
* status code and message.
*
* @author Daniel Garnier-Moiroux
* @see ServerTransportSecurityValidator
*/
public class ServerTransportSecurityException extends Exception {
private final int statusCode;
/**
* Creates a new ServerTransportSecurityException with the specified HTTP status code
* and message.
*/
public ServerTransportSecurityException(int statusCode, String message) {
super(message);
this.statusCode = statusCode;
}
public int getStatusCode() {
return statusCode;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
ServerTransportSecurityException that = (ServerTransportSecurityException) obj;
return statusCode == that.statusCode && java.util.Objects.equals(getMessage(), that.getMessage());
}
@Override
public int hashCode() {
return java.util.Objects.hash(statusCode, getMessage());
}
}

View file

@ -0,0 +1,36 @@
/*
* Copyright 2026-2026 the original author or authors.
*/
package io.modelcontextprotocol.server.transport;
import java.util.List;
import java.util.Map;
/**
* Interface for validating HTTP requests in server transports. Implementations can
* validate Origin headers, Host headers, or any other security-related headers according
* to the MCP specification.
*
* @author Daniel Garnier-Moiroux
* @see DefaultServerTransportSecurityValidator
* @see ServerTransportSecurityException
*/
@FunctionalInterface
public interface ServerTransportSecurityValidator {
/**
* A no-op validator that accepts all requests without validation.
*/
ServerTransportSecurityValidator NOOP = headers -> {
};
/**
* Validates the HTTP headers from an incoming request.
* @param headers A map of header names to their values (multi-valued headers
* supported)
* @throws ServerTransportSecurityException if validation fails
*/
void validateHeaders(Map<String, List<String>> headers) throws ServerTransportSecurityException;
}