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

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

commit b3ed39d067c86eb030e5562212079f7cfc4786ea
Author: shuwenwei <[email protected]>
AuthorDate: Thu Sep 3 09:34:26 2026 +0800

    Restrict COPY TO export paths
---
 .../query/recent/copyto/IoTDBCopyToTsFileIT.java   | 24 +++++++++
 .../apache/iotdb/db/i18n/DataNodeMiscMessages.java |  4 ++
 .../iotdb/db/i18n/DataNodeQueryMessages.java       |  3 ++
 .../apache/iotdb/db/i18n/DataNodeMiscMessages.java |  4 ++
 .../iotdb/db/i18n/DataNodeQueryMessages.java       |  2 +
 .../java/org/apache/iotdb/db/conf/IoTDBConfig.java | 63 ++++++++++++++++++++++
 .../org/apache/iotdb/db/conf/IoTDBDescriptor.java  | 14 +++++
 .../relational/analyzer/StatementAnalyzer.java     | 22 ++++++++
 .../conf/iotdb-system.properties.template          | 10 ++++
 .../org/apache/iotdb/commons/utils/FileUtils.java  | 33 ++++++++++++
 10 files changed, 179 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..e170605b022 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,30 @@ 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 testCopySelectAllColumns()
       throws IoTDBConnectionException, StatementExecutionException, 
