CVE-2022-23532 (Java, published 2023-01-14T01:15Z)
Description: 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.
Severity: nan (CVSS v3 base score: 6.5)
Weakness: CWE-22 \u2014 Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
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.
Repository: https://github.com/neo4j-contrib/neo4j-apoc-procedures
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 message: 4.4 export security fix (#3370)

* [vFQk1rX0] Fix security issue

* [vFQk1rX0] Add more test cases from PR review

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