Harden MCP sessions, outbound HTTP policy, and actuator access

This commit is contained in:
Lucio Lelii 2026-05-05 20:08:30 +02:00
parent f66bdeee15
commit 2e2d998de5
5 changed files with 170 additions and 13 deletions

View File

@ -36,7 +36,8 @@ public class SecurityConfig {
.requestMatchers("/swagger-ui.html").permitAll()
.requestMatchers("/swagger-ui/**").permitAll()
.requestMatchers("/v3/api-docs/**").permitAll()
.requestMatchers("/actuator/**").permitAll()
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/actuator/**").authenticated()
.requestMatchers("/error").permitAll()
.requestMatchers("/blocks/types/**").permitAll()
.requestMatchers("/containers/types/**").permitAll()

View File

@ -1,10 +1,14 @@
package it.cnr.isti.workflow.manager.http;
import java.nio.charset.StandardCharsets;
import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.time.Duration;
import java.util.Base64;
import java.util.Objects;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
@ -16,14 +20,29 @@ import it.cnr.isti.workflow.manager.blocks.configurations.HTTPServerCallBlockCon
@Component
public class HTTPServerCallService {
private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(HTTPServerCallService.class);
private final WebClient.Builder webClientBuilder;
@Value("${app.http.outbound.timeout-seconds:30}")
private long timeoutSeconds;
@Value("${app.http.outbound.retry-attempts:1}")
private int retryAttempts;
@Value("${app.http.outbound.allow-private-network:false}")
private boolean allowPrivateNetwork;
@Value("${app.http.outbound.allowed-host-patterns:}")
private String allowedHostPatterns;
public HTTPServerCallService(WebClient.Builder webClientBuilder) {
this.webClientBuilder = Objects.requireNonNull(webClientBuilder, "webClientBuilder cannot be null");
}
public String execute(HTTPServerCallBlockConfiguration configuration, String resolvedUrl, String resolvedRequestBody,
String authorizationValue) {
validateResolvedUrl(resolvedUrl);
WebClient.RequestHeadersSpec<?> requestSpec;
WebClient webClient = webClientBuilder.build();
@ -52,10 +71,71 @@ public class HTTPServerCallService {
return requestSpec.retrieve()
.bodyToMono(String.class)
.timeout(Duration.ofSeconds(30))
.timeout(Duration.ofSeconds(timeoutSeconds))
.retry(Math.max(0, retryAttempts))
.block();
}
private void validateResolvedUrl(String resolvedUrl) {
if (!StringUtils.hasText(resolvedUrl)) {
throw new IllegalArgumentException("HTTP server call URL cannot be empty");
}
URI uri = URI.create(resolvedUrl);
String scheme = uri.getScheme();
if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
throw new IllegalArgumentException("Only HTTP/HTTPS URLs are allowed for HTTP server call blocks");
}
String host = uri.getHost();
if (!StringUtils.hasText(host)) {
throw new IllegalArgumentException("HTTP server call URL must include a valid host");
}
if (isAllowedHost(host)) {
return;
}
if (!allowPrivateNetwork && isPrivateOrLocalHost(host)) {
logger.warn("Blocked outbound HTTP request to private/local host {}", host);
throw new IllegalArgumentException("Outbound URL host is not allowed: " + host);
}
}
private boolean isAllowedHost(String host) {
if (!StringUtils.hasText(allowedHostPatterns)) {
return false;
}
String normalizedHost = host.toLowerCase();
for (String rawPattern : allowedHostPatterns.split(",")) {
String pattern = rawPattern.trim().toLowerCase();
if (pattern.isEmpty()) {
continue;
}
if (pattern.startsWith("*.")) {
String suffix = pattern.substring(1);
if (normalizedHost.endsWith(suffix)) {
return true;
}
} else if (normalizedHost.equals(pattern)) {
return true;
}
}
return false;
}
private boolean isPrivateOrLocalHost(String host) {
String normalized = host.trim().toLowerCase();
if ("localhost".equals(normalized)) {
return true;
}
try {
InetAddress address = InetAddress.getByName(host);
return address.isAnyLocalAddress()
|| address.isLoopbackAddress()
|| address.isSiteLocalAddress()
|| address.isLinkLocalAddress();
} catch (UnknownHostException ignored) {
return false;
}
}
private void applyAuthorization(HTTPServerCallBlockConfiguration configuration,
WebClient.RequestHeadersSpec<?> requestSpec, String authorizationValue) {
if (!Boolean.TRUE.equals(configuration.getRequiresAuthorization())) {

View File

@ -7,11 +7,14 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.util.StringUtils;
@ -29,6 +32,25 @@ public class MCPAgentService {
private final String mcpBridgeUrl;
private final ObjectMapper mapper;
private final MCPServersProvider mcpServersProvider;
private final ConcurrentMap<String, Long> activeSessions = new ConcurrentHashMap<>();
@Value("${app.mcp.bridge.open-timeout-seconds:30}")
private long openTimeoutSeconds;
@Value("${app.mcp.bridge.query-timeout-seconds:120}")
private long queryTimeoutSeconds;
@Value("${app.mcp.bridge.close-timeout-seconds:30}")
private long closeTimeoutSeconds;
@Value("${app.mcp.bridge.retry-attempts:1}")
private int retryAttempts;
@Value("${app.mcp.session.max-active:500}")
private int maxActiveSessions;
@Value("${app.mcp.session.ttl-seconds:1800}")
private long sessionTtlSeconds;
public MCPAgentService(WebClient.Builder webClientBuilder,
@Value("${app.mcp.bridge.url}") String mcpBridgeUrl,
@ -67,9 +89,13 @@ public class MCPAgentService {
List<it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentBlockConfiguration.MCPServerBinding> mcpServers,
Map<String, Object> executionVariables) {
Objects.requireNonNull(model, "model cannot be null");
cleanupExpiredSessions();
if (activeSessions.size() >= maxActiveSessions) {
throw new IllegalStateException("MCP session limit reached (maxActiveSessions=" + maxActiveSessions + ")");
}
Map<String, Object> request = buildOpenSessionRequest(model, mcpServers, executionVariables);
SessionResponse response = client().post()
Mono<SessionResponse> requestMono = client().post()
.uri("/sessions")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
@ -78,13 +104,14 @@ public class MCPAgentService {
.defaultIfEmpty("MCP bridge returned an error without body")
.flatMap(body -> Mono.error(new RuntimeException(
"Unable to create MCP bridge session: " + body))))
.bodyToMono(SessionResponse.class)
.timeout(Duration.ofSeconds(30))
.block();
.bodyToMono(SessionResponse.class);
SessionResponse response = blockWithRetry(requestMono, Duration.ofSeconds(openTimeoutSeconds),
"create MCP bridge session");
if (response == null || response.session_id() == null || response.session_id().isBlank()) {
throw new RuntimeException("MCP bridge returned an empty session id");
}
touchSession(response.session_id());
return response.session_id();
}
@ -165,7 +192,7 @@ public class MCPAgentService {
public String querySession(String sessionId, String prompt) {
Objects.requireNonNull(sessionId, "sessionId cannot be null");
Objects.requireNonNull(prompt, "prompt cannot be null");
QueryResponse response = client().post()
Mono<QueryResponse> requestMono = client().post()
.uri("/sessions/{sessionId}/query", sessionId)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("query", prompt))
@ -174,13 +201,14 @@ public class MCPAgentService {
.defaultIfEmpty("MCP bridge returned an error without body")
.flatMap(body -> Mono.error(new RuntimeException(
"Unable to execute MCP bridge query: " + body))))
.bodyToMono(QueryResponse.class)
.timeout(Duration.ofMinutes(2))
.block();
.bodyToMono(QueryResponse.class);
QueryResponse response = blockWithRetry(requestMono, Duration.ofSeconds(queryTimeoutSeconds),
"execute MCP bridge query");
if (response == null || response.result() == null) {
throw new RuntimeException("MCP bridge query returned an empty result");
}
touchSession(sessionId);
return response.result();
}
@ -189,7 +217,7 @@ public class MCPAgentService {
return;
}
try {
client().delete()
Mono<Void> requestMono = client().delete()
.uri("/sessions/{sessionId}", sessionId)
.retrieve()
.onStatus(status -> status.isError(), clientResponse -> clientResponse.bodyToMono(String.class)
@ -197,17 +225,48 @@ public class MCPAgentService {
.flatMap(body -> Mono.error(new RuntimeException(
"Unable to delete MCP bridge session: " + body))))
.toBodilessEntity()
.timeout(Duration.ofSeconds(30))
.block();
.then();
blockWithRetry(requestMono, Duration.ofSeconds(closeTimeoutSeconds), "delete MCP bridge session");
activeSessions.remove(sessionId);
} catch (Exception ex) {
log.warn("Unable to delete MCP bridge session {}", sessionId, ex);
}
}
@Scheduled(fixedDelayString = "${app.mcp.session.cleanup-interval-ms:60000}")
void cleanupExpiredSessions() {
if (activeSessions.isEmpty() || sessionTtlSeconds <= 0) {
return;
}
long now = System.currentTimeMillis();
long ttlMs = Duration.ofSeconds(sessionTtlSeconds).toMillis();
activeSessions.entrySet().stream()
.filter(entry -> now - entry.getValue() >= ttlMs)
.map(Map.Entry::getKey)
.toList()
.forEach(this::closeSessionQuietly);
}
private WebClient client() {
return webClientBuilder.baseUrl(mcpBridgeUrl).build();
}
private <T> T blockWithRetry(Mono<T> requestMono, Duration timeout, String operationLabel) {
try {
return requestMono.timeout(timeout)
.retry(Math.max(0, retryAttempts))
.block();
} catch (Exception ex) {
throw new RuntimeException("Unable to " + operationLabel, ex);
}
}
private void touchSession(String sessionId) {
if (StringUtils.hasText(sessionId)) {
activeSessions.put(sessionId, System.currentTimeMillis());
}
}
private boolean isHttpTransport(String transport) {
return "http".equalsIgnoreCase(transport)
|| "https".equalsIgnoreCase(transport)

View File

@ -8,5 +8,11 @@ app.auth.cookie.secure=true
management.endpoint.health.show-details=when_authorized
management.endpoints.web.exposure.include=health,info,metrics,prometheus
app.mcp.session.max-active=300
app.mcp.session.ttl-seconds=900
app.http.outbound.allow-private-network=false
app.http.outbound.retry-attempts=1
logging.level.it.cnr.isti.workflow.manager=INFO
logging.level.root=WARN

View File

@ -38,10 +38,21 @@ app.security.require-explicit-key=${WFEDITOR_REQUIRE_EXPLICIT_SECRET:false}
app.ollama.internal.key=${OLLAMA_INTERNAL_KEY:ollama}
app.ollama.internal.url=${OLLAMA_INTERNAL_URL:https://ollama.internal/api}
app.mcp.bridge.url=${MCP_BRIDGE_URL:http://localhost:8000}
app.mcp.bridge.open-timeout-seconds=${MCP_BRIDGE_OPEN_TIMEOUT_SECONDS:30}
app.mcp.bridge.query-timeout-seconds=${MCP_BRIDGE_QUERY_TIMEOUT_SECONDS:120}
app.mcp.bridge.close-timeout-seconds=${MCP_BRIDGE_CLOSE_TIMEOUT_SECONDS:30}
app.mcp.bridge.retry-attempts=${MCP_BRIDGE_RETRY_ATTEMPTS:1}
app.mcp.session.max-active=${MCP_SESSION_MAX_ACTIVE:500}
app.mcp.session.ttl-seconds=${MCP_SESSION_TTL_SECONDS:1800}
app.mcp.session.cleanup-interval-ms=${MCP_SESSION_CLEANUP_INTERVAL_MS:60000}
app.mcp.servers.file=${MCP_SERVERS_FILE:}
app.executions.cache.max-size=${APP_EXECUTIONS_CACHE_MAX_SIZE:1000}
app.executions.cache.final-ttl-ms=${APP_EXECUTIONS_CACHE_FINAL_TTL_MS:1800000}
app.executions.cache.cleanup-interval-ms=${APP_EXECUTIONS_CACHE_CLEANUP_INTERVAL_MS:60000}
app.http.outbound.timeout-seconds=${HTTP_OUTBOUND_TIMEOUT_SECONDS:30}
app.http.outbound.retry-attempts=${HTTP_OUTBOUND_RETRY_ATTEMPTS:1}
app.http.outbound.allow-private-network=${HTTP_OUTBOUND_ALLOW_PRIVATE_NETWORK:false}
app.http.outbound.allowed-host-patterns=${HTTP_OUTBOUND_ALLOWED_HOST_PATTERNS:}
app.assistant.default-model=${ASSISTANT_DEFAULT_MODEL:gpt-oss:20b}
cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:4200}
app.auth.cookie.name=${AUTH_COOKIE_NAME:auth_token}