You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@atlas.apache.org by ni...@apache.org on 2026/10/26 09:56:24 UTC

(atlas) 05/06: ATLAS-5421 : Prevent /api/atlas/admin/importfile from reading arbitrary server files and enumerating filesystem paths (#776)

This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch atlas-2.6
in repository https://gitbox.apache.org/repos/asf/atlas.git

commit 88a03ec751278e60ec8ec76ea879bfef5100622b
Author: mandarambawane <ma...@freestoneinfotech.com>
AuthorDate: Tue Sep 29 15:16:13 2026 +0530

    ATLAS-5421 : Prevent /api/atlas/admin/importfile from reading arbitrary server files and enumerating filesystem paths (#776)
    
    Co-authored-by: Mandar Ambawane <ma...@cloudera.com>
---
 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 77c0797aa3..d6a22a0d35 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 78b98340fd..446418f532 100644
--- a/intg/src/main/java/org/apache/atlas/AtlasConfiguration.java
+++ b/intg/src/main/java/org/apache/atlas/AtlasConfiguration.java
@@ -74,6 +74,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 9e82b12485..9621098441 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 0000000000..5cac5fc044
--- /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 b6a66522cb..170675db35 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 0000000000..c165e7ebf9
--- /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 bef2de68f6..6a853fedd1 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();
         }
     }