Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions apps/files_sharing/tests/External/ManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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']) {
Expand Down Expand Up @@ -216,7 +216,7 @@ function (GenericEvent $e) use ($openShares) {
[
'sharedItem' => '/SharedFolder',
'shareAcceptedFrom' => 'foobar',
'remoteUrl' => 'http://localhost',
'remoteUrl' => 'https://oc-federation-test.example.com',
]
);

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
10 changes: 10 additions & 0 deletions changelog/unreleased/41576
Original file line number Diff line number Diff line change
@@ -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
69 changes: 69 additions & 0 deletions lib/private/Files/Storage/DAV.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -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;
Expand Down
80 changes: 80 additions & 0 deletions tests/lib/Files/Storage/DavTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand Down