Harden MCP sessions, outbound HTTP policy, and actuator access
This commit is contained in:
parent
f66bdeee15
commit
2e2d998de5
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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())) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue