Compare commits

...
Author SHA1 Message Date
Matej Bačo 5da6793e26 Revert "fix tests"
This reverts commit fe3c1f74bf.
2026-03-30 10:28:04 +02:00
Chirag Aggarwal fe3c1f74bf fix tests 2026-03-27 22:21:00 +05:30
Matej Bačo 6d59ac4bb0 Fix tests 2026-03-27 16:42:11 +01:00
Matej Bačo 45929f4df7 add tests 2026-03-27 16:20:42 +01:00
Matej Bačo 9bc5407cb5 Configurable abuse limit 2026-03-27 16:15:14 +01:00
Matej Bačo abce4ade25 pr review fixes 2026-03-27 16:13:00 +01:00
Matej Bačo d2172033dc Fix email abuse limit 2026-03-27 15:51:13 +01:00
8 changed files with 87 additions and 11 deletions
+1
View File
@@ -142,6 +142,7 @@ _APP_MESSAGE_PUSH_TEST_DSN=
_APP_WEBHOOK_MAX_FAILED_ATTEMPTS=10
_APP_PROJECT_REGIONS=default
_APP_FUNCTIONS_CREATION_ABUSE_LIMIT=5000
_APP_EMAILS_ABUSE_LIMIT=20
_APP_STATS_USAGE_DUAL_WRITING_DBS=database_db_main
_APP_TRUSTED_HEADERS=x-forwarded-for
_APP_POOL_ADAPTER=stack
+8 -8
View File
@@ -962,7 +962,7 @@ Http::patch('/v1/account/sessions/:sessionId')
Http::post('/v1/account/sessions/email')
->alias('/v1/account/sessions')
->desc('Create email password session')
->groups(['api', 'account', 'auth', 'session'])
->groups(['api', 'account', 'auth', 'session', 'outgoingEmail'])
->label('event', 'users.[userId].sessions.[sessionId].create')
->label('scope', 'sessions.write')
->label('auth.type', 'email-password')
@@ -1269,7 +1269,7 @@ Http::post('/v1/account/sessions/anonymous')
Http::post('/v1/account/sessions/token')
->desc('Create session')
->label('event', 'users.[userId].sessions.[sessionId].create')
->groups(['api', 'account', 'session'])
->groups(['api', 'account', 'session', 'outgoingEmail'])
->label('scope', 'sessions.write')
->label('audits.event', 'session.create')
->label('audits.resource', 'user/{response.userId}')
@@ -2115,7 +2115,7 @@ Http::get('/v1/account/tokens/oauth2/:provider')
Http::post('/v1/account/tokens/magic-url')
->alias('/v1/account/sessions/magic-url')
->desc('Create magic URL token')
->groups(['api', 'account', 'auth'])
->groups(['api', 'account', 'auth', 'outgoingEmail'])
->label('scope', 'sessions.write')
->label('auth.type', 'magic-url')
->label('audits.event', 'session.create')
@@ -2395,7 +2395,7 @@ Http::post('/v1/account/tokens/magic-url')
Http::post('/v1/account/tokens/email')
->desc('Create email token (OTP)')
->groups(['api', 'account', 'auth'])
->groups(['api', 'account', 'auth', 'outgoingEmail'])
->label('scope', 'sessions.write')
->label('auth.type', 'email-otp')
->label('audits.event', 'session.create')
@@ -2698,7 +2698,7 @@ Http::post('/v1/account/tokens/email')
Http::put('/v1/account/sessions/magic-url')
->desc('Update magic URL session')
->label('event', 'users.[userId].sessions.[sessionId].create')
->groups(['api', 'account', 'session'])
->groups(['api', 'account', 'session', 'outgoingEmail'])
->label('scope', 'sessions.write')
->label('audits.event', 'session.create')
->label('audits.resource', 'user/{response.userId}')
@@ -2748,7 +2748,7 @@ Http::put('/v1/account/sessions/magic-url')
Http::put('/v1/account/sessions/phone')
->desc('Update phone session')
->label('event', 'users.[userId].sessions.[sessionId].create')
->groups(['api', 'account', 'session'])
->groups(['api', 'account', 'session', 'outgoingEmail'])
->label('scope', 'sessions.write')
->label('audits.event', 'session.create')
->label('audits.resource', 'user/{response.userId}')
@@ -3575,7 +3575,7 @@ Http::patch('/v1/account/status')
Http::post('/v1/account/recovery')
->desc('Create password recovery')
->groups(['api', 'account'])
->groups(['api', 'account', 'outgoingEmail'])
->label('scope', 'sessions.write')
->label('event', 'users.[userId].recovery.[tokenId].create')
->label('audits.event', 'recovery.create')
@@ -3869,7 +3869,7 @@ Http::put('/v1/account/recovery')
Http::post('/v1/account/verifications/email')
->alias('/v1/account/verification')
->desc('Create email verification')
->groups(['api', 'account'])
->groups(['api', 'account', 'outgoingEmail'])
->label('scope', 'account')
->label('event', 'users.[userId].verification.[tokenId].create')
->label('audits.event', 'verification.create')
+1 -1
View File
@@ -1480,7 +1480,7 @@ Http::patch('/v1/projects/:projectId/smtp')
Http::post('/v1/projects/:projectId/smtp/tests')
->desc('Create SMTP test')
->groups(['api', 'projects'])
->groups(['api', 'projects', 'outgoingEmail'])
->label('scope', 'projects.write')
->label('sdk', [
new Method(
+29
View File
@@ -37,6 +37,7 @@ use Executor\Executor;
use MaxMind\Db\Reader;
use Swoole\Http\Request as SwooleRequest;
use Swoole\Table;
use Utopia\Abuse\Abuse;
use Utopia\Bus\Bus;
use Utopia\Config\Config;
use Utopia\Console;
@@ -1126,6 +1127,34 @@ Http::init()
});
});
/**
* Email worker abuse protection
*/
Http::init()
->groups(['outgoingEmail'])
->inject('project')
->inject('timelimit')
->action(function (Document $project, $timelimit) {
/*
* Abuse Check
*/
$abuseKey = 'initHook:outgoingEmail,projectId:{projectId}';
$limitPerHour = \intval(System::getEnv('_APP_EMAILS_ABUSE_LIMIT', '1000'));
$timeLimit = $timelimit($abuseKey, limit: $limitPerHour, time: 3600); // 1000 emails per hour , per project
$timeLimit
->setParam('{projectId}', $project->getId());
$abuse = new Abuse($timeLimit);
$enabled = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled';
if ($enabled && $abuse->check()) {
throw new AppwriteException(AppwriteException::GENERAL_RATE_LIMIT_EXCEEDED);
}
});
Http::options()
->inject('utopia')
->inject('swooleRequest')
+1
View File
@@ -138,6 +138,7 @@ services:
- _APP_EMAIL_SECURITY
- _APP_SYSTEM_RESPONSE_FORMAT
- _APP_OPTIONS_ABUSE
- _APP_EMAILS_ABUSE_LIMIT
- _APP_OPTIONS_ROUTER_PROTECTION
- _APP_OPTIONS_FORCE_HTTPS
- _APP_OPTIONS_ROUTER_FORCE_HTTPS
@@ -49,7 +49,7 @@ class Create extends Action
->setHttpPath('/v1/account/mfa/challenges')
->httpAlias('/v1/account/mfa/challenge')
->desc('Create MFA challenge')
->groups(['api', 'account', 'mfa'])
->groups(['api', 'account', 'mfa', 'outgoingEmail'])
->label('scope', 'account')
->label('event', 'users.[userId].challenges.[challengeId].create')
->label('audits.event', 'challenge.create')
@@ -53,7 +53,7 @@ class Create extends Action
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/teams/:teamId/memberships')
->desc('Create team membership')
->groups(['api', 'teams', 'auth'])
->groups(['api', 'teams', 'auth', 'outgoingEmail'])
->label('event', 'teams.[teamId].memberships.[membershipId].create')
->label('scope', 'teams.write')
->label('auth.type', 'invites')
@@ -3,6 +3,7 @@
namespace Tests\E2E\Services\Account;
use Appwrite\Tests\Retry;
use PHPUnit\Framework\Attributes\Group;
use Tests\E2E\Client;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
@@ -11,6 +12,7 @@ use Utopia\Database\DateTime;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Query;
use Utopia\Database\Validator\Datetime as DatetimeValidator;
use Utopia\System\System;
use function sleep;
@@ -4150,4 +4152,47 @@ class AccountCustomClientTest extends Scope
$this->assertEquals(401, $verification3['headers']['status-code']);
}
#[Group('abuseEnabled')]
public function testEmailAbuseLimit(): void
{
if (System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') === 'disabled') {
$this->markTestSkipped('Abuse checks are disabled.');
}
$email = 'abuse.email.' . bin2hex(random_bytes(8)) . '@example.com';
$password = 'password';
$baseHeaders = [
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
];
$account = $this->client->call(Client::METHOD_POST, '/account', $baseHeaders, [
'userId' => ID::unique(),
'email' => $email,
'password' => $password,
'name' => 'Email Abuse Test',
]);
$this->assertEquals(201, $account['headers']['status-code']);
// Successful requests up to the limit should all pass
for ($i = 0; $i < 20; $i++) {
$session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $baseHeaders, [
'email' => $email,
'password' => $password,
]);
$this->assertEquals(201, $session['headers']['status-code'], 'Request ' . ($i + 1) . ' of 20 should succeed.');
}
// The next request should be rate limited
$session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $baseHeaders, [
'email' => $email,
'password' => $password,
]);
$this->assertEquals(429, $session['headers']['status-code']);
}
}