CVE-2022-23532 (published 2023-01-14T01:15Z)

APOC (Awesome Procedures on Cypher) is an add-on library for Neo4j that provides hundreds of procedures and functions. A path traversal vulnerability found in the apoc.export.* procedures of apoc plugins in Neo4j Graph database. The issue allows a malicious actor to potentially break out of the expected directory. The vulnerability is such that files could only be created but not overwritten. For the vulnerability to be exploited, an attacker would need access to execute an arbitrary query, either by having access to an authenticated Neo4j client, or a Cypher injection vulnerability in an application. The minimum versions containing patch for this vulnerability are 4.4.0.12 and 4.3.0.12 and 5.3.1. As a workaround, you can control the\xa0allowlist of the procedures\xa0that can be used in your system, and/or turn off local file access by setting apoc.export.file.enabled=false.

CVSS v3 base score: 6.5, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N)

Weakness information:
cwe_id: CWE-22
cwe_name: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
description: The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
extended_description: 
            Many file operations are intended to take place within a restricted directory. By using special elements such as ".." and "/" separators, attackers can escape outside of the restricted location to access files or directories that are elsewhere on the system. One of the most common special elements is the "../" sequence, which in most modern operating systems is interpreted as the parent directory of the current location. This is referred to as relative path traversal. Path traversal also covers the use of absolute pathnames such as "/usr/local/bin" to access unexpected files. This is referred to as absolute path traversal.
         
         

