fix(user): confine the home folder naming rule to the data directory - #849
Conversation
The "User Home Folder Naming Rule" setting can point the per user home at an LDAP attribute (`attr:homeDirectory`). UserEntry::getHome() returned that attribute value almost verbatim: an absolute path was accepted after nothing more than a check for a leading slash, and a relative one was concatenated onto the data directory without normalization, so a value containing ".." escaped it. The LDAP directory is not necessarily under the control of the ownCloud administrator. 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. The home read from LDAP is now rejected 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_ldap.home_base_dirs` config option. The refusal is logged with both 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. ownCloud core carries the matching defense-in-depth check in SyncService::syncHome(), which covers every user backend that supplies a home. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
| * @return string the normalized path, without a trailing slash | ||
| */ | ||
| private static function normalizePathLexically($path) { | ||
| $isAbsolute = isset($path[0]) && $path[0] === '/'; |
There was a problem hiding this comment.
Although the $path won't be empty in this case, it seems better to either ensure it won't happen here, or document that is the caller's responsibility to ensure that the $path isn't empty.
There was a problem hiding this comment.
Good instinct — following it up turned out to find a real bug, not just a missing doc line.
You're right that $path (the home) can't be empty here: it is guarded absolute before normalizePath() is reached. But the other argument was not. getHome() skipped an empty base dir like this:
if (!\is_string($baseDir) || $baseDir === '') {
continue;
}
if (self::isContainedIn($path, self::normalizePath($baseDir))) {The guard ran before normalization — and ., ./ and ./. normalize to the empty string, so they slipped past it. isContainedIn() then computed:
\strpos($path, \rtrim('', '/') . '/') === 0 // strpos($path, '/') === 0which is true for every absolute path. A single stray '.' in user_ldap.home_base_dirs silently disabled the containment check completely.
It's also worth noting that an empty value is asymmetric — which is exactly why your comment was worth chasing:
isContainedIn('', '/var/www/owncloud/data') = false <- fail-safe
isContainedIn('/etc', '') = true <- fail-OPEN
Harmless in the first argument, disabling in the second.
Fixed in 1f7034b by requiring the base dir to be an absolute path before normalizing it. One condition covers '', '.', './', './.', '..' and non-strings, and it additionally rules out a relative entry such as 'data' — realpath() would have resolved that against the working directory of whichever process ran the check, so the answer would differ between the web server and occ.
Then both halves of what you asked for:
- Ensured it won't happen:
isContainedIn()now returnsfalsefor an empty$baseDirin its own right, so the predicate can't fail open even if a future caller forgets to guard. - Documented the responsibility: both
normalizePath()docblocks now state that passing an absolute path is the caller's job, and why.
Verified with a negative control — reverting only the guard reproduces exactly the three '.'-shaped failures and nothing else; restoring it turns them green. owncloud/core#41752 had the identical defect in SyncService::verifyHomeLocation() and is fixed the same way.
New tests: testGetHomeIsRefusedForUnusableBaseDir (8 cases).
| 'traversal mid-path' => ['foo/../../../etc'], | ||
| // a sibling directory that merely shares the data dir's prefix | ||
| // must not pass a naive string comparison | ||
| 'prefix sibling' => ['/var/www/owncloud/data-evil'], |
There was a problem hiding this comment.
I miss some tests for /var/www/../../opt or similar (absolute path with .. in the middle).
Also something like /var/www/./owncloud
There was a problem hiding this comment.
Added in 1f7034b. To be straight about the result: I checked your exact inputs against the real helpers before writing the tests, and they already resolved correctly — normalizePath() resolves dot segments before comparing, so /var/www/../../opt → /opt and /var/www/./owncloud → /var/www/owncloud, both refused. So this is coverage for behaviour that was already right, not a bug fix. Still worth having — nothing was pinning it.
Five cases added to providesHomeOutsideDataDir:
'absolute traversal mid-path' => ['/var/www/../../opt'],
'absolute traversal out of data dir' => ['/var/www/owncloud/data/../../apps'],
'absolute traversal with dot segments' => ['/var/www/./owncloud/./apps'],
'absolute traversal to root' => ['/..'],
'absolute dot segments to code root' => ['/var/www/owncloud/data/../.'],I also added a companion provider for the other direction (testGetHomeWithDotSegmentsInsideDataDirIsAccepted), so the suite isn't only proving that things get rejected — a check that rejected everything would have passed the old provider. It asserts that dot segments landing back inside the data directory are still accepted and normalize as expected: /var/www/owncloud/data/./alice, .../foo/../alice, //data//alice, a trailing slash, the data directory itself, and the relative ./alice.
UserEntryTest is now 76 tests / 125 assertions, green.
Chasing your other comment did turn up a real fail-open defect — see the reply on UserEntry.php.
Addresses review feedback on #849 about the empty path contract. The base dir loop 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_ldap.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. 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 both normalizePath() docblocks now state that passing an absolute path is the caller's responsibility. Verified with a negative control: reverting only the guard reproduces exactly the three '.'-shaped failures, restoring it turns them green. Also adds the absolute-path traversal cases the review asked for (/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, alongside a companion provider asserting that dot segments landing back inside the data directory are still accepted. 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>
…41752) * fix(user): confine backend provided user homes to the data directory 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> * chore: add changelog entry for #41752 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> * docs(config): reword the user.home_base_dirs sample comment 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> * fix(user): refuse a base directory that is not an absolute path 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> * chore: note the absolute-path requirement in the changelog entry 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>
…_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>
Summary
The LDAP "User Home Folder Naming Rule" setting can name a per user home from an
LDAP attribute (
attr:homeDirectory). That value was accepted essentiallyverbatim, so an LDAP-controlled home pointing at the ownCloud code directory gave
the user read and write access to the application's own PHP files — remote code
execution. This PR confines the home to the data directory.
Reported internally as OC10-133.
Root cause
UserEntry::getHome()(lib/User/UserEntry.php) validated the attribute valuewith nothing more than a check for a leading slash:
Two ways through:
/var/www/owncloud/appspasses the[0] !== '/'check untouchedand becomes the user's home.
../../appsescapes it.The value is then persisted on the account and used to construct the user's
Storage\Home.Local::getSourcePath()looks like it would catch this, but itanchors its containment check to a
realDataDirderived from the home it isvalidating — it effectively validates the path against itself.
Note a
files/offset applies (Root::getUserFolder()), so the code root is notlisted directly; RCE still lands via the
files/directory that gets createdinside the target, or by aiming at
/var/www/owncloud/appswhere writing a newapp directory is enough.
Fix
Reject a home that does not resolve inside the configured
datadirectory.Installations that legitimately keep homes elsewhere (a separate NFS mount, say)
list the permitted base directories in the new
user_ldap.home_base_dirsconfig option. The refusal logs both the offending path and the option name.
Two details worth calling out:
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.
separator, so
/data-evilis not treated as contained in/data.Testing
New tests in
tests/unit/User/UserEntryTest.php:testGetHomeOutsideDataDirIsRefused— 7 cases (app root, apps dir,/etc,traversal, prefix sibling, …)
testGetHomeEscapingViaSymlinkIsRefused— real symlink on disktestGetHomeUnderSymlinkedDataDirIsAccepted— the legitimate setup keeps workingtestGetHomeOutsideDataDirAllowedByConfig— the opt-out is honouredTwo pre-existing tests (
testGetHomeAttributeWithAbsolutePath/…WithRelativePath) needed theirgetSystemValuemocks relaxed, since the fixreads a second config key.
Full app unit suite green: 259 tests, 544 assertions, OK.
php-cs-fixerandphp -lclean on both changed files.An installation whose LDAP homes legitimately resolve outside
datadirectorywill refuse those logins after upgrading until
user_ldap.home_base_dirs(orcore's
user.home_base_dirs) lists the base directory. The error naming the pathand the option is written to the log, so affected installs are diagnosable.
Known limitation (follow-up)
Accounts provisioned before this fix keep the already-persisted home in the
accountstable: core'ssyncHome()only sets a home when none is stored, sosuch accounts stay exploitable until the row is corrected. Deliberately out of
scope here; tracked as OC10-139, which will add a read-only
occreport foraccounts whose stored home falls outside the permitted base directories.
Related
fix(user): confine backend provided user homes to the data directory core#41752
🤖 Generated with Claude Code