You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@atlas.apache.org by ma...@apache.org on 2026/09/28 11:37:35 UTC

(atlas) branch ATLAS-5421-0928 created (now b829994b1)

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

mandarambawane pushed a change to branch ATLAS-5421-0928
in repository https://gitbox.apache.org/repos/asf/atlas.git


      at b829994b1 ATLAS-5421 : Prevent /api/atlas/admin/importfile from reading arbitrary server files and enumerating filesystem paths

This branch includes the following new commits:

     new b829994b1 ATLAS-5421 : Prevent /api/atlas/admin/importfile from reading arbitrary server files and enumerating filesystem paths

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



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

Posted by ma...@apache.org.
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 <ma...@cloudera.com>
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();
         }
     }