Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*
* Copyright (c) 2012-2026 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/
package org.eclipse.che.commons.lang;

import static com.google.common.base.Strings.isNullOrEmpty;

import java.io.IOException;
import java.net.InetAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.UnknownHostException;

/**
* Checks that a URL derived from user input points at a target the server is allowed to reach.
*
* <p>Several endpoints take a URL from the user and make the server fetch it. Without a check on
* the destination, such a URL can be aimed at a service that is only reachable from inside the
* cluster - the cloud metadata endpoint, the Kubernetes API, a neighbouring pod - turning the
* server into a proxy for the caller (SSRF).
*/
public final class UrlTargetValidator {

private UrlTargetValidator() {}

/**
* Throws if the given URL may not be requested by the server, either because of its scheme or
* because its host resolves to an address that is not publicly routable.
*
* @param url the URL about to be requested
* @throws IOException if the URL is malformed or its target is not allowed
*/
public static void validate(String url) throws IOException {
// the scheme is read off the raw string: an opaque URL such as jar:file:/x!/y is rejected here
// rather than reported as a parsing failure
int schemeEnd = url == null ? -1 : url.indexOf(':');
String scheme = schemeEnd > 0 ? url.substring(0, schemeEnd) : null;
if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
throw new IOException(
"Only http and https URLs are allowed, got: " + scheme + " in URL " + url);
}

final URI uri;
try {
uri = new URI(url);
} catch (URISyntaxException e) {
throw new IOException("Invalid URL " + url, e);
}

String host = uri.getHost();
if (isNullOrEmpty(host)) {
throw new IOException("URL host is missing in " + url);
}

final InetAddress[] addresses;
try {
// all records are checked, so that a host publishing both a public and an internal address
// cannot pass the check and then be connected to on the internal one
addresses = InetAddress.getAllByName(host);
} catch (UnknownHostException e) {
throw new IOException("Unable to resolve URL host " + host + " in " + url, e);
}

for (InetAddress address : addresses) {
if (!isPubliclyRoutable(address)) {
throw new IOException("URL host is not allowed: " + host);
}
}
}

/**
* Same check as {@link #validate(String)}, in a form usable where a URL is probed on a best
* effort basis and a disallowed target simply means "not a match".
*
* @param url the URL about to be requested
* @return true if the server may request the URL
*/
public static boolean isAllowed(String url) {
try {
validate(url);
return true;
} catch (IOException e) {
return false;
}
}

