diff --git a/services/secondary-storage/server/src/main/java/org/apache/cloudstack/storage/formatinspector/Qcow2HeaderField.java b/services/secondary-storage/server/src/main/java/org/apache/cloudstack/storage/formatinspector/Qcow2HeaderField.java
new file mode 100644
index 00000000000..4a8e8b51a47
--- /dev/null
+++ b/services/secondary-storage/server/src/main/java/org/apache/cloudstack/storage/formatinspector/Qcow2HeaderField.java
@@ -0,0 +1,51 @@
+// 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.storage.formatinspector;
+
+public enum Qcow2HeaderField {
+ MAGIC(0, 4),
+ VERSION(4, 4),
+ BACKING_FILE_OFFSET(8, 8),
+ BACKING_FILE_NAME_LENGTH(16, 4),
+ CLUSTER_BITS(20, 4),
+ SIZE(24, 8),
+ CRYPT_METHOD(32, 4),
+ L1_SIZE(36, 4),
+ LI_TABLE_OFFSET(40, 8),
+ REFCOUNT_TABLE_OFFSET(48, 8),
+ REFCOUNT_TABLE_CLUSTERS(56, 4),
+ NB_SNAPSHOTS(60, 4),
+ SNAPSHOTS_OFFSET(64, 8),
+ INCOMPATIBLE_FEATURES(72, 8);
+
+ private final int offset;
+ private final int length;
+
+ Qcow2HeaderField(int offset, int length) {
+ this.offset = offset;
+ this.length = length;
+ }
+
+ public int getLength() {
+ return length;
+ }
+
+ public int getOffset() {
+ return offset;
+ }
+}
diff --git a/services/secondary-storage/server/src/main/java/org/apache/cloudstack/storage/formatinspector/Qcow2Inspector.java b/services/secondary-storage/server/src/main/java/org/apache/cloudstack/storage/formatinspector/Qcow2Inspector.java
new file mode 100644
index 00000000000..1ad2076a12d
--- /dev/null
+++ b/services/secondary-storage/server/src/main/java/org/apache/cloudstack/storage/formatinspector/Qcow2Inspector.java
@@ -0,0 +1,267 @@
+// 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.storage.formatinspector;
+
+import com.cloud.utils.NumbersUtil;
+import org.apache.commons.lang3.ArrayUtils;
+import org.apache.log4j.Logger;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Class to inspect QCOW2 files/objects. In our context, a QCOW2 might be a threat to the environment if it meets one of the following criteria when coming from external sources
+ * (like registering or uploading volumes and templates):
+ *
+ * - has a backing file reference;
+ * - has an external data file reference;
+ * - has unknown incompatible features.
+ *
+ *
+ * The implementation was done based on the QEMU's official interoperability documentation
+ * and on the OpenStack's Cinder implementation for Python.
+ */
+public class Qcow2Inspector {
+ protected static Logger LOGGER = Logger.getLogger(Qcow2Inspector.class);
+
+ private static final byte[] QCOW_MAGIC_STRING = ArrayUtils.add("QFI".getBytes(), (byte) 0xfb);
+ private static final int INCOMPATIBLE_FEATURES_MAX_KNOWN_BIT = 4;
+ private static final int INCOMPATIBLE_FEATURES_MAX_KNOWN_BYTE = 0;
+ private static final int EXTERNAL_DATA_FILE_BYTE_POSITION = 7;
+ private static final int EXTERNAL_DATA_FILE_BIT = 2;
+ private static final byte EXTERNAL_DATA_FILE_BITMASK = (byte) (1 << EXTERNAL_DATA_FILE_BIT);
+
+ private static final Set SET_OF_HEADER_FIELDS_TO_READ = Set.of(Qcow2HeaderField.MAGIC,
+ Qcow2HeaderField.VERSION,
+ Qcow2HeaderField.SIZE,
+ Qcow2HeaderField.BACKING_FILE_OFFSET,
+ Qcow2HeaderField.INCOMPATIBLE_FEATURES);
+
+ /**
+ * Validates if the file is a valid and allowed QCOW2 (i.e.: does not contain external references).
+ * @param filePath Path of the file to be validated.
+ * @throws RuntimeException If the QCOW2 file meets one of the following criteria:
+ *
+ * - has a backing file reference;
+ * - has an external data file reference;
+ * - has unknown incompatible features.
+ *
+ */
+ public static void validateQcow2File(String filePath) throws RuntimeException {
+ LOGGER.info(String.format("Verifying if [%s] is a valid and allowed QCOW2 file .", filePath));
+
+ Map headerFieldsAndValues;
+ try (InputStream inputStream = new FileInputStream(filePath)) {
+ headerFieldsAndValues = unravelQcow2Header(inputStream, filePath);
+ } catch (IOException ex) {
+ throw new RuntimeException(String.format("Unable to validate file [%s] due to: ", filePath), ex);
+ }
+
+ validateQcow2HeaderFields(headerFieldsAndValues, filePath);
+
+ LOGGER.info(String.format("[%s] is a valid and allowed QCOW2 file.", filePath));
+ }
+
+ /**
+ * Unravels the QCOW2 header in a serial fashion, iterating through the {@link Qcow2HeaderField}, reading the fields specified in
+ * {@link Qcow2Inspector#SET_OF_HEADER_FIELDS_TO_READ} and skipping the others.
+ * @param qcow2InputStream InputStream of the QCOW2 being unraveled.
+ * @param qcow2LogReference A reference (like the filename) of the QCOW2 being unraveled to print in the logs and exceptions.
+ * @return A map of the header fields and their values according to the {@link Qcow2Inspector#SET_OF_HEADER_FIELDS_TO_READ}.
+ * @throws IOException If the field cannot be read or skipped.
+ */
+ public static Map unravelQcow2Header(InputStream qcow2InputStream, String qcow2LogReference) throws IOException {
+ Map result = new HashMap<>();
+
+ LOGGER.debug(String.format("Unraveling QCOW2 [%s] headers.", qcow2LogReference));
+ for (Qcow2HeaderField qcow2Header : Qcow2HeaderField.values()) {
+ if (!SET_OF_HEADER_FIELDS_TO_READ.contains(qcow2Header)) {
+ skipHeader(qcow2InputStream, qcow2Header, qcow2LogReference);
+ continue;
+ }
+
+ byte[] headerValue = readHeader(qcow2InputStream, qcow2Header, qcow2LogReference);
+ result.put(qcow2Header.name(), headerValue);
+ }
+
+ return result;
+ }
+
+ /**
+ * Skips the field's length in the InputStream.
+ * @param qcow2InputStream InputStream of the QCOW2 being unraveled.
+ * @param field Field being skipped (name and length).
+ * @param qcow2LogReference A reference (like the filename) of the QCOW2 being unraveled to print in the logs and exceptions.
+ * @throws IOException If the bytes skipped do not match the field length.
+ */
+ protected static void skipHeader(InputStream qcow2InputStream, Qcow2HeaderField field, String qcow2LogReference) throws IOException {
+ LOGGER.trace(String.format("Skipping field [%s] of QCOW2 [%s].", field, qcow2LogReference));
+
+ if (qcow2InputStream.skip(field.getLength()) != field.getLength()) {
+ throw new IOException(String.format("Unable to skip field [%s] of QCOW2 [%s].", field, qcow2LogReference));
+ }
+ }
+
+ /**
+ * Reads the field's length in the InputStream.
+ * @param qcow2InputStream InputStream of the QCOW2 being unraveled.
+ * @param field Field being read (name and length).
+ * @param qcow2LogReference A reference (like the filename) of the QCOW2 being unraveled to print in the logs and exceptions.
+ * @throws IOException If the bytes read do not match the field length.
+ */
+ protected static byte[] readHeader(InputStream qcow2InputStream, Qcow2HeaderField field, String qcow2LogReference) throws IOException {
+ byte[] readBytes = new byte[field.getLength()];
+
+ LOGGER.trace(String.format("Reading field [%s] of QCOW2 [%s].", field, qcow2LogReference));
+ if (qcow2InputStream.read(readBytes) != field.getLength()) {
+ throw new IOException(String.format("Unable to read field [%s] of QCOW2 [%s].", field, qcow2LogReference));
+ }
+
+ LOGGER.trace(String.format("Read %s as field [%s] of QCOW2 [%s].", ArrayUtils.toString(readBytes), field, qcow2LogReference));
+ return readBytes;
+ }
+
+ /**
+ * Validates the values of the header fields {@link Qcow2HeaderField#MAGIC}, {@link Qcow2HeaderField#BACKING_FILE_OFFSET}, and {@link Qcow2HeaderField#INCOMPATIBLE_FEATURES}.
+ * @param headerFieldsAndValues A map of the header fields and their values.
+ * @param qcow2LogReference A reference (like the filename) of the QCOW2 being unraveled to print in the logs and exceptions.
+ * @throws SecurityException If the QCOW2 does not contain the QCOW magic string or contains a backing file reference or incompatible features.
+ */
+ public static void validateQcow2HeaderFields(Map headerFieldsAndValues, String qcow2LogReference) throws SecurityException{
+ byte[] fieldValue = headerFieldsAndValues.get(Qcow2HeaderField.MAGIC.name());
+ validateQcowMagicString(fieldValue, qcow2LogReference);
+
+ fieldValue = headerFieldsAndValues.get(Qcow2HeaderField.BACKING_FILE_OFFSET.name());
+ validateAbsenceOfBackingFileReference(NumbersUtil.bytesToLong(fieldValue), qcow2LogReference);
+
+ fieldValue = headerFieldsAndValues.get(Qcow2HeaderField.INCOMPATIBLE_FEATURES.name());
+ validateAbsenceOfIncompatibleFeatures(fieldValue, qcow2LogReference);
+ }
+
+ /**
+ * Verifies if the first 4 bytes of the header are the QCOW magic string. Throws an exception if not.
+ * @param headerMagicString The first 4 bytes of the header.
+ * @param qcow2LogReference A reference (like the filename) of the QCOW2 being unraveled to print in the logs and exceptions.
+ * @throws SecurityException If the header's magic string is not the QCOW magic string.
+ */
+ private static void validateQcowMagicString(byte[] headerMagicString, String qcow2LogReference) throws SecurityException {
+ LOGGER.debug(String.format("Verifying if [%s] has a valid QCOW magic string.", qcow2LogReference));
+
+ if (!Arrays.equals(QCOW_MAGIC_STRING, headerMagicString)) {
+ throw new SecurityException(String.format("[%s] is not a valid QCOW2 because its first 4 bytes are not the QCOW magic string.", qcow2LogReference));
+ }
+
+ LOGGER.debug(String.format("[%s] has a valid QCOW magic string.", qcow2LogReference));
+ }
+
+ /**
+ * Verifies if the QCOW2 has a backing file and throws an exception if so.
+ * @param backingFileOffset The backing file offset value of the QCOW2 header.
+ * @param qcow2LogReference A reference (like the filename) of the QCOW2 being unraveled to print in the logs and exceptions.
+ * @throws SecurityException If the QCOW2 has a backing file reference.
+ */
+ private static void validateAbsenceOfBackingFileReference(long backingFileOffset, String qcow2LogReference) throws SecurityException {
+ LOGGER.debug(String.format("Verifying if [%s] has a backing file reference.", qcow2LogReference));
+
+ if (backingFileOffset != 0) {
+ throw new SecurityException(String.format("[%s] has a backing file reference. This can be an attack to the infrastructure; therefore, we will not accept" +
+ " this QCOW2.", qcow2LogReference));
+ }
+
+ LOGGER.debug(String.format("[%s] does not have a backing file reference.", qcow2LogReference));
+ }
+
+ /**
+ * Verifies if the QCOW2 has incompatible features and throw an exception if it has an external data file reference or unknown incompatible features.
+ * @param incompatibleFeatures The incompatible features bytes of the QCOW2 header.
+ * @param qcow2LogReference A reference (like the filename) of the QCOW2 being unraveled to print in the logs and exceptions.
+ * @throws SecurityException If the QCOW2 has an external data file reference or unknown incompatible features.
+ */
+ private static void validateAbsenceOfIncompatibleFeatures(byte[] incompatibleFeatures, String qcow2LogReference) throws SecurityException {
+ LOGGER.debug(String.format("Verifying if [%s] has incompatible features.", qcow2LogReference));
+
+ if (NumbersUtil.bytesToLong(incompatibleFeatures) == 0) {
+ LOGGER.debug(String.format("[%s] does not have incompatible features.", qcow2LogReference));
+ return;
+ }
+
+ LOGGER.debug(String.format("[%s] has incompatible features.", qcow2LogReference));
+
+ validateAbsenceOfExternalDataFileReference(incompatibleFeatures, qcow2LogReference);
+ validateAbsenceOfUnknownIncompatibleFeatures(incompatibleFeatures, qcow2LogReference);
+ }
+
+ /**
+ * Verifies if the QCOW2 has an external data file reference and throw an exception if so.
+ * @param incompatibleFeatures The incompatible features bytes of the QCOW2 header.
+ * @param qcow2LogReference A reference (like the filename) of the QCOW2 being unraveled to print in the logs and exceptions.
+ * @throws SecurityException If the QCOW2 has an external data file reference.
+ */
+ private static void validateAbsenceOfExternalDataFileReference(byte[] incompatibleFeatures, String qcow2LogReference) throws SecurityException {
+ LOGGER.debug(String.format("Verifying if [%s] has an external data file reference.", qcow2LogReference));
+
+ if ((incompatibleFeatures[EXTERNAL_DATA_FILE_BYTE_POSITION] & EXTERNAL_DATA_FILE_BITMASK) != 0) {
+ throw new SecurityException(String.format("[%s] has an external data file reference. This can be an attack to the infrastructure; therefore, we will discard" +
+ " this file.", qcow2LogReference));
+ }
+
+ LOGGER.info(String.format("[%s] does not have an external data file reference.", qcow2LogReference));
+ }
+
+ /**
+ * Verifies if the QCOW2 has unknown incompatible features and throw an exception if so.
+ *
+ * Unknown incompatible features are those with bit greater than
+ * {@link Qcow2Inspector#INCOMPATIBLE_FEATURES_MAX_KNOWN_BIT}, which will be the represented by bytes in positions greater than
+ * {@link Qcow2Inspector#INCOMPATIBLE_FEATURES_MAX_KNOWN_BYTE} (in Big Endian order). Therefore, we expect that those bytes are always zero. If not, an exception is thrown.
+ * @param incompatibleFeatures The incompatible features bytes of the QCOW2 header.
+ * @param qcow2LogReference A reference (like the filename) of the QCOW2 being unraveled to print in the logs and exceptions.
+ * @throws SecurityException If the QCOW2 has unknown incompatible features.
+ */
+ private static void validateAbsenceOfUnknownIncompatibleFeatures(byte[] incompatibleFeatures, String qcow2LogReference) throws SecurityException {
+ LOGGER.debug(String.format("Verifying if [%s] has unknown incompatible features [%s].", qcow2LogReference, ArrayUtils.toString(incompatibleFeatures)));
+
+ for (int byteNum = incompatibleFeatures.length - 1; byteNum >= 0; byteNum--) {
+ int bytePosition = incompatibleFeatures.length - 1 - byteNum;
+ LOGGER.trace(String.format("Looking for unknown incompatible feature bit in position [%s].", bytePosition));
+
+ byte bitmask = 0;
+ if (byteNum == INCOMPATIBLE_FEATURES_MAX_KNOWN_BYTE) {
+ bitmask = ((1 << INCOMPATIBLE_FEATURES_MAX_KNOWN_BIT) - 1);
+ }
+
+ LOGGER.trace(String.format("Bitmask for byte in position [%s] is [%s].", bytePosition, Integer.toBinaryString(bitmask)));
+
+ int featureBit = incompatibleFeatures[bytePosition] & ~bitmask;
+ if (featureBit != 0) {
+ throw new SecurityException(String.format("Found unknown incompatible feature bit [%s] in byte [%s] of [%s]. This can be an attack to the infrastructure; " +
+ "therefore, we will discard this QCOW2.", featureBit, bytePosition + Qcow2HeaderField.INCOMPATIBLE_FEATURES.getOffset(), qcow2LogReference));
+ }
+
+ LOGGER.trace(String.format("Did not find unknown incompatible feature in position [%s].", bytePosition));
+ }
+
+ LOGGER.info(String.format("[%s] does not have unknown incompatible features.", qcow2LogReference));
+ }
+
+}
diff --git a/services/secondary-storage/server/src/main/java/org/apache/cloudstack/storage/resource/NfsSecondaryStorageResource.java b/services/secondary-storage/server/src/main/java/org/apache/cloudstack/storage/resource/NfsSecondaryStorageResource.java
index 41cf11025a1..ceaa37926e9 100644
--- a/services/secondary-storage/server/src/main/java/org/apache/cloudstack/storage/resource/NfsSecondaryStorageResource.java
+++ b/services/secondary-storage/server/src/main/java/org/apache/cloudstack/storage/resource/NfsSecondaryStorageResource.java
@@ -71,6 +71,7 @@ import org.apache.cloudstack.storage.command.UploadStatusCommand;
import org.apache.cloudstack.storage.command.browser.ListDataStoreObjectsCommand;
import org.apache.cloudstack.storage.configdrive.ConfigDrive;
import org.apache.cloudstack.storage.configdrive.ConfigDriveBuilder;
+import org.apache.cloudstack.storage.formatinspector.Qcow2Inspector;
import org.apache.cloudstack.storage.template.DownloadManager;
import org.apache.cloudstack.storage.template.DownloadManagerImpl;
import org.apache.cloudstack.storage.template.UploadEntity;
@@ -3482,8 +3483,19 @@ public class NfsSecondaryStorageResource extends ServerResourceBase implements S
return result;
}
+ String finalFilename = resourcePath + "/" + templateFilename;
+
+ if (ImageStoreUtil.isCorrectExtension(finalFilename, "qcow2")) {
+ try {
+ Qcow2Inspector.validateQcow2File(finalFilename);
+ } catch (RuntimeException e) {
+ s_logger.error(String.format("Uploaded file [%s] is not a valid QCOW2.", finalFilename), e);
+ return "The uploaded file is not a valid QCOW2. Ask the administrator to check the logs for more details.";
+ }
+ }
+
// Set permissions for the downloaded template
- File downloadedTemplate = new File(resourcePath + "/" + templateFilename);
+ File downloadedTemplate = new File(finalFilename);
_storage.setWorldReadableAndWriteable(downloadedTemplate);
// Set permissions for template/volume.properties
diff --git a/services/secondary-storage/server/src/main/java/org/apache/cloudstack/storage/template/DownloadManagerImpl.java b/services/secondary-storage/server/src/main/java/org/apache/cloudstack/storage/template/DownloadManagerImpl.java
index 11878ae6400..c946614934b 100644
--- a/services/secondary-storage/server/src/main/java/org/apache/cloudstack/storage/template/DownloadManagerImpl.java
+++ b/services/secondary-storage/server/src/main/java/org/apache/cloudstack/storage/template/DownloadManagerImpl.java
@@ -16,8 +16,6 @@
// under the License.
package org.apache.cloudstack.storage.template;
-import static com.cloud.utils.NumbersUtil.toHumanReadableSize;
-
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
@@ -51,9 +49,11 @@ import org.apache.cloudstack.storage.command.DownloadProgressCommand.RequestType
import org.apache.cloudstack.storage.resource.IpTablesHelper;
import org.apache.cloudstack.storage.resource.NfsSecondaryStorageResource;
import org.apache.cloudstack.storage.resource.SecondaryStorageResource;
+import org.apache.cloudstack.storage.formatinspector.Qcow2HeaderField;
+import org.apache.cloudstack.storage.formatinspector.Qcow2Inspector;
import org.apache.cloudstack.utils.security.ChecksumValue;
import org.apache.cloudstack.utils.security.DigestHelper;
-import org.apache.commons.lang3.StringUtils;
+
import org.apache.log4j.Logger;
import com.cloud.agent.api.storage.DownloadAnswer;
@@ -91,7 +91,9 @@ import com.cloud.utils.component.ManagerBase;
import com.cloud.utils.exception.CloudRuntimeException;
import com.cloud.utils.net.Proxy;
import com.cloud.utils.script.Script;
-import com.cloud.utils.storage.QCOW2Utils;
+import com.cloud.utils.StringUtils;
+
+import static com.cloud.utils.NumbersUtil.toHumanReadableSize;
public class DownloadManagerImpl extends ManagerBase implements DownloadManager {
private String _name;
@@ -365,11 +367,17 @@ public class DownloadManagerImpl extends ManagerBase implements DownloadManager
// The QCOW2 is the only format with a header,
// and as such can be easily read.
- try (InputStream inputStream = td.getS3ObjectInputStream();) {
- dnld.setTemplatesize(QCOW2Utils.getVirtualSize(inputStream, false));
- }
- catch (IOException e) {
- result = "Couldn't read QCOW2 virtual size. Error: " + e.getMessage();
+ try (InputStream inputStream = td.getS3ObjectInputStream()) {
+ Map qcow2HeaderFieldsAndValues = Qcow2Inspector.unravelQcow2Header(inputStream, td.getDownloadUrl());
+ Qcow2Inspector.validateQcow2HeaderFields(qcow2HeaderFieldsAndValues, td.getDownloadUrl());
+
+ dnld.setTemplatesize(NumbersUtil.bytesToLong(qcow2HeaderFieldsAndValues.get(Qcow2HeaderField.SIZE.name())));
+ } catch (IOException ex) {
+ result = String.format("Unable to read QCOW2 metadata. Error: %s", ex.getMessage());
+ LOGGER.error(result, ex);
+ } catch (SecurityException ex) {
+ result = String.format("[%s] is not a valid QCOW2:", td.getDownloadUrl());
+ LOGGER.error(result, ex);
}
}
@@ -515,8 +523,19 @@ public class DownloadManagerImpl extends ManagerBase implements DownloadManager
return result;
}
+ String finalFilename = resourcePath + "/" + templateFilename;
+
+ if (ImageFormat.QCOW2.equals(dnld.getFormat())) {
+ try {
+ Qcow2Inspector.validateQcow2File(finalFilename);
+ } catch (RuntimeException e) {
+ LOGGER.error(String.format("The downloaded file [%s] is not a valid QCOW2.", finalFilename), e);
+ return "The downloaded file is not a valid QCOW2. Ask the administrator to check the logs for more details.";
+ }
+ }
+
// Set permissions for the downloaded template
- File downloadedTemplate = new File(resourcePath + "/" + templateFilename);
+ File downloadedTemplate = new File(finalFilename);
_storage.setWorldReadableAndWriteable(downloadedTemplate);
setPermissionsForTheDownloadedTemplate(resourcePath, resourceType);