Fix for security hotspot related to safe archive expansion 69/119369/9
authorputhuparambil.aditya <aditya.puthuparambil@bell.ca>
Tue, 16 Mar 2021 12:01:23 +0000 (12:01 +0000)
committerToine Siebelink <toine.siebelink@est.tech>
Tue, 6 Apr 2021 09:16:35 +0000 (09:16 +0000)
https://sonarcloud.io/project/security_hotspots?id=onap_cps&hotspots=AXfObcsqA2pnU4Plp4-g

Issue-ID: CPS-289
Signed-off-by: puthuparambil.aditya <aditya.puthuparambil@bell.ca>
Change-Id: Ibe8627413fc9e3964cdc5bb98caf5e25fa4f3a95

cps-rest/src/main/java/org/onap/cps/rest/utils/MultipartFileUtil.java
cps-rest/src/main/java/org/onap/cps/rest/utils/ZipFileSizeValidator.java [new file with mode: 0644]
cps-rest/src/test/groovy/org/onap/cps/rest/utils/ZipFileSizeValidatorSpec.groovy [new file with mode: 0644]

index 532a0ca..e3b0b28 100644 (file)
@@ -1,6 +1,7 @@
 /*
  *  ============LICENSE_START=======================================================
  *  Copyright (C) 2020 Pantheon.tech
+ *  Modifications Copyright (C) 2021 Bell Canada.
  *  ================================================================================
  *  Licensed under the Apache License, Version 2.0 (the "License");
  *  you may not use this file except in compliance with the License.
@@ -69,21 +70,22 @@ public class MultipartFileUtil {
 
     private static Map<String, String> extractYangResourcesMapFromZipArchive(final MultipartFile multipartFile) {
         final ImmutableMap.Builder<String, String> yangResourceMapBuilder = ImmutableMap.builder();
-
+        final ZipFileSizeValidator zipFileSizeValidator = new ZipFileSizeValidator();
         try (
             final InputStream inputStream = multipartFile.getInputStream();
             final ZipInputStream zipInputStream = new ZipInputStream(inputStream);
         ) {
             ZipEntry zipEntry;
             while ((zipEntry = zipInputStream.getNextEntry()) != null) {
-                extractZipEntryToMapIfApplicable(yangResourceMapBuilder, zipEntry, zipInputStream);
+                extractZipEntryToMapIfApplicable(yangResourceMapBuilder, zipEntry, zipInputStream,
+                    zipFileSizeValidator);
             }
             zipInputStream.closeEntry();
 
         } catch (final IOException e) {
             throw new CpsException("Cannot extract resources from zip archive.", e.getMessage(), e);
         }
-
+        zipFileSizeValidator.validateSizeAndEntries();
         try {
             final Map<String, String> yangResourceMap = yangResourceMapBuilder.build();
             if (yangResourceMap.isEmpty()) {
@@ -100,13 +102,13 @@ public class MultipartFileUtil {
 
     private static void extractZipEntryToMapIfApplicable(
         final ImmutableMap.Builder<String, String> yangResourceMapBuilder, final ZipEntry zipEntry,
-        final ZipInputStream zipInputStream) throws IOException {
-
+        final ZipInputStream zipInputStream, final ZipFileSizeValidator zipFileSizeValidator) throws IOException {
+        zipFileSizeValidator.setCompressedSize(zipEntry.getCompressedSize());
         final String yangResourceName = extractResourceNameFromPath(zipEntry.getName());
         if (zipEntry.isDirectory() || !resourceNameEndsWithExtension(yangResourceName, YANG_FILE_EXTENSION)) {
             return;
         }
-        yangResourceMapBuilder.put(yangResourceName, extractYangResourceContent(zipInputStream));
+        yangResourceMapBuilder.put(yangResourceName, extractYangResourceContent(zipInputStream, zipFileSizeValidator));
     }
 
     private static boolean resourceNameEndsWithExtension(final String resourceName, final String extension) {
@@ -125,12 +127,18 @@ public class MultipartFileUtil {
         }
     }
 
-    private static String extractYangResourceContent(final ZipInputStream zipInputStream) throws IOException {
+    private static String extractYangResourceContent(final ZipInputStream zipInputStream,
+        final ZipFileSizeValidator zipFileSizeValidator) throws IOException {
         try (final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
-            final byte[] buffer = new byte[READ_BUFFER_SIZE];
+            int totalSizeEntry = 0;
             int numberOfBytesRead;
+            final byte[] buffer = new byte[READ_BUFFER_SIZE];
+            zipFileSizeValidator.incrementTotalEntryInArchive();
             while ((numberOfBytesRead = zipInputStream.read(buffer, 0, READ_BUFFER_SIZE)) > 0) {
                 byteArrayOutputStream.write(buffer, 0, numberOfBytesRead);
+                totalSizeEntry += numberOfBytesRead;
+                zipFileSizeValidator.updateTotalSizeArchive(totalSizeEntry);
+                zipFileSizeValidator.validateCompresssionRatio(totalSizeEntry);
             }
             return byteArrayOutputStream.toString(StandardCharsets.UTF_8);
         }
diff --git a/cps-rest/src/main/java/org/onap/cps/rest/utils/ZipFileSizeValidator.java b/cps-rest/src/main/java/org/onap/cps/rest/utils/ZipFileSizeValidator.java
new file mode 100644 (file)
index 0000000..d148fb7
--- /dev/null
@@ -0,0 +1,81 @@
+/*
+ *  ============LICENSE_START=======================================================
+ *  Copyright (C) 2021 Bell Canada.
+ *  ================================================================================
+ *  Licensed 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.
+ *
+ *  SPDX-License-Identifier: Apache-2.0
+ *  ============LICENSE_END=========================================================
+ */
+
+package org.onap.cps.rest.utils;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.onap.cps.spi.exceptions.ModelValidationException;
+
+@Setter
+@Getter
+public class ZipFileSizeValidator {
+
+    private static final int THRESHOLD_ENTRIES = 10000;
+    private static final int THRESHOLD_SIZE = 100000000;
+    private static final double THRESHOLD_RATIO = 40;
+    private static final String INVALID_ZIP = "Invalid ZIP archive content.";
+
+    private int totalSizeArchive = 0;
+    private int totalEntryInArchive = 0;
+    private long compressedSize = 0;
+
+    /**
+     * Increment the totalEntryInArchive by 1.
+     */
+    public void incrementTotalEntryInArchive() {
+        totalEntryInArchive++;
+    }
+
+    /**
+     * Update the totalSizeArchive by numberOfBytesRead.
+     * @param numberOfBytesRead the number of bytes of each entry
+     */
+    public void updateTotalSizeArchive(final int numberOfBytesRead) {
+        totalSizeArchive += numberOfBytesRead;
+    }
+
+    /**
+     * Validate the total Compression size of the zip.
+     * @param totalEntrySize the size of the unzipped entry.
+     */
+    public void validateCompresssionRatio(final int totalEntrySize) {
+        final double compressionRatio = (double) totalEntrySize / compressedSize;
+        if (compressionRatio > THRESHOLD_RATIO) {
+            throw new ModelValidationException(INVALID_ZIP,
+                String.format("Ratio between compressed and uncompressed data exceeds the CPS limit"
+                    + " %s.", THRESHOLD_RATIO));
+        }
+    }
+
+    /**
+     * Validate the total Size and number of entries in the zip.
+     */
+    public void validateSizeAndEntries() {
+        if (totalSizeArchive > THRESHOLD_SIZE) {
+            throw new ModelValidationException(INVALID_ZIP,
+                String.format("The uncompressed data size exceeds the CPS limit %s bytes.", THRESHOLD_SIZE));
+        }
+        if (totalEntryInArchive > THRESHOLD_ENTRIES) {
+            throw new ModelValidationException(INVALID_ZIP,
+                String.format("The number of entries in the archive exceeds the CPS limit %s.",
+                    THRESHOLD_ENTRIES));
+        }
+    }
+}
diff --git a/cps-rest/src/test/groovy/org/onap/cps/rest/utils/ZipFileSizeValidatorSpec.groovy b/cps-rest/src/test/groovy/org/onap/cps/rest/utils/ZipFileSizeValidatorSpec.groovy
new file mode 100644 (file)
index 0000000..16fbf98
--- /dev/null
@@ -0,0 +1,101 @@
+/*
+ *  ============LICENSE_START=======================================================
+ *  Copyright (C) 2021 Bell Canada.
+ *  ================================================================================
+ *  Licensed 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.
+ *
+ *  SPDX-License-Identifier: Apache-2.0
+ *  ============LICENSE_END=========================================================
+ */
+
+package org.onap.cps.rest.utils
+
+import org.onap.cps.spi.exceptions.ModelValidationException
+import spock.lang.Specification
+
+class ZipFileSizeValidatorSpec extends Specification {
+
+    def static thresholdSize = ZipFileSizeValidator.THRESHOLD_SIZE
+    def static thresholdEntries = ZipFileSizeValidator.THRESHOLD_ENTRIES
+    def static thresholdRatio = ZipFileSizeValidator.THRESHOLD_RATIO
+
+    def objectUnderTest = new ZipFileSizeValidator()
+    def compressedFileSize = 100
+
+    def setup() {
+        objectUnderTest.setTotalEntryInArchive(0)
+        objectUnderTest.setTotalSizeArchive(0)
+        objectUnderTest.setCompressedSize(compressedFileSize)
+    }
+
+    def 'Increment the total entries in Archive.'() {
+        when: 'the totalEntriesInArchive value is incremented'
+            objectUnderTest.incrementTotalEntryInArchive()
+        then: 'the totalEntriesInArchive is incremented by 1'
+            assert objectUnderTest.totalEntryInArchive == old(objectUnderTest.totalEntryInArchive) + 1
+    }
+
+    def 'Update the total size of Archive.'() {
+        given: 'the size of an entry of archive'
+            def entrySize = 100
+        when: 'the totalSizeArchive is to be updated with the latest entry Size'
+            objectUnderTest.updateTotalSizeArchive(entrySize)
+        then: 'the totalSizeArchive is updated as expected'
+            assert objectUnderTest.totalSizeArchive == old(objectUnderTest.totalSizeArchive) + entrySize
+    }
+
+    def 'Validate the zip archive for compression ratio less that threshold compression ratio.'() {
+        given: 'the totalEntrySize of the archive so that compression ratio is within the threshold'
+            int totalEntrySize = compressedFileSize * thresholdRatio - 1
+        when: 'the validation is performed against the threshold compression ratio'
+            objectUnderTest.validateCompresssionRatio(totalEntrySize)
+        then: 'validation passes and no exception is thrown'
+            noExceptionThrown()
+    }
+
+    def 'Validate the zip archive for compression ratio.'() {
+        given: 'the totalEntrySize of the archive so that compression ratio is higher than the threshold'
+            int totalEntrySize = compressedFileSize * thresholdRatio + 1
+        when: 'the validation is performed against the threshold compression ratio'
+            objectUnderTest.validateCompresssionRatio(totalEntrySize)
+        then: 'validation fails and exception is thrown'
+            thrown ModelValidationException
+    }
+
+    def 'Validate the zip archive for thresholdSize and thresholdEntries #caseDescriptor.'() {
+        given:
+            objectUnderTest.setTotalEntryInArchive(totalEntriesInArchive)
+            objectUnderTest.setTotalSizeArchive(totalSizeArchive)
+        when: 'the validation is performed against the threshold size and threshold Entries count'
+            objectUnderTest.validateSizeAndEntries()
+        then: 'validation passes and no exception is thrown'
+            noExceptionThrown()
+        where: 'following cases are tested'
+            caseDescriptor              | totalSizeArchive  | totalEntriesInArchive
+            'less than threshold value' | thresholdSize - 1 | thresholdEntries - 1
+            'at threshold value'        | thresholdSize     | thresholdEntries
+    }
+
+    def 'Validate the zip archive for thresholdSize and thresholdEntries with #caseDescriptor.'() {
+        given:
+            objectUnderTest.setTotalEntryInArchive(totalEntriesInArchive)
+            objectUnderTest.setTotalSizeArchive(totalSizeArchive)
+        when: 'the validation is performed against the threshold size and threshold Entries count'
+            objectUnderTest.validateSizeAndEntries()
+        then: 'validation fails and exception is thrown'
+            thrown ModelValidationException
+        where: 'following cases are tested'
+            caseDescriptor                                  | totalSizeArchive  | totalEntriesInArchive
+            'totalEntriesInArchive exceeds threshold value' | thresholdSize     | thresholdEntries + 1
+            'totalSizeArchive exceeds threshold value'      | thresholdSize + 1 | thresholdEntries
+    }
+}