Skip to content

fix(user): confine the home folder naming rule to the data directory - #849

Merged
DeepDiver1975 merged 3 commits into
masterfrom
fix/oc10-133-confine-home-folder-naming-rule
Jul 28, 2026
Merged

fix(user): confine the home folder naming rule to the data directory#849
DeepDiver1975 merged 3 commits into
masterfrom
fix/oc10-133-confine-home-folder-naming-rule

Conversation

@DeepDiver1975

@DeepDiver1975 DeepDiver1975 commented Jul 28, 2026

Copy link
Copy Markdown
Member

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 essentially
verbatim, 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 value
with nothing more than a check for a leading slash:

if ($path[0] !== '/') {
    $path = $dataDir . '/' . $path;
}
// ... returned as-is

Two ways through:

  • Absolute/var/www/owncloud/apps passes the [0] !== '/' check untouched
    and becomes the user's home.
  • Relative — concatenated onto the data directory with no normalization, so
    ../../apps escapes 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 it
anchors its containment check to a realDataDir derived from the home it is
validating
— it effectively validates the path against itself.

Note a files/ offset applies (Root::getUserFolder()), so the code root is not
listed directly; RCE still lands via the files/ directory that gets created
inside the target, or by aiming at /var/www/owncloud/apps where writing a new
app 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_dirs
config option. The refusal logs both the offending path and the option name.

Two details worth calling out:

  • Symlinks are resolved on the longest leading part of the path that exists on
    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.
  • Prefix siblings are rejected: the comparison includes the trailing
    separator, so /data-evil is 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 disk
  • testGetHomeUnderSymlinkedDataDirIsAccepted — the legitimate setup keeps working
  • testGetHomeOutsideDataDirAllowedByConfig — the opt-out is honoured

Two pre-existing tests (testGetHomeAttributeWithAbsolutePath /
…WithRelativePath) needed their getSystemValue mocks relaxed, since the fix
reads a second config key.

Full app unit suite green: 259 tests, 544 assertions, OK.
php-cs-fixer and php -l clean on both changed files.

⚠️ Breaking change

An installation whose LDAP homes legitimately resolve outside datadirectory
will refuse those logins after upgrading until user_ldap.home_base_dirs (or
core's user.home_base_dirs) lists the base directory. The error naming the path
and 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
accounts table: core's syncHome() only sets a home when none is stored, so
such accounts stay exploitable until the row is corrected. Deliberately out of
scope here; tracked as OC10-139, which will add a read-only occ report for
accounts whose stored home falls outside the permitted base directories.

Related

🤖 Generated with Claude Code

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>
@DeepDiver1975
DeepDiver1975 requested a review from a team as a code owner July 28, 2026 12:52
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
Comment thread lib/User/UserEntry.php
* @return string the normalized path, without a trailing slash
*/
private static function normalizePathLexically($path) {
$isAbsolute = isset($path[0]) && $path[0] === '/';

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.

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.

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.

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, '/') === 0

which 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 returns false for an empty $baseDir in 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'],

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 miss some tests for /var/www/../../opt or similar (absolute path with .. in the middle).
Also something like /var/www/./owncloud

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.

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>
DeepDiver1975 added a commit to owncloud/core that referenced this pull request Jul 28, 2026
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>
@DeepDiver1975
DeepDiver1975 merged commit 9b82a09 into master Jul 28, 2026
11 checks passed
@DeepDiver1975
DeepDiver1975 deleted the fix/oc10-133-confine-home-folder-naming-rule branch July 28, 2026 15:34
DeepDiver1975 added a commit to owncloud/core that referenced this pull request Jul 28, 2026
…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>
DeepDiver1975 added a commit to owncloud/docs-server that referenced this pull request Jul 28, 2026
…_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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants