From 96adf29ad4a5fbe2bf7855156314bbe78f8c62b0 Mon Sep 17 00:00:00 2001 From: munishchouhan Date: Tue, 13 Jan 2026 14:12:23 +0100 Subject: [PATCH 01/11] added SsrfValidator Signed-off-by: munishchouhan --- .../wave/controller/ValidateController.groovy | 9 + .../io/seqera/wave/util/SsrfValidator.groovy | 278 ++++++++++++++++++ .../ValidateCredsControllerTest.groovy | 68 +++++ .../seqera/wave/util/SsrfValidatorTest.groovy | 208 +++++++++++++ 4 files changed, 563 insertions(+) create mode 100644 src/main/groovy/io/seqera/wave/util/SsrfValidator.groovy create mode 100644 src/test/groovy/io/seqera/wave/util/SsrfValidatorTest.groovy diff --git a/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy b/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy index cebc541b5..a19618e54 100644 --- a/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy @@ -24,6 +24,7 @@ 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.util.SsrfValidator import jakarta.inject.Inject import jakarta.validation.Valid @@ -36,11 +37,19 @@ class ValidateController { @Deprecated @Post("/validate-creds") Boolean validateCreds(@Valid ValidateRegistryCredsRequest request){ + // Validate registry to prevent SSRF attacks + if (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 (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..fcc9880a4 --- /dev/null +++ b/src/main/groovy/io/seqera/wave/util/SsrfValidator.groovy @@ -0,0 +1,278 @@ +/* + * 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 URLs and 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 URL to ensure it doesn't target internal/private resources + * + * @param url The URL to validate + * @throws IllegalArgumentException if the URL is potentially malicious + */ + static void validateUrl(String url) { + if (!url) { + throw new IllegalArgumentException("URL cannot be null or empty") + } + + URI uri + try { + uri = new URI(url) + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Invalid URL format: ${url}", e) + } + + validateUri(uri) + } + + /** + * Validates a URI to ensure it doesn't target internal/private resources + * + * @param uri The URI to validate + * @throws IllegalArgumentException if the URI is potentially malicious + */ + static void validateUri(URI uri) { + if (!uri) { + throw new IllegalArgumentException("URI cannot be null") + } + + // Validate scheme - only allow http and https + def scheme = uri.scheme?.toLowerCase() + if (scheme != 'http' && scheme != 'https') { + throw new IllegalArgumentException("URL scheme must be http or https, got: ${scheme}") + } + + def host = uri.host + if (!host) { + throw new IllegalArgumentException("URL must have a valid host") + } + + validateHost(host) + } + + /** + * 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 + */ + 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}") + } + } + } + + /** + * Checks if a URL is safe without throwing an exception + * + * @param url The URL to check + * @return true if the URL is safe, false otherwise + */ + static boolean isSafeUrl(String url) { + try { + validateUrl(url) + return true + } catch (Exception e) { + log.debug "URL validation failed: ${e.message}" + return false + } + } + + /** + * Checks if a host is safe without throwing an exception + * + * @param host The host to check + * @return true if the host is safe, false otherwise + */ + static boolean isSafeHost(String host) { + try { + validateHost(host) + return true + } catch (Exception e) { + log.debug "Host validation failed: ${e.message}" + return false + } + } +} diff --git a/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy b/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy index 884728787..f088efb1c 100644 --- a/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy +++ b/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy @@ -190,4 +190,72 @@ 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 + } + + 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 + } + + 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 + } + + 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 + } } 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..d6459ef28 --- /dev/null +++ b/src/test/groovy/io/seqera/wave/util/SsrfValidatorTest.groovy @@ -0,0 +1,208 @@ +/* + * 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' + ] + } + + @Unroll + def 'should validate URLs with scheme: #url'() { + when: + SsrfValidator.validateUrl(url) + + then: + noExceptionThrown() + + where: + url << [ + 'https://docker.io', + 'https://registry-1.docker.io', + 'https://quay.io/v2/', + 'http://example.com', + 'https://github.com/path/to/resource' + ] + } + + @Unroll + def 'should reject URLs with private IPs: #url'() { + when: + SsrfValidator.validateUrl(url) + + then: + def e = thrown(IllegalArgumentException) + e.message.contains('private') || e.message.contains('loopback') || e.message.contains('localhost') || e.message.contains('metadata') || e.message.contains('link-local') + + where: + url << [ + 'http://127.0.0.1', + 'https://localhost', + 'http://10.0.0.1', + 'http://192.168.1.1', + 'http://172.16.0.1', + 'http://169.254.169.254' // AWS metadata service + ] + } + + @Unroll + def 'should reject invalid URL schemes: #url'() { + when: + SsrfValidator.validateUrl(url) + + then: + def e = thrown(IllegalArgumentException) + e.message.contains('scheme') + + where: + url << [ + 'file:///etc/passwd', + 'ftp://example.com', + 'gopher://example.com', + 'dict://example.com' + ] + } + + def 'should reject null or empty inputs'() { + when: + SsrfValidator.validateUrl(null) + + then: + thrown(IllegalArgumentException) + + when: + SsrfValidator.validateHost(null) + + then: + thrown(IllegalArgumentException) + + when: + SsrfValidator.validateHost('') + + then: + thrown(IllegalArgumentException) + } + + def 'should handle isSafeUrl helper method'() { + expect: + SsrfValidator.isSafeUrl('https://docker.io') == true + SsrfValidator.isSafeUrl('http://127.0.0.1') == false + SsrfValidator.isSafeUrl('http://10.0.0.1') == false + SsrfValidator.isSafeUrl('file:///etc/passwd') == false + } + + def 'should handle isSafeHost helper method'() { + expect: + SsrfValidator.isSafeHost('docker.io') == true + SsrfValidator.isSafeHost('quay.io') == true + SsrfValidator.isSafeHost('localhost') == false + SsrfValidator.isSafeHost('127.0.0.1') == false + SsrfValidator.isSafeHost('10.0.0.1') == false + } + + def 'should reject cloud metadata service IPs'() { + when: + SsrfValidator.validateHost('169.254.169.254') + + then: + def e = thrown(IllegalArgumentException) + e.message.contains('metadata') + } + + def 'should handle URI validation'() { + when: + SsrfValidator.validateUri(new URI('https://docker.io')) + + then: + noExceptionThrown() + + when: + SsrfValidator.validateUri(new URI('http://127.0.0.1')) + + then: + thrown(IllegalArgumentException) + } +} \ No newline at end of file From 2f649a64fcf3dbe84f77f40bf377958e8516165f Mon Sep 17 00:00:00 2001 From: munishchouhan Date: Wed, 14 Jan 2026 09:55:07 +0100 Subject: [PATCH 02/11] removed SsrfValidator Signed-off-by: munishchouhan --- .../wave/controller/ValidateController.groovy | 11 +- .../io/seqera/wave/util/SsrfValidator.groovy | 278 ------------------ .../seqera/wave/util/SsrfValidatorTest.groovy | 208 ------------- 3 files changed, 2 insertions(+), 495 deletions(-) delete mode 100644 src/main/groovy/io/seqera/wave/util/SsrfValidator.groovy delete mode 100644 src/test/groovy/io/seqera/wave/util/SsrfValidatorTest.groovy diff --git a/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy b/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy index a19618e54..76ff7231a 100644 --- a/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy @@ -24,7 +24,6 @@ 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.util.SsrfValidator import jakarta.inject.Inject import jakarta.validation.Valid @@ -37,19 +36,13 @@ class ValidateController { @Deprecated @Post("/validate-creds") Boolean validateCreds(@Valid ValidateRegistryCredsRequest request){ - // Validate registry to prevent SSRF attacks - if (request.registry) { - SsrfValidator.validateHost(request.registry) - } + // SSRF protection is now handled by SsrfProtectionFilter for all HTTP client requests 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 (request.registry) { - SsrfValidator.validateHost(request.registry) - } + // SSRF protection is now handled by SsrfProtectionFilter for all HTTP client requests 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 deleted file mode 100644 index fcc9880a4..000000000 --- a/src/main/groovy/io/seqera/wave/util/SsrfValidator.groovy +++ /dev/null @@ -1,278 +0,0 @@ -/* - * 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 URLs and 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 URL to ensure it doesn't target internal/private resources - * - * @param url The URL to validate - * @throws IllegalArgumentException if the URL is potentially malicious - */ - static void validateUrl(String url) { - if (!url) { - throw new IllegalArgumentException("URL cannot be null or empty") - } - - URI uri - try { - uri = new URI(url) - } catch (URISyntaxException e) { - throw new IllegalArgumentException("Invalid URL format: ${url}", e) - } - - validateUri(uri) - } - - /** - * Validates a URI to ensure it doesn't target internal/private resources - * - * @param uri The URI to validate - * @throws IllegalArgumentException if the URI is potentially malicious - */ - static void validateUri(URI uri) { - if (!uri) { - throw new IllegalArgumentException("URI cannot be null") - } - - // Validate scheme - only allow http and https - def scheme = uri.scheme?.toLowerCase() - if (scheme != 'http' && scheme != 'https') { - throw new IllegalArgumentException("URL scheme must be http or https, got: ${scheme}") - } - - def host = uri.host - if (!host) { - throw new IllegalArgumentException("URL must have a valid host") - } - - validateHost(host) - } - - /** - * 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 - */ - 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}") - } - } - } - - /** - * Checks if a URL is safe without throwing an exception - * - * @param url The URL to check - * @return true if the URL is safe, false otherwise - */ - static boolean isSafeUrl(String url) { - try { - validateUrl(url) - return true - } catch (Exception e) { - log.debug "URL validation failed: ${e.message}" - return false - } - } - - /** - * Checks if a host is safe without throwing an exception - * - * @param host The host to check - * @return true if the host is safe, false otherwise - */ - static boolean isSafeHost(String host) { - try { - validateHost(host) - return true - } catch (Exception e) { - log.debug "Host validation failed: ${e.message}" - return false - } - } -} diff --git a/src/test/groovy/io/seqera/wave/util/SsrfValidatorTest.groovy b/src/test/groovy/io/seqera/wave/util/SsrfValidatorTest.groovy deleted file mode 100644 index d6459ef28..000000000 --- a/src/test/groovy/io/seqera/wave/util/SsrfValidatorTest.groovy +++ /dev/null @@ -1,208 +0,0 @@ -/* - * 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' - ] - } - - @Unroll - def 'should validate URLs with scheme: #url'() { - when: - SsrfValidator.validateUrl(url) - - then: - noExceptionThrown() - - where: - url << [ - 'https://docker.io', - 'https://registry-1.docker.io', - 'https://quay.io/v2/', - 'http://example.com', - 'https://github.com/path/to/resource' - ] - } - - @Unroll - def 'should reject URLs with private IPs: #url'() { - when: - SsrfValidator.validateUrl(url) - - then: - def e = thrown(IllegalArgumentException) - e.message.contains('private') || e.message.contains('loopback') || e.message.contains('localhost') || e.message.contains('metadata') || e.message.contains('link-local') - - where: - url << [ - 'http://127.0.0.1', - 'https://localhost', - 'http://10.0.0.1', - 'http://192.168.1.1', - 'http://172.16.0.1', - 'http://169.254.169.254' // AWS metadata service - ] - } - - @Unroll - def 'should reject invalid URL schemes: #url'() { - when: - SsrfValidator.validateUrl(url) - - then: - def e = thrown(IllegalArgumentException) - e.message.contains('scheme') - - where: - url << [ - 'file:///etc/passwd', - 'ftp://example.com', - 'gopher://example.com', - 'dict://example.com' - ] - } - - def 'should reject null or empty inputs'() { - when: - SsrfValidator.validateUrl(null) - - then: - thrown(IllegalArgumentException) - - when: - SsrfValidator.validateHost(null) - - then: - thrown(IllegalArgumentException) - - when: - SsrfValidator.validateHost('') - - then: - thrown(IllegalArgumentException) - } - - def 'should handle isSafeUrl helper method'() { - expect: - SsrfValidator.isSafeUrl('https://docker.io') == true - SsrfValidator.isSafeUrl('http://127.0.0.1') == false - SsrfValidator.isSafeUrl('http://10.0.0.1') == false - SsrfValidator.isSafeUrl('file:///etc/passwd') == false - } - - def 'should handle isSafeHost helper method'() { - expect: - SsrfValidator.isSafeHost('docker.io') == true - SsrfValidator.isSafeHost('quay.io') == true - SsrfValidator.isSafeHost('localhost') == false - SsrfValidator.isSafeHost('127.0.0.1') == false - SsrfValidator.isSafeHost('10.0.0.1') == false - } - - def 'should reject cloud metadata service IPs'() { - when: - SsrfValidator.validateHost('169.254.169.254') - - then: - def e = thrown(IllegalArgumentException) - e.message.contains('metadata') - } - - def 'should handle URI validation'() { - when: - SsrfValidator.validateUri(new URI('https://docker.io')) - - then: - noExceptionThrown() - - when: - SsrfValidator.validateUri(new URI('http://127.0.0.1')) - - then: - thrown(IllegalArgumentException) - } -} \ No newline at end of file From 7b54fe98b41f6c50fab7eae5e53ba415746289d8 Mon Sep 17 00:00:00 2001 From: munishchouhan Date: Wed, 14 Jan 2026 09:55:24 +0100 Subject: [PATCH 03/11] added SsrfProtectionFilter Signed-off-by: munishchouhan --- .../wave/filter/SsrfProtectionFilter.groovy | 250 +++++++++++++++++ .../filter/SsrfProtectionFilterTest.groovy | 256 ++++++++++++++++++ 2 files changed, 506 insertions(+) create mode 100644 src/main/groovy/io/seqera/wave/filter/SsrfProtectionFilter.groovy create mode 100644 src/test/groovy/io/seqera/wave/filter/SsrfProtectionFilterTest.groovy diff --git a/src/main/groovy/io/seqera/wave/filter/SsrfProtectionFilter.groovy b/src/main/groovy/io/seqera/wave/filter/SsrfProtectionFilter.groovy new file mode 100644 index 000000000..cf7db6e62 --- /dev/null +++ b/src/main/groovy/io/seqera/wave/filter/SsrfProtectionFilter.groovy @@ -0,0 +1,250 @@ +/* + * 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.filter + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import io.micronaut.context.annotation.Requires +import io.micronaut.http.HttpResponse +import io.micronaut.http.MutableHttpRequest +import io.micronaut.http.annotation.ClientFilter +import io.micronaut.http.annotation.RequestFilter +import io.micronaut.http.exceptions.HttpStatusException +import io.micronaut.http.HttpStatus + +import java.util.regex.Pattern + +/** + * HTTP Client Filter to prevent Server-Side Request Forgery (SSRF) attacks + * by validating outgoing HTTP requests before they are executed. + * + * This filter intercepts all HTTP client requests and validates the target + * host to ensure it doesn't point to internal/private resources. + * + * @author Munish Chouhan + */ +@Slf4j +@CompileStatic +@ClientFilter(ClientFilter.MATCH_ALL_PATTERN) +@Requires(property = 'wave.security.ssrf-protection.enabled', value = 'true', defaultValue = 'true') +class SsrfProtectionFilter { + + // 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 + + @RequestFilter + void requestFilter(MutableHttpRequest request) { + def uri = request.getUri() + log.trace "SSRF protection filter validating request to: ${uri}" + + try { + validateUri(uri) + } catch (IllegalArgumentException e) { + log.warn "SSRF protection blocked request to: ${uri} - ${e.message}" + throw new HttpStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } + + /** + * Validates a URI to ensure it doesn't target internal/private resources + * + * @param uri The URI to validate + * @throws IllegalArgumentException if the URI is potentially malicious + */ + protected void validateUri(URI uri) { + if (!uri) { + throw new IllegalArgumentException("URI cannot be null") + } + + // Validate scheme - only allow http and https + def scheme = uri.scheme?.toLowerCase() + if (scheme != 'http' && scheme != 'https') { + throw new IllegalArgumentException("URL scheme must be http or https, got: ${scheme}") + } + + def host = uri.host + if (!host) { + throw new IllegalArgumentException("URL must have a valid host") + } + + validateHost(host) + } + + /** + * 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 + */ + protected 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 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 + */ + protected 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/test/groovy/io/seqera/wave/filter/SsrfProtectionFilterTest.groovy b/src/test/groovy/io/seqera/wave/filter/SsrfProtectionFilterTest.groovy new file mode 100644 index 000000000..a90d28839 --- /dev/null +++ b/src/test/groovy/io/seqera/wave/filter/SsrfProtectionFilterTest.groovy @@ -0,0 +1,256 @@ +/* + * 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.filter + +import io.micronaut.http.HttpRequest +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.http.exceptions.HttpStatusException +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import spock.lang.Specification +import spock.lang.Unroll + +/** + * Tests for SsrfProtectionFilter + * + * @author Munish Chouhan + */ +@MicronautTest +class SsrfProtectionFilterTest extends Specification { + + @Inject + @Client("/") + HttpClient client + + @Unroll + def 'should block private IP addresses: #host'() { + given: + def filter = new SsrfProtectionFilter() + + when: + filter.validateHost(host) + + 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: + host << [ + '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 block localhost variations: #host'() { + given: + def filter = new SsrfProtectionFilter() + + when: + filter.validateHost(host) + + then: + def e = thrown(IllegalArgumentException) + e.message.contains('localhost') || e.message.contains('loopback') + + where: + host << [ + 'localhost', + 'LOCALHOST', + 'localhost.localdomain' + ] + } + + @Unroll + def 'should allow public hostnames: #host'() { + given: + def filter = new SsrfProtectionFilter() + + when: + filter.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' + ] + } + + @Unroll + def 'should validate URIs with public hosts: #url'() { + given: + def filter = new SsrfProtectionFilter() + def uri = new URI(url) + + when: + filter.validateUri(uri) + + then: + noExceptionThrown() + + where: + url << [ + 'https://docker.io', + 'https://registry-1.docker.io', + 'https://quay.io/v2/', + 'http://example.com', + 'https://github.com/path/to/resource' + ] + } + + @Unroll + def 'should block URIs with private IPs: #url'() { + given: + def filter = new SsrfProtectionFilter() + def uri = new URI(url) + + when: + filter.validateUri(uri) + + then: + def e = thrown(IllegalArgumentException) + e.message.contains('private') || e.message.contains('loopback') || e.message.contains('localhost') || e.message.contains('metadata') || e.message.contains('link-local') + + where: + url << [ + 'http://127.0.0.1', + 'https://localhost', + 'http://10.0.0.1', + 'http://192.168.1.1', + 'http://172.16.0.1', + 'http://169.254.169.254' // AWS metadata service + ] + } + + @Unroll + def 'should reject invalid URL schemes: #url'() { + given: + def filter = new SsrfProtectionFilter() + def uri = new URI(url) + + when: + filter.validateUri(uri) + + then: + def e = thrown(IllegalArgumentException) + e.message.contains('scheme') + + where: + url << [ + 'file:///etc/passwd', + 'ftp://example.com', + 'gopher://example.com', + 'dict://example.com' + ] + } + + def 'should handle null inputs'() { + given: + def filter = new SsrfProtectionFilter() + + when: + filter.validateUri(null) + + then: + thrown(IllegalArgumentException) + + when: + filter.validateHost(null) + + then: + thrown(IllegalArgumentException) + + when: + filter.validateHost('') + + then: + thrown(IllegalArgumentException) + } + + def 'should block cloud metadata service IPs'() { + given: + def filter = new SsrfProtectionFilter() + + when: + filter.validateHost('169.254.169.254') + + then: + def e = thrown(IllegalArgumentException) + e.message.contains('metadata') + } + + def 'should validate IP addresses using InetAddress'() { + given: + def filter = new SsrfProtectionFilter() + + when: + filter.validateIpAddress(InetAddress.getByName('8.8.8.8')) + + then: + noExceptionThrown() + + when: + filter.validateIpAddress(InetAddress.getByName('127.0.0.1')) + + then: + thrown(IllegalArgumentException) + } + + def 'filter should throw HttpStatusException for blocked requests'() { + given: + def filter = new SsrfProtectionFilter() + def request = HttpRequest.GET(new URI('http://127.0.0.1')) + + when: + filter.requestFilter(request) + + then: + thrown(HttpStatusException) + } + + def 'filter should allow valid requests'() { + given: + def filter = new SsrfProtectionFilter() + def request = HttpRequest.GET(new URI('https://docker.io')) + + when: + filter.requestFilter(request) + + then: + noExceptionThrown() + } +} \ No newline at end of file From 681e550996128739f667c16d3326eb2d46222ad6 Mon Sep 17 00:00:00 2001 From: munishchouhan Date: Wed, 14 Jan 2026 10:04:41 +0100 Subject: [PATCH 04/11] disable ssrf in application-local.yml Signed-off-by: munishchouhan --- src/main/resources/application-local.yml | 3 +++ 1 file changed, 3 insertions(+) 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: From d1727f12b7c840ab3ea485166c10c7a3013c3fac Mon Sep 17 00:00:00 2001 From: munishchouhan Date: Wed, 14 Jan 2026 13:09:02 +0100 Subject: [PATCH 05/11] removed SsrfProtectionFilter Signed-off-by: munishchouhan --- .../wave/filter/SsrfProtectionFilter.groovy | 250 ----------------- .../filter/SsrfProtectionFilterTest.groovy | 256 ------------------ 2 files changed, 506 deletions(-) delete mode 100644 src/main/groovy/io/seqera/wave/filter/SsrfProtectionFilter.groovy delete mode 100644 src/test/groovy/io/seqera/wave/filter/SsrfProtectionFilterTest.groovy diff --git a/src/main/groovy/io/seqera/wave/filter/SsrfProtectionFilter.groovy b/src/main/groovy/io/seqera/wave/filter/SsrfProtectionFilter.groovy deleted file mode 100644 index cf7db6e62..000000000 --- a/src/main/groovy/io/seqera/wave/filter/SsrfProtectionFilter.groovy +++ /dev/null @@ -1,250 +0,0 @@ -/* - * 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.filter - -import groovy.transform.CompileStatic -import groovy.util.logging.Slf4j -import io.micronaut.context.annotation.Requires -import io.micronaut.http.HttpResponse -import io.micronaut.http.MutableHttpRequest -import io.micronaut.http.annotation.ClientFilter -import io.micronaut.http.annotation.RequestFilter -import io.micronaut.http.exceptions.HttpStatusException -import io.micronaut.http.HttpStatus - -import java.util.regex.Pattern - -/** - * HTTP Client Filter to prevent Server-Side Request Forgery (SSRF) attacks - * by validating outgoing HTTP requests before they are executed. - * - * This filter intercepts all HTTP client requests and validates the target - * host to ensure it doesn't point to internal/private resources. - * - * @author Munish Chouhan - */ -@Slf4j -@CompileStatic -@ClientFilter(ClientFilter.MATCH_ALL_PATTERN) -@Requires(property = 'wave.security.ssrf-protection.enabled', value = 'true', defaultValue = 'true') -class SsrfProtectionFilter { - - // 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 - - @RequestFilter - void requestFilter(MutableHttpRequest request) { - def uri = request.getUri() - log.trace "SSRF protection filter validating request to: ${uri}" - - try { - validateUri(uri) - } catch (IllegalArgumentException e) { - log.warn "SSRF protection blocked request to: ${uri} - ${e.message}" - throw new HttpStatusException(HttpStatus.BAD_REQUEST, e.message) - } - } - - /** - * Validates a URI to ensure it doesn't target internal/private resources - * - * @param uri The URI to validate - * @throws IllegalArgumentException if the URI is potentially malicious - */ - protected void validateUri(URI uri) { - if (!uri) { - throw new IllegalArgumentException("URI cannot be null") - } - - // Validate scheme - only allow http and https - def scheme = uri.scheme?.toLowerCase() - if (scheme != 'http' && scheme != 'https') { - throw new IllegalArgumentException("URL scheme must be http or https, got: ${scheme}") - } - - def host = uri.host - if (!host) { - throw new IllegalArgumentException("URL must have a valid host") - } - - validateHost(host) - } - - /** - * 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 - */ - protected 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 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 - */ - protected 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/test/groovy/io/seqera/wave/filter/SsrfProtectionFilterTest.groovy b/src/test/groovy/io/seqera/wave/filter/SsrfProtectionFilterTest.groovy deleted file mode 100644 index a90d28839..000000000 --- a/src/test/groovy/io/seqera/wave/filter/SsrfProtectionFilterTest.groovy +++ /dev/null @@ -1,256 +0,0 @@ -/* - * 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.filter - -import io.micronaut.http.HttpRequest -import io.micronaut.http.client.HttpClient -import io.micronaut.http.client.annotation.Client -import io.micronaut.http.exceptions.HttpStatusException -import io.micronaut.test.extensions.spock.annotation.MicronautTest -import jakarta.inject.Inject -import spock.lang.Specification -import spock.lang.Unroll - -/** - * Tests for SsrfProtectionFilter - * - * @author Munish Chouhan - */ -@MicronautTest -class SsrfProtectionFilterTest extends Specification { - - @Inject - @Client("/") - HttpClient client - - @Unroll - def 'should block private IP addresses: #host'() { - given: - def filter = new SsrfProtectionFilter() - - when: - filter.validateHost(host) - - 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: - host << [ - '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 block localhost variations: #host'() { - given: - def filter = new SsrfProtectionFilter() - - when: - filter.validateHost(host) - - then: - def e = thrown(IllegalArgumentException) - e.message.contains('localhost') || e.message.contains('loopback') - - where: - host << [ - 'localhost', - 'LOCALHOST', - 'localhost.localdomain' - ] - } - - @Unroll - def 'should allow public hostnames: #host'() { - given: - def filter = new SsrfProtectionFilter() - - when: - filter.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' - ] - } - - @Unroll - def 'should validate URIs with public hosts: #url'() { - given: - def filter = new SsrfProtectionFilter() - def uri = new URI(url) - - when: - filter.validateUri(uri) - - then: - noExceptionThrown() - - where: - url << [ - 'https://docker.io', - 'https://registry-1.docker.io', - 'https://quay.io/v2/', - 'http://example.com', - 'https://github.com/path/to/resource' - ] - } - - @Unroll - def 'should block URIs with private IPs: #url'() { - given: - def filter = new SsrfProtectionFilter() - def uri = new URI(url) - - when: - filter.validateUri(uri) - - then: - def e = thrown(IllegalArgumentException) - e.message.contains('private') || e.message.contains('loopback') || e.message.contains('localhost') || e.message.contains('metadata') || e.message.contains('link-local') - - where: - url << [ - 'http://127.0.0.1', - 'https://localhost', - 'http://10.0.0.1', - 'http://192.168.1.1', - 'http://172.16.0.1', - 'http://169.254.169.254' // AWS metadata service - ] - } - - @Unroll - def 'should reject invalid URL schemes: #url'() { - given: - def filter = new SsrfProtectionFilter() - def uri = new URI(url) - - when: - filter.validateUri(uri) - - then: - def e = thrown(IllegalArgumentException) - e.message.contains('scheme') - - where: - url << [ - 'file:///etc/passwd', - 'ftp://example.com', - 'gopher://example.com', - 'dict://example.com' - ] - } - - def 'should handle null inputs'() { - given: - def filter = new SsrfProtectionFilter() - - when: - filter.validateUri(null) - - then: - thrown(IllegalArgumentException) - - when: - filter.validateHost(null) - - then: - thrown(IllegalArgumentException) - - when: - filter.validateHost('') - - then: - thrown(IllegalArgumentException) - } - - def 'should block cloud metadata service IPs'() { - given: - def filter = new SsrfProtectionFilter() - - when: - filter.validateHost('169.254.169.254') - - then: - def e = thrown(IllegalArgumentException) - e.message.contains('metadata') - } - - def 'should validate IP addresses using InetAddress'() { - given: - def filter = new SsrfProtectionFilter() - - when: - filter.validateIpAddress(InetAddress.getByName('8.8.8.8')) - - then: - noExceptionThrown() - - when: - filter.validateIpAddress(InetAddress.getByName('127.0.0.1')) - - then: - thrown(IllegalArgumentException) - } - - def 'filter should throw HttpStatusException for blocked requests'() { - given: - def filter = new SsrfProtectionFilter() - def request = HttpRequest.GET(new URI('http://127.0.0.1')) - - when: - filter.requestFilter(request) - - then: - thrown(HttpStatusException) - } - - def 'filter should allow valid requests'() { - given: - def filter = new SsrfProtectionFilter() - def request = HttpRequest.GET(new URI('https://docker.io')) - - when: - filter.requestFilter(request) - - then: - noExceptionThrown() - } -} \ No newline at end of file From 0a5a4ad19ecca7eae2fe8d6a591499926eb095aa Mon Sep 17 00:00:00 2001 From: munishchouhan Date: Wed, 14 Jan 2026 13:09:29 +0100 Subject: [PATCH 06/11] added SsrfValidator Signed-off-by: munishchouhan --- .../wave/controller/ValidateController.groovy | 11 +- .../io/seqera/wave/util/SsrfValidator.groovy | 200 ++++++++++++++++++ .../seqera/wave/util/SsrfValidatorTest.groovy | 115 ++++++++++ 3 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 src/main/groovy/io/seqera/wave/util/SsrfValidator.groovy create mode 100644 src/test/groovy/io/seqera/wave/util/SsrfValidatorTest.groovy diff --git a/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy b/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy index 76ff7231a..a19618e54 100644 --- a/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy @@ -24,6 +24,7 @@ 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.util.SsrfValidator import jakarta.inject.Inject import jakarta.validation.Valid @@ -36,13 +37,19 @@ class ValidateController { @Deprecated @Post("/validate-creds") Boolean validateCreds(@Valid ValidateRegistryCredsRequest request){ - // SSRF protection is now handled by SsrfProtectionFilter for all HTTP client requests + // Validate registry to prevent SSRF attacks + if (request.registry) { + SsrfValidator.validateHost(request.registry) + } loginService.validateUser(request.registry, request.userName, request.password) } @Post("/v1alpha2/validate-creds") Boolean validateCredsV2(@Valid @Body ValidateRegistryCredsRequest request){ - // SSRF protection is now handled by SsrfProtectionFilter for all HTTP client requests + // Validate registry to prevent SSRF attacks + if (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/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 From 22352b353fc08ee55e819173962e2a791c2ea359 Mon Sep 17 00:00:00 2001 From: munishchouhan Date: Wed, 14 Jan 2026 13:22:14 +0100 Subject: [PATCH 07/11] added option to disable SsrfValidator Signed-off-by: munishchouhan --- .../wave/controller/ValidateController.groovy | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy b/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy index a19618e54..61b3503ec 100644 --- a/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy @@ -18,6 +18,8 @@ package io.seqera.wave.controller +import groovy.util.logging.Slf4j +import io.micronaut.context.annotation.Value import io.micronaut.http.annotation.Body import io.micronaut.http.annotation.Controller import io.micronaut.http.annotation.Post @@ -28,18 +30,25 @@ import io.seqera.wave.util.SsrfValidator import jakarta.inject.Inject import jakarta.validation.Valid +@Slf4j @Controller("/") @ExecuteOn(TaskExecutors.BLOCKING) class ValidateController { @Inject RegistryAuthService loginService + @Value('${wave.security.ssrf-protection.enabled:true}') + Boolean ssrfProtectionEnabled + @Deprecated @Post("/validate-creds") Boolean validateCreds(@Valid ValidateRegistryCredsRequest request){ // Validate registry to prevent SSRF attacks - if (request.registry) { + if (ssrfProtectionEnabled && request.registry) { + log.debug "SSRF protection enabled, validating registry: ${request.registry}" SsrfValidator.validateHost(request.registry) + } else if (!ssrfProtectionEnabled) { + log.warn "SSRF protection is DISABLED - allowing registry: ${request.registry}" } loginService.validateUser(request.registry, request.userName, request.password) } @@ -47,8 +56,11 @@ class ValidateController { @Post("/v1alpha2/validate-creds") Boolean validateCredsV2(@Valid @Body ValidateRegistryCredsRequest request){ // Validate registry to prevent SSRF attacks - if (request.registry) { + if (ssrfProtectionEnabled && request.registry) { + log.debug "SSRF protection enabled, validating registry: ${request.registry}" SsrfValidator.validateHost(request.registry) + } else if (!ssrfProtectionEnabled) { + log.warn "SSRF protection is DISABLED - allowing registry: ${request.registry}" } loginService.validateUser(request.registry, request.userName, request.password) } From b4122d87a3a1410002d0af88670f04bbe5e644be Mon Sep 17 00:00:00 2001 From: munishchouhan Date: Thu, 15 Jan 2026 08:53:07 +0100 Subject: [PATCH 08/11] fixed tests Signed-off-by: munishchouhan --- .../controller/ValidateCredsControllerTest.groovy | 11 ++++++++++- src/test/resources/application-test.yml | 3 +++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy b/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy index f088efb1c..6d29f8c65 100644 --- a/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy +++ b/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy @@ -22,6 +22,7 @@ import spock.lang.Shared import spock.lang.Specification import io.micronaut.context.ApplicationContext +import io.micronaut.context.annotation.Property import io.micronaut.context.annotation.Value import io.micronaut.http.HttpRequest import io.micronaut.http.HttpResponse @@ -33,7 +34,7 @@ import io.micronaut.test.extensions.spock.annotation.MicronautTest import io.seqera.wave.test.SecureDockerRegistryContainer import jakarta.inject.Inject -@MicronautTest +@MicronautTest(environments = ['test']) class ValidateCredsControllerTest extends Specification implements SecureDockerRegistryContainer { @Inject @@ -191,6 +192,7 @@ class ValidateCredsControllerTest extends Specification implements SecureDockerR 'test' | 'test' | 'test' | true } + @Property(name = "wave.security.ssrf-protection.enabled", value = "true") void 'should reject SSRF attempts with private IP'() { given: def req = [ @@ -206,8 +208,10 @@ class ValidateCredsControllerTest extends Specification implements SecureDockerR then: def e = thrown(HttpClientResponseException) e.status == HttpStatus.INTERNAL_SERVER_ERROR + e.message.contains('loopback') } + @Property(name = "wave.security.ssrf-protection.enabled", value = "true") void 'should reject SSRF attempts with localhost'() { given: def req = [ @@ -223,8 +227,10 @@ class ValidateCredsControllerTest extends Specification implements SecureDockerR then: def e = thrown(HttpClientResponseException) e.status == HttpStatus.INTERNAL_SERVER_ERROR + e.message.contains('localhost') } + @Property(name = "wave.security.ssrf-protection.enabled", value = "true") void 'should reject SSRF attempts with AWS metadata IP'() { given: def req = [ @@ -240,8 +246,10 @@ class ValidateCredsControllerTest extends Specification implements SecureDockerR then: def e = thrown(HttpClientResponseException) e.status == HttpStatus.INTERNAL_SERVER_ERROR + e.message.contains('metadata') } + @Property(name = "wave.security.ssrf-protection.enabled", value = "true") void 'should reject SSRF attempts with private network IP'() { given: def req = [ @@ -257,5 +265,6 @@ class ValidateCredsControllerTest extends Specification implements SecureDockerR then: def e = thrown(HttpClientResponseException) e.status == HttpStatus.INTERNAL_SERVER_ERROR + e.message.contains('private') } } diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 23b1020d1..a87760ad0 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -21,6 +21,9 @@ datasources: schema-generate: CREATE_DROP --- wave: + security: + ssrf-protection: + enabled: false accounts: foo: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" bar: "486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7" From 60343ee3242e24ece34fc91d01f8f08548d62090 Mon Sep 17 00:00:00 2001 From: munishchouhan Date: Thu, 15 Jan 2026 09:29:24 +0100 Subject: [PATCH 09/11] fixed tests Signed-off-by: munishchouhan --- .../io/seqera/wave/controller/ValidateController.groovy | 2 ++ .../wave/controller/ValidateCredsControllerTest.groovy | 6 +----- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy b/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy index 61b3503ec..72047be72 100644 --- a/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy @@ -18,6 +18,7 @@ package io.seqera.wave.controller +import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import io.micronaut.context.annotation.Value import io.micronaut.http.annotation.Body @@ -31,6 +32,7 @@ import jakarta.inject.Inject import jakarta.validation.Valid @Slf4j +@CompileStatic @Controller("/") @ExecuteOn(TaskExecutors.BLOCKING) class ValidateController { diff --git a/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy b/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy index 6d29f8c65..d0a52282d 100644 --- a/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy +++ b/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy @@ -34,7 +34,7 @@ import io.micronaut.test.extensions.spock.annotation.MicronautTest import io.seqera.wave.test.SecureDockerRegistryContainer import jakarta.inject.Inject -@MicronautTest(environments = ['test']) +@MicronautTest class ValidateCredsControllerTest extends Specification implements SecureDockerRegistryContainer { @Inject @@ -192,7 +192,6 @@ class ValidateCredsControllerTest extends Specification implements SecureDockerR 'test' | 'test' | 'test' | true } - @Property(name = "wave.security.ssrf-protection.enabled", value = "true") void 'should reject SSRF attempts with private IP'() { given: def req = [ @@ -211,7 +210,6 @@ class ValidateCredsControllerTest extends Specification implements SecureDockerR e.message.contains('loopback') } - @Property(name = "wave.security.ssrf-protection.enabled", value = "true") void 'should reject SSRF attempts with localhost'() { given: def req = [ @@ -230,7 +228,6 @@ class ValidateCredsControllerTest extends Specification implements SecureDockerR e.message.contains('localhost') } - @Property(name = "wave.security.ssrf-protection.enabled", value = "true") void 'should reject SSRF attempts with AWS metadata IP'() { given: def req = [ @@ -249,7 +246,6 @@ class ValidateCredsControllerTest extends Specification implements SecureDockerR e.message.contains('metadata') } - @Property(name = "wave.security.ssrf-protection.enabled", value = "true") void 'should reject SSRF attempts with private network IP'() { given: def req = [ From fec101bc83d3047f4bea1c62fdbc5214ae82b3a5 Mon Sep 17 00:00:00 2001 From: munishchouhan Date: Thu, 15 Jan 2026 09:52:19 +0100 Subject: [PATCH 10/11] added SSRFConfig Signed-off-by: munishchouhan --- .../wave/configuration/SSRFConfig.groovy | 38 +++++++++++++++++++ .../wave/controller/ValidateController.groovy | 15 +++----- .../ValidateCredsControllerTest.groovy | 1 - 3 files changed, 43 insertions(+), 11 deletions(-) create mode 100644 src/main/groovy/io/seqera/wave/configuration/SSRFConfig.groovy 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 72047be72..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 @@ -20,13 +20,13 @@ package io.seqera.wave.controller import groovy.transform.CompileStatic import groovy.util.logging.Slf4j -import io.micronaut.context.annotation.Value 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 @@ -39,18 +39,15 @@ class ValidateController { @Inject RegistryAuthService loginService - @Value('${wave.security.ssrf-protection.enabled:true}') - Boolean ssrfProtectionEnabled + @Inject SSRFConfig ssrfConfig @Deprecated @Post("/validate-creds") Boolean validateCreds(@Valid ValidateRegistryCredsRequest request){ // Validate registry to prevent SSRF attacks - if (ssrfProtectionEnabled && request.registry) { + if (ssrfConfig.ssrfProtectionEnabled && request.registry) { log.debug "SSRF protection enabled, validating registry: ${request.registry}" SsrfValidator.validateHost(request.registry) - } else if (!ssrfProtectionEnabled) { - log.warn "SSRF protection is DISABLED - allowing registry: ${request.registry}" } loginService.validateUser(request.registry, request.userName, request.password) } @@ -58,11 +55,9 @@ class ValidateController { @Post("/v1alpha2/validate-creds") Boolean validateCredsV2(@Valid @Body ValidateRegistryCredsRequest request){ // Validate registry to prevent SSRF attacks - if (ssrfProtectionEnabled && request.registry) { + if (ssrfConfig.ssrfProtectionEnabled && request.registry) { log.debug "SSRF protection enabled, validating registry: ${request.registry}" SsrfValidator.validateHost(request.registry) - } else if (!ssrfProtectionEnabled) { - log.warn "SSRF protection is DISABLED - allowing registry: ${request.registry}" } loginService.validateUser(request.registry, request.userName, request.password) } diff --git a/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy b/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy index d0a52282d..41ff3faf3 100644 --- a/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy +++ b/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy @@ -22,7 +22,6 @@ import spock.lang.Shared import spock.lang.Specification import io.micronaut.context.ApplicationContext -import io.micronaut.context.annotation.Property import io.micronaut.context.annotation.Value import io.micronaut.http.HttpRequest import io.micronaut.http.HttpResponse From ca7e70c9fa5c62e3937fdc89e5eb16ed6fb676ec Mon Sep 17 00:00:00 2001 From: munishchouhan Date: Thu, 15 Jan 2026 10:33:01 +0100 Subject: [PATCH 11/11] removed SSRFConfig from test Signed-off-by: munishchouhan --- src/test/resources/application-test.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index a87760ad0..23b1020d1 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -21,9 +21,6 @@ datasources: schema-generate: CREATE_DROP --- wave: - security: - ssrf-protection: - enabled: false accounts: foo: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" bar: "486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7"