From b51374532e434ff2e36e814b96cdba9f5deb413c Mon Sep 17 00:00:00 2001 From: SatyamSah5 Date: Fri, 18 Apr 2025 09:45:21 +0530 Subject: [PATCH] [MNT-24623] fix for unzipping zip files having accent chars (#3321) --- .../executer/ImporterActionExecuter.java | 1091 +++++++++-------- .../executer/ImporterActionExecuterTest.java | 78 +- .../import-archive-test/accentCharTestZip.zip | Bin 0 -> 647 bytes 3 files changed, 610 insertions(+), 559 deletions(-) create mode 100644 repository/src/test/resources/import-archive-test/accentCharTestZip.zip diff --git a/repository/src/main/java/org/alfresco/repo/action/executer/ImporterActionExecuter.java b/repository/src/main/java/org/alfresco/repo/action/executer/ImporterActionExecuter.java index 46bc957e1c..e85f4b2ee4 100644 --- a/repository/src/main/java/org/alfresco/repo/action/executer/ImporterActionExecuter.java +++ b/repository/src/main/java/org/alfresco/repo/action/executer/ImporterActionExecuter.java @@ -1,537 +1,554 @@ -/* - * #%L - * Alfresco Repository - * %% - * Copyright (C) 2005 - 2022 Alfresco Software Limited - * %% - * This file is part of the Alfresco software. - * If the software was purchased under a paid Alfresco license, the terms of - * the paid license agreement will prevail. Otherwise, the software is - * provided under the following open source license terms: - * - * Alfresco is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Alfresco is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with Alfresco. If not, see . - * #L% - */ -package org.alfresco.repo.action.executer; - -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.Serializable; -import java.util.Enumeration; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.zip.ZipException; - -import org.alfresco.error.AlfrescoRuntimeException; -import org.alfresco.model.ApplicationModel; -import org.alfresco.model.ContentModel; -import org.alfresco.repo.action.ParameterDefinitionImpl; -import org.alfresco.repo.content.MimetypeMap; -import org.alfresco.repo.importer.ACPImportPackageHandler; -import org.alfresco.service.cmr.action.Action; -import org.alfresco.service.cmr.action.ParameterDefinition; -import org.alfresco.service.cmr.dictionary.DataTypeDefinition; -import org.alfresco.service.cmr.model.FileExistsException; -import org.alfresco.service.cmr.model.FileFolderService; -import org.alfresco.service.cmr.model.FileInfo; -import org.alfresco.service.cmr.repository.ContentReader; -import org.alfresco.service.cmr.repository.ContentService; -import org.alfresco.service.cmr.repository.ContentWriter; -import org.alfresco.service.cmr.repository.NodeRef; -import org.alfresco.service.cmr.repository.NodeService; -import org.alfresco.service.cmr.view.ImporterService; -import org.alfresco.service.cmr.view.Location; -import org.alfresco.service.namespace.QName; -import org.alfresco.util.TempFileProvider; -import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; -import org.apache.commons.compress.archivers.zip.ZipFile; -import org.apache.commons.compress.utils.InputStreamStatistics; -import org.apache.commons.lang3.StringUtils; - -/** - * Importer action executor - * - * @author gavinc - */ -public class ImporterActionExecuter extends ActionExecuterAbstractBase -{ - public static final String NAME = "import"; - public static final String PARAM_ENCODING = "encoding"; - public static final String PARAM_DESTINATION_FOLDER = "destination"; - public static final String ARCHIVE_CONTAINS_SUSPICIOUS_PATHS_ERROR = "Archive contains suspicious paths. Please review it's contents and make sure it doesn't contain entries with absolute paths or paths containing references to the parent folder (i.e. \"..\")"; - - private static final int BUFFER_SIZE = 16384; - private static final String TEMP_FILE_PREFIX = "alf"; - private static final String TEMP_FILE_SUFFIX_ACP = ".acp"; - - private long ratioThreshold; - private long uncompressedBytesLimit = -1L; - private boolean highByteZip = false; - - /** - * The importer service - */ - private ImporterService importerService; - - /** - * The node service - */ - private NodeService nodeService; - - /** - * The content service - */ - private ContentService contentService; - - /** - * The file folder service - */ - private FileFolderService fileFolderService; - - /** - * Sets the ImporterService to use - * - * @param importerService The ImporterService - */ - public void setImporterService(ImporterService importerService) - { - this.importerService = importerService; - } - - /** - * Sets the NodeService to use - * - * @param nodeService The NodeService - */ - public void setNodeService(NodeService nodeService) - { - this.nodeService = nodeService; - } - - /** - * Sets the ContentService to use - * - * @param contentService The ContentService - */ - public void setContentService(ContentService contentService) - { - this.contentService = contentService; - } - - /** - * Sets the FileFolderService to use - * - * @param fileFolderService The FileFolderService - */ - public void setFileFolderService(FileFolderService fileFolderService) - { - this.fileFolderService = fileFolderService; - } - - /** - * @return the highByteZip encoding switch - */ - public boolean isHighByteZip() - { - return this.highByteZip; - } - - /** - * @param highByteZip the encoding switch for high-byte ZIP filenames to set - */ - public void setHighByteZip(boolean highByteZip) - { - this.highByteZip = highByteZip; - } - - /** - * @param ratioThreshold the compression ratio threshold for Zip bomb detection - */ - public void setRatioThreshold(long ratioThreshold) - { - this.ratioThreshold = ratioThreshold; - } - - /** - * This method sets a value for the uncompressed bytes limit. If the string does not {@link Long#parseLong(String) parse} to a - * java long. - * - * @param limit a String representing a valid Java long. - */ - public void setUncompressedBytesLimit(String limit) - { - // A string parameter is used here in order to not to require end users to provide a value for the limit in a property - // file. This results in the empty string being injected to this method. - long longLimit = -1L; - try - { - longLimit = Long.parseLong(limit); - } - catch (NumberFormatException ignored) - { - // Intentionally empty - } - this.uncompressedBytesLimit = longLimit; - } - - /** - * @see org.alfresco.repo.action.executer.ActionExecuter#execute(Action, NodeRef) - */ - public void executeImpl(Action ruleAction, NodeRef actionedUponNodeRef) - { - if (this.nodeService.exists(actionedUponNodeRef) == true) - { - // The node being passed in should be an Alfresco content package - ContentReader reader = this.contentService.getReader(actionedUponNodeRef, ContentModel.PROP_CONTENT); - if (reader != null) - { - NodeRef importDest = (NodeRef)ruleAction.getParameterValue(PARAM_DESTINATION_FOLDER); - if (MimetypeMap.MIMETYPE_ACP.equals(reader.getMimetype())) - { - // perform an import of an Alfresco ACP file (special format ZIP structure) - File zipFile = null; - try - { - // unfortunately a ZIP file can not be read directly from an input stream so we have to create - // a temporary file first - zipFile = TempFileProvider.createTempFile(TEMP_FILE_PREFIX, TEMP_FILE_SUFFIX_ACP); - reader.getContent(zipFile); - - ACPImportPackageHandler importHandler = new ACPImportPackageHandler(zipFile, - (String)ruleAction.getParameterValue(PARAM_ENCODING)); - - this.importerService.importView(importHandler, new Location(importDest), null, null); - } - finally - { - // now the import is done, delete the temporary file - if (zipFile != null) - { - zipFile.delete(); - } - } - } - else if (MimetypeMap.MIMETYPE_ZIP.equals(reader.getMimetype())) - { - // perform an import of a standard ZIP file - ZipFile zipFile = null; - File tempFile = null; - try - { - tempFile = TempFileProvider.createTempFile(TEMP_FILE_PREFIX, TEMP_FILE_SUFFIX_ACP); - reader.getContent(tempFile); - // NOTE: This encoding allows us to workaround bug: - // http://bugs.sun.com/bugdatabase/view_bug.do;:WuuT?bug_id=4820807 - // We also try to use the extra encoding information if present - String encoding = (String) ruleAction.getParameterValue(PARAM_ENCODING); - if (encoding == null) - { - encoding = "UTF-8"; - } - else - { - if (encoding.equalsIgnoreCase("default")) - { - encoding = null; - } - } - zipFile = new ZipFile(tempFile, encoding, false); - // build a temp dir name based on the ID of the noderef we are importing - // also use the long life temp folder as large ZIP files can take a while - File alfTempDir = TempFileProvider.getLongLifeTempDir("import"); - File tempDir = new File(alfTempDir.getPath() + File.separatorChar + actionedUponNodeRef.getId()); - try - { - // TODO: improve this code to directly pipe the zip stream output into the repo objects - - // to remove the need to expand to the filesystem first? - extractFile(zipFile, tempDir.getPath(), new ZipBombProtection(ratioThreshold, uncompressedBytesLimit)); - importDirectory(tempDir.getPath(), importDest); - } - finally - { - deleteDir(tempDir); - } - } - catch (IOException ioErr) - { - throw new AlfrescoRuntimeException("Failed to import ZIP file.", ioErr); - } - finally - { - // now the import is done, delete the temporary file - if (tempFile != null) - { - tempFile.delete(); - } - if (zipFile != null) - { - try - { - zipFile.close(); - } - catch (IOException e) - { - throw new AlfrescoRuntimeException("Failed to close zip package.", e); - } - } - } - } - } - } - } - - /** - * Recursively import a directory structure into the specified root node - * - * @param dir The directory of files and folders to import - * @param root The root node to import into - */ - private void importDirectory(String dir, NodeRef root) - { - File topdir = new File(dir); - for (File file : topdir.listFiles()) - { - try - { - if (file.isFile()) - { - String fileName = file.getName(); - - // create content node based on the file name - FileInfo fileInfo = this.fileFolderService.create(root, fileName, ContentModel.TYPE_CONTENT); - NodeRef fileRef = fileInfo.getNodeRef(); - - // add titled aspect for the read/edit properties screens - Map titledProps = new HashMap(1, 1.0f); - titledProps.put(ContentModel.PROP_TITLE, fileName); - this.nodeService.addAspect(fileRef, ContentModel.ASPECT_TITLED, titledProps); - - // push the content of the file into the node - InputStream contentStream = new BufferedInputStream(new FileInputStream(file), BUFFER_SIZE); - ContentWriter writer = this.contentService.getWriter(fileRef, ContentModel.PROP_CONTENT, true); - writer.guessMimetype(fileName); - writer.putContent(contentStream); - } - else - { - String folderName = file.getName(); - - // create a folder based on the folder name - FileInfo folderInfo = this.fileFolderService.create(root, folderName, ContentModel.TYPE_FOLDER); - NodeRef folderRef = folderInfo.getNodeRef(); - - // add titled aspect - Map titledProps = new HashMap(1, 1.0f); - this.nodeService.addAspect(folderRef, ContentModel.ASPECT_TITLED, titledProps); - - // add the uifacets aspect for the read/edit properties screens - this.nodeService.addAspect(folderRef, ApplicationModel.ASPECT_UIFACETS, null); - - importDirectory(file.getPath(), folderRef); - } - } - catch (FileNotFoundException e) - { - // TODO: add failed file info to status message? - throw new AlfrescoRuntimeException("Failed to process ZIP file.", e); - } - catch (FileExistsException e) - { - // TODO: add failed file info to status message? - throw new AlfrescoRuntimeException("Failed to process ZIP file.", e); - } - } - } - - /** - * @see org.alfresco.repo.action.ParameterizedItemAbstractBase#addParameterDefinitions(java.util.List) - */ - protected void addParameterDefinitions(List paramList) - { - paramList.add(new ParameterDefinitionImpl(PARAM_DESTINATION_FOLDER, DataTypeDefinition.NODE_REF, - true, getParamDisplayLabel(PARAM_DESTINATION_FOLDER))); - paramList.add(new ParameterDefinitionImpl(PARAM_ENCODING, DataTypeDefinition.TEXT, - false, getParamDisplayLabel(PARAM_ENCODING))); - } - - /** - * Extract the file and folder structure of a ZIP file into the specified directory - * - * @param archive The ZIP archive to extract - * @param extractDir The directory to extract into - */ - public static void extractFile(ZipFile archive, String extractDir) - { - extractFile(archive, extractDir, ExtractionProgressTracker.NONE); - } - - /** - * Extract the file and folder structure of a ZIP file into the specified directory using a progress tracker - * - * @param archive The ZIP archive to extract - * @param extractDir The directory to extract into - * @param tracker The extraction progress tracker to check against during the extraction process - */ - public static void extractFile(ZipFile archive, String extractDir, ExtractionProgressTracker tracker) - { - String fileName; - String destFileName; - byte[] buffer = new byte[BUFFER_SIZE]; - extractDir = extractDir + File.separator; - try - { - long totalCompressedBytesCount = 0; - long totalUncompressedBytesCount = 0; - tracker.reportProgress(0, 0); - for (Enumeration e = archive.getEntries(); e.hasMoreElements();) - { - ZipArchiveEntry entry = e.nextElement(); - if (!entry.isDirectory()) - { - fileName = StringUtils.stripAccents(entry.getName()).replaceAll("\\?","_"); - fileName = fileName.replace('/', File.separatorChar); - - if (fileName.startsWith("/") || fileName.indexOf(":" + File.separator) == 1 || fileName.contains(".." + File.separator)) - { - throw new AlfrescoRuntimeException(ARCHIVE_CONTAINS_SUSPICIOUS_PATHS_ERROR); - } - - destFileName = extractDir + fileName; - File destFile = new File(destFileName); - String parent = destFile.getParent(); - if (parent != null) - { - File parentFile = new File(parent); - if (!parentFile.exists()) parentFile.mkdirs(); - } - - try (InputStream zis = archive.getInputStream(entry); - InputStream in = new BufferedInputStream(zis, BUFFER_SIZE); - OutputStream out = new BufferedOutputStream(new FileOutputStream(destFileName), BUFFER_SIZE)) - { - final InputStreamStatistics entryStats = (InputStreamStatistics) zis; - int count; - while ((count = in.read(buffer)) != -1) - { - tracker.reportProgress(totalCompressedBytesCount + entryStats.getCompressedCount(), totalUncompressedBytesCount + entryStats.getUncompressedCount()); - out.write(buffer, 0, count); - } - totalCompressedBytesCount += entryStats.getCompressedCount(); - totalUncompressedBytesCount += entryStats.getUncompressedCount(); - } - } - else - { - File newdir = new File(extractDir + entry.getName()); - newdir.mkdirs(); - } - } - } - catch (ZipException e) - { - throw new AlfrescoRuntimeException("Failed to process ZIP file.", e); - } - catch (FileNotFoundException e) - { - throw new AlfrescoRuntimeException("Failed to process ZIP file.", e); - } - catch (IOException e) - { - throw new AlfrescoRuntimeException("Failed to process ZIP file.", e); - } - } - - /** - * Recursively delete a dir of files and directories - * - * @param dir directory to delete - */ - public static void deleteDir(File dir) - { - if (dir != null) - { - File elenco = new File(dir.getPath()); - - // listFiles can return null if the path is invalid i.e. already been deleted, - // therefore check for null before using in loop - File[] files = elenco.listFiles(); - if (files != null) - { - for (File file : files) - { - if (file.isFile()) file.delete(); - else deleteDir(file); - } - } - - // delete provided directory - dir.delete(); - } - } - - private static class ZipBombProtection implements ExtractionProgressTracker - { - private final long ratioThreshold; - private final long uncompressedBytesLimit; - - private ZipBombProtection(long ratioThreshold, long uncompressedBytesLimit) - { - this.ratioThreshold = ratioThreshold; - this.uncompressedBytesLimit = uncompressedBytesLimit; - } - - @Override - public void reportProgress(long compressedBytesCount, long uncompressedBytesCount) - { - if (compressedBytesCount <= 0 || uncompressedBytesCount <= 0) - { - return; - } - - long ratio = uncompressedBytesCount / compressedBytesCount; - - if (ratio > ratioThreshold) - { - throw new AlfrescoRuntimeException("Unexpected compression ratio detected (" + ratio + "%). Possible zip bomb attack. Breaking the extraction process."); - } - - if (uncompressedBytesLimit > 0 && uncompressedBytesCount > uncompressedBytesLimit) - { - throw new AlfrescoRuntimeException("Uncompressed bytes limit exceeded (" + uncompressedBytesCount + "). Possible zip bomb attack. Breaking the extraction process."); - } - } - } - - private interface ExtractionProgressTracker - { - void reportProgress(long compressedBytesCount, long uncompressedBytesCount); - - ExtractionProgressTracker NONE = new ExtractionProgressTracker() - { - @Override - public void reportProgress(long compressedBytesCount, long uncompressedBytesCount) - { - // intentionally do nothing - } - }; - } -} +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.action.executer; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Serializable; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipException; + +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; +import org.apache.commons.compress.archivers.zip.ZipFile; +import org.apache.commons.compress.utils.InputStreamStatistics; +import org.apache.commons.lang3.StringUtils; + +import org.alfresco.error.AlfrescoRuntimeException; +import org.alfresco.model.ApplicationModel; +import org.alfresco.model.ContentModel; +import org.alfresco.repo.action.ParameterDefinitionImpl; +import org.alfresco.repo.content.MimetypeMap; +import org.alfresco.repo.importer.ACPImportPackageHandler; +import org.alfresco.service.cmr.action.Action; +import org.alfresco.service.cmr.action.ParameterDefinition; +import org.alfresco.service.cmr.dictionary.DataTypeDefinition; +import org.alfresco.service.cmr.model.FileExistsException; +import org.alfresco.service.cmr.model.FileFolderService; +import org.alfresco.service.cmr.model.FileInfo; +import org.alfresco.service.cmr.repository.ContentReader; +import org.alfresco.service.cmr.repository.ContentService; +import org.alfresco.service.cmr.repository.ContentWriter; +import org.alfresco.service.cmr.repository.NodeRef; +import org.alfresco.service.cmr.repository.NodeService; +import org.alfresco.service.cmr.view.ImporterService; +import org.alfresco.service.cmr.view.Location; +import org.alfresco.service.namespace.QName; +import org.alfresco.util.TempFileProvider; + +/** + * Importer action executor + * + * @author gavinc + */ +public class ImporterActionExecuter extends ActionExecuterAbstractBase +{ + public static final String NAME = "import"; + public static final String PARAM_ENCODING = "encoding"; + public static final String PARAM_DESTINATION_FOLDER = "destination"; + public static final String ARCHIVE_CONTAINS_SUSPICIOUS_PATHS_ERROR = "Archive contains suspicious paths. Please review it's contents and make sure it doesn't contain entries with absolute paths or paths containing references to the parent folder (i.e. \"..\")"; + + private static final int BUFFER_SIZE = 16384; + private static final String TEMP_FILE_PREFIX = "alf"; + private static final String TEMP_FILE_SUFFIX_ACP = ".acp"; + + private long ratioThreshold; + private long uncompressedBytesLimit = -1L; + private boolean highByteZip = false; + + /** + * The importer service + */ + private ImporterService importerService; + + /** + * The node service + */ + private NodeService nodeService; + + /** + * The content service + */ + private ContentService contentService; + + /** + * The file folder service + */ + private FileFolderService fileFolderService; + + /** + * Sets the ImporterService to use + * + * @param importerService + * The ImporterService + */ + public void setImporterService(ImporterService importerService) + { + this.importerService = importerService; + } + + /** + * Sets the NodeService to use + * + * @param nodeService + * The NodeService + */ + public void setNodeService(NodeService nodeService) + { + this.nodeService = nodeService; + } + + /** + * Sets the ContentService to use + * + * @param contentService + * The ContentService + */ + public void setContentService(ContentService contentService) + { + this.contentService = contentService; + } + + /** + * Sets the FileFolderService to use + * + * @param fileFolderService + * The FileFolderService + */ + public void setFileFolderService(FileFolderService fileFolderService) + { + this.fileFolderService = fileFolderService; + } + + /** + * @return the highByteZip encoding switch + */ + public boolean isHighByteZip() + { + return this.highByteZip; + } + + /** + * @param highByteZip + * the encoding switch for high-byte ZIP filenames to set + */ + public void setHighByteZip(boolean highByteZip) + { + this.highByteZip = highByteZip; + } + + /** + * @param ratioThreshold + * the compression ratio threshold for Zip bomb detection + */ + public void setRatioThreshold(long ratioThreshold) + { + this.ratioThreshold = ratioThreshold; + } + + /** + * This method sets a value for the uncompressed bytes limit. If the string does not {@link Long#parseLong(String) parse} to a java long. + * + * @param limit + * a String representing a valid Java long. + */ + public void setUncompressedBytesLimit(String limit) + { + // A string parameter is used here in order to not to require end users to provide a value for the limit in a property + // file. This results in the empty string being injected to this method. + long longLimit = -1L; + try + { + longLimit = Long.parseLong(limit); + } + catch (NumberFormatException ignored) + { + // Intentionally empty + } + this.uncompressedBytesLimit = longLimit; + } + + /** + * @see org.alfresco.repo.action.executer.ActionExecuter#execute(Action, NodeRef) + */ + public void executeImpl(Action ruleAction, NodeRef actionedUponNodeRef) + { + if (this.nodeService.exists(actionedUponNodeRef) == true) + { + // The node being passed in should be an Alfresco content package + ContentReader reader = this.contentService.getReader(actionedUponNodeRef, ContentModel.PROP_CONTENT); + if (reader != null) + { + NodeRef importDest = (NodeRef) ruleAction.getParameterValue(PARAM_DESTINATION_FOLDER); + if (MimetypeMap.MIMETYPE_ACP.equals(reader.getMimetype())) + { + // perform an import of an Alfresco ACP file (special format ZIP structure) + File zipFile = null; + try + { + // unfortunately a ZIP file can not be read directly from an input stream so we have to create + // a temporary file first + zipFile = TempFileProvider.createTempFile(TEMP_FILE_PREFIX, TEMP_FILE_SUFFIX_ACP); + reader.getContent(zipFile); + + ACPImportPackageHandler importHandler = new ACPImportPackageHandler(zipFile, + (String) ruleAction.getParameterValue(PARAM_ENCODING)); + + this.importerService.importView(importHandler, new Location(importDest), null, null); + } + finally + { + // now the import is done, delete the temporary file + if (zipFile != null) + { + zipFile.delete(); + } + } + } + else if (MimetypeMap.MIMETYPE_ZIP.equals(reader.getMimetype())) + { + // perform an import of a standard ZIP file + ZipFile zipFile = null; + File tempFile = null; + try + { + tempFile = TempFileProvider.createTempFile(TEMP_FILE_PREFIX, TEMP_FILE_SUFFIX_ACP); + reader.getContent(tempFile); + // NOTE: This encoding allows us to workaround bug: + // http://bugs.sun.com/bugdatabase/view_bug.do;:WuuT?bug_id=4820807 + // We also try to use the extra encoding information if present + String encoding = (String) ruleAction.getParameterValue(PARAM_ENCODING); + if (encoding == null) + { + encoding = "UTF-8"; + } + else + { + if (encoding.equalsIgnoreCase("default")) + { + encoding = null; + } + } + zipFile = new ZipFile(tempFile, encoding, false); + // build a temp dir name based on the ID of the noderef we are importing + // also use the long life temp folder as large ZIP files can take a while + File alfTempDir = TempFileProvider.getLongLifeTempDir("import"); + File tempDir = new File(alfTempDir.getPath() + File.separatorChar + actionedUponNodeRef.getId()); + try + { + // TODO: improve this code to directly pipe the zip stream output into the repo objects - + // to remove the need to expand to the filesystem first? + extractFile(zipFile, tempDir.getPath(), new ZipBombProtection(ratioThreshold, uncompressedBytesLimit)); + importDirectory(tempDir.getPath(), importDest); + } + finally + { + deleteDir(tempDir); + } + } + catch (IOException ioErr) + { + throw new AlfrescoRuntimeException("Failed to import ZIP file.", ioErr); + } + finally + { + // now the import is done, delete the temporary file + if (tempFile != null) + { + tempFile.delete(); + } + if (zipFile != null) + { + try + { + zipFile.close(); + } + catch (IOException e) + { + throw new AlfrescoRuntimeException("Failed to close zip package.", e); + } + } + } + } + } + } + } + + /** + * Recursively import a directory structure into the specified root node + * + * @param dir + * The directory of files and folders to import + * @param root + * The root node to import into + */ + private void importDirectory(String dir, NodeRef root) + { + File topdir = new File(dir); + for (File file : topdir.listFiles()) + { + try + { + if (file.isFile()) + { + String fileName = file.getName(); + + // create content node based on the file name + FileInfo fileInfo = this.fileFolderService.create(root, fileName, ContentModel.TYPE_CONTENT); + NodeRef fileRef = fileInfo.getNodeRef(); + + // add titled aspect for the read/edit properties screens + Map titledProps = new HashMap(1, 1.0f); + titledProps.put(ContentModel.PROP_TITLE, fileName); + this.nodeService.addAspect(fileRef, ContentModel.ASPECT_TITLED, titledProps); + + // push the content of the file into the node + InputStream contentStream = new BufferedInputStream(new FileInputStream(file), BUFFER_SIZE); + ContentWriter writer = this.contentService.getWriter(fileRef, ContentModel.PROP_CONTENT, true); + writer.guessMimetype(fileName); + writer.putContent(contentStream); + } + else + { + String folderName = file.getName(); + + // create a folder based on the folder name + FileInfo folderInfo = this.fileFolderService.create(root, folderName, ContentModel.TYPE_FOLDER); + NodeRef folderRef = folderInfo.getNodeRef(); + + // add titled aspect + Map titledProps = new HashMap(1, 1.0f); + this.nodeService.addAspect(folderRef, ContentModel.ASPECT_TITLED, titledProps); + + // add the uifacets aspect for the read/edit properties screens + this.nodeService.addAspect(folderRef, ApplicationModel.ASPECT_UIFACETS, null); + + importDirectory(file.getPath(), folderRef); + } + } + catch (FileNotFoundException e) + { + // TODO: add failed file info to status message? + throw new AlfrescoRuntimeException("Failed to process ZIP file.", e); + } + catch (FileExistsException e) + { + // TODO: add failed file info to status message? + throw new AlfrescoRuntimeException("Failed to process ZIP file.", e); + } + } + } + + /** + * @see org.alfresco.repo.action.ParameterizedItemAbstractBase#addParameterDefinitions(java.util.List) + */ + protected void addParameterDefinitions(List paramList) + { + paramList.add(new ParameterDefinitionImpl(PARAM_DESTINATION_FOLDER, DataTypeDefinition.NODE_REF, + true, getParamDisplayLabel(PARAM_DESTINATION_FOLDER))); + paramList.add(new ParameterDefinitionImpl(PARAM_ENCODING, DataTypeDefinition.TEXT, + false, getParamDisplayLabel(PARAM_ENCODING))); + } + + /** + * Extract the file and folder structure of a ZIP file into the specified directory + * + * @param archive + * The ZIP archive to extract + * @param extractDir + * The directory to extract into + */ + public static void extractFile(ZipFile archive, String extractDir) + { + extractFile(archive, extractDir, ExtractionProgressTracker.NONE); + } + + /** + * Extract the file and folder structure of a ZIP file into the specified directory using a progress tracker + * + * @param archive + * The ZIP archive to extract + * @param extractDir + * The directory to extract into + * @param tracker + * The extraction progress tracker to check against during the extraction process + */ + public static void extractFile(ZipFile archive, String extractDir, ExtractionProgressTracker tracker) + { + String fileName; + String destFileName; + byte[] buffer = new byte[BUFFER_SIZE]; + extractDir = extractDir + File.separator; + try + { + long totalCompressedBytesCount = 0; + long totalUncompressedBytesCount = 0; + tracker.reportProgress(0, 0); + for (Enumeration e = archive.getEntries(); e.hasMoreElements();) + { + ZipArchiveEntry entry = e.nextElement(); + if (!entry.isDirectory()) + { + fileName = StringUtils.stripAccents(entry.getName()).replaceAll("\\?", "_"); + fileName = fileName.replace('/', File.separatorChar); + + if (fileName.startsWith("/") || fileName.indexOf(":" + File.separator) == 1 || fileName.contains(".." + File.separator)) + { + throw new AlfrescoRuntimeException(ARCHIVE_CONTAINS_SUSPICIOUS_PATHS_ERROR); + } + + destFileName = extractDir + fileName; + File destFile = new File(destFileName); + String parent = destFile.getParent(); + if (parent != null) + { + File parentFile = new File(parent); + if (!parentFile.exists()) + parentFile.mkdirs(); + } + + try (InputStream zis = archive.getInputStream(entry); + InputStream in = new BufferedInputStream(zis, BUFFER_SIZE); + OutputStream out = new BufferedOutputStream(new FileOutputStream(destFileName), BUFFER_SIZE)) + { + final InputStreamStatistics entryStats = (InputStreamStatistics) zis; + int count; + while ((count = in.read(buffer)) != -1) + { + tracker.reportProgress(totalCompressedBytesCount + entryStats.getCompressedCount(), totalUncompressedBytesCount + entryStats.getUncompressedCount()); + out.write(buffer, 0, count); + } + totalCompressedBytesCount += entryStats.getCompressedCount(); + totalUncompressedBytesCount += entryStats.getUncompressedCount(); + } + } + else + { + File newdir = new File(extractDir + StringUtils.stripAccents(entry.getName()).replaceAll("\\?", "_")); + newdir.mkdirs(); + } + } + } + catch (ZipException e) + { + throw new AlfrescoRuntimeException("Failed to process ZIP file.", e); + } + catch (FileNotFoundException e) + { + throw new AlfrescoRuntimeException("Failed to process ZIP file.", e); + } + catch (IOException e) + { + throw new AlfrescoRuntimeException("Failed to process ZIP file.", e); + } + } + + /** + * Recursively delete a dir of files and directories + * + * @param dir + * directory to delete + */ + public static void deleteDir(File dir) + { + if (dir != null) + { + File elenco = new File(dir.getPath()); + + // listFiles can return null if the path is invalid i.e. already been deleted, + // therefore check for null before using in loop + File[] files = elenco.listFiles(); + if (files != null) + { + for (File file : files) + { + if (file.isFile()) + file.delete(); + else + deleteDir(file); + } + } + + // delete provided directory + dir.delete(); + } + } + + private static class ZipBombProtection implements ExtractionProgressTracker + { + private final long ratioThreshold; + private final long uncompressedBytesLimit; + + private ZipBombProtection(long ratioThreshold, long uncompressedBytesLimit) + { + this.ratioThreshold = ratioThreshold; + this.uncompressedBytesLimit = uncompressedBytesLimit; + } + + @Override + public void reportProgress(long compressedBytesCount, long uncompressedBytesCount) + { + if (compressedBytesCount <= 0 || uncompressedBytesCount <= 0) + { + return; + } + + long ratio = uncompressedBytesCount / compressedBytesCount; + + if (ratio > ratioThreshold) + { + throw new AlfrescoRuntimeException("Unexpected compression ratio detected (" + ratio + "%). Possible zip bomb attack. Breaking the extraction process."); + } + + if (uncompressedBytesLimit > 0 && uncompressedBytesCount > uncompressedBytesLimit) + { + throw new AlfrescoRuntimeException("Uncompressed bytes limit exceeded (" + uncompressedBytesCount + "). Possible zip bomb attack. Breaking the extraction process."); + } + } + } + + private interface ExtractionProgressTracker + { + void reportProgress(long compressedBytesCount, long uncompressedBytesCount); + + ExtractionProgressTracker NONE = new ExtractionProgressTracker() { + @Override + public void reportProgress(long compressedBytesCount, long uncompressedBytesCount) + { + // intentionally do nothing + } + }; + } +} diff --git a/repository/src/test/java/org/alfresco/repo/action/executer/ImporterActionExecuterTest.java b/repository/src/test/java/org/alfresco/repo/action/executer/ImporterActionExecuterTest.java index 397d74db90..3ba5fa5205 100644 --- a/repository/src/test/java/org/alfresco/repo/action/executer/ImporterActionExecuterTest.java +++ b/repository/src/test/java/org/alfresco/repo/action/executer/ImporterActionExecuterTest.java @@ -2,7 +2,7 @@ * #%L * Alfresco Repository * %% - * Copyright (C) 2005 - 2022 Alfresco Software Limited + * Copyright (C) 2005 - 2025 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -34,6 +34,10 @@ import java.io.File; import java.io.IOException; import java.net.URL; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + import org.alfresco.error.AlfrescoRuntimeException; import org.alfresco.model.ContentModel; import org.alfresco.repo.action.ActionImpl; @@ -44,7 +48,6 @@ import org.alfresco.repo.transaction.RetryingTransactionHelper; import org.alfresco.repo.transaction.RetryingTransactionHelper.RetryingTransactionCallback; import org.alfresco.service.ServiceRegistry; import org.alfresco.service.cmr.action.Action; -import org.alfresco.service.cmr.repository.ContentData; import org.alfresco.service.cmr.repository.ContentService; import org.alfresco.service.cmr.repository.ContentWriter; import org.alfresco.service.cmr.repository.NodeRef; @@ -53,15 +56,13 @@ import org.alfresco.service.cmr.repository.StoreRef; import org.alfresco.service.namespace.QName; import org.alfresco.util.GUID; import org.alfresco.util.test.junitrules.ApplicationContextInit; -import org.junit.AfterClass; -import org.junit.BeforeClass; -import org.junit.Test; /** * This class contains tests for {@link ImporterActionExecuter}. * * @author abalmus */ +@SuppressWarnings("PMD.JUnitTestsShouldIncludeAssert") public class ImporterActionExecuterTest { // Rule to initialise the default Alfresco spring configuration @@ -87,8 +88,7 @@ public class ImporterActionExecuterTest AuthenticationUtil.setRunAsUserSystem(); // we need a store - storeRef = serviceRegistry.getRetryingTransactionHelper().doInTransaction(new RetryingTransactionCallback() - { + storeRef = serviceRegistry.getRetryingTransactionHelper().doInTransaction(new RetryingTransactionCallback() { public StoreRef execute() { StoreRef storeRef = nodeService.createStore(StoreRef.PROTOCOL_WORKSPACE, "Test_" + System.nanoTime()); @@ -102,8 +102,7 @@ public class ImporterActionExecuterTest { try { - serviceRegistry.getRetryingTransactionHelper().doInTransaction(new RetryingTransactionCallback() - { + serviceRegistry.getRetryingTransactionHelper().doInTransaction(new RetryingTransactionCallback() { public Void execute() { if (storeRef != null) @@ -125,8 +124,7 @@ public class ImporterActionExecuterTest { final RetryingTransactionHelper retryingTransactionHelper = serviceRegistry.getRetryingTransactionHelper(); - retryingTransactionHelper.doInTransaction(new RetryingTransactionCallback() - { + retryingTransactionHelper.doInTransaction(new RetryingTransactionCallback() { public Void execute() { NodeRef rootNodeRef = nodeService.getRootNode(storeRef); @@ -162,8 +160,7 @@ public class ImporterActionExecuterTest } /** - * MNT-16292: Unzipped files which have folders do not get the cm:titled - * aspect applied + * MNT-16292: Unzipped files which have folders do not get the cm:titled aspect applied * * @throws IOException */ @@ -172,8 +169,7 @@ public class ImporterActionExecuterTest { final RetryingTransactionHelper retryingTransactionHelper = serviceRegistry.getRetryingTransactionHelper(); - retryingTransactionHelper.doInTransaction(new RetryingTransactionCallback() - { + retryingTransactionHelper.doInTransaction(new RetryingTransactionCallback() { public Void execute() { NodeRef rootNodeRef = nodeService.getRootNode(storeRef); @@ -190,7 +186,7 @@ public class ImporterActionExecuterTest { importerActionExecuter.execute(action, zipFileNodeRef); - // check if import succeeded + // check if import succeeded NodeRef importedFolder = nodeService.getChildByName(targetFolderNodeRef, ContentModel.ASSOC_CONTAINS, "folderCmTitledAspectArchive"); assertNotNull("import action failed", importedFolder); @@ -199,7 +195,7 @@ public class ImporterActionExecuterTest assertTrue("folder didn't get the cm:titled aspect applied", hasAspectTitled); // MNT-17017 check ContentModel.PROP_TITLE is not set on the top level folder, just like Share - String title = (String)nodeService.getProperty(importedFolder, ContentModel.PROP_TITLE); + String title = (String) nodeService.getProperty(importedFolder, ContentModel.PROP_TITLE); assertNull("The title should not have cm:title set", title); } finally @@ -224,8 +220,7 @@ public class ImporterActionExecuterTest { final RetryingTransactionHelper retryingTransactionHelper = serviceRegistry.getRetryingTransactionHelper(); - retryingTransactionHelper.doInTransaction(new RetryingTransactionCallback() - { + retryingTransactionHelper.doInTransaction(new RetryingTransactionCallback() { public Void execute() { NodeRef rootNodeRef = nodeService.getRootNode(storeRef); @@ -270,8 +265,7 @@ public class ImporterActionExecuterTest { final RetryingTransactionHelper retryingTransactionHelper = serviceRegistry.getRetryingTransactionHelper(); - retryingTransactionHelper.doInTransaction(new RetryingTransactionCallback() - { + retryingTransactionHelper.doInTransaction(new RetryingTransactionCallback() { public Void execute() { NodeRef rootNodeRef = nodeService.getRootNode(storeRef); @@ -306,11 +300,51 @@ public class ImporterActionExecuterTest }); } + @Test + public void testUnzipZipFileHavingAccentCharInFolderName() throws IOException + { + final RetryingTransactionHelper retryingTransactionHelper = serviceRegistry.getRetryingTransactionHelper(); + + retryingTransactionHelper.doInTransaction(new RetryingTransactionCallback() { + @Override + public Void execute() throws Throwable + + { + NodeRef rootNodeRef = nodeService.getRootNode(storeRef); + + // create test data + NodeRef zipFileNodeRef = nodeService.createNode(rootNodeRef, ContentModel.ASSOC_CHILDREN, ContentModel.ASSOC_CHILDREN, ContentModel.TYPE_CONTENT).getChildRef(); + NodeRef targetFolderNodeRef = nodeService.createNode(rootNodeRef, ContentModel.ASSOC_CHILDREN, ContentModel.ASSOC_CHILDREN, ContentModel.TYPE_FOLDER).getChildRef(); + + putContent(zipFileNodeRef, "import-archive-test/accentCharTestZip.zip"); + + Action action = createAction(zipFileNodeRef, "ImporterActionExecuterTestActionDefinition", targetFolderNodeRef); + + try + { + importerActionExecuter.setUncompressedBytesLimit("100000"); + importerActionExecuter.execute(action, zipFileNodeRef); + NodeRef importedFolder = nodeService.getChildByName(targetFolderNodeRef, ContentModel.ASSOC_CONTAINS, "accentCharTestZip"); + assertNotNull("unzip action failed", importedFolder); + assertTrue("multiple folder structure created", nodeService.getChildAssocs(importedFolder).size() == 1); + } + finally + { + // clean test data + nodeService.deleteNode(targetFolderNodeRef); + nodeService.deleteNode(zipFileNodeRef); + } + + return null; + } + }); + } + private void putContent(NodeRef zipFileNodeRef, String resource) { URL url = AbstractContentTransformerTest.class.getClassLoader().getResource(resource); final File file = new File(url.getFile()); - + ContentWriter writer = contentService.getWriter(zipFileNodeRef, ContentModel.PROP_CONTENT, true); writer.setMimetype(MimetypeMap.MIMETYPE_ZIP); writer.putContent(file); diff --git a/repository/src/test/resources/import-archive-test/accentCharTestZip.zip b/repository/src/test/resources/import-archive-test/accentCharTestZip.zip new file mode 100644 index 0000000000000000000000000000000000000000..87850d8b8a44d5e65898d6e379bcb80e77163977 GIT binary patch literal 647 zcmWIWW@Zs#0D(0nEm2?wln`Q2U`R|(PR%QE&PXf@Ni8mk$}G??t>9*0WC1Aw6QLoz z4D3D&-=~KGu^tcyK+NM{V8}!>PYJ7e5F0?IJLaSvZoz8)`2`U3K{UjC4h{~6#w=9# z8WCka!oI^TdLT^x(%XPN3J>fjGdMkx7IZ zSI|j-LI@20JAx=80+N-15o9UcT&zKeY;FKna}gfI5uiYOAOQ(+KP)Jb{bs~~uxDH2 i2BPdi*oz}zf%bv|G{Bpc4HP2GKxo0pz+ef)3=9Ak-