fix(user): confine backend provided user homes to the data directory - #41752
Conversation
A user backend can supply a per user home directory. SyncService::syncHome() accepted whatever the backend returned after nothing more than a check for a leading slash, and persisted it on the account. Such a value is not necessarily under the control of the ownCloud administrator - the LDAP backend for instance can be configured to read the home from a per user attribute. A home pointing at the ownCloud code directory turns the user's file listing into read and write access to the application's own PHP files; writing a PHP file into a web reachable location, or modifying one of the shipped ones, results in remote code execution. Storage\Local::getSourcePath() looks like it would catch this, but it anchors its containment check to a data directory it derives from the very home it is validating, so it validates the path against itself. A backend provided home is now refused unless it resolves inside the configured `datadirectory`. Installations that legitimately keep user homes elsewhere, for instance on a separate NFS mount, can list the permitted base directories in the new `user.home_base_dirs` config option. The refusal is logged with the backend class, the offending path and the name of the config option. Symlinks are resolved on the longest leading part of the path that exists on disk before the comparison, because a home directory is created lazily on first login and usually does not exist yet when it is validated. That keeps a symlinked data directory working while preventing a symlink inside it from being used to escape. The containment check compares against the base directory plus a separator, so a sibling directory sharing the prefix (/data-evil vs /data) is not mistaken for being contained. syncHome() is the single point every backend's home passes through, so this covers all of them. The LDAP backend carries the matching check at its own entry point in owncloud/user_ldap#849. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
|
Thanks for opening this pull request! The maintainers of this repository would appreciate it if you would create a changelog item based on your changes. |
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
The sentence wrapped so that a line began with "- the application's own files.", which AsciiDoc renders as a list item once config-to-docs converts config.sample.php into the admin manual's config parameters page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
Carries the fix for the review feedback on owncloud/user_ldap#849 over to the core side of the containment check, which had the identical defect. verifyHomeLocation() skipped '' before normalizing, but '.', './' and './.' normalize *to* the empty string and so slipped through. isContainedIn() then compared against rtrim('', '/') . '/' == '/', i.e. strpos($path, '/') === 0, which is true for every absolute path - a single stray '.' in user.home_base_dirs silently disabled the containment check completely. The value is now required to be an absolute path before it is normalized. That covers '', '.', './', './.' and '..' in one condition, and additionally rules out a relative entry such as 'data', which realpath() would have resolved against the working directory of whichever process happened to run the check - for a home check that runs from both the web server and occ, that is not a stable answer. isContainedIn() also refuses an empty base dir in its own right, so the predicate cannot fail open if a future caller forgets to guard, and the normalizePath() docblock now states that passing an absolute path is the caller's responsibility. config.sample.php documents that entries must be absolute and that others are ignored. Verified with a negative control: reverting only the guard reproduces exactly the three '.'-shaped failures, restoring it turns them green. Also adds absolute-path traversal cases (/var/www/../../opt, /var/www/./owncloud/./apps, /.., ...). Those already passed - normalizePath() resolves dot segments before comparing - so they are coverage for behaviour that was already correct. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
Regenerated from core's config.sample.php after owncloud/core#41752 spelled out that a non-absolute entry in user.home_base_dirs is ignored - a relative one would otherwise resolve against the working directory of whichever process happens to run the containment check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
|
Pushed b2dd5c6 — a follow-up fix that came out of @jvillafanez's review on owncloud/user_ldap#849. The same defect was here, so it is fixed identically. What was wrong. if (!\is_string($baseDir) || $baseDir === '') {
continue;
}
if (self::isContainedIn($normalizedHome, self::normalizePath($baseDir))) {But What changed.
Verification. Negative control: reverting only the guard reproduces exactly the three 🤖 Generated with Claude Code |
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
…_dirs (#1573) * docs(admin_manual): document user home containment and user.home_base_dirs A user backend can supply a per user home directory - the LDAP backend for instance can be configured to read it from a user attribute via the "User Home Folder Naming Rule" setting. That value is now confined to the data directory, because an LDAP directory is not necessarily administered by the people who administer the ownCloud server (owncloud/core#41752, owncloud/user_ldap#849). Add a CAUTION to the "User Home Folder Naming Rule" item explaining the containment, what an affected user sees, how symlinks are treated, and how to permit additional base directories with "user_ldap.home_base_dirs" or "user.home_base_dirs". Existing installations whose homes legitimately live outside of the data directory need the setting before upgrading, so that is called out explicitly. Regenerate the config parameters page with owncloud/config-to-docs from the core branch's config.sample.php, which adds the "user.home_base_dirs" section. Verified the generator reproduces the current page byte for byte from the pre-change config.sample.php, so the diff is limited to the new option. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> * docs(admin_manual): note that home base dirs must be absolute paths Regenerated from core's config.sample.php after owncloud/core#41752 spelled out that a non-absolute entry in user.home_base_dirs is ignored - a relative one would otherwise resolve against the working directory of whichever process happens to run the containment check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --------- Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Description
A user backend can supply a per user home directory.
SyncService::syncHome()accepted whatever the backend returned after nothing more than a check for a
leading slash, and persisted it on the account. A home pointing at the ownCloud
code directory turns the user's file listing into read and write access to the
application's own PHP files — remote code execution.
This adds containment at
syncHome(), the single point every backend's homepasses through, so all home-providing backends are covered.
Reported internally as OC10-133 (against the LDAP backend, fixed at its own
entry point in owncloud/user_ldap#849 — this PR is the defense in depth behind it).
Root cause
Any absolute path the backend returns is taken verbatim.
Storage\Local::getSourcePath()looks like it would catch an escape, but it anchors its containment check to a
realDataDirit derives from the very home it is validating — it validates thepath against itself.
A
files/offset applies (Root::getUserFolder()), so the code root is not listeddirectly; RCE still lands via the
files/directory created inside the target, orby aiming at
/var/www/owncloud/apps, where creating a new app directory suffices.Fix
Refuse a backend provided home unless it resolves inside the configured
datadirectory. Installs that legitimately keep homes elsewhere (separate NFSmount, etc.) list the permitted base directories in the new
user.home_base_dirsconfig option, documented in
config/config.sample.php. The refusal logs thebackend class, the offending path and the option name.
Two details worth review attention:
disk.
realpath()alone will not do — a home is created lazily on first login,so it usually does not exist yet at validation time. Resolving the existing
prefix keeps a symlinked data directory working (a legitimate setup) while
stopping a symlink inside the data directory from being used to escape.
so
/data-evilis not treated as contained in/data.Related Issue
Motivation and Context
The value a backend returns is not necessarily under the ownCloud
administrator's control; with LDAP it can come from a per user attribute in a
directory administered by someone else entirely. Validating it at the sync
chokepoint means every present and future home-providing backend inherits the
guarantee, rather than each having to remember it.
How Has This Been Tested?
tests/lib/User/SyncServiceTest.php:testSyncHomeRefusesHomeOutsideDataDir(5 cases — app root, apps dir,
/etc, traversal, prefix sibling),testSyncHomeRefusesHomeEscapingViaSymlink,testSyncHomeAcceptsHomeUnderSymlinkedDataDir,testSyncHomeAcceptsHomeInsideDataDir,testSyncHomeAcceptsHomeInConfiguredBaseDir.tests/lib/User/green: 257 tests, 10216 assertions, OK.SyncService.phpreverted and the new tests kept, 6 ofthem fail (the malicious home reaches
setHome()); restoring the fix turns themgreen. The tests demonstrably exercise the fix.
php-cs-fixer(owncloud codestyle) andphp -lclean on all changed files.wired together.
Types of changes
An installation whose backend provided homes legitimately resolve outside
datadirectorywill refuse those logins after upgrading untiluser.home_base_dirslists the base directory. This needs to reach admins in therelease notes. The logged error names both the refused path and the option, so
affected installs are diagnosable.
Known limitation (follow-up, not in this PR)
syncHome()only sets a home when none is stored yet — by design, so a synccannot relocate an existing user's files. Consequently accounts provisioned
before this fix keep the already-persisted malicious home in the
accountstableand remain exploitable until the row is corrected. Fixing that means touching
existing data, which does not belong in this change. Tracked as OC10-139, which will add a
read-only
occcommand reporting accounts whose stored home falls outside thepermitted base directories.
Checklist:
🤖 Generated with Claude Code