/**
* Tells whether an address belongs to the public internet, as opposed to the ranges reserved for
* private networks, the host itself, or protocol machinery.
*/
private static boolean isPubliclyRoutable(InetAddress address) {
if (address.isAnyLocalAddress()
|| address.isLoopbackAddress()
|| address.isLinkLocalAddress()
// IPv4 private ranges and the deprecated IPv6 site-local fec0::/10
|| address.isSiteLocalAddress()
|| address.isMulticastAddress()) {
return false;
}

byte[] bytes = address.getAddress();
if (bytes.length == 4) {
int first = bytes[0] & 0xFF;
int second = bytes[1] & 0xFF;
// 100.64.0.0/10 shared address space (RFC 6598), used by several CNI plugins
if (first == 100 && second >= 64 && second <= 127) {
return false;
}
// 192.0.0.0/24 IETF protocol assignments (RFC 6890)
Comment thread
vinokurig marked this conversation as resolved.
if (first == 192 && second == 0 && (bytes[2] & 0xFF) == 0) {
return false;
}
// 198.18.0.0/15 benchmarking (RFC 2544)
if (first == 198 && (second == 18 || second == 19)) {
return false;
}
// 240.0.0.0/4 reserved, including the 255.255.255.255 broadcast address
if (first >= 240) {
return false;
}
} else if (bytes.length == 16) {
// fc00::/7 unique local addresses (RFC 4193), which isSiteLocalAddress does not cover
if ((bytes[0] & 0xFE) == 0xFC) {
return false;
}
}
return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2012-2026 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/
package org.eclipse.che.commons.lang;

import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertTrue;

import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;

/** Tests of {@link UrlTargetValidator}. */
public class UrlTargetValidatorTest {

@DataProvider
public Object[][] disallowedUrls() {
return new Object[][] {
// schemes that are not a web request at all
{"file:///etc/passwd"},
{"ftp://example.com/file"},
{"jar:file:///tmp/evil.jar!/payload"},
{"gopher://example.com/"},
{"no-scheme-at-all"},
// the host itself
{"http://127.0.0.1/"},
{"http://[::1]/"},
{"http://0/"},
{"http://[::ffff:127.0.0.1]/"},
// cloud metadata, reachable on the link-local range
{"http://169.254.169.254/latest/meta-data/"},
{"http://[fe80::1]/"},
{"http://[0:0:0:0:0:ffff:a9fe:a9fe]/"},
// IPv4 private ranges
{"http://10.0.0.1/"},
{"http://172.16.0.1/"},
{"http://192.168.1.1/"},
// IPv6 unique local addresses, which InetAddress#isSiteLocalAddress does not cover
{"http://[fc00::1]/"},
{"http://[fd12:3456:789a::1]/"},
// ranges that are not the public internet either
{"http://100.64.1.1/"},
{"http://192.0.0.1/"},
{"http://198.18.0.1/"},
{"http://240.0.0.1/"},
{"http://255.255.255.255/"},
// no host to check
{"http:///path"},
};
}

@Test(dataProvider = "disallowedUrls")
public void shouldRejectUrl(String url) {
assertFalse(UrlTargetValidator.isAllowed(url), url + " should not be reachable");
}

@DataProvider
public Object[][] allowedUrls() {
return new Object[][] {
{"https://93.184.216.34/devfile.yaml"},
{"http://8.8.8.8/"},
{"https://[2001:4860:4860::8888]/"},
};
}

@Test(dataProvider = "allowedUrls")
public void shouldAllowUrl(String url) {
assertTrue(UrlTargetValidator.isAllowed(url), url + " should be reachable");
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2012-2025 Red Hat, Inc.
* Copyright (c) 2012-2026 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
Expand All @@ -14,6 +14,7 @@
import static org.eclipse.che.api.factory.server.azure.devops.AzureDevOps.getAuthenticateUrlPath;
import static org.eclipse.che.commons.lang.StringUtils.trimEnd;

import com.google.common.annotations.VisibleForTesting;
import java.util.Arrays;
import java.util.Optional;
import javax.inject.Inject;
Expand All @@ -35,6 +36,7 @@
import org.eclipse.che.api.factory.server.scm.exception.UnknownScmProviderException;
import org.eclipse.che.commons.lang.NameGenerator;
import org.eclipse.che.commons.lang.Pair;
import org.eclipse.che.commons.lang.UrlTargetValidator;
import org.eclipse.che.commons.subject.Subject;
import org.eclipse.che.security.oauth.OAuthAPI;
import org.slf4j.Logger;
Expand Down Expand Up @@ -171,10 +173,29 @@ public Optional<Boolean> isValid(PersonalAccessToken personalAccessToken) {
}
}

/**
* Tells whether the server may contact an Azure DevOps Server that is not the SaaS endpoint. Such
* a URL comes from a secret in the user's namespace, so contacting it unconditionally would let
* anyone holding a namespace have the server reach services only it can see (SSRF). A server on a
* private network is reached through the configured provider endpoint, which is matched before it
* comes to this.
*/
@VisibleForTesting
boolean canContact(String scmServerUrl) {
return UrlTargetValidator.isAllowed(scmServerUrl);
}

@Override
public Optional<Pair<Boolean, String>> isValid(PersonalAccessTokenParams params) {
if (!isValidAzureDevOpsSAASUrl(params.getScmProviderUrl())) {
if (OAUTH_PROVIDER_NAME.equals(params.getScmProviderName())) {
if (!canContact(params.getScmProviderUrl())) {
LOG.warn(
"Not contacting {}: it is not the configured Azure DevOps endpoint and does not point"
+ " to a publicly routable host.",
params.getScmProviderUrl());
return Optional.empty();
}
AzureDevOpsServerApiClient azureDevOpsServerApiClient =
new AzureDevOpsServerApiClient(params.getScmProviderUrl(), params.getOrganization());
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2012-2025 Red Hat, Inc.
* Copyright (c) 2012-2026 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
Expand All @@ -15,6 +15,7 @@
import static org.eclipse.che.api.factory.server.azure.devops.AzureDevOps.SAAS_ENDPOINT;
import static org.eclipse.che.api.factory.server.azure.devops.AzureDevOps.getAuthenticateUrlPath;

import com.google.common.annotations.VisibleForTesting;
import javax.inject.Inject;
import javax.inject.Named;
import org.eclipse.che.api.factory.server.scm.AbstractGitUserDataFetcher;
Expand All @@ -25,6 +26,7 @@
import org.eclipse.che.api.factory.server.scm.exception.ScmCommunicationException;
import org.eclipse.che.api.factory.server.scm.exception.ScmItemNotFoundException;
import org.eclipse.che.api.factory.server.scm.exception.ScmUnauthorizedException;
import org.eclipse.che.commons.lang.UrlTargetValidator;

/**
* Azure DevOps user data fetcher.
Expand Down Expand Up @@ -61,6 +63,18 @@ protected GitUserData fetchGitUserDataWithOAuthToken(String token)
return new GitUserData(user.getDisplayName(), user.getEmailAddress());
}

/**
* Tells whether the server may contact an Azure DevOps Server that is not the SaaS endpoint. Such
* a URL comes from a secret in the user's namespace, so contacting it unconditionally would let
* anyone holding a namespace have the server reach services only it can see (SSRF). A server on a
* private network is reached through the configured provider endpoint, which is matched before it
* comes to this.
*/
@VisibleForTesting
boolean canContact(String scmServerUrl) {
return UrlTargetValidator.isAllowed(scmServerUrl);
}

@Override
protected GitUserData fetchGitUserDataWithPersonalAccessToken(
PersonalAccessToken personalAccessToken)
Expand All @@ -78,6 +92,13 @@ protected GitUserData fetchGitUserDataWithPersonalAccessToken(
return new GitUserData(user.getDisplayName(), user.getEmailAddress());
}
} else {
if (!canContact(personalAccessToken.getScmProviderUrl())) {
throw new ScmCommunicationException(
"Refusing to contact "
+ personalAccessToken.getScmProviderUrl()
+ ": it is not the configured Azure DevOps endpoint and does not point to a"
+ " publicly routable host.");
}
AzureDevOpsServerApiClient apiClient =
new AzureDevOpsServerApiClient(
personalAccessToken.getScmProviderUrl(), personalAccessToken.getScmOrganization());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,13 +190,19 @@ public void shouldNotValidateSAASPersonalAccessToken() throws Exception {

@Test
public void shouldValidateServerPersonalAccessToken() throws Exception {
// the wiremock server stands in for an Azure DevOps Server, but is only reachable over loopback
personalAccessTokenFetcher =
new AzureDevOpsPersonalAccessTokenFetcher(
"localhost",
"https://dev.azure-server.com",
new String[] {},
new AzureDevOpsApiClient(wireMockServer.url("/")),
oAuthAPI);
oAuthAPI) {
@Override
boolean canContact(String scmServerUrl) {
return true;
}
};
stubFor(
get(urlEqualTo("/organization/_api/_common/GetUserProfile"))
.withHeader(
Expand Down Expand Up @@ -246,4 +252,30 @@ public void shouldValidateOauthToken() throws Exception {
assertTrue(valid.isPresent());
assertTrue(valid.get().first);
}

/**
* The provider URL of a token comes from a secret in the user's namespace, so it must not become
* a way of having the server reach whatever the namespace owner names.
*/
@Test
public void shouldNotContactPrivateAddresses() throws Exception {
personalAccessTokenFetcher =
new AzureDevOpsPersonalAccessTokenFetcher(
"localhost",
"https://dev.azure-server.com",
new String[] {},
new AzureDevOpsApiClient(wireMockServer.url("/")),
oAuthAPI);

PersonalAccessTokenParams params =
new PersonalAccessTokenParams(
"https://10.0.0.1",
"azure-devops",
"token-name",
"tid-23434",
azureDevOpsToken,
"organization");

assertTrue(personalAccessTokenFetcher.isValid(params).isEmpty());
}
}
Loading
Loading