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");