diff --git a/lib/Controller/ExAppProxyController.php b/lib/Controller/ExAppProxyController.php index 3c79ff64..55ad842a 100644 --- a/lib/Controller/ExAppProxyController.php +++ b/lib/Controller/ExAppProxyController.php @@ -244,6 +244,14 @@ private function prepareProxy( ); return null; } + // As the ExApp may read it: cut at "?" or "#", decoded once, any case, surrounding slashes and newlines. + $target = rawurldecode(substr($other, 0, strcspn($other, '?#'))); + if (in_array(strtolower(trim($target, "/\n")), ['heartbeat', 'init', 'enabled'], true)) { + $this->logger->debug( + sprintf('Returning status 404 for "%s": only AppAPI calls this ExApp endpoint.', $other) + ); + return null; + } $exApp = $this->exAppService->getExApp($appId); if ($exApp === null) { $this->logger->debug( diff --git a/tests/php/Controller/ExAppProxyControllerTest.php b/tests/php/Controller/ExAppProxyControllerTest.php index eefef966..84e9aa3a 100644 --- a/tests/php/Controller/ExAppProxyControllerTest.php +++ b/tests/php/Controller/ExAppProxyControllerTest.php @@ -90,6 +90,31 @@ public function testAccessDeniedDoesNotFallThroughToLaterRoutes(): void { self::assertSame('^/.*', $this->routeFor('GET', 'other', $adminFirst, 'alice')['url']); } + public static function internalEndpointProvider(): array { + return [ + 'heartbeat' => ['heartbeat'], + 'init' => ['init'], + 'enabled' => ['enabled'], + 'trailing slash' => ['init/'], + 'other case' => ['Enabled'], + 'encoded leading slash' => ['%2Fheartbeat'], + 'percent-encoded' => ['%65nabled'], + 'followed by a decoded "?"' => ['enabled?enabled=0'], + 'followed by a decoded "#"' => ['init#x'], + 'followed by a newline' => ["heartbeat\n"], + ]; + } + + #[DataProvider('internalEndpointProvider')] + public function testInternalEndpointIsRefused(string $path): void { + self::assertFalse($this->isForwarded($path)); + } + + public function testPathsNearInternalEndpointsAreForwarded(): void { + self::assertTrue($this->isForwarded('heartbeats')); + self::assertTrue($this->isForwarded('api/enabled')); + } + public function testHeadIsAcceptedWhereGetIs(): void { // Nextcloud routes HEAD to the proxy's GET action, and HaRP accepts HEAD on GET routes too. self::assertSame('GET', $this->routeFor('HEAD', 'settings', self::SAME_URL)['verb']);