IOException {
diff --git 
a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java
 
b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java
index 4aef9cd74f0..1e79e3079a3 100644
--- 
a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java
+++ 
b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java
@@ -1383,6 +1383,10 @@ public final class DataNodeMiscMessages {
           + "is your token hint info: %s";
   public static final String 
MISC_EXCEPTION_FAIL_TO_RELOAD_CONFIG_FILE_S_BECAUSE_S_93CCAB8D =
       "Fail to reload config file %s because %s";
+  public static final String 
MISC_EXCEPTION_FAILED_TO_CANONICALIZE_COPY_TO_ALLOWED_EXPORT_DIR_70EB7BF1 =
+      "Failed to canonicalize COPY TO allowed export directory: %s";
+  public static final String 
MISC_EXCEPTION_COPY_TO_ALLOWED_EXPORT_DIR_OVERLAPS_ACTIVE_LOAD_DIR_DAACF086 =
+      "COPY TO allowed export directory overlaps active load directory: %s";
   public static final String 
MISC_EXCEPTION_EACH_SUBSECTION_OF_CONFIGURATION_ITEM_UDF_READER_TRANSFORMER_97CA8962
 =
       "Each subsection of configuration item 
udf_reader_transformer_collector_memory_proportion "
           + "should be an integer, which is %s";
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 3feefd72a6f..91a1a03a3f0 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
@@ -190,6 +190,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/DataNodeMiscMessages.java
 
b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java
index bf909f27745..8556b3ade0c 100644
--- 
a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java
+++ 
b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java
@@ -1355,6 +1355,10 @@ public final class DataNodeMiscMessages {
       "首次启动后不允许修改 TsFile 加密类型或密钥。token hint 信息:%s";
   public static final String 
MISC_EXCEPTION_FAIL_TO_RELOAD_CONFIG_FILE_S_BECAUSE_S_93CCAB8D =
       "无法重新加载配置文件 %s,原因:%s";
+  public static final String 
MISC_EXCEPTION_FAILED_TO_CANONICALIZE_COPY_TO_ALLOWED_EXPORT_DIR_70EB7BF1 =
+      "无法获取 COPY TO 允许导出目录的规范路径:%s";
+  public static final String 
MISC_EXCEPTION_COPY_TO_ALLOWED_EXPORT_DIR_OVERLAPS_ACTIVE_LOAD_DIR_DAACF086 =
+      "COPY TO 允许导出目录与 active load 目录重叠:%s";
   public static final String 
MISC_EXCEPTION_EACH_SUBSECTION_OF_CONFIGURATION_ITEM_UDF_READER_TRANSFORMER_97CA8962
 =
       "配置项 udf_reader_transformer_collector_memory_proportion 的每个子项都应为整数,当前为 
%s";
   public static final String
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 0d4e7cc0071..dc6e4af5cfd 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
@@ -179,6 +179,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 5e0ff2dec6a..4885d5300be 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
@@ -25,6 +25,7 @@ import 
org.apache.iotdb.commons.client.property.ClientPoolProperty.DefaultProper
 import org.apache.iotdb.commons.conf.CommonDescriptor;
 import org.apache.iotdb.commons.conf.IoTDBConstant;
 import org.apache.iotdb.commons.enums.ReadConsistencyLevel;
+import org.apache.iotdb.commons.exception.IoTDBRuntimeException;
 import org.apache.iotdb.commons.pipe.config.PipeConfig;
 import org.apache.iotdb.commons.utils.FileUtils;
 import org.apache.iotdb.consensus.ConsensusFactory;
@@ -47,6 +48,7 @@ import org.apache.iotdb.metrics.config.MetricConfigDescriptor;
 import org.apache.iotdb.metrics.metricsets.system.SystemMetrics;
 import org.apache.iotdb.rpc.BaseRpcTransportFactory;
 import org.apache.iotdb.rpc.RpcUtils;
+import org.apache.iotdb.rpc.TSStatusCode;
 import org.apache.iotdb.rpc.ZeroCopyRpcTransportFactory;
 
 import org.apache.tsfile.common.conf.TSFileDescriptor;
@@ -1191,6 +1193,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
@@ -1419,6 +1428,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);
@@ -4337,6 +4349,57 @@ 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 void validateCopyToAllowedExportDirs() {
+    try {
+      for (final String allowedExportDir : copyToAllowedExportDirs) {
+        final Path allowedExportPath = new 
File(allowedExportDir).getCanonicalFile().toPath();
+        for (final String activeLoadDir : getLoadActiveListeningDirs()) {
+          validateCopyToDirectoryOverlap(allowedExportPath, activeLoadDir);
+        }
+        validateCopyToDirectoryOverlap(allowedExportPath, 
getLoadActiveListeningPipeDir());
+      }
+    } catch (IOException e) {
+      throw new IoTDBRuntimeException(
+          DataNodeMiscMessages
+              
.MISC_EXCEPTION_FAILED_TO_CANONICALIZE_COPY_TO_ALLOWED_EXPORT_DIR_70EB7BF1,
+          e,
+          TSStatusCode.CONFIGURATION_ERROR.getStatusCode());
+    }
+  }
+
+  private void validateCopyToDirectoryOverlap(
+      final Path allowedExportPath, final String activeLoadDir) throws 
IOException {
+    final Path activeLoadPath = new 
File(activeLoadDir).getCanonicalFile().toPath();
+    if (allowedExportPath.startsWith(activeLoadPath)
+        || activeLoadPath.startsWith(allowedExportPath)) {
+      throw new IoTDBRuntimeException(
+          String.format(
+              DataNodeMiscMessages
+                  
.MISC_EXCEPTION_COPY_TO_ALLOWED_EXPORT_DIR_OVERLAPS_ACTIVE_LOAD_DIR_DAACF086,
+              allowedExportPath),
+          TSStatusCode.CONFIGURATION_ERROR.getStatusCode(),
+          true);
+    }
+  }
+
   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 c60db041223..f6591857863 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
@@ -509,6 +509,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(
@@ -2617,6 +2629,7 @@ public class IoTDBDescriptor {
     conf.setLoadActiveListeningPipeDir(
         properties.getProperty(
             "load_active_listening_pipe_dir", 
conf.getLoadActiveListeningPipeDir()));
+    conf.validateCopyToAllowedExportDirs();
 
     final long loadActiveListeningCheckIntervalSeconds =
         Long.parseLong(
@@ -2754,6 +2767,7 @@ public class IoTDBDescriptor {
     conf.setLoadActiveListeningPipeDir(
         properties.getProperty(
             "load_active_listening_pipe_dir", 
conf.getLoadActiveListeningPipeDir()));
+    conf.validateCopyToAllowedExportDirs();
 
     conf.setLoadTsFileSpiltPartitionMaxSize(
         Integer.parseInt(
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..5b7f5bb65f2 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,7 +250,9 @@ 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.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
@@ -1314,6 +1320,22 @@ 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();
+        final String[] activeLoadDirectories = 
config.getLoadActiveListeningDirs();
+        final String[] forbiddenDirectories =
+            Arrays.copyOf(activeLoadDirectories, activeLoadDirectories.length 
+ 1);
+        forbiddenDirectories[activeLoadDirectories.length] = 
config.getLoadActiveListeningPipeDir();
+        if (!FileUtils.isFilePathAllowed(
+            targetFilePath, config.getCopyToAllowedExportDirs(), 
forbiddenDirectories)) {
+          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/node-commons/src/assembly/resources/conf/iotdb-system.properties.template
 
b/iotdb-core/node-commons/src/assembly/resources/conf/iotdb-system.properties.template
index e325ff42573..1248b13cef6 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
@@ -2411,6 +2411,16 @@ 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 must not overlap load_active_listening_dirs or 
load_active_listening_pipe_dir.
+# effectiveMode: restart
+# Datatype: String
+# 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..4058f1e41fe 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,39 @@ public class FileUtils {
     return file;
   }
 
+  /**
+   * Checks whether a target path is under one of the allowed directories and 
none of the forbidden
+   * directories after canonicalization.
+   *
+   * <p>The method returns {@code false} if any path cannot be canonicalized.
+   */
+  public static boolean isFilePathAllowed(
+      String targetFilePath, String[] allowedDirectories, String[] 
forbiddenDirectories) {
+    if (targetFilePath == null || allowedDirectories == null || 
forbiddenDirectories == null) {
+      return false;
+    }
+    try {
+      final Path targetPath = new 
File(targetFilePath).getCanonicalFile().toPath();
+      for (String forbiddenDirectory : forbiddenDirectories) {
+        if (forbiddenDirectory != null
+            && !forbiddenDirectory.isEmpty()
+            && targetPath.startsWith(new 
File(forbiddenDirectory).getCanonicalFile().toPath())) {
+          return false;
+        }
+      }
+      for (String allowedDirectory : allowedDirectories) {
+        if (allowedDirectory != null
+            && !allowedDirectory.isEmpty()
+            && targetPath.startsWith(new 
File(allowedDirectory).getCanonicalFile().toPath())) {
+          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.

Reply via email to