Skip to content

fix(user): confine backend provided user homes to the data directory - #41752

Merged
DeepDiver1975 merged 5 commits into
masterfrom
fix/oc10-133-confine-backend-user-home
Jul 28, 2026
Merged

fix(user): confine backend provided user homes to the data directory#41752
DeepDiver1975 merged 5 commits into
masterfrom
fix/oc10-133-confine-backend-user-home

Conversation

@DeepDiver1975

@DeepDiver1975 DeepDiver1975 commented Jul 28, 2026

Copy link
Copy Markdown
Member

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 home
passes 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

if (!\is_string($home) || $home[0] !== '/') {
    $home = $dataDir . "/$uid";   // fallback
}
// else: accepted as-is and stored on the account

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
realDataDir it derives from the very home it is validating — it validates the
path against itself.

A files/ offset applies (Root::getUserFolder()), so the code root is not listed
directly; RCE still lands via the files/ directory created inside the target, or
by 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 NFS
mount, etc.) list the permitted base directories in the new user.home_base_dirs
config option, documented in config/config.sample.php. The refusal logs the
backend class, the offending path and the option name.

Two details worth review attention:

  • 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.

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?

  • New tests in 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.
  • Negative control: with SyncService.php reverted and the new tests kept, 6 of
    them fail (the malicious home reaches setHome()); restoring the fix turns them
    green. The tests demonstrably exercise the fix.
  • php-cs-fixer (owncloud codestyle) and php -l clean on all changed files.
  • Cross-checked against the user_ldap branch (259 tests OK) with both worktrees
    wired together.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Security fix

⚠️ Note on the breaking change

An installation whose backend provided homes legitimately resolve outside
datadirectory will refuse those logins after upgrading until
user.home_base_dirs lists the base directory. This needs to reach admins in the
release 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 sync
cannot relocate an existing user's files. Consequently accounts provisioned
before this fix keep the already-persisted malicious home in the accounts table
and 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 occ command reporting accounts whose stored home falls outside the
permitted base directories.

Checklist:

🤖 Generated with Claude Code

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>
@DeepDiver1975
DeepDiver1975 requested a review from a team as a code owner July 28, 2026 12:54
@update-docs

update-docs Bot commented Jul 28, 2026

Copy link
Copy Markdown

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>
DeepDiver1975 added a commit to owncloud/docs-server that referenced this pull request Jul 28, 2026
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>
@DeepDiver1975

Copy link
Copy Markdown
Member Author

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. verifyHomeLocation() skipped an unusable base dir before normalizing it:

if (!\is_string($baseDir) || $baseDir === '') {
    continue;
}
if (self::isContainedIn($normalizedHome, self::normalizePath($baseDir))) {

But '.', './' and './.' normalize to the empty string, so they slipped past that guard. isContainedIn() then evaluated strpos($path, rtrim('', '/') . '/') === 0, i.e. strpos($path, '/') === 0 — true for every absolute path. A single stray '.' in user.home_base_dirs silently disabled the whole containment check this PR adds, with no error and no log line.

What changed.

  • The base dir must now be an absolute path before it is normalized. One condition covers '', '.', './', './.', '..' and non-strings, and additionally rules out a relative entry like 'data'realpath() would have resolved that against the CWD of whichever process ran the check, so the web server and occ could disagree about whether a home is permitted.
  • isContainedIn() refuses an empty $baseDir in its own right, so the predicate can't fail open if a future caller forgets to guard. Worth noting the asymmetry that made this dangerous: empty in the first argument is fail-safe (false), empty in the second was fail-open (true).
  • normalizePath()'s docblock states that passing an absolute path is the caller's responsibility, and config.sample.php documents that non-absolute entries in user.home_base_dirs are ignored (docs regenerated in docs(admin_manual): document user home containment and user.home_base_dirs docs-server#1573).

Verification. Negative control: reverting only the guard reproduces exactly the three '.'-shaped failures and nothing else; restoring it turns them green. tests/lib/User/ is 270 tests / 10242 assertions OK (was 257). New test testSyncHomeRefusesUnusableBaseDir (8 cases) asserts setHome() is never called on the refused path, plus 5 absolute-traversal cases added to providesHomeOutsideDataDir. php-cs-fixer and php -l clean.

🤖 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>
@DeepDiver1975
DeepDiver1975 merged commit 756d49b into master Jul 28, 2026
29 checks passed
@DeepDiver1975
DeepDiver1975 deleted the fix/oc10-133-confine-backend-user-home branch July 28, 2026 15:35
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