feat: introduce ServerHttpHeaderValidator replacing ServerTransportSecurityValidator

Same feature set as ServerTransportSecurityValidator but uses a lazy
"header accessor" function to read header values instead of extracting
all headers into a map. Matches more closely what modern frameworks and
JDK APIs do.

Fixes #870

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
Neeraj Bhatt 2026-09-07 14:21:21 +02:00 • committed by Daniel Garnier-Moiroux
commit 5be2fa0688
11 changed files with 470 additions and 168 deletions

View file

@ -7,6 +7,7 @@ package io.modelcontextprotocol.server.transport;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.Assert;
@ -22,7 +23,8 @@ import io.modelcontextprotocol.util.Assert;
* @see ServerTransportSecurityValidator * @see ServerTransportSecurityValidator
* @see ServerTransportSecurityException * @see ServerTransportSecurityException
*/ */
public final class DefaultServerTransportSecurityValidator implements ServerTransportSecurityValidator { public final class DefaultServerTransportSecurityValidator
implements ServerTransportSecurityValidator, ServerHttpHeaderValidator {
private static final String ORIGIN_HEADER = "Origin"; private static final String ORIGIN_HEADER = "Origin";
@ -47,27 +49,24 @@ public final class DefaultServerTransportSecurityValidator implements ServerTran
} }
@Override @Override
@Deprecated
public void validateHeaders(Map<String, List<String>> headers) throws ServerTransportSecurityException { public void validateHeaders(Map<String, List<String>> headers) throws ServerTransportSecurityException {
boolean missingHost = true; validate(new MapHeaderAccessor(headers));
for (Map.Entry<String, List<String>> entry : headers.entrySet()) { }
if (ORIGIN_HEADER.equalsIgnoreCase(entry.getKey())) {
List<String> values = entry.getValue(); @Override
if (values == null || values.isEmpty()) { public void validate(HeaderAccessor headerAccessor) throws ServerTransportSecurityException {
throw new ServerTransportSecurityException(403, "Invalid Origin header"); List<String> originValues = headerAccessor.getHeader(ORIGIN_HEADER);
} if (originValues != null && !originValues.isEmpty()) {
validateOrigin(values.get(0)); validateOrigin(originValues.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"); if (!allowedHosts.isEmpty()) {
List<String> hostValues = headerAccessor.getHeader(HOST_HEADER);
if (hostValues == null || hostValues.isEmpty()) {
throw new ServerTransportSecurityException(421, "Invalid Host header");
}
validateHost(hostValues.get(0));
} }
} }
@ -139,6 +138,37 @@ public final class DefaultServerTransportSecurityValidator implements ServerTran
return new Builder(); return new Builder();
} }
/**
* {@link HeaderAccessor} view over a {@code Map<String, List<String>>}, used to
* bridge the deprecated {@link #validateHeaders(Map)} to
* {@link #validate(HeaderAccessor)}.
*/
private static final class MapHeaderAccessor implements HeaderAccessor {
private final Map<String, List<String>> headers;
private MapHeaderAccessor(Map<String, List<String>> headers) {
this.headers = headers;
}
@Override
public List<String> getHeader(String name) {
return headers.entrySet()
.stream()
.filter(entry -> entry.getKey().equalsIgnoreCase(name))
.map(Map.Entry::getValue)
.filter(Objects::nonNull)
.findFirst()
.orElse(List.of());
}
@Override
public List<String> getHeaderNames() {
return List.copyOf(headers.keySet());
}
}
/** /**
* Builder for creating instances of {@link DefaultServerTransportSecurityValidator}. * Builder for creating instances of {@link DefaultServerTransportSecurityValidator}.
*/ */

View file

@ -0,0 +1,34 @@
/*
* Copyright 2026-2026 the original author or authors.
*/
package io.modelcontextprotocol.server.transport;
import java.util.List;
/**
* Abstraction for accessing HTTP headers from an incoming request. Implementations should
* provide case-insensitive header name lookups (e.g., when backed by
* {@code HttpServletRequest}).
*
* @author Neeraj Bhatt
* @since 2.1.0
* @see ServerHttpHeaderValidator
*/
public interface HeaderAccessor {
/**
* Returns the values of the specified header, or an empty list if the header is not
* present.
* @param name the header name (case-insensitive)
* @return the list of header values, never {@code null}
*/
List<String> getHeader(String name);
/**
* Returns all header names present in the request.
* @return the list of header names, never {@code null}
*/
List<String> getHeaderNames();
}

View file

@ -0,0 +1,41 @@
/*
* Copyright 2026-2026 the original author or authors.
*/
package io.modelcontextprotocol.server.transport;
import java.util.Collections;
import java.util.List;
import jakarta.servlet.http.HttpServletRequest;
/**
* {@link HeaderAccessor} implementation backed by an {@link HttpServletRequest}. Header
* name lookups are case-insensitive as per the Servlet specification.
*
* <p>
* For internal use only.
*
* @author Neeraj Bhatt
* @since 2.1.0
* @see HeaderAccessor
*/
final class HttpServletHeaderAccessor implements HeaderAccessor {
private final HttpServletRequest request;
HttpServletHeaderAccessor(HttpServletRequest request) {
this.request = request;
}
@Override
public List<String> getHeader(String name) {
return Collections.list(this.request.getHeaders(name));
}
@Override
public List<String> getHeaderNames() {
return Collections.list(this.request.getHeaderNames());
}
}

View file

@ -8,11 +8,6 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@ -26,21 +21,6 @@ final class HttpServletRequestUtils {
private HttpServletRequestUtils() { private HttpServletRequestUtils() {
} }
/**
* Extracts all headers from the HTTP request into a map.
* @param request The HTTP servlet request
* @return A map of header names to their values
*/
static 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;
}
/** /**
* Reads the request body, decoded using the request's character encoding (or UTF-8 if * Reads the request body, decoded using the request's character encoding (or UTF-8 if
* not specified), while bounding the number of bytes read. * not specified), while bounding the number of bytes read.

View file

@ -161,7 +161,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
/** /**
* Security validator for validating HTTP requests. * Security validator for validating HTTP requests.
*/ */
private final ServerTransportSecurityValidator securityValidator; private final ServerHttpHeaderValidator httpHeaderValidator;
/** /**
* Creates a new HttpServletSseServerTransportProvider instance with a custom SSE * Creates a new HttpServletSseServerTransportProvider instance with a custom SSE
@ -174,20 +174,20 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
* @param keepAliveInterval The interval for keep-alive pings, or null to disable * @param keepAliveInterval The interval for keep-alive pings, or null to disable
* keep-alive functionality * keep-alive functionality
* @param contextExtractor The extractor for transport context from the request. * @param contextExtractor The extractor for transport context from the request.
* @param securityValidator The security validator for validating HTTP requests. * @param httpHeaderValidator The HTTP header validator for validating HTTP requests.
* @param requestMaxSize The maximum size, in bytes, of a single request body. Must be * @param requestMaxSize The maximum size, in bytes, of a single request body. Must be
* positive. * positive.
*/ */
private HttpServletSseServerTransportProvider(McpJsonMapper jsonMapper, String baseUrl, String messageEndpoint, private HttpServletSseServerTransportProvider(McpJsonMapper jsonMapper, String baseUrl, String messageEndpoint,
String sseEndpoint, Duration keepAliveInterval, String sseEndpoint, Duration keepAliveInterval,
McpTransportContextExtractor<HttpServletRequest> contextExtractor, McpTransportContextExtractor<HttpServletRequest> contextExtractor,
ServerTransportSecurityValidator securityValidator, int requestMaxSize) { ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize) {
Assert.notNull(jsonMapper, "JsonMapper must not be null"); Assert.notNull(jsonMapper, "JsonMapper must not be null");
Assert.notNull(messageEndpoint, "messageEndpoint must not be null"); Assert.notNull(messageEndpoint, "messageEndpoint must not be null");
Assert.notNull(sseEndpoint, "sseEndpoint must not be null"); Assert.notNull(sseEndpoint, "sseEndpoint must not be null");
Assert.notNull(contextExtractor, "Context extractor must not be null"); Assert.notNull(contextExtractor, "Context extractor must not be null");
Assert.notNull(securityValidator, "Security validator must not be null"); Assert.notNull(httpHeaderValidator, "HTTP header validator must not be null");
Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive"); Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive");
this.jsonMapper = jsonMapper; this.jsonMapper = jsonMapper;
@ -195,7 +195,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
this.messageEndpoint = messageEndpoint; this.messageEndpoint = messageEndpoint;
this.sseEndpoint = sseEndpoint; this.sseEndpoint = sseEndpoint;
this.contextExtractor = contextExtractor; this.contextExtractor = contextExtractor;
this.securityValidator = securityValidator; this.httpHeaderValidator = httpHeaderValidator;
this.requestMaxSize = requestMaxSize; this.requestMaxSize = requestMaxSize;
if (keepAliveInterval != null) { if (keepAliveInterval != null) {
@ -293,8 +293,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
} }
try { try {
Map<String, List<String>> headers = HttpServletRequestUtils.extractHeaders(request); this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
this.securityValidator.validateHeaders(headers);
} }
catch (ServerTransportSecurityException e) { catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage()); response.sendError(e.getStatusCode(), e.getMessage());
@ -371,8 +370,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
} }
try { try {
Map<String, List<String>> headers = HttpServletRequestUtils.extractHeaders(request); this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
this.securityValidator.validateHeaders(headers);
} }
catch (ServerTransportSecurityException e) { catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage()); response.sendError(e.getStatusCode(), e.getMessage());
@ -619,7 +617,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
private Duration keepAliveInterval; private Duration keepAliveInterval;
private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP; private ServerHttpHeaderValidator httpHeaderValidator = ServerHttpHeaderValidator.NOOP;
private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE; private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE;
@ -702,10 +700,25 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
* @param securityValidator The security validator to use. Must not be null. * @param securityValidator The security validator to use. Must not be null.
* @return This builder instance * @return This builder instance
* @throws IllegalArgumentException if securityValidator is null * @throws IllegalArgumentException if securityValidator is null
* @deprecated Use {@link #httpHeaderValidator(ServerHttpHeaderValidator)}
* instead.
*/ */
@Deprecated
public Builder securityValidator(ServerTransportSecurityValidator securityValidator) { public Builder securityValidator(ServerTransportSecurityValidator securityValidator) {
Assert.notNull(securityValidator, "Security validator must not be null"); Assert.notNull(securityValidator, "Security validator must not be null");
this.securityValidator = securityValidator; this.httpHeaderValidator = ServerTransportSecurityValidator.toHttpHeaderValidator(securityValidator);
return this;
}
/**
* Sets the HTTP header validator for validating HTTP requests.
* @param httpHeaderValidator The HTTP header validator to use. Must not be null.
* @return This builder instance
* @throws IllegalArgumentException if httpHeaderValidator is null
*/
public Builder httpHeaderValidator(ServerHttpHeaderValidator httpHeaderValidator) {
Assert.notNull(httpHeaderValidator, "HTTP header validator must not be null");
this.httpHeaderValidator = httpHeaderValidator;
return this; return this;
} }
@ -734,7 +747,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
} }
return new HttpServletSseServerTransportProvider( return new HttpServletSseServerTransportProvider(
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, baseUrl, messageEndpoint, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, baseUrl, messageEndpoint,
sseEndpoint, keepAliveInterval, contextExtractor, securityValidator, requestMaxSize); sseEndpoint, keepAliveInterval, contextExtractor, httpHeaderValidator, requestMaxSize);
} }
} }

View file

@ -7,7 +7,6 @@ package io.modelcontextprotocol.server.transport;
import java.io.IOException; import java.io.IOException;
import java.io.PrintWriter; import java.io.PrintWriter;
import java.util.List; import java.util.List;
import java.util.Map;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -68,7 +67,7 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
/** /**
* Security validator for validating HTTP requests. * Security validator for validating HTTP requests.
*/ */
private final ServerTransportSecurityValidator securityValidator; private final ServerHttpHeaderValidator httpHeaderValidator;
/** /**
* Maximum size, in bytes, of a single request body accepted by this transport. * Maximum size, in bytes, of a single request body accepted by this transport.
@ -82,24 +81,24 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
* @param mcpEndpoint The endpoint URI where clients should send their JSON-RPC * @param mcpEndpoint The endpoint URI where clients should send their JSON-RPC
* messages. * messages.
* @param contextExtractor The extractor for transport context from the request. * @param contextExtractor The extractor for transport context from the request.
* @param securityValidator The security validator for validating HTTP requests. * @param httpHeaderValidator The HTTP header validator for validating HTTP requests.
* @param requestMaxSize The maximum size, in bytes, of a single request body. Must be * @param requestMaxSize The maximum size, in bytes, of a single request body. Must be
* positive. * positive.
* @throws IllegalArgumentException if any parameter is null * @throws IllegalArgumentException if any parameter is null
*/ */
private HttpServletStatelessServerTransport(McpJsonMapper jsonMapper, String mcpEndpoint, private HttpServletStatelessServerTransport(McpJsonMapper jsonMapper, String mcpEndpoint,
McpTransportContextExtractor<HttpServletRequest> contextExtractor, McpTransportContextExtractor<HttpServletRequest> contextExtractor,
ServerTransportSecurityValidator securityValidator, int requestMaxSize) { ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize) {
Assert.notNull(jsonMapper, "jsonMapper must not be null"); Assert.notNull(jsonMapper, "jsonMapper must not be null");
Assert.notNull(mcpEndpoint, "mcpEndpoint must not be null"); Assert.notNull(mcpEndpoint, "mcpEndpoint must not be null");
Assert.notNull(contextExtractor, "contextExtractor must not be null"); Assert.notNull(contextExtractor, "contextExtractor must not be null");
Assert.notNull(securityValidator, "Security validator must not be null"); Assert.notNull(httpHeaderValidator, "HTTP header validator must not be null");
Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive"); Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive");
this.jsonMapper = jsonMapper; this.jsonMapper = jsonMapper;
this.mcpEndpoint = mcpEndpoint; this.mcpEndpoint = mcpEndpoint;
this.contextExtractor = contextExtractor; this.contextExtractor = contextExtractor;
this.securityValidator = securityValidator; this.httpHeaderValidator = httpHeaderValidator;
this.requestMaxSize = requestMaxSize; this.requestMaxSize = requestMaxSize;
} }
@ -162,8 +161,7 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
} }
try { try {
Map<String, List<String>> headers = HttpServletRequestUtils.extractHeaders(request); this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
this.securityValidator.validateHeaders(headers);
} }
catch (ServerTransportSecurityException e) { catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage()); response.sendError(e.getStatusCode(), e.getMessage());
@ -300,7 +298,7 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
private McpTransportContextExtractor<HttpServletRequest> contextExtractor = ( private McpTransportContextExtractor<HttpServletRequest> contextExtractor = (
serverRequest) -> McpTransportContext.EMPTY; serverRequest) -> McpTransportContext.EMPTY;
private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP; private ServerHttpHeaderValidator httpHeaderValidator = ServerHttpHeaderValidator.NOOP;
private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE; private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE;
@ -354,10 +352,25 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
* @param securityValidator The security validator to use. Must not be null. * @param securityValidator The security validator to use. Must not be null.
* @return this builder instance * @return this builder instance
* @throws IllegalArgumentException if securityValidator is null * @throws IllegalArgumentException if securityValidator is null
* @deprecated Use {@link #httpHeaderValidator(ServerHttpHeaderValidator)}
* instead.
*/ */
@Deprecated
public Builder securityValidator(ServerTransportSecurityValidator securityValidator) { public Builder securityValidator(ServerTransportSecurityValidator securityValidator) {
Assert.notNull(securityValidator, "Security validator must not be null"); Assert.notNull(securityValidator, "Security validator must not be null");
this.securityValidator = securityValidator; this.httpHeaderValidator = ServerTransportSecurityValidator.toHttpHeaderValidator(securityValidator);
return this;
}
/**
* Sets the HTTP header validator for validating HTTP requests.
* @param httpHeaderValidator The HTTP header validator to use. Must not be null.
* @return this builder instance
* @throws IllegalArgumentException if httpHeaderValidator is null
*/
public Builder httpHeaderValidator(ServerHttpHeaderValidator httpHeaderValidator) {
Assert.notNull(httpHeaderValidator, "HTTP header validator must not be null");
this.httpHeaderValidator = httpHeaderValidator;
return this; return this;
} }
@ -384,7 +397,7 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
Assert.notNull(mcpEndpoint, "Message endpoint must be set"); Assert.notNull(mcpEndpoint, "Message endpoint must be set");
return new HttpServletStatelessServerTransport( return new HttpServletStatelessServerTransport(
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, contextExtractor, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, contextExtractor,
securityValidator, requestMaxSize); httpHeaderValidator, requestMaxSize);
} }
} }

View file

@ -9,7 +9,6 @@ import java.io.PrintWriter;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantLock;
@ -131,7 +130,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
/** /**
* Security validator for validating HTTP requests. * Security validator for validating HTTP requests.
*/ */
private final ServerTransportSecurityValidator securityValidator; private final ServerHttpHeaderValidator httpHeaderValidator;
/** /**
* Constructs a new HttpServletStreamableServerTransportProvider instance. * Constructs a new HttpServletStreamableServerTransportProvider instance.
@ -143,25 +142,25 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
* @param contextExtractor The extractor for transport context from the request. * @param contextExtractor The extractor for transport context from the request.
* @param keepAliveInterval The interval for keep-alive pings. If null, no keep-alive * @param keepAliveInterval The interval for keep-alive pings. If null, no keep-alive
* will be scheduled. * will be scheduled.
* @param securityValidator The security validator for validating HTTP requests. * @param httpHeaderValidator The HTTP header validator for validating HTTP requests.
* @param requestMaxSize The maximum size, in bytes, of a single request body. Must be * @param requestMaxSize The maximum size, in bytes, of a single request body. Must be
* positive. * positive.
* @throws IllegalArgumentException if any parameter is null * @throws IllegalArgumentException if any parameter is null
*/ */
private HttpServletStreamableServerTransportProvider(McpJsonMapper jsonMapper, String mcpEndpoint, private HttpServletStreamableServerTransportProvider(McpJsonMapper jsonMapper, String mcpEndpoint,
boolean disallowDelete, McpTransportContextExtractor<HttpServletRequest> contextExtractor, boolean disallowDelete, McpTransportContextExtractor<HttpServletRequest> contextExtractor,
Duration keepAliveInterval, ServerTransportSecurityValidator securityValidator, int requestMaxSize) { Duration keepAliveInterval, ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize) {
Assert.notNull(jsonMapper, "JsonMapper must not be null"); Assert.notNull(jsonMapper, "JsonMapper must not be null");
Assert.notNull(mcpEndpoint, "MCP endpoint must not be null"); Assert.notNull(mcpEndpoint, "MCP endpoint must not be null");
Assert.notNull(contextExtractor, "Context extractor must not be null"); Assert.notNull(contextExtractor, "Context extractor must not be null");
Assert.notNull(securityValidator, "Security validator must not be null"); Assert.notNull(httpHeaderValidator, "HTTP header validator must not be null");
Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive"); Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive");
this.jsonMapper = jsonMapper; this.jsonMapper = jsonMapper;
this.mcpEndpoint = mcpEndpoint; this.mcpEndpoint = mcpEndpoint;
this.disallowDelete = disallowDelete; this.disallowDelete = disallowDelete;
this.contextExtractor = contextExtractor; this.contextExtractor = contextExtractor;
this.securityValidator = securityValidator; this.httpHeaderValidator = httpHeaderValidator;
this.requestMaxSize = requestMaxSize; this.requestMaxSize = requestMaxSize;
if (keepAliveInterval != null) { if (keepAliveInterval != null) {
@ -275,8 +274,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
} }
try { try {
Map<String, List<String>> headers = HttpServletRequestUtils.extractHeaders(request); this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
this.securityValidator.validateHeaders(headers);
} }
catch (ServerTransportSecurityException e) { catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage()); response.sendError(e.getStatusCode(), e.getMessage());
@ -415,8 +413,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
} }
try { try {
Map<String, List<String>> headers = HttpServletRequestUtils.extractHeaders(request); this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
this.securityValidator.validateHeaders(headers);
} }
catch (ServerTransportSecurityException e) { catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage()); response.sendError(e.getStatusCode(), e.getMessage());
@ -594,8 +591,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
} }
try { try {
Map<String, List<String>> headers = HttpServletRequestUtils.extractHeaders(request); this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
this.securityValidator.validateHeaders(headers);
} }
catch (ServerTransportSecurityException e) { catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage()); response.sendError(e.getStatusCode(), e.getMessage());
@ -848,7 +844,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
private Duration keepAliveInterval; private Duration keepAliveInterval;
private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP; private ServerHttpHeaderValidator httpHeaderValidator = ServerHttpHeaderValidator.NOOP;
private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE; private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE;
@ -916,10 +912,25 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
* @param securityValidator The security validator to use. Must not be null. * @param securityValidator The security validator to use. Must not be null.
* @return this builder instance * @return this builder instance
* @throws IllegalArgumentException if securityValidator is null * @throws IllegalArgumentException if securityValidator is null
* @deprecated Use {@link #httpHeaderValidator(ServerHttpHeaderValidator)}
* instead.
*/ */
@Deprecated
public Builder securityValidator(ServerTransportSecurityValidator securityValidator) { public Builder securityValidator(ServerTransportSecurityValidator securityValidator) {
Assert.notNull(securityValidator, "Security validator must not be null"); Assert.notNull(securityValidator, "Security validator must not be null");
this.securityValidator = securityValidator; this.httpHeaderValidator = ServerTransportSecurityValidator.toHttpHeaderValidator(securityValidator);
return this;
}
/**
* Sets the HTTP header validator for validating HTTP requests.
* @param httpHeaderValidator The HTTP header validator to use. Must not be null.
* @return this builder instance
* @throws IllegalArgumentException if httpHeaderValidator is null
*/
public Builder httpHeaderValidator(ServerHttpHeaderValidator httpHeaderValidator) {
Assert.notNull(httpHeaderValidator, "HTTP header validator must not be null");
this.httpHeaderValidator = httpHeaderValidator;
return this; return this;
} }
@ -946,7 +957,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set"); Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set");
return new HttpServletStreamableServerTransportProvider( return new HttpServletStreamableServerTransportProvider(
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, disallowDelete, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, disallowDelete,
contextExtractor, keepAliveInterval, securityValidator, requestMaxSize); contextExtractor, keepAliveInterval, httpHeaderValidator, requestMaxSize);
} }
} }

View file

@ -0,0 +1,30 @@
/*
* Copyright 2026-2026 the original author or authors.
*/
package io.modelcontextprotocol.server.transport;
/**
* Validates HTTP request headers in server transports.
*
* @author Neeraj Bhatt
* @see HeaderAccessor
* @see ServerTransportSecurityException
*/
@FunctionalInterface
public interface ServerHttpHeaderValidator {
/**
* A no-op validator that accepts all requests without validation.
*/
ServerHttpHeaderValidator NOOP = headerAccessor -> {
};
/**
* Validates the HTTP headers from an incoming request.
* @param headerAccessor provides access to request headers
* @throws ServerTransportSecurityException if validation fails
*/
void validate(HeaderAccessor headerAccessor) throws ServerTransportSecurityException;
}

View file

@ -6,6 +6,8 @@ package io.modelcontextprotocol.server.transport;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/** /**
* Interface for validating HTTP requests in server transports. Implementations can * Interface for validating HTTP requests in server transports. Implementations can
@ -13,9 +15,12 @@ import java.util.Map;
* to the MCP specification. * to the MCP specification.
* *
* @author Daniel Garnier-Moiroux * @author Daniel Garnier-Moiroux
* @see DefaultServerTransportSecurityValidator * @see ServerHttpHeaderValidator
* @see ServerTransportSecurityException * @see ServerTransportSecurityException
* @deprecated Use {@link ServerHttpHeaderValidator} instead. This interface will be
* removed in a future major version.
*/ */
@Deprecated
@FunctionalInterface @FunctionalInterface
public interface ServerTransportSecurityValidator { public interface ServerTransportSecurityValidator {
@ -25,6 +30,20 @@ public interface ServerTransportSecurityValidator {
ServerTransportSecurityValidator NOOP = headers -> { ServerTransportSecurityValidator NOOP = headers -> {
}; };
/**
* Adapts a legacy map-based validator to a header accessor validator.
* @param validator the legacy validator to adapt
* @return a header accessor validator delegating to {@code validator}
*/
static ServerHttpHeaderValidator toHttpHeaderValidator(ServerTransportSecurityValidator validator) {
return accessor -> {
var collectedHeaders = accessor.getHeaderNames()
.stream()
.collect(Collectors.toUnmodifiableMap(Function.identity(), accessor::getHeader));
validator.validateHeaders(collectedHeaders);
};
}
/** /**
* Validates the HTTP headers from an incoming request. * Validates the HTTP headers from an incoming request.
* @param headers A map of header names to their values (multi-valued headers * @param headers A map of header names to their values (multi-valued headers

View file

@ -43,50 +43,50 @@ class DefaultServerTransportSecurityValidatorTests {
@Test @Test
void originHeaderMissing() { void originHeaderMissing() {
assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException(); assertThatCode(() -> validator.validate(emptyAccessor())).doesNotThrowAnyException();
} }
@Test @Test
void originHeaderListEmpty() { void originHeaderListEmpty() {
assertThatThrownBy(() -> validator.validateHeaders(Map.of("Origin", List.of()))).isEqualTo(INVALID_ORIGIN); assertThatCode(() -> validator.validate(headerAccessor())).doesNotThrowAnyException();
} }
@Test @Test
void caseInsensitive() { void caseInsensitive() {
var headers = Map.of("origin", List.of("http://localhost:8080")); var accessor = headerAccessor("Origin", "http://localhost:8080");
assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); assertThatCode(() -> validator.validate(accessor)).doesNotThrowAnyException();
} }
@Test @Test
void exactMatch() { void exactMatch() {
var headers = originHeader("http://localhost:8080"); var accessor = originAccessor("http://localhost:8080");
assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); assertThatCode(() -> validator.validate(accessor)).doesNotThrowAnyException();
} }
@Test @Test
void differentPort() { void differentPort() {
var headers = originHeader("http://localhost:3000"); var accessor = originAccessor("http://localhost:3000");
assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); assertThatThrownBy(() -> validator.validate(accessor)).isEqualTo(INVALID_ORIGIN);
} }
@Test @Test
void differentHost() { void differentHost() {
var headers = originHeader("http://example.com:8080"); var accessor = originAccessor("http://example.com:8080");
assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); assertThatThrownBy(() -> validator.validate(accessor)).isEqualTo(INVALID_ORIGIN);
} }
@Test @Test
void differentScheme() { void differentScheme() {
var headers = originHeader("https://localhost:8080"); var accessor = originAccessor("https://localhost:8080");
assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); assertThatThrownBy(() -> validator.validate(accessor)).isEqualTo(INVALID_ORIGIN);
} }
@Nested @Nested
@ -99,37 +99,37 @@ class DefaultServerTransportSecurityValidatorTests {
@Test @Test
void anyPortWithWildcard() { void anyPortWithWildcard() {
var headers = originHeader("http://localhost:3000"); var accessor = originAccessor("http://localhost:3000");
assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException(); assertThatCode(() -> wildcardValidator.validate(accessor)).doesNotThrowAnyException();
} }
@Test @Test
void noPortWithWildcard() { void noPortWithWildcard() {
var headers = originHeader("http://localhost"); var accessor = originAccessor("http://localhost");
assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException(); assertThatCode(() -> wildcardValidator.validate(accessor)).doesNotThrowAnyException();
} }
@Test @Test
void differentPortWithWildcard() { void differentPortWithWildcard() {
var headers = originHeader("http://localhost:8080"); var accessor = originAccessor("http://localhost:8080");
assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException(); assertThatCode(() -> wildcardValidator.validate(accessor)).doesNotThrowAnyException();
} }
@Test @Test
void differentHostWithWildcard() { void differentHostWithWildcard() {
var headers = originHeader("http://example.com:3000"); var accessor = originAccessor("http://example.com:3000");
assertThatThrownBy(() -> wildcardValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); assertThatThrownBy(() -> wildcardValidator.validate(accessor)).isEqualTo(INVALID_ORIGIN);
} }
@Test @Test
void differentSchemeWithWildcard() { void differentSchemeWithWildcard() {
var headers = originHeader("https://localhost:3000"); var accessor = originAccessor("https://localhost:3000");
assertThatThrownBy(() -> wildcardValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); assertThatThrownBy(() -> wildcardValidator.validate(accessor)).isEqualTo(INVALID_ORIGIN);
} }
} }
@ -146,23 +146,23 @@ class DefaultServerTransportSecurityValidatorTests {
@Test @Test
void matchingOneOfMultiple() { void matchingOneOfMultiple() {
var headers = originHeader("http://example.com:3000"); var accessor = originAccessor("http://example.com:3000");
assertThatCode(() -> multipleOriginsValidator.validateHeaders(headers)).doesNotThrowAnyException(); assertThatCode(() -> multipleOriginsValidator.validate(accessor)).doesNotThrowAnyException();
} }
@Test @Test
void matchingWildcardInMultiple() { void matchingWildcardInMultiple() {
var headers = originHeader("http://myapp.example.com:9999"); var accessor = originAccessor("http://myapp.example.com:9999");
assertThatCode(() -> multipleOriginsValidator.validateHeaders(headers)).doesNotThrowAnyException(); assertThatCode(() -> multipleOriginsValidator.validate(accessor)).doesNotThrowAnyException();
} }
@Test @Test
void notMatchingAny() { void notMatchingAny() {
var headers = originHeader("http://malicious.example.com:1234"); var accessor = originAccessor("http://malicious.example.com:1234");
assertThatThrownBy(() -> multipleOriginsValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); assertThatThrownBy(() -> multipleOriginsValidator.validate(accessor)).isEqualTo(INVALID_ORIGIN);
} }
} }
@ -176,9 +176,9 @@ class DefaultServerTransportSecurityValidatorTests {
.allowedOrigins(List.of("http://localhost:8080", "http://example.com:*")) .allowedOrigins(List.of("http://localhost:8080", "http://example.com:*"))
.build(); .build();
var headers = originHeader("http://example.com:3000"); var accessor = originAccessor("http://example.com:3000");
assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); assertThatCode(() -> validator.validate(accessor)).doesNotThrowAnyException();
} }
@Test @Test
@ -188,11 +188,11 @@ class DefaultServerTransportSecurityValidatorTests {
.allowedOrigins(List.of("http://example.com:*", "http://test.com:3000")) .allowedOrigins(List.of("http://example.com:*", "http://test.com:3000"))
.build(); .build();
assertThatCode(() -> validator.validateHeaders(originHeader("http://localhost:8080"))) assertThatCode(() -> validator.validate(originAccessor("http://localhost:8080")))
.doesNotThrowAnyException(); .doesNotThrowAnyException();
assertThatCode(() -> validator.validateHeaders(originHeader("http://example.com:9999"))) assertThatCode(() -> validator.validate(originAccessor("http://example.com:9999")))
.doesNotThrowAnyException(); .doesNotThrowAnyException();
assertThatCode(() -> validator.validateHeaders(originHeader("http://test.com:3000"))) assertThatCode(() -> validator.validate(originAccessor("http://test.com:3000")))
.doesNotThrowAnyException(); .doesNotThrowAnyException();
} }
@ -210,45 +210,45 @@ class DefaultServerTransportSecurityValidatorTests {
@Test @Test
void notConfigured() { void notConfigured() {
assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException(); assertThatCode(() -> validator.validate(emptyAccessor())).doesNotThrowAnyException();
} }
@Test @Test
void missing() { void missing() {
assertThatThrownBy(() -> hostValidator.validateHeaders(new HashMap<>())).isEqualTo(INVALID_HOST); assertThatThrownBy(() -> hostValidator.validate(emptyAccessor())).isEqualTo(INVALID_HOST);
} }
@Test @Test
void listEmpty() { void listEmpty() {
assertThatThrownBy(() -> hostValidator.validateHeaders(Map.of("Host", List.of()))).isEqualTo(INVALID_HOST); assertThatThrownBy(() -> hostValidator.validate(headerAccessor())).isEqualTo(INVALID_HOST);
} }
@Test @Test
void caseInsensitive() { void caseInsensitive() {
var headers = Map.of("host", List.of("localhost:8080")); var accessor = headerAccessor("Host", "localhost:8080");
assertThatCode(() -> hostValidator.validateHeaders(headers)).doesNotThrowAnyException(); assertThatCode(() -> hostValidator.validate(accessor)).doesNotThrowAnyException();
} }
@Test @Test
void exactMatch() { void exactMatch() {
var headers = hostHeader("localhost:8080"); var accessor = hostAccessor("localhost:8080");
assertThatCode(() -> hostValidator.validateHeaders(headers)).doesNotThrowAnyException(); assertThatCode(() -> hostValidator.validate(accessor)).doesNotThrowAnyException();
} }
@Test @Test
void differentPort() { void differentPort() {
var headers = hostHeader("localhost:3000"); var accessor = hostAccessor("localhost:3000");
assertThatThrownBy(() -> hostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); assertThatThrownBy(() -> hostValidator.validate(accessor)).isEqualTo(INVALID_HOST);
} }
@Test @Test
void differentHost() { void differentHost() {
var headers = hostHeader("example.com:8080"); var accessor = hostAccessor("example.com:8080");
assertThatThrownBy(() -> hostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); assertThatThrownBy(() -> hostValidator.validate(accessor)).isEqualTo(INVALID_HOST);
} }
@Nested @Nested
@ -261,23 +261,23 @@ class DefaultServerTransportSecurityValidatorTests {
@Test @Test
void anyPort() { void anyPort() {
var headers = hostHeader("localhost:3000"); var accessor = hostAccessor("localhost:3000");
assertThatCode(() -> wildcardHostValidator.validateHeaders(headers)).doesNotThrowAnyException(); assertThatCode(() -> wildcardHostValidator.validate(accessor)).doesNotThrowAnyException();
} }
@Test @Test
void noPort() { void noPort() {
var headers = hostHeader("localhost"); var accessor = hostAccessor("localhost");
assertThatCode(() -> wildcardHostValidator.validateHeaders(headers)).doesNotThrowAnyException(); assertThatCode(() -> wildcardHostValidator.validate(accessor)).doesNotThrowAnyException();
} }
@Test @Test
void differentHost() { void differentHost() {
var headers = hostHeader("example.com:3000"); var accessor = hostAccessor("example.com:3000");
assertThatThrownBy(() -> wildcardHostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); assertThatThrownBy(() -> wildcardHostValidator.validate(accessor)).isEqualTo(INVALID_HOST);
} }
} }
@ -293,30 +293,30 @@ class DefaultServerTransportSecurityValidatorTests {
@Test @Test
void exactMatch() { void exactMatch() {
var headers = hostHeader("example.com:3000"); var accessor = hostAccessor("example.com:3000");
assertThatCode(() -> multipleHostsValidator.validateHeaders(headers)).doesNotThrowAnyException(); assertThatCode(() -> multipleHostsValidator.validate(accessor)).doesNotThrowAnyException();
} }
@Test @Test
void wildcard() { void wildcard() {
var headers = hostHeader("myapp.example.com:9999"); var accessor = hostAccessor("myapp.example.com:9999");
assertThatCode(() -> multipleHostsValidator.validateHeaders(headers)).doesNotThrowAnyException(); assertThatCode(() -> multipleHostsValidator.validate(accessor)).doesNotThrowAnyException();
} }
@Test @Test
void differentHost() { void differentHost() {
var headers = hostHeader("malicious.example.com:3000"); var accessor = hostAccessor("malicious.example.com:3000");
assertThatThrownBy(() -> multipleHostsValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); assertThatThrownBy(() -> multipleHostsValidator.validate(accessor)).isEqualTo(INVALID_HOST);
} }
@Test @Test
void differentPort() { void differentPort() {
var headers = hostHeader("localhost:8080"); var accessor = hostAccessor("localhost:8080");
assertThatThrownBy(() -> multipleHostsValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); assertThatThrownBy(() -> multipleHostsValidator.validate(accessor)).isEqualTo(INVALID_HOST);
} }
} }
@ -330,10 +330,8 @@ class DefaultServerTransportSecurityValidatorTests {
.allowedHosts(List.of("localhost:8080", "example.com:*")) .allowedHosts(List.of("localhost:8080", "example.com:*"))
.build(); .build();
assertThatCode(() -> validator.validateHeaders(hostHeader("example.com:3000"))) assertThatCode(() -> validator.validate(hostAccessor("example.com:3000"))).doesNotThrowAnyException();
.doesNotThrowAnyException(); assertThatCode(() -> validator.validate(hostAccessor("localhost:8080"))).doesNotThrowAnyException();
assertThatCode(() -> validator.validateHeaders(hostHeader("localhost:8080")))
.doesNotThrowAnyException();
} }
@Test @Test
@ -343,11 +341,9 @@ class DefaultServerTransportSecurityValidatorTests {
.allowedHosts(List.of("example.com:*", "test.com:3000")) .allowedHosts(List.of("example.com:*", "test.com:3000"))
.build(); .build();
assertThatCode(() -> validator.validateHeaders(hostHeader("localhost:8080"))) assertThatCode(() -> validator.validate(hostAccessor("localhost:8080"))).doesNotThrowAnyException();
.doesNotThrowAnyException(); assertThatCode(() -> validator.validate(hostAccessor("example.com:9999"))).doesNotThrowAnyException();
assertThatCode(() -> validator.validateHeaders(hostHeader("example.com:9999"))) assertThatCode(() -> validator.validate(hostAccessor("test.com:3000"))).doesNotThrowAnyException();
.doesNotThrowAnyException();
assertThatCode(() -> validator.validateHeaders(hostHeader("test.com:3000"))).doesNotThrowAnyException();
} }
} }
@ -365,60 +361,195 @@ class DefaultServerTransportSecurityValidatorTests {
@Test @Test
void bothValid() { void bothValid() {
var header = headers("http://localhost:8080", "localhost:8080"); var accessor = combinedAccessor("http://localhost:8080", "localhost:8080");
assertThatCode(() -> combinedValidator.validateHeaders(header)).doesNotThrowAnyException(); assertThatCode(() -> combinedValidator.validate(accessor)).doesNotThrowAnyException();
} }
@Test @Test
void originValidHostInvalid() { void originValidHostInvalid() {
var header = headers("http://localhost:8080", "malicious.example.com:8080"); var accessor = combinedAccessor("http://localhost:8080", "malicious.example.com:8080");
assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_HOST); assertThatThrownBy(() -> combinedValidator.validate(accessor)).isEqualTo(INVALID_HOST);
} }
@Test @Test
void originInvalidHostValid() { void originInvalidHostValid() {
var header = headers("http://malicious.example.com:8080", "localhost:8080"); var accessor = combinedAccessor("http://malicious.example.com:8080", "localhost:8080");
assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_ORIGIN); assertThatThrownBy(() -> combinedValidator.validate(accessor)).isEqualTo(INVALID_ORIGIN);
} }
@Test @Test
void originMissingHostValid() { void originMissingHostValid() {
// Origin missing is OK (same-origin request) // Origin missing is OK (same-origin request)
var header = headers(null, "localhost:8080"); var accessor = combinedAccessor(null, "localhost:8080");
assertThatCode(() -> combinedValidator.validateHeaders(header)).doesNotThrowAnyException(); assertThatCode(() -> combinedValidator.validate(accessor)).doesNotThrowAnyException();
} }
@Test @Test
void originValidHostMissing() { void originValidHostMissing() {
// Host missing is NOT OK when allowedHosts is configured // Host missing is NOT OK when allowedHosts is configured
var header = headers("http://localhost:8080", null); var accessor = combinedAccessor("http://localhost:8080", null);
assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_HOST); assertThatThrownBy(() -> combinedValidator.validate(accessor)).isEqualTo(INVALID_HOST);
} }
} }
private static Map<String, List<String>> originHeader(String origin) { @Nested
return Map.of("Origin", List.of(origin)); class DeprecatedMapBasedApi {
@Test
void originValidation() {
Map<String, List<String>> headers = new HashMap<>();
headers.put("Origin", List.of("http://localhost:8080"));
assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void originRejected() {
Map<String, List<String>> headers = new HashMap<>();
headers.put("Origin", List.of("http://malicious.example.com"));
assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN);
}
@Test
void caseInsensitiveHeaderLookup() {
Map<String, List<String>> headers = new HashMap<>();
headers.put("origin", List.of("http://localhost:8080"));
assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void hostValidation() {
DefaultServerTransportSecurityValidator hostValidator = DefaultServerTransportSecurityValidator.builder()
.allowedHost("localhost:8080")
.build();
Map<String, List<String>> headers = new HashMap<>();
headers.put("Host", List.of("localhost:8080"));
assertThatCode(() -> hostValidator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void hostRejected() {
DefaultServerTransportSecurityValidator hostValidator = DefaultServerTransportSecurityValidator.builder()
.allowedHost("localhost:8080")
.build();
Map<String, List<String>> headers = new HashMap<>();
headers.put("Host", List.of("malicious.com:8080"));
assertThatThrownBy(() -> hostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST);
}
@Test
void emptyHeaders() {
assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException();
}
@Test
void combinedOriginAndHost() {
DefaultServerTransportSecurityValidator combinedValidator = DefaultServerTransportSecurityValidator
.builder()
.allowedOrigin("http://localhost:*")
.allowedHost("localhost:*")
.build();
Map<String, List<String>> headers = new HashMap<>();
headers.put("Origin", List.of("http://localhost:8080"));
headers.put("Host", List.of("localhost:8080"));
assertThatCode(() -> combinedValidator.validateHeaders(headers)).doesNotThrowAnyException();
}
} }
private static Map<String, List<String>> hostHeader(String host) { @Nested
return Map.of("Host", List.of(host)); class ValidatorCompatibility {
@Test
void noopAcceptsAll() {
assertThatCode(() -> ServerHttpHeaderValidator.NOOP.validate(emptyAccessor())).doesNotThrowAnyException();
assertThatCode(() -> ServerTransportSecurityValidator.NOOP.validateHeaders(new HashMap<>()))
.doesNotThrowAnyException();
}
@Test
void legacyValidatorAdaptsToHeaderAccessor() {
ServerTransportSecurityValidator legacyValidator = headers -> {
List<String> origins = headers.getOrDefault("Origin", List.of());
if (!origins.isEmpty() && origins.get(0).contains("evil")) {
throw new ServerTransportSecurityException(403, "Invalid Origin header");
}
};
ServerHttpHeaderValidator headerValidator = ServerTransportSecurityValidator
.toHttpHeaderValidator(legacyValidator);
assertThatCode(() -> headerValidator.validate(originAccessor("http://good.example.com")))
.doesNotThrowAnyException();
assertThatThrownBy(() -> headerValidator.validate(originAccessor("http://evil.example.com")))
.isEqualTo(INVALID_ORIGIN);
}
} }
private static Map<String, List<String>> headers(String origin, String host) { private static HeaderAccessor emptyAccessor() {
var map = new HashMap<String, List<String>>(); return headerAccessor();
}
private static HeaderAccessor headerAccessor(String... namesAndValues) {
Map<String, List<String>> headers = new HashMap<>();
for (int i = 0; i < namesAndValues.length; i += 2) {
headers.put(namesAndValues[i], List.of(namesAndValues[i + 1]));
}
return new HeaderAccessor() {
@Override
public List<String> getHeader(String name) {
return headers.getOrDefault(name, List.of());
}
@Override
public List<String> getHeaderNames() {
return List.copyOf(headers.keySet());
}
};
}
private static HeaderAccessor originAccessor(String origin) {
return headerAccessor("Origin", origin);
}
private static HeaderAccessor hostAccessor(String host) {
return headerAccessor("Host", host);
}
private static HeaderAccessor combinedAccessor(String origin, String host) {
Map<String, List<String>> headers = new HashMap<>();
if (origin != null) { if (origin != null) {
map.put("Origin", List.of(origin)); headers.put("Origin", List.of(origin));
} }
if (host != null) { if (host != null) {
map.put("Host", List.of(host)); headers.put("Host", List.of(host));
} }
return map; return new HeaderAccessor() {
@Override
public List<String> getHeader(String name) {
return headers.getOrDefault(name, List.of());
}
@Override
public List<String> getHeaderNames() {
return List.copyOf(headers.keySet());
}
};
} }
} }

View file

@ -209,7 +209,7 @@ class ServerTransportSecurityIntegrationTests {
public Sse() { public Sse() {
transport = HttpServletSseServerTransportProvider.builder() transport = HttpServletSseServerTransportProvider.builder()
.messageEndpoint("/mcp/message") .messageEndpoint("/mcp/message")
.securityValidator(DefaultServerTransportSecurityValidator.builder() .httpHeaderValidator(DefaultServerTransportSecurityValidator.builder()
.allowedOrigin("http://localhost:*") .allowedOrigin("http://localhost:*")
.allowedHost("localhost:*") .allowedHost("localhost:*")
.build()) .build())
@ -242,7 +242,7 @@ class ServerTransportSecurityIntegrationTests {
public StreamableHttp() { public StreamableHttp() {
transport = HttpServletStreamableServerTransportProvider.builder() transport = HttpServletStreamableServerTransportProvider.builder()
.securityValidator(DefaultServerTransportSecurityValidator.builder() .httpHeaderValidator(DefaultServerTransportSecurityValidator.builder()
.allowedOrigin("http://localhost:*") .allowedOrigin("http://localhost:*")
.allowedHost("localhost:*") .allowedHost("localhost:*")
.build()) .build())
@ -276,7 +276,7 @@ class ServerTransportSecurityIntegrationTests {
public Stateless() { public Stateless() {
transport = HttpServletStatelessServerTransport.builder() transport = HttpServletStatelessServerTransport.builder()
.securityValidator(DefaultServerTransportSecurityValidator.builder() .httpHeaderValidator(DefaultServerTransportSecurityValidator.builder()
.allowedOrigin("http://localhost:*") .allowedOrigin("http://localhost:*")
.allowedHost("localhost:*") .allowedHost("localhost:*")
.build()) .build())