CVE-2024-24565 (published 2024-01-30T17:15Z)

CrateDB is a distributed SQL database that makes it simple to store and analyze massive amounts of data in real-time. There is a COPY FROM function in the CrateDB database that is used to import file data into database tables. This function has a flaw, and authenticated attackers can use the COPY FROM function to import arbitrary file content into database tables, resulting in information leakage. This vulnerability is patched in 5.3.9, 5.4.8, 5.5.4, and 5.6.1.

CVSS v3 base score: 6.5, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/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/crate/crate
commit_details: repo_url: https://github.com/crate/crate
author: Sebastian Utz
author_date: 2024-01-26 14:41:23+01:00
msg: Restrict `COPY FROM` using local files to superuser

Fixing a security issue where any user could read/import content
of any file on the host system, the CrateDB process user has read
access to.
repository: repo_url: https://github.com/crate/crate
repo_name: crate/crate
description: CrateDB is a distributed and scalable SQL database for storing and analyzing massive amounts of data in near real-time, even with complex queries. It is PostgreSQL-compatible, and based on Lucene.
repo_language: Java
owner: crate
file_changes: filename: 5.5.4.rst
change_type: ModificationType.MODIFY
diff: @@ -44,6 +44,14 @@ Version 5.5.4 - Unreleased
 See the :ref:`version_5.5.0` release notes for a full list of changes in the
 5.5 series.
 
+Security Fixes
+==============
+
+- Fixed a security issue where any CrateDB user could read/import the content of
+  any file on the host system, the CrateDB process user has read access to, by
+  using the ``COPY FROM`` command with a file URI. This access is now restricted
+  to the ``crate`` superuser only.
+
 Fixes
 =====
 

programming_language: Markdown
filename: 5.6.1.rst
change_type: ModificationType.MODIFY
diff: @@ -43,6 +43,13 @@ Version 5.6.1 - Unreleased
 See the :ref:`version_5.6.0` release notes for a full list of changes in the
 5.6 series.
 
+Security Fixes
+==============
+
+- Fixed a security issue where any CrateDB user could read/import the content of
+  any file on the host system, the CrateDB process user has read access to, by
+  using the ``COPY FROM`` command with a file URI. This access is now restricted
+  to the ``crate`` superuser only.
 
 Fixes
 =====

programming_language: Markdown
filename: copy-from.rst
change_type: ModificationType.MODIFY
diff: @@ -211,6 +211,8 @@ For example:
 
 The files must be accessible on at least one node and the system user running
 the ``crate`` process must have read access to every file specified.
+Additionally, only the ``crate`` superuser is allowed to use the ``file://``
+scheme.
 
 By default, every node will attempt to import every file. If the file is
 accessible on multiple nodes, you can set the `shared`_ option to true in order

