diff --git a/CHANGELOG.md b/CHANGELOG.md index d710795e..b1b8c14f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) ## [Unreleased] - XXXX-XX-XX +### Fixed + +- [#849](https://github.com/owncloud/user_ldap/pull/849) - Confine the LDAP home folder naming rule to the data directory + ## [0.20.2] - 2026-07-27 diff --git a/lib/User/UserEntry.php b/lib/User/UserEntry.php index 333f1a74..a6395f91 100644 --- a/lib/User/UserEntry.php +++ b/lib/User/UserEntry.php @@ -261,6 +261,7 @@ public function getEMailAddress() { * returns the home directory of the user if specified by LDAP settings * @return string|null * @throws \Exception if a naming rule attribute is enforced, but it doesn't exist for that LDAP user + * @throws \OutOfBoundsException if the configured attribute points outside of the permitted base directories */ public function getHome() { $path = ''; @@ -274,15 +275,53 @@ public function getHome() { } if ($path !== '') { + $dataDir = $this->config->getSystemValue( + 'datadirectory', + \OC::$SERVERROOT.'/data' + ); //if attribute's value is an absolute path take this, otherwise append it to data dir //check for / at the beginning if ($path[0] !== '/') { - $path = $this->config->getSystemValue( - 'datadirectory', - \OC::$SERVERROOT.'/data' - ) . '/' . $path; + $path = $dataDir . '/' . $path; } - return $path; + + // The value comes from the LDAP directory, which is not necessarily + // under the control of the ownCloud administrator. Left unchecked, a + // home pointing at the ownCloud code root turns the user's file + // listing into read/write access to the application's own PHP files, + // i.e. remote code execution. Confine it to the data directory, and + // to any additional base directories the admin opted into. + $path = self::normalizePath($path); + $baseDirs = $this->config->getSystemValue('user_ldap.home_base_dirs', []); + if (!\is_array($baseDirs)) { + $baseDirs = []; + } + \array_unshift($baseDirs, $dataDir); + + foreach ($baseDirs as $baseDir) { + // A base directory is only meaningful as an absolute path. Anything + // else is a misconfiguration and must not widen the comparison: '.' + // and './' would normalize to the empty string, which makes the + // containment check below accept every absolute path, and a relative + // value would resolve against the working directory of whichever + // process happens to run this check. + if (!\is_string($baseDir) || !isset($baseDir[0]) || $baseDir[0] !== '/') { + continue; + } + if (self::isContainedIn($path, self::normalizePath($baseDir))) { + return $path; + } + } + + $this->logger->error( + "Home dir <$path> for uid <{$this->getUserId()}> is outside of the data directory" . + " <$dataDir> and of any directory listed in the 'user_ldap.home_base_dirs' config" . + " option - refusing it", + ['app' => 'user_ldap'] + ); + throw new \OutOfBoundsException( + 'Home dir read from LDAP is not inside a permitted base directory for uid: ' . $this->getUserId() + ); } // TODO use OutOfBoundsException and https://github.com/owncloud/core/pull/28805 @@ -298,6 +337,92 @@ public function getHome() { return null; } + /** + * Resolve a path to a canonical form for comparison: symlinks are resolved on + * the longest leading part that exists on disk, the remainder is normalized + * lexically. realpath() alone cannot be used here because a home directory is + * created lazily on first login, so it usually does not exist yet at the time + * it is validated - but resolving the existing prefix keeps a symlink from + * pointing an apparently contained home somewhere else, and lets an admin have + * a symlinked data directory. + * + * Callers are responsible for passing an absolute path: a relative one would be + * resolved against the working directory of whichever process happens to run + * this, and '' and '.' normalize to the empty string. + * + * @param string $path an absolute path + * @return string the resolved path, without a trailing slash + */ + private static function normalizePath($path) { + $lexical = self::normalizePathLexically($path); + + $suffix = []; + $candidate = $lexical; + while ($candidate !== '' && $candidate !== '/') { + $real = \realpath($candidate); + if ($real !== false) { + return \rtrim($real, '/') . (empty($suffix) ? '' : '/' . \implode('/', $suffix)); + } + \array_unshift($suffix, \basename($candidate)); + $parent = \dirname($candidate); + if ($parent === $candidate) { + break; + } + $candidate = $parent; + } + + return $lexical; + } + + /** + * Resolve '.', '..' and duplicate slashes without touching the filesystem. + * + * @param string $path + * @return string the normalized path, without a trailing slash + */ + private static function normalizePathLexically($path) { + $isAbsolute = isset($path[0]) && $path[0] === '/'; + $parts = []; + foreach (\explode('/', $path) as $part) { + if ($part === '' || $part === '.') { + continue; + } + if ($part === '..') { + // a leading '..' cannot escape the root, mirroring the kernel + if (\end($parts) === '..') { + $parts[] = $part; + } elseif (\array_pop($parts) === null && !$isAbsolute) { + $parts[] = $part; + } + continue; + } + $parts[] = $part; + } + return ($isAbsolute ? '/' : '') . \implode('/', $parts); + } + + /** + * Whether $path is $baseDir itself or lies underneath it. Both arguments are + * expected to be normalized already. + * + * @param string $path + * @param string $baseDir + * @return bool + */ + private static function isContainedIn($path, $baseDir) { + // an empty base dir contains nothing - without this the comparison below + // degenerates into strpos($path, '/') === 0, accepting every absolute path + if ($baseDir === '') { + return false; + } + if ($path === $baseDir) { + return true; + } + // compare against the separator as well, so that a sibling directory + // sharing the base dir's prefix (/data-evil vs /data) is not accepted + return \strpos($path, \rtrim($baseDir, '/') . '/') === 0; + } + /** * @brief reads the image from LDAP that shall be used as Avatar * @return string|null image binary data (provided by LDAP) diff --git a/tests/unit/User/UserEntryTest.php b/tests/unit/User/UserEntryTest.php index 7bcaf82b..dc413fae 100644 --- a/tests/unit/User/UserEntryTest.php +++ b/tests/unit/User/UserEntryTest.php @@ -483,6 +483,14 @@ public function testGetEmailAddressUnset() { } public function testGetHomeAttributeWithAbsolutePath() { + $this->config->expects($this->any()) + ->method('getSystemValue') + ->willReturnCallback(function ($key, $default = '') { + if ($key === 'datadirectory') { + return '/absolute/path'; + } + return $default; + }); $this->connection->expects($this->once()) ->method('__get') ->with($this->equalTo('homeFolderNamingRule')) @@ -499,10 +507,307 @@ public function testGetHomeAttributeWithAbsolutePath() { self::assertEquals('/absolute/path/to/home', $userEntry->getHome()); } + /** + * The home attribute is controlled by the LDAP directory, not by the + * ownCloud admin. An absolute path outside of the data directory must be + * refused: pointing it at the ownCloud code root would expose - and allow + * overwriting - the application's own PHP files, which is remote code + * execution. + * + * @dataProvider providesHomeOutsideDataDir + */ + public function testGetHomeOutsideDataDirIsRefused($home) { + $this->expectException(\OutOfBoundsException::class); + + $this->config->expects($this->any()) + ->method('getSystemValue') + ->willReturnCallback(function ($key, $default = '') { + if ($key === 'datadirectory') { + return '/var/www/owncloud/data'; + } + if ($key === 'user_ldap.home_base_dirs') { + return []; + } + return $default; + }); + // the error path resolves the uid for the log message, which reads + // further config options + $this->connection->expects($this->any()) + ->method('__get') + ->willReturnCallback(function ($key) { + return $key === 'homeFolderNamingRule' ? 'attr:home' : 'mail'; + }); + $userEntry = new UserEntry( + $this->config, + $this->logger, + $this->connection, + [ + 'dn' => [0 => 'cn=foo,dc=foobar,dc=bar'], + 'mail' => [0 => 'a@b.c'], + 'home' => [0 => $home] + ] + ); + $userEntry->getHome(); + } + + public function providesHomeOutsideDataDir() { + return [ + // the reported vector: the ownCloud code root itself + 'application root' => ['/var/www/owncloud'], + 'apps directory' => ['/var/www/owncloud/apps'], + 'system directory' => ['/etc'], + // traversal through the relative branch, which is concatenated + // onto the data directory without any normalization + 'relative traversal' => ['../../../../etc'], + 'relative traversal to root' => ['..'], + 'traversal mid-path' => ['foo/../../../etc'], + // an absolute path can carry dot segments too - these resolve to a + // location outside of the data directory + '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/../.'], + // 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'], + ]; + } + + /** + * A symlink inside the data directory must not be usable to point a home that + * looks contained at a location outside of it. + */ + public function testGetHomeEscapingViaSymlinkIsRefused() { + $this->expectException(\OutOfBoundsException::class); + + $tmp = \realpath(\sys_get_temp_dir()) . '/oc-ldaphome-' . \uniqid(); + \mkdir($tmp . '/data', 0777, true); + \mkdir($tmp . '/apps', 0777, true); + \symlink($tmp . '/apps', $tmp . '/data/escape'); + + try { + $this->config->expects($this->any()) + ->method('getSystemValue') + ->willReturnCallback(function ($key, $default = '') use ($tmp) { + if ($key === 'datadirectory') { + return $tmp . '/data'; + } + if ($key === 'user_ldap.home_base_dirs') { + return []; + } + return $default; + }); + $this->connection->expects($this->any()) + ->method('__get') + ->willReturnCallback(function ($key) { + return $key === 'homeFolderNamingRule' ? 'attr:home' : 'mail'; + }); + $userEntry = new UserEntry( + $this->config, + $this->logger, + $this->connection, + [ + 'dn' => [0 => 'cn=foo,dc=foobar,dc=bar'], + 'mail' => [0 => 'a@b.c'], + 'home' => [0 => $tmp . '/data/escape'] + ] + ); + $userEntry->getHome(); + } finally { + \unlink($tmp . '/data/escape'); + \rmdir($tmp . '/apps'); + \rmdir($tmp . '/data'); + \rmdir($tmp); + } + } + + /** + * A symlinked data directory is a legitimate setup and must keep working: the + * home is compared after resolving both sides. + */ + public function testGetHomeUnderSymlinkedDataDirIsAccepted() { + $tmp = \realpath(\sys_get_temp_dir()) . '/oc-ldaphome-' . \uniqid(); + \mkdir($tmp . '/real-data', 0777, true); + \symlink($tmp . '/real-data', $tmp . '/data'); + + try { + $this->config->expects($this->any()) + ->method('getSystemValue') + ->willReturnCallback(function ($key, $default = '') use ($tmp) { + if ($key === 'datadirectory') { + return $tmp . '/data'; + } + if ($key === 'user_ldap.home_base_dirs') { + return []; + } + return $default; + }); + $this->connection->expects($this->once()) + ->method('__get') + ->with($this->equalTo('homeFolderNamingRule')) + ->will($this->returnValue('attr:home')); + $userEntry = new UserEntry( + $this->config, + $this->logger, + $this->connection, + [ + 'dn' => [0 => 'cn=foo,dc=foobar,dc=bar'], + 'home' => [0 => $tmp . '/data/alice'] + ] + ); + self::assertEquals($tmp . '/real-data/alice', $userEntry->getHome()); + } finally { + \unlink($tmp . '/data'); + \rmdir($tmp . '/real-data'); + \rmdir($tmp); + } + } + + /** + * Admins with home directories on a separate mount can opt back in by + * listing the permitted base directories explicitly. + */ + public function testGetHomeOutsideDataDirAllowedByConfig() { + $this->config->expects($this->any()) + ->method('getSystemValue') + ->willReturnCallback(function ($key, $default = '') { + if ($key === 'datadirectory') { + return '/var/www/owncloud/data'; + } + if ($key === 'user_ldap.home_base_dirs') { + return ['/mnt/nfs/homes']; + } + return $default; + }); + $this->connection->expects($this->once()) + ->method('__get') + ->with($this->equalTo('homeFolderNamingRule')) + ->will($this->returnValue('attr:home')); + $userEntry = new UserEntry( + $this->config, + $this->logger, + $this->connection, + [ + 'dn' => [0 => 'cn=foo,dc=foobar,dc=bar'], + 'home' => [0 => '/mnt/nfs/homes/alice'] + ] + ); + self::assertEquals('/mnt/nfs/homes/alice', $userEntry->getHome()); + } + + /** + * Dot segments that resolve back inside the data directory are fine - it is + * where the path lands that matters, not how it is spelled. + * + * @dataProvider providesHomeWithDotSegmentsInsideDataDir + */ + public function testGetHomeWithDotSegmentsInsideDataDirIsAccepted($home, $expected) { + $this->config->expects($this->any()) + ->method('getSystemValue') + ->willReturnCallback(function ($key, $default = '') { + if ($key === 'datadirectory') { + return '/var/www/owncloud/data'; + } + if ($key === 'user_ldap.home_base_dirs') { + return []; + } + return $default; + }); + $this->connection->expects($this->once()) + ->method('__get') + ->with($this->equalTo('homeFolderNamingRule')) + ->will($this->returnValue('attr:home')); + $userEntry = new UserEntry( + $this->config, + $this->logger, + $this->connection, + [ + 'dn' => [0 => 'cn=foo,dc=foobar,dc=bar'], + 'home' => [0 => $home] + ] + ); + self::assertEquals($expected, $userEntry->getHome()); + } + + public function providesHomeWithDotSegmentsInsideDataDir() { + return [ + 'dot segment' => ['/var/www/owncloud/data/./alice', '/var/www/owncloud/data/alice'], + 'traversal that returns' => ['/var/www/owncloud/data/foo/../alice', '/var/www/owncloud/data/alice'], + 'duplicate slashes' => ['/var/www/owncloud//data//alice', '/var/www/owncloud/data/alice'], + 'trailing slash' => ['/var/www/owncloud/data/alice/', '/var/www/owncloud/data/alice'], + 'the data directory itself' => ['/var/www/owncloud/data', '/var/www/owncloud/data'], + 'relative with dot segments' => ['./alice', '/var/www/owncloud/data/alice'], + ]; + } + + /** + * A base directory that is not usable as one must not widen the check. Values + * such as '.' or a relative path normalize to something unrelated to the + * intended directory - notably '.' normalizes to the empty string, which used + * to make the containment check accept every absolute path. + * + * @dataProvider providesUnusableBaseDirs + */ + public function testGetHomeIsRefusedForUnusableBaseDir($baseDir) { + $this->expectException(\OutOfBoundsException::class); + + $this->config->expects($this->any()) + ->method('getSystemValue') + ->willReturnCallback(function ($key, $default = '') use ($baseDir) { + if ($key === 'datadirectory') { + return '/var/www/owncloud/data'; + } + if ($key === 'user_ldap.home_base_dirs') { + return [$baseDir]; + } + return $default; + }); + // the error path resolves the uid for the log message, which reads + // further config options + $this->connection->expects($this->any()) + ->method('__get') + ->willReturnCallback(function ($key) { + return $key === 'homeFolderNamingRule' ? 'attr:home' : 'mail'; + }); + $userEntry = new UserEntry( + $this->config, + $this->logger, + $this->connection, + [ + 'dn' => [0 => 'cn=foo,dc=foobar,dc=bar'], + 'mail' => [0 => 'a@b.c'], + 'home' => [0 => '/var/www/owncloud/apps'] + ] + ); + $userEntry->getHome(); + } + + public function providesUnusableBaseDirs() { + return [ + 'empty' => [''], + // these normalize to the empty string + 'current directory' => ['.'], + 'current directory with slash' => ['./'], + 'dot segments only' => ['./.'], + // a relative base dir would resolve against the working directory of + // whichever process happens to run the check + 'relative' => ['data'], + 'relative with dot' => ['./data'], + 'parent' => ['..'], + 'not a string' => [null], + ]; + } + public function testGetHomeAttributeWithRelativePath() { - $this->config->expects($this->once()) + $this->config->expects($this->any()) ->method('getSystemValue') - ->will($this->returnValue('/path/to/data')); + ->willReturnCallback(function ($key, $default = '') { + if ($key === 'datadirectory') { + return '/path/to/data'; + } + return $default; + }); $this->connection->expects($this->once()) ->method('__get') ->with($this->equalTo('homeFolderNamingRule'))