Validate message endpoint in SSE client transport

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
Daniel Garnier-Moiroux 2026-04-30 15:32:34 +02:00 • committed by Daniel Garnier-Moiroux
commit 563b1551a3
4 changed files with 269 additions and 10 deletions

View file

@ -0,0 +1,58 @@
/*
* Copyright 2026-2026 the original author or authors.
*/
package io.modelcontextprotocol.client.transport;
import java.net.URI;
import java.net.URISyntaxException;
import io.modelcontextprotocol.util.Assert;
/**
* Default {@link SseMessageEndpointValidator} that validates the {@code message} endpoint
* advertised by an SSE server. Message endpoints must be a relative URI, without path
* traversal or authority.
*
* @author Daniel Garnier-Moiroux
*/
public final class DefaultSseMessageEndpointValidator implements SseMessageEndpointValidator {
@Override
public void validate(URI sseUri, String messageEndpoint) throws InvalidSseMessageEndpointException {
Assert.hasText(messageEndpoint, "messageEndpoint must not be empty");
URI endpointUri;
try {
endpointUri = new URI(messageEndpoint);
}
catch (URISyntaxException ex) {
throw new InvalidSseMessageEndpointException("messageEndpoint is not a valid URI: " + ex.getMessage(),
messageEndpoint);
}
if (endpointUri.isAbsolute()) {
// Exclude absolute URIs e.g. https://example.com/mcp
throw new InvalidSseMessageEndpointException("messageEndpoint must be a relative path, not an absolute URI",
messageEndpoint);
}
if (endpointUri.getRawAuthority() != null) {
// Exclude network paths e.g. //example.com/mcp
throw new InvalidSseMessageEndpointException(
"messageEndpoint must be a relative path and must not contain an authority", messageEndpoint);
}
// Exclude path-traversal
String decodedPath = endpointUri.getPath();
if (decodedPath != null) {
for (String segment : decodedPath.split("/", -1)) {
if (".".equals(segment) || "..".equals(segment)) {
throw new InvalidSseMessageEndpointException(
"messageEndpoint must not contain path-traversal segments", messageEndpoint);
}
}
}
}
}

View file

