From e9290b2c3632f4628fed840e35ecefeee212c384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Fri, 19 Jun 2026 09:20:14 +0200 Subject: [PATCH 1/2] feat(ssrf): implement SSRF protection with configurable validation --- .../config/AdminServerAutoConfiguration.java | 13 +- .../server/config/AdminServerProperties.java | 42 ++- .../config/AdminServerWebConfiguration.java | 13 +- .../server/services/InstanceRegistry.java | 15 +- .../admin/server/utils/SsrfUrlValidator.java | 280 ++++++++++++++++ .../admin/server/web/InstanceWebProxy.java | 20 +- .../exception/SsrfProtectionException.java | 29 ++ .../reactive/InstancesProxyController.java | 11 +- .../web/servlet/InstancesProxyController.java | 11 +- .../server/services/InstanceRegistryTest.java | 66 +++- .../server/utils/SsrfUrlValidatorTest.java | 298 ++++++++++++++++++ 11 files changed, 782 insertions(+), 16 deletions(-) create mode 100644 spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/utils/SsrfUrlValidator.java create mode 100644 spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/client/exception/SsrfProtectionException.java create mode 100644 spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/utils/SsrfUrlValidatorTest.java diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfiguration.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfiguration.java index 70c646678b0..a59549a036e 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfiguration.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2023 the original author or authors. + * Copyright 2014-2026 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -52,6 +52,7 @@ import de.codecentric.boot.admin.server.services.endpoints.ChainingStrategy; import de.codecentric.boot.admin.server.services.endpoints.ProbeEndpointsStrategy; import de.codecentric.boot.admin.server.services.endpoints.QueryIndexEndpointStrategy; +import de.codecentric.boot.admin.server.utils.SsrfUrlValidator; import de.codecentric.boot.admin.server.web.client.InstanceWebClient; @Configuration(proxyBeanMethods = false) @@ -76,11 +77,17 @@ public InstanceFilter instanceFilter() { return (instance) -> true; } + @Bean + @ConditionalOnMissingBean + public SsrfUrlValidator ssrfUrlValidator() { + return new SsrfUrlValidator(this.adminServerProperties.getSsrfProtection()); + } + @Bean @ConditionalOnMissingBean public InstanceRegistry instanceRegistry(InstanceRepository instanceRepository, - InstanceIdGenerator instanceIdGenerator, InstanceFilter instanceFilter) { - return new InstanceRegistry(instanceRepository, instanceIdGenerator, instanceFilter); + InstanceIdGenerator instanceIdGenerator, InstanceFilter instanceFilter, SsrfUrlValidator ssrfUrlValidator) { + return new InstanceRegistry(instanceRepository, instanceIdGenerator, instanceFilter, ssrfUrlValidator); } @Bean diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerProperties.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerProperties.java index 8b58cb03c8f..24572301534 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerProperties.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2023 the original author or authors. + * Copyright 2014-2026 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,8 +18,10 @@ import java.time.Duration; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; @@ -49,6 +51,8 @@ public class AdminServerProperties { private InstanceProxyProperties instanceProxy = new InstanceProxyProperties(); + private SsrfProtectionProperties ssrfProtection = new SsrfProtectionProperties(); + /** * The metadata keys which should be sanitized when serializing to JSON */ @@ -203,4 +207,40 @@ public static class InstanceProxyProperties { } + @lombok.Data + public static class SsrfProtectionProperties { + + /** + * Whether SSRF protection is enabled. When enabled, registration URLs are + * validated against blocked schemes and private/internal IP ranges. Default: + * false (opt-in). + */ + private boolean enabled = false; + + /** + * URL schemes that are permitted. Any scheme not in this list is blocked. + * Default: http, https. + */ + private Set allowedSchemes = new HashSet<>(asList("http", "https")); + + /** + * Hosts (exact match or glob-style suffix patterns) that are explicitly allowed + * even if they would otherwise match a blocked range. Useful for intranet + * deployments where SBA must reach private-IP services. + *

+ * Example: {@code 192.168.1.100}, {@code *.internal.corp} + */ + private List allowedHosts = new ArrayList<>(); + + /** + * Additional hostname patterns (regex) to block beyond the built-in private + * ranges. Matched against the raw hostname from the URL (before any DNS + * resolution). + *

+ * Example: {@code .*\.internal\.corp$} + */ + private List blockedHostPatterns = new ArrayList<>(); + + } + } diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerWebConfiguration.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerWebConfiguration.java index 52972b72efd..a50f10d73eb 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerWebConfiguration.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerWebConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * Copyright 2014-2026 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,6 +30,7 @@ import de.codecentric.boot.admin.server.eventstore.InstanceEventStore; import de.codecentric.boot.admin.server.services.ApplicationRegistry; import de.codecentric.boot.admin.server.services.InstanceRegistry; +import de.codecentric.boot.admin.server.utils.SsrfUrlValidator; import de.codecentric.boot.admin.server.utils.jackson.AdminServerModule; import de.codecentric.boot.admin.server.web.ApplicationsController; import de.codecentric.boot.admin.server.web.InstancesController; @@ -75,11 +76,12 @@ public ReactiveRestApiConfiguration(AdminServerProperties adminServerProperties) @Bean @ConditionalOnMissingBean public de.codecentric.boot.admin.server.web.reactive.InstancesProxyController instancesProxyController( - InstanceRegistry instanceRegistry, InstanceWebClient.Builder instanceWebClientBuilder) { + InstanceRegistry instanceRegistry, InstanceWebClient.Builder instanceWebClientBuilder, + SsrfUrlValidator ssrfUrlValidator) { return new de.codecentric.boot.admin.server.web.reactive.InstancesProxyController( this.adminServerProperties.getContextPath(), this.adminServerProperties.getInstanceProxy().getIgnoredHeaders(), instanceRegistry, - instanceWebClientBuilder.build()); + instanceWebClientBuilder.build(), ssrfUrlValidator); } @Bean @@ -108,11 +110,12 @@ public ServletRestApiConfiguration(AdminServerProperties adminServerProperties) @Bean @ConditionalOnMissingBean public de.codecentric.boot.admin.server.web.servlet.InstancesProxyController instancesProxyController( - InstanceRegistry instanceRegistry, InstanceWebClient.Builder instanceWebClientBuilder) { + InstanceRegistry instanceRegistry, InstanceWebClient.Builder instanceWebClientBuilder, + SsrfUrlValidator ssrfUrlValidator) { return new de.codecentric.boot.admin.server.web.servlet.InstancesProxyController( this.adminServerProperties.getContextPath(), this.adminServerProperties.getInstanceProxy().getIgnoredHeaders(), instanceRegistry, - instanceWebClientBuilder.build()); + instanceWebClientBuilder.build(), ssrfUrlValidator); } @Bean diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/InstanceRegistry.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/InstanceRegistry.java index 88f4c2bdf97..5464c1eafdc 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/InstanceRegistry.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/InstanceRegistry.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2023 the original author or authors. + * Copyright 2014-2026 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; import de.codecentric.boot.admin.server.domain.values.InstanceId; import de.codecentric.boot.admin.server.domain.values.Registration; +import de.codecentric.boot.admin.server.utils.SsrfUrlValidator; /** * Registry for all application instances that should be managed/administrated by the @@ -38,10 +39,19 @@ public class InstanceRegistry { private final InstanceFilter filter; + private final SsrfUrlValidator ssrfUrlValidator; + public InstanceRegistry(InstanceRepository repository, InstanceIdGenerator generator, InstanceFilter filter) { + this(repository, generator, filter, new SsrfUrlValidator( + new de.codecentric.boot.admin.server.config.AdminServerProperties.SsrfProtectionProperties())); + } + + public InstanceRegistry(InstanceRepository repository, InstanceIdGenerator generator, InstanceFilter filter, + SsrfUrlValidator ssrfUrlValidator) { this.repository = repository; this.generator = generator; this.filter = filter; + this.ssrfUrlValidator = ssrfUrlValidator; } /** @@ -51,6 +61,9 @@ public InstanceRegistry(InstanceRepository repository, InstanceIdGenerator gener */ public Mono register(Registration registration) { Assert.notNull(registration, "'registration' must not be null"); + ssrfUrlValidator.validate(registration.getHealthUrl()); + ssrfUrlValidator.validate(registration.getManagementUrl()); + ssrfUrlValidator.validate(registration.getServiceUrl()); InstanceId id = generator.generateId(registration); Assert.notNull(id, "'id' must not be null"); return repository.compute(id, (key, instance) -> { diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/utils/SsrfUrlValidator.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/utils/SsrfUrlValidator.java new file mode 100644 index 00000000000..769836cba9e --- /dev/null +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/utils/SsrfUrlValidator.java @@ -0,0 +1,280 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed 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 + * + * https://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 de.codecentric.boot.admin.server.utils; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.StringUtils; + +import de.codecentric.boot.admin.server.config.AdminServerProperties.SsrfProtectionProperties; +import de.codecentric.boot.admin.server.web.client.exception.SsrfProtectionException; + +/** + * Validates URLs submitted for instance registration and proxying against SSRF (Server- + * Side Request Forgery) attack patterns. + * + *

+ * When SSRF protection is enabled + * ({@code spring.boot.admin.ssrf-protection.enabled=true}), this validator checks: + *

    + *
  1. Scheme – only schemes listed in + * {@code spring.boot.admin.ssrf-protection.allowed-schemes} are accepted (default: + * {@code http}, {@code https}).
  2. + *
  3. Private / internal IP ranges – the raw hostname is compared against + * well-known private and special-purpose address literals without performing DNS + * resolution. Blocked by default: loopback ({@code 127.x}, {@code ::1}, + * {@code localhost}), link-local ({@code 169.254.x} / {@code fe80:}), RFC 1918 + * ({@code 10.x}, {@code 172.16-31.x}, {@code 192.168.x}), unique-local IPv6 + * ({@code fc}/{@code fd}), and the unspecified address ({@code 0.0.0.0}).
  4. + *
  5. User-supplied block patterns – additional regex patterns provided via + * {@code spring.boot.admin.ssrf-protection.blocked-host-patterns} are matched against the + * hostname.
  6. + *
  7. Allowlist override – hosts listed in + * {@code spring.boot.admin.ssrf-protection.allowed-hosts} bypass all block checks. + * Supports exact host names and glob-style suffix patterns (e.g. + * {@code *.internal.corp}).
  8. + *
+ * + *

+ * Limitation: Only literal hostname strings are inspected; no DNS resolution is + * performed. An attacker who controls a public DNS record that resolves to a private IP + * (DNS rebinding) is not blocked by this validator alone. Use IMDSv2 or network-level + * egress controls as additional layers of defence. + * + *

+ * When SSRF protection is disabled + * ({@code spring.boot.admin.ssrf-protection.enabled=false}, which is the default) all + * URLs are accepted without further inspection. + */ +public class SsrfUrlValidator { + + private static final Logger log = LoggerFactory.getLogger(SsrfUrlValidator.class); + + private final SsrfProtectionProperties properties; + + public SsrfUrlValidator(SsrfProtectionProperties properties) { + this.properties = properties; + } + + /** + * Validates the given URL against the configured SSRF protection rules. + * @param url the URL to validate (may be {@code null} or blank, which is skipped) + * @throws SsrfProtectionException if the URL violates a protection rule and + * protection is enabled + */ + public void validate(@Nullable String url) { + if (!properties.isEnabled()) { + return; + } + if (!StringUtils.hasText(url)) { + return; + } + + URI uri; + try { + uri = new URI(url); + } + catch (URISyntaxException ex) { + return; + } + + String scheme = uri.getScheme(); + String host = uri.getHost(); + + checkScheme(url, scheme); + if (host != null && !isAllowed(host)) { + checkPrivateHost(url, host); + checkBlockedPatterns(url, host); + } + } + + // ------------------------------------------------------------------------- + // Scheme check + // ------------------------------------------------------------------------- + + private void checkScheme(String url, @Nullable String scheme) { + if (scheme == null) { + return; + } + if (!properties.getAllowedSchemes().contains(scheme.toLowerCase())) { + throw new SsrfProtectionException("URL '" + url + "' uses disallowed scheme '" + scheme + + "'. Allowed schemes: " + properties.getAllowedSchemes() + + ". Configure 'spring.boot.admin.ssrf-protection" + ".allowed-schemes' to adjust."); + } + } + + // ------------------------------------------------------------------------- + // Allowlist check + // ------------------------------------------------------------------------- + + /** + * Returns {@code true} if the host matches any entry in the configured + * {@code allowedHosts} list. Supports exact matches and glob-style suffix wildcards + * (e.g. {@code *.internal.corp} matches {@code svc.internal.corp}). + * @param host the raw hostname from the URL (not null) + * @return true if the host is explicitly allowed + */ + private boolean isAllowed(String host) { + String lowerHost = host.toLowerCase(); + for (String allowed : properties.getAllowedHosts()) { + String lowerAllowed = allowed.toLowerCase(); + if (lowerAllowed.startsWith("*.")) { + // Glob suffix: *.example.com matches foo.example.com + String suffix = lowerAllowed.substring(1); // -> .example.com + if (lowerHost.endsWith(suffix) || lowerHost.equals(suffix.substring(1))) { + return true; + } + } + else if (lowerHost.equals(lowerAllowed)) { + return true; + } + } + return false; + } + + // ------------------------------------------------------------------------- + // Private / internal host check (no DNS resolution — literal strings only) + // ------------------------------------------------------------------------- + + private void checkPrivateHost(String url, String host) { + String lowerHost = host.toLowerCase(); + + // Loopback + if (lowerHost.equals("localhost") || lowerHost.equals("0:0:0:0:0:0:0:1") || lowerHost.equals("::1")) { + rejectPrivate(url, host, "loopback"); + } + + // Unspecified + if (lowerHost.equals("0.0.0.0")) { + rejectPrivate(url, host, "unspecified address"); + } + + // IPv4 — parse only if it looks like a dotted-decimal address + if (looksLikeIpv4(lowerHost)) { + checkPrivateIpv4(url, host, lowerHost); + return; // don't run IPv6 checks on IPv4 addresses + } + + // IPv6 + checkPrivateIpv6(url, host, lowerHost); + } + + private boolean looksLikeIpv4(String host) { + // Simple heuristic: all chars are digits or dots, at least one dot + return host.chars().allMatch((c) -> Character.isDigit(c) || c == '.') && host.contains("."); + } + + private void checkPrivateIpv4(String url, String host, String lowerHost) { + // Loopback: 127.0.0.0/8 + if (lowerHost.startsWith("127.")) { + rejectPrivate(url, host, "loopback (127.0.0.0/8)"); + } + // Link-local / AWS metadata: 169.254.0.0/16 + if (lowerHost.startsWith("169.254.")) { + rejectPrivate(url, host, "link-local (169.254.0.0/16, includes cloud metadata endpoints)"); + } + // RFC 1918 — Class A: 10.0.0.0/8 + if (lowerHost.startsWith("10.")) { + rejectPrivate(url, host, "private (10.0.0.0/8)"); + } + // RFC 1918 — Class C: 192.168.0.0/16 + if (lowerHost.startsWith("192.168.")) { + rejectPrivate(url, host, "private (192.168.0.0/16)"); + } + // RFC 1918 — Class B: 172.16.0.0/12 (172.16.x.x – 172.31.x.x) + if (lowerHost.startsWith("172.")) { + String[] parts = lowerHost.split("\\."); + if (parts.length >= 2) { + try { + int second = Integer.parseInt(parts[1]); + if (second >= 16 && second <= 31) { + rejectPrivate(url, host, "private (172.16.0.0/12)"); + } + } + catch (NumberFormatException ignored) { + // not a valid IPv4 — fall through + } + } + } + } + + private void checkPrivateIpv6(String url, String host, String lowerHost) { + // Strip surrounding brackets that URI.getHost() may leave on IPv6 literals + String stripped = (lowerHost.startsWith("[") && lowerHost.endsWith("]")) + ? lowerHost.substring(1, lowerHost.length() - 1) : lowerHost; + + // Loopback ::1 + if (stripped.equals("::1") || stripped.equals("0:0:0:0:0:0:0:1")) { + rejectPrivate(url, host, "loopback (::1)"); + } + // Link-local fe80::/10 + if (stripped.startsWith("fe80:") || stripped.startsWith("fe8") || stripped.startsWith("fe9") + || stripped.startsWith("fea") || stripped.startsWith("feb")) { + rejectPrivate(url, host, "link-local (fe80::/10)"); + } + // Unique-local fc00::/7 — covers fc and fd prefixes + if (stripped.startsWith("fc") || stripped.startsWith("fd")) { + rejectPrivate(url, host, "unique-local (fc00::/7)"); + } + // IPv4-mapped IPv6: ::ffff:0:0/96 — e.g. ::ffff:192.168.1.1 + if (stripped.startsWith("::ffff:")) { + String embedded = stripped.substring("::ffff:".length()); + // Recursively validate the embedded IPv4 part + checkPrivateIpv4(url, embedded, embedded.toLowerCase()); + } + } + + // ------------------------------------------------------------------------- + // User-supplied blocked patterns + // ------------------------------------------------------------------------- + + private void checkBlockedPatterns(String url, String host) { + for (String rawPattern : properties.getBlockedHostPatterns()) { + if (!StringUtils.hasText(rawPattern)) { + continue; + } + try { + if (Pattern.compile(rawPattern).matcher(host).matches()) { + throw new SsrfProtectionException("URL '" + url + "' is blocked: host '" + host + + "' matches configured blocked pattern '" + rawPattern + + "'. Configure 'spring.boot.admin.ssrf-protection.blocked-host-patterns' to adjust."); + } + } + catch (PatternSyntaxException ex) { + log.warn("Invalid SSRF blocked-host-pattern '{}': {}", rawPattern, ex.getMessage()); + } + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private void rejectPrivate(String url, String host, String rangeDescription) { + throw new SsrfProtectionException( + "URL '" + url + "' is blocked: host '" + host + "' resolves to a " + rangeDescription + " address. " + + "Add this host to 'spring.boot.admin.ssrf-protection.allowed-hosts' to permit it, " + + "or disable protection with 'spring.boot.admin.ssrf-protection.enabled=false'."); + } + +} diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/InstanceWebProxy.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/InstanceWebProxy.java index 2dd60260801..49e3fb2b025 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/InstanceWebProxy.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/InstanceWebProxy.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2023 the original author or authors. + * Copyright 2014-2026 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -41,8 +41,10 @@ import de.codecentric.boot.admin.server.domain.entities.Instance; import de.codecentric.boot.admin.server.domain.values.InstanceId; +import de.codecentric.boot.admin.server.utils.SsrfUrlValidator; import de.codecentric.boot.admin.server.web.client.InstanceWebClient; import de.codecentric.boot.admin.server.web.client.exception.ResolveEndpointException; +import de.codecentric.boot.admin.server.web.client.exception.SsrfProtectionException; import static org.springframework.http.HttpMethod.PATCH; import static org.springframework.http.HttpMethod.POST; @@ -65,8 +67,16 @@ public class InstanceWebProxy { private final ExchangeStrategies strategies = ExchangeStrategies.withDefaults(); + private final SsrfUrlValidator ssrfUrlValidator; + public InstanceWebProxy(InstanceWebClient instanceWebClient) { + this(instanceWebClient, new SsrfUrlValidator( + new de.codecentric.boot.admin.server.config.AdminServerProperties.SsrfProtectionProperties())); + } + + public InstanceWebProxy(InstanceWebClient instanceWebClient, SsrfUrlValidator ssrfUrlValidator) { this.instanceWebClient = instanceWebClient; + this.ssrfUrlValidator = ssrfUrlValidator; } public Mono forward(Mono instanceMono, ForwardRequest forwardRequest, @@ -98,6 +108,14 @@ public Flux forward(Flux instances, ForwardRequest f private Mono forward(Instance instance, ForwardRequest forwardRequest, Function> responseHandler) { log.trace("Proxy-Request for instance {} with URL '{}'", instance.getId(), forwardRequest.getUri()); + try { + ssrfUrlValidator.validate(forwardRequest.getUri().isAbsolute() ? forwardRequest.getUri().toString() : null); + } + catch (SsrfProtectionException ex) { + log.warn("SSRF protection blocked proxy request for instance {} to '{}': {}", instance.getId(), + forwardRequest.getUri(), ex.getMessage()); + return responseHandler.apply(ClientResponse.create(HttpStatus.FORBIDDEN, this.strategies).build()); + } WebClient.RequestBodySpec bodySpec = this.instanceWebClient.instance(instance) .method(forwardRequest.getMethod()) .uri(forwardRequest.getUri()) diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/client/exception/SsrfProtectionException.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/client/exception/SsrfProtectionException.java new file mode 100644 index 00000000000..1f3ae3ad55c --- /dev/null +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/client/exception/SsrfProtectionException.java @@ -0,0 +1,29 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed 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 + * + * https://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 de.codecentric.boot.admin.server.web.client.exception; + +/** + * Thrown when a URL submitted for instance registration or proxying is rejected by the + * SSRF protection rules (e.g. private IP range, disallowed scheme). + */ +public class SsrfProtectionException extends IllegalArgumentException { + + public SsrfProtectionException(String message) { + super(message); + } + +} diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/reactive/InstancesProxyController.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/reactive/InstancesProxyController.java index ff4cea7bdaf..5d47009aa25 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/reactive/InstancesProxyController.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/reactive/InstancesProxyController.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2023 the original author or authors. + * Copyright 2014-2026 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -39,6 +39,7 @@ import de.codecentric.boot.admin.server.domain.values.InstanceId; import de.codecentric.boot.admin.server.services.InstanceRegistry; +import de.codecentric.boot.admin.server.utils.SsrfUrlValidator; import de.codecentric.boot.admin.server.web.AdminController; import de.codecentric.boot.admin.server.web.HttpHeaderFilter; import de.codecentric.boot.admin.server.web.InstanceWebProxy; @@ -68,10 +69,16 @@ public class InstancesProxyController { public InstancesProxyController(String adminContextPath, Set ignoredHeaders, InstanceRegistry registry, InstanceWebClient instanceWebClient) { + this(adminContextPath, ignoredHeaders, registry, instanceWebClient, new SsrfUrlValidator( + new de.codecentric.boot.admin.server.config.AdminServerProperties.SsrfProtectionProperties())); + } + + public InstancesProxyController(String adminContextPath, Set ignoredHeaders, InstanceRegistry registry, + InstanceWebClient instanceWebClient, SsrfUrlValidator ssrfUrlValidator) { this.adminContextPath = adminContextPath; this.registry = registry; this.httpHeadersFilter = new HttpHeaderFilter(ignoredHeaders); - this.instanceWebProxy = new InstanceWebProxy(instanceWebClient); + this.instanceWebProxy = new InstanceWebProxy(instanceWebClient, ssrfUrlValidator); } @RequestMapping(path = INSTANCE_MAPPED_PATH, method = { RequestMethod.GET, RequestMethod.HEAD, RequestMethod.POST, diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/servlet/InstancesProxyController.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/servlet/InstancesProxyController.java index 5f3886c498f..fc9e052d2f4 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/servlet/InstancesProxyController.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/servlet/InstancesProxyController.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2023 the original author or authors. + * Copyright 2014-2026 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -46,6 +46,7 @@ import de.codecentric.boot.admin.server.domain.values.InstanceId; import de.codecentric.boot.admin.server.services.InstanceRegistry; +import de.codecentric.boot.admin.server.utils.SsrfUrlValidator; import de.codecentric.boot.admin.server.web.AdminController; import de.codecentric.boot.admin.server.web.HttpHeaderFilter; import de.codecentric.boot.admin.server.web.InstanceWebProxy; @@ -75,10 +76,16 @@ public class InstancesProxyController { public InstancesProxyController(String adminContextPath, Set ignoredHeaders, InstanceRegistry registry, InstanceWebClient instanceWebClient) { + this(adminContextPath, ignoredHeaders, registry, instanceWebClient, new SsrfUrlValidator( + new de.codecentric.boot.admin.server.config.AdminServerProperties.SsrfProtectionProperties())); + } + + public InstancesProxyController(String adminContextPath, Set ignoredHeaders, InstanceRegistry registry, + InstanceWebClient instanceWebClient, SsrfUrlValidator ssrfUrlValidator) { this.adminContextPath = adminContextPath; this.registry = registry; this.httpHeadersFilter = new HttpHeaderFilter(ignoredHeaders); - this.instanceWebProxy = new InstanceWebProxy(instanceWebClient); + this.instanceWebProxy = new InstanceWebProxy(instanceWebClient, ssrfUrlValidator); } @ResponseBody diff --git a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/InstanceRegistryTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/InstanceRegistryTest.java index 25aeb9cdaa0..79709ca5188 100644 --- a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/InstanceRegistryTest.java +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/InstanceRegistryTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * Copyright 2014-2026 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,9 +20,11 @@ import java.util.Map; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import reactor.test.StepVerifier; +import de.codecentric.boot.admin.server.config.AdminServerProperties.SsrfProtectionProperties; import de.codecentric.boot.admin.server.domain.entities.EventsourcingInstanceRepository; import de.codecentric.boot.admin.server.domain.entities.Instance; import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; @@ -31,6 +33,8 @@ import de.codecentric.boot.admin.server.domain.values.Registration; import de.codecentric.boot.admin.server.domain.values.StatusInfo; import de.codecentric.boot.admin.server.eventstore.InMemoryEventStore; +import de.codecentric.boot.admin.server.utils.SsrfUrlValidator; +import de.codecentric.boot.admin.server.web.client.exception.SsrfProtectionException; import static java.util.Collections.singletonMap; import static org.assertj.core.api.Assertions.assertThat; @@ -137,4 +141,64 @@ void findByNameAndFilter() { .verifyComplete(); } + @Nested + class SsrfProtection { + + private InstanceRegistry ssrfRegistry; + + @BeforeEach + void setUp() { + SsrfProtectionProperties ssrfProps = new SsrfProtectionProperties(); + ssrfProps.setEnabled(true); + SsrfUrlValidator ssrfValidator = new SsrfUrlValidator(ssrfProps); + ssrfRegistry = new InstanceRegistry(repository, idGenerator, (instance) -> true, ssrfValidator); + } + + @Test + void register_rejects_awsMetadataHealthUrl() { + Registration reg = Registration.create("evil", "http://169.254.169.254/latest/meta-data/").build(); + assertThatThrownBy(() -> ssrfRegistry.register(reg).block()).isInstanceOf(SsrfProtectionException.class) + .hasMessageContaining("link-local"); + } + + @Test + void register_rejects_loopbackManagementUrl() { + Registration reg = Registration.create("evil", "http://example.com/health") + .managementUrl("http://127.0.0.1:8080/actuator") + .build(); + assertThatThrownBy(() -> ssrfRegistry.register(reg).block()).isInstanceOf(SsrfProtectionException.class) + .hasMessageContaining("loopback"); + } + + @Test + void register_rejects_privateRangeServiceUrl() { + Registration reg = Registration.create("evil", "http://example.com/health") + .serviceUrl("http://192.168.1.1/") + .build(); + assertThatThrownBy(() -> ssrfRegistry.register(reg).block()).isInstanceOf(SsrfProtectionException.class) + .hasMessageContaining("192.168.0.0/16"); + } + + @Test + void register_allows_externalUrl_whenSsrfEnabled() { + Registration reg = Registration.create("legit", "http://example.com/actuator/health").build(); + InstanceId id = ssrfRegistry.register(reg).block(); + assertThat(id).isNotNull(); + } + + @Test + void register_allows_allowlistedPrivateHost() { + SsrfProtectionProperties ssrfProps = new SsrfProtectionProperties(); + ssrfProps.setEnabled(true); + ssrfProps.getAllowedHosts().add("192.168.1.100"); + ssrfRegistry = new InstanceRegistry(repository, idGenerator, (instance) -> true, + new SsrfUrlValidator(ssrfProps)); + + Registration reg = Registration.create("intranet-svc", "http://192.168.1.100/actuator/health").build(); + InstanceId id = ssrfRegistry.register(reg).block(); + assertThat(id).isNotNull(); + } + + } + } diff --git a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/utils/SsrfUrlValidatorTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/utils/SsrfUrlValidatorTest.java new file mode 100644 index 00000000000..8bbcabda630 --- /dev/null +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/utils/SsrfUrlValidatorTest.java @@ -0,0 +1,298 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed 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 + * + * https://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 de.codecentric.boot.admin.server.utils; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import de.codecentric.boot.admin.server.config.AdminServerProperties.SsrfProtectionProperties; +import de.codecentric.boot.admin.server.web.client.exception.SsrfProtectionException; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SsrfUrlValidatorTest { + + private SsrfProtectionProperties properties; + + private SsrfUrlValidator validator; + + @BeforeEach + void setUp() { + properties = new SsrfProtectionProperties(); + properties.setEnabled(true); + validator = new SsrfUrlValidator(properties); + } + + @Test + void doesNothing_whenDisabled() { + properties.setEnabled(false); + assertThatCode(() -> validator.validate("http://169.254.169.254/latest/meta-data")).doesNotThrowAnyException(); + assertThatCode(() -> validator.validate("http://127.0.0.1/")).doesNotThrowAnyException(); + assertThatCode(() -> validator.validate("file:///etc/passwd")).doesNotThrowAnyException(); + } + + @Test + void ignores_nullAndBlankUrls() { + assertThatCode(() -> validator.validate(null)).doesNotThrowAnyException(); + assertThatCode(() -> validator.validate("")).doesNotThrowAnyException(); + assertThatCode(() -> validator.validate(" ")).doesNotThrowAnyException(); + } + + @Test + void blocks_unspecifiedAddress() { + assertThatThrownBy(() -> validator.validate("http://0.0.0.0/")).isInstanceOf(SsrfProtectionException.class) + .hasMessageContaining("unspecified address"); + } + + @ParameterizedTest + @ValueSource(strings = { "http://example.com/actuator/health", "https://api.example.com/admin/health", + "https://93.184.216.34/health", // example.com IP + "http://172.15.0.1/health", // just outside 172.16-31 range + "http://172.32.0.1/health" // just above 172.16-31 range + }) + void allows_publicUrls(String url) { + assertThatCode(() -> validator.validate(url)).doesNotThrowAnyException(); + } + + // ------------------------------------------------------------------------- + // Scheme checks + // ------------------------------------------------------------------------- + + @Nested + class SchemeValidation { + + @Test + void allows_http() { + assertThatCode(() -> validator.validate("http://example.com/health")).doesNotThrowAnyException(); + } + + @Test + void allows_https() { + assertThatCode(() -> validator.validate("https://example.com/health")).doesNotThrowAnyException(); + } + + @Test + void blocks_fileScheme() { + assertThatThrownBy(() -> validator.validate("file:///etc/passwd")) + .isInstanceOf(SsrfProtectionException.class) + .hasMessageContaining("disallowed scheme"); + } + + @Test + void blocks_ftpScheme() { + assertThatThrownBy(() -> validator.validate("ftp://internal-host/")) + .isInstanceOf(SsrfProtectionException.class) + .hasMessageContaining("disallowed scheme"); + } + + @Test + void allows_customScheme_whenConfigured() { + properties.getAllowedSchemes().add("ftp"); + assertThatCode(() -> validator.validate("ftp://public-ftp.example.com/")).doesNotThrowAnyException(); + } + + } + + // ------------------------------------------------------------------------- + // Loopback addresses + // ------------------------------------------------------------------------- + + @Nested + class LoopbackAddresses { + + @ParameterizedTest + @ValueSource(strings = { "http://localhost/health", "http://127.0.0.1/health", "http://127.0.0.2/health", + "http://127.255.255.255/health" }) + void blocks_loopbackIpv4(String url) { + assertThatThrownBy(() -> validator.validate(url)).isInstanceOf(SsrfProtectionException.class) + .hasMessageContaining("loopback"); + } + + @ParameterizedTest + @ValueSource(strings = { "http://[::1]/health", "http://[0:0:0:0:0:0:0:1]/health" }) + void blocks_loopbackIpv6(String url) { + assertThatThrownBy(() -> validator.validate(url)).isInstanceOf(SsrfProtectionException.class) + .hasMessageContaining("loopback"); + } + + } + + // ------------------------------------------------------------------------- + // Link-local (cloud metadata endpoints) + // ------------------------------------------------------------------------- + + @Nested + class LinkLocalAddresses { + + @ParameterizedTest + @ValueSource(strings = { "http://169.254.169.254/latest/meta-data", + "http://169.254.169.254/latest/meta-data/iam/security-credentials/", "http://169.254.0.1/anything" }) + void blocks_awsMetadataEndpoint(String url) { + assertThatThrownBy(() -> validator.validate(url)).isInstanceOf(SsrfProtectionException.class) + .hasMessageContaining("link-local"); + } + + @Test + void blocks_ipv6LinkLocal() { + assertThatThrownBy(() -> validator.validate("http://[fe80::1]/path")) + .isInstanceOf(SsrfProtectionException.class) + .hasMessageContaining("link-local"); + } + + } + + // ------------------------------------------------------------------------- + // RFC 1918 private ranges + // ------------------------------------------------------------------------- + + @Nested + class PrivateRanges { + + @ParameterizedTest + @ValueSource(strings = { "http://10.0.0.1/", "http://10.255.255.255/health" }) + void blocks_classA(String url) { + assertThatThrownBy(() -> validator.validate(url)).isInstanceOf(SsrfProtectionException.class) + .hasMessageContaining("10.0.0.0/8"); + } + + @ParameterizedTest + @ValueSource(strings = { "http://192.168.0.1/", "http://192.168.255.254/" }) + void blocks_classC(String url) { + assertThatThrownBy(() -> validator.validate(url)).isInstanceOf(SsrfProtectionException.class) + .hasMessageContaining("192.168.0.0/16"); + } + + @ParameterizedTest + @ValueSource(strings = { "http://172.16.0.1/", "http://172.20.1.1/", "http://172.31.255.255/" }) + void blocks_classB(String url) { + assertThatThrownBy(() -> validator.validate(url)).isInstanceOf(SsrfProtectionException.class) + .hasMessageContaining("172.16.0.0/12"); + } + + @ParameterizedTest + @ValueSource(strings = { "http://172.15.0.1/", "http://172.32.0.1/" }) + void doesNotBlock_outsideClassBRange(String url) { + // 172.15.x and 172.32.x are NOT in 172.16-31.x + assertThatCode(() -> validator.validate(url)).doesNotThrowAnyException(); + } + + } + + // ------------------------------------------------------------------------- + // IPv6 unique-local and IPv4-mapped + // ------------------------------------------------------------------------- + + @Nested + class Ipv6SpecialRanges { + + @ParameterizedTest + @ValueSource(strings = { "http://[fc00::1]/", "http://[fd12:3456:789a::1]/" }) + void blocks_uniqueLocal(String url) { + assertThatThrownBy(() -> validator.validate(url)).isInstanceOf(SsrfProtectionException.class) + .hasMessageContaining("unique-local"); + } + + @Test + void blocks_ipv4MappedPrivate() { + // ::ffff:192.168.1.1 embeds a private IPv4 address + assertThatThrownBy(() -> validator.validate("http://[::ffff:192.168.1.1]/")) + .isInstanceOf(SsrfProtectionException.class) + .hasMessageContaining("192.168.0.0/16"); + } + + @Test + void allows_ipv4MappedPublic() { + // ::ffff:93.184.216.34 embeds a public IPv4 address (example.com) + assertThatCode(() -> validator.validate("http://[::ffff:93.184.216.34]/")).doesNotThrowAnyException(); + } + + } + + // ------------------------------------------------------------------------- + // Allowlist overrides + // ------------------------------------------------------------------------- + + @Nested + class AllowlistOverrides { + + @Test + void allowedHost_exactMatch_overridesBlockedRange() { + properties.setAllowedHosts(List.of("192.168.1.100")); + assertThatCode(() -> validator.validate("http://192.168.1.100/actuator/health")).doesNotThrowAnyException(); + } + + @Test + void allowedHost_globSuffix_overridesBlockedPatterns() { + properties.setAllowedHosts(List.of("*.internal.corp")); + assertThatCode(() -> validator.validate("http://svc.internal.corp/actuator/health")) + .doesNotThrowAnyException(); + } + + @Test + void allowedHost_globSuffix_doesNotMatchUnrelatedHost() { + properties.setAllowedHosts(List.of("*.internal.corp")); + properties.setBlockedHostPatterns(List.of(".*\\.evil\\.corp$")); + assertThatThrownBy(() -> validator.validate("http://svc.evil.corp/actuator/health")) + .isInstanceOf(SsrfProtectionException.class) + .hasMessageContaining("blocked pattern"); + } + + @Test + void allowedHost_exactMatch_isCaseInsensitive() { + properties.setAllowedHosts(List.of("MY-INTERNAL-HOST")); + assertThatCode(() -> validator.validate("http://my-internal-host/health")).doesNotThrowAnyException(); + } + + } + + // ------------------------------------------------------------------------- + // User-supplied blocked patterns + // ------------------------------------------------------------------------- + + @Nested + class BlockedHostPatterns { + + @Test + void blocks_hostMatchingCustomPattern() { + properties.setBlockedHostPatterns(List.of(".*\\.internal\\.corp$")); + assertThatThrownBy(() -> validator.validate("http://svc.internal.corp/health")) + .isInstanceOf(SsrfProtectionException.class) + .hasMessageContaining("blocked pattern"); + } + + @Test + void ignores_invalidRegexPattern_withoutThrowing() { + properties.setBlockedHostPatterns(List.of("[invalid-regex")); + // Should log a warning but not throw + assertThatCode(() -> validator.validate("http://example.com/health")).doesNotThrowAnyException(); + } + + @Test + void allows_hostNotMatchingCustomPattern() { + properties.setBlockedHostPatterns(List.of(".*\\.internal\\.corp$")); + assertThatCode(() -> validator.validate("http://example.com/health")).doesNotThrowAnyException(); + } + + } + +} From 24010f7bd09281f4f3539c1eff0bab3e9743d72d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Fri, 19 Jun 2026 09:31:28 +0200 Subject: [PATCH 2/2] feat(ssrf): add SSRF protection documentation and configuration details --- .../docs/05-security/30-csrf-protection.md | 1 + .../docs/05-security/40-ssrf-protection.md | 233 ++++++++++++++++++ .../src/site/docs/05-security/index.md | 15 +- 3 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 spring-boot-admin-docs/src/site/docs/05-security/40-ssrf-protection.md diff --git a/spring-boot-admin-docs/src/site/docs/05-security/30-csrf-protection.md b/spring-boot-admin-docs/src/site/docs/05-security/30-csrf-protection.md index 47483fbe9da..6241c42800f 100644 --- a/spring-boot-admin-docs/src/site/docs/05-security/30-csrf-protection.md +++ b/spring-boot-admin-docs/src/site/docs/05-security/30-csrf-protection.md @@ -758,4 +758,5 @@ public class SecurityConfig { - [Server Authentication](./10-server-authentication.md) - Configure Spring Security - [Actuator Security](./20-actuator-security.md) - Secure client endpoints +- [SSRF Protection](./40-ssrf-protection.md) - Block SSRF attacks via the instance registration endpoint - [Spring Security CSRF Documentation](https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html) diff --git a/spring-boot-admin-docs/src/site/docs/05-security/40-ssrf-protection.md b/spring-boot-admin-docs/src/site/docs/05-security/40-ssrf-protection.md new file mode 100644 index 00000000000..78b9a886929 --- /dev/null +++ b/spring-boot-admin-docs/src/site/docs/05-security/40-ssrf-protection.md @@ -0,0 +1,233 @@ +--- +sidebar_position: 40 +sidebar_custom_props: + icon: 'shield' +--- + +# SSRF Protection + +Spring Boot Admin Server includes opt-in protection against Server-Side Request Forgery (SSRF) attacks that can be +triggered through the instance registration API. + +## The Risk + +When a Spring Boot application registers with the Admin Server, it sends a `POST /instances` request containing +`healthUrl`, `managementUrl`, and `serviceUrl`. The Admin Server immediately begins making outbound HTTP requests to +those URLs to poll health status and discover actuator endpoints. It also exposes a proxy at +`/instances/{id}/actuator/**` that forwards requests verbatim to the registered management URL. + +Without authentication or URL validation on `POST /instances`, an attacker can: + +1. Register a fake instance pointing to `http://169.254.169.254/latest/meta-data/` (AWS IMDSv1) +2. The Admin Server polls the metadata endpoint automatically and stores the response +3. The attacker retrieves the response — including IAM credentials — through the actuator proxy + +This applies to loopback addresses, RFC 1918 private ranges, and any internal service reachable from the server's +network, not just cloud metadata endpoints. + +:::warning +SSRF protection is **disabled by default** to avoid breaking existing deployments where the Admin Server legitimately +communicates with services on private IP ranges. Enable it explicitly and configure an allowlist for any intranet +services that need to register. +::: + +--- + +## Enabling SSRF Protection + +Add the following to your `application.yml`: + +```yaml +spring: + boot: + admin: + ssrf-protection: + enabled: true +``` + +When enabled, all URLs submitted during instance registration (`healthUrl`, `managementUrl`, `serviceUrl`) are +validated before the instance is stored. The proxy also re-validates the resolved target URL before each outbound +request. + +--- + +## What Gets Blocked + +When protection is enabled, the following are rejected by default: + +| Category | Examples | +|---|---| +| Loopback | `localhost`, `127.0.0.1`, `127.x.x.x`, `::1` | +| Link-local (cloud metadata) | `169.254.0.0/16` — includes AWS IMDSv1 (`169.254.169.254`), GCP, Azure | +| RFC 1918 Class A | `10.0.0.0/8` | +| RFC 1918 Class B | `172.16.0.0/12` (172.16.x – 172.31.x) | +| RFC 1918 Class C | `192.168.0.0/16` | +| IPv6 link-local | `fe80::/10` | +| IPv6 unique-local | `fc00::/7` (fc and fd prefixes) | +| IPv4-mapped IPv6 | `::ffff:` prefix embedding a private IPv4 | +| Unspecified address | `0.0.0.0` | +| Disallowed schemes | Anything other than `http` and `https` (e.g. `file://`, `ftp://`) | + +Registration attempts targeting any of these return `400 Bad Request`. Proxy requests that resolve to a blocked address +return `403 Forbidden`. + +:::note +Hostname-to-IP resolution is **not** performed during validation. Only the literal hostname string from the URL is +checked. An attacker who controls a public DNS record pointing to a private IP (DNS rebinding) is not blocked by this +validator alone. Use IMDSv2 on AWS or network-level egress controls as additional layers of defence. +::: + +--- + +## Allowing Internal Services + +If your Admin Server legitimately needs to reach services on private addresses — for example in an on-premises or +Kubernetes cluster deployment — add those hosts to the allowlist. An allowlisted host bypasses all block checks. + +### Exact host match + +```yaml +spring: + boot: + admin: + ssrf-protection: + enabled: true + allowed-hosts: + - 192.168.1.100 + - monitoring-service.internal +``` + +### Glob-style suffix pattern + +Use `*.suffix` to allow an entire subdomain: + +```yaml +spring: + boot: + admin: + ssrf-protection: + enabled: true + allowed-hosts: + - "*.svc.cluster.local" # all Kubernetes services + - "*.internal.corp" +``` + +The glob `*.svc.cluster.local` matches `my-service.svc.cluster.local` but not `svc.cluster.local` itself. Matching +is case-insensitive. + +--- + +## Blocking Additional Hosts + +To block hostnames beyond the built-in private ranges — for example internal domains that should never register — add +regex patterns to `blocked-host-patterns`: + +```yaml +spring: + boot: + admin: + ssrf-protection: + enabled: true + blocked-host-patterns: + - ".*\\.internal\\.corp$" + - "metadata\\.google\\.internal" +``` + +Patterns are matched against the raw hostname using `java.util.regex.Pattern`. Invalid patterns are logged as warnings +and skipped. + +:::note +The allowlist takes precedence over blocked patterns. A host matching both an `allowed-hosts` entry and a +`blocked-host-patterns` entry is **allowed**. +::: + +--- + +## Allowing Additional Schemes + +By default only `http` and `https` are permitted. To add a custom scheme: + +```yaml +spring: + boot: + admin: + ssrf-protection: + enabled: true + allowed-schemes: + - http + - https + - grpc +``` + +--- + +## Configuration Reference + +| Property | Type | Default | Description | +|---|---|---|---| +| `spring.boot.admin.ssrf-protection.enabled` | `boolean` | `false` | Enable SSRF URL validation | +| `spring.boot.admin.ssrf-protection.allowed-schemes` | `Set` | `http, https` | URL schemes that are permitted | +| `spring.boot.admin.ssrf-protection.allowed-hosts` | `List` | _(empty)_ | Hosts exempt from all block checks. Supports exact names and `*.suffix` glob patterns | +| `spring.boot.admin.ssrf-protection.blocked-host-patterns` | `List` | _(empty)_ | Additional Java regex patterns matched against the raw hostname | + +--- + +## Providing a Custom Validator + +Override the default `SsrfUrlValidator` bean to implement custom logic — for example, DNS resolution or CIDR matching: + +```java +import de.codecentric.boot.admin.server.utils.SsrfUrlValidator; +import de.codecentric.boot.admin.server.config.AdminServerProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class CustomSsrfConfig { + + @Bean + public SsrfUrlValidator ssrfUrlValidator(AdminServerProperties properties) { + AdminServerProperties.SsrfProtectionProperties ssrfProps = + properties.getSsrfProtection(); + // Wrap or extend the default validator + SsrfUrlValidator defaultValidator = new SsrfUrlValidator(ssrfProps); + return url -> { + defaultValidator.validate(url); + // Add custom checks here + }; + } +} +``` + +--- + +## Dual Validation + +SSRF protection runs at two points: + +1. **Registration (`POST /instances`)** — `healthUrl`, `managementUrl`, and `serviceUrl` are validated before the + instance is stored. Invalid registrations are rejected with `400 Bad Request`. + +2. **Proxy (`/instances/{id}/actuator/**`)** — The resolved target URL is re-validated before each outbound request. + This provides defence-in-depth against scenarios where an endpoint URL is assembled dynamically after registration. + Blocked proxy requests return `403 Forbidden`. + +--- + +## Additional Hardening + +SSRF protection alone is not sufficient for a publicly accessible Admin Server. Combine it with: + +- **Authentication on `POST /instances`** — The most effective mitigation. See [Server Authentication](./10-server-authentication.md). +- **AWS IMDSv2** — Require a `PUT` request with a TTL header to obtain a metadata token, which the Admin Server cannot + provide without explicit support. +- **Network egress controls** — Firewall rules or security groups that prevent the Admin Server's outbound traffic from + reaching metadata endpoints and internal services. +- **VPC/private network isolation** — Run the Admin Server in a subnet that has no route to sensitive internal services. + +--- + +## See Also + +- [Server Authentication](./10-server-authentication.md) - Require authentication before instance registration +- [CSRF Protection](./30-csrf-protection.md) - Protect the registration endpoint against cross-origin forged requests diff --git a/spring-boot-admin-docs/src/site/docs/05-security/index.md b/spring-boot-admin-docs/src/site/docs/05-security/index.md index eafb0d6bf38..0c0e3a2166c 100644 --- a/spring-boot-admin-docs/src/site/docs/05-security/index.md +++ b/spring-boot-admin-docs/src/site/docs/05-security/index.md @@ -52,7 +52,18 @@ Configure CSRF tokens for Admin UI while allowing client registration: **See**: [CSRF Protection](./30-csrf-protection.md) -### 4. Mutual TLS (Optional) +### 4. SSRF Protection + +Prevent Server-Side Request Forgery via the instance registration API: + +- **IP Range Blocking**: Reject private/internal addresses in registered URLs +- **Scheme Allowlist**: Permit only `http` and `https` +- **Allowlist Override**: Explicitly permit intranet services by hostname +- **Proxy-time Validation**: Re-validate resolved URLs before each outbound request + +**See**: [SSRF Protection](./40-ssrf-protection.md) + +### 5. Mutual TLS (Optional) Enhanced security with client certificates: @@ -144,6 +155,7 @@ Use this checklist to ensure your deployment is secure: - [ ] Configure form login for UI access - [ ] Enable HTTP Basic for API/programmatic access - [ ] Configure CSRF protection with exemptions for `/instances` +- [ ] Enable SSRF protection (`spring.boot.admin.ssrf-protection.enabled=true`) - [ ] Set up remember-me with secure random key - [ ] Use HTTPS for deployments - [ ] Restrict access by IP (if applicable) @@ -476,6 +488,7 @@ public InMemoryUserDetailsManager userDetailsService(PasswordEncoder encoder) { - [Server Authentication](./10-server-authentication.md) - Secure Admin Server with Spring Security - [Actuator Security](./20-actuator-security.md) - Secure client actuator endpoints - [CSRF Protection](./30-csrf-protection.md) - Configure CSRF for UI and API +- [SSRF Protection](./40-ssrf-protection.md) - Block SSRF attacks via instance registration ---