Restrict local object access only for user files path#99405
Restrict local object access only for user files path#99405
Conversation
|
Workflow [PR], commit [8addb8b] Summary: ❌
|
src/Disks/DiskLocal.cpp
Outdated
| 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)) |
There was a problem hiding this comment.
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.
|
|
||
| void LocalObjectStorage::throwIfPathAccessDenied(const String & path) const | ||
| { | ||
| auto norm_base = std::filesystem::path(settings.key_prefix).lexically_normal(); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I think this is ok. Our mental model - allow to access everything which is inside user_files_path
There was a problem hiding this comment.
Ok, decided to verify symlinks as well
src/Disks/DiskLocal.cpp
Outdated
| ObjectStoragePtr DiskLocal::getObjectStorage() | ||
| { | ||
| LocalObjectStorageSettings settings_object_storage(name, disk_path, /* read_only */false); | ||
| auto application_type = Context::getGlobalContextInstance()->getApplicationType(); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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)
|
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. 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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
It is very minor, but ok
|
|
||
| auto rel = combined_canonical.lexically_relative(norm_base); | ||
|
|
||
| if ((rel.begin()->string() == "..") || !(pathStartsWith(combined_canonical, norm_base))) |
There was a problem hiding this comment.
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.
…k/fix_local_object_storage_arbitrary_access_2
|
Cursor found one potential security gap after the current fixes.
Impact
Anchor
Why this is a defect
Fix direction
|
|
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. |
|
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 |
c0330ee to
06c0c9c
Compare
| } | ||
|
|
||
| dir = dir.parent_path(); | ||
| fs::path(resolvePathRelativelyToKeyPrefix(fs::path(resolved_path).parent_path())); |
There was a problem hiding this comment.
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 : `{}`", |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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:
- enforce beneath/no-symlink resolution at open time (e.g.
openat2withRESOLVE_BENEATH|RESOLVE_NO_SYMLINKSon Linux, or equivalent fd-based walk), or - 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") |
There was a problem hiding this comment.
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> |
There was a problem hiding this comment.
nit: is this include needed or injected by IDE?
arsenmuk
left a comment
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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())); |
There was a problem hiding this comment.
Why do we need resolvePathRelativelyToKeyPrefix again here? Isn't resolved_path already resolved?
| void LocalObjectStorage::removeObjectIfExists(const StoredObject & object) | ||
| { | ||
| throwIfReadonly(); | ||
| resolvePathRelativelyToKeyPrefix(object.remote_path); |
There was a problem hiding this comment.
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;
....
Changelog category (leave one):
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