@ -16,8 +16,6 @@ import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.function.Function;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.modelcontextprotocol.client.transport.ResponseSubscribers.ResponseEvent;
import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer;
import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer;
@ -33,6 +31,8 @@ import io.modelcontextprotocol.spec.McpTransportException;
import io.modelcontextprotocol.spec.ProtocolVersions;
import io.modelcontextprotocol.util.Assert;
import io.modelcontextprotocol.util.Utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.Disposable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@ -117,6 +117,11 @@ public class HttpClientSseClientTransport implements McpClientTransport {
*/
private final McpAsyncHttpClientRequestCustomizer httpRequestCustomizer;
/**
* Validator for the message endpoint;
*/
private final SseMessageEndpointValidator messageEndpointValidator;
/**
* Creates a new transport instance with custom HTTP client builder, object mapper,
* and headers.
@ -127,22 +132,26 @@ public class HttpClientSseClientTransport implements McpClientTransport {
* @param jsonMapper the object mapper for JSON serialization/deserialization
* @param httpRequestCustomizer customizer for the requestBuilder before executing
* requests
* @param messageEndpointValidator validator for the message endpoint
* @throws IllegalArgumentException if objectMapper, clientBuilder, or headers is null
*/
HttpClientSseClientTransport(HttpClient httpClient, HttpRequest.Builder requestBuilder, String baseUri,
String sseEndpoint, McpJsonMapper jsonMapper, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer) {
String sseEndpoint, McpJsonMapper jsonMapper, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer,
SseMessageEndpointValidator messageEndpointValidator) {
Assert.notNull(jsonMapper, "jsonMapper must not be null");
Assert.hasText(baseUri, "baseUri must not be empty");
Assert.hasText(sseEndpoint, "sseEndpoint must not be empty");
Assert.notNull(httpClient, "httpClient must not be null");
Assert.notNull(requestBuilder, "requestBuilder must not be null");
Assert.notNull(httpRequestCustomizer, "httpRequestCustomizer must not be null");
Assert.notNull(messageEndpointValidator, "messageEndpointValidator must not be null");
this.baseUri = URI.create(baseUri);
this.sseEndpoint = sseEndpoint;
this.jsonMapper = jsonMapper;
this.httpClient = httpClient;
this.requestBuilder = requestBuilder;
this.httpRequestCustomizer = httpRequestCustomizer;
this.messageEndpointValidator = messageEndpointValidator;
}
@Override
@ -178,6 +187,8 @@ public class HttpClientSseClientTransport implements McpClientTransport {
private Duration connectTimeout = Duration.ofSeconds(10);
private SseMessageEndpointValidator messageEndpointValidator = new DefaultSseMessageEndpointValidator();
/**
* Creates a new builder instance.
*/
@ -297,6 +308,18 @@ public class HttpClientSseClientTransport implements McpClientTransport {
return this;
}
/**
* Sets the validator that ensure the message endpoint returned over the SSE
* connection is valid.
* @param messageEndpointValidator the validator
* @return this builder
*/
public Builder messageEndpointValidator(SseMessageEndpointValidator messageEndpointValidator) {
Assert.notNull(messageEndpointValidator, "messageEndpointValidator must not be null");
this.messageEndpointValidator = messageEndpointValidator;
return this;
}
/**
* Builds a new {@link HttpClientSseClientTransport} instance.
* @return a new transport instance
@ -304,7 +327,8 @@ public class HttpClientSseClientTransport implements McpClientTransport {
public HttpClientSseClientTransport build() {
HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build();
return new HttpClientSseClientTransport(httpClient, requestBuilder, baseUri, sseEndpoint,
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, httpRequestCustomizer);
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, httpRequestCustomizer,
messageEndpointValidator);
}
}
@ -342,6 +366,14 @@ public class HttpClientSseClientTransport implements McpClientTransport {
try {
if (ENDPOINT_EVENT_TYPE.equals(responseEvent.sseEvent().event())) {
String messageEndpointUri = responseEvent.sseEvent().data();
try {
messageEndpointValidator.validate(uri, messageEndpointUri);
}
catch (InvalidSseMessageEndpointException e) {
sink.error(e);
this.messageEndpointSink.tryEmitError(e);
return Flux.error(e);
}
if (this.messageEndpointSink.tryEmitValue(messageEndpointUri).isSuccess()) {
sink.success();
return Flux.empty(); // No further processing needed

View file

@ -0,0 +1,26 @@
/*
* Copyright 2026-2026 the original author or authors.
*/
package io.modelcontextprotocol.client.transport;
/**
* Exception thrown when the {@code message} endpoint returned from the SSE connection is
* not valid.
*
* @author Daniel Garnier-Moiroux
*/
public class InvalidSseMessageEndpointException extends Exception {
private final String messageEndpoint;
public InvalidSseMessageEndpointException(String message, String messageEndpoint) {
super(message);
this.messageEndpoint = messageEndpoint;
}
public String getMessageEndpoint() {
return messageEndpoint;
}
}

View file

@ -0,0 +1,27 @@
/*
* Copyright 2026-2026 the original author or authors.
*/
package io.modelcontextprotocol.client.transport;
import java.net.URI;
/**
* Validate the that message endpoint in the SSE transport is valid. Throws
* {@link InvalidSseMessageEndpointException} when then endpoint is not valid.
*
* @author Daniel Garnier-Moiroux
*/
@FunctionalInterface
public interface SseMessageEndpointValidator {
/**
* Validate the message endpoint coming from an SSE connection. Throws if not valid.
* @param sseUri the URI used to establish the SSE connection
* @param messageEndpoint the message endpoint from the SSE connection
* @throws InvalidSseMessageEndpointException error thrown if the message endpoint is
* not valid.
*/
void validate(URI sseUri, String messageEndpoint) throws InvalidSseMessageEndpointException;
}