diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/config/SecurityConfig.java b/src/main/java/it/cnr/isti/workflow/manager/auth/config/SecurityConfig.java index d9f9a71..61c80c8 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/auth/config/SecurityConfig.java +++ b/src/main/java/it/cnr/isti/workflow/manager/auth/config/SecurityConfig.java @@ -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() diff --git a/src/main/java/it/cnr/isti/workflow/manager/http/HTTPServerCallService.java b/src/main/java/it/cnr/isti/workflow/manager/http/HTTPServerCallService.java index e39afce..df6723b 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/http/HTTPServerCallService.java +++ b/src/main/java/it/cnr/isti/workflow/manager/http/HTTPServerCallService.java @@ -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())) { diff --git a/src/main/java/it/cnr/isti/workflow/manager/mcp/MCPAgentService.java b/src/main/java/it/cnr/isti/workflow/manager/mcp/MCPAgentService.java index 696aea0..58db0d5 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/mcp/MCPAgentService.java +++ b/src/main/java/it/cnr/isti/workflow/manager/mcp/MCPAgentService.java @@ -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 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 mcpServers, Map executionVariables) { Objects.requireNonNull(model, "model cannot be null"); + cleanupExpiredSessions(); + if (activeSessions.size() >= maxActiveSessions) { + throw new IllegalStateException("MCP session limit reached (maxActiveSessions=" + maxActiveSessions + ")"); + } Map request = buildOpenSessionRequest(model, mcpServers, executionVariables); - SessionResponse response = client().post() + Mono 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 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 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 blockWithRetry(Mono 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) diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index cb14e75..33737de 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -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 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a8328e3..88bba70 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -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}