From 2daefd9e40d26e3669c47690bdfcb17f24c8712c Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 29 Oct 2026 18:33:14 +0100 Subject: [PATCH] fix(dav): return 404 instead of 503 for unknown public share tokens PublicAuth::check() converted every unexpected exception into a ServiceUnavailable, including the NotFound thrown for a share token that does not exist (anymore), e.g. after the share was deleted by reaching its download limit. Clients got a 503 and every such request was logged as an error. Let NotFound pass through and also map the ShareNotFound from the strict cookie check to a NotFound, registering a brute force attempt like checkToken() does. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ferdinand Thiessen --- apps/dav/lib/Connector/Sabre/PublicAuth.php | 6 ++ .../unit/Connector/Sabre/PublicAuthTest.php | 65 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/apps/dav/lib/Connector/Sabre/PublicAuth.php b/apps/dav/lib/Connector/Sabre/PublicAuth.php index 4649a9a00f40c..773c6b8e30c4a 100644 --- a/apps/dav/lib/Connector/Sabre/PublicAuth.php +++ b/apps/dav/lib/Connector/Sabre/PublicAuth.php @@ -57,6 +57,7 @@ public function __construct( /** * @throws NotAuthenticated + * @throws NotFound * @throws MaxDelayReached * @throws ServiceUnavailable */ @@ -85,6 +86,11 @@ public function check(RequestInterface $request, ResponseInterface $response): a } catch (NotAuthenticated|MaxDelayReached $e) { $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); throw $e; + } catch (ShareNotFound) { + $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); + throw new NotFound(); + } catch (NotFound $e) { + throw $e; } catch (PreconditionFailed $e) { $response->setHeader( 'Location', diff --git a/apps/dav/tests/unit/Connector/Sabre/PublicAuthTest.php b/apps/dav/tests/unit/Connector/Sabre/PublicAuthTest.php index e76fc347d9359..0ef0c9de08519 100644 --- a/apps/dav/tests/unit/Connector/Sabre/PublicAuthTest.php +++ b/apps/dav/tests/unit/Connector/Sabre/PublicAuthTest.php @@ -20,6 +20,8 @@ use OCP\Share\IShare; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; +use Sabre\HTTP\Request; +use Sabre\HTTP\Response; /** * Class PublicAuthTest @@ -126,6 +128,69 @@ public function testCheckTokenInvalidShare(): void { self::invokePrivate($this->auth, 'checkToken'); } + public function testCheckShareNotFound(): void { + $this->request->method('getPathInfo') + ->willReturn('/dav/files/GX9HSGQrGE'); + $this->request->method('getRemoteAddress') + ->willReturn('1.2.3.4'); + + $this->shareManager->method('getShareByToken') + ->with('GX9HSGQrGE') + ->willThrowException(new ShareNotFound()); + + $this->throttler->expects($this->once()) + ->method('registerAttempt') + ->with('public_dav_auth', '1.2.3.4'); + $this->logger->expects($this->never()) + ->method('error'); + + $this->expectException(\Sabre\DAV\Exception\NotFound::class); + $this->auth->check(new Request('GET', '/dav/files/GX9HSGQrGE'), new Response()); + } + + public function testCheckShareNotFoundWithoutStrictCookie(): void { + $cookies = $_COOKIE; + $_COOKIE = ['foo' => 'bar']; + + $this->request->method('getPathInfo') + ->willReturn('/dav/files/GX9HSGQrGE'); + $this->request->method('getRemoteAddress') + ->willReturn('1.2.3.4'); + $this->request->method('passesStrictCookieCheck') + ->willReturn(false); + + $this->shareManager->method('getShareByToken') + ->with('GX9HSGQrGE') + ->willThrowException(new ShareNotFound()); + + $this->throttler->expects($this->once()) + ->method('registerAttempt') + ->with('public_dav_auth', '1.2.3.4'); + $this->logger->expects($this->never()) + ->method('error'); + + try { + $this->expectException(\Sabre\DAV\Exception\NotFound::class); + $this->auth->check(new Request('GET', '/dav/files/GX9HSGQrGE'), new Response()); + } finally { + $_COOKIE = $cookies; + } + } + + public function testCheckUnexpectedException(): void { + $this->request->method('getPathInfo') + ->willReturn('/dav/files/GX9HSGQrGE'); + + $this->shareManager->method('getShareByToken') + ->willThrowException(new \RuntimeException('Database is gone')); + + $this->logger->expects($this->once()) + ->method('error'); + + $this->expectException(\Sabre\DAV\Exception\ServiceUnavailable::class); + $this->auth->check(new Request('GET', '/dav/files/GX9HSGQrGE'), new Response()); + } + public function testCheckTokenAlreadyAuthenticated(): void { $this->request->method('getPathInfo') ->willReturn('/dav/files/GX9HSGQrGE');