Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 37 additions & 8 deletions be/src/io/fs/local_file_system.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -471,28 +471,57 @@ const std::shared_ptr<LocalFileSystem>& global_local_filesystem() {
Status LocalFileSystem::canonicalize_local_file(const std::string& dir,
const std::string& file_path,
std::string* full_path) {
const std::string absolute_path = dir + "/" + file_path;
// An empty root would turn a relative request into an unrestricted absolute path.
if (dir.empty()) {
return Status::InvalidArgument("user_files_secure_path must not be empty");
}
// Reject embedded NULs before passing paths to filesystem APIs that use C strings.
if (file_path.empty() || file_path.find('\0') != std::string::npos) {
return Status::InvalidArgument("local file path must not be empty or contain NUL");
}
Path local_path;
// Convert file: URIs to filesystem paths and reject remote schemes before any reader opens them.
RETURN_IF_ERROR(convert_to_abs_path(file_path, local_path));
std::string secure_path;
RETURN_IF_ERROR(canonicalize(dir, &secure_path));
std::string canonical_path;
RETURN_IF_ERROR(canonicalize(absolute_path, &canonical_path));
if (!contain_path(dir, canonical_path)) {
return Status::InvalidArgument("file path is not allowed: {}", canonical_path);
const auto status = canonicalize(Path(secure_path) / local_path, &canonical_path);
// A glob result can reveal external names even when resolution fails on a dangling symlink.
// Reject both failed resolution and out-of-root targets without exposing expanded names.
if (!status.ok() || !contain_path(secure_path, canonical_path)) {
return Status::InvalidArgument("file path is not allowed");
}

*full_path = canonical_path;
*full_path = std::move(canonical_path);
return Status::OK();
}

Status LocalFileSystem::safe_glob(const std::string& path, std::vector<FileInfo>* res) {
if (path.find("..") != std::string::npos) {
return Status::InvalidArgument("can not contain '..' in path");
}
std::string full_path = config::user_files_secure_path + "/" + path;
// An empty root would turn the prefix below into "/" and allow any local file.
if (config::user_files_secure_path.empty()) {
return Status::InvalidArgument("user_files_secure_path must not be empty");
}
std::string secure_path;
RETURN_IF_ERROR(canonicalize(config::user_files_secure_path, &secure_path));
// A symlink root may canonicalize to a directory such as "user_files[prod]".
// The root is literal; only the user-supplied suffix may contain glob operators.
std::string full_path;
for (char c : secure_path) {
if (c == '\\' || c == '*' || c == '?' || c == '[' || c == ']') {
full_path += '\\';
}
full_path += c;
}
full_path += "/" + path;
std::vector<std::string> files;
RETURN_IF_ERROR(_glob(full_path, &files));
for (auto& file : files) {
FileInfo fi;
fi.is_file = true;
RETURN_IF_ERROR(canonicalize_local_file("", file, &(fi.file_name)));
// Glob classes and symlinks can escape without a literal ".." in the pattern.
RETURN_IF_ERROR(canonicalize_local_file(secure_path, file, &(fi.file_name)));
RETURN_IF_ERROR(file_size_impl(fi.file_name, &(fi.file_size)));
res->push_back(std::move(fi));
}
Expand Down
2 changes: 2 additions & 0 deletions be/src/io/fs/local_file_system.h
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ class LocalFileSystem final : public FileSystem {

static std::filesystem::perms PERMS_OWNER_RW;

// Resolve a local path relative to a nonempty secure root, then check canonical containment.
// Absolute paths and file: URIs must resolve inside the same root; remote schemes are rejected.
Status canonicalize_local_file(const std::string& dir, const std::string& file_path,
std::string* full_path);

Expand Down
112 changes: 112 additions & 0 deletions be/test/io/fs/local_file_system_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,14 @@
#include <filesystem>
#include <vector>

#include "common/config.h"
#include "common/status.h"
#include "cpp/sync_point.h"
#include "gtest/gtest_pred_impl.h"
#include "io/fs/file_reader.h"
#include "io/fs/file_writer.h"
#include "io/fs/local_file_writer.h"
#include "util/defer_op.h"
#include "util/slice.h"

namespace doris {
Expand Down Expand Up @@ -568,6 +570,10 @@ TEST_F(LocalFileSystemTest, AbnormalWriteRead) {
}

TEST_F(LocalFileSystemTest, TestGlob) {
const std::string original_secure_path = config::user_files_secure_path;
DEFER(config::user_files_secure_path = original_secure_path);
// Bind this fixture to its own files, independent of the runner's build-profile directory.
config::user_files_secure_path = std::filesystem::absolute("./be/ut_build_ASAN/test").string();
std::string path = "./be/ut_build_ASAN/test/file_path/";
EXPECT_TRUE(io::global_local_filesystem()->delete_directory(path).ok());
EXPECT_TRUE(io::global_local_filesystem()
Expand Down Expand Up @@ -599,6 +605,112 @@ TEST_F(LocalFileSystemTest, TestGlob) {
EXPECT_TRUE(io::global_local_filesystem()->delete_directory(path).ok());
}

TEST_F(LocalFileSystemTest, TestSafeGlobPathContainment) {
auto fs = io::global_local_filesystem();
const std::string original_secure_path = config::user_files_secure_path;
DEFER(config::user_files_secure_path = original_secure_path);
const auto root = std::filesystem::canonical(test_dir);
const auto secure = root / "secure";
const auto outside = root / "secure-other";
std::filesystem::create_directories(secure / "nested");
std::filesystem::create_directory(outside);
ASSERT_TRUE(save_string_file((secure / "nested/inside.csv").string(), "inside").ok());
ASSERT_TRUE(save_string_file((outside / "outside.csv").string(), "outside").ok());
std::filesystem::create_directory_symlink(outside, secure / "escape");
std::filesystem::create_symlink(outside / "outside.csv", secure / "outside.csv");
std::filesystem::create_symlink(outside / "missing.csv", outside / "broken.csv");
std::filesystem::create_directory_symlink(secure / "nested", secure / "inside");
config::user_files_secure_path = secure.string();

for (const auto* pattern : {".[.]/secure-other/outside.csv", ".?/secure-other/outside.csv",
".[.-.]/secure-other/outside.csv", "escape/*.csv", "outside.csv",
".[.]/secure-other/*.csv", "escape/broken*.csv"}) {
std::vector<io::FileInfo> files;
const auto status = fs->safe_glob(pattern, &files);
EXPECT_FALSE(status.ok()) << pattern;
EXPECT_NE(status.to_string().find("file path is not allowed"), std::string::npos) << status;
// The pattern need not name the matched file, so errors must not reveal that name.
const auto message = status.to_string();
EXPECT_EQ(std::string::npos, message.find(root.string())) << message;
for (const auto* name : {"outside.csv", "broken.csv", "missing.csv"}) {
EXPECT_EQ(std::string::npos, message.find(name)) << message;
}
EXPECT_TRUE(files.empty());
}

for (const auto* pattern : {"nested/*.csv", "inside/*.csv"}) {
std::vector<io::FileInfo> files;
ASSERT_TRUE(fs->safe_glob(pattern, &files).ok()) << pattern;
ASSERT_EQ(1, files.size());
EXPECT_EQ((secure / "nested/inside.csv").string(), files[0].file_name);
EXPECT_EQ(6, files[0].file_size);
}
}

TEST_F(LocalFileSystemTest, TestSafeGlobSecureRoot) {
auto fs = io::global_local_filesystem();
const std::string original_secure_path = config::user_files_secure_path;
DEFER(config::user_files_secure_path = original_secure_path);
const auto root = std::filesystem::canonical(test_dir);
std::filesystem::create_directory(root / "secure");
std::filesystem::create_directory_symlink(root / "secure", root / "secure-link");
ASSERT_TRUE(save_string_file((root / "secure/inside.csv").string(), "inside").ok());

for (const auto& secure : {std::string(test_dir) + "/secure", (root / "secure/").string(),
(root / "secure-link").string()}) {
config::user_files_secure_path = secure;
std::vector<io::FileInfo> files;
ASSERT_TRUE(fs->safe_glob("*.csv", &files).ok()) << secure;
ASSERT_EQ(1, files.size());
EXPECT_EQ((root / "secure/inside.csv").string(), files[0].file_name);
}
const auto special_root = root / "secure[prod]*?\\root";
std::filesystem::create_directory(special_root);
std::filesystem::create_directory_symlink(special_root, root / "special-root-link");
ASSERT_TRUE(save_string_file((special_root / "inside.csv").string(), "inside").ok());
for (const auto& secure : {special_root, root / "special-root-link"}) {
config::user_files_secure_path = secure.string();
std::vector<io::FileInfo> matches;
ASSERT_TRUE(fs->safe_glob("*.csv", &matches).ok()) << secure;
ASSERT_EQ(1, matches.size());
EXPECT_EQ((special_root / "inside.csv").string(), matches[0].file_name);
}
config::user_files_secure_path = "";
std::vector<io::FileInfo> files;
EXPECT_FALSE(fs->safe_glob("*.csv", &files).ok());
config::user_files_secure_path = (root / "missing").string();
EXPECT_FALSE(fs->safe_glob("*.csv", &files).ok());
}

TEST_F(LocalFileSystemTest, TestCanonicalizeSecureLocalPath) {
auto fs = io::global_local_filesystem();
const auto root = std::filesystem::canonical(test_dir);
const auto secure = root / "secure";
std::filesystem::create_directories(secure / "nested");
std::filesystem::create_directory(root / "secure-other");
std::filesystem::create_directory_symlink(root / "secure-other", secure / "escape");
std::filesystem::create_directory_symlink(secure / "nested", secure / "inside");
for (const auto& path :
{std::string("nested"), std::string("nested/../inside"), (secure / "nested").string(),
"file://" + (secure / "nested").string()}) {
std::string resolved;
ASSERT_TRUE(fs->canonicalize_local_file(secure.string(), path, &resolved).ok()) << path;
EXPECT_EQ((secure / "nested").string(), resolved);
}
for (const auto& path :
{std::string("../secure-other"), std::string("escape"), (root / "secure-other").string(),
std::string("https://invalid/dataset"), std::string("s3://bucket/dataset"),
std::string("http://invalid/dataset"), std::string(""),
std::string("nested\0ignored", 14)}) {
std::string resolved;
const auto status = fs->canonicalize_local_file(secure.string(), path, &resolved);
EXPECT_FALSE(status.ok()) << path;
EXPECT_EQ(std::string::npos, status.to_string().find(root.string())) << status;
}
std::string resolved;
EXPECT_FALSE(fs->canonicalize_local_file("", secure.string(), &resolved).ok());
}

TEST_F(LocalFileSystemTest, TestConvertToAbsPath) {
io::Path abs_path;
Status st;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2815,6 +2815,10 @@ public static void validateMtmvCacheConfig() throws ConfigException {
+ "configuration to prevent command injection attacks.")
public static boolean enable_java_udf = true;

@ConfField(description = "Whether to enable LOCAL TVF and FILE TVF with local storage. Disabled by default. "
+ "Set this in fe.conf and restart every FE to enable it. Existing access checks still apply.")
public static boolean enable_local_tvf = false;

@ConfField(mutable = true, masterOnly = true, description = "When enabled, data can be processed using the "
+ "globally created Java UDF function during import. " + "The default setting is false.")
public static boolean enable_udf_in_load = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
import org.apache.doris.qe.AutoCloseSessionVariable;
import org.apache.doris.qe.ConnectContext;
import org.apache.doris.qe.SessionVariable;
import org.apache.doris.tablefunction.LocalTableValuedFunction;
import org.apache.doris.thrift.TPartialUpdateNewRowPolicy;

import com.google.common.annotations.VisibleForTesting;
Expand Down Expand Up @@ -701,6 +702,11 @@ private Plan bindTVFTableSink(MatchingContext<UnboundTVFTableSink<Plan>> ctx) {
"INSERT INTO TVF only supports local/s3/hdfs, but got: " + tvfName);
}

// INSERT/EXPLAIN INSERT bind a sink without constructing the LOCAL read TVF.
if (tvfName.equals("local")) {
LocalTableValuedFunction.checkEnabled();
}

// Validate required properties
if (!properties.containsKey("file_path")) {
throw new AnalysisException("TVF sink requires 'file_path' property");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import org.apache.doris.analysis.StorageBackend.StorageType;
import org.apache.doris.catalog.Env;
import org.apache.doris.common.AnalysisException;
import org.apache.doris.common.Config;
import org.apache.doris.common.ErrorCode;
import org.apache.doris.datasource.storage.StorageAdapter;
import org.apache.doris.mysql.privilege.PrivPredicate;
Expand Down Expand Up @@ -63,6 +64,8 @@ public class LocalTableValuedFunction extends ExternalFileTableValuedFunction {
private boolean sharedStorage = false;

public LocalTableValuedFunction(Map<String, String> properties) throws AnalysisException {
checkEnabled();

// Check privileges before listing files or discovering their schema.
checkAuth(ConnectContext.get());

Expand Down Expand Up @@ -137,6 +140,14 @@ private void getFileListFromBackend() throws AnalysisException {
}
}

/** Check read and write admission before properties or backend access. */
public static void checkEnabled() {
if (!Config.enable_local_tvf) {
throw new org.apache.doris.nereids.exceptions.AnalysisException(
"LOCAL TVF is disabled. Set enable_local_tvf=true in fe.conf and restart every FE to enable it.");
}
}

@Override
public void checkAuth(ConnectContext ctx) {
if (!Env.getCurrentEnv().getAccessManager().checkGlobalPriv(ctx, PrivPredicate.ADMIN)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,83 @@

package org.apache.doris.nereids.rules.analysis;

import org.apache.doris.common.Config;
import org.apache.doris.nereids.exceptions.AnalysisException;
import org.apache.doris.nereids.parser.NereidsParser;
import org.apache.doris.nereids.trees.expressions.NamedExpression;
import org.apache.doris.nereids.trees.plans.Plan;
import org.apache.doris.nereids.trees.plans.commands.ExplainCommand;
import org.apache.doris.nereids.trees.plans.commands.insert.InsertIntoTVFCommand;
import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
import org.apache.doris.nereids.trees.plans.logical.LogicalProject;
import org.apache.doris.nereids.trees.plans.logical.LogicalTVFTableSink;
import org.apache.doris.nereids.util.MemoTestUtils;
import org.apache.doris.nereids.util.PlanChecker;
import org.apache.doris.qe.ConnectContext;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.util.Arrays;
import java.util.List;

class BindTVFTableSinkTest {
private boolean originalEnableLocalTvf;

@BeforeEach
void setUp() {
originalEnableLocalTvf = Config.enable_local_tvf;
Config.enable_local_tvf = false;
}

@AfterEach
void tearDown() {
Config.enable_local_tvf = originalEnableLocalTvf;
}

@Test
void disabledLocalSinkRejectsInsertAndExplainBeforeProperties() {
for (String prefix : Arrays.asList("", "EXPLAIN ")) {
for (String properties : Arrays.asList("'format'='csv'",
"'file_path'='/tmp/local_gate_', 'backend_id'='1', 'format'='csv'")) {
AnalysisException exception = Assertions.assertThrows(AnalysisException.class,
() -> analyzeInsert(prefix + "INSERT INTO local(" + properties + ") SELECT 1"));
Assertions.assertTrue(exception.getMessage().contains("LOCAL TVF is disabled"));
}
}
}

@Test
void enabledLocalSinkKeepsPropertyValidation() {
Config.enable_local_tvf = true;
AnalysisException exception = Assertions.assertThrows(AnalysisException.class,
() -> analyzeInsert("INSERT INTO local('format'='csv') SELECT 1"));
Assertions.assertTrue(exception.getMessage().contains("requires 'file_path'"));
}

@Test
void disabledLocalDoesNotBlockRemoteSinks() {
for (String name : Arrays.asList("s3", "hdfs")) {
Assertions.assertInstanceOf(LogicalTVFTableSink.class,
analyzeInsert("INSERT INTO " + name + "('file_path'='remote/data', 'format'='csv') SELECT 1"));
}
}

private Plan analyzeInsert(String sql) {
ConnectContext context = MemoTestUtils.createConnectContext();
LogicalPlan parsed = new NereidsParser().parseSingle(sql);
if (parsed instanceof ExplainCommand) {
parsed = ((ExplainCommand) parsed).getLogicalPlan();
}
InsertIntoTVFCommand command = (InsertIntoTVFCommand) parsed;
return PlanChecker.from(context).analyze(command.getExplainPlan(context)).getPlan();
}

@Test
void duplicateOutputNamesKeepDistinctPositions() {
Config.enable_local_tvf = true;
ConnectContext context = MemoTestUtils.createConnectContext();
InsertIntoTVFCommand command = (InsertIntoTVFCommand) new NereidsParser().parseSingle(
"INSERT INTO local("
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
package org.apache.doris.tablefunction;

import org.apache.doris.catalog.Env;
import org.apache.doris.common.Config;
import org.apache.doris.mysql.privilege.AccessControllerManager;
import org.apache.doris.mysql.privilege.PrivPredicate;
import org.apache.doris.nereids.exceptions.AnalysisException;
Expand Down Expand Up @@ -50,12 +51,15 @@ public class LocalTableValuedFunctionAuthTest {
private final AccessControllerManager accessManager = Mockito.mock(AccessControllerManager.class);
private final SystemInfoService systemInfo = Mockito.mock(SystemInfoService.class);
private final BackendServiceProxy proxy = Mockito.mock(BackendServiceProxy.class);
private boolean originalEnableLocalTvf;
private MockedStatic<Env> mockedEnv;
private MockedStatic<ConnectContext> mockedContext;
private MockedStatic<BackendServiceProxy> mockedProxy;

@BeforeEach
public void setUp() throws Exception {
originalEnableLocalTvf = Config.enable_local_tvf;
Config.enable_local_tvf = true;
mockedEnv = Mockito.mockStatic(Env.class);
mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
mockedEnv.when(Env::getCurrentSystemInfo).thenReturn(systemInfo);
Expand All @@ -76,6 +80,7 @@ public void setUp() throws Exception {

@AfterEach
public void tearDown() {
Config.enable_local_tvf = originalEnableLocalTvf;
mockedProxy.close();
mockedContext.close();
mockedEnv.close();
Expand Down
Loading
Loading