From b3ed39d067c86eb030e5562212079f7cfc4786ea Mon Sep 17 00:00:00 2001 From: shuwenwei Date: Thu, 3 Sep 2026 09:34:26 +0800 Subject: [PATCH 1/5] Restrict COPY TO export paths --- .../recent/copyto/IoTDBCopyToTsFileIT.java | 24 +++++++ .../iotdb/db/i18n/DataNodeMiscMessages.java | 4 ++ .../iotdb/db/i18n/DataNodeQueryMessages.java | 3 + .../iotdb/db/i18n/DataNodeMiscMessages.java | 4 ++ .../iotdb/db/i18n/DataNodeQueryMessages.java | 2 + .../org/apache/iotdb/db/conf/IoTDBConfig.java | 63 +++++++++++++++++++ .../apache/iotdb/db/conf/IoTDBDescriptor.java | 14 +++++ .../analyzer/StatementAnalyzer.java | 22 +++++++ .../conf/iotdb-system.properties.template | 10 +++ .../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 e25c3f5829949..e170605b0225b 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 void testCopyTable() } } + @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 4aef9cd74f050..1e79e3079a393 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 @@ private 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 3feefd72a6f46..91a1a03a3f0ec 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 bf909f2774521..8556b3ade0c91 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 @@ private 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 0d4e7cc0071bf..dc6e4af5cfd76 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 5e0ff2dec6a8f..4885d5300beb5 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.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.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 @@ private void formulateFolders() { 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 void setLoadActiveListeningDirs(String[] loadActiveListeningDirs) { 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 c60db04122307..f6591857863d4 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 void loadProperties(TrimProperties properties) throws BadNodeUrlException 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 @@ private void loadLoadTsFileProps(TrimProperties properties) { conf.setLoadActiveListeningPipeDir( properties.getProperty( "load_active_listening_pipe_dir", conf.getLoadActiveListeningPipeDir())); + conf.validateCopyToAllowedExportDirs(); final long loadActiveListeningCheckIntervalSeconds = Long.parseLong( @@ -2754,6 +2767,7 @@ private void loadLoadTsFileHotModifiedProp(TrimProperties properties) throws IOE 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 b5c394539f217..5b7f5bb65f26b 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 @@ 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.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.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 Scope visitExplain(Explain node, Optional context) { @Override public Scope visitCopyTo(CopyTo node, Optional 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 e325ff4257302..1248b13cef6b3 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 d5b1b76a7eb27..4058f1e41fee6 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 static String addPrefix2FilePath(String prefix, String file) { return file; } + /** + * Checks whether a target path is under one of the allowed directories and none of the forbidden + * directories after canonicalization. + * + *

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. From b3699f01aebc4c5abab3f47dde00d22a30347707 Mon Sep 17 00:00:00 2001 From: shuwenwei Date: Thu, 3 Sep 2026 10:24:05 +0800 Subject: [PATCH 2/5] Enable hot reload for COPY TO export directories --- .../recent/copyto/IoTDBCopyToTsFileIT.java | 33 +++++++++++++++++++ .../apache/iotdb/db/conf/IoTDBDescriptor.java | 33 ++++++++++++++++++- .../conf/iotdb-system.properties.template | 5 +-- 3 files changed, 68 insertions(+), 3 deletions(-) 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 e170605b0225b..5107880dc39b3 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 @@ -165,6 +165,39 @@ public void testCopyToRejectsClientSuppliedAbsolutePath() } } + @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/java/org/apache/iotdb/db/conf/IoTDBDescriptor.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBDescriptor.java index f6591857863d4..29431784377c7 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 @@ -2629,6 +2629,18 @@ private void loadLoadTsFileProps(TrimProperties properties) { conf.setLoadActiveListeningPipeDir( 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)); conf.validateCopyToAllowedExportDirs(); final long loadActiveListeningCheckIntervalSeconds = @@ -2767,7 +2779,26 @@ private void loadLoadTsFileHotModifiedProp(TrimProperties properties) throws IOE conf.setLoadActiveListeningPipeDir( properties.getProperty( "load_active_listening_pipe_dir", conf.getLoadActiveListeningPipeDir())); - conf.validateCopyToAllowedExportDirs(); + + final String[] previousCopyToAllowedExportDirs = + conf.getCopyToAllowedExportDirs().clone(); + 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)); + try { + conf.validateCopyToAllowedExportDirs(); + } catch (RuntimeException e) { + conf.setCopyToAllowedExportDirs(previousCopyToAllowedExportDirs); + throw e; + } conf.setLoadTsFileSpiltPartitionMaxSize( Integer.parseInt( 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 1248b13cef6b3..3081cd4477965 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 @@ -2417,9 +2417,10 @@ load_active_listening_check_interval_seconds=5 # 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 +# effectiveMode: hot_reload # Datatype: String -# copy_to_allowed_export_dirs= +# 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; From 27022cda49abac50cc244d63e649237583b7b20a Mon Sep 17 00:00:00 2001 From: shuwenwei Date: Thu, 3 Sep 2026 10:29:26 +0800 Subject: [PATCH 3/5] Fix COPY TO configuration formatting --- .../main/java/org/apache/iotdb/db/conf/IoTDBDescriptor.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 29431784377c7..7a75f1ce9562c 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 @@ -2780,8 +2780,7 @@ private void loadLoadTsFileHotModifiedProp(TrimProperties properties) throws IOE properties.getProperty( "load_active_listening_pipe_dir", conf.getLoadActiveListeningPipeDir())); - final String[] previousCopyToAllowedExportDirs = - conf.getCopyToAllowedExportDirs().clone(); + final String[] previousCopyToAllowedExportDirs = conf.getCopyToAllowedExportDirs().clone(); conf.setCopyToAllowedExportDirs( Arrays.stream( properties From 81dc738c5c06db7a3ad7a82a1672bee21c27cff7 Mon Sep 17 00:00:00 2001 From: shuwenwei Date: Thu, 3 Sep 2026 10:57:15 +0800 Subject: [PATCH 4/5] spotless --- .../it/query/recent/copyto/IoTDBCopyToTsFileIT.java | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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 5107880dc39b3..5f06815cb0662 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 @@ -177,9 +177,7 @@ public void testCopyToUsesHotReloadedAllowedExportDirectory() try (ITableSession session = EnvFactory.getEnv().getTableSessionConnectionWithDB(DATABASE_NAME)) { session.executeNonQueryStatement( - "set configuration \"copy_to_allowed_export_dirs\"='" - + exportDirectoryPath - + "'"); + "set configuration \"copy_to_allowed_export_dirs\"='" + exportDirectoryPath + "'"); try { SessionDataSet sessionDataSet = session.executeQueryStatement( @@ -189,8 +187,7 @@ public void testCopyToUsesHotReloadedAllowedExportDirectory() Assert.assertEquals(targetFile.getAbsolutePath(), iterator.getString(1)); Assert.assertTrue(targetFile.exists()); } finally { - session.executeNonQueryStatement( - "set configuration \"copy_to_allowed_export_dirs\"=''"); + session.executeNonQueryStatement("set configuration \"copy_to_allowed_export_dirs\"=''"); } } finally { Files.deleteIfExists(targetFile.toPath()); From 5fd188f57e1f1527ce0d199295223de9850447b0 Mon Sep 17 00:00:00 2001 From: shuwenwei Date: Fri, 4 Sep 2026 09:50:28 +0800 Subject: [PATCH 5/5] remove overlap check --- .../iotdb/db/i18n/DataNodeMiscMessages.java | 4 --- .../iotdb/db/i18n/DataNodeMiscMessages.java | 4 --- .../org/apache/iotdb/db/conf/IoTDBConfig.java | 35 ------------------- .../apache/iotdb/db/conf/IoTDBDescriptor.java | 9 ----- .../analyzer/StatementAnalyzer.java | 8 +---- .../conf/iotdb-system.properties.template | 3 +- .../apache/iotdb/commons/utils/FileUtils.java | 15 ++------ 7 files changed, 6 insertions(+), 72 deletions(-) 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 1e79e3079a393..4aef9cd74f050 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,10 +1383,6 @@ private 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/zh/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java index 8556b3ade0c91..bf909f2774521 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,10 +1355,6 @@ private 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/java/org/apache/iotdb/db/conf/IoTDBConfig.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBConfig.java index 4885d5300beb5..4b3593c28eaaf 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,7 +25,6 @@ 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; @@ -48,7 +47,6 @@ 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; @@ -4367,39 +4365,6 @@ public void setCopyToAllowedExportDirs(final String[] copyToAllowedExportDirs) { .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 7a75f1ce9562c..4a259c690deda 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 @@ -2641,7 +2641,6 @@ private void loadLoadTsFileProps(TrimProperties properties) { .map(String::trim) .filter(dir -> !dir.isEmpty()) .toArray(String[]::new)); - conf.validateCopyToAllowedExportDirs(); final long loadActiveListeningCheckIntervalSeconds = Long.parseLong( @@ -2780,7 +2779,6 @@ private void loadLoadTsFileHotModifiedProp(TrimProperties properties) throws IOE properties.getProperty( "load_active_listening_pipe_dir", conf.getLoadActiveListeningPipeDir())); - final String[] previousCopyToAllowedExportDirs = conf.getCopyToAllowedExportDirs().clone(); conf.setCopyToAllowedExportDirs( Arrays.stream( properties @@ -2792,13 +2790,6 @@ private void loadLoadTsFileHotModifiedProp(TrimProperties properties) throws IOE .map(String::trim) .filter(dir -> !dir.isEmpty()) .toArray(String[]::new)); - try { - conf.validateCopyToAllowedExportDirs(); - } catch (RuntimeException e) { - conf.setCopyToAllowedExportDirs(previousCopyToAllowedExportDirs); - throw e; - } - 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 5b7f5bb65f26b..777e0de391a41 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 @@ -252,7 +252,6 @@ import java.io.File; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -1324,12 +1323,7 @@ public Scope visitCopyTo(CopyTo node, Optional context) { 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)) { + if (!FileUtils.isFilePathAllowed(targetFilePath, config.getCopyToAllowedExportDirs())) { throw new IoTDBRuntimeException( DataNodeQueryMessages.COPY_TO_TARGET_PATH_NOT_ALLOWED + targetFilePath, TSStatusCode.COPY_TO_WRITE_ERROR.getStatusCode(), 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 3081cd4477965..5719ec9a65221 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 @@ -2416,7 +2416,8 @@ load_active_listening_check_interval_seconds=5 # 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. +# 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 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 4058f1e41fee6..442f304818f6e 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 @@ -330,25 +330,16 @@ public static String addPrefix2FilePath(String prefix, String file) { } /** - * Checks whether a target path is under one of the allowed directories and none of the forbidden - * directories after canonicalization. + * Checks whether a target path is under one of the allowed directories after canonicalization. * *

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) { + 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 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()