DNS rebinding protection: check host header
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
parent
b57b1269d0
commit
f6c3fde7f4
5 changed files with 551 additions and 119 deletions
|
|
@ -12,10 +12,11 @@ import io.modelcontextprotocol.util.Assert;
|
|||
|
||||
/**
|
||||
* Default implementation of {@link ServerTransportSecurityValidator} that validates the
|
||||
* Origin header against a list of allowed origins.
|
||||
* Origin and Host headers against lists of allowed values.
|
||||
*
|
||||
* <p>
|
||||
* Supports exact matches and wildcard port patterns (e.g., "http://example.com:*").
|
||||
* Supports exact matches and wildcard port patterns (e.g., "http://example.com:*" for
|
||||
* origins, "example.com:*" for hosts).
|
||||
*
|
||||
* @author Daniel Garnier-Moiroux
|
||||
* @see ServerTransportSecurityValidator
|
||||
|
|
@ -25,28 +26,48 @@ public final class DefaultServerTransportSecurityValidator implements ServerTran
|
|||
|
||||
private static final String ORIGIN_HEADER = "Origin";
|
||||
|
||||
private static final String HOST_HEADER = "Host";
|
||||
|
||||
private final List<String> allowedOrigins;
|
||||
|
||||
private final List<String> allowedHosts;
|
||||
|
||||
/**
|
||||
* Creates a new validator with the specified allowed origins.
|
||||
* Creates a new validator with the specified allowed origins and hosts.
|
||||
* @param allowedOrigins List of allowed origin patterns. Supports exact matches
|
||||
* (e.g., "http://example.com:8080") and wildcard ports (e.g., "http://example.com:*")
|
||||
* @param allowedHosts List of allowed host patterns. Supports exact matches (e.g.,
|
||||
* "example.com:8080") and wildcard ports (e.g., "example.com:*")
|
||||
*/
|
||||
private DefaultServerTransportSecurityValidator(List<String> allowedOrigins) {
|
||||
private DefaultServerTransportSecurityValidator(List<String> allowedOrigins, List<String> allowedHosts) {
|
||||
Assert.notNull(allowedOrigins, "allowedOrigins must not be null");
|
||||
Assert.notNull(allowedHosts, "allowedHosts must not be null");
|
||||
this.allowedOrigins = allowedOrigins;
|
||||
this.allowedHosts = allowedHosts;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validateHeaders(Map<String, List<String>> headers) throws ServerTransportSecurityException {
|
||||
boolean missingHost = true;
|
||||
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));
|
||||
if (values == null || values.isEmpty()) {
|
||||
throw new ServerTransportSecurityException(403, "Invalid Origin header");
|
||||
}
|
||||
break;
|
||||
validateOrigin(values.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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -79,6 +100,37 @@ public final class DefaultServerTransportSecurityValidator implements ServerTran
|
|||
throw new ServerTransportSecurityException(403, "Invalid Origin header");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a single host value against the allowed hosts.
|
||||
* @param host The host header value, or null if not present
|
||||
* @throws ServerTransportSecurityException if the host is not allowed
|
||||
*/
|
||||
private void validateHost(String host) throws ServerTransportSecurityException {
|
||||
if (allowedHosts.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Host is required
|
||||
if (host == null || host.isBlank()) {
|
||||
throw new ServerTransportSecurityException(421, "Invalid Host header");
|
||||
}
|
||||
|
||||
for (String allowed : allowedHosts) {
|
||||
if (allowed.equals(host)) {
|
||||
return;
|
||||
}
|
||||
else if (allowed.endsWith(":*")) {
|
||||
// Wildcard port pattern: "example.com:*"
|
||||
String baseHost = allowed.substring(0, allowed.length() - 2);
|
||||
if (host.equals(baseHost) || host.startsWith(baseHost + ":")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new ServerTransportSecurityException(421, "Invalid Host header");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new builder for constructing a DefaultServerTransportSecurityValidator.
|
||||
* @return A new builder instance
|
||||
|
|
@ -94,6 +146,8 @@ public final class DefaultServerTransportSecurityValidator implements ServerTran
|
|||
|
||||
private final List<String> allowedOrigins = new ArrayList<>();
|
||||
|
||||
private final List<String> allowedHosts = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Adds an allowed origin pattern.
|
||||
* @param origin The origin to allow (e.g., "http://localhost:8080" or
|
||||
|
|
@ -116,12 +170,33 @@ public final class DefaultServerTransportSecurityValidator implements ServerTran
|
|||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an allowed host pattern.
|
||||
* @param host The host to allow (e.g., "localhost:8080" or "example.com:*")
|
||||
* @return this builder instance
|
||||
*/
|
||||
public Builder allowedHost(String host) {
|
||||
this.allowedHosts.add(host);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds multiple allowed host patterns.
|
||||
* @param hosts The hosts to allow
|
||||
* @return this builder instance
|
||||
*/
|
||||
public Builder allowedHosts(List<String> hosts) {
|
||||
Assert.notNull(hosts, "hosts must not be null");
|
||||
this.allowedHosts.addAll(hosts);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the validator instance.
|
||||
* @return A new DefaultServerTransportSecurityValidator
|
||||
*/
|
||||
public DefaultServerTransportSecurityValidator build() {
|
||||
return new DefaultServerTransportSecurityValidator(allowedOrigins);
|
||||
return new DefaultServerTransportSecurityValidator(allowedOrigins, allowedHosts);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ class DefaultServerTransportSecurityValidatorTests {
|
|||
private static final ServerTransportSecurityException INVALID_ORIGIN = new ServerTransportSecurityException(403,
|
||||
"Invalid Origin header");
|
||||
|
||||
private static final ServerTransportSecurityException INVALID_HOST = new ServerTransportSecurityException(421,
|
||||
"Invalid Host header");
|
||||
|
||||
private final DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder()
|
||||
.allowedOrigin("http://localhost:8080")
|
||||
.build();
|
||||
|
|
@ -31,161 +34,370 @@ class DefaultServerTransportSecurityValidatorTests {
|
|||
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);
|
||||
assertThatThrownBy(() -> DefaultServerTransportSecurityValidator.builder().allowedHosts(null).build())
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Nested
|
||||
class WildcardPort {
|
||||
|
||||
private final DefaultServerTransportSecurityValidator wildcardValidator = DefaultServerTransportSecurityValidator
|
||||
.builder()
|
||||
.allowedOrigin("http://localhost:*")
|
||||
.build();
|
||||
class OriginHeader {
|
||||
|
||||
@Test
|
||||
void anyPortWithWildcard() {
|
||||
var headers = originHeader("http://localhost:3000");
|
||||
|
||||
assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException();
|
||||
void originHeaderMissing() {
|
||||
assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void noPortWithWildcard() {
|
||||
var headers = originHeader("http://localhost");
|
||||
|
||||
assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException();
|
||||
void originHeaderListEmpty() {
|
||||
assertThatThrownBy(() -> validator.validateHeaders(Map.of("Origin", List.of()))).isEqualTo(INVALID_ORIGIN);
|
||||
}
|
||||
|
||||
@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");
|
||||
void caseInsensitive() {
|
||||
var headers = Map.of("origin", List.of("http://localhost:8080"));
|
||||
|
||||
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"))
|
||||
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();
|
||||
|
||||
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();
|
||||
@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.example.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.example.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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
class HostHeader {
|
||||
|
||||
private final DefaultServerTransportSecurityValidator hostValidator = DefaultServerTransportSecurityValidator
|
||||
.builder()
|
||||
.allowedHost("localhost:8080")
|
||||
.build();
|
||||
|
||||
@Test
|
||||
void notConfigured() {
|
||||
assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void missing() {
|
||||
assertThatThrownBy(() -> hostValidator.validateHeaders(new HashMap<>())).isEqualTo(INVALID_HOST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listEmpty() {
|
||||
assertThatThrownBy(() -> hostValidator.validateHeaders(Map.of("Host", List.of()))).isEqualTo(INVALID_HOST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void caseInsensitive() {
|
||||
var headers = Map.of("host", List.of("localhost:8080"));
|
||||
|
||||
assertThatCode(() -> hostValidator.validateHeaders(headers)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactMatch() {
|
||||
var headers = hostHeader("localhost:8080");
|
||||
|
||||
assertThatCode(() -> hostValidator.validateHeaders(headers)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentPort() {
|
||||
var headers = hostHeader("localhost:3000");
|
||||
|
||||
assertThatThrownBy(() -> hostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentHost() {
|
||||
var headers = hostHeader("example.com:8080");
|
||||
|
||||
assertThatThrownBy(() -> hostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST);
|
||||
}
|
||||
|
||||
@Nested
|
||||
class HostWildcardPort {
|
||||
|
||||
private final DefaultServerTransportSecurityValidator wildcardHostValidator = DefaultServerTransportSecurityValidator
|
||||
.builder()
|
||||
.allowedHost("localhost:*")
|
||||
.build();
|
||||
|
||||
@Test
|
||||
void anyPort() {
|
||||
var headers = hostHeader("localhost:3000");
|
||||
|
||||
assertThatCode(() -> wildcardHostValidator.validateHeaders(headers)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void noPort() {
|
||||
var headers = hostHeader("localhost");
|
||||
|
||||
assertThatCode(() -> wildcardHostValidator.validateHeaders(headers)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentHost() {
|
||||
var headers = hostHeader("example.com:3000");
|
||||
|
||||
assertThatThrownBy(() -> wildcardHostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
class MultipleHosts {
|
||||
|
||||
DefaultServerTransportSecurityValidator multipleHostsValidator = DefaultServerTransportSecurityValidator
|
||||
.builder()
|
||||
.allowedHost("example.com:3000")
|
||||
.allowedHost("myapp.example.com:*")
|
||||
.build();
|
||||
|
||||
@Test
|
||||
void exactMatch() {
|
||||
var headers = hostHeader("example.com:3000");
|
||||
|
||||
assertThatCode(() -> multipleHostsValidator.validateHeaders(headers)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void wildcard() {
|
||||
var headers = hostHeader("myapp.example.com:9999");
|
||||
|
||||
assertThatCode(() -> multipleHostsValidator.validateHeaders(headers)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentHost() {
|
||||
var headers = hostHeader("malicious.example.com:3000");
|
||||
|
||||
assertThatThrownBy(() -> multipleHostsValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentPort() {
|
||||
var headers = hostHeader("localhost:8080");
|
||||
|
||||
assertThatThrownBy(() -> multipleHostsValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
class HostBuilderTests {
|
||||
|
||||
@Test
|
||||
void multipleHosts() {
|
||||
DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder()
|
||||
.allowedHosts(List.of("localhost:8080", "example.com:*"))
|
||||
.build();
|
||||
|
||||
assertThatCode(() -> validator.validateHeaders(hostHeader("example.com:3000")))
|
||||
.doesNotThrowAnyException();
|
||||
assertThatCode(() -> validator.validateHeaders(hostHeader("localhost:8080")))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void combined() {
|
||||
DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder()
|
||||
.allowedHost("localhost:8080")
|
||||
.allowedHosts(List.of("example.com:*", "test.com:3000"))
|
||||
.build();
|
||||
|
||||
assertThatCode(() -> validator.validateHeaders(hostHeader("localhost:8080")))
|
||||
.doesNotThrowAnyException();
|
||||
assertThatCode(() -> validator.validateHeaders(hostHeader("example.com:9999")))
|
||||
.doesNotThrowAnyException();
|
||||
assertThatCode(() -> validator.validateHeaders(hostHeader("test.com:3000"))).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
class CombinedOriginAndHostValidation {
|
||||
|
||||
private final DefaultServerTransportSecurityValidator combinedValidator = DefaultServerTransportSecurityValidator
|
||||
.builder()
|
||||
.allowedOrigin("http://localhost:*")
|
||||
.allowedHost("localhost:*")
|
||||
.build();
|
||||
|
||||
@Test
|
||||
void bothValid() {
|
||||
var header = headers("http://localhost:8080", "localhost:8080");
|
||||
|
||||
assertThatCode(() -> combinedValidator.validateHeaders(header)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void originValidHostInvalid() {
|
||||
var header = headers("http://localhost:8080", "malicious.example.com:8080");
|
||||
|
||||
assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_HOST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void originInvalidHostValid() {
|
||||
var header = headers("http://malicious.example.com:8080", "localhost:8080");
|
||||
|
||||
assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_ORIGIN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void originMissingHostValid() {
|
||||
// Origin missing is OK (same-origin request)
|
||||
var header = headers(null, "localhost:8080");
|
||||
|
||||
assertThatCode(() -> combinedValidator.validateHeaders(header)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void originValidHostMissing() {
|
||||
// Host missing is NOT OK when allowedHosts is configured
|
||||
var header = headers("http://localhost:8080", null);
|
||||
|
||||
assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_HOST);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -194,4 +406,19 @@ class DefaultServerTransportSecurityValidatorTests {
|
|||
return Map.of("Origin", List.of(origin));
|
||||
}
|
||||
|
||||
private static Map<String, List<String>> hostHeader(String host) {
|
||||
return Map.of("Host", List.of(host));
|
||||
}
|
||||
|
||||
private static Map<String, List<String>> headers(String origin, String host) {
|
||||
var map = new HashMap<String, List<String>>();
|
||||
if (origin != null) {
|
||||
map.put("Origin", List.of(origin));
|
||||
}
|
||||
if (host != null) {
|
||||
map.put("Host", List.of(host));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ public class WebFluxServerTransportSecurityIntegrationTests {
|
|||
|
||||
private static final String DISALLOWED_ORIGIN = "https://malicious.example.com";
|
||||
|
||||
private static final String DISALLOWED_HOST = "malicious.example.com:8080";
|
||||
|
||||
@Parameter
|
||||
private static Transport transport;
|
||||
|
||||
|
|
@ -79,7 +81,7 @@ public class WebFluxServerTransportSecurityIntegrationTests {
|
|||
|
||||
private McpSyncClient mcpClient;
|
||||
|
||||
private final TestOriginHeaderExchangeFilterFunction exchangeFilterFunction = new TestOriginHeaderExchangeFilterFunction();
|
||||
private final TestHeaderExchangeFilterFunction exchangeFilterFunction = new TestHeaderExchangeFilterFunction();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
|
|
@ -125,6 +127,29 @@ public class WebFluxServerTransportSecurityIntegrationTests {
|
|||
assertThatThrownBy(() -> mcpClient.listTools());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hostAllowed() {
|
||||
// Host header is set by default by WebClient to the request URI host
|
||||
var result = mcpClient.initialize();
|
||||
var tools = mcpClient.listTools();
|
||||
|
||||
assertThat(result.protocolVersion()).isNotEmpty();
|
||||
assertThat(tools.tools()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectHostNotAllowed() {
|
||||
exchangeFilterFunction.setHostHeader(DISALLOWED_HOST);
|
||||
assertThatThrownBy(() -> mcpClient.initialize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void messageHostNotAllowed() {
|
||||
mcpClient.initialize();
|
||||
exchangeFilterFunction.setHostHeader(DISALLOWED_HOST);
|
||||
assertThatThrownBy(() -> mcpClient.listTools());
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// Server management
|
||||
// ----------------------------------------------------
|
||||
|
|
@ -165,7 +190,7 @@ public class WebFluxServerTransportSecurityIntegrationTests {
|
|||
*/
|
||||
interface Transport {
|
||||
|
||||
McpSyncClient createMcpClient(String baseUrl, TestOriginHeaderExchangeFilterFunction customizer);
|
||||
McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction customizer);
|
||||
|
||||
RouterFunction<?> routerFunction();
|
||||
|
||||
|
|
@ -181,8 +206,10 @@ public class WebFluxServerTransportSecurityIntegrationTests {
|
|||
public Sse() {
|
||||
transportProvider = WebFluxSseServerTransportProvider.builder()
|
||||
.messageEndpoint("/mcp/message")
|
||||
.securityValidator(
|
||||
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build())
|
||||
.securityValidator(DefaultServerTransportSecurityValidator.builder()
|
||||
.allowedOrigin("http://localhost:*")
|
||||
.allowedHost("localhost:*")
|
||||
.build())
|
||||
.build();
|
||||
McpServer.sync(transportProvider)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
|
|
@ -191,8 +218,7 @@ public class WebFluxServerTransportSecurityIntegrationTests {
|
|||
}
|
||||
|
||||
@Override
|
||||
public McpSyncClient createMcpClient(String baseUrl,
|
||||
TestOriginHeaderExchangeFilterFunction exchangeFilterFunction) {
|
||||
public McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction exchangeFilterFunction) {
|
||||
var transport = WebFluxSseClientTransport
|
||||
.builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction))
|
||||
.jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper())
|
||||
|
|
@ -213,8 +239,10 @@ public class WebFluxServerTransportSecurityIntegrationTests {
|
|||
|
||||
public StreamableHttp() {
|
||||
transportProvider = WebFluxStreamableServerTransportProvider.builder()
|
||||
.securityValidator(
|
||||
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build())
|
||||
.securityValidator(DefaultServerTransportSecurityValidator.builder()
|
||||
.allowedOrigin("http://localhost:*")
|
||||
.allowedHost("localhost:*")
|
||||
.build())
|
||||
.build();
|
||||
McpServer.sync(transportProvider)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
|
|
@ -223,8 +251,7 @@ public class WebFluxServerTransportSecurityIntegrationTests {
|
|||
}
|
||||
|
||||
@Override
|
||||
public McpSyncClient createMcpClient(String baseUrl,
|
||||
TestOriginHeaderExchangeFilterFunction exchangeFilterFunction) {
|
||||
public McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction exchangeFilterFunction) {
|
||||
var transport = WebClientStreamableHttpTransport
|
||||
.builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction))
|
||||
.jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper())
|
||||
|
|
@ -246,8 +273,10 @@ public class WebFluxServerTransportSecurityIntegrationTests {
|
|||
|
||||
public Stateless() {
|
||||
transportProvider = WebFluxStatelessServerTransport.builder()
|
||||
.securityValidator(
|
||||
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build())
|
||||
.securityValidator(DefaultServerTransportSecurityValidator.builder()
|
||||
.allowedOrigin("http://localhost:*")
|
||||
.allowedHost("localhost:*")
|
||||
.build())
|
||||
.build();
|
||||
McpServer.sync(transportProvider)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
|
|
@ -256,8 +285,7 @@ public class WebFluxServerTransportSecurityIntegrationTests {
|
|||
}
|
||||
|
||||
@Override
|
||||
public McpSyncClient createMcpClient(String baseUrl,
|
||||
TestOriginHeaderExchangeFilterFunction exchangeFilterFunction) {
|
||||
public McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction exchangeFilterFunction) {
|
||||
var transport = WebClientStreamableHttpTransport
|
||||
.builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction))
|
||||
.jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper())
|
||||
|
|
@ -273,18 +301,30 @@ public class WebFluxServerTransportSecurityIntegrationTests {
|
|||
|
||||
}
|
||||
|
||||
static class TestOriginHeaderExchangeFilterFunction implements ExchangeFilterFunction {
|
||||
static class TestHeaderExchangeFilterFunction implements ExchangeFilterFunction {
|
||||
|
||||
private String origin = null;
|
||||
|
||||
private String host = null;
|
||||
|
||||
public void setOriginHeader(String origin) {
|
||||
this.origin = origin;
|
||||
}
|
||||
|
||||
public void setHostHeader(String host) {
|
||||
this.host = host;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
|
||||
var updatedRequest = ClientRequest.from(request).header("origin", this.origin).build();
|
||||
return next.exchange(updatedRequest);
|
||||
var builder = ClientRequest.from(request);
|
||||
if (this.origin != null) {
|
||||
builder.header("Origin", this.origin);
|
||||
}
|
||||
if (this.host != null) {
|
||||
builder.header("Host", this.host);
|
||||
}
|
||||
return next.exchange(builder.build());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ public class ServerTransportSecurityIntegrationTests {
|
|||
|
||||
private static final String DISALLOWED_ORIGIN = "https://malicious.example.com";
|
||||
|
||||
private static final String DISALLOWED_HOST = "malicious.example.com:8080";
|
||||
|
||||
@Parameter
|
||||
private static Class<?> configClass;
|
||||
|
||||
|
|
@ -90,6 +92,7 @@ public class ServerTransportSecurityIntegrationTests {
|
|||
void setUp() {
|
||||
mcpClient = tomcatServer.appContext().getBean(McpSyncClient.class);
|
||||
requestCustomizer = tomcatServer.appContext().getBean(TestRequestCustomizer.class);
|
||||
requestCustomizer.reset();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
|
|
@ -131,6 +134,29 @@ public class ServerTransportSecurityIntegrationTests {
|
|||
assertThatThrownBy(() -> mcpClient.listTools());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hostAllowed() {
|
||||
// Host header is set by default by HttpClient to the request URI host
|
||||
var result = mcpClient.initialize();
|
||||
var tools = mcpClient.listTools();
|
||||
|
||||
assertThat(result.protocolVersion()).isNotEmpty();
|
||||
assertThat(tools.tools()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectHostNotAllowed() {
|
||||
requestCustomizer.setHostHeader(DISALLOWED_HOST);
|
||||
assertThatThrownBy(() -> mcpClient.initialize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void messageHostNotAllowed() {
|
||||
mcpClient.initialize();
|
||||
requestCustomizer.setHostHeader(DISALLOWED_HOST);
|
||||
assertThatThrownBy(() -> mcpClient.listTools());
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// Tomcat management
|
||||
// ----------------------------------------------------
|
||||
|
|
@ -195,7 +221,10 @@ public class ServerTransportSecurityIntegrationTests {
|
|||
|
||||
@Bean
|
||||
DefaultServerTransportSecurityValidator validator() {
|
||||
return DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build();
|
||||
return DefaultServerTransportSecurityValidator.builder()
|
||||
.allowedOrigin("http://localhost:*")
|
||||
.allowedHost("localhost:*")
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -318,18 +347,32 @@ public class ServerTransportSecurityIntegrationTests {
|
|||
|
||||
private String originHeader = null;
|
||||
|
||||
private String hostHeader = null;
|
||||
|
||||
@Override
|
||||
public void customize(HttpRequest.Builder builder, String method, URI endpoint, String body,
|
||||
McpTransportContext context) {
|
||||
if (originHeader != null) {
|
||||
builder.header("Origin", originHeader);
|
||||
}
|
||||
if (hostHeader != null) {
|
||||
builder.header("Host", hostHeader);
|
||||
}
|
||||
}
|
||||
|
||||
public void setOriginHeader(String originHeader) {
|
||||
this.originHeader = originHeader;
|
||||
}
|
||||
|
||||
public void setHostHeader(String hostHeader) {
|
||||
this.hostHeader = hostHeader;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
this.originHeader = null;
|
||||
this.hostHeader = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ class ServerTransportSecurityIntegrationTests {
|
|||
|
||||
private static final String DISALLOWED_ORIGIN = "https://malicious.example.com";
|
||||
|
||||
private static final String DISALLOWED_HOST = "malicious.example.com:8080";
|
||||
|
||||
@Parameter
|
||||
private static Transport transport;
|
||||
|
||||
|
|
@ -73,6 +75,7 @@ class ServerTransportSecurityIntegrationTests {
|
|||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
requestCustomizer.reset();
|
||||
mcpClient = transport.createMcpClient(baseUrl, requestCustomizer);
|
||||
}
|
||||
|
||||
|
|
@ -115,6 +118,29 @@ class ServerTransportSecurityIntegrationTests {
|
|||
assertThatThrownBy(() -> mcpClient.listTools());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hostAllowed() {
|
||||
// Host header is set by default by HttpClient to the request URI host
|
||||
var result = mcpClient.initialize();
|
||||
var tools = mcpClient.listTools();
|
||||
|
||||
assertThat(result.protocolVersion()).isNotEmpty();
|
||||
assertThat(tools.tools()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectHostNotAllowed() {
|
||||
requestCustomizer.setHostHeader(DISALLOWED_HOST);
|
||||
assertThatThrownBy(() -> mcpClient.initialize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void messageHostNotAllowed() {
|
||||
mcpClient.initialize();
|
||||
requestCustomizer.setHostHeader(DISALLOWED_HOST);
|
||||
assertThatThrownBy(() -> mcpClient.listTools());
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// Tomcat management
|
||||
// ----------------------------------------------------
|
||||
|
|
@ -182,8 +208,10 @@ class ServerTransportSecurityIntegrationTests {
|
|||
public Sse() {
|
||||
transport = HttpServletSseServerTransportProvider.builder()
|
||||
.messageEndpoint("/mcp/message")
|
||||
.securityValidator(
|
||||
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build())
|
||||
.securityValidator(DefaultServerTransportSecurityValidator.builder()
|
||||
.allowedOrigin("http://localhost:*")
|
||||
.allowedHost("localhost:*")
|
||||
.build())
|
||||
.build();
|
||||
McpServer.sync(transport)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
|
|
@ -213,8 +241,10 @@ class ServerTransportSecurityIntegrationTests {
|
|||
|
||||
public StreamableHttp() {
|
||||
transport = HttpServletStreamableServerTransportProvider.builder()
|
||||
.securityValidator(
|
||||
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build())
|
||||
.securityValidator(DefaultServerTransportSecurityValidator.builder()
|
||||
.allowedOrigin("http://localhost:*")
|
||||
.allowedHost("localhost:*")
|
||||
.build())
|
||||
.build();
|
||||
McpServer.sync(transport)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
|
|
@ -245,8 +275,10 @@ class ServerTransportSecurityIntegrationTests {
|
|||
|
||||
public Stateless() {
|
||||
transport = HttpServletStatelessServerTransport.builder()
|
||||
.securityValidator(
|
||||
DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build())
|
||||
.securityValidator(DefaultServerTransportSecurityValidator.builder()
|
||||
.allowedOrigin("http://localhost:*")
|
||||
.allowedHost("localhost:*")
|
||||
.build())
|
||||
.build();
|
||||
McpServer.sync(transport)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
|
|
@ -275,18 +307,33 @@ class ServerTransportSecurityIntegrationTests {
|
|||
|
||||
private String originHeader = null;
|
||||
|
||||
private String hostHeader = null;
|
||||
|
||||
@Override
|
||||
public void customize(HttpRequest.Builder builder, String method, URI endpoint, String body,
|
||||
McpTransportContext context) {
|
||||
if (originHeader != null) {
|
||||
builder.header("Origin", originHeader);
|
||||
}
|
||||
if (hostHeader != null) {
|
||||
// HttpClient normally sets Host automatically, but we can override it
|
||||
builder.header("Host", hostHeader);
|
||||
}
|
||||
}
|
||||
|
||||
public void setOriginHeader(String originHeader) {
|
||||
this.originHeader = originHeader;
|
||||
}
|
||||
|
||||
public void setHostHeader(String hostHeader) {
|
||||
this.hostHeader = hostHeader;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
this.originHeader = null;
|
||||
this.hostHeader = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue