This is an automated email from the ASF dual-hosted git repository. mandarambawane pushed a commit to branch ATLAS-5421-0928 in repository https://gitbox.apache.org/repos/asf/atlas.git
commit b829994b1c2443db8b5c5cc8dd6a8a65c49c1797 Author: Mandar Ambawane <[email protected]> AuthorDate: Mon Sep 28 17:06:59 2026 +0530 ATLAS-5421 : Prevent /api/atlas/admin/importfile from reading arbitrary server files and enumerating filesystem paths --- distro/src/conf/atlas-application.properties | 2 + .../java/org/apache/atlas/AtlasConfiguration.java | 1 + .../main/java/org/apache/atlas/AtlasErrorCode.java | 1 + .../repository/impexp/ImportFilePathValidator.java | 112 +++++++++++++ .../atlas/repository/impexp/ImportService.java | 18 ++- .../impexp/ImportFilePathValidatorTest.java | 176 +++++++++++++++++++++ .../atlas/repository/impexp/ImportServiceTest.java | 29 +++- 7 files changed, 328 insertions(+), 11 deletions(-) diff --git a/distro/src/conf/atlas-application.properties b/distro/src/conf/atlas-application.properties index f4363702f..df638a8ea 100755 --- a/distro/src/conf/atlas-application.properties +++ b/distro/src/conf/atlas-application.properties @@ -86,6 +86,8 @@ atlas.graph.index.search.max-result-set-size=150 ######### Import Configs ######### #atlas.import.temp.directory=/temp/import +# Restrict importfile API to files under this directory (relative paths only). Required for server-side import. +#atlas.import.allowed.directory=${sys:atlas.data}/import ######### Notification Configs ######### atlas.notification.embedded=true diff --git a/intg/src/main/java/org/apache/atlas/AtlasConfiguration.java b/intg/src/main/java/org/apache/atlas/AtlasConfiguration.java index 882d88f11..4bcb31cf0 100644 --- a/intg/src/main/java/org/apache/atlas/AtlasConfiguration.java +++ b/intg/src/main/java/org/apache/atlas/AtlasConfiguration.java @@ -77,6 +77,7 @@ public enum AtlasConfiguration { LABEL_MAX_LENGTH("atlas.entity.label.max.length", 50), IMPORT_TEMP_DIRECTORY("atlas.import.temp.directory", ""), + IMPORT_ALLOWED_DIRECTORY("atlas.import.allowed.directory", ""), MIGRATION_IMPORT_START_POSITION("atlas.migration.import.start.position", 0), LINEAGE_USING_GREMLIN("atlas.lineage.query.use.gremlin", false), LINEAGE_ON_DEMAND_ENABLED("atlas.lineage.on.demand.enabled", false), diff --git a/intg/src/main/java/org/apache/atlas/AtlasErrorCode.java b/intg/src/main/java/org/apache/atlas/AtlasErrorCode.java index 9e82b1248..962109844 100644 --- a/intg/src/main/java/org/apache/atlas/AtlasErrorCode.java +++ b/intg/src/main/java/org/apache/atlas/AtlasErrorCode.java @@ -187,6 +187,7 @@ public enum AtlasErrorCode { INVALID_GUID(400, "ATLAS-400-00-108", "guid {0} is not a valid UUID"), PURGE_REQUEST_SIZE_EXCEEDS_LIMIT(400, "ATLAS-400-00-109", "purge request size {0} exceeds maximum limit {1}"), NOT_IN_DELETED_STATE(400, "ATLAS-400-00-10A", "entity {0} is not in DELETED state"), + IMPORT_FILE_NOT_ACCESSIBLE(400, "ATLAS-400-00-10B", "Import file not found or invalid."), UNAUTHORIZED_ACCESS(403, "ATLAS-403-00-001", "{0} is not authorized to perform {1}"), diff --git a/repository/src/main/java/org/apache/atlas/repository/impexp/ImportFilePathValidator.java b/repository/src/main/java/org/apache/atlas/repository/impexp/ImportFilePathValidator.java new file mode 100644 index 000000000..5cac5fc04 --- /dev/null +++ b/repository/src/main/java/org/apache/atlas/repository/impexp/ImportFilePathValidator.java @@ -0,0 +1,112 @@ +/** + * 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.atlas.repository.impexp; + +import org.apache.atlas.AtlasConfiguration; +import org.apache.atlas.AtlasErrorCode; +import org.apache.atlas.exception.AtlasBaseException; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; + +/** + * Validates server-side import file paths for {@code /api/atlas/admin/importfile}. + * Resolves paths relative to a configured base directory and rejects path traversal. + */ +public final class ImportFilePathValidator { + private static final Logger LOG = LoggerFactory.getLogger(ImportFilePathValidator.class); + private static final String ZIP_EXTENSION = ".zip"; + + private ImportFilePathValidator() { + } + + public static File validate(String fileName) throws AtlasBaseException { + try { + return doValidate(fileName); + } catch (AtlasBaseException excp) { + if (excp.getAtlasErrorCode() == AtlasErrorCode.IMPORT_FILE_NOT_ACCESSIBLE) { + throw excp; + } + + throw importFileNotAccessible(fileName, excp.getMessage()); + } catch (Exception excp) { + throw importFileNotAccessible(fileName, excp.getMessage()); + } + } + + private static File doValidate(String fileName) throws AtlasBaseException, IOException { + String allowedDirectory = AtlasConfiguration.IMPORT_ALLOWED_DIRECTORY.getString(); + + if (StringUtils.isBlank(allowedDirectory)) { + throw importFileNotAccessible(fileName, "atlas.import.allowed.directory is not configured"); + } + + if (new File(fileName).isAbsolute()) { + throw importFileNotAccessible(fileName, "absolute paths are not allowed"); + } + + File allowedBase = getCanonicalDirectory(new File(allowedDirectory)); + File importFile = getCanonicalFile(new File(allowedBase, fileName)); + + if (!isWithinDirectory(importFile, allowedBase)) { + throw importFileNotAccessible(fileName, "path is outside the configured import directory"); + } + + if (!StringUtils.endsWithIgnoreCase(importFile.getName(), ZIP_EXTENSION)) { + throw importFileNotAccessible(fileName, "file is not a zip archive"); + } + + if (!importFile.isFile() || !importFile.canRead()) { + throw importFileNotAccessible(fileName, "file does not exist or is not readable"); + } + + return importFile; + } + + private static File getCanonicalDirectory(File directory) throws AtlasBaseException, IOException { + if (!directory.exists()) { + throw importFileNotAccessible(directory.getPath(), "configured import directory does not exist"); + } + + if (!directory.isDirectory()) { + throw importFileNotAccessible(directory.getPath(), "configured import path is not a directory"); + } + + return directory.getCanonicalFile(); + } + + private static File getCanonicalFile(File file) throws IOException { + return file.getCanonicalFile(); + } + + private static boolean isWithinDirectory(File file, File directory) throws IOException { + String filePath = file.getCanonicalPath(); + String directoryPath = directory.getCanonicalPath(); + + return filePath.equals(directoryPath) || filePath.startsWith(directoryPath + File.separator); + } + + private static AtlasBaseException importFileNotAccessible(String fileName, String reason) { + LOG.warn("Rejected import file request for '{}': {}", fileName, reason); + + return new AtlasBaseException(AtlasErrorCode.IMPORT_FILE_NOT_ACCESSIBLE); + } +} diff --git a/repository/src/main/java/org/apache/atlas/repository/impexp/ImportService.java b/repository/src/main/java/org/apache/atlas/repository/impexp/ImportService.java index b6a66522c..170675db3 100644 --- a/repository/src/main/java/org/apache/atlas/repository/impexp/ImportService.java +++ b/repository/src/main/java/org/apache/atlas/repository/impexp/ImportService.java @@ -66,6 +66,8 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import static org.apache.atlas.AtlasErrorCode.IMPORT_ATTEMPTING_EMPTY_ZIP; +import static org.apache.atlas.AtlasErrorCode.IMPORT_FILE_NOT_ACCESSIBLE; import static org.apache.atlas.model.impexp.AtlasAsyncImportRequest.ImportStatus.FAILED; import static org.apache.atlas.model.impexp.AtlasImportRequest.TRANSFORMERS_KEY; import static org.apache.atlas.model.impexp.AtlasImportRequest.TRANSFORMS_KEY; @@ -131,17 +133,23 @@ public class ImportService implements AsyncImporter { try { LOG.info("==> import(user={}, from={}, fileName={})", userName, requestingIP, fileName); - File file = new File(fileName); + File file = ImportFilePathValidator.validate(fileName); result = run(new FileInputStream(file), request, userName, hostName, requestingIP); } catch (AtlasBaseException excp) { + if (isImportFileAccessFailure(excp)) { + LOG.warn("import(user={}, from={}, fileName={}): invalid import file", userName, requestingIP, fileName, excp); + + throw new AtlasBaseException(IMPORT_FILE_NOT_ACCESSIBLE); + } + LOG.error("import(user={}, from={}, fileName={}): failed", userName, requestingIP, excp); throw excp; } catch (FileNotFoundException excp) { - LOG.error("import(user={}, from={}, fileName={}): file not found", userName, requestingIP, excp); + LOG.warn("import(user={}, from={}, fileName={}): invalid import file", userName, requestingIP, fileName, excp); - throw new AtlasBaseException(AtlasErrorCode.INVALID_PARAMETERS, fileName + ": file not found"); + throw new AtlasBaseException(IMPORT_FILE_NOT_ACCESSIBLE); } catch (Exception excp) { LOG.error("import(user={}, from={}, fileName={}): failed", userName, requestingIP, excp); @@ -154,6 +162,10 @@ public class ImportService implements AsyncImporter { return result; } + private static boolean isImportFileAccessFailure(AtlasBaseException excp) { + return excp.getAtlasErrorCode() == IMPORT_FILE_NOT_ACCESSIBLE || excp.getAtlasErrorCode() == IMPORT_ATTEMPTING_EMPTY_ZIP; + } + public AtlasAsyncImportRequest run(AtlasImportRequest request, InputStream inputStream, String userName, String hostName, String requestingIP) throws AtlasBaseException { if (request == null) { request = new AtlasImportRequest(); diff --git a/repository/src/test/java/org/apache/atlas/repository/impexp/ImportFilePathValidatorTest.java b/repository/src/test/java/org/apache/atlas/repository/impexp/ImportFilePathValidatorTest.java new file mode 100644 index 000000000..c165e7ebf --- /dev/null +++ b/repository/src/test/java/org/apache/atlas/repository/impexp/ImportFilePathValidatorTest.java @@ -0,0 +1,176 @@ +/** + * 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.atlas.repository.impexp; + +import org.apache.atlas.ApplicationProperties; +import org.apache.atlas.AtlasConfiguration; +import org.apache.atlas.AtlasErrorCode; +import org.apache.atlas.exception.AtlasBaseException; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.fail; + +public class ImportFilePathValidatorTest { + private File importDirectory; + private String previousAllowedDirectory; + + @BeforeMethod + public void setUp() throws Exception { + importDirectory = Files.createTempDirectory("atlas-import-allowed-").toFile(); + previousAllowedDirectory = ApplicationProperties.get().getString(AtlasConfiguration.IMPORT_ALLOWED_DIRECTORY.getPropertyName()); + + ApplicationProperties.get().setProperty(AtlasConfiguration.IMPORT_ALLOWED_DIRECTORY.getPropertyName(), + importDirectory.getAbsolutePath()); + } + + @AfterMethod + public void tearDown() throws Exception { + if (previousAllowedDirectory == null) { + ApplicationProperties.get().clearProperty(AtlasConfiguration.IMPORT_ALLOWED_DIRECTORY.getPropertyName()); + } else { + ApplicationProperties.get().setProperty(AtlasConfiguration.IMPORT_ALLOWED_DIRECTORY.getPropertyName(), + previousAllowedDirectory); + } + + deleteRecursively(importDirectory); + } + + @Test + public void validateAcceptsReadableZipInAllowedDirectory() throws Exception { + File zipFile = new File(importDirectory, "export.zip"); + assertTrue(zipFile.createNewFile()); + + File validatedFile = ImportFilePathValidator.validate("export.zip"); + + assertNotNull(validatedFile); + assertEquals(validatedFile.getCanonicalPath(), zipFile.getCanonicalPath()); + } + + @Test + public void validateAcceptsNestedZipInAllowedDirectory() throws Exception { + File nestedDir = new File(importDirectory, "nested"); + assertTrue(nestedDir.mkdir()); + + File zipFile = new File(nestedDir, "export.zip"); + assertTrue(zipFile.createNewFile()); + + File validatedFile = ImportFilePathValidator.validate("nested/export.zip"); + + assertEquals(validatedFile.getCanonicalPath(), zipFile.getCanonicalPath()); + } + + @Test + public void validateRejectsAbsolutePath() { + assertImportFileNotAccessible("/etc/passwd"); + } + + @Test + public void validateRejectsPathTraversal() { + assertImportFileNotAccessible("../outside.zip"); + } + + @Test + public void validateRejectsNonZipExtension() throws Exception { + File textFile = new File(importDirectory, "notes.txt"); + assertTrue(textFile.createNewFile()); + + assertImportFileNotAccessible("notes.txt"); + } + + @Test + public void validateRejectsMissingFile() { + assertImportFileNotAccessible("missing.zip"); + } + + @Test + public void validateRejectsExistingNonZipFile() throws Exception { + File passwdLikeFile = new File(importDirectory, "passwd"); + assertTrue(passwdLikeFile.createNewFile()); + + assertImportFileNotAccessible("passwd"); + } + + @Test + public void validateRejectsWhenAllowedDirectoryNotConfigured() throws Exception { + ApplicationProperties.get().clearProperty(AtlasConfiguration.IMPORT_ALLOWED_DIRECTORY.getPropertyName()); + + assertImportFileNotAccessible("export.zip"); + } + + @Test + public void validateReturnsSameErrorForMissingAndInvalidFiles() throws Exception { + File existingFile = new File(importDirectory, "existing.txt"); + assertTrue(existingFile.createNewFile()); + + AtlasErrorCode missingFileError = getImportFileError("missing.zip"); + AtlasErrorCode existingNonZipError = getImportFileError("existing.txt"); + + assertEquals(missingFileError, AtlasErrorCode.IMPORT_FILE_NOT_ACCESSIBLE); + assertEquals(existingNonZipError, AtlasErrorCode.IMPORT_FILE_NOT_ACCESSIBLE); + assertEquals(missingFileError, existingNonZipError); + } + + private AtlasErrorCode getImportFileError(String fileName) { + try { + ImportFilePathValidator.validate(fileName); + fail("Expected AtlasBaseException for fileName=" + fileName); + } catch (AtlasBaseException excp) { + return excp.getAtlasErrorCode(); + } + + return null; + } + + private void assertImportFileNotAccessible(String fileName) { + try { + ImportFilePathValidator.validate(fileName); + fail("Expected AtlasBaseException for fileName=" + fileName); + } catch (AtlasBaseException excp) { + assertEquals(excp.getAtlasErrorCode(), AtlasErrorCode.IMPORT_FILE_NOT_ACCESSIBLE); + } + } + + private static void deleteRecursively(File file) throws IOException { + if (file == null || !file.exists()) { + return; + } + + if (file.isDirectory()) { + File[] children = file.listFiles(); + + if (children != null) { + for (File child : children) { + deleteRecursively(child); + } + } + } + + if (!file.delete()) { + file.deleteOnExit(); + } + } +} diff --git a/repository/src/test/java/org/apache/atlas/repository/impexp/ImportServiceTest.java b/repository/src/test/java/org/apache/atlas/repository/impexp/ImportServiceTest.java index bef2de68f..6a853fedd 100644 --- a/repository/src/test/java/org/apache/atlas/repository/impexp/ImportServiceTest.java +++ b/repository/src/test/java/org/apache/atlas/repository/impexp/ImportServiceTest.java @@ -18,6 +18,8 @@ package org.apache.atlas.repository.impexp; import com.google.inject.Inject; +import org.apache.atlas.ApplicationProperties; +import org.apache.atlas.AtlasConfiguration; import org.apache.atlas.AtlasErrorCode; import org.apache.atlas.RequestContext; import org.apache.atlas.TestModules; @@ -50,7 +52,6 @@ import org.apache.atlas.type.AtlasClassificationType; import org.apache.atlas.type.AtlasTypeRegistry; import org.apache.atlas.v1.typesystem.types.utils.TypesUtil; import org.apache.commons.lang3.StringUtils; -import org.mockito.stubbing.Answer; import org.testng.ITestContext; import org.testng.annotations.AfterClass; import org.testng.annotations.AfterTest; @@ -601,20 +602,32 @@ public class ImportServiceTest extends AtlasTestBase { } @Test - public void importServiceProcessesIOException() { + public void importServiceRejectsMissingImportFile() throws Exception { ImportService importService = new ImportService(typeDefStore, typeRegistry, null, null, null, null, null, null, atlasAuditService); AtlasImportRequest req = mock(AtlasImportRequest.class); - Answer<Map> answer = invocationOnMock -> { - throw new IOException("file is read only"); - }; + java.io.File importDirectory = java.nio.file.Files.createTempDirectory("atlas-import-service-").toFile(); + + String previousAllowedDirectory = ApplicationProperties.get().getString( + AtlasConfiguration.IMPORT_ALLOWED_DIRECTORY.getPropertyName()); + ApplicationProperties.get().setProperty(AtlasConfiguration.IMPORT_ALLOWED_DIRECTORY.getPropertyName(), + importDirectory.getAbsolutePath()); - when(req.getFileName()).thenReturn("some-file.zip"); - when(req.getOptions()).thenAnswer(answer); + when(req.getFileName()).thenReturn("missing-file.zip"); try { importService.run(req, "a", "b", "c"); + fail("Expected AtlasBaseException"); } catch (AtlasBaseException ex) { - assertEquals(ex.getAtlasErrorCode().getErrorCode(), AtlasErrorCode.INVALID_PARAMETERS.getErrorCode()); + assertEquals(ex.getAtlasErrorCode(), AtlasErrorCode.IMPORT_FILE_NOT_ACCESSIBLE); + } finally { + if (previousAllowedDirectory == null) { + ApplicationProperties.get().clearProperty(AtlasConfiguration.IMPORT_ALLOWED_DIRECTORY.getPropertyName()); + } else { + ApplicationProperties.get().setProperty(AtlasConfiguration.IMPORT_ALLOWED_DIRECTORY.getPropertyName(), + previousAllowedDirectory); + } + + importDirectory.delete(); } }
