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