Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e073c3e
refactor(abuse): make TimeLimit core immutable with Result and withPa…
ChiragAgg5k Oct 1, 2026
0ee3cec
refactor(abuse): add immutable TimeLimit adapters, Result and unit tests
ChiragAgg5k Oct 1, 2026
1e793e0
refactor(abuse): port Database and TablesDB TimeLimit adapters to imm…
ChiragAgg5k Oct 1, 2026
5bf6b64
refactor(abuse): port SlidingWindow adapters to immutable contract
ChiragAgg5k Oct 1, 2026
86a579b
refactor(abuse): port TokenBucket adapters to immutable contract
ChiragAgg5k Oct 1, 2026
51a17a8
refactor(abuse): move ReCaptcha to standalone class on utopia-php/client
ChiragAgg5k Oct 1, 2026
eb56d88
test(abuse): migrate TimeLimit e2e suites to immutable API
ChiragAgg5k Oct 1, 2026
62909f4
test(abuse): migrate SlidingWindow and TokenBucket e2e suites and ben…
ChiragAgg5k Oct 1, 2026
af4928f
refactor: migrate abuse call sites to immutable TimeLimit API
ChiragAgg5k Oct 1, 2026
44bb8f0
fix(abuse): throw on non-numeric script results in SlidingWindow and …
ChiragAgg5k Oct 1, 2026
4ba1fdf
test(abuse): derive expected keys from the adapter in param-reuse e2e
ChiragAgg5k Oct 1, 2026
ad27b77
style(abuse): apply rector null-coalescing assignment in TimeLimit lo…
ChiragAgg5k Oct 1, 2026
6cfed4d
chore(abuse): drop the phpstan baseline now that level max is clean
ChiragAgg5k Oct 1, 2026
fd0d3e6
test(abuse): give the non-window TimeLimit e2e tests an hour-long window
ChiragAgg5k Oct 1, 2026
3d7852a
fix(abuse): derive token bucket reset from the fractional balance
ChiragAgg5k Oct 1, 2026
34bb27c
test(abuse): assert decoded ReCaptcha form fields instead of exact en…
ChiragAgg5k Oct 1, 2026
5d1c089
Merge main into refactor/abuse-immutable
ChiragAgg5k Oct 5, 2026
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
83 changes: 40 additions & 43 deletions app/controllers/shared/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use Utopia\Abuse\Abuse;
use Utopia\Abuse\Adapters\TimeLimit;
use Utopia\Abuse\Adapter\TimeLimit;
use Utopia\Bus\Bus;
use Utopia\Cache\Adapter\Filesystem;
use Utopia\Cache\Cache;
Expand Down Expand Up @@ -600,36 +599,33 @@
try {
$start = $request->getContentRangeStart();
$end = $request->getContentRangeEnd();
$isRateLimited = $timelimit($abuseKey, $abuseLimit, $route->getLabel('abuse-time', 3600), function (TimeLimit $timeLimit) use ($route, $request, $response, $project, $user, $start, $end, $shouldCheckAbuse, &$closestLimit): bool {
$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)));

