CVE-2022-23532: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (CWE-22)
value: 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 allowlist of the procedures that can be used in your system, and/or turn off local file access by setting apoc.export.file.enabled=false.
CWE 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.
CVSS v3 base score: 6.5
Published: 2023-01-14T01:15Z
Repository: https://github.com/neo4j-contrib/neo4j-apoc-procedures
Language: Java
Files changed: core/src/main/java/apoc/export/csv/ExportCSV.java, core/src/main/java/apoc/util/FileUtils.java, core/src/test/java/apoc/export/ExportCoreSecurityTest.java, core/src/test/java/apoc/export/csv/ExportCsvTest.java
Commit date: 2022-12-16 12:44:40 +0100
Commit message: 4.4 export security fix (#3370)

* [vFQk1rX0] Fix security issue

* [vFQk1rX0] Add more test cases from PR review
Diff: diff --git a/core/src/main/java/apoc/export/csv/ExportCSV.java b/core/src/main/java/apoc/export/csv/ExportCSV.java
index bb056083..e8debedd 100644
--- a/core/src/main/java/apoc/export/csv/ExportCSV.java
+++ b/core/src/main/java/apoc/export/csv/ExportCSV.java
@@ -114,10 +114,10 @@ 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);
     }
 }
diff --git a/core/src/main/java/apoc/util/FileUtils.java b/core/src/main/java/apoc/util/FileUtils.java
index 999756bf..605afd99 100644
--- a/core/src/main/java/apoc/util/FileUtils.java
+++ b/core/src/main/java/apoc/util/FileUtils.java
@@ -225,11 +225,11 @@ public class FileUtils {
 
     private static Path getPath(String url) {
         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) {
             if (toURL != null) {
                 urlPath = Paths.get(StringUtils.isBlank(toURL.getFile()) ? toURL.getHost() : toURL.getFile());
@@ -240,14 +240,14 @@ public class FileUtils {
         return urlPath;
     }
 
     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;
         }
     }
 
diff --git a/core/src/test/java/apoc/export/ExportCoreSecurityTest.java b/core/src/test/java/apoc/export/ExportCoreSecurityTest.java
index 82cf316e..2fff5cbb 100644
--- a/core/src/test/java/apoc/export/ExportCoreSecurityTest.java
+++ b/core/src/test/java/apoc/export/ExportCoreSecurityTest.java
@@ -6,20 +6,21 @@ import apoc.export.csv.ExportCSV;
 import apoc.export.cypher.ExportCypher;
 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;
 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;
 
 import java.io.File;
@@ -36,34 +37,36 @@ import static org.junit.Assert.fail;
 
 @RunWith(Enclosed.class)
 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
     public static DbmsRule db = new ImpermanentDbmsRule()
             .withSetting(GraphDatabaseSettings.load_csv_file_url_root, directory.toPath().toAbsolutePath())
             .withSetting(ApocSettings.apoc_export_file_enabled, false);
 
     @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) {
         return APOC_EXPORT_PROCEDURE_NAME
                 .stream()
@@ -112,20 +115,16 @@ public class ExportCoreSecurityTest {
             return ExportCoreSecurityTest.data(APOC_PROCEDURE_ARGUMENTS);
         }
 
         @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)
     public static class TestIllegalExternalFSAccess {
         private final String apocProcedure;
@@ -160,49 +159,314 @@ public class ExportCoreSecurityTest {
 
         @Test
         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);
         }
     }
 
     private static void assertError(Exception e, String errorMessage, Class<? extends Exception> exceptionType, String apocProcedure) {
         final Throwable rootCause = ExceptionUtils.getRootCause(e);
diff --git a/core/src/test/java/apoc/export/csv/ExportCsvTest.java b/core/src/test/java/apoc/export/csv/ExportCsvTest.java
index ca08cd1e..9333af76 100644
--- a/core/src/test/java/apoc/export/csv/ExportCsvTest.java
+++ b/core/src/test/java/apoc/export/csv/ExportCsvTest.java
@@ -112,12 +112,13 @@ public class ExportCsvTest {
             "4,:Address,,,,,Bar Sport,,,,%n" +
             "5,:Address,,,,,,via Benni,,,%n" +
             ",,,,,,,,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();
     }
 
     @ClassRule
     public static DbmsRule db = new ImpermanentDbmsRule()

Vulnerable code:             exporter.dump((SubGraph)data,printWriter,reporter,c);
            exporter.dump((Result)data,printWriter,reporter,c);
            final URI uri = URI.create(url.trim());
            return resolvedPath.toRealPath().startsWith(basePath.toRealPath());
            if (e instanceof NoSuchFileException) { // If we're about to creating a file this exception has been thrown
                return resolvedPath.normalize().startsWith(basePath);
import org.junit.After;
import org.junit.Before;
    @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);
            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);
            }
        }
                ApocConfig.apocConfig().setProperty(ApocSettings.apoc_export_file_enabled, true);
                ApocConfig.apocConfig().setProperty(ApocSettings.apoc_export_file_enabled, false);
        private final String message = apocProcedure + " should throw an exception";
            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);
            }
            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);
            }
    private static File directory = new File("target/import");
    static { //noinspection ResultOfMethodCallIgnored
Fixed code:             exporter.dump((SubGraph) data, printWriter, reporter, c);
            exporter.dump((Result) data, printWriter, reporter, c);
            final URI uri = URI.create(url.trim()).normalize();
            return resolvedPath.toFile().getCanonicalFile().toPath().startsWith(basePath.toRealPath());
            if (e instanceof NoSuchFileException) { // If we're about to create a file this exception has been thrown
                return resolvedPath.toFile().getCanonicalFile().toPath().startsWith(basePath);
import junit.framework.TestCase;
import org.junit.Assert;
import org.neo4j.graphdb.QueryExecutionException;
    private static final File directoryWithSamePrefix = new File("target/imported");
    private static final File subDirectory = new File("target/import/tests");
        //noinspection ResultOfMethodCallIgnored
        //noinspection ResultOfMethodCallIgnored
        subDirectory.mkdirs();
        //noinspection ResultOfMethodCallIgnored
        directoryWithSamePrefix.mkdirs();
        setFileExport(false);
    private static void setFileExport(boolean allowed) {
        ApocConfig.apocConfig().setProperty(ApocConfig.APOC_EXPORT_FILE_ENABLED, allowed);
            QueryExecutionException e = Assert.assertThrows(QueryExecutionException.class,
                    () -> TestUtil.testCall(db, "CALL " + apocProcedure, (r) -> {})
            );
            assertError(e, ApocConfig.EXPORT_TO_FILE_ERROR, RuntimeException.class, apocProcedure);
        }
                setFileExport(true);
                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());
        }
    }

            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);
            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);
    private static final File directory = new File("target/import");
    static {
        //noinspection ResultOfMethodCallIgnored