Skip to content

Restrict local object access only for user files path#99405

Open
divanik wants to merge 22 commits intomasterfrom
divanik/fix_local_object_storage_arbitrary_access_2
Open

Restrict local object access only for user files path#99405
divanik wants to merge 22 commits intomasterfrom
divanik/fix_local_object_storage_arbitrary_access_2

Conversation

@divanik
Copy link
Copy Markdown
Member

@divanik divanik commented Mar 13, 2026

Changelog category (leave one):

  • Critical Bug Fix (crash, data loss, RBAC)

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

Restrict local object access only for user files path

Documentation entry for user-facing changes

  • Documentation is written (mandatory for new features)

@clickhouse-gh
Copy link
Copy Markdown
Contributor

clickhouse-gh bot commented Mar 13, 2026

Workflow [PR], commit [8addb8b]

Summary:

job_name test_name status info comment
Stateless tests (arm_asan, targeted) failure
02242_system_filesystem_cache_log_table FAIL cidb
Unit tests (asan) failure
MetadataPlainRewritableDiskTest.RemoveDirectoryRecursive failure cidb
MetadataPlainRewritableDiskTest.RemoveDirectoryRecursiveVirtualNodes failure cidb
MetadataPlainRewritableDiskTest.RootFiles failure cidb
MetadataPlainRewritableDiskTest.RemoveRoot failure cidb
MetadataPlainRewritableDiskTest.CreateHardLink failure cidb
MetadataPlainRewritableDiskTest.CreateHardLinkUndo failure cidb
MetadataPlainRewritableDiskTest.CreateHardLinkRootFiles failure cidb
MetadataPlainRewritableDiskTest.MoveVirtual failure cidb
MetadataPlainRewritableDiskTest.RemoveRecursiveVirtual failure cidb
MetadataPlainRewritableDiskTest.VirtualSubpathTrim failure cidb
9 more test cases not shown
Unit tests (tsan) failure
MetadataPlainRewritableDiskTest.RemoveDirectoryRecursive failure cidb
MetadataPlainRewritableDiskTest.RemoveDirectoryRecursiveVirtualNodes failure cidb
MetadataPlainRewritableDiskTest.RootFiles failure cidb
MetadataPlainRewritableDiskTest.RemoveRoot failure cidb
MetadataPlainRewritableDiskTest.CreateHardLink failure cidb
MetadataPlainRewritableDiskTest.CreateHardLinkUndo failure cidb
MetadataPlainRewritableDiskTest.CreateHardLinkRootFiles failure cidb
MetadataPlainRewritableDiskTest.MoveVirtual failure cidb
MetadataPlainRewritableDiskTest.RemoveRecursiveVirtual failure cidb
MetadataPlainRewritableDiskTest.VirtualSubpathTrim failure cidb
9 more test cases not shown
Unit tests (msan) failure
MetadataPlainRewritableDiskTest.RemoveDirectoryRecursive failure cidb
MetadataPlainRewritableDiskTest.RemoveDirectoryRecursiveVirtualNodes failure cidb
MetadataPlainRewritableDiskTest.RootFiles failure cidb
MetadataPlainRewritableDiskTest.RemoveRoot failure cidb
MetadataPlainRewritableDiskTest.CreateHardLink failure cidb
MetadataPlainRewritableDiskTest.CreateHardLinkUndo failure cidb
MetadataPlainRewritableDiskTest.CreateHardLinkRootFiles failure cidb
MetadataPlainRewritableDiskTest.MoveVirtual failure cidb
MetadataPlainRewritableDiskTest.RemoveRecursiveVirtual failure cidb
MetadataPlainRewritableDiskTest.VirtualSubpathTrim failure cidb
9 more test cases not shown
Unit tests (ubsan) failure
MetadataPlainRewritableDiskTest.RemoveDirectoryRecursive failure cidb
MetadataPlainRewritableDiskTest.RemoveDirectoryRecursiveVirtualNodes failure cidb
MetadataPlainRewritableDiskTest.RootFiles failure cidb
MetadataPlainRewritableDiskTest.RemoveRoot failure cidb
MetadataPlainRewritableDiskTest.CreateHardLink failure cidb
MetadataPlainRewritableDiskTest.CreateHardLinkUndo failure cidb
MetadataPlainRewritableDiskTest.CreateHardLinkRootFiles failure cidb
MetadataPlainRewritableDiskTest.MoveVirtual failure cidb
MetadataPlainRewritableDiskTest.RemoveRecursiveVirtual failure cidb
MetadataPlainRewritableDiskTest.VirtualSubpathTrim failure cidb
9 more test cases not shown
Unit tests (amd_llvm_coverage) failure
MetadataPlainRewritableDiskTest.RemoveDirectoryRecursive failure cidb
MetadataPlainRewritableDiskTest.RemoveDirectoryRecursiveVirtualNodes failure cidb
MetadataPlainRewritableDiskTest.RootFiles failure cidb
MetadataPlainRewritableDiskTest.RemoveRoot failure cidb
MetadataPlainRewritableDiskTest.CreateHardLink failure cidb
MetadataPlainRewritableDiskTest.CreateHardLinkUndo failure cidb
MetadataPlainRewritableDiskTest.CreateHardLinkRootFiles failure cidb
MetadataPlainRewritableDiskTest.MoveVirtual failure cidb
MetadataPlainRewritableDiskTest.RemoveRecursiveVirtual failure cidb
MetadataPlainRewritableDiskTest.VirtualSubpathTrim failure cidb
9 more test cases not shown
BuzzHouse (amd_debug) failure
Logical error: 'Inconsistent AST formatting: the query: (STID: 1941-1bfa) FAIL cidb
LLVM Coverage dropped

