fix(dav): Do not respond version/trashbin download requests with 404 due to ChunkingV2Plugin

ChunkingV2Plugin::beforeGet eagerly resolves the request path during
beforeMethod:GET to block reading intermediate chunked uploads. App-provided
DAV collections (versions, trashbin) are attached to the root lazily in a
beforeMethod:* closure in Server.php, while uploads is registered eagerly. When
beforeGet runs before that closure, getNodeForPath() throws NotFound and aborts
the whole request, turning every GET under /dav/versions/ and /dav/trashbin/
into "404 File not found: versions in 'root'". PROPFIND is unaffected, since
nothing resolves the path that early for it.

This does not currently surface on master only by accident of listener ordering:
beforeGet and the collection closure share the default priority, and because
beforeGet is registered as a first-class callable (a Closure), the wildcard
closure happens to sort before it, so the collections are already attached by the
time beforeGet resolves the path. That ordering is not guaranteed -- it depends on
how equal-priority listeners are tie-broken -- so the unguarded resolution is a
latent fault that any reordering can expose. On stable 28 it is already broken,
because the backport registered the handler as an array callable, which tie-breaks
the other way and runs beforeGet first.

Catch NotFound in beforeGet and bail out: a path that cannot be resolved is by
definition not an intermediate upload. This removes the dependency on listener
ordering entirely and makes the handler consistent with
beforePut()/beforeMove()/beforeDelete(), which already swallow NotFound for the
same reason.

Add a ChunkingV2Plugin unit test (the NotFound case is the regression guard) and
a file-versions integration scenario covering a real version download.

Assisted-by: ClaudeCode:claude-opus-4-8[1m]
Signed-off-by: David Dreschner <david.dreschner@nextcloud.com>
pull/61680/head
David Dreschner 2 months ago
parent 507c0a78f5
commit d101907a18
No known key found for this signature in database
  1. 19
      apps/dav/lib/Upload/ChunkingV2Plugin.php
  2. 105
      apps/dav/tests/unit/Upload/ChunkingV2PluginTest.php
  3. 17
      build/integration/dav_features/file-versions.feature
  4. 40
      build/integration/features/bootstrap/WebDav.php
  5. 3
      build/integration/filesdrop_features/filesdrop.feature

