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();
}
}