foreach ($request->getParams() as $key => $value) {
if (! empty($value)) {
$timeLimit->setParam('{param-' . $key . '}', (\is_array($value) || \is_object($value)) ? \json_encode($value) : $value);
}
$params = [
'{projectId}' => (string) $project->getId(),
'{userId}' => (string) $user->getId(),
'{userAgent}' => (string) $request->getUserAgent(''),
'{ip}' => (string) $request->getIP(),
'{url}' => $request->getHostname() . $route->getPath(),
'{method}' => (string) $request->getMethod(),
'{chunkId}' => (string) (int) ($start / ($end + 1 - $start)),
];

foreach ($request->getParams() as $key => $value) {
if (! empty($value)) {
$params['{param-' . $key . '}'] = (\is_array($value) || \is_object($value)) ? (string) \json_encode($value) : (string) $value;
}
}

$abuse = new Abuse($timeLimit);
$remaining = $timeLimit->remaining();
$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);
$isRateLimited = $timelimit($abuseKey, $abuseLimit, $route->getLabel('abuse-time', 3600), function (TimeLimit $timeLimit) use ($params, $response, $shouldCheckAbuse, &$closestLimit): bool {
$timeLimit = $timeLimit->withParams($params);
$result = $shouldCheckAbuse ? $timeLimit->check() : $timeLimit->peek();
if ($result->limit && ($result->remaining < $closestLimit || is_null($closestLimit))) {
$closestLimit = $result->remaining;
$response->addHeader('X-RateLimit-Limit', (string) $result->limit)
->addHeader('X-RateLimit-Remaining', (string) $result->remaining)
->addHeader('X-RateLimit-Reset', (string) $result->reset);
}

return $shouldCheckAbuse && $abuse->check();
return $shouldCheckAbuse && $result->limited;
});
} catch (\Throwable $th) {
\error_log((string) $th);
Expand Down Expand Up @@ -983,23 +979,24 @@
foreach ($abuseKeyLabel as $abuseKey) {
$start = $request->getContentRangeStart();
$end = $request->getContentRangeEnd();
$timelimit($abuseKey, $route->getLabel('abuse-limit', 0), $route->getLabel('abuse-time', 3600), function (TimeLimit $timeLimit) use ($route, $request, $project, $user, $start, $end): void {
$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)));

foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys
if (! empty($value)) {
$timeLimit->setParam('{param-' . $key . '}', (\is_array($value) || \is_object($value)) ? \json_encode($value) : $value);
}
$params = [
'{projectId}' => (string) $project->getId(),
'{userId}' => (string) $user->getId(),
'{userAgent}' => (string) $request->getUserAgent(''),
'{ip}' => (string) $request->getIP(),
'{url}' => $request->getHostname() . $route->getPath(),
'{method}' => (string) $request->getMethod(),
'{chunkId}' => (string) (int) ($start / ($end + 1 - $start)),
];

foreach ($request->getParams() as $key => $value) {
if (! empty($value)) {
$params['{param-' . $key . '}'] = (\is_array($value) || \is_object($value)) ? (string) \json_encode($value) : (string) $value;
}
}

(new Abuse($timeLimit))->reset();
$timelimit($abuseKey, $route->getLabel('abuse-limit', 0), $route->getLabel('abuse-time', 3600), function (TimeLimit $timeLimit) use ($params): void {
$timeLimit->withParams($params)->reset();
});
}
});
Expand Down
11 changes: 4 additions & 7 deletions app/init/resources.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
use Appwrite\Vcs\RepositoryWebhooks;
use Executor\Executor;
use OpenRuntimes\Orchestrator\Jobs;
use Utopia\Abuse\Adapters\TimeLimit\Redis as TimeLimitRedis;
use Utopia\Abuse\Adapter\TimeLimit\Redis as TimeLimitRedis;
use Utopia\Cache\Adapter\Pool as CachePool;
use Utopia\Cache\Adapter\Sharding;
use Utopia\Cache\Cache;
Expand Down Expand Up @@ -307,12 +307,9 @@ function (\Redis $redis) use ($key, $ttl, $callback, $timeout): mixed {
}
), ['pools']);

