From 7e0eef83c71d0e4b635cbfdc47b86438971d405c Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Sun, 6 Sep 2026 18:08:57 +0200 Subject: [PATCH 1/9] [release] Add ProxyConfig egress-proxy resolver to lib-util-net (0.2.0) Add io.seqera.util.net.ProxyConfig: resolve an HTTP/HTTPS forward (egress) proxy - including an authenticating one - from a proxy URI or from the HTTP_PROXY/HTTPS_PROXY/NO_PROXY environment variables, exposed as a java.net ProxySelector and a proxy-scoped Authenticator suitable for a java.net.http.HttpClient. Parsing, credential-decoding, no-proxy and Basic-over-CONNECT semantics mirror Nextflow's nextflow.util.ProxyConfig (the source of truth), so products sharing this class share the same proxy behaviour. Depends only on the JDK plus the slf4j-api logging facade; the caller passes the URI or environment map in. This lets Wave (and, subsequently, Nextflow) drop their own duplicated resolver implementations. Co-Authored-By: Claude Opus 4.8 --- lib-util-net/README.md | 32 ++ lib-util-net/VERSION | 2 +- lib-util-net/changelog.txt | 3 + .../java/io/seqera/util/net/ProxyConfig.java | 372 ++++++++++++++++++ .../io/seqera/util/net/ProxyConfigTest.groovy | 239 +++++++++++ 5 files changed, 647 insertions(+), 1 deletion(-) create mode 100644 lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java create mode 100644 lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy diff --git a/lib-util-net/README.md b/lib-util-net/README.md index 575480fd..d8c073bc 100644 --- a/lib-util-net/README.md +++ b/lib-util-net/README.md @@ -42,3 +42,35 @@ when the host: Validation resolves DNS at call time; a caller that later opens a connection resolves DNS again, leaving a TOCTOU / DNS-rebinding window. Pin the resolved address if that gap matters for your use case. + +### Egress proxy configuration + +`io.seqera.util.net.ProxyConfig` resolves an HTTP/HTTPS forward (egress) proxy — +including an authenticating one — from a proxy URI or from the +`HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` environment variables, and exposes it as a +`java.net.ProxySelector` and a proxy-scoped `java.net.Authenticator` for a +`java.net.http.HttpClient`. Parsing, no-proxy and Basic-over-`CONNECT` semantics +mirror Nextflow's `nextflow.util.ProxyConfig`. + +```java +import io.seqera.util.net.ProxyConfig; +import java.net.http.HttpClient; + +// from an explicit URI (applied to both http and https), or from the environment +ProxyConfig proxy = ProxyConfig.fromUri("http://user:pass@proxy.example.com:3128"); +// ProxyConfig proxy = ProxyConfig.fromEnvironment(System.getenv()); + +if (proxy != null) { + var builder = HttpClient.newBuilder().proxy(proxy.toProxySelector()); + var auth = proxy.toAuthenticator(); // null when no credentials + if (auth != null) builder.authenticator(auth); + // for authenticating proxies on https targets, clear the JDK's default block + // on Basic-over-CONNECT (no-op when the operator already set the property): + if (proxy.hasCredentials()) ProxyConfig.enableBasicProxyTunneling(); + HttpClient client = builder.build(); +} +``` + +`NO_PROXY` entries match host names or domain suffixes (optionally prefixed with +`.` or `*.`); `*` disables proxying entirely, and loopback targets always bypass +the proxy. CIDR notation is not supported. diff --git a/lib-util-net/VERSION b/lib-util-net/VERSION index 6e8bf73a..341cf11f 100644 --- a/lib-util-net/VERSION +++ b/lib-util-net/VERSION @@ -1 +1 @@ -0.1.0 +0.2.0 \ No newline at end of file diff --git a/lib-util-net/changelog.txt b/lib-util-net/changelog.txt index a5f8684e..88df33fe 100644 --- a/lib-util-net/changelog.txt +++ b/lib-util-net/changelog.txt @@ -1,4 +1,7 @@ # lib-util-net changelog +0.2.0 +- Add ProxyConfig: resolve an HTTP/HTTPS forward (egress) proxy - including an authenticating one - from a proxy URI or the HTTP_PROXY/HTTPS_PROXY/NO_PROXY environment variables, exposed as a java.net ProxySelector and proxy-scoped Authenticator (parsing and no-proxy semantics mirror Nextflow's nextflow.util.ProxyConfig) + 0.1.0 - Initial release with SsrfValidator providing SSRF host validation (rejects localhost, loopback, link-local, private and cloud-metadata addresses) diff --git a/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java b/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java new file mode 100644 index 00000000..099b0d1b --- /dev/null +++ b/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java @@ -0,0 +1,372 @@ +/* + * Copyright 2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package io.seqera.util.net; + +import java.io.IOException; +import java.net.Authenticator; +import java.net.InetSocketAddress; +import java.net.MalformedURLException; +import java.net.PasswordAuthentication; +import java.net.Proxy; +import java.net.ProxySelector; +import java.net.SocketAddress; +import java.net.URI; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Immutable HTTP/HTTPS forward (egress) proxy configuration resolved from a proxy URI or from the + * {@code HTTP_PROXY}/{@code HTTPS_PROXY}/{@code NO_PROXY} environment variables, exposed as a + * {@link ProxySelector} and a proxy-scoped {@link Authenticator} suitable for a + * {@link java.net.http.HttpClient}. + * + *

Parsing, credential-decoding, no-proxy and Basic-over-CONNECT semantics mirror Nextflow's + * {@code nextflow.util.ProxyConfig} (the source of truth), so products sharing this class share the + * same proxy behaviour. This class depends only on the JDK (and the slf4j-api logging facade); it + * never reads {@link System#getenv()} on its own — the caller passes the URI or environment map in. + * + *

