From f59e004dac8738120ef2558a5bff8776820d9a8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:23:32 +0200 Subject: [PATCH 1/3] fix(files/storage): add SSRF host validation to DAV storage constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DAV storage class accepted arbitrary host values (including loopback, link-local 169.254.x.x, RFC-1918 private ranges, and IPv6 equivalents) without validation. When user external storage mounting is enabled, any authenticated user could force the server to make HTTP requests to cloud metadata endpoints, localhost services, or internal network hosts. Adds validateHost() called immediately after scheme-stripping so all downstream HTTP sinks (Guzzle GET/PUT and Sabre DAV Client) are covered. Signed-off-by: Thomas Müller Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- lib/private/Files/Storage/DAV.php | 69 +++++++++++++++++++++++++ tests/lib/Files/Storage/DavTest.php | 80 +++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/lib/private/Files/Storage/DAV.php b/lib/private/Files/Storage/DAV.php index 71dbea5ea04c..a8a404dc0341 100644 --- a/lib/private/Files/Storage/DAV.php +++ b/lib/private/Files/Storage/DAV.php @@ -100,6 +100,7 @@ public function __construct($params) { } elseif (\substr($host, 0, 7) == "http://") { $host = \substr($host, 7); } + $this->validateHost($host); $this->host = $host; $this->user = $params['user']; $this->password = $params['password']; @@ -127,6 +128,74 @@ public function __construct($params) { } } + /** + * Validate that the host does not point to a private, loopback, or + * link-local address to prevent Server-Side Request Forgery (SSRF). + * + * The host string has already had any http:// / https:// prefix stripped + * and may contain a port (host:port) or a path component (host/path). + * + * @param string $host + * @throws \InvalidArgumentException when the host resolves to a blocked range + */ + protected function validateHost($host) { + // A bare IPv6 address (e.g. "::1", "fe80::1") contains colons but no + // brackets, so parse_url cannot handle it as a URL host component. + // Detect this early and validate directly. + if (\strpos($host, ':') !== false && \strpos($host, '[') === false) { + // Strip any trailing path component, e.g. "::1/some/path" + $ipv6 = \explode('/', $host, 2)[0]; + if (\filter_var($ipv6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) { + $this->checkIpNotBlocked($ipv6); + return; + } + } + + // Reconstruct a full URL so parse_url can reliably extract the hostname + // for both IPv4 literals and hostnames (including bracket-enclosed IPv6). + $parsed = \parse_url('http://' . $host); + $hostname = isset($parsed['host']) ? $parsed['host'] : $host; + + // Strip IPv6 brackets, e.g. [::1] -> ::1 + $hostname = \trim($hostname, '[]'); + + if ($hostname === '') { + throw new \InvalidArgumentException('Invalid webdav storage configuration: empty host'); + } + + // Block loopback / link-local / private IP literals for both IPv4 and IPv6. + if (\filter_var($hostname, FILTER_VALIDATE_IP) !== false) { + $this->checkIpNotBlocked($hostname); + return; + } + + // Block localhost by name (covers localhost, localhost.localdomain, etc.). + if (\preg_match('/^localhost(\..*)?$/i', $hostname)) { + throw new \InvalidArgumentException( + 'WebDAV host points to a blocked IP address range' + ); + } + } + + /** + * Throws an InvalidArgumentException if the given validated IP address + * falls into a private, loopback, link-local, or reserved range. + * + * @param string $ip a value already confirmed to be a valid IP address + * @throws \InvalidArgumentException + */ + private function checkIpNotBlocked($ip) { + if (\filter_var( + $ip, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + ) === false) { + throw new \InvalidArgumentException( + 'WebDAV host points to a blocked IP address range' + ); + } + } + protected function init() { if ($this->ready) { return; diff --git a/tests/lib/Files/Storage/DavTest.php b/tests/lib/Files/Storage/DavTest.php index 03dfd7efde40..c16716de2319 100644 --- a/tests/lib/Files/Storage/DavTest.php +++ b/tests/lib/Files/Storage/DavTest.php @@ -192,6 +192,86 @@ public function testInstantiateWebDavClientInvalidConfig($params) { new \OC\Files\Storage\DAV($params); } + /** + * Hosts that must be rejected to prevent SSRF. + */ + public function ssrfBlockedHostDataProvider() { + return [ + // IPv4 loopback + ['127.0.0.1'], + ['127.0.0.1:9200'], + // IPv4 link-local (AWS/GCP metadata) + ['169.254.169.254'], + ['169.254.169.254/latest/meta-data/'], + // Private RFC-1918 ranges + ['10.0.0.1'], + ['10.255.255.255'], + ['172.16.0.1'], + ['172.31.255.255'], + ['192.168.0.1'], + ['192.168.1.100:8080'], + // IPv6 loopback + ['::1'], + ['[::1]'], + ['[::1]:8080'], + // IPv6 link-local + ['fe80::1'], + ['[fe80::1]'], + // IPv6 private (ULA) + ['fc00::1'], + ['fd00::1'], + // localhost by name + ['localhost'], + ['localhost:6379'], + ['localhost.localdomain'], + // Scheme-prefixed variants (stripping happens before validation) + ['http://127.0.0.1'], + ['https://169.254.169.254'], + ]; + } + + /** + * @dataProvider ssrfBlockedHostDataProvider + */ + public function testSsrfBlockedHostThrows($host) { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/blocked/i'); + + new \OC\Files\Storage\DAV([ + 'user' => 'davuser', + 'password' => 'davpassword', + 'host' => $host, + ]); + } + + /** + * Hosts that must be allowed (public/routable addresses). + */ + public function ssrfAllowedHostDataProvider() { + return [ + ['example.com'], + ['webdav.example.org'], + ['webdav.example.org:8080'], + ['8.8.8.8'], + ['2001:db8::1'], + ['[2001:db8::1]'], + ['[2001:db8::1]:8080'], + ]; + } + + /** + * @dataProvider ssrfAllowedHostDataProvider + */ + public function testSsrfAllowedHostDoesNotThrow($host) { + // Should not throw; we don't need a real connection here + $instance = new \OC\Files\Storage\DAV([ + 'user' => 'davuser', + 'password' => 'davpassword', + 'host' => $host, + ]); + $this->assertInstanceOf(\OC\Files\Storage\DAV::class, $instance); + } + private function createClientHttpException($statusCode) { $response = $this->createMock(\Sabre\HTTP\ResponseInterface::class); $response->method('getStatusText')->willReturn(''); From c1ff5273a9e82595f0b51f0995498677fa222a17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:58:57 +0200 Subject: [PATCH 2/3] chore: add changelog entry for #41576 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- changelog/unreleased/41576 | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 changelog/unreleased/41576 diff --git a/changelog/unreleased/41576 b/changelog/unreleased/41576 new file mode 100644 index 000000000000..49410458411e --- /dev/null +++ b/changelog/unreleased/41576 @@ -0,0 +1,10 @@ +Security: Block SSRF host targets in DAV storage constructor + +The DAV storage class accepted arbitrary host values without validating +against private IP ranges, loopback addresses, or link-local ranges such +as 169.254.x.x. When user external storage mounting was enabled, an +authenticated user could force outbound HTTP requests to cloud metadata +endpoints or internal services. Host validation now blocks RFC-1918, +loopback, link-local, and equivalent IPv6 ranges. + +https://github.com/owncloud/core/pull/41576 From 33fd316f535781655b52a41f004caa0c05519294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:19:46 +0200 Subject: [PATCH 3/3] fix(test): replace localhost with routable hostname in ManagerTest to pass SSRF validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- apps/files_sharing/tests/External/ManagerTest.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/files_sharing/tests/External/ManagerTest.php b/apps/files_sharing/tests/External/ManagerTest.php index 3e8f285aabfe..fbbddc18c71e 100644 --- a/apps/files_sharing/tests/External/ManagerTest.php +++ b/apps/files_sharing/tests/External/ManagerTest.php @@ -102,7 +102,7 @@ private function setupMounts(): void { public function testAddShare(): void { $shareData1 = [ - 'remote' => 'http://localhost', + 'remote' => 'https://oc-federation-test.example.com', 'token' => 'token1', 'password' => '', 'name' => '/SharedFolder', @@ -164,7 +164,7 @@ function (GenericEvent $e) use ($openShares) { if ($e->getArgument('shareAcceptedFrom') !== 'foobar') { return false; } - if ($e->getArgument('remoteUrl') !== 'http://localhost') { + if ($e->getArgument('remoteUrl') !== 'https://oc-federation-test.example.com') { return false; } if ($e->getArgument('shareId') !== $openShares[0]['id']) { @@ -216,7 +216,7 @@ function (GenericEvent $e) use ($openShares) { [ 'sharedItem' => '/SharedFolder', 'shareAcceptedFrom' => 'foobar', - 'remoteUrl' => 'http://localhost', + 'remoteUrl' => 'https://oc-federation-test.example.com', ] ); @@ -290,7 +290,7 @@ function ($event) use ($acceptedShares) { public function testAddShareAccepted(): void { $shareData1 = [ - 'remote' => 'http://localhost', + 'remote' => 'https://oc-federation-test.example.com', 'token' => 'token1', 'password' => '', 'name' => '/SharedFolder', @@ -357,7 +357,7 @@ private function getFullPath($path): string { public function testRemoveShare(): void { /*$shareData1 = [ - 'remote' => 'http://localhost', + 'remote' => 'https://oc-federation-test.example.com', 'token' => 'token1', 'password' => '', 'name' => '/SharedFolder',