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',
- ]
- }
-}