diff --git a/build.gradle b/build.gradle index edb92c31a..8f66c0762 100644 --- a/build.gradle +++ b/build.gradle @@ -41,6 +41,7 @@ dependencies { implementation 'io.seqera:lib-retry:2.0.0' implementation 'io.seqera:lib-random:1.0.0' implementation 'io.seqera:lib-util-time:1.0.0' + implementation 'io.seqera:lib-util-net:0.1.0' implementation 'io.seqera:lib-activator:1.0.0' implementation project(':wave-api') implementation project(':wave-utils') diff --git a/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy b/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy index 97da755d0..939d34384 100644 --- a/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy @@ -24,9 +24,11 @@ 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.util.net.SsrfValidationException +import io.seqera.util.net.SsrfValidator import io.seqera.wave.auth.RegistryAuthService import io.seqera.wave.configuration.SsrfConfig -import io.seqera.wave.util.SsrfValidator +import io.seqera.wave.exception.BadRequestException import jakarta.inject.Inject import jakarta.validation.Valid @@ -54,7 +56,12 @@ class ValidateController { private void validateRegistry(String registry) { if (ssrfConfig.ssrfProtectionEnabled) { - SsrfValidator.validateHost(registry) + try { + SsrfValidator.validateHost(registry) + } + catch (SsrfValidationException e) { + throw new BadRequestException(e.message) + } } } 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 ccd2f8f61..000000000 --- a/src/main/groovy/io/seqera/wave/util/SsrfValidator.groovy +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Wave, containers provisioning service - * 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 - * 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 io.seqera.wave.exception.BadRequestException - -/** - * Utility class to prevent Server-Side Request Forgery (SSRF) attacks - * by validating hostnames before making HTTP requests. - * - * @author Munish Chouhan - */ -@Slf4j -@CompileStatic -class SsrfValidator { - - // Cloud metadata service IPs - private static final Set METADATA_IPS = [ - '169.254.169.254', // AWS metadata service- https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instancedata-data-retrieval.html - '169.254.170.2', // AWS ECS metadata service - https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html - 'fd00:ec2::254' // AWS IMDSv2 IPv6 - https://aws.amazon.com/blogs/aws/amazon-ec2-instance-metadata-service-imdsv2-by-default/ - ] as Set - - // Localhost variations that should be rejected before DNS resolution - private static final Set LOCALHOST_NAMES = [ - 'localhost', - 'localhost.localdomain', - '0.0.0.0', - '0000:0000:0000:0000:0000:0000:0000:0001', - '::1' // https://datatracker.ietf.org/doc/html/rfc4291#section-2.5.3 - ] as Set - - /** - * Validates a hostname to ensure it doesn't resolve to internal/private resources - * - * @param host The hostname to validate - * @throws BadRequestException if the hostname is potentially malicious - */ - static void validateHost(String host) { - if (!host) { - throw new BadRequestException("Host cannot be null or empty") - } - - // Normalize host (lowercase, trim) - host = host.toLowerCase().trim() - - // Extract hostname from URL if scheme is present - host = extractHostname(host) - - // Check localhost variations - if (LOCALHOST_NAMES.contains(host)) { - throw new BadRequestException("Access to localhost is not allowed: ${host}") - } - - // Resolve the host to IP address(es) and validate each - try { - def addresses = InetAddress.getAllByName(host) - for (InetAddress addr : addresses) { - validateIpAddress(addr) - } - } catch (UnknownHostException e) { - // Fail closed - reject hosts that cannot be resolved - throw new BadRequestException("Unable to resolve host: ${host}") - } - } - - /** - * Extracts the hostname from a registry string - */ - private static String extractHostname(String host) { - if (host.startsWith('http://') || host.startsWith('https://')) { - try { - return new URI(host).getHost() - } catch (URISyntaxException ignored) { - return host - } - } - // Handle bracketed IPv6 with optional port: [::1]:8080 - if (host.startsWith('[')) { - int closeBracket = host.indexOf(']') - if (closeBracket > 0) { - return host.substring(1, closeBracket) - } - } - // Strip port from bare host:port (e.g. 192.168.1.1:5000) - // Only when there is exactly one colon (not IPv6 which has multiple) - int colonIdx = host.lastIndexOf(':') - if (colonIdx > 0 && host.indexOf(':') == colonIdx) { - return host.substring(0, colonIdx) - } - return host - } - - /** - * Validates an InetAddress to ensure it's not a private or internal address - */ - private static void validateIpAddress(InetAddress address) { - def ip = address.hostAddress - - // Check metadata service IPs first (before link-local, for a specific error message) - if (METADATA_IPS.contains(ip)) { - log.debug("SSRF validation rejected cloud metadata service IP: ${ip}") - throw new BadRequestException("Invalid registry hostname") - } - - if (address.isLoopbackAddress()) { - log.debug("SSRF validation rejected loopback address: ${ip}") - throw new BadRequestException("Invalid registry hostname") - } - - if (address.isLinkLocalAddress()) { - log.debug("SSRF validation rejected link-local address: ${ip}") - throw new BadRequestException("Invalid registry hostname") - } - - if (address.isSiteLocalAddress()) { - log.debug("SSRF validation rejected private IP address: ${ip}") - throw new BadRequestException("Invalid registry hostname") - } - - // Check for IPv6 unique local addresses (fc00::/7) - if (address instanceof Inet6Address) { - byte[] bytes = address.address - if ((bytes[0] & 0xfe) == 0xfc) { - log.debug("SSRF validation rejected IPv6 unique local address: ${ip}") - throw new BadRequestException("Invalid registry hostname") - } - } - } -} 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 ca383bc49..000000000 --- a/src/test/groovy/io/seqera/wave/util/SsrfValidatorTest.groovy +++ /dev/null @@ -1,185 +0,0 @@ -/* - * Wave, containers provisioning service - * 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 - * 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 io.seqera.wave.exception.BadRequestException -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(BadRequestException) - e.message.contains('Invalid registry hostname') || 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(BadRequestException) - e.message.contains('localhost') || e.message.contains('Invalid registry hostname') - - 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(BadRequestException) - - when: - SsrfValidator.validateHost('') - - then: - thrown(BadRequestException) - } - - def 'should reject cloud metadata service IPs'() { - when: - SsrfValidator.validateHost('169.254.169.254') - - then: - def e = thrown(BadRequestException) - e.message.contains('Invalid registry hostname') - } - - @Unroll - def 'should strip port and reject private host:port inputs: #hostPort'() { - when: - SsrfValidator.validateHost(hostPort) - - then: - thrown(BadRequestException) - - where: - hostPort << [ - '192.168.1.1:5000', - '10.0.0.1:8080', - '127.0.0.1:5000', - ] - } - - @Unroll - def 'should reject IPv6 loopback and private addresses: #host'() { - when: - SsrfValidator.validateHost(host) - - then: - thrown(BadRequestException) - - where: - host << [ - '::1', - '0000:0000:0000:0000:0000:0000:0000:0001', - '[::1]:5000', - ] - } - - @Unroll - def 'should extract hostname from URL and accept public registries: #url'() { - when: - SsrfValidator.validateHost(url) - - then: - noExceptionThrown() - - where: - url << [ - 'https://registry-1.docker.io', - 'https://quay.io', - 'https://ghcr.io', - 'http://registry-1.docker.io', - 'https://gcr.io/some/path', - ] - } - - @Unroll - def 'should extract hostname from URL and reject private hosts: #url'() { - when: - SsrfValidator.validateHost(url) - - then: - thrown(BadRequestException) - - where: - url << [ - 'http://localhost:8080', - 'https://localhost/v2', - 'http://127.0.0.1:5000', - 'http://10.0.0.1:8080', - 'http://192.168.1.1:5000', - 'https://169.254.169.254/latest/meta-data', - ] - } -}