programming_language: Markdown
filename: S3FileReadingCollectorTest.java
change_type: ModificationType.MODIFY
diff: @@ -106,7 +106,7 @@ public class S3FileReadingCollectorTest extends ESTestCase {
     private FileReadingIterator createBatchIterator(S3ObjectInputStream inputStream, String ... fileUris) {
         String compression = null;
         return new FileReadingIterator(
-            Arrays.asList(fileUris),
+            Arrays.stream(fileUris).map(FileReadingIterator::toURI).toList(),
             compression,
             Map.of(
                 S3FileInputFactory.NAME,

programming_language: Java
filename: UnauthorizedException.java
change_type: ModificationType.MODIFY
diff: @@ -21,10 +21,18 @@
 
 package io.crate.exceptions;
 
-public class UnauthorizedException extends RuntimeException implements UnscopedException {
+import java.io.IOException;
+
+import org.elasticsearch.ElasticsearchException;
+import org.elasticsearch.common.io.stream.StreamInput;
+
+public class UnauthorizedException extends ElasticsearchException implements UnscopedException {
 
     public UnauthorizedException(String message) {
         super(message);
     }
 
+    public UnauthorizedException(StreamInput in) throws IOException {
+        super(in);
+    }
 }

programming_language: Java
filename: FileReadingIterator.java
change_type: ModificationType.MODIFY
diff: @@ -175,7 +175,7 @@ public class FileReadingIterator implements BatchIterator<FileReadingIterator.Li
         }
     }
 
-    public FileReadingIterator(Collection<String> fileUris,
+    public FileReadingIterator(Collection<URI> fileUris,
                                String compression,
                                Map<String, FileInputFactory> fileInputFactories,
                                Boolean shared,
@@ -398,8 +398,7 @@ public class FileReadingIterator implements BatchIterator<FileReadingIterator.Li
     }
 
     @Nullable
-    private FileInput toFileInput(String fileUri, Settings withClauseOptions) {
-        URI uri = toURI(fileUri);
+    private FileInput toFileInput(URI uri, Settings withClauseOptions) {
         FileInputFactory fileInputFactory = fileInputFactories.get(uri.getScheme());
         if (fileInputFactory != null) {
             try {

programming_language: Java
filename: FileCollectSource.java
change_type: ModificationType.MODIFY
diff: @@ -21,6 +21,9 @@
 
 package io.crate.execution.engine.collect.sources;
 
+import static java.util.Objects.requireNonNull;
+
+import java.net.URI;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
@@ -40,6 +43,7 @@ import io.crate.common.annotations.VisibleForTesting;
 import io.crate.data.BatchIterator;
 import io.crate.data.Row;
 import io.crate.data.SkippingBatchIterator;
+import io.crate.exceptions.UnauthorizedException;
 import io.crate.execution.dsl.phases.CollectPhase;
 import io.crate.execution.dsl.phases.FileUriCollectPhase;
 import io.crate.execution.engine.collect.CollectTask;
@@ -53,6 +57,8 @@ import io.crate.expression.symbol.Symbol;
 import io.crate.metadata.NodeContext;
 import io.crate.metadata.TransactionContext;
 import io.crate.planner.operators.SubQueryResults;
+import io.crate.role.Role;
+import io.crate.role.Roles;
 import io.crate.types.DataTypes;
 
 @Singleton
@@ -63,17 +69,20 @@ public class FileCollectSource implements CollectSource {
     private final InputFactory inputFactory;
     private final NodeContext nodeCtx;
     private final ThreadPool threadPool;
+    private final Roles roles;
 
     @Inject
     public FileCollectSource(NodeContext nodeCtx,
                              ClusterService clusterService,
                              Map<String, FileInputFactory> fileInputFactoryMap,
-                             ThreadPool threadPool) {
+                             ThreadPool threadPool,
+                             Roles roles) {
         this.fileInputFactoryMap = fileInputFactoryMap;
         this.nodeCtx = nodeCtx;
         this.inputFactory = new InputFactory(nodeCtx);
         this.clusterService = clusterService;
         this.threadPool = threadPool;
+        this.roles = roles;
     }
 
     @Override
@@ -86,7 +95,16 @@ public class FileCollectSource implements CollectSource {
             inputFactory.ctxForRefs(txnCtx, FileLineReferenceResolver::getImplementation);
         ctx.add(collectPhase.toCollect());
 
-        List<String> fileUris = targetUriToStringList(txnCtx, nodeCtx, fileUriCollectPhase.targetUri());
+        Role user = requireNonNull(roles.findUser(txnCtx.sessionSettings().userName()), "User who invoked a statement must exist");
+        List<URI> fileUris = targetUriToStringList(txnCtx, nodeCtx, fileUriCollectPhase.targetUri()).stream()
+            .map(s -> {
+                var uri = FileReadingIterator.toURI(s);
+                if (uri.getScheme().equals("file") && user.isSuperUser() == false) {
+                    throw new UnauthorizedException("Only a superuser can read from the local file system");
+                }
+                return uri;
+            })
+            .toList();
         FileReadingIterator fileReadingIterator = new FileReadingIterator(
             fileUris,
             fileUriCollectPhase.compression(),

programming_language: Java
filename: ElasticsearchException.java
change_type: ModificationType.MODIFY
diff: @@ -973,7 +973,12 @@ public class ElasticsearchException extends RuntimeException implements ToXConte
             io.crate.exceptions.OperationOnInaccessibleRelationException.class,
             io.crate.exceptions.OperationOnInaccessibleRelationException::new,
             176,
-            Version.V_5_6_0);
+            Version.V_5_6_0),
+        UNAUTHORIZED_EXCEPTION(
+            io.crate.exceptions.UnauthorizedException.class,
+            io.crate.exceptions.UnauthorizedException::new,
+            177,
+            Version.V_5_7_0);
 
         final Class<? extends ElasticsearchException> exceptionClass;
         final CheckedFunction<StreamInput, ? extends ElasticsearchException, IOException> constructor;

programming_language: Java
filename: MapSideDataCollectOperationTest.java
change_type: ModificationType.MODIFY
diff: @@ -54,6 +54,7 @@ import io.crate.execution.engine.collect.sources.FileCollectSource;
 import io.crate.expression.symbol.Literal;
 import io.crate.metadata.ColumnIdent;
 import io.crate.metadata.CoordinatorTxnCtx;
+import io.crate.role.Role;
 import io.crate.test.integration.CrateDummyClusterServiceUnitTest;
 import io.crate.types.DataTypes;
 
@@ -69,7 +70,8 @@ public class MapSideDataCollectOperationTest extends CrateDummyClusterServiceUni
             createNodeContext(),
             clusterService,
             Collections.emptyMap(),
-            THREAD_POOL
+            THREAD_POOL,
+            () -> List.of(Role.CRATE_USER)
             );
 
         File tmpFile = temporaryFolder.newFile("fileUriCollectOperation.json");

programming_language: Java
filename: FileReadingCollectorTest.java
change_type: ModificationType.MODIFY
diff: @@ -200,7 +200,7 @@ public class FileReadingCollectorTest extends ESTestCase {
 
     private static FileReadingIterator it(Collection<String> fileUris, String compression) {
         return new FileReadingIterator(
-            fileUris,
+            fileUris.stream().map(FileReadingIterator::toURI).toList(),
             compression,
             Map.of(LocalFsFileInputFactory.NAME, new LocalFsFileInputFactory()),
             false,

programming_language: Java
filename: FileReadingIteratorTest.java
change_type: ModificationType.MODIFY
diff: @@ -34,6 +34,7 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.net.SocketTimeoutException;
+import java.net.URI;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -43,6 +44,7 @@ import java.util.Map;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Supplier;
+import java.util.stream.Stream;
 
 import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.test.ESTestCase;
@@ -87,7 +89,9 @@ public class FileReadingIteratorTest extends ESTestCase {
         Path tempFile2 = createTempFile("tempfile2", ".csv");
         List<String> lines2 = List.of("name,id,age", "Trillian,5,33");
         Files.write(tempFile2, lines2);
-        List<String> fileUris = List.of(tempFile1.toUri().toString(), tempFile2.toUri().toString());
+        List<URI> fileUris = Stream.of(tempFile1.toUri().toString(), tempFile2.toUri().toString())
+            .map(FileReadingIterator::toURI)
+            .toList();
 
         Supplier<BatchIterator<LineCursor>> batchIteratorSupplier =
             () -> new FileReadingIterator(
@@ -139,7 +143,8 @@ public class FileReadingIteratorTest extends ESTestCase {
         Path tempFile = createTempFile("tempfile1", ".csv");
         List<String> lines = List.of("id", "1", "2", "3", "4", "5");
         Files.write(tempFile, lines);
-        List<String> fileUris = List.of(tempFile.toUri().toString());
+        List<URI> fileUris = Stream.of(tempFile.toUri().toString())
+            .map(FileReadingIterator::toURI).toList();
 
         Supplier<BatchIterator<LineCursor>> batchIteratorSupplier =
             () -> new FileReadingIterator(
@@ -213,7 +218,8 @@ public class FileReadingIteratorTest extends ESTestCase {
         Files.write(tempFile, List.of("1", "2", "3"));
         Path tempFile2 = createTempFile("tempfile2", ".csv");
         Files.write(tempFile2, List.of("4", "5", "6"));
-        List<String> fileUris = List.of(tempFile.toUri().toString(), tempFile2.toUri().toString());
+        List<URI> fileUris = Stream.of(tempFile.toUri().toString(), tempFile2.toUri().toString())
+            .map(FileReadingIterator::toURI).toList();
 
         var fi = new FileReadingIterator(
             fileUris,

programming_language: Java
filename: FileCollectSourceTest.java
change_type: ModificationType.MODIFY
diff: @@ -92,7 +92,8 @@ public class FileCollectSourceTest extends CrateDummyClusterServiceUnitTest {
             new NodeContext(new Functions(Map.of()), roles),
             clusterService,
             Map.of(),
-            THREAD_POOL
+            THREAD_POOL,
+            () -> List.of(Role.CRATE_USER)
         );
 
         CompletableFuture<BatchIterator<Row>> iterator = fileCollectSource.getIterator(

programming_language: Java
filename: CopyIntegrationTest.java
change_type: ModificationType.MODIFY
diff: @@ -26,6 +26,7 @@ import static io.crate.protocols.postgres.PGErrorStatus.INTERNAL_ERROR;
 import static io.crate.testing.Asserts.assertThat;
 import static io.crate.testing.TestingHelpers.printedTable;
 import static io.netty.handler.codec.http.HttpResponseStatus.BAD_REQUEST;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 import java.io.File;
 import java.io.FileOutputStream;
@@ -53,10 +54,15 @@ import org.junit.rules.TemporaryFolder;
 
 import com.carrotsearch.randomizedtesting.LifecycleScope;
 
+import io.crate.action.sql.Sessions;
+import io.crate.exceptions.UnauthorizedException;
+import io.crate.role.Role;
+import io.crate.role.Roles;
 import io.crate.testing.Asserts;
 import io.crate.testing.SQLResponse;
 import io.crate.testing.UseJdbc;
 import io.crate.testing.UseNewCluster;
+import io.crate.testing.UseRandomizedSchema;
 
 @IntegTestCase.ClusterScope(numDataNodes = 2)
 public class CopyIntegrationTest extends SQLHttpIntegrationTest {
@@ -1196,4 +1202,22 @@ public class CopyIntegrationTest extends SQLHttpIntegrationTest {
             "2| 31123| apple safari| 23073"
         );
     }
+
+    @UseRandomizedSchema(random = false)
+    @Test
+    public void test_copy_from_local_file_is_only_allowed_for_superusers() {
+        execute("CREATE TABLE quotes (id INT PRIMARY KEY, " +
+            "quote STRING INDEX USING FULLTEXT) WITH (number_of_replicas = 0)");
+        execute("CREATE USER test_user");
+        execute("GRANT ALL TO test_user");
+
+        var roles = cluster().getInstance(Roles.class);
+        Role user = roles.findUser("test_user");
+        Sessions sqlOperations = cluster().getInstance(Sessions.class);
+        try (var session = sqlOperations.newSession(null, user)) {
+            assertThatThrownBy(() -> execute("COPY quotes FROM ?", new Object[]{copyFilePath + "test_copy_from.json"}, session))
+                .isExactlyInstanceOf(UnauthorizedException.class)
+                .hasMessage("Only a superuser can read from the local file system");
+        }
+    }
 }

programming_language: Java