{@code NO_PROXY} entries are matched as host names or domain suffixes (optionally prefixed with + * {@code .} or {@code *.}); the single entry {@code *} disables proxying entirely, and loopback + * targets always bypass the proxy. CIDR notation is not supported. + * + * @author Paolo Di Tommaso + */ +public final class ProxyConfig { + + private static final Logger log = LoggerFactory.getLogger(ProxyConfig.class); + + /** A single proxy endpoint with optional Basic credentials. */ + private record Endpoint(String host, int port, String username, String password) { + boolean hasCredentials() { + return username != null && !username.isEmpty(); + } + InetSocketAddress address() { + return InetSocketAddress.createUnresolved(host, port); + } + } + + private final Endpoint httpProxy; // nullable + private final Endpoint httpsProxy; // nullable + private final List noProxyHosts; + + private ProxyConfig(Endpoint httpProxy, Endpoint httpsProxy, List noProxyHosts) { + this.httpProxy = httpProxy; + this.httpsProxy = httpsProxy; + this.noProxyHosts = normalizeNoProxy(noProxyHosts); + } + + // ------------------------------------------------------------------ factories + + /** + * Resolve a proxy applied to both http and https destinations from a single URI. + * + * @param uri A proxy URI e.g. {@code http://user:pass@proxy:3128}, or {@code null}/empty for none + * @return The corresponding {@link ProxyConfig}, or {@code null} when {@code uri} is empty + */ + public static ProxyConfig fromUri(String uri) { + return fromUri(uri, null, null, null); + } + + /** + * Resolve a proxy applied to both http and https destinations from a single URI. When an explicit + * {@code username} is provided it (with {@code password}) takes precedence over any credentials + * embedded in the URI. + * + * @param uri A proxy URI e.g. {@code http://user:pass@proxy:3128}, or {@code null}/empty for none + * @param username Proxy username overriding the URI user-info; may be {@code null} + * @param password Proxy password used together with an explicit {@code username}; may be {@code null} + * @param noProxy Hosts that must bypass the proxy; may be {@code null} + * @return The corresponding {@link ProxyConfig}, or {@code null} when {@code uri} is empty + */ + public static ProxyConfig fromUri(String uri, String username, String password, List noProxy) { + final Parsed p = parse(uri); + if( p == null ) + return null; + final boolean explicit = username != null && !username.isEmpty(); + final String user = explicit ? username : p.username(); + final String pass = explicit ? password : p.password(); + final int port = portAsInt(p.port(), "https".equalsIgnoreCase(p.protocol()) ? 443 : 80); + final Endpoint ep = new Endpoint(p.host(), port, user, pass); + final ProxyConfig cfg = new ProxyConfig(ep, ep, noProxy); + log.debug("Proxy config from uri: {}", cfg); + return cfg; + } + + /** + * Resolve per-protocol proxies from a {@code HTTP_PROXY}/{@code HTTPS_PROXY}/{@code NO_PROXY} + * environment map (upper- and lower-case names, with an {@code ALL_PROXY} fallback). The caller + * supplies the map — this method never reads {@link System#getenv()} itself. + * + * @param env The environment variables map + * @return The corresponding {@link ProxyConfig}, or {@code null} when no proxy variable is present + */ + public static ProxyConfig fromEnvironment(Map env) { + final Parsed http = parse(firstNonEmpty(env, "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy")); + final Parsed https = parse(firstNonEmpty(env, "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy")); + if( http == null && https == null ) + return null; + final Endpoint httpEp = http != null + ? new Endpoint(http.host(), portAsInt(http.port(), 80), http.username(), http.password()) + : null; + final Endpoint httpsEp = https != null + ? new Endpoint(https.host(), portAsInt(https.port(), 443), https.username(), https.password()) + : null; + final ProxyConfig cfg = new ProxyConfig(httpEp, httpsEp, split(firstNonEmpty(env, "NO_PROXY", "no_proxy"))); + log.debug("Proxy config from environment: {}", cfg); + return cfg; + } + + // ------------------------------------------------------------------ java.net views + + /** + * @return {@code true} when at least one configured proxy carries credentials + */ + public boolean hasCredentials() { + return (httpProxy != null && httpProxy.hasCredentials()) + || (httpsProxy != null && httpsProxy.hasCredentials()); + } + + /** + * @return A {@link ProxySelector} routing per scheme, bypassing loopback and {@code NO_PROXY} targets + */ + public ProxySelector toProxySelector() { + // precompute the proxy lists - select() runs once per outbound request + final List direct = List.of(Proxy.NO_PROXY); + final List viaHttp = httpProxy != null + ? List.of(new Proxy(Proxy.Type.HTTP, httpProxy.address())) + : direct; + final List viaHttps = httpsProxy != null + ? List.of(new Proxy(Proxy.Type.HTTP, httpsProxy.address())) + : direct; + return new ProxySelector() { + @Override + public List select(URI uri) { + if( uri == null || isBypassed(uri.getHost()) ) + return direct; + return "https".equalsIgnoreCase(uri.getScheme()) ? viaHttps : viaHttp; + } + @Override + public void connectFailed(URI uri, SocketAddress sa, IOException e) { + log.debug("Failed to connect to proxy {} for {}: {}", sa, uri, e.getMessage()); + } + }; + } + + /** + * Creates a proxy-scoped {@link Authenticator}, or {@code null} when no credentials are configured. + * + *

Credentials are released only for {@link Authenticator.RequestorType#PROXY} challenges whose + * host and port match a configured proxy — never for origin-server challenges. Matching on host+port + * (not protocol) inherently covers the HTTPS {@code CONNECT} tunnel, where the JDK reports the + * requesting protocol as {@code http} even for an https destination. + * + * @return A new {@link Authenticator}, or {@code null} when no proxy credentials are configured + */ + public Authenticator toAuthenticator() { + if( !hasCredentials() ) + return null; + return new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + if( getRequestorType() != RequestorType.PROXY ) + return null; + final Endpoint ep = credentialsFor(getRequestingHost(), getRequestingPort()); + if( ep == null ) + return null; + final String pass = ep.password() != null ? ep.password() : ""; + return new PasswordAuthentication(ep.username(), pass.toCharArray()); + } + }; + } + + /** + * Determines whether the given target host must bypass the proxy, because it is a loopback address + * or matches a {@code NO_PROXY} entry. + * + * @param host The target host name + * @return {@code true} when the target must be reached directly + */ + public boolean isBypassed(String host) { + if( host == null ) + return true; + final String target = host.toLowerCase(Locale.ROOT); + // always bypass loopback targets, consistent with the JDK default `http.nonProxyHosts` + if( target.equals("localhost") || target.startsWith("127.") || target.equals("::1") || target.equals("[::1]") ) + return true; + for( String entry : noProxyHosts ) { + if( entry.equals("*") ) + return true; + // "*.example.com" and ".example.com" match sub-domains only; + // "example.com" matches the host itself and any sub-domain + final String suffix = entry.startsWith("*.") ? entry.substring(1) : entry; + if( suffix.startsWith(".") ) { + if( target.endsWith(suffix) ) + return true; + } + else if( target.equals(suffix) || target.endsWith("." + suffix) ) { + return true; + } + } + return false; + } + + private Endpoint credentialsFor(String host, int port) { + if( httpsProxy != null && httpsProxy.hasCredentials() && httpsProxy.host().equalsIgnoreCase(host) && httpsProxy.port() == port ) + return httpsProxy; + if( httpProxy != null && httpProxy.hasCredentials() && httpProxy.host().equalsIgnoreCase(host) && httpProxy.port() == port ) + return httpProxy; + return null; + } + + // ------------------------------------------------------------------ Basic-over-CONNECT toggle + + /** + * The JDK disables the Basic scheme for proxy authentication over HTTPS {@code CONNECT} tunnelling + * by default ({@code jdk.http.auth.tunneling.disabledSchemes=Basic}), which blocks authenticating + * proxies for {@code https} targets. This clears that property so Basic credentials reach the proxy, + * but only when it is unset — an operator's explicit value (e.g. via {@code JAVA_TOOL_OPTIONS}) wins. + * + *

Call once at startup, before the first outbound request: the JDK reads this property lazily and + * only once. + * + * @return {@code true} when the property was changed, {@code false} when it was already set + */ + public static boolean enableBasicProxyTunneling() { + final String key = "jdk.http.auth.tunneling.disabledSchemes"; + if( System.getProperty(key) == null ) { + System.setProperty(key, ""); + log.debug("Cleared '{}' to allow Basic proxy authentication over HTTPS tunnelling", key); + return true; + } + return false; + } + + // ------------------------------------------------------------------ parsing (source of truth: nextflow.util.ProxyConfig) + + /** The components of a parsed proxy URI. */ + record Parsed(String protocol, String host, String port, String username, String password) { } + + /** + * Parse a proxy string retrieving its protocol, host, port, username and password components. + * + * @param value A proxy string e.g. {@code host}, {@code host:port}, {@code scheme://host:port} + * or {@code scheme://user:pass@host:port} + * @return The parsed components, or {@code null} when {@code value} is empty + * @throws IllegalArgumentException when {@code value} is not a valid proxy URL + */ + static Parsed parse(String value) { + if( value == null || value.isEmpty() ) + return null; + try { + if( value.contains("://") ) { + final URL url = new URL(value); + String user = null, pass = null; + final String info = url.getUserInfo(); + final int p = info != null ? info.indexOf(':') : -1; + if( p != -1 ) { + user = decodeUserInfo(info.substring(0, p)); + pass = decodeUserInfo(info.substring(p + 1)); + } + final String port = url.getPort() > 0 ? String.valueOf(url.getPort()) : null; + return new Parsed(url.getProtocol(), url.getHost(), port, user, pass); + } + final int p = value.indexOf(':'); + if( p != -1 ) + return new Parsed(null, value.substring(0, p), value.substring(p + 1), null, null); + return new Parsed(null, value, null, null, null); + } + catch( MalformedURLException e ) { + throw new IllegalArgumentException("Invalid proxy URL: " + value, e); + } + } + + /** + * Percent-decode a userinfo component (username or password) per RFC 3986, so proxy credentials + * carrying special characters (e.g. {@code @}, {@code :}) work. A literal {@code +} is preserved — + * userinfo is not form-encoded — so it is shielded from the {@code +}→space rule of + * {@link URLDecoder}. + */ + private static String decodeUserInfo(String s) { + return s != null ? URLDecoder.decode(s.replace("+", "%2B"), StandardCharsets.UTF_8) : null; + } + + private static int portAsInt(String port, int defaultPort) { + if( port == null || port.isBlank() ) + return defaultPort; + try { + return Integer.parseInt(port.trim()); + } + catch( NumberFormatException e ) { + log.warn("Ignoring invalid proxy port '{}' - using default {}", port, defaultPort); + return defaultPort; + } + } + + // ------------------------------------------------------------------ helpers + + private static List normalizeNoProxy(List hosts) { + if( hosts == null ) + return List.of(); + final List result = new ArrayList<>(); + for( String h : hosts ) { + if( h == null ) + continue; + final String t = h.trim().toLowerCase(Locale.ROOT); + if( !t.isEmpty() ) + result.add(t); + } + return List.copyOf(result); + } + + private static List split(String csv) { + if( csv == null || csv.isBlank() ) + return List.of(); + final List result = new ArrayList<>(); + for( String s : csv.split(",") ) + if( !s.isBlank() ) + result.add(s.trim()); + return result; + } + + private static String firstNonEmpty(Map env, String... keys) { + for( String k : keys ) { + final String v = env.get(k); + if( v != null && !v.isEmpty() ) + return v; + } + return null; + } + + @Override + public String toString() { + return "ProxyConfig[http=" + describe(httpProxy) + "; https=" + describe(httpsProxy) + "; noProxy=" + noProxyHosts + "]"; + } + + private static String describe(Endpoint ep) { + // credentials are never rendered + return ep == null ? "-" : ep.host() + ":" + ep.port() + (ep.hasCredentials() ? " (auth)" : ""); + } +} diff --git a/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy b/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy new file mode 100644 index 00000000..3f222bfc --- /dev/null +++ b/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy @@ -0,0 +1,239 @@ +/* + * Copyright 2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package io.seqera.util.net + +import spock.lang.Specification +import spock.lang.Unroll + +/** + * Tests for the {@link ProxyConfig} egress-proxy resolver. + * + *

Parsing, no-proxy and proxy-authentication semantics mirror Nextflow's + * {@code nextflow.util.ProxyConfig} (the source of truth). + * + * @author Paolo Di Tommaso + */ +class ProxyConfigTest extends Specification { + + static final List DIRECT = List.of(Proxy.NO_PROXY) + + static List proxied(String host, int port) { + return List.of(new Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(host, port))) + } + + @Unroll + def 'should parse proxy uri #PROXY_URI' () { + when: + def config = ProxyConfig.parse(PROXY_URI) + then: + config?.protocol() == PROTOCOL + config?.host() == HOST + config?.port() == PORT + config?.username() == USER + config?.password() == PASS + + where: + PROXY_URI | PROTOCOL | HOST | PORT | USER | PASS + null | null | null | null | null | null + '' | null | null | null | null | null + 'proxy.example.com' | null | 'proxy.example.com' | null | null | null + 'proxy.example.com:3128' | null | 'proxy.example.com' | '3128' | null | null + 'http://proxy.example.com' | 'http' | 'proxy.example.com' | null | null | null + 'https://proxy.example.com:8080' | 'https' | 'proxy.example.com' | '8080' | null | null + 'http://foo:bar@proxy.example.com:8080' | 'http' | 'proxy.example.com' | '8080' | 'foo' | 'bar' + 'http://foo:p%40ss@proxy.example.com' | 'http' | 'proxy.example.com' | null | 'foo' | 'p@ss' + 'http://foo:p+ss@proxy.example.com' | 'http' | 'proxy.example.com' | null | 'foo' | 'p+ss' + 'http://foo:b:ar@proxy.example.com:1234' | 'http' | 'proxy.example.com' | '1234' | 'foo' | 'b:ar' + } + + def 'should resolve a proxy uri applying it to both http and https destinations' () { + when: + def config = ProxyConfig.fromUri('http://proxy.example.com:3128') + def selector = config.toProxySelector() + then: + selector.select(new URI('http://quay.io/v2/')) == proxied('proxy.example.com', 3128) + selector.select(new URI('https://quay.io/v2/')) == proxied('proxy.example.com', 3128) + and: + !config.hasCredentials() + config.toAuthenticator() == null + } + + @Unroll + def 'should default the proxy port by protocol for #PROXY_URI' () { + when: + def selector = ProxyConfig.fromUri(PROXY_URI).toProxySelector() + then: + selector.select(new URI('https://quay.io/v2/')) == proxied('proxy.example.com', PORT) + + where: + PROXY_URI | PORT + 'proxy.example.com' | 80 + 'http://proxy.example.com' | 80 + 'https://proxy.example.com' | 443 + } + + def 'should return no proxy config when the uri is empty' () { + expect: + ProxyConfig.fromUri(null) == null + ProxyConfig.fromUri('') == null + } + + def 'should give precedence to explicit credentials over uri user-info' () { + given: + def config = ProxyConfig.fromUri('http://foo:bar@proxy.example.com:8080', 'this', 'that', null) + + when: + def result = config.toAuthenticator().requestPasswordAuthenticationInstance( + 'proxy.example.com', null, 8080, 'http', 'auth required', 'basic', null, Authenticator.RequestorType.PROXY) + then: + result.userName == 'this' + result.password == 'that'.toCharArray() + } + + @Unroll + def 'should resolve proxy from environment #ENV' () { + when: + def config = ProxyConfig.fromEnvironment(ENV) + def selector = config?.toProxySelector() + then: + (selector?.select(new URI('http://foo.com/'))) == HTTP_RESULT + (selector?.select(new URI('https://foo.com/'))) == HTTPS_RESULT + + where: + ENV | HTTP_RESULT | HTTPS_RESULT + [:] | null | null + [HTTPS_PROXY: 'http://proxy1:3128'] | DIRECT | proxied('proxy1', 3128) + [https_proxy: 'http://proxy1:3128'] | DIRECT | proxied('proxy1', 3128) + [HTTP_PROXY: 'http://proxy2:8080'] | proxied('proxy2', 8080) | DIRECT + [HTTPS_PROXY: 'http://proxy1:3128', HTTP_PROXY: 'http://x:1'] | proxied('x', 1) | proxied('proxy1', 3128) + [HTTPS_PROXY: 'https://proxy1'] | DIRECT | proxied('proxy1', 443) + } + + def 'should resolve proxy credentials and no-proxy from environment' () { + when: + def config = ProxyConfig.fromEnvironment([HTTPS_PROXY: 'http://foo:bar@proxy1:3128', NO_PROXY: 'a.com']) + then: + config.hasCredentials() + config.toProxySelector().select(new URI('https://a.com/')) == DIRECT + and: + def result = config.toAuthenticator().requestPasswordAuthenticationInstance( + 'proxy1', null, 3128, 'http', 'auth required', 'basic', null, Authenticator.RequestorType.PROXY) + result.userName == 'foo' + result.password == 'bar'.toCharArray() + } + + def 'should fall back to ALL_PROXY when protocol-specific vars are absent' () { + when: + def config = ProxyConfig.fromEnvironment([ALL_PROXY: 'http://proxy1:3128']) + def selector = config.toProxySelector() + then: + selector.select(new URI('http://foo.com/')) == proxied('proxy1', 3128) + selector.select(new URI('https://foo.com/')) == proxied('proxy1', 3128) + } + + @Unroll + def 'should bypass=#EXPECTED the proxy for host #TARGET with no-proxy #NO_PROXY' () { + given: + def selector = ProxyConfig.fromUri('proxy.example.com:3128', null, null, NO_PROXY).toProxySelector() + expect: + (selector.select(new URI("https://${TARGET}/")) == DIRECT) == EXPECTED + + where: + TARGET | NO_PROXY | EXPECTED + 'quay.io' | null | false + 'quay.io' | ['docker.io'] | false + 'docker.io' | ['docker.io'] | true + 'DOCKER.IO' | ['docker.io'] | true + // a bare host name entry also matches its sub-domains + 'registry.docker.io'| ['docker.io'] | true + // a `.` or `*.` suffix entry matches sub-domains only + 'reg.example.com' | ['.example.com'] | true + 'reg.example.com' | ['*.example.com'] | true + 'example.com' | ['.example.com'] | false + 'notexample.com' | ['.example.com'] | false + 'anything.io' | ['*'] | true + // loopback addresses always bypass the proxy + 'localhost' | null | true + '127.0.0.1' | null | true + } + + def 'should create authenticator scoped to the proxy host and requestor type' () { + given: + def auth = ProxyConfig.fromUri('http://foo:bar@proxy.example.com:3128').toAuthenticator() + + when: 'the proxy asks for authentication' + def result = auth.requestPasswordAuthenticationInstance('proxy.example.com', null, 3128, 'http', 'auth required', 'basic', null, Authenticator.RequestorType.PROXY) + then: + result.userName == 'foo' + result.password == 'bar'.toCharArray() + + when: 'a server (not the proxy) asks for authentication' + result = auth.requestPasswordAuthenticationInstance('proxy.example.com', null, 3128, 'http', 'auth required', 'basic', null, Authenticator.RequestorType.SERVER) + then: + result == null + + when: 'a different host asks for proxy authentication' + result = auth.requestPasswordAuthenticationInstance('other.example.com', null, 3128, 'http', 'auth required', 'basic', null, Authenticator.RequestorType.PROXY) + then: + result == null + + when: 'a different port asks for proxy authentication' + result = auth.requestPasswordAuthenticationInstance('proxy.example.com', null, 8080, 'http', 'auth required', 'basic', null, Authenticator.RequestorType.PROXY) + then: + result == null + } + + def 'should release credentials for the https CONNECT tunnel where the JDK reports protocol http' () { + given: 'an https-only proxy with credentials, resolved from the environment' + def auth = ProxyConfig.fromEnvironment([HTTPS_PROXY: 'http://foo:bar@proxy.example.com:3128']).toAuthenticator() + + when: 'the JDK challenges for the CONNECT tunnel reporting the requesting protocol as http' + def result = auth.requestPasswordAuthenticationInstance('proxy.example.com', null, 3128, 'http', 'auth required', 'basic', null, Authenticator.RequestorType.PROXY) + then: 'credentials are released because matching is on host+port, not protocol' + result.userName == 'foo' + result.password == 'bar'.toCharArray() + } + + def 'should redact password in string representation' () { + expect: + !ProxyConfig.fromUri('http://foo:secret1234@proxy.example.com').toString().contains('secret1234') + } + + def 'should clear the tunnelling disabledSchemes property only when unset' () { + given: + def key = 'jdk.http.auth.tunneling.disabledSchemes' + def previous = System.getProperty(key) + + when: 'the property is not set' + System.clearProperty(key) + def changed = ProxyConfig.enableBasicProxyTunneling() + then: + changed + System.getProperty(key) == '' + + when: 'the property is already set by the operator' + System.setProperty(key, 'Basic') + changed = ProxyConfig.enableBasicProxyTunneling() + then: 'the operator value wins and nothing changes' + !changed + System.getProperty(key) == 'Basic' + + cleanup: + if( previous != null ) System.setProperty(key, previous) else System.clearProperty(key) + } +} From 2ada7e620b03c8f1e809396da68e10173f4f3108 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Sun, 6 Sep 2026 18:39:11 +0200 Subject: [PATCH 2/9] Make ProxyConfig.parse public for component-level reuse Expose parse(String) and the Parsed record so callers needing the individual proxy components (e.g. Nextflow's Launcher, which sets -Dhttp.proxyHost system properties) can reuse the parsing instead of duplicating it. Co-Authored-By: Claude Opus 4.8 --- .../src/main/java/io/seqera/util/net/ProxyConfig.java | 11 ++++++++--- .../groovy/io/seqera/util/net/ProxyConfigTest.groovy | 3 +++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java b/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java index 099b0d1b..a5c944f0 100644 --- a/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java +++ b/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java @@ -266,18 +266,23 @@ public static boolean enableBasicProxyTunneling() { // ------------------------------------------------------------------ parsing (source of truth: nextflow.util.ProxyConfig) - /** The components of a parsed proxy URI. */ - record Parsed(String protocol, String host, String port, String username, String password) { } + /** + * The components of a parsed proxy URI. Percent-encoded {@code username}/{@code password} are + * decoded; any path/query in the URI is ignored. Fields not present in the input are {@code null}. + */ + public record Parsed(String protocol, String host, String port, String username, String password) { } /** * Parse a proxy string retrieving its protocol, host, port, username and password components. + * Exposed so callers that need the individual components (e.g. to set {@code -Dhttp.proxyHost} + * system properties) can reuse the same parsing instead of duplicating it. * * @param value A proxy string e.g. {@code host}, {@code host:port}, {@code scheme://host:port} * or {@code scheme://user:pass@host:port} * @return The parsed components, or {@code null} when {@code value} is empty * @throws IllegalArgumentException when {@code value} is not a valid proxy URL */ - static Parsed parse(String value) { + public static Parsed parse(String value) { if( value == null || value.isEmpty() ) return null; try { diff --git a/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy b/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy index 3f222bfc..f8d67d6a 100644 --- a/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy +++ b/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy @@ -59,6 +59,9 @@ class ProxyConfigTest extends Specification { 'http://foo:p%40ss@proxy.example.com' | 'http' | 'proxy.example.com' | null | 'foo' | 'p@ss' 'http://foo:p+ss@proxy.example.com' | 'http' | 'proxy.example.com' | null | 'foo' | 'p+ss' 'http://foo:b:ar@proxy.example.com:1234' | 'http' | 'proxy.example.com' | '1234' | 'foo' | 'b:ar' + // a path/query in the URI is ignored + 'http://10.20.30.40:333/some/path' | 'http' | '10.20.30.40' | '333' | null | null + 'http://user:pass@10.20.30.40:333/some/path'| 'http' | '10.20.30.40' | '333' | 'user' | 'pass' } def 'should resolve a proxy uri applying it to both http and https destinations' () { From f7c0abb3ef936d1c93e7403127722c5bc08c6f82 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Sun, 6 Sep 2026 18:54:31 +0200 Subject: [PATCH 3/9] Add ProxyConfig.setupFromEnvironment for JVM-global proxy install Capture the env->proxy setup previously hand-rolled in Nextflow's launcher: set per-protocol http/https/ftp proxyHost/proxyPort + http.nonProxyHosts system properties, install the proxy-scoped Authenticator as the JVM default and clear jdk.http.auth.tunneling.disabledSchemes when credentials are present. Returns the resolved http/https ProxyConfig for wiring java.net.http clients explicitly. Co-Authored-By: Claude Opus 4.8 --- lib-util-net/changelog.txt | 2 + .../java/io/seqera/util/net/ProxyConfig.java | 62 +++++++++++++++++++ .../io/seqera/util/net/ProxyConfigTest.groovy | 42 +++++++++++++ 3 files changed, 106 insertions(+) diff --git a/lib-util-net/changelog.txt b/lib-util-net/changelog.txt index 88df33fe..376a0c6d 100644 --- a/lib-util-net/changelog.txt +++ b/lib-util-net/changelog.txt @@ -2,6 +2,8 @@ 0.2.0 - Add ProxyConfig: resolve an HTTP/HTTPS forward (egress) proxy - including an authenticating one - from a proxy URI or the HTTP_PROXY/HTTPS_PROXY/NO_PROXY environment variables, exposed as a java.net ProxySelector and proxy-scoped Authenticator (parsing and no-proxy semantics mirror Nextflow's nextflow.util.ProxyConfig) +- ProxyConfig.parse is public so callers can reuse the component-level parsing +- Add ProxyConfig.setupFromEnvironment: install http/https/ftp proxies into the JVM (per-protocol proxyHost/proxyPort + http.nonProxyHosts system properties, default proxy Authenticator and Basic-over-CONNECT tunnelling), capturing the setup previously hand-rolled in Nextflow's launcher 0.1.0 - Initial release with SsrfValidator providing SSRF host validation (rejects localhost, loopback, link-local, private and cloud-metadata addresses) diff --git a/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java b/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java index a5c944f0..d52c160d 100644 --- a/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java +++ b/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java @@ -264,6 +264,68 @@ public static boolean enableBasicProxyTunneling() { return false; } + // ------------------------------------------------------------------ JVM-global setup from the environment + + /** + * Resolve the http/https/ftp proxies from the environment and install them into the JVM, mirroring + * the setup Nextflow's launcher performs so JVM-global HTTP/FTP code (e.g. {@code URLConnection}) + * honours the proxy: + *

+ * The caller supplies the map — this method never reads {@link System#getenv()} itself. A malformed + * proxy value is logged and skipped rather than raised. + * + * @param env The environment variables map + * @return The resolved http/https {@link ProxyConfig} for wiring {@code java.net.http} clients + * explicitly (ftp has no {@code java.net.http} representation - it is applied via system + * properties only), or {@code null} when no proxy variable is present + */ + public static ProxyConfig setupFromEnvironment(Map env) { + // per-protocol JVM system properties (honoured by URLConnection, FTP and other JVM-global code) + applyProxySystemProperty(env, "http"); + applyProxySystemProperty(env, "https"); + applyProxySystemProperty(env, "ftp"); + final String noProxy = firstNonEmpty(env, "NO_PROXY", "no_proxy"); + if( noProxy != null && !noProxy.isBlank() ) + System.setProperty("http.nonProxyHosts", String.join("|", split(noProxy))); + // http/https config for java.net.http clients (ftp is not an HttpClient scheme) + ProxyConfig cfg; + try { + cfg = fromEnvironment(env); + } + catch( IllegalArgumentException e ) { + log.warn("Ignoring invalid proxy environment variable: {}", e.getMessage()); + cfg = null; + } + if( cfg != null && cfg.hasCredentials() ) { + Authenticator.setDefault(cfg.toAuthenticator()); + enableBasicProxyTunneling(); + } + return cfg; + } + + private static void applyProxySystemProperty(Map env, String proto) { + final String value = firstNonEmpty(env, proto.toUpperCase(Locale.ROOT) + "_PROXY", proto + "_proxy", "ALL_PROXY", "all_proxy"); + final Parsed p; + try { + p = parse(value); + } + catch( IllegalArgumentException e ) { + log.warn("Ignoring invalid {} proxy '{}': {}", proto, value, e.getMessage()); + return; + } + if( p == null ) + return; + System.setProperty(proto + ".proxyHost", p.host()); + if( p.port() != null && !p.port().isBlank() ) + System.setProperty(proto + ".proxyPort", p.port()); + } + // ------------------------------------------------------------------ parsing (source of truth: nextflow.util.ProxyConfig) /** diff --git a/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy b/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy index f8d67d6a..1970e5ac 100644 --- a/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy +++ b/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy @@ -217,6 +217,48 @@ class ProxyConfigTest extends Specification { !ProxyConfig.fromUri('http://foo:secret1234@proxy.example.com').toString().contains('secret1234') } + def 'setupFromEnvironment should install per-protocol system properties and return the http/https config' () { + given: + def keys = ['http.proxyHost','http.proxyPort','https.proxyHost','https.proxyPort', + 'ftp.proxyHost','ftp.proxyPort','http.nonProxyHosts','jdk.http.auth.tunneling.disabledSchemes'] + def saved = keys.collectEntries { [(it): System.getProperty(it)] } + keys.each { System.clearProperty(it) } + def savedAuth = Authenticator.default + + when: + def cfg = ProxyConfig.setupFromEnvironment([ + HTTP_PROXY : 'http://alice:secret@http-proxy:3128', + HTTPS_PROXY: 'http://https-proxy:8080', + FTP_PROXY : 'ftp-proxy:2121', + NO_PROXY : 'internal.example.com, .corp' ]) + + then: 'per-protocol system properties are set (ftp too)' + System.getProperty('http.proxyHost') == 'http-proxy' + System.getProperty('http.proxyPort') == '3128' + System.getProperty('https.proxyHost') == 'https-proxy' + System.getProperty('https.proxyPort') == '8080' + System.getProperty('ftp.proxyHost') == 'ftp-proxy' + System.getProperty('ftp.proxyPort') == '2121' + and: 'NO_PROXY is installed as pipe-separated http.nonProxyHosts' + System.getProperty('http.nonProxyHosts') == 'internal.example.com|.corp' + and: 'credentials present -> tunnelling Basic scheme is enabled' + System.getProperty('jdk.http.auth.tunneling.disabledSchemes') == '' + and: 'the returned config carries the http/https proxies and no-proxy for java.net.http clients' + cfg.hasCredentials() + cfg.toProxySelector().select(new URI('http://x/')) == proxied('http-proxy', 3128) + cfg.toProxySelector().select(new URI('https://x/')) == proxied('https-proxy', 8080) + cfg.toProxySelector().select(new URI('https://internal.example.com/')) == DIRECT + + cleanup: + keys.each { saved[it] != null ? System.setProperty(it, saved[it]) : System.clearProperty(it) } + Authenticator.setDefault(savedAuth) + } + + def 'setupFromEnvironment should return null and touch nothing when no proxy var is set' () { + expect: + ProxyConfig.setupFromEnvironment([:]) == null + } + def 'should clear the tunnelling disabledSchemes property only when unset' () { given: def key = 'jdk.http.auth.tunneling.disabledSchemes' From 068d26637ffbd36e5a09fd4ca311a395ecbd5d6a Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Sun, 6 Sep 2026 18:57:16 +0200 Subject: [PATCH 4/9] Expose resolved proxy endpoints on ProxyConfig Make the Endpoint record public and add getHttpProxy()/getHttpsProxy()/ getNoProxyHosts() so a caller (e.g. Nextflow) can adapt the resolved config to another representation such as lib-httpx HxProxyConfig without re-parsing. Co-Authored-By: Claude Opus 4.8 --- .../java/io/seqera/util/net/ProxyConfig.java | 17 ++++++++++++++++- .../io/seqera/util/net/ProxyConfigTest.groovy | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java b/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java index d52c160d..3e134263 100644 --- a/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java +++ b/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java @@ -59,7 +59,7 @@ public final class ProxyConfig { private static final Logger log = LoggerFactory.getLogger(ProxyConfig.class); /** A single proxy endpoint with optional Basic credentials. */ - private record Endpoint(String host, int port, String username, String password) { + public record Endpoint(String host, int port, String username, String password) { boolean hasCredentials() { return username != null && !username.isEmpty(); } @@ -149,6 +149,21 @@ public boolean hasCredentials() { || (httpsProxy != null && httpsProxy.hasCredentials()); } + /** @return The resolved HTTP proxy endpoint, or {@code null} when none is configured */ + public Endpoint getHttpProxy() { + return httpProxy; + } + + /** @return The resolved HTTPS proxy endpoint, or {@code null} when none is configured */ + public Endpoint getHttpsProxy() { + return httpsProxy; + } + + /** @return The {@code NO_PROXY} host entries (normalized to lower-case), never {@code null} */ + public List getNoProxyHosts() { + return noProxyHosts; + } + /** * @return A {@link ProxySelector} routing per scheme, bypassing loopback and {@code NO_PROXY} targets */ diff --git a/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy b/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy index 1970e5ac..84e7d5bb 100644 --- a/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy +++ b/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy @@ -212,6 +212,25 @@ class ProxyConfigTest extends Specification { result.password == 'bar'.toCharArray() } + def 'should expose the resolved per-protocol endpoints and no-proxy hosts' () { + when: + def cfg = ProxyConfig.fromEnvironment([HTTPS_PROXY: 'http://foo:bar@https-proxy:3129', HTTP_PROXY: 'http://http-proxy:3128', NO_PROXY: 'a.com,b.com']) + then: + cfg.httpProxy.host() == 'http-proxy' + cfg.httpProxy.port() == 3128 + cfg.httpProxy.username() == null + cfg.httpsProxy.host() == 'https-proxy' + cfg.httpsProxy.port() == 3129 + cfg.httpsProxy.username() == 'foo' + cfg.httpsProxy.password() == 'bar' + cfg.noProxyHosts == ['a.com','b.com'] + and: 'fromUri applies the same endpoint to both protocols' + with(ProxyConfig.fromUri('proxy:8080')) { + httpProxy.host() == 'proxy' && httpProxy.port() == 8080 + httpsProxy.host() == 'proxy' && httpsProxy.port() == 8080 + } + } + def 'should redact password in string representation' () { expect: !ProxyConfig.fromUri('http://foo:secret1234@proxy.example.com').toString().contains('secret1234') From a6ad3e96123075f3d061572d1e208f510b578606 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Sun, 6 Sep 2026 19:05:00 +0200 Subject: [PATCH 5/9] Add setupFromEnvironment coverage: ALL_PROXY fallback and disabledSchemes cases Recapture the behaviours previously verified in Nextflow's LauncherTest (now that the setup logic lives here): ALL_PROXY fallback for the per-protocol system properties, and the jdk.http.auth.tunneling.disabledSchemes clear/preserve rules. Co-Authored-By: Claude Opus 4.8 --- .../io/seqera/util/net/ProxyConfigTest.groovy | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy b/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy index 84e7d5bb..803ab7e0 100644 --- a/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy +++ b/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy @@ -278,6 +278,48 @@ class ProxyConfigTest extends Specification { ProxyConfig.setupFromEnvironment([:]) == null } + def 'setupFromEnvironment should fall back to ALL_PROXY for the system properties' () { + given: + def keys = ['http.proxyHost','http.proxyPort','https.proxyHost','https.proxyPort'] + def saved = keys.collectEntries { [(it): System.getProperty(it)] } + keys.each { System.clearProperty(it) } + + when: 'ALL_PROXY is the only variable set' + ProxyConfig.setupFromEnvironment([ALL_PROXY: 'all.example.com:9090']) + then: 'it feeds both http and https system properties' + System.getProperty('http.proxyHost') == 'all.example.com' + System.getProperty('http.proxyPort') == '9090' + System.getProperty('https.proxyHost') == 'all.example.com' + System.getProperty('https.proxyPort') == '9090' + + cleanup: + keys.each { saved[it] != null ? System.setProperty(it, saved[it]) : System.clearProperty(it) } + } + + @Unroll + def 'setupFromEnvironment disabledSchemes handling: preset=#PRESET creds=#CREDS -> #EXPECTED' () { + given: + def key = 'jdk.http.auth.tunneling.disabledSchemes' + def previous = System.getProperty(key) + PRESET != null ? System.setProperty(key, PRESET) : System.clearProperty(key) + def savedAuth = Authenticator.default + + when: + ProxyConfig.setupFromEnvironment([HTTPS_PROXY: CREDS ? 'http://user:pass@proxy:8080' : 'http://proxy:8080']) + then: + System.getProperty(key) == EXPECTED + + cleanup: + previous != null ? System.setProperty(key, previous) : System.clearProperty(key) + Authenticator.setDefault(savedAuth) + + where: + PRESET | CREDS || EXPECTED + null | true || '' // credentials + unset -> cleared to enable Basic over CONNECT + null | false || null // no credentials -> left untouched + 'NTLM' | true || 'NTLM' // operator value always wins + } + def 'should clear the tunnelling disabledSchemes property only when unset' () { given: def key = 'jdk.http.auth.tunneling.disabledSchemes' From a11a2337c5196a47615b8b163ca091ebca6f5f1d Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Sun, 6 Sep 2026 19:33:46 +0200 Subject: [PATCH 6/9] Address review: lenient env parsing, username-only creds, null-safety, redaction - fromEnvironment tolerates an unsupported scheme (e.g. socks5://) or malformed value, logging and treating it as absent instead of throwing; fromUri still throws - parse keeps a username-only user-info (e.g. a token proxy) instead of dropping it - fromEnvironment/setupFromEnvironment return null for a null env map - warn when a proxy is addressed over https (JDK has no TLS-to-proxy support) - Parsed.toString redacts the password - README: move the egress-proxy section under Usage (was nested under Limitations) Co-Authored-By: Claude Opus 4.8 --- lib-util-net/README.md | 18 +++-- .../java/io/seqera/util/net/ProxyConfig.java | 78 +++++++++++++------ .../io/seqera/util/net/ProxyConfigTest.groovy | 33 ++++++++ 3 files changed, 98 insertions(+), 31 deletions(-) diff --git a/lib-util-net/README.md b/lib-util-net/README.md index d8c073bc..d62185c4 100644 --- a/lib-util-net/README.md +++ b/lib-util-net/README.md @@ -37,12 +37,6 @@ when the host: - resolves to a cloud metadata service IP (AWS `169.254.169.254`, ECS `169.254.170.2`, IMDSv2 IPv6) - cannot be resolved (fail closed) -## Limitations - -Validation resolves DNS at call time; a caller that later opens a connection -resolves DNS again, leaving a TOCTOU / DNS-rebinding window. Pin the resolved -address if that gap matters for your use case. - ### Egress proxy configuration `io.seqera.util.net.ProxyConfig` resolves an HTTP/HTTPS forward (egress) proxy — @@ -74,3 +68,15 @@ if (proxy != null) { `NO_PROXY` entries match host names or domain suffixes (optionally prefixed with `.` or `*.`); `*` disables proxying entirely, and loopback targets always bypass the proxy. CIDR notation is not supported. + +To honour the proxy JVM-wide — installing the per-protocol `.proxyHost`/ +`.proxyPort` and `http.nonProxyHosts` system properties and a default +`Authenticator` (also covering `FTP_PROXY`), the way a CLI launcher would — use +`ProxyConfig.setupFromEnvironment(System.getenv())`, which returns the same +http/https config for wiring `java.net.http` clients explicitly. + +## Limitations + +`SsrfValidator` resolves DNS at call time; a caller that later opens a connection +resolves DNS again, leaving a TOCTOU / DNS-rebinding window. Pin the resolved +address if that gap matters for your use case. diff --git a/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java b/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java index 3e134263..c9565939 100644 --- a/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java +++ b/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java @@ -102,7 +102,7 @@ public static ProxyConfig fromUri(String uri) { * @return The corresponding {@link ProxyConfig}, or {@code null} when {@code uri} is empty */ public static ProxyConfig fromUri(String uri, String username, String password, List noProxy) { - final Parsed p = parse(uri); + final Parsed p = warnIfTlsProxy(parse(uri)); if( p == null ) return null; final boolean explicit = username != null && !username.isEmpty(); @@ -124,8 +124,12 @@ public static ProxyConfig fromUri(String uri, String username, String password, * @return The corresponding {@link ProxyConfig}, or {@code null} when no proxy variable is present */ public static ProxyConfig fromEnvironment(Map env) { - final Parsed http = parse(firstNonEmpty(env, "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy")); - final Parsed https = parse(firstNonEmpty(env, "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy")); + if( env == null ) + return null; + // ambient environment may carry an unsupported scheme (e.g. socks5://) or a malformed value - + // treat it as "no proxy" rather than failing the process (unlike the explicit fromUri path) + final Parsed http = warnIfTlsProxy(parseLenient(firstNonEmpty(env, "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"))); + final Parsed https = warnIfTlsProxy(parseLenient(firstNonEmpty(env, "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"))); if( http == null && https == null ) return null; final Endpoint httpEp = http != null @@ -301,6 +305,8 @@ public static boolean enableBasicProxyTunneling() { * properties only), or {@code null} when no proxy variable is present */ public static ProxyConfig setupFromEnvironment(Map env) { + if( env == null ) + return null; // per-protocol JVM system properties (honoured by URLConnection, FTP and other JVM-global code) applyProxySystemProperty(env, "http"); applyProxySystemProperty(env, "https"); @@ -309,14 +315,7 @@ public static ProxyConfig setupFromEnvironment(Map env) { if( noProxy != null && !noProxy.isBlank() ) System.setProperty("http.nonProxyHosts", String.join("|", split(noProxy))); // http/https config for java.net.http clients (ftp is not an HttpClient scheme) - ProxyConfig cfg; - try { - cfg = fromEnvironment(env); - } - catch( IllegalArgumentException e ) { - log.warn("Ignoring invalid proxy environment variable: {}", e.getMessage()); - cfg = null; - } + final ProxyConfig cfg = fromEnvironment(env); if( cfg != null && cfg.hasCredentials() ) { Authenticator.setDefault(cfg.toAuthenticator()); enableBasicProxyTunneling(); @@ -325,15 +324,7 @@ public static ProxyConfig setupFromEnvironment(Map env) { } private static void applyProxySystemProperty(Map env, String proto) { - final String value = firstNonEmpty(env, proto.toUpperCase(Locale.ROOT) + "_PROXY", proto + "_proxy", "ALL_PROXY", "all_proxy"); - final Parsed p; - try { - p = parse(value); - } - catch( IllegalArgumentException e ) { - log.warn("Ignoring invalid {} proxy '{}': {}", proto, value, e.getMessage()); - return; - } + final Parsed p = parseLenient(firstNonEmpty(env, proto.toUpperCase(Locale.ROOT) + "_PROXY", proto + "_proxy", "ALL_PROXY", "all_proxy")); if( p == null ) return; System.setProperty(proto + ".proxyHost", p.host()); @@ -347,7 +338,39 @@ private static void applyProxySystemProperty(Map env, String prot * The components of a parsed proxy URI. Percent-encoded {@code username}/{@code password} are * decoded; any path/query in the URI is ignored. Fields not present in the input are {@code null}. */ - public record Parsed(String protocol, String host, String port, String username, String password) { } + public record Parsed(String protocol, String host, String port, String username, String password) { + @Override + public String toString() { + // never render the password + return "Parsed[protocol=" + protocol + ", host=" + host + ", port=" + port + + ", username=" + username + ", password=" + (password != null ? "****" : null) + "]"; + } + } + + /** + * Like {@link #parse(String)} but lenient: an unsupported scheme (e.g. {@code socks5://}) or an + * otherwise malformed value is logged and treated as absent rather than raised. Used for the + * ambient environment, which the caller does not control. + */ + private static Parsed parseLenient(String value) { + try { + return parse(value); + } + catch( IllegalArgumentException e ) { + log.warn("Ignoring unsupported or invalid proxy value '{}': {}", value, e.getMessage()); + return null; + } + } + + /** + * Warn when a proxy is addressed over {@code https}: the JDK {@link java.net.http.HttpClient} has + * no TLS-to-proxy support, so it will speak plaintext to the proxy. Returns the argument unchanged. + */ + private static Parsed warnIfTlsProxy(Parsed p) { + if( p != null && "https".equalsIgnoreCase(p.protocol()) ) + log.warn("Proxy '{}' is addressed over https, but connecting to a proxy over TLS is not supported - the connection to the proxy will use plaintext", p.host()); + return p; + } /** * Parse a proxy string retrieving its protocol, host, port, username and password components. @@ -367,10 +390,15 @@ public static Parsed parse(String value) { final URL url = new URL(value); String user = null, pass = null; final String info = url.getUserInfo(); - final int p = info != null ? info.indexOf(':') : -1; - if( p != -1 ) { - user = decodeUserInfo(info.substring(0, p)); - pass = decodeUserInfo(info.substring(p + 1)); + if( info != null && !info.isEmpty() ) { + final int p = info.indexOf(':'); + if( p == -1 ) { + user = decodeUserInfo(info); // username-only (e.g. a token proxy), no password + } + else { + user = decodeUserInfo(info.substring(0, p)); + pass = decodeUserInfo(info.substring(p + 1)); + } } final String port = url.getPort() > 0 ? String.valueOf(url.getPort()) : null; return new Parsed(url.getProtocol(), url.getHost(), port, user, pass); diff --git a/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy b/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy index 803ab7e0..a71b133f 100644 --- a/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy +++ b/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy @@ -62,6 +62,39 @@ class ProxyConfigTest extends Specification { // a path/query in the URI is ignored 'http://10.20.30.40:333/some/path' | 'http' | '10.20.30.40' | '333' | null | null 'http://user:pass@10.20.30.40:333/some/path'| 'http' | '10.20.30.40' | '333' | 'user' | 'pass' + // username-only user-info (e.g. a token proxy) keeps the username, no password + 'http://token@proxy.example.com:3128' | 'http' | 'proxy.example.com' | '3128' | 'token' | null + } + + def 'should treat an unsupported or malformed proxy scheme in the environment as no proxy' () { + expect: 'socks5 (and other unsupported schemes) are ignored, not raised' + ProxyConfig.fromEnvironment([ALL_PROXY: 'socks5://proxy:1080']) == null + and: 'a bad entry for one protocol does not sink a valid one' + ProxyConfig.fromEnvironment([HTTP_PROXY: 'http://ok:3128', HTTPS_PROXY: 'socks5://bad:1']) + .toProxySelector().select(new URI('http://x/')) == proxied('ok', 3128) + } + + def 'should throw for an unsupported scheme on the explicit fromUri path' () { + when: + ProxyConfig.fromUri('socks5://proxy:1080') + then: + thrown(IllegalArgumentException) + } + + def 'should return null for a null environment map' () { + expect: + ProxyConfig.fromEnvironment(null) == null + ProxyConfig.setupFromEnvironment(null) == null + } + + def 'a username-only proxy releases credentials with an empty password' () { + given: + def auth = ProxyConfig.fromUri('http://token@proxy.example.com:3128').toAuthenticator() + when: + def result = auth.requestPasswordAuthenticationInstance('proxy.example.com', null, 3128, 'http', 'auth', 'basic', null, Authenticator.RequestorType.PROXY) + then: + result.userName == 'token' + result.password == ''.toCharArray() } def 'should resolve a proxy uri applying it to both http and https destinations' () { From 0b7c7971a1c6af6052ffb80e1357c8cc320289b5 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Sun, 6 Sep 2026 21:17:47 +0200 Subject: [PATCH 7/9] Address re-review: redact proxy creds in errors, fix NO_PROXY->nonProxyHosts B1: redact the user-info before logging/raising an invalid proxy value, so the proxy password never reaches the log or an exception message (parseLenient + parse) B2: map NO_PROXY to the JDK http.nonProxyHosts grammar (exact host + '*' wildcard), prepend the default loopback bypass it would otherwise replace, expand a bare host to 'host|*.host' to match isBypassed, and set ftp.nonProxyHosts too N1: default the proxy port from the proxy scheme (http->80, https->443) in fromEnvironment, matching fromUri N3: make the public Endpoint record's hasCredentials()/address() public N4/N5: javadoc notes (ftp is system-property only; IPv6-without-scheme and NO_PROXY-port limits) N6: @ResourceLock the JVM-global-mutating tests Co-Authored-By: Claude Opus 4.8 --- .../java/io/seqera/util/net/ProxyConfig.java | 68 ++++++++++++++++--- .../io/seqera/util/net/ProxyConfigTest.groovy | 29 +++++++- 2 files changed, 83 insertions(+), 14 deletions(-) diff --git a/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java b/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java index c9565939..63a2ca99 100644 --- a/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java +++ b/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java @@ -60,10 +60,10 @@ public final class ProxyConfig { /** A single proxy endpoint with optional Basic credentials. */ public record Endpoint(String host, int port, String username, String password) { - boolean hasCredentials() { + public boolean hasCredentials() { return username != null && !username.isEmpty(); } - InetSocketAddress address() { + public InetSocketAddress address() { return InetSocketAddress.createUnresolved(host, port); } } @@ -108,8 +108,7 @@ public static ProxyConfig fromUri(String uri, String username, String password, final boolean explicit = username != null && !username.isEmpty(); final String user = explicit ? username : p.username(); final String pass = explicit ? password : p.password(); - final int port = portAsInt(p.port(), "https".equalsIgnoreCase(p.protocol()) ? 443 : 80); - final Endpoint ep = new Endpoint(p.host(), port, user, pass); + final Endpoint ep = new Endpoint(p.host(), portAsInt(p.port(), defaultPort(p)), user, pass); final ProxyConfig cfg = new ProxyConfig(ep, ep, noProxy); log.debug("Proxy config from uri: {}", cfg); return cfg; @@ -132,11 +131,13 @@ public static ProxyConfig fromEnvironment(Map env) { final Parsed https = warnIfTlsProxy(parseLenient(firstNonEmpty(env, "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"))); if( http == null && https == null ) return null; + // the default port follows the proxy scheme (http->80, https->443), not the traffic protocol, + // so HTTPS_PROXY=http://proxy (no port) reaches the proxy on 80 - consistent with fromUri final Endpoint httpEp = http != null - ? new Endpoint(http.host(), portAsInt(http.port(), 80), http.username(), http.password()) + ? new Endpoint(http.host(), portAsInt(http.port(), defaultPort(http)), http.username(), http.password()) : null; final Endpoint httpsEp = https != null - ? new Endpoint(https.host(), portAsInt(https.port(), 443), https.username(), https.password()) + ? new Endpoint(https.host(), portAsInt(https.port(), defaultPort(https)), https.username(), https.password()) : null; final ProxyConfig cfg = new ProxyConfig(httpEp, httpsEp, split(firstNonEmpty(env, "NO_PROXY", "no_proxy"))); log.debug("Proxy config from environment: {}", cfg); @@ -302,7 +303,8 @@ public static boolean enableBasicProxyTunneling() { * @param env The environment variables map * @return The resolved http/https {@link ProxyConfig} for wiring {@code java.net.http} clients * explicitly (ftp has no {@code java.net.http} representation - it is applied via system - * properties only), or {@code null} when no proxy variable is present + * properties only, so this can return {@code null} even after {@code FTP_PROXY} installed + * ftp system properties), or {@code null} when no http/https proxy variable is present */ public static ProxyConfig setupFromEnvironment(Map env) { if( env == null ) @@ -312,8 +314,12 @@ public static ProxyConfig setupFromEnvironment(Map env) { applyProxySystemProperty(env, "https"); applyProxySystemProperty(env, "ftp"); final String noProxy = firstNonEmpty(env, "NO_PROXY", "no_proxy"); - if( noProxy != null && !noProxy.isBlank() ) - System.setProperty("http.nonProxyHosts", String.join("|", split(noProxy))); + if( noProxy != null && !noProxy.isBlank() ) { + final String nonProxyHosts = toNonProxyHosts(split(noProxy)); + // http.nonProxyHosts covers both http and https; ftp has its own property + System.setProperty("http.nonProxyHosts", nonProxyHosts); + System.setProperty("ftp.nonProxyHosts", nonProxyHosts); + } // http/https config for java.net.http clients (ftp is not an HttpClient scheme) final ProxyConfig cfg = fromEnvironment(env); if( cfg != null && cfg.hasCredentials() ) { @@ -332,6 +338,28 @@ private static void applyProxySystemProperty(Map env, String prot System.setProperty(proto + ".proxyPort", p.port()); } + /** + * Translate {@code NO_PROXY} entries to the JDK {@code http.nonProxyHosts} grammar (exact host or + * {@code *} wildcard, {@code |}-separated). Setting the property replaces the JDK default, so the + * loopback bypass ({@code localhost|127.*|[::1]|0.0.0.0|[::0]}) is prepended; and because a bare + * {@code example.com} means "host and sub-domains" here (see {@link #isBypassed(String)}) it is + * expanded to {@code example.com|*.example.com}, while {@code .x}/{@code *.x} become {@code *.x}. + */ + private static String toNonProxyHosts(List entries) { + final StringBuilder sb = new StringBuilder("localhost|127.*|[::1]|0.0.0.0|[::0]"); + for( String entry : entries ) { + if( entry.equals("*") ) + sb.append("|*"); + else if( entry.startsWith("*.") ) + sb.append('|').append(entry); + else if( entry.startsWith(".") ) + sb.append("|*").append(entry); // ".corp" -> "*.corp" + else + sb.append('|').append(entry).append("|*.").append(entry); // "x" -> "x|*.x" + } + return sb.toString(); + } + // ------------------------------------------------------------------ parsing (source of truth: nextflow.util.ProxyConfig) /** @@ -357,7 +385,7 @@ private static Parsed parseLenient(String value) { return parse(value); } catch( IllegalArgumentException e ) { - log.warn("Ignoring unsupported or invalid proxy value '{}': {}", value, e.getMessage()); + log.warn("Ignoring unsupported or invalid proxy value '{}': {}", redactUserInfo(value), e.getMessage()); return null; } } @@ -377,6 +405,10 @@ private static Parsed warnIfTlsProxy(Parsed p) { * Exposed so callers that need the individual components (e.g. to set {@code -Dhttp.proxyHost} * system properties) can reuse the same parsing instead of duplicating it. * + *

Limitations (unchanged, mirroring Nextflow): a bracketed IPv6 literal without a scheme + * ({@code [::1]:3128}) is not parsed correctly - give it a scheme ({@code http://[::1]:3128}); + * and {@code NO_PROXY} entries do not carry ports. + * * @param value A proxy string e.g. {@code host}, {@code host:port}, {@code scheme://host:port} * or {@code scheme://user:pass@host:port} * @return The parsed components, or {@code null} when {@code value} is empty @@ -409,10 +441,19 @@ public static Parsed parse(String value) { return new Parsed(null, value, null, null, null); } catch( MalformedURLException e ) { - throw new IllegalArgumentException("Invalid proxy URL: " + value, e); + // never include the raw value verbatim - it may carry the proxy password in its user-info + throw new IllegalArgumentException("Invalid proxy URL: " + redactUserInfo(value), e); } } + /** + * Replace the user-info of a proxy URI with {@code ****} so credentials are never logged or + * surfaced in an exception message, e.g. {@code http://user:pass@host} → {@code http://****@host}. + */ + private static String redactUserInfo(String value) { + return value != null ? value.replaceAll("://[^@/]+@", "://****@") : null; + } + /** * Percent-decode a userinfo component (username or password) per RFC 3986, so proxy credentials * carrying special characters (e.g. {@code @}, {@code :}) work. A literal {@code +} is preserved — @@ -423,6 +464,11 @@ private static String decodeUserInfo(String s) { return s != null ? URLDecoder.decode(s.replace("+", "%2B"), StandardCharsets.UTF_8) : null; } + /** @return the default proxy port for the parsed scheme: 443 for https, 80 otherwise */ + private static int defaultPort(Parsed p) { + return "https".equalsIgnoreCase(p.protocol()) ? 443 : 80; + } + private static int portAsInt(String port, int defaultPort) { if( port == null || port.isBlank() ) return defaultPort; diff --git a/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy b/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy index a71b133f..c52869af 100644 --- a/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy +++ b/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy @@ -17,6 +17,7 @@ package io.seqera.util.net +import spock.lang.ResourceLock import spock.lang.Specification import spock.lang.Unroll @@ -269,10 +270,28 @@ class ProxyConfigTest extends Specification { !ProxyConfig.fromUri('http://foo:secret1234@proxy.example.com').toString().contains('secret1234') } + def 'should not leak the proxy password in the error for an invalid proxy uri' () { + when: 'an unsupported scheme carrying credentials' + ProxyConfig.fromUri('socks5://foo:secret1234@proxy.example.com:1080') + then: + def e = thrown(IllegalArgumentException) + !e.message.contains('secret1234') + e.message.contains('****') + } + + def 'fromEnvironment should default the proxy port from the proxy scheme, not the traffic protocol' () { + expect: 'HTTPS_PROXY reached over http with no port -> 80 (proxy scheme), consistent with fromUri' + (ProxyConfig.fromEnvironment([HTTPS_PROXY: 'http://proxy.corp']).toProxySelector().select(new URI('https://x/'))[0].address() as InetSocketAddress).port == 80 + and: 'an https-scheme proxy still defaults to 443' + (ProxyConfig.fromEnvironment([HTTPS_PROXY: 'https://proxy.corp']).toProxySelector().select(new URI('https://x/'))[0].address() as InetSocketAddress).port == 443 + } + + @ResourceLock('proxy-jvm-globals') def 'setupFromEnvironment should install per-protocol system properties and return the http/https config' () { given: def keys = ['http.proxyHost','http.proxyPort','https.proxyHost','https.proxyPort', - 'ftp.proxyHost','ftp.proxyPort','http.nonProxyHosts','jdk.http.auth.tunneling.disabledSchemes'] + 'ftp.proxyHost','ftp.proxyPort','http.nonProxyHosts','ftp.nonProxyHosts', + 'jdk.http.auth.tunneling.disabledSchemes'] def saved = keys.collectEntries { [(it): System.getProperty(it)] } keys.each { System.clearProperty(it) } def savedAuth = Authenticator.default @@ -291,8 +310,9 @@ class ProxyConfigTest extends Specification { System.getProperty('https.proxyPort') == '8080' System.getProperty('ftp.proxyHost') == 'ftp-proxy' System.getProperty('ftp.proxyPort') == '2121' - and: 'NO_PROXY is installed as pipe-separated http.nonProxyHosts' - System.getProperty('http.nonProxyHosts') == 'internal.example.com|.corp' + and: 'NO_PROXY -> http.nonProxyHosts in JDK grammar, keeping the loopback defaults and http+ftp' + System.getProperty('http.nonProxyHosts') == 'localhost|127.*|[::1]|0.0.0.0|[::0]|internal.example.com|*.internal.example.com|*.corp' + System.getProperty('ftp.nonProxyHosts') == System.getProperty('http.nonProxyHosts') and: 'credentials present -> tunnelling Basic scheme is enabled' System.getProperty('jdk.http.auth.tunneling.disabledSchemes') == '' and: 'the returned config carries the http/https proxies and no-proxy for java.net.http clients' @@ -311,6 +331,7 @@ class ProxyConfigTest extends Specification { ProxyConfig.setupFromEnvironment([:]) == null } + @ResourceLock('proxy-jvm-globals') def 'setupFromEnvironment should fall back to ALL_PROXY for the system properties' () { given: def keys = ['http.proxyHost','http.proxyPort','https.proxyHost','https.proxyPort'] @@ -330,6 +351,7 @@ class ProxyConfigTest extends Specification { } @Unroll + @ResourceLock('proxy-jvm-globals') def 'setupFromEnvironment disabledSchemes handling: preset=#PRESET creds=#CREDS -> #EXPECTED' () { given: def key = 'jdk.http.auth.tunneling.disabledSchemes' @@ -353,6 +375,7 @@ class ProxyConfigTest extends Specification { 'NTLM' | true || 'NTLM' // operator value always wins } + @ResourceLock('proxy-jvm-globals') def 'should clear the tunnelling disabledSchemes property only when unset' () { given: def key = 'jdk.http.auth.tunneling.disabledSchemes' From bb5e891412ffcb5baf6f9252f13f0ecd0bfa385c Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Sun, 6 Sep 2026 21:38:56 +0200 Subject: [PATCH 8/9] Redact the password in Endpoint.toString (review follow-up) Endpoint is public and returned by getHttpProxy()/getHttpsProxy(), so its default record toString() would render the proxy password if a consumer logged it - the same latent leak class B1 closed for Parsed. Add a redacting toString override. Co-Authored-By: Claude Opus 4.8 --- .../src/main/java/io/seqera/util/net/ProxyConfig.java | 6 ++++++ .../test/groovy/io/seqera/util/net/ProxyConfigTest.groovy | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java b/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java index 63a2ca99..9a341373 100644 --- a/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java +++ b/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java @@ -66,6 +66,12 @@ public boolean hasCredentials() { public InetSocketAddress address() { return InetSocketAddress.createUnresolved(host, port); } + @Override + public String toString() { + // never render the password (Endpoint is public, returned by getHttpProxy()/getHttpsProxy()) + return "Endpoint[host=" + host + ", port=" + port + ", username=" + username + + ", password=" + (password != null ? "****" : null) + "]"; + } } private final Endpoint httpProxy; // nullable diff --git a/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy b/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy index c52869af..ea846dbd 100644 --- a/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy +++ b/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy @@ -270,6 +270,14 @@ class ProxyConfigTest extends Specification { !ProxyConfig.fromUri('http://foo:secret1234@proxy.example.com').toString().contains('secret1234') } + def 'Endpoint toString should redact the password' () { + given: + def ep = ProxyConfig.fromUri('http://foo:secret1234@proxy.example.com:3128').httpProxy + expect: + !ep.toString().contains('secret1234') + ep.toString().contains('****') + } + def 'should not leak the proxy password in the error for an invalid proxy uri' () { when: 'an unsupported scheme carrying credentials' ProxyConfig.fromUri('socks5://foo:secret1234@proxy.example.com:1080') From 1285e911f27db552ddcaac64bbc48366635c247f Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Sun, 6 Sep 2026 21:40:46 +0200 Subject: [PATCH 9/9] [release] Remove HxProxyConfig in favour of lib-util-net ProxyConfig (lib-httpx 2.7.0) (#123) BREAKING (shipped as a minor - see changelog): delete HxProxyConfig and depend on io.seqera:lib-util-net so the proxy selector / authenticator / no-proxy semantics live in one place, shared with Wave and Nextflow instead of being duplicated across lib-httpx and lib-util-net. HxClient.Builder.withProxyConfig now accepts an io.seqera.util.net.ProxyConfig (its toProxySelector()/toAuthenticator()/getHttpProxy()/getHttpsProxy() are the same shape the method already used). Callers that built an HxProxyConfig resolve a ProxyConfig instead via ProxyConfig.fromUri(...)/fromEnvironment(...). The only affected consumer (Nextflow) is migrated in lock-step. Also updates README (version + proxy example/Key Classes now reference ProxyConfig) and reorders the publish workflow so lib-util-net is published before its new dependent lib-httpx. Co-authored-by: Claude Opus 4.8 --- .github/workflows/build.yml | 2 +- lib-httpx/README.md | 23 +- lib-httpx/VERSION | 2 +- lib-httpx/build.gradle | 1 + lib-httpx/changelog.txt | 17 ++ .../main/java/io/seqera/http/HxClient.java | 22 +- .../main/java/io/seqera/http/HxConfig.java | 8 +- .../java/io/seqera/http/HxProxyConfig.java | 258 ------------------ .../HxClientProxyAuthIntegrationTest.groovy | 9 +- .../io/seqera/http/HxProxyConfigTest.groovy | 168 ------------ 10 files changed, 52 insertions(+), 458 deletions(-) delete mode 100644 lib-httpx/src/main/java/io/seqera/http/HxProxyConfig.java delete mode 100644 lib-httpx/src/test/groovy/io/seqera/http/HxProxyConfigTest.groovy diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ce87cba7..14437542 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -85,6 +85,7 @@ jobs: bash publish.sh lib-lang bash publish.sh lib-lock bash publish.sh lib-lock-redis + bash publish.sh lib-util-net bash publish.sh lib-httpx bash publish.sh lib-mail bash publish.sh lib-node-id-redis @@ -94,7 +95,6 @@ jobs: bash publish.sh lib-util-string bash publish.sh lib-util-time bash publish.sh lib-util-tsid - bash publish.sh lib-util-net bash publish.sh lib-pool bash publish.sh lib-random bash publish.sh lib-retry diff --git a/lib-httpx/README.md b/lib-httpx/README.md index 48244c11..52f52a79 100644 --- a/lib-httpx/README.md +++ b/lib-httpx/README.md @@ -10,7 +10,7 @@ Add the dependency to your `build.gradle`: ```gradle dependencies { - implementation 'io.seqera:lib-httpx:2.6.0' + implementation 'io.seqera:lib-httpx:2.7.0' } ``` @@ -25,7 +25,7 @@ dependencies { - **Custom Token Storage**: Pluggable token store interface for distributed deployments (Redis, database, etc.) - **WWW-Authenticate Support**: Automatic handling of HTTP authentication challenges (Basic and Bearer schemes) - **Anonymous Authentication**: Fallback to anonymous authentication when credentials aren't provided -- **Proxy Support**: Authenticated forward-proxy support via `.proxy(...)`/`.authenticator(...)`, or an `HxProxyConfig` value applied with `.withProxyConfig(...)` +- **Proxy Support**: Authenticated forward-proxy support via `.proxy(...)`/`.authenticator(...)`, or an `io.seqera.util.net.ProxyConfig` (from `io.seqera:lib-util-net`) applied with `.withProxyConfig(...)` - **Configurable**: Customizable retry policies, timeouts, token refresh, authentication settings, and cookie policies - **Generic Integration**: Compatible with any `Retryable.Config` for flexible retry configuration - **Thread-safe**: Safe for concurrent use with atomic token refresh coordination @@ -403,16 +403,17 @@ HxClient client = HxClient.newBuilder() .build(); ``` -For callers that resolve proxy settings themselves (host, port, optional credentials per protocol and -`NO_PROXY` entries), `HxProxyConfig` bundles them into a single value and produces the matching selector -and a proxy-only authenticator (credentials are supplied only for proxy authentication challenges, never -to origin servers). Apply it in one call with `.withProxyConfig(...)`: +For callers that resolve proxy settings themselves, `io.seqera.util.net.ProxyConfig` (from +`io.seqera:lib-util-net`, an `api` dependency of this library) bundles a proxy URI or the +`HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` environment variables into a single value and produces the matching +selector and a proxy-only authenticator (credentials are supplied only for proxy authentication +challenges, never to origin servers). Apply it in one call with `.withProxyConfig(...)`: ```java -HxProxyConfig proxy = HxProxyConfig.newBuilder() - .httpsProxy("proxy.example.com", 8080, "user", "pass") - .noProxy(List.of("internal.example.com")) - .build(); +// prefer the explicit-credentials overload - it avoids having to percent-encode a password +// containing '@', ':' or '%' that would otherwise be embedded in the URI +ProxyConfig proxy = ProxyConfig.fromUri("http://proxy.example.com:8080", "user", "pass", + List.of("internal.example.com")); HxClient client = HxClient.newBuilder() .withProxyConfig(proxy) // no-op if proxy is null .build(); @@ -469,7 +470,7 @@ HxClient client = HxClient.newBuilder() - **`HxClient`**: Main HTTP client with retry, JWT, and WWW-Authenticate functionality - **`HxConfig`**: Configuration builder with all available options -- **`HxProxyConfig`**: Forward-proxy settings (selector + proxy-only authenticator) assembled from explicit values via its builder +- **`io.seqera.util.net.ProxyConfig`** (from `io.seqera:lib-util-net`): Forward-proxy settings (selector + proxy-only authenticator) resolved from a URI or the environment, applied via `HxClient.Builder.withProxyConfig(...)` - **`HxAuth`**: Interface for authentication credentials with stable identity across refreshes - **`HxTokenStore`**: Interface for pluggable token storage (default: in-memory ConcurrentHashMap) - **`HxTokenManager`**: Thread-safe JWT token lifecycle management with multi-session support diff --git a/lib-httpx/VERSION b/lib-httpx/VERSION index e70b4523..9aa34646 100644 --- a/lib-httpx/VERSION +++ b/lib-httpx/VERSION @@ -1 +1 @@ -2.6.0 +2.7.0 \ No newline at end of file diff --git a/lib-httpx/build.gradle b/lib-httpx/build.gradle index 76c51b45..deb95b04 100644 --- a/lib-httpx/build.gradle +++ b/lib-httpx/build.gradle @@ -24,6 +24,7 @@ version = "${project.file('VERSION').text.trim()}" dependencies { api project(':lib-retry') + api project(':lib-util-net') implementation 'com.google.code.gson:gson:2.10.1' testImplementation 'com.github.tomakehurst:wiremock:3.0.1' diff --git a/lib-httpx/changelog.txt b/lib-httpx/changelog.txt index d8a86b04..739452af 100644 --- a/lib-httpx/changelog.txt +++ b/lib-httpx/changelog.txt @@ -1,5 +1,22 @@ # lib-httpx changelog +2.7.0 +- BREAKING (see note below): remove HxProxyConfig in favour of io.seqera.util.net.ProxyConfig + (io.seqera:lib-util-net), so the proxy selector / authenticator / no-proxy semantics live in a + single place shared with Wave and Nextflow +- HxClient.Builder.withProxyConfig now accepts an io.seqera.util.net.ProxyConfig; callers that built an + HxProxyConfig should resolve a ProxyConfig instead - its toProxySelector()/toAuthenticator() are + unchanged. For raw credentials prefer the explicit overload + ProxyConfig.fromUri(uri, user, pass, noProxy) over embedding them in the URI, so a password with + '@'/':'/'%' does not need percent-encoding; fromEnvironment(env) reads HTTP_PROXY/HTTPS_PROXY/NO_PROXY +- Note: HxProxyConfig could hold an http proxy without an https one (or vice versa); fromUri applies one + endpoint to both protocols, so asymmetric per-protocol config from explicit values is now only + expressible via fromEnvironment +- lib-httpx now depends on io.seqera:lib-util-net +- NOTE: this removes a public type, so it is source-incompatible for any caller of HxProxyConfig. + It is released as a minor (2.7.0) rather than a major because the only affected consumer is + Nextflow, which is migrated in lock-step; callers pinning <= 2.6.0 are unaffected. + 2.6.0 - 12 Aug 2026 - Add HxClient.shouldRetryOnException(HttpRequest, Throwable) - the hook the retry policy now consults - so a subclass can decide on the exception per request diff --git a/lib-httpx/src/main/java/io/seqera/http/HxClient.java b/lib-httpx/src/main/java/io/seqera/http/HxClient.java index 9b66919d..9ae56228 100644 --- a/lib-httpx/src/main/java/io/seqera/http/HxClient.java +++ b/lib-httpx/src/main/java/io/seqera/http/HxClient.java @@ -40,6 +40,7 @@ import io.seqera.http.auth.AuthenticationChallenge; import io.seqera.http.auth.AuthenticationScheme; import io.seqera.http.auth.WwwAuthenticateParser; +import io.seqera.util.net.ProxyConfig; import io.seqera.util.retry.Retryable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -1195,12 +1196,12 @@ public Builder version(HttpClient.Version version) { *

The selector is forwarded to the inner {@link HttpClient.Builder} and is also * inherited by the internal HTTP clients used for JWT token refresh and anonymous * Bearer token retrieval. Proxy settings are never resolved from the environment - * automatically; supply them explicitly here (or via {@link #withProxyConfig(HxProxyConfig)}). + * automatically; supply them explicitly here (or via {@link #withProxyConfig(ProxyConfig)}). * * @param proxySelector the proxy selector to use * @return this Builder instance * @see HttpClient.Builder#proxy(ProxySelector) - * @see HxProxyConfig + * @see ProxyConfig */ public Builder proxy(ProxySelector proxySelector) { this.proxySelector = proxySelector; @@ -1220,12 +1221,12 @@ public Builder proxy(ProxySelector proxySelector) { * {@link Authenticator#setDefault(Authenticator)} - proxy credentials only take effect * when supplied via this method. For Basic proxy authentication of HTTPS traffic the JDK's * {@code jdk.http.auth.tunneling.disabledSchemes} property must also be cleared - see - * {@link HxProxyConfig} for details. + * {@link ProxyConfig} for details. * * @param authenticator the authenticator providing credentials * @return this Builder instance * @see HttpClient.Builder#authenticator(Authenticator) - * @see HxProxyConfig#toAuthenticator() + * @see ProxyConfig#toAuthenticator() */ public Builder authenticator(Authenticator authenticator) { this.proxyAuthenticator = authenticator; @@ -1233,16 +1234,17 @@ public Builder authenticator(Authenticator authenticator) { } /** - * Applies the proxy selector and authenticator carried by the given {@link HxProxyConfig}, - * a convenience over calling {@link #proxy(ProxySelector)} and - * {@link #authenticator(Authenticator)} separately. A {@code null} config is a no-op, so - * callers can pass an optionally-resolved configuration directly. + * Applies the proxy selector and authenticator carried by the given + * {@link ProxyConfig} (from {@code io.seqera:lib-util-net}), a convenience over calling + * {@link #proxy(ProxySelector)} and {@link #authenticator(Authenticator)} separately. A + * {@code null} config is a no-op, so callers can pass an optionally-resolved configuration + * directly. * * @param config the proxy configuration to apply, or null for none * @return this Builder instance - * @see HxProxyConfig + * @see ProxyConfig */ - public Builder withProxyConfig(HxProxyConfig config) { + public Builder withProxyConfig(ProxyConfig config) { if( config == null ) return this; if( config.getHttpProxy() != null || config.getHttpsProxy() != null ) diff --git a/lib-httpx/src/main/java/io/seqera/http/HxConfig.java b/lib-httpx/src/main/java/io/seqera/http/HxConfig.java index 6ba1e414..cfeb87ba 100644 --- a/lib-httpx/src/main/java/io/seqera/http/HxConfig.java +++ b/lib-httpx/src/main/java/io/seqera/http/HxConfig.java @@ -701,7 +701,7 @@ public Builder withRefreshCookiePolicy(CookiePolicy policy) { * * @param proxySelector the proxy selector, or null to use the JVM default behaviour * @return this builder instance for method chaining - * @see HxProxyConfig + * @see io.seqera.util.net.ProxyConfig */ public Builder proxySelector(ProxySelector proxySelector) { this.proxySelector = proxySelector; @@ -711,12 +711,12 @@ public Builder proxySelector(ProxySelector proxySelector) { /** * Sets the authenticator used to supply credentials to an authenticating forward proxy, * applied to the main HTTP client and the internal token refresh clients. See - * {@link HxProxyConfig} for the {@code Authenticator.setDefault} and Basic-over-HTTPS - * tunnelling caveats. + * {@link io.seqera.util.net.ProxyConfig} for the {@code Authenticator.setDefault} and + * Basic-over-HTTPS tunnelling caveats. * * @param proxyAuthenticator the authenticator providing proxy credentials, or null for none * @return this builder instance for method chaining - * @see HxProxyConfig#toAuthenticator() + * @see io.seqera.util.net.ProxyConfig#toAuthenticator() */ public Builder proxyAuthenticator(Authenticator proxyAuthenticator) { this.proxyAuthenticator = proxyAuthenticator; diff --git a/lib-httpx/src/main/java/io/seqera/http/HxProxyConfig.java b/lib-httpx/src/main/java/io/seqera/http/HxProxyConfig.java deleted file mode 100644 index 6e75fa70..00000000 --- a/lib-httpx/src/main/java/io/seqera/http/HxProxyConfig.java +++ /dev/null @@ -1,258 +0,0 @@ -/* - * Copyright 2026, Seqera Labs - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package io.seqera.http; - -import java.io.IOException; -import java.net.Authenticator; -import java.net.InetSocketAddress; -import java.net.PasswordAuthentication; -import java.net.Proxy; -import java.net.ProxySelector; -import java.net.SocketAddress; -import java.net.URI; -import java.util.List; -import java.util.Locale; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Holds HTTP/HTTPS forward-proxy settings supplied explicitly (host, port and optional - * credentials per protocol, plus {@code NO_PROXY} entries) and exposes them as a - * {@link ProxySelector} and a proxy-only {@link Authenticator} suitable for - * {@link java.net.http.HttpClient}. Build one with {@link #newBuilder()}. - * - *

The library never reads the environment or system properties on its own; the caller - * resolves proxy settings however it wishes and passes the values to {@link Builder}. - * - *

NO_PROXY entries are honoured as a list of host names or domain suffixes - * (optionally prefixed with . or *.); the single entry * - * disables proxying entirely. Loopback targets (localhost, 127.*, - * [::1]) always bypass the proxy, mirroring the JDK default - * http.nonProxyHosts behaviour. CIDR notation entries are not supported. - * - *

Why an explicit Authenticator?
- * {@link java.net.http.HttpClient} ignores {@link Authenticator#setDefault(Authenticator)}; - * proxy credentials only take effect when supplied via - * {@link java.net.http.HttpClient.Builder#authenticator(Authenticator)}. The authenticator - * returned by {@link #toAuthenticator()} releases credentials only for - * {@link Authenticator.RequestorType#PROXY} requests that match the configured proxy - * host and port, never for origin-server challenges. - * - *

Basic auth over HTTPS tunnels:
- * The JDK disables the Basic scheme for HTTPS CONNECT tunnelling by default via - * jdk.http.auth.tunneling.disabledSchemes=Basic (see - * $JAVA_HOME/conf/net.properties). For proxy credentials to be sent on the - * CONNECT request of HTTPS traffic, that property must be cleared, e.g. with - * -Djdk.http.auth.tunneling.disabledSchemes=. - * - * @see HxClient.Builder#proxy(ProxySelector) - * @see HxClient.Builder#authenticator(Authenticator) - */ -public class HxProxyConfig { - - private static final Logger log = LoggerFactory.getLogger(HxProxyConfig.class); - - /** - * Connection details for a single proxy server, with optional credentials. - */ - static final class ProxyEntry { - final String host; - final int port; - final String username; - final String password; - - ProxyEntry(String host, int port, String username, String password) { - this.host = host; - this.port = port; - this.username = username; - this.password = password; - } - - boolean hasCredentials() { - return username != null && !username.isEmpty(); - } - - InetSocketAddress address() { - return InetSocketAddress.createUnresolved(host, port); - } - } - - private final ProxyEntry httpProxy; - private final ProxyEntry httpsProxy; - private final List noProxyHosts; - - private HxProxyConfig(ProxyEntry httpProxy, ProxyEntry httpsProxy, List noProxyHosts) { - this.httpProxy = httpProxy; - this.httpsProxy = httpsProxy; - this.noProxyHosts = noProxyHosts; - } - - /** - * Creates a builder to assemble a proxy configuration from explicit values, for callers that - * resolve proxy settings themselves rather than from the environment. - * - * @return a new {@link Builder} - */ - public static Builder newBuilder() { - return new Builder(); - } - - /** - * Builder for {@link HxProxyConfig}: the proxy details are supplied explicitly - * (host, port and optional credentials per protocol, plus {@code NO_PROXY} entries). - */ - public static final class Builder { - private ProxyEntry httpProxy; - private ProxyEntry httpsProxy; - private List noProxyHosts = List.of(); - - public Builder httpProxy(String host, int port, String username, String password) { - this.httpProxy = host != null ? new ProxyEntry(host, port, username, password) : null; - return this; - } - - public Builder httpsProxy(String host, int port, String username, String password) { - this.httpsProxy = host != null ? new ProxyEntry(host, port, username, password) : null; - return this; - } - - public Builder noProxy(List hosts) { - this.noProxyHosts = hosts == null - ? List.of() - : hosts.stream() - .filter(h -> h != null) - .map(h -> h.trim().toLowerCase(Locale.ROOT)) - .filter(h -> !h.isEmpty()) - .toList(); - return this; - } - - public HxProxyConfig build() { - return new HxProxyConfig(httpProxy, httpsProxy, noProxyHosts); - } - } - - /** - * Creates a {@link ProxySelector} that routes requests through the configured proxies, - * bypassing hosts matched by {@code NO_PROXY} and loopback addresses. - * - * @return a new ProxySelector reflecting this configuration - */ - public ProxySelector toProxySelector() { - // precompute the proxy lists - select() runs once per outbound request - final List direct = List.of(Proxy.NO_PROXY); - final List viaHttpProxy = httpProxy != null - ? List.of(new Proxy(Proxy.Type.HTTP, httpProxy.address())) - : direct; - final List viaHttpsProxy = httpsProxy != null - ? List.of(new Proxy(Proxy.Type.HTTP, httpsProxy.address())) - : direct; - return new ProxySelector() { - @Override - public List select(URI uri) { - if (uri == null || isBypassed(uri.getHost())) - return direct; - return "https".equalsIgnoreCase(uri.getScheme()) ? viaHttpsProxy : viaHttpProxy; - } - - @Override - public void connectFailed(URI uri, SocketAddress sa, IOException ioe) { - log.debug("Failed to connect to proxy {} for {}: {}", sa, uri, ioe.getMessage()); - } - }; - } - - /** - * Creates an {@link Authenticator} that supplies the configured proxy credentials. - * - *

Credentials are released only for {@link Authenticator.RequestorType#PROXY} - * requests whose host and port match the configured proxy, never for origin-server - * authentication challenges. - * - * @return a new Authenticator, or null when no proxy credentials are configured - */ - public Authenticator toAuthenticator() { - if (!hasCredentials()) - return null; - return new Authenticator() { - @Override - protected PasswordAuthentication getPasswordAuthentication() { - if (getRequestorType() != RequestorType.PROXY) - return null; - final ProxyEntry entry = credentialsFor(getRequestingHost(), getRequestingPort()); - if (entry == null) - return null; - final String password = entry.password != null ? entry.password : ""; - return new PasswordAuthentication(entry.username, password.toCharArray()); - } - }; - } - - /** - * @return true when at least one configured proxy carries credentials - */ - public boolean hasCredentials() { - return (httpProxy != null && httpProxy.hasCredentials()) - || (httpsProxy != null && httpsProxy.hasCredentials()); - } - - ProxyEntry getHttpProxy() { - return httpProxy; - } - - ProxyEntry getHttpsProxy() { - return httpsProxy; - } - - /** - * Determines whether the given target host must bypass the proxy, either because it is - * a loopback address or because it matches a {@code NO_PROXY} entry. - */ - boolean isBypassed(String host) { - if (host == null) - return true; - final String target = host.toLowerCase(Locale.ROOT); - // always bypass loopback targets, consistent with the JDK default `http.nonProxyHosts` - if (target.equals("localhost") || target.startsWith("127.") || target.equals("::1") || target.equals("[::1]")) - return true; - for (String entry : noProxyHosts) { - if (entry.equals("*")) - return true; - // "*.example.com" and ".example.com" match sub-domains only; - // "example.com" matches the host itself and any sub-domain - final String suffix = entry.startsWith("*.") ? entry.substring(1) : entry; - if (suffix.startsWith(".")) { - if (target.endsWith(suffix)) - return true; - } - else if (target.equals(suffix) || target.endsWith("." + suffix)) { - return true; - } - } - return false; - } - - private ProxyEntry credentialsFor(String host, int port) { - if (httpsProxy != null && httpsProxy.hasCredentials() && httpsProxy.host.equalsIgnoreCase(host) && httpsProxy.port == port) - return httpsProxy; - if (httpProxy != null && httpProxy.hasCredentials() && httpProxy.host.equalsIgnoreCase(host) && httpProxy.port == port) - return httpProxy; - return null; - } -} diff --git a/lib-httpx/src/test/groovy/io/seqera/http/HxClientProxyAuthIntegrationTest.groovy b/lib-httpx/src/test/groovy/io/seqera/http/HxClientProxyAuthIntegrationTest.groovy index b7dbe930..dc95ffe6 100644 --- a/lib-httpx/src/test/groovy/io/seqera/http/HxClientProxyAuthIntegrationTest.groovy +++ b/lib-httpx/src/test/groovy/io/seqera/http/HxClientProxyAuthIntegrationTest.groovy @@ -24,6 +24,7 @@ import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse +import io.seqera.util.net.ProxyConfig import spock.lang.Specification /** @@ -202,11 +203,9 @@ class HxClientProxyAuthIntegrationTest extends Specification { client.getConfig().getProxyAuthenticator().is(authenticator) } - def 'should route through the proxy via withProxyConfig and the HxProxyConfig builder'() { - given: 'a proxy config assembled from explicit values' - def proxyConfig = HxProxyConfig.newBuilder() - .httpProxy('127.0.0.1', proxyPort, 'alice', 's3cret') - .build() + def 'should route through the proxy via withProxyConfig and a lib-util-net ProxyConfig'() { + given: 'a proxy config resolved from an explicit uri' + def proxyConfig = ProxyConfig.fromUri("http://alice:s3cret@127.0.0.1:${proxyPort}".toString()) def client = HxClient.newBuilder() .withProxyConfig(proxyConfig) .build() diff --git a/lib-httpx/src/test/groovy/io/seqera/http/HxProxyConfigTest.groovy b/lib-httpx/src/test/groovy/io/seqera/http/HxProxyConfigTest.groovy deleted file mode 100644 index b5d40d90..00000000 --- a/lib-httpx/src/test/groovy/io/seqera/http/HxProxyConfigTest.groovy +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright 2026, Seqera Labs - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package io.seqera.http - -import java.net.Authenticator -import java.net.PasswordAuthentication -import java.net.Proxy - -import spock.lang.Specification - -/** - * Test cases for {@link HxProxyConfig} proxy selection and proxy authentication behaviour. - */ -class HxProxyConfigTest extends Specification { - - private static PasswordAuthentication invokeAuth(Authenticator authenticator, Authenticator.RequestorType type, String host, int port) { - // drive the protected Authenticator API the same way the JDK HTTP client does - return authenticator.requestPasswordAuthenticationInstance( - host, null, port, 'http', 'proxy auth', 'basic', new URL('http://example.com'), type ) - } - - def 'builder should assemble a proxy config from explicit values'() { - when: - def config = HxProxyConfig.newBuilder() - .httpsProxy('proxy.example.com', 8080, 'foo', 'bar') - .noProxy(['internal.example.com']) - .build() - - then: - config.httpsProxy.host == 'proxy.example.com' - config.httpsProxy.port == 8080 - config.httpsProxy.username == 'foo' - config.httpsProxy.password == 'bar' - config.httpProxy == null - config.hasCredentials() - and: 'the selector honours the explicit NO_PROXY entry' - config.isBypassed('internal.example.com') - !config.isBypassed('api.example.com') - and: - config.toAuthenticator() != null - } - - def 'builder without credentials yields no authenticator'() { - when: - def config = HxProxyConfig.newBuilder() - .httpProxy('proxy.local', 3128, null, null) - .build() - - then: - config.httpProxy.host == 'proxy.local' - config.httpProxy.port == 3128 - !config.hasCredentials() - config.toAuthenticator() == null - } - - def 'should select the proxy matching the target scheme'() { - given: - def config = HxProxyConfig.newBuilder() - .httpProxy('http-proxy', 3128, null, null) - .httpsProxy('https-proxy', 3129, null, null) - .build() - def selector = config.toProxySelector() - - when: - def httpResult = selector.select(URI.create('http://api.example.com/foo')) - def httpsResult = selector.select(URI.create('https://api.example.com/foo')) - - then: - (httpResult[0].address() as InetSocketAddress).hostString == 'http-proxy' - (httpResult[0].address() as InetSocketAddress).port == 3128 - (httpsResult[0].address() as InetSocketAddress).hostString == 'https-proxy' - (httpsResult[0].address() as InetSocketAddress).port == 3129 - } - - def 'should connect directly when no proxy matches the target scheme'() { - given: - def config = HxProxyConfig.newBuilder().httpsProxy('proxy', 3128, null, null).build() - - expect: - config.toProxySelector().select(URI.create('http://api.example.com/foo')) == [Proxy.NO_PROXY] - } - - def 'should bypass proxy for NO_PROXY entries'() { - given: - def config = HxProxyConfig.newBuilder() - .httpProxy('proxy', 3128, null, null) - .httpsProxy('proxy', 3128, null, null) - .noProxy(['internal.example.com', '.corp.example.org', '*.svc.cluster.local']) - .build() - - expect: - config.isBypassed(host) == bypassed - - where: - host | bypassed - 'internal.example.com' | true - 'sub.internal.example.com' | true - 'other.example.com' | false - 'internal.example.com.evil.io'| false - 'foo.corp.example.org' | true - 'corp.example.org' | false - 'db.default.svc.cluster.local'| true - } - - def 'should bypass everything when NO_PROXY is a wildcard'() { - given: - def config = HxProxyConfig.newBuilder().httpsProxy('proxy', 3128, null, null).noProxy(['*']).build() - - expect: - config.toProxySelector().select(URI.create('https://api.example.com')) == [Proxy.NO_PROXY] - } - - def 'should always bypass loopback targets'() { - given: - def config = HxProxyConfig.newBuilder().httpsProxy('proxy', 3128, null, null).build() - - expect: - config.isBypassed('localhost') - config.isBypassed('127.0.0.1') - !config.isBypassed('api.example.com') - } - - def 'should provide credentials only for matching proxy requests'() { - given: - def config = HxProxyConfig.newBuilder().httpsProxy('proxy.example.com', 8080, 'foo', 'bar').build() - def authenticator = config.toAuthenticator() - - when: 'the proxy itself requests authentication' - def auth = invokeAuth(authenticator, Authenticator.RequestorType.PROXY, 'proxy.example.com', 8080) - - then: - auth.userName == 'foo' - new String(auth.password) == 'bar' - - when: 'an origin server requests authentication' - auth = invokeAuth(authenticator, Authenticator.RequestorType.SERVER, 'proxy.example.com', 8080) - - then: - auth == null - - when: 'a different host requests proxy authentication' - auth = invokeAuth(authenticator, Authenticator.RequestorType.PROXY, 'other-proxy.example.com', 8080) - - then: - auth == null - - when: 'the right host but a different port requests proxy authentication' - auth = invokeAuth(authenticator, Authenticator.RequestorType.PROXY, 'proxy.example.com', 9999) - - then: - auth == null - } -}