This is an automated email from the ASF dual-hosted git repository.

JackieTien97 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iotdb.git


The following commit(s) were added to refs/heads/master by this push:
     new 6343e1fe7a0 Restrict COPY TO export paths (#18573)
6343e1fe7a0 is described below

commit 6343e1fe7a0396ffc34ee0d805d4d57f22bdc9c4
Author: shuwenwei <[email protected]>
AuthorDate: Wed Sep 9 17:30:25 2026 +0800

    Restrict COPY TO export paths (#18573)
---
 .../query/recent/copyto/IoTDBCopyToTsFileIT.java   | 86 ++++++++++++++++++++++
 .../iotdb/db/i18n/DataNodeQueryMessages.java       |  3 +
 .../iotdb/db/i18n/DataNodeQueryMessages.java       |  2 +
 .../java/org/apache/iotdb/db/conf/IoTDBConfig.java | 28 +++++++
 .../org/apache/iotdb/db/conf/IoTDBDescriptor.java  | 36 +++++++++
 .../relational/analyzer/StatementAnalyzer.java     | 16 ++++
 .../org/apache/iotdb/db/conf/PropertiesTest.java   | 19 +++++
 .../conf/iotdb-system.properties.template          | 12 +++
 .../org/apache/iotdb/commons/utils/FileUtils.java  | 27 +++++++
 .../apache/iotdb/commons/utils/FileUtilsTest.java  | 12 +++
 10 files changed, 241 insertions(+)

diff --git 
a/integration-test/src/test/java/org/apache/iotdb/relational/it/query/recent/copyto/IoTDBCopyToTsFileIT.java
 
b/integration-test/src/test/java/org/apache/iotdb/relational/it/query/recent/copyto/IoTDBCopyToTsFileIT.java
index e25c3f58299..388d833f700 100644
--- 
a/integration-test/src/test/java/org/apache/iotdb/relational/it/query/recent/copyto/IoTDBCopyToTsFileIT.java
+++ 
b/integration-test/src/test/java/org/apache/iotdb/relational/it/query/recent/copyto/IoTDBCopyToTsFileIT.java
@@ -141,6 +141,92 @@ public class IoTDBCopyToTsFileIT {
     }
   }
 
+  @Test
+  public void testCopyToRejectsClientSuppliedAbsolutePath()
+      throws IoTDBConnectionException, IOException {
+    File targetDirectory = 
Files.createTempDirectory("iotdb-copy-to-security").toFile();
+    File targetFile = new File(targetDirectory, "result.tsfile");
+    String targetPath = targetFile.getAbsolutePath().replace("\\", 
"\\\\").replace("'", "''");
+
+    try (ITableSession session =
+        EnvFactory.getEnv().getTableSessionConnectionWithDB(DATABASE_NAME)) {
+      try {
+        session.executeQueryStatement(
+            "copy table1 to '" + targetPath + "' (memory_threshold 1000000)");
+        Assert.fail("COPY TO should reject a client-supplied absolute path");
+      } catch (StatementExecutionException e) {
+        Assert.assertTrue(
+            e.getMessage(), e.getMessage().contains("COPY TO target path is 
outside"));
+      }
+      Assert.assertFalse(targetFile.exists());
+    } finally {
+      Files.deleteIfExists(targetFile.toPath());
+      Files.deleteIfExists(targetDirectory.toPath());
+    }
+  }
+
+  @Test
+  public void testCopyToRejectsAllowedExportDirectoryItself()
+      throws IoTDBConnectionException, StatementExecutionException, 
IOException {
+    File exportDirectory = 
Files.createTempDirectory("iotdb-copy-to-directory").toFile();
+    File targetDirectory = new File(exportDirectory, "export");
+    String targetPath = targetDirectory.getAbsolutePath().replace("\\", 
"\\\\").replace("'", "''");
+    String exportDirectoryPath =
+        targetDirectory.getAbsolutePath().replace("\\", "\\\\").replace("'", 
"''");
+
+    try (ITableSession session =
+        EnvFactory.getEnv().getTableSessionConnectionWithDB(DATABASE_NAME)) {
+      session.executeNonQueryStatement(
+          "set configuration \"copy_to_allowed_export_dirs\"='" + 
exportDirectoryPath + "'");
+      try {
+        try {
+          session.executeQueryStatement(
+              "copy table1 to '" + targetPath + "' (memory_threshold 
1000000)");
+          Assert.fail("COPY TO should reject the allowed export directory 
itself");
+        } catch (StatementExecutionException e) {
+          Assert.assertTrue(
+              e.getMessage(), e.getMessage().contains("COPY TO target path is 
outside"));
+        }
+        Assert.assertFalse(targetDirectory.exists());
+      } finally {
+        session.executeNonQueryStatement("set configuration 
\"copy_to_allowed_export_dirs\"=''");
+      }
+    } finally {
+      Files.deleteIfExists(targetDirectory.toPath());
+      Files.deleteIfExists(exportDirectory.toPath());
+    }
+  }
+
+  @Test
+  public void testCopyToUsesHotReloadedAllowedExportDirectory()
+      throws IoTDBConnectionException, StatementExecutionException, 
IOException {
+    File targetDirectory = 
Files.createTempDirectory("iotdb-copy-to-allowed").toFile();
+    File targetFile = new File(targetDirectory, "result.tsfile");
+    String targetPath = targetFile.getAbsolutePath().replace("\\", 
"\\\\").replace("'", "''");
+    String exportDirectoryPath =
+        targetDirectory.getAbsolutePath().replace("\\", "\\\\").replace("'", 
"''");
+
+    try (ITableSession session =
+        EnvFactory.getEnv().getTableSessionConnectionWithDB(DATABASE_NAME)) {
+      session.executeNonQueryStatement(
+          "set configuration \"copy_to_allowed_export_dirs\"='" + 
exportDirectoryPath + "'");
+      try {
+        SessionDataSet sessionDataSet =
+            session.executeQueryStatement(
+                "copy table1 to '" + targetPath + "' (memory_threshold 
1000000)");
+        SessionDataSet.DataIterator iterator = sessionDataSet.iterator();
+        Assert.assertTrue(iterator.next());
+        Assert.assertEquals(targetFile.getAbsolutePath(), 
iterator.getString(1));
+        Assert.assertTrue(targetFile.exists());
+      } finally {
+        session.executeNonQueryStatement("set configuration 
\"copy_to_allowed_export_dirs\"=''");
+      }
+    } finally {
+      Files.deleteIfExists(targetFile.toPath());
+      Files.deleteIfExists(targetDirectory.toPath());
+    }
+  }
+
   @Test
   public void testCopySelectAllColumns()
       throws IoTDBConnectionException, StatementExecutionException, 
IOException {
diff --git 
a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
 
b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
index 2c25ba74606..bf7a3601003 100644
--- 
a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
+++ 
b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
@@ -202,6 +202,9 @@ public final class DataNodeQueryMessages {
       "Target file already exists: ";
   public static final String FAILED_TO_CREATE_FILE =
       "Failed to create file: ";
+  public static final String COPY_TO_TARGET_PATH_NOT_ALLOWED =
+      "COPY TO target path is outside the allowed export directories"
+          + " (configure copy_to_allowed_export_dirs to permit it): ";
   public static final String DATA_TYPE_OF_TARGET_TIME_COLUMN_IS_NOT =
       "Data type of target time column is not TIMESTAMP";
   public static final String DUPLICATE_COLUMN_NAMES_IN_QUERY_DATASET =
diff --git 
a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
 
b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
index c6a55ebad3e..f26f2306be2 100644
--- 
a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
+++ 
b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
@@ -192,6 +192,8 @@ public final class DataNodeQueryMessages {
       "目标文件已存在:";
   public static final String FAILED_TO_CREATE_FILE =
       "创建文件失败:";
+  public static final String COPY_TO_TARGET_PATH_NOT_ALLOWED =
+      "COPY TO 目标路径不在允许的导出目录内(可通过 copy_to_allowed_export_dirs 配置):";
   public static final String DATA_TYPE_OF_TARGET_TIME_COLUMN_IS_NOT =
       "目标时间列的数据类型不是 TIMESTAMP";
   public static final String DUPLICATE_COLUMN_NAMES_IN_QUERY_DATASET =
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBConfig.java 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBConfig.java
index 989031fc0fd..80f9f1f1b30 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBConfig.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBConfig.java
@@ -1210,6 +1210,13 @@ public class IoTDBConfig {
             + IoTDBConstant.LOAD_TSFILE_ACTIVE_LISTENING_PENDING_FOLDER_NAME
       };
 
+  /**
+   * Directories into which COPY ... TO may export when the client supplies a 
target path with a
+   * parent component. Empty (the default) rejects such paths; bare file names 
always land in the
+   * TierManager-managed copyto folders.
+   */
+  private String[] copyToAllowedExportDirs = new String[0];
+
   private String loadActiveListeningPipeDir =
       IoTDBConstant.EXT_FOLDER_NAME
           + File.separator
@@ -1438,6 +1445,9 @@ public class IoTDBConfig {
       loadTsFileAllowedDirs[i] = addDataHomeDir(loadTsFileAllowedDirs[i]);
     }
     loadTsFileAllowedDirCanonicalPaths = canonicalPaths(loadTsFileAllowedDirs);
+    for (int i = 0; i < copyToAllowedExportDirs.length; i++) {
+      copyToAllowedExportDirs[i] = addDataHomeDir(copyToAllowedExportDirs[i]);
+    }
     loadActiveListeningPipeDir = addDataHomeDir(loadActiveListeningPipeDir);
     loadActiveListeningFailDir = addDataHomeDir(loadActiveListeningFailDir);
     udfDir = addDataHomeDir(udfDir);
@@ -4414,6 +4424,24 @@ public class IoTDBConfig {
     this.loadActiveListeningDirs = normalizedDirs;
   }
 
+  public String[] getCopyToAllowedExportDirs() {
+    return copyToAllowedExportDirs;
+  }
+
+  public void setCopyToAllowedExportDirs(final String[] 
copyToAllowedExportDirs) {
+    if (copyToAllowedExportDirs == null) {
+      this.copyToAllowedExportDirs = new String[0];
+      return;
+    }
+    this.copyToAllowedExportDirs =
+        Arrays.stream(copyToAllowedExportDirs)
+            .filter(Objects::nonNull)
+            .map(String::trim)
+            .filter(dir -> !dir.isEmpty())
+            .map(IoTDBConfig::addDataHomeDir)
+            .toArray(String[]::new);
+  }
+
   public boolean getLoadActiveListeningEnable() {
     return loadActiveListeningEnable;
   }
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBDescriptor.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBDescriptor.java
index 9f48bac0e0c..565f0b7832a 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBDescriptor.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBDescriptor.java
@@ -511,6 +511,18 @@ public class IoTDBDescriptor {
             properties.getProperty(
                 "query_timeout_threshold", 
Long.toString(conf.getQueryTimeoutThreshold()))));
 
+    conf.setCopyToAllowedExportDirs(
+        Arrays.stream(
+                properties
+                    .getProperty(
+                        "copy_to_allowed_export_dirs",
+                        String.join(",", conf.getCopyToAllowedExportDirs()))
+                    .trim()
+                    .split(","))
+            .map(String::trim)
+            .filter(dir -> !dir.isEmpty())
+            .toArray(String[]::new));
+
     conf.setSessionTimeoutThreshold(
         Integer.parseInt(
             properties.getProperty(
@@ -2659,6 +2671,18 @@ public class IoTDBDescriptor {
         properties.getProperty(
             "load_active_listening_pipe_dir", 
conf.getLoadActiveListeningPipeDir()));
 
+    conf.setCopyToAllowedExportDirs(
+        Arrays.stream(
+                properties
+                    .getProperty(
+                        "copy_to_allowed_export_dirs",
+                        String.join(",", conf.getCopyToAllowedExportDirs()))
+                    .trim()
+                    .split(","))
+            .map(String::trim)
+            .filter(dir -> !dir.isEmpty())
+            .toArray(String[]::new));
+
     final long loadActiveListeningCheckIntervalSeconds =
         Long.parseLong(
             properties.getProperty(
@@ -2796,6 +2820,18 @@ public class IoTDBDescriptor {
         properties.getProperty(
             "load_active_listening_pipe_dir", 
conf.getLoadActiveListeningPipeDir()));
 
+    conf.setCopyToAllowedExportDirs(
+        Arrays.stream(
+                properties
+                    .getProperty(
+                        "copy_to_allowed_export_dirs",
+                        ConfigurationFileUtils.getConfigurationDefaultValue(
+                            "copy_to_allowed_export_dirs"))
+                    .trim()
+                    .split(","))
+            .map(String::trim)
+            .filter(dir -> !dir.isEmpty())
+            .toArray(String[]::new));
     conf.setLoadTsFileSpiltPartitionMaxSize(
         Integer.parseInt(
             properties.getProperty(
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/StatementAnalyzer.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/StatementAnalyzer.java
index b5c394539f2..777e0de391a 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/StatementAnalyzer.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/StatementAnalyzer.java
@@ -21,6 +21,7 @@ package 
org.apache.iotdb.db.queryengine.plan.relational.analyzer;
 
 import org.apache.iotdb.calc.plan.relational.metadata.CommonMetadataUtils;
 import org.apache.iotdb.commons.exception.IoTDBException;
+import org.apache.iotdb.commons.exception.IoTDBRuntimeException;
 import org.apache.iotdb.commons.exception.SemanticException;
 import org.apache.iotdb.commons.i18n.QueryMessages;
 import org.apache.iotdb.commons.queryengine.common.SessionInfo;
@@ -124,6 +125,9 @@ import 
org.apache.iotdb.commons.schema.table.column.TsTableColumnSchema;
 import org.apache.iotdb.commons.udf.builtin.relational.tvf.FFTTableFunction;
 import org.apache.iotdb.commons.udf.builtin.relational.tvf.M4TableFunction;
 import org.apache.iotdb.commons.udf.utils.UDFDataTypeTransformer;
+import org.apache.iotdb.commons.utils.FileUtils;
+import org.apache.iotdb.db.conf.IoTDBConfig;
+import org.apache.iotdb.db.conf.IoTDBDescriptor;
 import org.apache.iotdb.db.i18n.DataNodeQueryMessages;
 import org.apache.iotdb.db.queryengine.common.MPPQueryContext;
 import org.apache.iotdb.db.queryengine.common.MPPQueryContext.ExplainType;
@@ -246,6 +250,7 @@ import org.apache.tsfile.read.common.type.UnknownType;
 import org.apache.tsfile.utils.Binary;
 import org.apache.tsfile.utils.Pair;
 
+import java.io.File;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -1314,6 +1319,17 @@ public class StatementAnalyzer {
     @Override
     public Scope visitCopyTo(CopyTo node, Optional<Scope> context) {
       accessControl.checkUserGlobalSysPrivilege(queryContext);
+      final String targetFilePath = node.getTargetFileName();
+      final File targetFile = new File(targetFilePath);
+      if (targetFile.getParent() != null) {
+        final IoTDBConfig config = IoTDBDescriptor.getInstance().getConfig();
+        if (!FileUtils.isFilePathAllowed(targetFilePath, 
config.getCopyToAllowedExportDirs())) {
+          throw new IoTDBRuntimeException(
+              DataNodeQueryMessages.COPY_TO_TARGET_PATH_NOT_ALLOWED + 
targetFilePath,
+              TSStatusCode.COPY_TO_WRITE_ERROR.getStatusCode(),
+              true);
+        }
+      }
       Scope innerQueryScope = visitQuery((Query) node.getQueryStatement(), 
context);
       analysis.setScope(node, innerQueryScope);
       return innerQueryScope;
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/conf/PropertiesTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/conf/PropertiesTest.java
index b842318199f..92e52da7fa1 100755
--- 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/conf/PropertiesTest.java
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/conf/PropertiesTest.java
@@ -155,6 +155,25 @@ public class PropertiesTest {
     }
   }
 
+  @Test
+  public void testHotReloadCopyToAllowedExportDirsRestoresDefaultWhenMissing() 
throws Exception {
+    final IoTDBDescriptor descriptor = IoTDBDescriptor.getInstance();
+    final String[] originalDirs = 
descriptor.getConfig().getCopyToAllowedExportDirs().clone();
+    final TrimProperties properties = new TrimProperties();
+
+    try {
+      properties.setProperty("copy_to_allowed_export_dirs", "copy-to-allowed");
+      descriptor.loadHotModifiedProps(properties);
+      Assert.assertEquals(1, 
descriptor.getConfig().getCopyToAllowedExportDirs().length);
+
+      properties.remove("copy_to_allowed_export_dirs");
+      descriptor.loadHotModifiedProps(properties);
+      Assert.assertEquals(0, 
descriptor.getConfig().getCopyToAllowedExportDirs().length);
+    } finally {
+      descriptor.getConfig().setCopyToAllowedExportDirs(originalDirs);
+    }
+  }
+
   @Test
   public void testHotReloadTsFileParserInFlightLimits() throws Exception {
     final IoTDBDescriptor descriptor = IoTDBDescriptor.getInstance();
diff --git 
a/iotdb-core/node-commons/src/assembly/resources/conf/iotdb-system.properties.template
 
b/iotdb-core/node-commons/src/assembly/resources/conf/iotdb-system.properties.template
index c53c95fd016..5079abe5100 100644
--- 
a/iotdb-core/node-commons/src/assembly/resources/conf/iotdb-system.properties.template
+++ 
b/iotdb-core/node-commons/src/assembly/resources/conf/iotdb-system.properties.template
@@ -2421,6 +2421,18 @@ load_active_listening_max_thread_num=0
 # Datatype: int
 load_active_listening_check_interval_seconds=5
 
+# The directories into which COPY ... TO statements may export files when the 
client supplies
+# a target path containing a parent component (absolute or relative). Multiple 
directories
+# should be separated by a ','.
+# Empty (the default) rejects such paths; a target consisting of a bare file 
name is always
+# accepted and lands in the managed 'copyto' folder under the data 
directories. The configured
+# directories are used as the COPY TO allowlist; directory layout and any 
overlap with other
+# configured directories are the administrator's responsibility.
+# effectiveMode: hot_reload
+# Datatype: String
+# Privilege: SECURITY
+copy_to_allowed_export_dirs=
+
 # The operation performed to LastCache when a TsFile is successfully loaded.
 # UPDATE: use the data in the TsFile to update LastCache;
 # UPDATE_NO_BLOB: similar to UPDATE, but will invalidate LastCache for blob 
series;
diff --git 
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/utils/FileUtils.java
 
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/utils/FileUtils.java
index d5b1b76a7eb..2efc792d74a 100644
--- 
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/utils/FileUtils.java
+++ 
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/utils/FileUtils.java
@@ -329,6 +329,33 @@ public class FileUtils {
     return file;
   }
 
+  /**
+   * Checks whether a target path is strictly under one of the allowed 
directories after
+   * canonicalization.
+   *
+   * <p>The method returns {@code false} if the target equals an allowed 
directory or if any path
+   * cannot be canonicalized.
+   */
+  public static boolean isFilePathAllowed(String targetFilePath, String[] 
allowedDirectories) {
+    if (targetFilePath == null || allowedDirectories == null) {
+      return false;
+    }
+    try {
+      final Path targetPath = new 
File(targetFilePath).getCanonicalFile().toPath();
+      for (String allowedDirectory : allowedDirectories) {
+        if (allowedDirectory != null && !allowedDirectory.isEmpty()) {
+          final Path allowedPath = new 
File(allowedDirectory).getCanonicalFile().toPath();
+          if (!targetPath.equals(allowedPath) && 
targetPath.startsWith(allowedPath)) {
+            return true;
+          }
+        }
+      }
+      return false;
+    } catch (IOException e) {
+      return false;
+    }
+  }
+
   /**
    * Move source file to target file. The move will be divided into three 
steps: 1. Copy the source
    * file to the "target.unfinished" location 2. Rename the 
"target.unfinished" to "target" 3.
diff --git 
a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/utils/FileUtilsTest.java
 
b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/utils/FileUtilsTest.java
index 875a5a2bfcd..0a684beda5f 100644
--- 
a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/utils/FileUtilsTest.java
+++ 
b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/utils/FileUtilsTest.java
@@ -74,6 +74,18 @@ public class FileUtilsTest {
     Assert.assertNull(FileUtils.getIllegalError4Directory("valid_dir"));
   }
 
+  @Test
+  public void testIsFilePathAllowedRejectsAllowedDirectoryItself() {
+    final File allowedDirectory = new File(tmpDir, "allowed-export-dir");
+    final String[] allowedDirectories = {allowedDirectory.getAbsolutePath()};
+
+    Assert.assertFalse(
+        FileUtils.isFilePathAllowed(allowedDirectory.getAbsolutePath(), 
allowedDirectories));
+    Assert.assertTrue(
+        FileUtils.isFilePathAllowed(
+            new File(allowedDirectory, "result.tsfile").getAbsolutePath(), 
allowedDirectories));
+  }
+
   @Test
   public void testTruncateFile() throws IOException {
     File file = new File(tmpDir, "truncate-file");

Reply via email to