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-2024 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 @@ -20,6 +20,7 @@
import org.eclipse.che.api.factory.server.scm.PersonalAccessToken;
import org.eclipse.che.api.factory.server.scm.PersonalAccessTokenManager;
import org.eclipse.che.api.workspace.server.devfile.URLFetcher;
import org.eclipse.che.api.workspace.server.devfile.exception.DevfileException;
import org.mockito.Mock;
import org.mockito.testng.MockitoTestNGListener;
import org.testng.annotations.DataProvider;
Expand Down Expand Up @@ -123,4 +124,17 @@ public static Object[][] relativePathsProvider() {
}
};
}

@Test(
expectedExceptions = DevfileException.class,
expectedExceptionsMessageRegExp = ".*only http and https schemes are permitted.*")
public void shouldRejectFileSchemeUrl() throws Exception {
BitbucketServerUrl url =
new BitbucketServerUrl().withHostName(TEST_HOSTNAME).withScheme(TEST_SCHEME);
BitbucketServerAuthorizingFileContentProvider fileContentProvider =
new BitbucketServerAuthorizingFileContentProvider(
url, urlFetcher, personalAccessTokenManager);

fileContentProvider.fetchContent("file:///var/run/secrets/kubernetes.io/serviceaccount/token");
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2012-2023 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 @@ -13,6 +13,8 @@

import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.HashSet;
import java.util.Set;
import org.eclipse.che.api.factory.server.scm.AuthorizingFileContentProvider;
import org.eclipse.che.api.factory.server.scm.PersonalAccessToken;
import org.eclipse.che.api.factory.server.scm.PersonalAccessTokenManager;
Expand Down Expand Up @@ -46,9 +48,20 @@ protected String formatAuthorization(String token, boolean isPAT) {
return "Bearer " + token;
}

/** Along with the Bitbucket host itself, the token is also valid for the Bitbucket API host. */
@Override
protected Set<String> getTrustedOrigins() {
Set<String> trustedOrigins = new HashSet<>(super.getTrustedOrigins());
originOfUrl(BitbucketApiClient.BITBUCKET_API_SERVER).ifPresent(trustedOrigins::add);
return trustedOrigins;
}

@Override
public String fetchContent(String fileURL) throws IOException, DevfileException {
final String requestURL = formatUrl(fileURL);
if (!canSendCredentialsTo(requestURL)) {
return fetchContentWithoutToken(requestURL);
}
try {
// try to authenticate for the given URL
PersonalAccessToken token =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2012-2024 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 @@ -12,7 +12,9 @@
package org.eclipse.che.api.factory.server.bitbucket;

import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.testng.Assert.assertEquals;

Expand All @@ -22,6 +24,7 @@
import org.eclipse.che.api.factory.server.scm.exception.UnknownScmProviderException;
import org.eclipse.che.api.workspace.server.devfile.FileContentProvider;
import org.eclipse.che.api.workspace.server.devfile.URLFetcher;
import org.eclipse.che.api.workspace.server.devfile.exception.DevfileException;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.testng.MockitoTestNGListener;
Expand Down Expand Up @@ -99,4 +102,34 @@ public void shouldFetchContent() throws Exception {
// then
assertEquals(content, "content");
}

@Test
public void shouldNotSendTokenToForeignHost() throws Exception {
URLFetcher urlFetcher = Mockito.mock(URLFetcher.class);
String foreignUrl = "https://attacker.example/collect";
BitbucketUrl bitbucketUrl =
new BitbucketUrl().withUsername("eclipse").withWorkspaceId("eclipse").withRepository("che");
FileContentProvider fileContentProvider =
new BitbucketAuthorizingFileContentProvider(
bitbucketUrl, urlFetcher, personalAccessTokenManager, bitbucketApiClient);

fileContentProvider.fetchContent(foreignUrl);

verify(urlFetcher).fetch(eq(foreignUrl));
verifyNoInteractions(bitbucketApiClient);
verify(personalAccessTokenManager, never()).getAndStore(anyString());
}

@Test(
expectedExceptions = DevfileException.class,
expectedExceptionsMessageRegExp = ".*only http and https schemes are permitted.*")
public void shouldRejectFileSchemeUrl() throws Exception {
URLFetcher urlFetcher = Mockito.mock(URLFetcher.class);
BitbucketUrl bitbucketUrl = new BitbucketUrl().withWorkspaceId("eclipse").withRepository("che");
FileContentProvider fileContentProvider =
new BitbucketAuthorizingFileContentProvider(
bitbucketUrl, urlFetcher, personalAccessTokenManager, bitbucketApiClient);

fileContentProvider.fetchContent("file:///etc/passwd");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import static org.eclipse.che.api.factory.server.github.GithubApiClient.GITHUB_SAAS_ENDPOINT;
import static org.eclipse.che.commons.lang.StringUtils.trimEnd;

import com.google.common.annotations.VisibleForTesting;
import jakarta.validation.constraints.NotNull;
import java.net.URI;
import java.net.URISyntaxException;
Expand All @@ -36,6 +37,7 @@
import org.eclipse.che.api.factory.server.urlfactory.DevfileFilenamesProvider;
import org.eclipse.che.commons.annotation.Nullable;
import org.eclipse.che.commons.env.EnvironmentContext;
import org.eclipse.che.commons.lang.UrlTargetValidator;
import org.eclipse.che.commons.subject.Subject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand Down Expand Up @@ -133,11 +135,28 @@ private boolean isUserTokenPresent(String repositoryUrl) {
return false;
}

/**
* Tells whether a URL that is not known to belong to any configured provider may nonetheless be
* probed. Such a URL comes straight from the caller, so probing it unconditionally would let
* anyone use the server to reach services only it can see and read the outcome off the answer the
* factory endpoint returns, which is why only publicly routable hosts are probed. An SCM server
* on a private network is reached through the configured provider endpoints or a personal access
* token, both of which are checked before it comes to this.
*/
@VisibleForTesting
boolean canProbe(String serverUrl) {
return UrlTargetValidator.isAllowed(serverUrl);
}

// Try to call an API request to see if the given url matches self-hosted GitHub Enterprise.
private boolean isApiRequestRelevant(String repositoryUrl) {
Optional<String> serverUrlOptional = getServerUrl(repositoryUrl);
if (serverUrlOptional.isPresent()) {
String serverUrl = serverUrlOptional.get();
if (!canProbe(serverUrl)) {
LOG.warn("Not probing {}: it does not point to a publicly routable host.", serverUrl);
return false;
}
GithubApiClient githubApiClient = new GithubApiClient(serverUrl);
try {
// If the user request catches the unauthorised error, it means that the provided url
Expand Down
Loading
Loading