This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-26665-mcp-allowed-hosts in repository https://gitbox.apache.org/repos/asf/camel.git
commit 01b1236165c6b91a689b64bfc84fab7450b31a4f Author: Claus Ibsen <[email protected]> AuthorDate: Thu Nov 12 20:12:38 2026 +0100 CAMEL-26665: camel-mcp-server - optional allow-list for the Host header on /mcp (Camel Main engine) New option camel.server.mcp-allowed-hosts: a comma-separated list of hosts (exact, or host:* for any port or none). Not set by default, and then the Host header is not checked, as before. When set, a request on /mcp (POST, the SSE GET and DELETE) whose Host is missing or not in the list gets HTTP 421 with a JSON-RPC error, before the body is read and before any session is created, in the same handler as the Origin check of CAMEL-26359. It uses the allowedHosts of the MCP SDK validator. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../org/apache/camel/catalog/docs/main.adoc | 3 +- .../org/apache/camel/catalog/docs/mcp-server.adoc | 25 +- .../main/camel-main-configuration-metadata.json | 1 + .../component/mcp/server/McpServerBridge.java | 8 +- .../mcp/server/McpServerConfiguration.java | 20 ++ .../camel/component/mcp/server/McpServerHosts.java | 132 ++++++++ .../camel/component/mcp/server/McpServerInfo.java | 46 ++- .../component/mcp/server/McpServerBridgeTest.java | 41 +++ .../component/mcp/server/McpServerHostsTest.java | 91 ++++++ .../camel-mcp-server/src/main/docs/mcp-server.adoc | 25 +- .../mcp/server/main/DefaultMcpServerFactory.java | 1 + .../mcp/server/vertx/VertxMcpServerEngine.java | 6 +- .../VertxMcpStreamableServerTransportProvider.java | 84 +++++- .../server/main/McpServerMainPropertiesTest.java | 43 ++- .../mcp/server/vertx/VertxMcpHostTest.java | 331 +++++++++++++++++++++ ...ttpServerConfigurationPropertiesConfigurer.java | 7 + .../camel-main-configuration-metadata.json | 1 + core/camel-main/src/main/docs/main.adoc | 3 +- .../main/HttpServerConfigurationProperties.java | 28 ++ 19 files changed, 878 insertions(+), 18 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc index ebbbbfb72fe8..0270e06c65c8 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc @@ -197,7 +197,7 @@ The camel.routecontroller supports 12 options, which are listed below. === Camel Embedded HTTP Server (only for standalone; not Spring Boot or Quarkus) configurations -The camel.server supports 40 options, which are listed below. +The camel.server supports 41 options, which are listed below. [width="100%",cols="2,5,^1,2",options="header"] |=== @@ -217,6 +217,7 @@ The camel.server supports 40 options, which are listed below. | `camel.server.jwtKeystorePath` | Path to the keystore file used for JWT tokens validation. | | String | `camel.server.jwtKeystoreType` | Type of the keystore used for JWT tokens validation (jks, pkcs12, etc.). | | String | `camel.server.maxBodySize` | Maximum HTTP body size the embedded HTTP server can accept. | | Long +| `camel.server.mcpAllowedHosts` | Comma-separated list of hosts allowed in the Host header of a request to the MCP endpoint on the Vert.x streamable transport, such as mcp.example.com or localhost:*. Each entry is a host, or a host and a port, where the port may be an asterisk to match any port (or none). The host is compared ignoring case. When set, a request whose Host header is missing or not in the list is rejected with HTTP 421 (Misdirected Request), before its body is read and bef [...] | `camel.server.mcpAllowedOrigins` | Comma-separated list of origins allowed to call the MCP endpoint on the Vert.x streamable transport. Each entry is an exact origin (scheme, host and port), or ends with a colon and an asterisk to match any port. Scheme and host are compared ignoring case, and a default port (80 for http, 443 for https) may be left out. A wildcard for all origins is not supported: an entry that is not of the form scheme://host or scheme://host:port fails the startup. A [...] | `camel.server.mcpEnabled` | Whether to expose ai-tool routes as MCP tools, ai-resource routes as MCP resources and ai-prompt routes as MCP prompts. Requires camel-mcp-server on the classpath. Use setMcpTransport(String) to choose streamable HTTP (default) or stdio for IDE subprocess integration. By default, the MCP server is not enabled. | false | boolean | `camel.server.mcpInstructions` | Top-level MCP instructions returned to clients on initialize. | | String diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc index cdf1ca7600b1..5767742c4f41 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc @@ -522,12 +522,35 @@ path such as `+https://app.example.com/mcp+`, an entry without a scheme such as A refused request is logged at `DEBUG` level by the `org.apache.camel.component.mcp.server.vertx.VertxMcpStreamableServerTransportProvider` -logger, with the `Origin` it carried. +logger, with the `Origin` (or the `Host`) it carried. The check is independent of the platform-http CORS configuration: CORS only adds response headers for the browser, while the origin check refuses the request on the server. +The `Host` header can be checked as well, with +`camel.server.mcp-allowed-hosts`. It is not set by default, and then the `Host` +header is not checked. When set, a request whose `Host` header is missing or not +in the list gets HTTP 421 (Misdirected Request) with a JSON-RPC error, before +its body is read and before any session is created. This protects a server on a +private address against DNS rebinding: a hostile web page whose own host name +resolves to the address of the server sends its own host name in the `Host` +header, not one from the list. + +[source,properties] +---- +camel.server.mcp-allowed-hosts = mcp.example.com, localhost:*, 127.0.0.1:* +---- + +Each entry is a host, or a host and a port, without a scheme. A port of `+*+` +matches any port, and also a `Host` header without a port (the default port of +the scheme), so `localhost:*` allows `localhost`, `localhost:8080` and +`localhost:9876`. An entry without a port matches only a `Host` header without +a port: `mcp.example.com` is the host name a client uses on the default port, +behind a reverse proxy for example. The host is compared ignoring case. Write an +IPv6 address in brackets, such as `[::1]:*`. An entry with a scheme or a path, +and `+*+`, fail the startup with a message saying why. + The main HTTP server listens on all network interfaces by default (`camel.server.host = 0.0.0.0`). When the MCP server is only meant for clients on the same machine, bind it to the loopback interface: diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json index 1b2d7a784d63..dfa566aca830 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json @@ -463,6 +463,7 @@ { "name": "camel.server.jwtKeystorePath", "required": false, "description": "Path to the keystore file used for JWT tokens validation.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "string", "javaType": "java.lang.String", "secret": false }, { "name": "camel.server.jwtKeystoreType", "required": false, "description": "Type of the keystore used for JWT tokens validation (jks, pkcs12, etc.).", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "string", "javaType": "java.lang.String", "secret": false }, { "name": "camel.server.maxBodySize", "required": false, "description": "Maximum HTTP body size the embedded HTTP server can accept.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "integer", "javaType": "java.lang.Long", "secret": false }, + { "name": "camel.server.mcpAllowedHosts", "required": false, "description": "Comma-separated list of hosts allowed in the Host header of a request to the MCP endpoint on the Vert.x streamable transport, such as mcp.example.com or localhost:*. Each entry is a host, or a host and a port, where the port may be an asterisk to match any port (or none). The host is compared ignoring case. When set, a request whose Host header is missing or not in the list is rejected with HTTP 421 (Misdire [...] { "name": "camel.server.mcpAllowedOrigins", "required": false, "description": "Comma-separated list of origins allowed to call the MCP endpoint on the Vert.x streamable transport. Each entry is an exact origin (scheme, host and port), or ends with a colon and an asterisk to match any port. Scheme and host are compared ignoring case, and a default port (80 for http, 443 for https) may be left out. A wildcard for all origins is not supported: an entry that is not of the form scheme:\/\ [...] { "name": "camel.server.mcpEnabled", "required": false, "description": "Whether to expose ai-tool routes as MCP tools, ai-resource routes as MCP resources and ai-prompt routes as MCP prompts. Requires camel-mcp-server on the classpath. Use setMcpTransport(String) to choose streamable HTTP (default) or stdio for IDE subprocess integration. By default, the MCP server is not enabled.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "boolean", "javaType" [...] { "name": "camel.server.mcpInstructions", "required": false, "description": "Top-level MCP instructions returned to clients on initialize.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "string", "javaType": "java.lang.String", "secret": false }, diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java index d1facd6d9923..e3eab52bbe58 100644 --- a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java @@ -158,7 +158,8 @@ public class McpServerBridge extends ServiceSupport implements CamelContextAware configuration.getServerIcons(), McpServerOrigins.parseAllowedOrigins(configuration.getAllowedOrigins()), configuration.getOauthProfile(), configuration.getRequiredScopes(), - configuration.getResourceUri())); + configuration.getResourceUri(), + McpServerHosts.parseAllowedHosts(configuration.getAllowedHosts()))); if (!engine.consumesServingConfiguration()) { if (!McpServerConstants.DEFAULT_PATH.equals(configuration.getPath())) { @@ -171,6 +172,11 @@ public class McpServerBridge extends ServiceSupport implements CamelContextAware + "configuration decides which origins are allowed", engine.getClass().getSimpleName()); } + if (isSet(configuration.getAllowedHosts())) { + LOG.warn("The MCP allowed hosts option is ignored by engine {}: the runtime's native MCP server " + + "configuration decides which hosts are allowed", + engine.getClass().getSimpleName()); + } if (hasServerMetadataConfiguration()) { LOG.warn("The MCP server metadata options may be ignored by engine {}: the runtime's native MCP " + "server configuration decides the server identity and initialize metadata", diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConfiguration.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConfiguration.java index ee8c293b8d3d..50477086a1ad 100644 --- a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConfiguration.java +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConfiguration.java @@ -45,6 +45,7 @@ public class McpServerConfiguration { private long sessionKeepAliveInterval = McpServerConstants.DEFAULT_SESSION_KEEP_ALIVE_INTERVAL; private long sessionIdleTtl = McpServerConstants.DEFAULT_SESSION_IDLE_TTL; private String allowedOrigins; + private String allowedHosts; private String oauthProfile; private String requiredScopes; private String resourceUri; @@ -223,6 +224,25 @@ public class McpServerConfiguration { this.allowedOrigins = allowedOrigins; } + /** + * Comma-separated list of hosts allowed in the {@code Host} header of a request to the MCP endpoint, such as + * {@code mcp.example.com} or {@code localhost:*} (any port, or none). When set, a request whose {@code Host} header + * is missing or not in the list is rejected with HTTP 421 (Misdirected Request), before its body is read and before + * any session is created; this protects a server bound to a private address against DNS rebinding. The host is + * compared ignoring case. An entry that is not of the form {@code host[:port]}, such as {@code *} or an entry with + * a scheme, fails the startup (see {@link McpServerHosts}). When not set, the {@code Host} header is not checked. + * Engine-owned: ignored by native engines. + * + * @since 4.24 + */ + public String getAllowedHosts() { + return allowedHosts; + } + + public void setAllowedHosts(String allowedHosts) { + this.allowedHosts = allowedHosts; + } + /** * Name of the camel-oauth profile used to authenticate OAuth bearer tokens. Supported by the Camel Main HTTP MCP * engine; other engines reject this option unless they explicitly implement OAuth support. diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerHosts.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerHosts.java new file mode 100644 index 000000000000..3e313ef47239 --- /dev/null +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerHosts.java @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.mcp.server; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * Parses and normalizes the hosts allowed in the {@code Host} header of a request to the MCP endpoint, and the + * {@code Host} header itself, so that both are compared in the same form: the host in lower case and the port without + * leading zeros. + * <p> + * An allowed host is {@code host} or {@code host:port}, where the port may be {@code *} to match any port, and also a + * {@code Host} header without a port. An IPv6 address is written in brackets, such as {@code [::1]:*}. Anything else, + * such as {@code *}, an entry with a scheme or a path, is refused with an {@link IllegalArgumentException}, so a + * mistake shows at startup instead of as an error for every request. + * + * @since 4.24 + */ +public final class McpServerHosts { + + private static final String ANY_PORT = "*"; + private static final Pattern HOST = Pattern.compile("[a-zA-Z0-9._-]+|\\[[0-9a-fA-F:.]+]"); + private static final Pattern PORT = Pattern.compile("\\d{1,5}"); + + private McpServerHosts() { + } + + /** + * Splits a comma-separated list of allowed hosts, skipping empty entries, and normalizes each entry with + * {@link #normalizeAllowedHost(String)}. + * + * @param allowedHosts the comma-separated list + * @return the normalized hosts, or {@code null} when not set or without any entry, so the + * {@code Host} header is not checked + * @throws IllegalArgumentException when an entry is not a valid host + */ + public static List<String> parseAllowedHosts(String allowedHosts) { + if (allowedHosts == null || allowedHosts.isBlank()) { + return null; + } + List<String> answer = new ArrayList<>(); + for (String entry : allowedHosts.split(",")) { + if (!entry.isBlank()) { + answer.add(normalizeAllowedHost(entry)); + } + } + return answer.isEmpty() ? null : answer; + } + + /** + * Normalizes an allowed host: trims it, lower-cases the host and drops the leading zeros of the port. + * + * @param allowedHost a host such as {@code MCP.Example.com}, {@code localhost:8080} or + * {@code localhost:*} + * @return the normalized host, such as {@code mcp.example.com} + * @throws IllegalArgumentException when the entry is {@code *} or not of the form {@code host[:port]} + */ + public static String normalizeAllowedHost(String allowedHost) { + String value = allowedHost == null ? "" : allowedHost.trim(); + if (ANY_PORT.equals(value)) { + throw new IllegalArgumentException( + "MCP allowed host * is not supported: leave the allowed hosts option unset to not check the Host" + + " header, or list each host, such as mcp.example.com or localhost:* (any port)."); + } + String answer = normalize(value, true); + if (answer == null) { + throw new IllegalArgumentException( + "Invalid MCP allowed host '" + allowedHost + "': expected host or host:port, without a scheme," + + " where the port may be * for any port, such as mcp.example.com or" + + " localhost:*"); + } + return answer; + } + + /** + * Normalizes the value of a {@code Host} header the same way as the allowed hosts, so that both compare as strings. + * + * @param host the header value + * @return the normalized host, or the value as is when it is not of the form {@code host[:port]} (such as + * {@code null}), so it matches no allowed host + */ + public static String normalizeHost(String host) { + if (host == null) { + return null; + } + String answer = normalize(host.trim(), false); + return answer != null ? answer : host; + } + + private static String normalize(String value, boolean allowAnyPort) { + String host = value; + String port = null; + int colon = value.lastIndexOf(':'); + if (colon >= 0 && colon > value.lastIndexOf(']')) { + host = value.substring(0, colon); + port = value.substring(colon + 1); + } + if (!HOST.matcher(host).matches()) { + return null; + } + host = host.toLowerCase(Locale.ROOT); + if (port != null) { + if (ANY_PORT.equals(port)) { + if (!allowAnyPort) { + return null; + } + } else if (PORT.matcher(port).matches() && Integer.parseInt(port) <= 65535) { + port = String.valueOf(Integer.parseInt(port)); + } else { + return null; + } + } + return port != null ? host + ":" + port : host; + } +} diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerInfo.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerInfo.java index b06b81f7af6b..fc40fba1e4b7 100644 --- a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerInfo.java +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerInfo.java @@ -44,6 +44,9 @@ import java.util.List; * disabled * @param requiredScopes comma-separated OAuth scopes required by the MCP HTTP endpoint * @param resourceUri explicit public HTTPS URI of the protected MCP resource for RFC 9728 metadata + * @param allowedHosts hosts allowed in the {@code Host} header of a request to the MCP endpoint (exact, + * or {@code host:*} for any port or none); {@code null} does not check the + * {@code Host} header. Since 4.24. * * @since 4.22 */ @@ -61,19 +64,20 @@ public record McpServerInfo( List<String> allowedOrigins, String oauthProfile, String requiredScopes, - String resourceUri) { + String resourceUri, + List<String> allowedHosts) { public McpServerInfo(String serverName, String version, String path) { this(serverName, version, path, McpServerConstants.DEFAULT_SESSION_KEEP_ALIVE_INTERVAL, McpServerConstants.DEFAULT_SESSION_IDLE_TTL, - null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null); } public McpServerInfo(String serverName, String version, String path, long sessionKeepAliveIntervalMs, long sessionIdleTtlMs) { this(serverName, version, path, sessionKeepAliveIntervalMs, sessionIdleTtlMs, - null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null); } public McpServerInfo(String serverName, String version, String path, @@ -81,7 +85,7 @@ public record McpServerInfo( String title, String description, String websiteUrl, String instructions, List<McpServerIcon> icons) { this(serverName, version, path, sessionKeepAliveIntervalMs, sessionIdleTtlMs, - title, description, websiteUrl, instructions, icons, null, null, null, null); + title, description, websiteUrl, instructions, icons, null, null, null, null, null); } /** @@ -105,7 +109,36 @@ public record McpServerInfo( String title, String description, String websiteUrl, String instructions, List<McpServerIcon> icons, List<String> allowedOrigins) { this(serverName, version, path, sessionKeepAliveIntervalMs, sessionIdleTtlMs, - title, description, websiteUrl, instructions, icons, allowedOrigins, null, null, null); + title, description, websiteUrl, instructions, icons, allowedOrigins, null, null, null, null); + } + + /** + * Compatibility constructor preserving the signature from before the allowed hosts. The {@code Host} header is not + * checked when this constructor is used. + * + * @param serverName the MCP server name + * @param version the MCP server version + * @param path the HTTP endpoint path + * @param sessionKeepAliveIntervalMs keep-alive interval in milliseconds + * @param sessionIdleTtlMs session idle TTL in milliseconds + * @param title optional display title + * @param description optional server description + * @param websiteUrl optional server website URL + * @param instructions optional server instructions + * @param icons optional server icons + * @param allowedOrigins allowed HTTP origins, or null for the default + * @param oauthProfile the camel-oauth profile, or null when disabled + * @param requiredScopes comma-separated OAuth scopes required by the MCP HTTP endpoint + * @param resourceUri explicit public HTTPS URI of the protected MCP resource + */ + public McpServerInfo(String serverName, String version, String path, + long sessionKeepAliveIntervalMs, long sessionIdleTtlMs, + String title, String description, String websiteUrl, String instructions, + List<McpServerIcon> icons, List<String> allowedOrigins, + String oauthProfile, String requiredScopes, String resourceUri) { + this(serverName, version, path, sessionKeepAliveIntervalMs, sessionIdleTtlMs, + title, description, websiteUrl, instructions, icons, allowedOrigins, oauthProfile, requiredScopes, + resourceUri, null); } public McpServerInfo { @@ -121,5 +154,8 @@ public record McpServerInfo( if (allowedOrigins != null) { allowedOrigins = List.copyOf(allowedOrigins); } + if (allowedHosts != null) { + allowedHosts = List.copyOf(allowedHosts); + } } } diff --git a/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerBridgeTest.java b/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerBridgeTest.java index b0411d32aab2..c4908e526813 100644 --- a/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerBridgeTest.java +++ b/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerBridgeTest.java @@ -247,6 +247,47 @@ class McpServerBridgeTest extends CamelTestSupport { assertThat(engine.info().allowedOrigins()).isNull(); } + @Test + void testAllowedHostsPassedToEngine() throws Exception { + CamelContext hostContext = new DefaultCamelContext(); + RecordingMcpServerEngine hostEngine = new RecordingMcpServerEngine(); + hostContext.getRegistry().bind("mcpServerEngine", hostEngine); + McpServerConfiguration configuration = new McpServerConfiguration(); + configuration.setTags("crm"); + configuration.setAllowedHosts(" MCP.Example.com ,, localhost:* "); + McpServerBridge hostBridge = new McpServerBridge(configuration); + hostContext.addService(hostBridge); + hostContext.start(); + hostBridge.start(); + + try { + assertThat(hostEngine.info().allowedHosts()).containsExactly("mcp.example.com", "localhost:*"); + } finally { + hostBridge.stop(); + hostContext.stop(); + } + } + + @Test + void testAllowedHostsNotSetDoesNotCheckTheHost() { + assertThat(engine.info().allowedHosts()).isNull(); + } + + @Test + void testAllowedHostThatCanNeverMatchFailsTheStart() throws Exception { + try (DefaultCamelContext hostContext = new DefaultCamelContext()) { + hostContext.getRegistry().bind("mcpServerEngine", new RecordingMcpServerEngine()); + McpServerConfiguration configuration = new McpServerConfiguration(); + configuration.setTags("crm"); + configuration.setAllowedHosts("http://mcp.example.com"); + McpServerBridge hostBridge = new McpServerBridge(configuration); + + assertThatThrownBy(() -> hostContext.addService(hostBridge)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Invalid MCP allowed host 'http://mcp.example.com'"); + } + } + @Test void testAllowedOriginThatCanNeverMatchFailsTheStart() throws Exception { try (DefaultCamelContext originContext = new DefaultCamelContext()) { diff --git a/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerHostsTest.java b/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerHostsTest.java new file mode 100644 index 000000000000..3ad7687f3a3e --- /dev/null +++ b/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerHostsTest.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.mcp.server; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class McpServerHostsTest { + + @Test + void notSetTurnsTheCheckOff() { + assertThat(McpServerHosts.parseAllowedHosts(null)).isNull(); + assertThat(McpServerHosts.parseAllowedHosts(" ")).isNull(); + // a value with no host in it is the same as not set + assertThat(McpServerHosts.parseAllowedHosts(",")).isNull(); + } + + @Test + void entriesAreTrimmedAndNormalized() { + assertThat(McpServerHosts.parseAllowedHosts(" MCP.Example.com ,, LocalHost:* , [::1]:08080 ")) + .containsExactly("mcp.example.com", "localhost:*", "[::1]:8080"); + } + + @ParameterizedTest + @CsvSource({ + "mcp.example.com, mcp.example.com", + "MCP.Example.COM, mcp.example.com", + "mcp.example.com:443, mcp.example.com:443", + "localhost:08080, localhost:8080", + "localhost:*, localhost:*", + "127.0.0.1:*, 127.0.0.1:*", + "[::1]:*, [::1]:*", + "[FE80::1]:3000, [fe80::1]:3000" }) + void allowedHostIsNormalized(String entry, String expected) { + assertThat(McpServerHosts.normalizeAllowedHost(entry)).isEqualTo(expected); + } + + @Test + void wildcardForAllHostsIsRefused() { + assertThatThrownBy(() -> McpServerHosts.parseAllowedHosts("mcp.example.com, *")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("MCP allowed host * is not supported") + .hasMessageContaining("localhost:*"); + } + + @ParameterizedTest + @ValueSource(strings = { + "http://mcp.example.com", "mcp.example.com/mcp", "[email protected]", "*.example.com", "mcp.example.com:", + "mcp.example.com:x", "mcp.example.com:70000", "mcp example.com", "::1", "[::1" }) + void entryThatCanNeverMatchIsRefused(String entry) { + assertThatThrownBy(() -> McpServerHosts.normalizeAllowedHost(entry)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Invalid MCP allowed host '" + entry + "'"); + } + + @ParameterizedTest + @CsvSource({ + "mcp.example.com, mcp.example.com", + "MCP.Example.com:8080, mcp.example.com:8080", + "[::1]:8080, [::1]:8080", + "evil..example.com/x, evil..example.com/x" }) + void hostHeaderIsNormalized(String header, String expected) { + assertThat(McpServerHosts.normalizeHost(header)).isEqualTo(expected); + } + + @Test + void hostHeaderWithAWildcardPortIsKeptAsIs() { + // only an allowed host may have a wildcard port; a header with one is left as is, it is lower-cased only when valid + assertThat(McpServerHosts.normalizeHost("LocalHost:*")).isEqualTo("LocalHost:*"); + assertThat(McpServerHosts.normalizeHost(null)).isNull(); + } +} diff --git a/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc b/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc index cdf1ca7600b1..5767742c4f41 100644 --- a/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc +++ b/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc @@ -522,12 +522,35 @@ path such as `+https://app.example.com/mcp+`, an entry without a scheme such as A refused request is logged at `DEBUG` level by the `org.apache.camel.component.mcp.server.vertx.VertxMcpStreamableServerTransportProvider` -logger, with the `Origin` it carried. +logger, with the `Origin` (or the `Host`) it carried. The check is independent of the platform-http CORS configuration: CORS only adds response headers for the browser, while the origin check refuses the request on the server. +The `Host` header can be checked as well, with +`camel.server.mcp-allowed-hosts`. It is not set by default, and then the `Host` +header is not checked. When set, a request whose `Host` header is missing or not +in the list gets HTTP 421 (Misdirected Request) with a JSON-RPC error, before +its body is read and before any session is created. This protects a server on a +private address against DNS rebinding: a hostile web page whose own host name +resolves to the address of the server sends its own host name in the `Host` +header, not one from the list. + +[source,properties] +---- +camel.server.mcp-allowed-hosts = mcp.example.com, localhost:*, 127.0.0.1:* +---- + +Each entry is a host, or a host and a port, without a scheme. A port of `+*+` +matches any port, and also a `Host` header without a port (the default port of +the scheme), so `localhost:*` allows `localhost`, `localhost:8080` and +`localhost:9876`. An entry without a port matches only a `Host` header without +a port: `mcp.example.com` is the host name a client uses on the default port, +behind a reverse proxy for example. The host is compared ignoring case. Write an +IPv6 address in brackets, such as `[::1]:*`. An entry with a scheme or a path, +and `+*+`, fail the startup with a message saying why. + The main HTTP server listens on all network interfaces by default (`camel.server.host = 0.0.0.0`). When the MCP server is only meant for clients on the same machine, bind it to the loopback interface: diff --git a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/main/DefaultMcpServerFactory.java b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/main/DefaultMcpServerFactory.java index bdc2510a672e..6a5dfed267fc 100644 --- a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/main/DefaultMcpServerFactory.java +++ b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/main/DefaultMcpServerFactory.java @@ -61,6 +61,7 @@ public class DefaultMcpServerFactory implements McpServerFactory { mcpConfiguration.setSessionKeepAliveInterval(configuration.getMcpSessionKeepAliveInterval()); mcpConfiguration.setSessionIdleTtl(configuration.getMcpSessionIdleTtl()); mcpConfiguration.setAllowedOrigins(configuration.getMcpAllowedOrigins()); + mcpConfiguration.setAllowedHosts(configuration.getMcpAllowedHosts()); // OAuth options have no meaning on stdio. Keep the historical warn-and-ignore behavior here, // while programmatic bridge configurations fail closed on engines that do not support OAuth. if (!isStdioTransport(configuration.getMcpTransport())) { diff --git a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java index 724438defdfa..d0175cdea2e2 100644 --- a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java +++ b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java @@ -64,7 +64,9 @@ import org.slf4j.LoggerFactory; * <p> * Every request on the MCP endpoint that carries an {@code Origin} header must come from an allowed origin * ({@link McpServerInfo#allowedOrigins()}, loopback origins by default), or it is rejected with HTTP 403 before it - * reaches the MCP server. Requests without an {@code Origin} header (MCP clients running as a process) are served. + * reaches the MCP server. Requests without an {@code Origin} header (MCP clients running as a process) are served. When + * allowed hosts are configured ({@link McpServerInfo#allowedHosts()}), every request must also carry a {@code Host} + * header from that list, or it is rejected with HTTP 421 in the same way. */ @JdkService(McpServerConstants.MCP_SERVER_ENGINE_FACTORY) public class VertxMcpServerEngine extends ServiceSupport implements McpServerEngine { @@ -148,7 +150,7 @@ public class VertxMcpServerEngine extends ServiceSupport implements McpServerEng jsonMapper, info.path(), info.sessionKeepAliveIntervalMs(), info.sessionIdleTtlMs(), info.allowedOrigins(), camelContext, oauthSecurity, info.requiredScopes(), info.resourceUri(), - authorizationServerIssuer, allowInsecureAuthorizationServer); + authorizationServerIssuer, allowInsecureAuthorizationServer, info.allowedHosts()); McpServer.SyncSpecification<?> specification = McpServer.sync(transport) .serverInfo(buildServerInfo(info)) // resources(subscribe, listChanged): per-resource subscriptions are not offered, but clients are diff --git a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java index de2df446ac5c..f1ba4a80816c 100644 --- a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java +++ b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java @@ -43,12 +43,14 @@ import io.vertx.core.Context; import io.vertx.core.Vertx; import io.vertx.core.http.HttpMethod; import io.vertx.core.http.HttpServerResponse; +import io.vertx.core.net.HostAndPort; import io.vertx.ext.auth.User; import io.vertx.ext.web.Route; import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.handler.BodyHandler; import io.vertx.ext.web.handler.SecurityPolicyHandler; import org.apache.camel.CamelContext; +import org.apache.camel.component.mcp.server.McpServerHosts; import org.apache.camel.component.mcp.server.McpServerOrigins; import org.apache.camel.component.platform.http.vertx.VertxPlatformHttpRouter; import org.apache.camel.http.base.OAuthHttpSecuritySupport; @@ -70,6 +72,10 @@ import reactor.core.publisher.Mono; * Every request carrying an {@code Origin} header is checked against the allowed origins before any other handling, as * the MCP streamable HTTP transport requires; a request from an origin that is not allowed gets HTTP 403 with a * JSON-RPC error and never reaches the MCP session. Requests without an {@code Origin} header are served. + * <p> + * When allowed hosts are configured, the {@code Host} header is checked in the same handler: a request whose + * {@code Host} is missing or not allowed gets HTTP 421 (Misdirected Request), which protects a server on a private + * address against DNS rebinding. Without allowed hosts the {@code Host} header is not checked. */ public class VertxMcpStreamableServerTransportProvider implements McpStreamableServerTransportProvider { @@ -86,6 +92,9 @@ public class VertxMcpStreamableServerTransportProvider implements McpStreamableS private static final String APPLICATION_JSON = "application/json"; private static final String TEXT_EVENT_STREAM = "text/event-stream"; private static final String ORIGIN = "Origin"; + private static final String HOST = "Host"; + // the status the MCP SDK validator answers a Host that is not allowed with + private static final int MISDIRECTED_REQUEST = 421; /** * The origins allowed when none are configured: loopback hosts on any port, so browser-based tools on the same @@ -158,6 +167,32 @@ public class VertxMcpStreamableServerTransportProvider implements McpStreamableS String resourceUri, String authorizationServerIssuer, boolean allowInsecureAuthorizationServer) { + this(jsonMapper, path, sessionKeepAliveIntervalMs, sessionIdleTtlMs, allowedOrigins, camelContext, + oauthSecurity, requiredScopes, resourceUri, authorizationServerIssuer, allowInsecureAuthorizationServer, + null); + } + + /** + * Creates the transport with allowed hosts. + * + * @param allowedHosts the hosts allowed in the {@code Host} header, each exact (e.g. {@code mcp.example.com}) or + * with a wildcard port (e.g. {@code localhost:*}, which also allows a {@code Host} without a + * port), normalized with {@link McpServerHosts#normalizeAllowedHost(String)}; {@code null} to + * not check the {@code Host} header + */ + public VertxMcpStreamableServerTransportProvider( + McpJsonMapper jsonMapper, + String path, + long sessionKeepAliveIntervalMs, + long sessionIdleTtlMs, + List<String> allowedOrigins, + CamelContext camelContext, + OAuthHttpSecuritySupport oauthSecurity, + String requiredScopes, + String resourceUri, + String authorizationServerIssuer, + boolean allowInsecureAuthorizationServer, + List<String> allowedHosts) { this.jsonMapper = jsonMapper; this.path = path; this.sessionKeepAliveIntervalMs = sessionKeepAliveIntervalMs; @@ -175,9 +210,26 @@ public class VertxMcpStreamableServerTransportProvider implements McpStreamableS .allowedOrigins(allowedOrigins != null ? allowedOrigins.stream().map(McpServerOrigins::normalizeAllowedOrigin).toList() : DEFAULT_ALLOWED_ORIGINS) + .allowedHosts(allowedHosts != null ? hostPatterns(allowedHosts) : List.of()) .build(); } + /** + * The hosts as the MCP SDK validator matches them: a {@code host:*} entry matches only a {@code Host} with a port + * there, so the host without a port (the default port of the scheme) is added next to it. + */ + private static List<String> hostPatterns(List<String> allowedHosts) { + List<String> answer = new ArrayList<>(); + for (String entry : allowedHosts) { + String host = McpServerHosts.normalizeAllowedHost(entry); + answer.add(host); + if (host.endsWith(":*")) { + answer.add(host.substring(0, host.length() - 2)); + } + } + return answer; + } + @Override public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) { this.sessionFactory = sessionFactory; @@ -262,19 +314,28 @@ public class VertxMcpStreamableServerTransportProvider implements McpStreamableS } /** - * Serves a request without an {@code Origin} header or from an allowed origin; answers any other with HTTP 403 and - * a JSON-RPC error that has no {@code id}. Runs on the event loop before any other handler of the route. + * Serves a request without an {@code Origin} header or from an allowed origin, and, when allowed hosts are + * configured, with an allowed {@code Host}; answers any other with HTTP 403 (origin) or 421 (host) and a JSON-RPC + * error that has no {@code id}. Runs on the event loop before any other handler of the route. */ private void checkOrigin(RoutingContext ctx) { try { originValidator.validate(new VertxHeaderAccessor(ctx)); } catch (ServerTransportSecurityException e) { - LOG.debug("Rejected MCP {} request on {}: origin {} is not allowed", ctx.request().method(), path, - ctx.request().getHeader(ORIGIN)); + String message; + if (e.getStatusCode() == MISDIRECTED_REQUEST) { + LOG.debug("Rejected MCP {} request on {}: host {} is not allowed", ctx.request().method(), path, + ctx.request().authority()); + message = "Host not allowed"; + } else { + LOG.debug("Rejected MCP {} request on {}: origin {} is not allowed", ctx.request().method(), path, + ctx.request().getHeader(ORIGIN)); + message = "Origin not allowed"; + } ctx.response() .setStatusCode(e.getStatusCode()) .putHeader("Content-Type", APPLICATION_JSON) - .end(errorBody(McpSchema.ErrorCodes.INVALID_REQUEST, "Origin not allowed")); + .end(errorBody(McpSchema.ErrorCodes.INVALID_REQUEST, message)); return; } ctx.next(); @@ -789,6 +850,19 @@ public class VertxMcpStreamableServerTransportProvider implements McpStreamableS @Override public List<String> getHeader(String name) { + if (HOST.equalsIgnoreCase(name)) { + // the authority of the request: the Host header on HTTP/1.1, :authority on HTTP/2 + HostAndPort authority = ctx.request().authority(); + if (authority == null) { + return List.of(); + } + String host = authority.host(); + if (host.indexOf(':') >= 0 && !host.startsWith("[")) { + host = "[" + host + "]"; + } + String value = authority.port() >= 0 ? host + ":" + authority.port() : host; + return List.of(McpServerHosts.normalizeHost(value)); + } List<String> values = ctx.request().headers().getAll(name); if (ORIGIN.equalsIgnoreCase(name)) { // compare in the form the allowed origins are kept in: lower-case scheme and host, no default port diff --git a/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/main/McpServerMainPropertiesTest.java b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/main/McpServerMainPropertiesTest.java index c3a2a0d97b04..8af1cbda8324 100644 --- a/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/main/McpServerMainPropertiesTest.java +++ b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/main/McpServerMainPropertiesTest.java @@ -124,6 +124,43 @@ class McpServerMainPropertiesTest { } } + @Test + void testMcpAllowedHostsFromProperties() throws Exception { + int port = AvailablePortFinder.getNextAvailable(); + + Main main = new Main(); + main.addInitialProperty("camel.server.enabled", "true"); + main.addInitialProperty("camel.server.port", String.valueOf(port)); + main.addInitialProperty("camel.server.mcp-enabled", "true"); + main.addInitialProperty("camel.server.mcp-tags", "crm"); + main.addInitialProperty("camel.server.mcp-allowed-hosts", "LocalHost:*"); + main.start(); + + try { + assertThat(initialize(URI.create("http://localhost:" + port + "/mcp"), null)).isEqualTo(200); + assertThat(initialize(URI.create("http://127.0.0.1:" + port + "/mcp"), null)).isEqualTo(421); + } finally { + main.stop(); + } + } + + @Test + void testMcpAllowedHostWithSchemeFailsFast() { + Main main = new Main(); + main.addInitialProperty("camel.server.enabled", "true"); + main.addInitialProperty("camel.server.port", String.valueOf(AvailablePortFinder.getNextAvailable())); + main.addInitialProperty("camel.server.mcp-enabled", "true"); + main.addInitialProperty("camel.server.mcp-tags", "crm"); + main.addInitialProperty("camel.server.mcp-allowed-hosts", "http://localhost:8080"); + + try { + assertThatThrownBy(main::start) + .hasStackTraceContaining("Invalid MCP allowed host 'http://localhost:8080'"); + } finally { + main.stop(); + } + } + @Test void testMcpAllowedOriginWildcardFailsFast() { Main main = new Main(); @@ -142,8 +179,12 @@ class McpServerMainPropertiesTest { } private static int initialize(int port, String origin) throws Exception { + return initialize(URI.create("http://localhost:" + port + "/mcp"), origin); + } + + private static int initialize(URI uri, String origin) throws Exception { HttpRequest.Builder builder = HttpRequest.newBuilder() - .uri(URI.create("http://localhost:" + port + "/mcp")) + .uri(uri) .timeout(Duration.ofSeconds(10)) .header("Content-Type", "application/json") .header("Accept", "application/json, text/event-stream") diff --git a/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/VertxMcpHostTest.java b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/VertxMcpHostTest.java new file mode 100644 index 000000000000..dbdd1f9c5413 --- /dev/null +++ b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/VertxMcpHostTest.java @@ -0,0 +1,331 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.mcp.server.vertx; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.camel.CamelContext; +import org.apache.camel.component.ai.tool.AiToolParameterHelper.ParameterDef; +import org.apache.camel.component.mcp.server.McpServerInfo; +import org.apache.camel.component.mcp.server.McpServerTool; +import org.apache.camel.component.mcp.server.McpToolCallHandler; +import org.apache.camel.component.mcp.server.McpToolCallResult; +import org.apache.camel.component.platform.http.main.MainHttpServer; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.test.AvailablePortFinder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that the Vert.x streamable transport checks the {@code Host} header on every method of the MCP endpoint when + * allowed hosts are configured, and not at all otherwise (CAMEL-26665). + */ +class VertxMcpHostTest { + + private static final HttpClient HTTP = HttpClient.newHttpClient(); + + private static final String INITIALIZE_REQUEST = """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18", + "capabilities":{},"clientInfo":{"name":"test","version":"1"}}}"""; + + private static final String INITIALIZED_NOTIFICATION = """ + {"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"""; + + private static final String CALL_TOOL_REQUEST = """ + {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ping","arguments":{}}}"""; + + private CamelContext camelContext; + private VertxMcpServerEngine engine; + private int port; + + @AfterEach + void stop() { + if (engine != null) { + engine.stop(); + } + if (camelContext != null) { + camelContext.stop(); + } + } + + @Test + void hostIsNotCheckedByDefault() throws Exception { + start(null); + + assertThat(post("localhost", null, INITIALIZE_REQUEST).statusCode()).isEqualTo(200); + assertThat(post("127.0.0.1", null, INITIALIZE_REQUEST).statusCode()).isEqualTo(200); + assertThat(rawPost("Host: attacker.example.com:" + port)).startsWith("HTTP/1.1 200"); + assertThat(engine.sessionCount()).isEqualTo(3); + } + + @Test + void allowedHostIsServed() throws Exception { + start(List.of("localhost:*")); + + HttpResponse<String> response = post("localhost", null, INITIALIZE_REQUEST); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.headers().firstValue("mcp-session-id")).isPresent(); + assertThat(engine.sessionCount()).isEqualTo(1); + } + + @Test + void otherHostIsRejected() throws Exception { + start(List.of("localhost:*")); + + HttpResponse<String> response = post("127.0.0.1", null, INITIALIZE_REQUEST); + + assertThat(response.statusCode()).isEqualTo(421); + assertThat(response.headers().firstValue("Content-Type")).hasValueSatisfying( + value -> assertThat(value).startsWith("application/json")); + assertThat(response.body()) + .contains("\"jsonrpc\":\"2.0\"") + .contains("\"code\":-32600") + .contains("Host not allowed") + .doesNotContain("\"id\""); + assertThat(response.headers().firstValue("mcp-session-id")).isEmpty(); + assertThat(engine.sessionCount()).isZero(); + } + + @Test + void reboundHostNameIsRejected() throws Exception { + start(List.of("localhost:*", "127.0.0.1:*")); + + // a page on a host name that resolves to this server (DNS rebinding) sends its own host name + assertThat(rawPost("Host: attacker.example.com:" + port)).startsWith("HTTP/1.1 421"); + assertThat(rawPost("Host: localhost.attacker.example.com:" + port)).startsWith("HTTP/1.1 421"); + assertThat(engine.sessionCount()).isZero(); + } + + @Test + void missingHostIsRejected() throws Exception { + start(List.of("localhost:*")); + + // Vert.x refuses an HTTP/1.1 request without a Host header itself; HTTP/1.0 has no Host header to require + assertThat(rawPost("HTTP/1.1", null)).startsWith("HTTP/1.1 400"); + assertThat(rawPost("HTTP/1.0", null)).contains(" 421 "); + assertThat(engine.sessionCount()).isZero(); + } + + @Test + void hostIsComparedIgnoringCase() throws Exception { + start(List.of("mcp.example.com:*")); + + assertThat(rawPost("Host: MCP.Example.COM:" + port)).startsWith("HTTP/1.1 200"); + } + + @Test + void wildcardPortAlsoMatchesAHostWithoutAPort() throws Exception { + start(List.of("mcp.example.com:*")); + + assertThat(rawPost("Host: mcp.example.com")).startsWith("HTTP/1.1 200"); + assertThat(rawPost("Host: mcp.example.com:" + port)).startsWith("HTTP/1.1 200"); + } + + @Test + void exactPortMatchesThatPortOnly() throws Exception { + start(List.of("mcp.example.com:8443", "proxy.example.com")); + + assertThat(rawPost("Host: mcp.example.com:8443")).startsWith("HTTP/1.1 200"); + assertThat(rawPost("Host: mcp.example.com:" + port)).startsWith("HTTP/1.1 421"); + assertThat(rawPost("Host: mcp.example.com")).startsWith("HTTP/1.1 421"); + assertThat(rawPost("Host: proxy.example.com")).startsWith("HTTP/1.1 200"); + assertThat(rawPost("Host: proxy.example.com:" + port)).startsWith("HTTP/1.1 421"); + } + + @Test + void originIsStillCheckedFirst() throws Exception { + start(List.of("localhost:*")); + + HttpResponse<String> response = post("localhost", "http://app.example.com", null, INITIALIZE_REQUEST); + + assertThat(response.statusCode()).isEqualTo(403); + assertThat(response.body()).contains("Origin not allowed"); + } + + @Test + void rejectedHostDoesNotCallTheTool() throws Exception { + start(List.of("localhost:*")); + AtomicInteger calls = new AtomicInteger(); + engine.toolAdded(tool("ping", arguments -> { + calls.incrementAndGet(); + return new McpToolCallResult("pong", false); + })); + String sessionId = initializeSession(); + + HttpResponse<String> rejected = post("127.0.0.1", sessionId, CALL_TOOL_REQUEST); + assertThat(rejected.statusCode()).isEqualTo(421); + assertThat(calls).hasValue(0); + + HttpResponse<String> served = post("localhost", sessionId, CALL_TOOL_REQUEST); + assertThat(served.statusCode()).isEqualTo(200); + assertThat(served.body()).contains("pong"); + assertThat(calls).hasValue(1); + } + + @Test + void sseChannelChecksTheHost() throws Exception { + start(List.of("localhost:*")); + String sessionId = initializeSession(); + + HttpResponse<InputStream> rejected = HTTP.send( + request("127.0.0.1", null, sessionId).header("Accept", "text/event-stream").GET().build(), + HttpResponse.BodyHandlers.ofInputStream()); + try (InputStream body = rejected.body()) { + assertThat(rejected.statusCode()).isEqualTo(421); + } + } + + @Test + void deleteChecksTheHost() throws Exception { + start(List.of("localhost:*")); + String sessionId = initializeSession(); + + assertThat(delete("127.0.0.1", sessionId)).isEqualTo(421); + assertThat(engine.sessionCount()).isEqualTo(1); + + assertThat(delete("localhost", sessionId)).isEqualTo(200); + assertThat(engine.sessionCount()).isZero(); + } + + private void start(List<String> allowedHosts) throws Exception { + port = AvailablePortFinder.getNextAvailable(); + camelContext = new DefaultCamelContext(); + MainHttpServer main = new MainHttpServer(); + main.setCamelContext(camelContext); + main.setHost("0.0.0.0"); + main.setPort(port); + camelContext.addService(main); + camelContext.start(); + + engine = new VertxMcpServerEngine(); + engine.setCamelContext(camelContext); + engine.initialize(new McpServerInfo( + "host", "1.0", "/mcp", 0, 0, null, null, null, null, null, null, null, null, null, allowedHosts)); + engine.start(); + } + + private String initializeSession() throws Exception { + HttpResponse<String> response = post("localhost", null, INITIALIZE_REQUEST); + assertThat(response.statusCode()).isEqualTo(200); + String sessionId = response.headers().firstValue("mcp-session-id").orElseThrow(); + assertThat(post("localhost", sessionId, INITIALIZED_NOTIFICATION).statusCode()).isEqualTo(202); + return sessionId; + } + + private HttpResponse<String> post(String host, String origin, String sessionId, String body) throws Exception { + HttpRequest.Builder builder = request(host, origin, sessionId) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .POST(HttpRequest.BodyPublishers.ofString(body)); + return HTTP.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse<String> post(String host, String sessionId, String body) throws Exception { + return post(host, null, sessionId, body); + } + + private int delete(String host, String sessionId) throws Exception { + HttpRequest.Builder builder = request(host, null, sessionId).DELETE(); + return HTTP.send(builder.build(), HttpResponse.BodyHandlers.discarding()).statusCode(); + } + + /** + * A request to {@code host} on the port of the server: the HTTP client sends the host and port of the URI as the + * {@code Host} header, and both {@code localhost} and {@code 127.0.0.1} reach the server. + */ + private HttpRequest.Builder request(String host, String origin, String sessionId) { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create("http://" + host + ":" + port + "/mcp")) + .timeout(Duration.ofSeconds(10)); + if (origin != null) { + builder.header("Origin", origin); + } + if (sessionId != null) { + builder.header("Mcp-Session-Id", sessionId); + } + return builder; + } + + /** + * Sends an initialize request over a plain socket, with the given HTTP version and {@code Host} header line, or + * none when {@code null} (the HTTP client does not allow setting it), and returns the response. + */ + private String rawPost(String hostLine) throws Exception { + return rawPost("HTTP/1.1", hostLine); + } + + private String rawPost(String version, String hostLine) throws Exception { + byte[] body = INITIALIZE_REQUEST.getBytes(StandardCharsets.UTF_8); + String head = "POST /mcp " + version + "\r\n" + + (hostLine != null ? hostLine + "\r\n" : "") + + "Content-Type: application/json\r\n" + + "Accept: application/json, text/event-stream\r\n" + + "Content-Length: " + body.length + "\r\n" + + "Connection: close\r\n\r\n"; + try (Socket socket = new Socket("127.0.0.1", port)) { + socket.setSoTimeout(10_000); + OutputStream out = socket.getOutputStream(); + out.write(head.getBytes(StandardCharsets.US_ASCII)); + out.write(body); + out.flush(); + return new String(socket.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static McpServerTool tool(String name, McpToolCallHandler handler) { + return new McpServerTool() { + @Override + public String name() { + return name; + } + + @Override + public String description() { + return "Answers pong"; + } + + @Override + public String inputSchemaJson() { + return null; + } + + @Override + public Map<String, ParameterDef> parameters() { + return Map.of(); + } + + @Override + public McpToolCallHandler handler() { + return handler; + } + }; + } +} diff --git a/core/camel-main/src/generated/java/org/apache/camel/main/HttpServerConfigurationPropertiesConfigurer.java b/core/camel-main/src/generated/java/org/apache/camel/main/HttpServerConfigurationPropertiesConfigurer.java index 402a5ffb8f06..47fd5067db27 100644 --- a/core/camel-main/src/generated/java/org/apache/camel/main/HttpServerConfigurationPropertiesConfigurer.java +++ b/core/camel-main/src/generated/java/org/apache/camel/main/HttpServerConfigurationPropertiesConfigurer.java @@ -38,6 +38,7 @@ public class HttpServerConfigurationPropertiesConfigurer extends org.apache.came map.put("JwtKeystorePath", java.lang.String.class); map.put("JwtKeystoreType", java.lang.String.class); map.put("MaxBodySize", java.lang.Long.class); + map.put("McpAllowedHosts", java.lang.String.class); map.put("McpAllowedOrigins", java.lang.String.class); map.put("McpEnabled", boolean.class); map.put("McpInstructions", java.lang.String.class); @@ -98,6 +99,8 @@ public class HttpServerConfigurationPropertiesConfigurer extends org.apache.came case "jwtKeystoreType": target.setJwtKeystoreType(property(camelContext, java.lang.String.class, value)); return true; case "maxbodysize": case "maxBodySize": target.setMaxBodySize(property(camelContext, java.lang.Long.class, value)); return true; + case "mcpallowedhosts": + case "mcpAllowedHosts": target.setMcpAllowedHosts(property(camelContext, java.lang.String.class, value)); return true; case "mcpallowedorigins": case "mcpAllowedOrigins": target.setMcpAllowedOrigins(property(camelContext, java.lang.String.class, value)); return true; case "mcpenabled": @@ -186,6 +189,8 @@ public class HttpServerConfigurationPropertiesConfigurer extends org.apache.came case "jwtKeystoreType": return java.lang.String.class; case "maxbodysize": case "maxBodySize": return java.lang.Long.class; + case "mcpallowedhosts": + case "mcpAllowedHosts": return java.lang.String.class; case "mcpallowedorigins": case "mcpAllowedOrigins": return java.lang.String.class; case "mcpenabled": @@ -270,6 +275,8 @@ public class HttpServerConfigurationPropertiesConfigurer extends org.apache.came case "jwtKeystoreType": return target.getJwtKeystoreType(); case "maxbodysize": case "maxBodySize": return target.getMaxBodySize(); + case "mcpallowedhosts": + case "mcpAllowedHosts": return target.getMcpAllowedHosts(); case "mcpallowedorigins": case "mcpAllowedOrigins": return target.getMcpAllowedOrigins(); case "mcpenabled": diff --git a/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json b/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json index 1b2d7a784d63..dfa566aca830 100644 --- a/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json +++ b/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json @@ -463,6 +463,7 @@ { "name": "camel.server.jwtKeystorePath", "required": false, "description": "Path to the keystore file used for JWT tokens validation.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "string", "javaType": "java.lang.String", "secret": false }, { "name": "camel.server.jwtKeystoreType", "required": false, "description": "Type of the keystore used for JWT tokens validation (jks, pkcs12, etc.).", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "string", "javaType": "java.lang.String", "secret": false }, { "name": "camel.server.maxBodySize", "required": false, "description": "Maximum HTTP body size the embedded HTTP server can accept.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "integer", "javaType": "java.lang.Long", "secret": false }, + { "name": "camel.server.mcpAllowedHosts", "required": false, "description": "Comma-separated list of hosts allowed in the Host header of a request to the MCP endpoint on the Vert.x streamable transport, such as mcp.example.com or localhost:*. Each entry is a host, or a host and a port, where the port may be an asterisk to match any port (or none). The host is compared ignoring case. When set, a request whose Host header is missing or not in the list is rejected with HTTP 421 (Misdire [...] { "name": "camel.server.mcpAllowedOrigins", "required": false, "description": "Comma-separated list of origins allowed to call the MCP endpoint on the Vert.x streamable transport. Each entry is an exact origin (scheme, host and port), or ends with a colon and an asterisk to match any port. Scheme and host are compared ignoring case, and a default port (80 for http, 443 for https) may be left out. A wildcard for all origins is not supported: an entry that is not of the form scheme:\/\ [...] { "name": "camel.server.mcpEnabled", "required": false, "description": "Whether to expose ai-tool routes as MCP tools, ai-resource routes as MCP resources and ai-prompt routes as MCP prompts. Requires camel-mcp-server on the classpath. Use setMcpTransport(String) to choose streamable HTTP (default) or stdio for IDE subprocess integration. By default, the MCP server is not enabled.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "boolean", "javaType" [...] { "name": "camel.server.mcpInstructions", "required": false, "description": "Top-level MCP instructions returned to clients on initialize.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "string", "javaType": "java.lang.String", "secret": false }, diff --git a/core/camel-main/src/main/docs/main.adoc b/core/camel-main/src/main/docs/main.adoc index ebbbbfb72fe8..0270e06c65c8 100644 --- a/core/camel-main/src/main/docs/main.adoc +++ b/core/camel-main/src/main/docs/main.adoc @@ -197,7 +197,7 @@ The camel.routecontroller supports 12 options, which are listed below. === Camel Embedded HTTP Server (only for standalone; not Spring Boot or Quarkus) configurations -The camel.server supports 40 options, which are listed below. +The camel.server supports 41 options, which are listed below. [width="100%",cols="2,5,^1,2",options="header"] |=== @@ -217,6 +217,7 @@ The camel.server supports 40 options, which are listed below. | `camel.server.jwtKeystorePath` | Path to the keystore file used for JWT tokens validation. | | String | `camel.server.jwtKeystoreType` | Type of the keystore used for JWT tokens validation (jks, pkcs12, etc.). | | String | `camel.server.maxBodySize` | Maximum HTTP body size the embedded HTTP server can accept. | | Long +| `camel.server.mcpAllowedHosts` | Comma-separated list of hosts allowed in the Host header of a request to the MCP endpoint on the Vert.x streamable transport, such as mcp.example.com or localhost:*. Each entry is a host, or a host and a port, where the port may be an asterisk to match any port (or none). The host is compared ignoring case. When set, a request whose Host header is missing or not in the list is rejected with HTTP 421 (Misdirected Request), before its body is read and bef [...] | `camel.server.mcpAllowedOrigins` | Comma-separated list of origins allowed to call the MCP endpoint on the Vert.x streamable transport. Each entry is an exact origin (scheme, host and port), or ends with a colon and an asterisk to match any port. Scheme and host are compared ignoring case, and a default port (80 for http, 443 for https) may be left out. A wildcard for all origins is not supported: an entry that is not of the form scheme://host or scheme://host:port fails the startup. A [...] | `camel.server.mcpEnabled` | Whether to expose ai-tool routes as MCP tools, ai-resource routes as MCP resources and ai-prompt routes as MCP prompts. Requires camel-mcp-server on the classpath. Use setMcpTransport(String) to choose streamable HTTP (default) or stdio for IDE subprocess integration. By default, the MCP server is not enabled. | false | boolean | `camel.server.mcpInstructions` | Top-level MCP instructions returned to clients on initialize. | | String diff --git a/core/camel-main/src/main/java/org/apache/camel/main/HttpServerConfigurationProperties.java b/core/camel-main/src/main/java/org/apache/camel/main/HttpServerConfigurationProperties.java index 124cb992cf44..54699cb39dd6 100644 --- a/core/camel-main/src/main/java/org/apache/camel/main/HttpServerConfigurationProperties.java +++ b/core/camel-main/src/main/java/org/apache/camel/main/HttpServerConfigurationProperties.java @@ -91,6 +91,8 @@ public class HttpServerConfigurationProperties implements BootstrapCloseable { private long mcpSessionIdleTtl = 300000; @Metadata private String mcpAllowedOrigins; + @Metadata + private String mcpAllowedHosts; @Metadata(label = "security") private String mcpOauthProfile; @Metadata(label = "security") @@ -560,6 +562,23 @@ public class HttpServerConfigurationProperties implements BootstrapCloseable { this.mcpAllowedOrigins = mcpAllowedOrigins; } + public String getMcpAllowedHosts() { + return mcpAllowedHosts; + } + + /** + * Comma-separated list of hosts allowed in the Host header of a request to the MCP endpoint on the Vert.x + * streamable transport, such as mcp.example.com or localhost:*. Each entry is a host, or a host and a port, where + * the port may be an asterisk to match any port (or none). The host is compared ignoring case. When set, a request + * whose Host header is missing or not in the list is rejected with HTTP 421 (Misdirected Request), before its body + * is read and before any session is created; this protects a server on a private address against DNS rebinding. An + * entry with a scheme, a path or a wildcard for all hosts fails the startup. When not set, the Host header is not + * checked. + */ + public void setMcpAllowedHosts(String mcpAllowedHosts) { + this.mcpAllowedHosts = mcpAllowedHosts; + } + public String getMcpOauthProfile() { return mcpOauthProfile; } @@ -913,6 +932,15 @@ public class HttpServerConfigurationProperties implements BootstrapCloseable { return this; } + /** + * Comma-separated list of hosts allowed in the Host header of a request to the MCP endpoint on the Vert.x + * streamable transport. When not set, the Host header is not checked. + */ + public HttpServerConfigurationProperties withMcpAllowedHosts(String mcpAllowedHosts) { + this.mcpAllowedHosts = mcpAllowedHosts; + return this; + } + /** * Name of the camel-oauth profile used to authenticate OAuth bearer tokens on the MCP HTTP endpoint. */
