From 90f7829aac127c46a322c663cae76b31909b7944 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 11 May 2026 14:56:34 +0530 Subject: [PATCH] Skip database abuse checks when cache circuit is open --- app/controllers/shared/api.php | 113 ++++++++++-------- app/init/resources.php | 10 +- src/Appwrite/Cache/Adapter/CircuitBreaker.php | 5 + 3 files changed, 76 insertions(+), 52 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 14ffdc059f..60eed3f434 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -3,6 +3,7 @@ use Appwrite\Auth\Key; use Appwrite\Auth\MFA\Type\TOTP; use Appwrite\Bus\Events\RequestCompleted; +use Appwrite\Cache\Adapter\CircuitBreaker as CircuitBreakerCache; use Appwrite\Event\Context\Audit as AuditContext; use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Delete; @@ -503,8 +504,9 @@ Http::init() ->inject('telemetry') ->inject('platform') ->inject('authorization') + ->inject('cacheCircuitBreakers') ->inject('cacheControlForStorage') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, AuditContext $auditContext, Delete $queueForDeletes, EventDatabase $queueForDatabase, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization, callable $cacheControlForStorage) { + ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, AuditContext $auditContext, Delete $queueForDeletes, EventDatabase $queueForDatabase, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization, array $cacheCircuitBreakers, callable $cacheControlForStorage) { $response->setUser($user); $request->setUser($user); @@ -525,63 +527,76 @@ Http::init() * Abuse Check */ - $abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}'); - $timeLimitArray = []; - - $abuseKeyLabel = (! is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel; - - foreach ($abuseKeyLabel as $abuseKey) { - $start = $request->getContentRangeStart(); - $end = $request->getContentRangeEnd(); - $timeLimit = $timelimit($abuseKey, $route->getLabel('abuse-limit', 0), $route->getLabel('abuse-time', 3600)); - $timeLimit - ->setParam('{projectId}', $project->getId()) - ->setParam('{userId}', $user->getId()) - ->setParam('{userAgent}', $request->getUserAgent('')) - ->setParam('{ip}', $request->getIP()) - ->setParam('{url}', $request->getHostname() . $route->getPath()) - ->setParam('{method}', $request->getMethod()) - ->setParam('{chunkId}', (int) ($start / ($end + 1 - $start))); - $timeLimitArray[] = $timeLimit; - } - - $closestLimit = null; - $roles = $authorization->getRoles(); $isPrivilegedUser = $user->isPrivileged($roles); $isAppUser = $user->isApp($roles); + $enabled = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled'; + $shouldCheckAbuse = $enabled + && ! $isAppUser + && ! $isPrivilegedUser + && $devKey->isEmpty(); - foreach ($timeLimitArray as $timeLimit) { - foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys - if (! empty($value)) { - $timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value); + $isDatabaseRoute = str_starts_with($path, '/v1/databases') + || str_starts_with($path, '/v1/tablesdb') + || str_starts_with($path, '/v1/documentsdb') + || str_starts_with($path, '/v1/vectorsdb'); + $cacheCircuitOpen = $shouldCheckAbuse + && $isDatabaseRoute + && \count(\array_filter( + $cacheCircuitBreakers, + fn (CircuitBreakerCache $breaker): bool => $breaker->isOpen() + )) > 0; + + if (! $cacheCircuitOpen) { + $abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}'); + $timeLimitArray = []; + + $abuseKeyLabel = (! is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel; + + foreach ($abuseKeyLabel as $abuseKey) { + $start = $request->getContentRangeStart(); + $end = $request->getContentRangeEnd(); + $timeLimit = $timelimit($abuseKey, $route->getLabel('abuse-limit', 0), $route->getLabel('abuse-time', 3600)); + $timeLimit + ->setParam('{projectId}', $project->getId()) + ->setParam('{userId}', $user->getId()) + ->setParam('{userAgent}', $request->getUserAgent('')) + ->setParam('{ip}', $request->getIP()) + ->setParam('{url}', $request->getHostname() . $route->getPath()) + ->setParam('{method}', $request->getMethod()) + ->setParam('{chunkId}', (int) ($start / ($end + 1 - $start))); + $timeLimitArray[] = $timeLimit; + } + + $closestLimit = null; + + foreach ($timeLimitArray as $timeLimit) { + foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys + if (! empty($value)) { + $timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value); + } } - } - $abuse = new Abuse($timeLimit); - $remaining = $timeLimit->remaining(); + $abuse = new Abuse($timeLimit); + $remaining = $timeLimit->remaining(); - $limit = $timeLimit->limit(); - $time = $timeLimit->time() + $route->getLabel('abuse-time', 3600); + $limit = $timeLimit->limit(); + $time = $timeLimit->time() + $route->getLabel('abuse-time', 3600); - if ($limit && ($remaining < $closestLimit || is_null($closestLimit))) { - $closestLimit = $remaining; - $response - ->addHeader('X-RateLimit-Limit', $limit) - ->addHeader('X-RateLimit-Remaining', $remaining) - ->addHeader('X-RateLimit-Reset', $time); - } + if ($limit && ($remaining < $closestLimit || is_null($closestLimit))) { + $closestLimit = $remaining; + $response + ->addHeader('X-RateLimit-Limit', $limit) + ->addHeader('X-RateLimit-Remaining', $remaining) + ->addHeader('X-RateLimit-Reset', $time); + } - $enabled = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled'; - - if ( - $enabled // Abuse is enabled - && ! $isAppUser // User is not API key - && ! $isPrivilegedUser // User is not an admin - && $devKey->isEmpty() // request doesn't not contain development key - && $abuse->check() // Route is rate-limited - ) { - throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED); + if ( + $shouldCheckAbuse // Abuse is enabled and the user is rate-limited + && $abuse->check() // Route is rate-limited + ) { + throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED); + } } } diff --git a/app/init/resources.php b/app/init/resources.php index 2417f5ba90..9aa3884cda 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -192,7 +192,7 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $container->set('telemetry', fn () => new NoTelemetry()); -$container->set('cache', function (Group $pools, Telemetry $telemetry) { +$container->set('cacheCircuitBreakers', function (Group $pools, Telemetry $telemetry) { $list = Config::getParam('pools-cache', []); $adapters = []; @@ -203,11 +203,15 @@ $container->set('cache', function (Group $pools, Telemetry $telemetry) { ); } - $cache = new Cache(new Sharding($adapters)); + return $adapters; +}, ['pools', 'telemetry']); + +$container->set('cache', function (array $cacheCircuitBreakers, Telemetry $telemetry) { + $cache = new Cache(new Sharding($cacheCircuitBreakers)); $cache->setTelemetry($telemetry); return $cache; -}, ['pools', 'telemetry']); +}, ['cacheCircuitBreakers', 'telemetry']); $container->set('cacheControlForStorage', fn () => function (StorageCacheControl $config): string { return \sprintf('private, max-age=%d', $config->maxAge); diff --git a/src/Appwrite/Cache/Adapter/CircuitBreaker.php b/src/Appwrite/Cache/Adapter/CircuitBreaker.php index a5f2af8e2f..512a6d667b 100644 --- a/src/Appwrite/Cache/Adapter/CircuitBreaker.php +++ b/src/Appwrite/Cache/Adapter/CircuitBreaker.php @@ -78,6 +78,11 @@ class CircuitBreaker implements Adapter } } + public function isOpen(): bool + { + return $this->breaker->isOpen(); + } + public function setMaxRetries(int $maxRetries): self { $this->adapter->setMaxRetries($maxRetries);