Fix information:
repo_url: https://github.com/neo4j-contrib/neo4j-apoc-procedures
commit_details: repo_url: https://github.com/neo4j-contrib/neo4j-apoc-procedures
author: Gem Lamont
author_date: 2022-12-16 12:44:40+01:00
msg: 4.4 export security fix (#3370)

* [vFQk1rX0] Fix security issue

* [vFQk1rX0] Add more test cases from PR review
repository: repo_url: https://github.com/neo4j-contrib/neo4j-apoc-procedures
repo_name: neo4j-contrib/neo4j-apoc-procedures
description: Awesome Procedures On Cypher for Neo4j - codenamed "apoc"                     If you like it, please ★ above ⇧            
repo_language: Java
owner: neo4j-contrib
file_changes: filename: ExportCSV.java
change_type: ModificationType.MODIFY
diff: @@ -116,8 +116,8 @@ public class ExportCSV {
 
     private void dump(Object data, ExportConfig c, ProgressReporter reporter, ExportFileManager printWriter, CsvFormat exporter) {
         if (data instanceof SubGraph)
-            exporter.dump((SubGraph)data,printWriter,reporter,c);
+            exporter.dump((SubGraph) data, printWriter, reporter, c);
         if (data instanceof Result)
-            exporter.dump((Result)data,printWriter,reporter,c);
+            exporter.dump((Result) data, printWriter, reporter, c);
     }
 }

programming_language: TypeScript
filename: FileUtils.java
change_type: ModificationType.MODIFY
diff: @@ -227,7 +227,7 @@ public class FileUtils {
         Path urlPath;
         URL toURL = null;
         try {
-            final URI uri = URI.create(url.trim());
+            final URI uri = URI.create(url.trim()).normalize();
             toURL = uri.toURL();
             urlPath = Paths.get(uri);
         } catch (Exception e) {
@@ -242,10 +242,10 @@ public class FileUtils {
 
     private static boolean pathStartsWithOther(Path resolvedPath, Path basePath) throws IOException {
         try {
-            return resolvedPath.toRealPath().startsWith(basePath.toRealPath());
+            return resolvedPath.toFile().getCanonicalFile().toPath().startsWith(basePath.toRealPath());
         } catch (Exception e) {
-            if (e instanceof NoSuchFileException) { // If we're about to creating a file this exception has been thrown
-                return resolvedPath.normalize().startsWith(basePath);
+            if (e instanceof NoSuchFileException) { // If we're about to create a file this exception has been thrown
+                return resolvedPath.toFile().getCanonicalFile().toPath().startsWith(basePath);
             }
             return false;
         }

programming_language: Java
filename: ExportCoreSecurityTest.java
change_type: ModificationType.MODIFY
diff: @@ -8,9 +8,9 @@ import apoc.export.graphml.ExportGraphML;
 import apoc.export.json.ExportJson;
 import apoc.util.FileUtils;
 import apoc.util.TestUtil;
+import junit.framework.TestCase;
 import org.apache.commons.lang3.exception.ExceptionUtils;
-import org.junit.After;
-import org.junit.Before;
+import org.junit.Assert;
 import org.junit.BeforeClass;
 import org.junit.ClassRule;
 import org.junit.Test;
@@ -18,6 +18,7 @@ import org.junit.experimental.runners.Enclosed;
 import org.junit.runner.RunWith;
 import org.junit.runners.Parameterized;
 import org.neo4j.configuration.GraphDatabaseSettings;
+import org.neo4j.graphdb.QueryExecutionException;
 import org.neo4j.graphdb.Result;
 import org.neo4j.test.rule.DbmsRule;
 import org.neo4j.test.rule.ImpermanentDbmsRule;
@@ -38,10 +39,17 @@ import static org.junit.Assert.fail;
 public class ExportCoreSecurityTest {
 
     private static final File directory = new File("target/import");
+    private static final File directoryWithSamePrefix = new File("target/imported");
+    private static final File subDirectory = new File("target/import/tests");
     private static final List<String> APOC_EXPORT_PROCEDURE_NAME = Arrays.asList("csv", "json", "graphml", "cypher");
 
     static {
+        //noinspection ResultOfMethodCallIgnored
         directory.mkdirs();
+        //noinspection ResultOfMethodCallIgnored
+        subDirectory.mkdirs();
+        //noinspection ResultOfMethodCallIgnored
+        directoryWithSamePrefix.mkdirs();
     }
 
     @ClassRule
@@ -52,16 +60,11 @@ public class ExportCoreSecurityTest {
     @BeforeClass
     public static void setUp() throws Exception {
         TestUtil.registerProcedure(db, ExportCSV.class, ExportJson.class, ExportGraphML.class, ExportCypher.class);
+        setFileExport(false);
     }
 
-    @Before
-    public void before() {
-        ApocConfig.apocConfig().setProperty(ApocSettings.apoc_export_file_enabled, false);
-    }
-
-    @After
-    public void after() {
-        ApocConfig.apocConfig().setProperty(ApocSettings.apoc_export_file_enabled, false);
+    private static void setFileExport(boolean allowed) {
+        ApocConfig.apocConfig().setProperty(ApocConfig.APOC_EXPORT_FILE_ENABLED, allowed);
     }
 
     private static Collection<String[]> data(Map<String, List<String>> apocProcedureArguments) {
@@ -114,16 +117,12 @@ public class ExportCoreSecurityTest {
 
         @Test
         public void testIllegalFSAccessExport() {
-            final String message = apocProcedure + " should throw an exception";
-            try {
-                db.executeTransactionally("CALL " + apocProcedure, Map.of(),
-                        Result::resultAsString);
-                fail(message);
-            } catch (Exception e) {
-                assertError(e, ApocConfig.EXPORT_TO_FILE_ERROR, RuntimeException.class, apocProcedure);
-            }
-        }
+            QueryExecutionException e = Assert.assertThrows(QueryExecutionException.class,
+                    () -> TestUtil.testCall(db, "CALL " + apocProcedure, (r) -> {})
+            );
 
+            assertError(e, ApocConfig.EXPORT_TO_FILE_ERROR, RuntimeException.class, apocProcedure);
+        }
     }
 
     @RunWith(Parameterized.class)
@@ -162,45 +161,310 @@ public class ExportCoreSecurityTest {
         public void testIllegalExternalFSAccessExport() {
             final String message = apocProcedure + " should throw an exception";
             try {
-                ApocConfig.apocConfig().setProperty(ApocSettings.apoc_export_file_enabled, true);
+                setFileExport(true);
                 db.executeTransactionally("CALL " + apocProcedure, Map.of(),
                         Result::resultAsString);
                 fail(message);
             } catch (Exception e) {
                 assertError(e, FileUtils.ACCESS_OUTSIDE_DIR_ERROR, IOException.class, apocProcedure);
             } finally {
-                ApocConfig.apocConfig().setProperty(ApocSettings.apoc_export_file_enabled, false);
+                setFileExport(false);
             }
         }
     }
 
+    @RunWith(Parameterized.class)
+    public static class TestPathTraversalAccess {
+        private final String apocProcedure;
+
+        public TestPathTraversalAccess(String exportMethod, String exportMethodType, String exportMethodArguments) {
+            this.apocProcedure = "apoc.export." + exportMethod + "." + exportMethodType + "(" + exportMethodArguments + ")";
+        }
+
+        private static final String case1 = "'file:///%2e%2e%2f%2f%2e%2e%2f%2f%2e%2e%2f%2f%2e%2e%2f%2fapoc/test.txt'";
+        private static final String case2 = "'file:///%2e%2e%2f%2ftest.txt'";
+        private static final String case3 = "'../test.txt'";
+        private static final String case4 = "'tests/../../test.txt'";
+        private static final String case5 = "'tests/..//..//test.txt'";
+
+        private static final List<String> cases = Arrays.asList(case1, case2, case3, case4, case5);
+
+        private static final Map<String, List<String>> METHOD_ARGUMENTS = Map.of(
+                "query",  cases.stream().map(
+                        filePath -> "\"RETURN 1\", " + filePath + ", {}"
+                ).collect(Collectors.toList()),
+                "all", cases.stream().map(
+                        filePath -> filePath + ", {}"
+                ).collect(Collectors.toList()),
+                "data", cases.stream().map(
+                        filePath -> "[], [], " + filePath + ", {}"
+                ).collect(Collectors.toList()),
+                "graph", cases.stream().map(
+                        filePath -> "{nodes: [], relationships: []}, " + filePath + ", {}"
+                ).collect(Collectors.toList())
+        );
+
+        @Parameterized.Parameters
+        public static Collection<String[]> data() {
+            return ExportCoreSecurityTest.data(METHOD_ARGUMENTS);
+        }
+
+        @Test
+        public void testPathTraversal() {
+            setFileExport(true);
+
+            QueryExecutionException e = Assert.assertThrows(QueryExecutionException.class,
+                    () -> TestUtil.testCall(db, "CALL " + apocProcedure, (r) -> {})
+            );
+
+            assertError(e, FileUtils.ACCESS_OUTSIDE_DIR_ERROR, IOException.class, apocProcedure);
+            setFileExport(false);
+        }
+    }
+
+    /**
+     * All of these will resolve to a local path after normalization which will point to
+     * a non-existing directory in our import folder: /apoc. Causing them to error that is
+     * not found. They all attempt to exit the import folder back to the apoc folder:
+     * Directory Layout: .../apoc/core/target/import
+     */
+    @RunWith(Parameterized.class)
+    public static class TestPathTraversalIsNormalised {
+        private final String apocProcedure;
+
+        public TestPathTraversalIsNormalised(String exportMethod, String exportMethodType, String exportMethodArguments) {
+            this.apocProcedure = "apoc.export." + exportMethod + "." + exportMethodType + "(" + exportMethodArguments + ")";
+        }
+
+        private static final String case1 = "'file://%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f/apoc/test.txt'";
+        private static final String case2 = "'file://../../../../apoc/test.txt'";
+        private static final String case3 = "'file:///..//..//..//..//apoc//core//..//test.txt'";
+        private static final String case4 = "'file:///..//..//..//..//apoc/test.txt'";
+        private static final String case5 = "'file://" + directory.getAbsolutePath() + "//..//..//..//..//apoc/test.txt'";
+        private static final String case6 = "'file:///%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f/apoc/test.txt'";
+
+        private static final List<String> cases = Arrays.asList(case1, case2, case3, case4, case5, case6);
+
+        private static final Map<String, List<String>> METHOD_ARGUMENTS = Map.of(
+                "query", cases.stream().map(
+                        filePath -> "\"RETURN 1\", " + filePath + ", {}"
+                ).collect(Collectors.toList()),
+                "all", cases.stream().map(
+                        filePath -> filePath + ", {}"
+                ).collect(Collectors.toList()),
+                "data", cases.stream().map(
+                        filePath -> "[], [], " + filePath + ", {}"
+                ).collect(Collectors.toList()),
+                "graph", cases.stream().map(
+                        filePath -> "{nodes: [], relationships: []}, " + filePath + ", {}"
+                ).collect(Collectors.toList())
+        );
+
+        @Parameterized.Parameters
+        public static Collection<String[]> data() {
+            return ExportCoreSecurityTest.data(METHOD_ARGUMENTS);
+        }
+
+        @Test
+        public void testPathTraversal() {
+            setFileExport(true);
+
+            QueryExecutionException e = Assert.assertThrows(QueryExecutionException.class,
+                    () -> TestUtil.testCall(db, "CALL " + apocProcedure, (r) -> {})
+            );
+
+            TestCase.assertTrue(e.getMessage().contains("apoc/test.txt (No such file or directory)"));
+            setFileExport(false);
+        }
+    }
+
+    /**
+     * These tests normalize the path to be within the import directory and make the file there.
+     * Some attempt to exit the directory.
+     * They result in a file name test.txt being created (and deleted after).
+     */
+    @RunWith(Parameterized.class)
+    public static class TestPathTraversalIsNormalisedWithinDirectory {
+        private final String apocProcedure;
+
+        public TestPathTraversalIsNormalisedWithinDirectory(String exportMethod, String exportMethodType, String exportMethodArguments) {
+            this.apocProcedure = "apoc.export." + exportMethod + "." + exportMethodType + "(" + exportMethodArguments + ")";
+        }
+
+        private static final String case1 = "'file:///..//..//..//..//apoc//..//..//..//..//test.txt'";
+        private static final String case2 = "'file:///..//..//..//..//apoc//..//test.txt'";
+        private static final String case3 = "'file:///../import/../import//..//test.txt'";
+        private static final String case4 = "'file://test.txt'";
+        private static final String case5 = "'file://tests/../test.txt'";
+        private static final String case6 = "'file:///tests//..//test.txt'";
+        private static final String case7 = "'test.txt'";
+        private static final String case8 = "'file:///..//..//..//..//test.txt'";
+
+        private static final List<String> cases = Arrays.asList(case1, case2, case3, case4, case5, case6, case7, case8);
+
+        private static final Map<String, List<String>> METHOD_ARGUMENTS = Map.of(
+                "query", cases.stream().map(
+                        filePath -> "\"RETURN 1\", " + filePath + ", {}"
+                ).collect(Collectors.toList()),
+                "all", cases.stream().map(
+                        filePath -> filePath + ", {}"
+                ).collect(Collectors.toList()),
+                "data", cases.stream().map(
+                        filePath -> "[], [], " + filePath + ", {}"
+                ).collect(Collectors.toList()),
+                "graph", cases.stream().map(
+                        filePath -> "{nodes: [], relationships: []}, " + filePath + ", {}"
+                ).collect(Collectors.toList())
+        );
+
+        @Parameterized.Parameters
+        public static Collection<String[]> data() {
+            return ExportCoreSecurityTest.data(METHOD_ARGUMENTS);
+        }
+
+        @Test
+        public void testPathTraversal() {
+            setFileExport(true);
+
+            TestUtil.testCall(db, "CALL " + apocProcedure,
+                    (r) -> assertTrue(((String) r.get("file")).contains("test.txt"))
+            );
+
+            File f = new File(directory.getAbsolutePath() + "/test.txt");
+            TestCase.assertTrue(f.exists());
+            TestCase.assertTrue(f.delete());
+        }
+    }
+
+    /*
+     * These test cases attempt to access a directory with the same prefix as the import directory. This is design to
+     * test "directoryName.startsWith" logic which is a common path traversal bug.
+     *
+     * All these tests should fail because they access a directory which isn't the configured directory
+     */
+    @RunWith(Parameterized.class)
+    public static class TestPathTraversalIsWithSimilarDirectoryName {
+        private final String apocProcedure;
+
+        public TestPathTraversalIsWithSimilarDirectoryName(String exportMethod, String exportMethodType, String exportMethodArguments) {
+            this.apocProcedure = "apoc.export." + exportMethod + "." + exportMethodType + "(" + exportMethodArguments + ")";
+        }
+
+        private static final String case1 = "'../imported/test.txt'";
+        private static final String case2 = "'tests/../../imported/test.txt'";
+
+        private static final List<String> cases = Arrays.asList(case1, case2);
+
+        private static final Map<String, List<String>> METHOD_ARGUMENTS = Map.of(
+                "query", cases.stream().map(
+                        filePath -> "\"RETURN 1\", " + filePath + ", {}"
+                ).collect(Collectors.toList()),
+                "all", cases.stream().map(
+                        filePath -> filePath + ", {}"
+                ).collect(Collectors.toList()),
+                "data", cases.stream().map(
+                        filePath -> "[], [], " + filePath + ", {}"
+                ).collect(Collectors.toList()),
+                "graph", cases.stream().map(
+                        filePath -> "{nodes: [], relationships: []}, " + filePath + ", {}"
+                ).collect(Collectors.toList())
+        );
+
+        @Parameterized.Parameters
+        public static Collection<String[]> data() {
+            return ExportCoreSecurityTest.data(METHOD_ARGUMENTS);
+        }
+
+        @Test
+        public void testPathTraversal() {
+            setFileExport(true);
+
+            QueryExecutionException e = Assert.assertThrows(QueryExecutionException.class,
+                    () -> TestUtil.testCall(db, "CALL " + apocProcedure, (r) -> {})
+            );
+
+            assertError(e, FileUtils.ACCESS_OUTSIDE_DIR_ERROR, IOException.class, apocProcedure);
+
+            setFileExport(false);
+        }
+    }
+
+
+    /**
+     * These tests normalize the path to be within the import directory and step into a subdirectory
+     * to make the file there.
+     * Some attempt to exit the directory.
+     * They result in a file name test.txt in the directory /tests being created (and deleted after).
+     */
+    @RunWith(Parameterized.class)
+    public static class TestPathTraversAllowedWithinDirectory {
+        private final String apocProcedure;
+
+        public TestPathTraversAllowedWithinDirectory(String exportMethod, String exportMethodType, String exportMethodArguments) {
+            this.apocProcedure = "apoc.export." + exportMethod + "." + exportMethodType + "(" + exportMethodArguments + ")";
+        }
+
+        private static final String case1 = "'file:///../import/../import//..//tests/test.txt'";
+        private static final String case2 = "'file:///..//..//..//..//apoc//..//tests/test.txt'";
+        private static final String case3 = "'file:///../import/../import//..//tests/../tests/test.txt'";
+        private static final String case4 = "'file:///tests/test.txt'";
+        private static final String case5 = "'tests/test.txt'";
+
+        private static final List<String> cases = Arrays.asList(case1, case2, case3, case4, case5);
+
+        private static final Map<String, List<String>> METHOD_ARGUMENTS = Map.of(
+                "query", cases.stream().map(
+                        filePath -> "\"RETURN 1\", " + filePath + ", {}"
+                ).collect(Collectors.toList()),
+                "all", cases.stream().map(
+                        filePath -> filePath + ", {}"
+                ).collect(Collectors.toList()),
+                "data", cases.stream().map(
+                        filePath -> "[], [], " + filePath + ", {}"
+                ).collect(Collectors.toList()),
+                "graph", cases.stream().map(
+                        filePath -> "{nodes: [], relationships: []}, " + filePath + ", {}"
+                ).collect(Collectors.toList())
+        );
+
+        @Parameterized.Parameters
+        public static Collection<String[]> data() {
+            return ExportCoreSecurityTest.data(METHOD_ARGUMENTS);
+        }
+
+        @Test
+        public void testPathTraversal() {
+            setFileExport(true);
+
+            TestUtil.testCall(db, "CALL " + apocProcedure,
+                    (r) -> assertTrue(((String) r.get("file")).contains("tests/test.txt"))
+            );
+
+            File f = new File(subDirectory.getAbsolutePath() + "/test.txt");
+            TestCase.assertTrue(f.exists());
+            TestCase.assertTrue(f.delete());
+        }
+    }
+
     public static class TestCypherSchema {
         private final String apocProcedure = "apoc.export.cypher.schema(%s)";
-        private final String message = apocProcedure + " should throw an exception";
 
         @Test
         public void testIllegalFSAccessExportCypherSchema() {
-            try {
-                db.executeTransactionally(String.format("CALL " + apocProcedure, "'./hello', {}"), Map.of(),
-                        Result::resultAsString);
-                fail(message);
-            } catch (Exception e) {
-                assertError(e, ApocConfig.EXPORT_TO_FILE_ERROR, RuntimeException.class, apocProcedure);
-            }
+            QueryExecutionException e = Assert.assertThrows(QueryExecutionException.class,
+                    () -> TestUtil.testCall(db, String.format("CALL " + apocProcedure, "'./hello', {}"), (r) -> {})
+            );
+            assertError(e, ApocConfig.EXPORT_TO_FILE_ERROR, RuntimeException.class, apocProcedure);
         }
 
         @Test
         public void testIllegalExternalFSAccessExportCypherSchema() {
-            try {
-                ApocConfig.apocConfig().setProperty(ApocSettings.apoc_export_file_enabled, true);
-                db.executeTransactionally(String.format("CALL " + apocProcedure, "'../hello', {}"), Map.of(),
-                        Result::resultAsString);
-                fail(message);
-            } catch (Exception e) {
-                assertError(e, FileUtils.ACCESS_OUTSIDE_DIR_ERROR, IOException.class, apocProcedure);
-            } finally {
-                ApocConfig.apocConfig().setProperty(ApocSettings.apoc_export_file_enabled, false);
-            }
+            setFileExport(true);
+            QueryExecutionException e = Assert.assertThrows(QueryExecutionException.class,
+                    () -> TestUtil.testCall(db, String.format("CALL " + apocProcedure, "'../hello', {}"), (r) -> {})
+            );
+            assertError(e, FileUtils.ACCESS_OUTSIDE_DIR_ERROR, IOException.class, apocProcedure);
+            setFileExport(false);
         }
     }
 

programming_language: TypeScript
filename: ExportCsvTest.java
change_type: ModificationType.MODIFY
diff: @@ -114,8 +114,9 @@ public class ExportCsvTest {
             ",,,,,,,,0,1,KNOWS%n" +
             ",,,,,,,,3,4,NEXT_DELIVERY%n");
 
-    private static File directory = new File("target/import");
-    static { //noinspection ResultOfMethodCallIgnored
+    private static final File directory = new File("target/import");
+    static {
+        //noinspection ResultOfMethodCallIgnored
         directory.mkdirs();
     }
 

programming_language: Java