@ -79,10 +79,21 @@ class ChunkingV2Plugin extends ServerPlugin {
$this->server = $server;
}
protected function beforeGet(RequestInterface $request) {
$sourceNode = $this->server->tree->getNodeForPath($request->getPath());
if (($sourceNode instanceof FutureFile) || ($sourceNode instanceof UploadFile)) {
throw new MethodNotAllowed('Reading intermediate uploads is not allowed');
/**
* @throws MethodNotAllowed
*/
public function beforeGet(RequestInterface $request) {
try {
$sourceNode = $this->server->tree->getNodeForPath($request->getPath());
if ($sourceNode instanceof FutureFile || $sourceNode instanceof UploadFile) {
throw new MethodNotAllowed('Reading intermediate uploads is not allowed');
}
} catch (NotFound) {
// The node could not be resolved (yet), e.g. because the targeted
// collection is provided by another app and not registered on the
// tree at this point. This is no intermediate upload, so let the
// regular request handling deal with it (and report any 404).
}
return true;

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Tests\unit\Upload;
use OCA\DAV\Connector\Sabre\Directory;
use OCA\DAV\Upload\ChunkingV2Plugin;
use OCA\DAV\Upload\FutureFile;
use OCA\DAV\Upload\UploadFile;
use OCP\ICache;
use OCP\ICacheFactory;
use PHPUnit\Framework\MockObject\MockObject;
use Sabre\DAV\Exception\MethodNotAllowed;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\Server;
use Sabre\DAV\Tree;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
use Test\TestCase;
class ChunkingV2PluginTest extends TestCase {
/** @var Server | MockObject */
private $server;
/** @var Tree | MockObject */
private $tree;
/** @var ChunkingV2Plugin */
private $plugin;
/** @var RequestInterface | MockObject */
private $request;
/** @var ResponseInterface | MockObject */
private $response;
protected function setUp(): void {
parent::setUp();
$this->server = $this->getMockBuilder('\Sabre\DAV\Server')
->disableOriginalConstructor()
->getMock();
$this->tree = $this->getMockBuilder('\Sabre\DAV\Tree')
->disableOriginalConstructor()
->getMock();
$this->server->tree = $this->tree;
$cacheFactory = $this->createMock(ICacheFactory::class);
$cacheFactory->method('createDistributed')->willReturn($this->createMock(ICache::class));
$this->plugin = new ChunkingV2Plugin($cacheFactory);
$this->request = $this->createMock(RequestInterface::class);
$this->response = $this->createMock(ResponseInterface::class);
$this->server->httpRequest = $this->request;
$this->server->httpResponse = $this->response;
$this->plugin->initialize($this->server);
}
/**
* The handler only blocks reading intermediate uploads. A path it cannot
* resolve (e.g. an app-provided collection such as `versions`/`trashbin`
* that is registered later in the request lifecycle) must not abort the
* request: beforeGet has to swallow the NotFound and let normal handling
* (and any real 404) take over. This is the regression guard for the
* "version/trashbin downloads return 404" bug.
*/
public function testBeforeGetIgnoresUnresolvablePath(): void {
$this->request->method('getPath')->willReturn('versions/admin/versions/74/1782831952');
$this->tree->expects($this->once())
->method('getNodeForPath')
->with('versions/admin/versions/74/1782831952')
->willThrowException(new NotFound("File not found: versions in 'root'"));
$this->assertTrue($this->plugin->beforeGet($this->request));
}
public function testBeforeGetBlocksFutureFile(): void {
$this->expectException(MethodNotAllowed::class);
$this->request->method('getPath')->willReturn('uploads/admin/web-file-upload-id/1');
$this->tree->method('getNodeForPath')->willReturn($this->createMock(FutureFile::class));
$this->plugin->beforeGet($this->request);
}
public function testBeforeGetBlocksUploadFile(): void {
$this->expectException(MethodNotAllowed::class);
$this->request->method('getPath')->willReturn('uploads/admin/web-file-upload-id/.target');
$this->tree->method('getNodeForPath')->willReturn($this->createMock(UploadFile::class));
$this->plugin->beforeGet($this->request);
}
public function testBeforeGetAllowsRegularNode(): void {
$this->request->method('getPath')->willReturn('files/admin/foo.txt');
$this->tree->method('getNodeForPath')->willReturn($this->createMock(Directory::class));
$this->assertTrue($this->plugin->beforeGet($this->request));
}
}

@ -0,0 +1,17 @@
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
Feature: file-versions
Background:
Given using new dav path
# Regression test for file version downloads returning "404 File not found: versions in 'root'".
# The versions/trash bin collections are attached to the DAV root lazily during beforeMethod,
# so an early GET handler that resolved the request path too eagerly aborted the request.
# This exercises the full plugin stack: a real previous version must be downloadable via DAV.
Scenario: Download a previous version of a file via the versions DAV endpoint
Given user "admin" uploads file with content "first version" and mtime "1111111111" to "/versioned.txt"
And user "admin" uploads file with content "second version" and mtime "2222222222" to "/versioned.txt"
When user "admin" downloads version "1111111111" of file "/versioned.txt"
Then the HTTP status code should be "200"
And Downloaded content should be "first version"

@ -739,6 +739,46 @@ trait WebDav {
}
}
/**
* @When /^user "([^"]*)" uploads file with content "([^"]*)" and mtime "([^"]*)" to "([^"]*)"$/
* @param string $user
* @param string $content
* @param string $mtime
* @param string $destination
*/
public function userUploadsAFileWithContentAndMtimeTo($user, $content, $mtime, $destination) {
$file = \GuzzleHttp\Psr7\Utils::streamFor($content);
try {
$this->response = $this->makeDavRequest($user, 'PUT', $destination, ['X-OC-Mtime' => $mtime], $file);
} catch (\GuzzleHttp\Exception\ServerException $e) {
$this->response = $e->getResponse();
} catch (\GuzzleHttp\Exception\ClientException $e) {
$this->response = $e->getResponse();
}
}
/**
* Downloads a specific version (identified by its revision/timestamp) of a
* file through the versions DAV endpoint:
* GET remote.php/dav/versions/<user>/versions/<fileid>/<revision>
*
* @When /^user "([^"]*)" downloads version "([^"]*)" of file "([^"]*)"$/
* @param string $user
* @param string $revision
* @param string $path
*/
public function userDownloadsVersionOfFile($user, $revision, $path) {
$fileId = $this->getFileIdForPath($user, $path);
$versionPath = '/' . $user . '/versions/' . $fileId . '/' . $revision;
try {
$this->response = $this->makeDavRequest($user, 'GET', $versionPath, [], null, 'versions');
} catch (\GuzzleHttp\Exception\ServerException $e) {
$this->response = $e->getResponse();
} catch (\GuzzleHttp\Exception\ClientException $e) {
$this->response = $e->getResponse();
}
}
/**
* @When /^User "([^"]*)" deletes (file|folder) "([^"]*)"$/
* @param string $user

@ -214,9 +214,8 @@ Feature: FilesDrop
Then the HTTP status code should be "405"
And Downloading public folder "Mallory/folder"
Then the HTTP status code should be "405"
# Individual files are not exposed at all (404 Not Found)
And Downloading public file "Mallory/folder/a.txt"
Then the HTTP status code should be "404"
Then the HTTP status code should be "405"
Scenario: Files drop requires nickname if file request is enabled
Given user "user0" exists

Loading…
Cancel
Save