throw Exception(
ErrorCodes::BAD_ARGUMENTS,
"User files path is not properly set, cannot use LocalObjectStorage in this server configuration at all");
if (!disk_path.starts_with(user_files_path))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

disk_path.starts_with(user_files_path) is a string-prefix check, so /var/lib/clickhouse/user_files_evil/... is accepted even though it is outside user_files_path. This keeps a path-escape bypass in the server-side guard. Please compare normalized filesystem paths with a boundary-aware helper (for example pathStartsWith on canonicalized paths) instead of starts_with.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems valid


void LocalObjectStorage::throwIfPathAccessDenied(const String & path) const
{
auto norm_base = std::filesystem::path(settings.key_prefix).lexically_normal();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This validation is lexical only (lexically_normal / lexically_relative) and does not resolve symlinks. A path like <user_files>/allowed_link/file where allowed_link is a symlink to /tmp still passes the check but accesses data outside user_files. Please validate against weakly_canonical (or use openat-style resolution with RESOLVE_BENEATH) before allowing the operation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is ok. Our mental model - allow to access everything which is inside user_files_path

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, decided to verify symlinks as well

ObjectStoragePtr DiskLocal::getObjectStorage()
{
LocalObjectStorageSettings settings_object_storage(name, disk_path, /* read_only */false);
auto application_type = Context::getGlobalContextInstance()->getApplicationType();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DiskLocal::getObjectStorage is now used as a security gate, but this method is also called from generic disk registration checks (for example DiskSelector::recordDisk compares getObjectStorage for plain disks). In server mode this can reject valid non-user_files local disks and break startup/config validation even when LocalObjectStorage is never used. Please move this restriction to IcebergLocal/StorageLocalConfiguration construction paths (where local object storage is actually requested), or gate it behind an explicit local-object-storage usage path only.

Copy link
Copy Markdown
Member Author

@divanik divanik Mar 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is what it it, I didn't see failed tests in CI because of this, so I don't see any reasons to change this

Copy link
Copy Markdown
Member Author

@divanik divanik Mar 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Asked @alexey-milovidov for comment

Copy link
Copy Markdown
Member Author

@divanik divanik Mar 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I decided to revert this logic myself. I think that we need to give an opportunity to create disks over arbitrary folder (and leave enforcement only on an Iceberg configuration level)

@divanik
Copy link
Copy Markdown
Member Author

divanik commented Mar 13, 2026

I think if somebody managed to create symlink somewhere is not the problem we should address:

[src/Disks/DiskObjectStorage/ObjectStorages/Local/LocalObjectStorage.cpp:248] Path validation is lexical and does not resolve symlinks, so a path under user_files that traverses via symlink to an external directory can still pass validation.
Suggested fix: resolve using weakly_canonical (or secure openat/RESOLVE_BENEATH style traversal) before allowing file operations.

Why symlink is worse than true path?

UPD: added symlink resolution

auto combined = (norm_base / path).lexically_normal();
auto combined_canonical = std::filesystem::weakly_canonical(combined);

auto rel = combined_canonical.lexically_relative(norm_base);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ resolvePathRelativelyToBase canonicalizes combined, but compares it against non-canonical norm_base. If user_files_path (base) is configured through a symlink, valid paths under that directory resolve to a different canonical prefix and will be rejected with PATH_ACCESS_DENIED. Please canonicalize the base too (e.g. auto canonical_base = std::filesystem::weakly_canonical(norm_base);) and use canonical_base for both lexically_relative and pathStartsWith checks.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is very minor, but ok


auto rel = combined_canonical.lexically_relative(norm_base);

if ((rel.begin()->string() == "..") || !(pathStartsWith(combined_canonical, norm_base)))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolvePathRelativelyToBase dereferences rel.begin() without checking rel.empty(). For inputs where combined_canonical == norm_base (or equivalent), lexically_relative can be empty, and rel.begin()->string() is undefined behavior. Please guard emptiness before dereference (e.g. if (!rel.empty() && rel.begin()->string() == "..")), and keep the prefix check as the primary security check.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@clickhouse-gh clickhouse-gh bot added pr-critical-bugfix pr-must-backport Pull request should be backported intentionally. Use this label with great care! labels Mar 16, 2026
@arsenmuk arsenmuk self-assigned this Mar 16, 2026
@alsugiliazova
Copy link
Copy Markdown
Contributor

Cursor found one potential security gap after the current fixes.

LocalObjectStorage now canonicalizes and checks paths against user_files_path, but the check is still done separately from the actual filesystem operation. That leaves a TOCTOU window: a concurrent local actor can swap a symlink after validation and before open/unlink/metadata access, redirecting the operation outside the intended base path.

Impact

  • Potential unintended read/write/delete outside user_files_path under concurrent filesystem mutation.

Anchor

  • src/Disks/DiskObjectStorage/ObjectStorages/Local/LocalObjectStorage.cpp
  • resolvePathRelativelyToBase() + subsequent use in readObject(), writeObject(), removeObject(), getObjectMetadata(), tryGetObjectMetadata(), copyObject().

Why this is a defect

  • Canonical pre-check is not race-safe if path components can change before the actual syscall.
  • Security decision and object access are not bound to a single trusted fd-based traversal.

Fix direction

  • Use fd-based resolution/operation (e.g. openat2 with RESOLVE_BENEATH semantics, or equivalent trusted dirfd walk) so validation and use are atomic from a security perspective.

@divanik
Copy link
Copy Markdown
Member Author

divanik commented Mar 16, 2026

Thanks, @alsugiliazova, for sharing the report with us. We think that the risk of symlinks attacks is close to because it is already near impossible to manipulate with symlinks on a clickhouse-server.

@divanik
Copy link
Copy Markdown
Member Author

divanik commented Mar 16, 2026

Hm, I think that I will change the code though to use only resolved paths, it seemed better for me and it makes sense in a context of @alsugiliazova comment

@divanik divanik force-pushed the divanik/fix_local_object_storage_arbitrary_access_2 branch from c0330ee to 06c0c9c Compare March 16, 2026 14:17
}

dir = dir.parent_path();
fs::path(resolvePathRelativelyToKeyPrefix(fs::path(resolved_path).parent_path()));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dir is not updated here. This expression creates a temporary fs::path and immediately discards it, so the loop keeps using the same directory.

After the first successful rmdir, the next iteration calls rmdir on the same path again and usually throws CANNOT_RMDIR (ENOENT) during removeObject, so deletes can fail even on valid paths.

Please assign the parent path, e.g. dir = dir.parent_path();.

{
throw Exception(
ErrorCodes::PATH_ACCESS_DENIED,
"Explicit metadata file path `{}` should be in the table path directory : `{}`",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ The PATH_ACCESS_DENIED message is misleading here: this helper validates all local object paths (reads/writes/deletes), but the text says Explicit metadata file path and table path directory.

Please make this error text generic (for example, "Path {...} is outside the allowed base path {...}") so users can understand failures in non-metadata flows.

norm_base_canonical.string());
}

// We return canonical path to avoid TOCTOU attack
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

combined_canonical validation here is still vulnerable to a TOCTOU race: path resolution and file open happen in separate steps, so a symlink inside user_files can be swapped after this check and before readObject / writeObject open the path.

Because this PR is a security hardening change, the comment "avoid TOCTOU attack" is not correct with the current implementation and may provide a false safety signal. Please either:

  1. enforce beneath/no-symlink resolution at open time (e.g. openat2 with RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS on Linux, or equivalent fd-based walk), or
  2. at minimum, remove the TOCTOU claim and document that this only blocks lexical/canonical traversal but not concurrent path replacement.

manifest_list_path = snapshot["manifest-list"]

# 3. Download manifest-list (Avro) from container to read manifest path
manifest_list_local = tempfile.mktemp(suffix=".avro")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using tempfile.mktemp here introduces a TOCTOU race: the filename is generated first and opened later, so another process can create/replace that path in between. This can make the test flaky under parallel execution and is generally unsafe.

Please switch to creating the file atomically (tempfile.mkstemp/NamedTemporaryFile(delete=False)) for manifest_list_local, manifest_local, and patched_manifest_local.

#include <Disks/DiskObjectStorage/ObjectStorages/Local/LocalObjectStorage.h>

#include <filesystem>
#include <Interpreters/Context_fwd.h>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: is this include needed or injected by IDE?

Copy link
Copy Markdown
Member

@arsenmuk arsenmuk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall LGTM, with few minor comments

/// For clickhouse-local, allow access to any path (use root "/").
/// For server, restrict to user_files_path for security.
String path_prefix;
if (context->getApplicationType() == Context::ApplicationType::LOCAL)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we allowing any path to be accessed for ch-local? Should we perhaps totally restrict it down to user-files only for any scenario, like we did for Iceberg? If we can't do it because of the wicked amount of tests/supporting tools relying on such behaviour, I'd explicitly call this out in a comment somewhere here

auto rel_canonical = combined_canonical.lexically_relative(norm_base_canonical);

// Checking that both symbolic link and relative path traversal are not used to access files outside of the base path.
if ((!rel.empty() && rel.begin()->string() == "..") || !(pathStartsWith(combined, norm_base_canonical))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not much familiar with std::filesystem, so please ignore me if I'm confused.

I.
As std::fs::relative already performs weakly_canonical normalization inside and the base path is meant to be absolute, perhaps something like this would be a bit cleaner?

if (!fileOrSymlinkPathStartsWith(path, base_path) || !pathStartsWith(path, base_path))
          throw ....

      return fs::weakly_canonical(path).string();

II.
Certainly out of scope for this change, but since I dug into std::fs::relative I was surprised to learn that it throws on malformed paths. I don't think we need to do anything with it, just sharing.

void LocalObjectStorage::removeObject(const StoredObject & object) const
{
throwIfReadonly();
auto resolved_path = resolvePathRelativelyToKeyPrefix(object.remote_path);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we're calling the same resolvePathRelativelyToKeyPrefix at !exists(..) one line below. I am not a big fan of that, but happy to leave it as is


/// Remove empty directories.
fs::path dir = fs::path(object.remote_path).parent_path();
fs::path dir = fs::path(resolvePathRelativelyToKeyPrefix(fs::path(resolved_path).parent_path()));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need resolvePathRelativelyToKeyPrefix again here? Isn't resolved_path already resolved?

void LocalObjectStorage::removeObjectIfExists(const StoredObject & object)
{
throwIfReadonly();
resolvePathRelativelyToKeyPrefix(object.remote_path);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be missing some under-the-hood nuances, but couldn't we just call removeObject(..) without any checks? It seems doing all of them inside as well, from L153:

void LocalObjectStorage::removeObject(const StoredObject & object) const
{
    throwIfReadonly();
    auto resolved_path = resolvePathRelativelyToKeyPrefix(object.remote_path

    if (!exists(object))
        return;
   ....

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-critical-bugfix pr-must-backport Pull request should be backported intentionally. Use this label with great care!

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants