diff --git a/src/main/groovy/io/seqera/wave/configuration/SSRFConfig.groovy b/src/main/groovy/io/seqera/wave/configuration/SSRFConfig.groovy
new file mode 100644
index 000000000..6e4fc8582
--- /dev/null
+++ b/src/main/groovy/io/seqera/wave/configuration/SSRFConfig.groovy
@@ -0,0 +1,38 @@
+/*
+ * Wave, containers provisioning service
+ * Copyright (c) 2026, Seqera Labs
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package io.seqera.wave.configuration
+
+import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
+import io.micronaut.context.annotation.Value
+import jakarta.inject.Singleton
+/**
+ * Configuration for SSRF protection
+ *
+ * @author Munish Chouhan
+ */
+@CompileStatic
+@Singleton
+@Slf4j
+class SSRFConfig {
+
+ @Value('${wave.security.ssrf-protection.enabled:true}')
+ Boolean ssrfProtectionEnabled
+
+}
diff --git a/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy b/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy
index cebc541b5..901bbdb17 100644
--- a/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy
+++ b/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy
@@ -1,6 +1,6 @@
/*
* Wave, containers provisioning service
- * Copyright (c) 2023-2024, Seqera Labs
+ * Copyright (c) 2023-2026, Seqera Labs
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@@ -18,29 +18,47 @@
package io.seqera.wave.controller
+import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
import io.micronaut.http.annotation.Body
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Post
import io.micronaut.scheduling.TaskExecutors
import io.micronaut.scheduling.annotation.ExecuteOn
import io.seqera.wave.auth.RegistryAuthService
+import io.seqera.wave.configuration.SSRFConfig
+import io.seqera.wave.util.SsrfValidator
import jakarta.inject.Inject
import jakarta.validation.Valid
+@Slf4j
+@CompileStatic
@Controller("/")
@ExecuteOn(TaskExecutors.BLOCKING)
class ValidateController {
@Inject RegistryAuthService loginService
+ @Inject SSRFConfig ssrfConfig
+
@Deprecated
@Post("/validate-creds")
Boolean validateCreds(@Valid ValidateRegistryCredsRequest request){
+ // Validate registry to prevent SSRF attacks
+ if (ssrfConfig.ssrfProtectionEnabled && request.registry) {
+ log.debug "SSRF protection enabled, validating registry: ${request.registry}"
+ SsrfValidator.validateHost(request.registry)
+ }
loginService.validateUser(request.registry, request.userName, request.password)
}
@Post("/v1alpha2/validate-creds")
Boolean validateCredsV2(@Valid @Body ValidateRegistryCredsRequest request){
+ // Validate registry to prevent SSRF attacks
+ if (ssrfConfig.ssrfProtectionEnabled && request.registry) {
+ log.debug "SSRF protection enabled, validating registry: ${request.registry}"
+ SsrfValidator.validateHost(request.registry)
+ }
loginService.validateUser(request.registry, request.userName, request.password)
}
diff --git a/src/main/groovy/io/seqera/wave/util/SsrfValidator.groovy b/src/main/groovy/io/seqera/wave/util/SsrfValidator.groovy
new file mode 100644
index 000000000..67748f9cc
--- /dev/null
+++ b/src/main/groovy/io/seqera/wave/util/SsrfValidator.groovy
@@ -0,0 +1,200 @@
+/*
+ * Wave, containers provisioning service
+ * Copyright (c) 2023-2024, Seqera Labs
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package io.seqera.wave.util
+
+import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
+
+import java.util.regex.Pattern
+
+/**
+ * Utility class to prevent Server-Side Request Forgery (SSRF) attacks
+ * by validating hostnames before making HTTP requests.
+ *
+ * @author Munish Chouhan
+ */
+@Slf4j
+@CompileStatic
+class SsrfValidator {
+
+ // Private IP ranges (RFC 1918)
+ private static final Pattern PRIVATE_IP_PATTERN = Pattern.compile(
+ '^(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.)'
+ )
+
+ // Loopback addresses
+ private static final Pattern LOOPBACK_PATTERN = Pattern.compile(
+ '^(127\\.|0\\.0\\.0\\.0$)'
+ )
+
+ // Link-local addresses
+ private static final Pattern LINK_LOCAL_PATTERN = Pattern.compile(
+ '^169\\.254\\.'
+ )
+
+ // Cloud metadata service IPs
+ private static final Set METADATA_IPS = [
+ '169.254.169.254', // AWS, GCP, Azure metadata service
+ '169.254.170.2', // AWS ECS metadata service
+ 'fd00:ec2::254' // AWS IMDSv2 IPv6
+ ] as Set
+
+ // Localhost variations
+ private static final Set LOCALHOST_NAMES = [
+ 'localhost',
+ 'localhost.localdomain',
+ '0.0.0.0',
+ '0000:0000:0000:0000:0000:0000:0000:0001',
+ '::1'
+ ] as Set
+
+ /**
+ * Validates a hostname to ensure it doesn't resolve to internal/private resources
+ *
+ * @param host The hostname to validate
+ * @throws IllegalArgumentException if the hostname is potentially malicious
+ */
+ static void validateHost(String host) {
+ if (!host) {
+ throw new IllegalArgumentException("Host cannot be null or empty")
+ }
+
+ // Normalize host (lowercase, trim)
+ host = host.toLowerCase().trim()
+
+ // Check localhost variations
+ if (LOCALHOST_NAMES.contains(host)) {
+ throw new IllegalArgumentException("Access to localhost is not allowed: ${host}")
+ }
+
+ // Check if the host is a direct IP address (before DNS resolution)
+ if (isIpAddress(host)) {
+ // Direct IP address validation
+ validateIpString(host)
+ }
+
+ // Try to resolve the host to IP address(es)
+ try {
+ def addresses = InetAddress.getAllByName(host)
+ for (InetAddress addr : addresses) {
+ validateIpAddress(addr)
+ }
+ } catch (UnknownHostException e) {
+ // Host doesn't resolve - this is fine, let it fail naturally
+ log.warn "Unable to resolve host: ${host} - ${e.message}"
+ }
+ }
+
+ /**
+ * Check if a string is an IP address
+ */
+ private static boolean isIpAddress(String host) {
+ // Check if it looks like an IPv4 address
+ if (host.matches('^\\d{1,3}(\\.\\d{1,3}){3}$')) {
+ return true
+ }
+ // Check if it contains colons (likely IPv6)
+ if (host.contains(':')) {
+ return true
+ }
+ return false
+ }
+
+ /**
+ * Validates an IP address string
+ */
+ private static void validateIpString(String ip) {
+ // Check if it's in our block lists
+ if (METADATA_IPS.contains(ip)) {
+ throw new IllegalArgumentException("Access to cloud metadata service is not allowed: ${ip}")
+ }
+
+ // Check link-local
+ if (LINK_LOCAL_PATTERN.matcher(ip).find()) {
+ throw new IllegalArgumentException("Access to link-local address is not allowed: ${ip}")
+ }
+
+ // Check private IPs
+ if (PRIVATE_IP_PATTERN.matcher(ip).find()) {
+ throw new IllegalArgumentException("Access to private IP range is not allowed: ${ip}")
+ }
+
+ // Check loopback
+ if (LOOPBACK_PATTERN.matcher(ip).find()) {
+ throw new IllegalArgumentException("Access to loopback address is not allowed: ${ip}")
+ }
+ }
+
+ /**
+ * Validates an IP address to ensure it's not a private or internal address
+ *
+ * @param address The IP address to validate
+ * @throws IllegalArgumentException if the IP address is private or internal
+ */
+ private static void validateIpAddress(InetAddress address) {
+ def ip = address.hostAddress
+
+ // Check metadata service IPs
+ if (METADATA_IPS.contains(ip)) {
+ throw new IllegalArgumentException("Access to cloud metadata service is not allowed: ${ip}")
+ }
+
+ // Check if it's a site-local (private) address
+ if (address.isSiteLocalAddress()) {
+ throw new IllegalArgumentException("Access to private IP address is not allowed: ${ip}")
+ }
+
+ // Check if it's a loopback address
+ if (address.isLoopbackAddress()) {
+ throw new IllegalArgumentException("Access to loopback address is not allowed: ${ip}")
+ }
+
+ // Check if it's a link-local address
+ if (address.isLinkLocalAddress()) {
+ throw new IllegalArgumentException("Access to link-local address is not allowed: ${ip}")
+ }
+
+ // Additional regex-based checks for IPv4
+ if (address instanceof Inet4Address) {
+ // Check private IP ranges
+ if (PRIVATE_IP_PATTERN.matcher(ip).find()) {
+ throw new IllegalArgumentException("Access to private IP range is not allowed: ${ip}")
+ }
+
+ // Check loopback
+ if (LOOPBACK_PATTERN.matcher(ip).find()) {
+ throw new IllegalArgumentException("Access to loopback address is not allowed: ${ip}")
+ }
+
+ // Check link-local
+ if (LINK_LOCAL_PATTERN.matcher(ip).find()) {
+ throw new IllegalArgumentException("Access to link-local address is not allowed: ${ip}")
+ }
+ }
+
+ // Check for IPv6 unique local addresses (fc00::/7)
+ if (address instanceof Inet6Address) {
+ byte[] bytes = address.address
+ // Check if first byte is 0xfc or 0xfd (unique local addresses)
+ if ((bytes[0] & 0xfe) == 0xfc) {
+ throw new IllegalArgumentException("Access to IPv6 unique local address is not allowed: ${ip}")
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml
index e6a8b4cc9..69ff4f59c 100644
--- a/src/main/resources/application-local.yml
+++ b/src/main/resources/application-local.yml
@@ -36,6 +36,9 @@ wave:
threshold: 100
proxy-service:
threshold: 100
+ security:
+ ssrf-protection:
+ enabled: false
---
endpoints:
metrics:
diff --git a/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy b/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy
index 884728787..41ff3faf3 100644
--- a/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy
+++ b/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy
@@ -190,4 +190,76 @@ class ValidateCredsControllerTest extends Specification implements SecureDockerR
'nope' | 'yepes' | "https://quay.io" | false
'test' | 'test' | 'test' | true
}
+
+ void 'should reject SSRF attempts with private IP'() {
+ given:
+ def req = [
+ userName: 'test',
+ password: 'test',
+ registry: '127.0.0.1'
+ ]
+ HttpRequest request = HttpRequest.POST("/v1alpha2/validate-creds", req)
+
+ when:
+ client.toBlocking().exchange(request, Boolean)
+
+ then:
+ def e = thrown(HttpClientResponseException)
+ e.status == HttpStatus.INTERNAL_SERVER_ERROR
+ e.message.contains('loopback')
+ }
+
+ void 'should reject SSRF attempts with localhost'() {
+ given:
+ def req = [
+ userName: 'test',
+ password: 'test',
+ registry: 'localhost'
+ ]
+ HttpRequest request = HttpRequest.POST("/v1alpha2/validate-creds", req)
+
+ when:
+ client.toBlocking().exchange(request, Boolean)
+
+ then:
+ def e = thrown(HttpClientResponseException)
+ e.status == HttpStatus.INTERNAL_SERVER_ERROR
+ e.message.contains('localhost')
+ }
+
+ void 'should reject SSRF attempts with AWS metadata IP'() {
+ given:
+ def req = [
+ userName: 'test',
+ password: 'test',
+ registry: '169.254.169.254'
+ ]
+ HttpRequest request = HttpRequest.POST("/v1alpha2/validate-creds", req)
+
+ when:
+ client.toBlocking().exchange(request, Boolean)
+
+ then:
+ def e = thrown(HttpClientResponseException)
+ e.status == HttpStatus.INTERNAL_SERVER_ERROR
+ e.message.contains('metadata')
+ }
+
+ void 'should reject SSRF attempts with private network IP'() {
+ given:
+ def req = [
+ userName: 'test',
+ password: 'test',
+ registry: '10.0.0.1'
+ ]
+ HttpRequest request = HttpRequest.POST("/v1alpha2/validate-creds", req)
+
+ when:
+ client.toBlocking().exchange(request, Boolean)
+
+ then:
+ def e = thrown(HttpClientResponseException)
+ e.status == HttpStatus.INTERNAL_SERVER_ERROR
+ e.message.contains('private')
+ }
}
diff --git a/src/test/groovy/io/seqera/wave/util/SsrfValidatorTest.groovy b/src/test/groovy/io/seqera/wave/util/SsrfValidatorTest.groovy
new file mode 100644
index 000000000..03ed603b7
--- /dev/null
+++ b/src/test/groovy/io/seqera/wave/util/SsrfValidatorTest.groovy
@@ -0,0 +1,115 @@
+/*
+ * Wave, containers provisioning service
+ * Copyright (c) 2023-2024, Seqera Labs
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package io.seqera.wave.util
+
+import spock.lang.Specification
+import spock.lang.Unroll
+
+/**
+ * Tests for SsrfValidator utility class
+ *
+ * @author Munish Chouhan
+ */
+class SsrfValidatorTest extends Specification {
+
+ @Unroll
+ def 'should reject private IP addresses: #ip'() {
+ when:
+ SsrfValidator.validateHost(ip)
+
+ then:
+ def e = thrown(IllegalArgumentException)
+ e.message.contains('private') || e.message.contains('loopback') || e.message.contains('link-local') || e.message.contains('metadata') || e.message.contains('localhost')
+
+ where:
+ ip << [
+ '10.0.0.1',
+ '10.255.255.255',
+ '172.16.0.1',
+ '172.31.255.255',
+ '192.168.1.1',
+ '192.168.255.255',
+ '127.0.0.1',
+ '127.0.0.2',
+ '169.254.169.254', // AWS metadata service
+ '0.0.0.0'
+ ]
+ }
+
+ @Unroll
+ def 'should reject localhost variations: #host'() {
+ when:
+ SsrfValidator.validateHost(host)
+
+ then:
+ def e = thrown(IllegalArgumentException)
+ e.message.contains('localhost') || e.message.contains('loopback')
+
+ where:
+ host << [
+ 'localhost',
+ 'LOCALHOST',
+ 'localhost.localdomain'
+ ]
+ }
+
+ @Unroll
+ def 'should accept public hostnames: #host'() {
+ when:
+ SsrfValidator.validateHost(host)
+
+ then:
+ noExceptionThrown()
+
+ where:
+ host << [
+ 'docker.io',
+ 'registry-1.docker.io',
+ 'quay.io',
+ 'ghcr.io',
+ 'gcr.io',
+ 'public.ecr.aws',
+ 'example.com',
+ 'github.com'
+ ]
+ }
+
+ def 'should reject null or empty inputs'() {
+ when:
+ SsrfValidator.validateHost(null)
+
+ then:
+ thrown(IllegalArgumentException)
+
+ when:
+ SsrfValidator.validateHost('')
+
+ then:
+ thrown(IllegalArgumentException)
+ }
+
+ def 'should reject cloud metadata service IPs'() {
+ when:
+ SsrfValidator.validateHost('169.254.169.254')
+
+ then:
+ def e = thrown(IllegalArgumentException)
+ e.message.contains('metadata')
+ }
+}
\ No newline at end of file