From a3ceacb9ebf11c6467d66c0f42af714ef93591c2 Mon Sep 17 00:00:00 2001 From: "puthuparambil.aditya" Date: Tue, 16 Mar 2021 12:01:23 +0000 Subject: Fix for security hotspot related to safe archive expansion https://sonarcloud.io/project/security_hotspots?id=onap_cps&hotspots=AXfObcsqA2pnU4Plp4-g Issue-ID: CPS-289 Signed-off-by: puthuparambil.aditya Change-Id: Ibe8627413fc9e3964cdc5bb98caf5e25fa4f3a95 --- .../org/onap/cps/rest/utils/MultipartFileUtil.java | 24 +++-- .../onap/cps/rest/utils/ZipFileSizeValidator.java | 81 +++++++++++++++++ .../cps/rest/utils/ZipFileSizeValidatorSpec.groovy | 101 +++++++++++++++++++++ 3 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 cps-rest/src/main/java/org/onap/cps/rest/utils/ZipFileSizeValidator.java create mode 100644 cps-rest/src/test/groovy/org/onap/cps/rest/utils/ZipFileSizeValidatorSpec.groovy diff --git a/cps-rest/src/main/java/org/onap/cps/rest/utils/MultipartFileUtil.java b/cps-rest/src/main/java/org/onap/cps/rest/utils/MultipartFileUtil.java index 532a0ca848..e3b0b2835c 100644 --- a/cps-rest/src/main/java/org/onap/cps/rest/utils/MultipartFileUtil.java +++ b/cps-rest/src/main/java/org/onap/cps/rest/utils/MultipartFileUtil.java @@ -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 extractYangResourcesMapFromZipArchive(final MultipartFile multipartFile) { final ImmutableMap.Builder 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 yangResourceMap = yangResourceMapBuilder.build(); if (yangResourceMap.isEmpty()) { @@ -100,13 +102,13 @@ public class MultipartFileUtil { private static void extractZipEntryToMapIfApplicable( final ImmutableMap.Builder 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 index 0000000000..d148fb70d4 --- /dev/null +++ b/cps-rest/src/main/java/org/onap/cps/rest/utils/ZipFileSizeValidator.java @@ -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 index 0000000000..16fbf9885a --- /dev/null +++ b/cps-rest/src/test/groovy/org/onap/cps/rest/utils/ZipFileSizeValidatorSpec.groovy @@ -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 + } +} -- cgit 1.2.3-korg