// The lease spans the whole callback because a TimeLimit issues several round
// trips (remaining, limit, check, reset) and must hold one connection for all of
// them. Do not throw from the callback: the pool treats that as a failed lease
// and reconnects the socket before returning it.
$container->set('timelimit', fn (Group $pools) => fn (string $key, int $limit, int $time, callable $callback): mixed => $pools->get('abuse')->use(
fn (\Redis $redis): mixed => $callback(new TimeLimitRedis($key, $limit, $time, $redis))
// The lease spans the callback; throwing from it makes the pool reconnect the socket.
$container->set('timelimit', fn (Group $pools) => fn (string $key, int $limit, int $seconds, callable $callback): mixed => $pools->get('abuse')->use(
fn (\Redis $redis): mixed => $callback(new TimeLimitRedis($key, $limit, $seconds, $redis))
), ['pools']);

$container->set('deviceForLocal', fn (Telemetry $telemetry) => new Device\Telemetry($telemetry, new Local()), ['telemetry']);
Expand Down
19 changes: 3 additions & 16 deletions app/realtime.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@
use Swoole\Runtime;
use Swoole\Table;
use Swoole\Timer;
use Utopia\Abuse\Abuse;
use Utopia\Abuse\Adapters\TimeLimit;
use Utopia\Abuse\Adapter\TimeLimit;
use Utopia\Cache\Adapter\Pool as CachePool;
use Utopia\Cache\Adapter\Sharding;
use Utopia\Cache\Cache;
Expand Down Expand Up @@ -1008,13 +1007,7 @@ function checkForProjectUsage(Document $project): void
* Abuse limits are connecting 128 times per minute and ip address.
*/
$isRateLimited = $timelimit('url:{url},ip:{ip}', 128, 60, function (TimeLimit $timeLimit) use ($request): bool {
$timeLimit
->setParam('{ip}', $request->getIP())
->setParam('{url}', $request->getURI());

$abuse = new Abuse($timeLimit);

return System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') === 'enabled' && $abuse->check();
return System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') === 'enabled' && $timeLimit->withParams(['{ip}' => $request->getIP(), '{url}' => $request->getURI()])->check()->limited;
});

if ($isRateLimited) {
Expand Down Expand Up @@ -1322,13 +1315,7 @@ function checkForProjectUsage(Document $project): void
* Abuse limits are sending 32 times per minute and connection.
*/
$isRateLimited = $container->get('timelimit')('url:{url},connection:{connection}', 32, 60, function (TimeLimit $timeLimit) use ($connection, $containerId): bool {
$timeLimit
->setParam('{connection}', $connection)
->setParam('{container}', $containerId);

$abuse = new Abuse($timeLimit);

return $abuse->check() && System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') === 'enabled';
return $timeLimit->withParams(['{connection}' => (string) $connection, '{container}' => (string) $containerId])->check()->limited && System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') === 'enabled';
});

if ($isRateLimited) {
Expand Down
56 changes: 24 additions & 32 deletions packages/abuse/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ This adapter uses a MySQL / MariaDB to store usage attempts. Before using it, ca

require_once __DIR__ . '/../../vendor/autoload.php';

use Utopia\Abuse\Abuse;
use Utopia\Abuse\Adapters\TimeLimit;
use Utopia\Abuse\Adapter\TimeLimit\Database as TimeLimit;
use Utopia\Cache\Adapter\None as NoCache;
use Utopia\Cache\Cache;
use Utopia\Database\Adapter\MySQL;
Expand All @@ -55,18 +54,18 @@ $db = new Database(new MySQL($pdo), new Cache(new NoCache()));
$db->setNamespace('namespace');

// Limit login attempts to 10 time in 5 minutes time frame
$adapter = new TimeLimit('login-attempt-from-{{ip}}', 10, (60 * 5), $db);
$adapter = new TimeLimit('login-attempt-from-{{ip}}', 10, 60 * 5, $db);
$adapter->setup(); // Setup database as required

$adapter->setup(); //setup database as required
$adapter->setParam('{{ip}}', '127.0.0.1')
;
// withParams() returns an immutable copy with the key resolved
$result = $adapter->withParams(['{{ip}}' => '127.0.0.1'])->check();

$abuse = new Abuse($adapter);
header('X-RateLimit-Limit: ' . $result->limit);
header('X-RateLimit-Remaining: ' . $result->remaining);
header('X-RateLimit-Reset: ' . $result->reset);

// Use vars to resolve adapter key

if($abuse->check()) {
throw new Exception('Service was abused!'); // throw error and return X-Rate limit headers here
if ($result->limited) {
throw new Exception('Service was abused!');
}
```

Expand All @@ -77,58 +76,51 @@ if($abuse->check()) {

require_once __DIR__ . '/../../vendor/autoload.php';

use Utopia\Abuse\Abuse;
use Utopia\Abuse\Adapters\TimeLimit\Appwrite\TablesDB as TablesDBAdapter;
use Appwrite\Client;
use Utopia\Abuse\Adapter\TimeLimit\Appwrite\TablesDB;

$client = (new Client())
$client = new Client()
->setEndpoint('[YOUR_ENDPOINT]')
->setProject('[YOUR_PROJECT_ID]')
->setKey('[YOUR_API_KEY]');
$databaseId = 'abuse';

// Limit login attempts to 10 time in 5 minutes time frame
$adapter = new TablesDBAdapter('login-attempt-from-{{ip}}', 10, (60 * 5), $client, $databaseId);

$adapter->setup(); //setup database as required
$adapter->setParam('{{ip}}', '127.0.0.1');
$adapter = new TablesDB('login-attempt-from-{{ip}}', 10, 60 * 5, $client, $databaseId);
$adapter->setup(); // Setup database as required

$abuse = new Abuse($adapter);
$result = $adapter->withParams(['{{ip}}' => '127.0.0.1'])->check();

// Use vars to resolve adapter key

if($abuse->check()) {
throw new Exception('Service was abused!'); // throw error and return X-Rate limit headers here
if ($result->limited) {
throw new Exception('Service was abused!');
}
```

**ReCaptcha Abuse**

The ReCaptcha abuse controller is using Google ReCaptcha service to detect when service is being abused by bots.
To use this adapter you need to create an API key from the Google ReCaptcha service [admin console](https://www.google.com/recaptcha/admin).
To use it you need to create an API key from the Google ReCaptcha service [admin console](https://www.google.com/recaptcha/admin).

```php
<?php

require_once __DIR__ . '/../../vendor/autoload.php';

use Utopia\Abuse\Abuse;
use Utopia\Abuse\Adapters\ReCaptcha;
use Utopia\Abuse\ReCaptcha;

// Limit login attempts to 10 time in 5 minutes time frame
$adapter = new ReCaptcha('secret-api-key', $_POST['g-recaptcha-response'], $_SERVER['REMOTE_ADDR']);
$abuse = new Abuse($adapter);
$recaptcha = new ReCaptcha('secret-api-key');

if($abuse->check()) {
throw new Exception('Service was abused!'); // throw error and return X-Rate limit headers here
// verify() returns true when the token belongs to a human scoring at least 0.5
if (!$recaptcha->verify($_POST['g-recaptcha-response'], $_SERVER['REMOTE_ADDR'])) {
throw new Exception('Service was abused!');
}
```

*Notice: The code above is for example purpose only. It is always recommended to validate user input before using it in your code. If you are using a load balancer or any proxy server you might need to get user IP from the HTTP_X_FORWARDE‌​D_FOR header.*

## System Requirements

Utopia Framework requires PHP 8.0 or later. We recommend using the latest PHP version whenever possible.
Utopia Abuse requires PHP 8.5 or later. We recommend using the latest PHP version whenever possible.

## Copyright and license

Expand Down
8 changes: 6 additions & 2 deletions packages/abuse/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,16 @@
"bench": "vendor/bin/phpbench run --report=aggregate"
},
"require": {
"php": ">=8.4.1",
"php": ">=8.5",
"ext-pdo": "*",
"ext-curl": "*",
"ext-redis": "*",
"psr/http-client": "^1.0",
"psr/http-message": "^2.0",
"utopia-php/client": "^0.5",
"utopia-php/database": "^7.0.0",
"utopia-php/pools": "2.*",
"utopia-php/pools": "^2.0",
"utopia-php/psr7": "^0.2.1",
"appwrite/appwrite": "^27.1"
},
"require-dev": {
Expand Down
Loading
Loading