Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
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