Compare commits

...
Author SHA1 Message Date
Levi van Noort 100097eb77 refactor: move getEnv outside of the class 2025-12-11 16:14:58 +01:00
Levi van Noort cc6ddc5dcd chore: remove utopia http request overwrite tests 2025-12-11 15:41:36 +01:00
Levi van Noort a9424a3630 chore: update imports to use TrustedIp in http and realtime files 2025-12-11 15:36:24 +01:00
Levi van Noort 5dd9229c2f chore: reorder dependency injection for trustedIp and devKey in error handling 2025-12-11 15:27:14 +01:00
Levi van Noort a084d6b5da chore: setup changes for trustedIp in variables based function 2025-12-11 15:08:12 +01:00
Levi van Noort e828e9e82f chore: remove unused inject 2025-12-11 14:51:01 +01:00
Levi van NoortandGitHub 5cea741282 Merge branch '1.8.x' into pla-3704-enhance-getip-based-on-trusted-headers 2025-12-11 14:24:21 +01:00
Levi van Noort 5c136b37c0 refactor: move trustedIp to Appwrite\Network 2025-12-11 13:50:30 +01:00
Levi van Noort 168cbf4409 chore: change parameter order to match injection 2025-12-10 17:55:22 +01:00
Levi van Noort 1a7587a175 chore: initial refactor to inject based approach 2025-12-10 17:51:35 +01:00
Levi van Noort e321712d3a chore: wording change requested by code-rabbit 2025-11-29 13:13:32 +01:00
Levi van NoortandGitHub d06ee44c49 Merge branch '1.8.x' into pla-3704-enhance-getip-based-on-trusted-headers 2025-11-28 18:17:16 +01:00
Levi van Noort 7c96030aca chore: linter fixes 2025-11-28 16:46:49 +01:00
Levi van Noort 8d9ac9d428 chore: add putenv for unit tests and add end-to-end 2025-11-28 16:10:43 +01:00
Levi van Noort 4b3e6a6422 chore: linter changes 2025-11-27 14:52:22 +01:00
Levi van NoortandGitHub 1b6aba4713 Merge branch '1.8.x' into pla-3704-enhance-getip-based-on-trusted-headers 2025-11-27 13:59:17 +01:00
Levi van Noort 006f8fef45 chore: add additional tests 2025-11-27 13:40:43 +01:00
Levi van Noort 2c3ddc4e73 chore: add better explanation of variables usage 2025-11-27 13:32:56 +01:00
Levi van NoortGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
5642b86c55 chore: set correct default from the variables side
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-11-27 13:17:32 +01:00
Levi van Noort d3d4b6f67f docs: add missing server based sdk reference 2025-11-27 13:05:15 +01:00
Levi van Noort 95ffa6bcda docs: add missing server based getting started with 2025-11-27 13:02:01 +01:00
Levi van Noort 8aa6d72b3e feat: add trusted headers variables and setup enhanced getIP 2025-11-27 12:59:30 +01:00
16 changed files with 173 additions and 52 deletions
+1
View File
@@ -125,3 +125,4 @@ _APP_WEBHOOK_MAX_FAILED_ATTEMPTS=10
_APP_PROJECT_REGIONS=default
_APP_FUNCTIONS_CREATION_ABUSE_LIMIT=5000
_APP_STATS_USAGE_DUAL_WRITING_DBS=database_db_main
_APP_TRUSTED_HEADERS=
+2
View File
@@ -164,6 +164,7 @@ Getting started with Appwrite is as easy as creating a new project, choosing you
| | [Quick start for PHP](https://appwrite.io/docs/quick-starts/php) |
| | [Quick start for Kotlin](https://appwrite.io/docs/quick-starts/kotlin) |
| | [Quick start for Swift](https://appwrite.io/docs/quick-starts/swift) |
| | [Quick start for Go](https://appwrite.io/docs/quick-starts/go) |
### Products
@@ -205,6 +206,7 @@ Below is a list of currently supported platforms and languages. If you would lik
- :white_check_mark: &nbsp; [Kotlin](https://github.com/appwrite/sdk-for-kotlin) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [Swift](https://github.com/appwrite/sdk-for-swift) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [.NET](https://github.com/appwrite/sdk-for-dotnet) - **Beta** (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [Go](https://github.com/appwrite/sdk-for-go) - **Beta** (Maintained by the Appwrite Team)
#### Community
+9
View File
@@ -357,6 +357,15 @@ return [
'required' => false,
'question' => '',
'filter' => ''
],
[
'name' => '_APP_TRUSTED_HEADERS',
'description' => 'This option allows you to set the list of trusted headers, the value is a commaseparated list of HTTP header names, evaluated left-to-right for the first valid IP. Header names are treated case-insensitively.',
'introduction' => '1.8.0',
'default' => 'x-forwarded-for',
'required' => false,
'question' => '',
'filter' => ''
]
],
],
+42 -30
View File
@@ -194,7 +194,7 @@ function sendSessionAlert(Locale $locale, Document $user, Document $project, Doc
}
;
$createSession = function (string $userId, string $secret, Request $request, Response $response, User $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Store $store, ProofsToken $proofForToken, ProofsCode $proofForCode) {
$createSession = function (string $userId, string $secret, Request $request, Response $response, User $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Store $store, ProofsToken $proofForToken, ProofsCode $proofForCode, string $trustedIp) {
/** @var Appwrite\Utopia\Database\Documents\User $userFromRequest */
$userFromRequest = Authorization::skip(fn () => $dbForProject->getDocument('users', $userId));
@@ -214,7 +214,7 @@ $createSession = function (string $userId, string $secret, Request $request, Res
$duration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG;
$detector = new Detector($request->getUserAgent('UNKNOWN'));
$record = $geodb->get($request->getIP());
$record = $geodb->get($trustedIp);
$sessionSecret = $proofForToken->generate();
$factor = (match ($verifiedToken->getAttribute('type')) {
@@ -243,7 +243,7 @@ $createSession = function (string $userId, string $secret, Request $request, Res
'provider' => $provider,
'secret' => $proofForToken->hash($sessionSecret), // One way hash encryption to protect DB leak
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'ip' => $trustedIp,
'factors' => [$factor],
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
'expire' => DateTime::addSeconds(new \DateTime(), $duration)
@@ -358,13 +358,13 @@ App::post('/v1/account')
->param('email', '', new EmailValidator(), 'User email.')
->param('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, $project->getAttribute('auths', [])['passwordDictionary'] ?? false), 'New user password. Must be between 8 and 256 chars.', false, ['project', 'passwordsDictionary'])
->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
->inject('request')
->inject('response')
->inject('user')
->inject('project')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, string $email, string $password, string $name, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Hooks $hooks) {
->inject('trustedIp')
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $user, Document $project, Database $dbForProject, Hooks $hooks, string $trustedIp) {
$email = \strtolower($email);
if ('console' === $project->getId()) {
@@ -375,7 +375,7 @@ App::post('/v1/account')
throw new Exception(Exception::USER_EMAIL_NOT_WHITELISTED);
}
if (!empty($whitelistIPs) && !\in_array($request->getIP(), $whitelistIPs)) {
if (!empty($whitelistIPs) && !\in_array($trustedIp, $whitelistIPs)) {
throw new Exception(Exception::USER_IP_NOT_WHITELISTED);
}
}
@@ -961,7 +961,8 @@ App::post('/v1/account/sessions/email')
->inject('store')
->inject('proofForPassword')
->inject('proofForToken')
->action(function (string $email, string $password, Request $request, Response $response, User $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Hooks $hooks, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken) {
->inject('trustedIp')
->action(function (string $email, string $password, Request $request, Response $response, User $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Hooks $hooks, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, string $trustedIp) {
$email = \strtolower($email);
$protocol = $request->getProtocol();
@@ -985,7 +986,7 @@ App::post('/v1/account/sessions/email')
$duration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG;
$detector = new Detector($request->getUserAgent('UNKNOWN'));
$record = $geodb->get($request->getIP());
$record = $geodb->get($trustedIp);
$secret = $proofForToken->generate();
$session = new Document(array_merge(
[
@@ -996,7 +997,7 @@ App::post('/v1/account/sessions/email')
'providerUid' => $email,
'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'ip' => $trustedIp,
'factors' => ['password'],
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
'expire' => DateTime::addSeconds(new \DateTime(), $duration)
@@ -1105,7 +1106,8 @@ App::post('/v1/account/sessions/anonymous')
->inject('store')
->inject('proofForPassword')
->inject('proofForToken')
->action(function (Request $request, Response $response, Locale $locale, User $user, Document $project, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken) {
->inject('trustedIp')
->action(function (Request $request, Response $response, Locale $locale, User $user, Document $project, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, string $trustedIp) {
$protocol = $request->getProtocol();
if ('console' === $project->getId()) {
@@ -1155,7 +1157,7 @@ App::post('/v1/account/sessions/anonymous')
// Create session token
$duration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG;
$detector = new Detector($request->getUserAgent('UNKNOWN'));
$record = $geodb->get($request->getIP());
$record = $geodb->get($trustedIp);
$secret = $proofForToken->generate();
$session = new Document(array_merge(
@@ -1166,7 +1168,7 @@ App::post('/v1/account/sessions/anonymous')
'provider' => SESSION_PROVIDER_ANONYMOUS,
'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'ip' => $trustedIp,
'factors' => ['anonymous'],
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
'expire' => DateTime::addSeconds(new \DateTime(), $duration)
@@ -1257,6 +1259,7 @@ App::post('/v1/account/sessions/token')
->inject('store')
->inject('proofForToken')
->inject('proofForCode')
->inject('trustedIp')
->action($createSession);
App::get('/v1/account/sessions/oauth2/:provider')
@@ -1453,7 +1456,8 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
->inject('store')
->inject('proofForPassword')
->inject('proofForToken')
->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken) use ($oauthDefaultSuccess) {
->inject('trustedIp')
->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, string $trustedIp) use ($oauthDefaultSuccess) {
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
$port = $request->getPort();
$callbackBase = $protocol . '://' . $request->getHostname();
@@ -1816,7 +1820,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
'secret' => $proofForTokenOAuth2->hash($secret), // One way hash encryption to protect DB leak
'expire' => $expire,
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'ip' => $trustedIp,
]);
Authorization::setRole(Role::user($user->getId())->toString());
@@ -1840,7 +1844,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
// If the `token` param is not set, we persist the session in a cookie
} else {
$detector = new Detector($request->getUserAgent('UNKNOWN'));
$record = $geodb->get($request->getIP());
$record = $geodb->get($trustedIp);
$secret = $proofForToken->generate();
$session = new Document(array_merge([
@@ -1854,7 +1858,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
'providerAccessTokenExpiry' => DateTime::addSeconds(new \DateTime(), (int)$accessTokenExpiry),
'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'ip' => $trustedIp,
'factors' => [TYPE::EMAIL, 'oauth2'], // include a special oauth2 factor to bypass MFA checks
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
'expire' => DateTime::addSeconds(new \DateTime(), $duration)
@@ -2047,7 +2051,8 @@ App::post('/v1/account/tokens/magic-url')
->inject('queueForMails')
->inject('proofForPassword')
->inject('platform')
->action(function (string $userId, string $email, string $url, bool $phrase, Request $request, Response $response, User $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, ProofsPassword $proofForPassword, array $platform) {
->inject('trustedIp')
->action(function (string $userId, string $email, string $url, bool $phrase, Request $request, Response $response, User $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, ProofsPassword $proofForPassword, array $platform, string $trustedIp) {
if (empty(System::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled');
}
@@ -2137,7 +2142,7 @@ App::post('/v1/account/tokens/magic-url')
'secret' => $proofForToken->hash($tokenSecret), // One way hash encryption to protect DB leak
'expire' => $expire,
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'ip' => $trustedIp,
]);
Authorization::setRole(Role::user($user->getId())->toString());
@@ -2315,7 +2320,8 @@ App::post('/v1/account/tokens/email')
->inject('queueForMails')
->inject('proofForPassword')
->inject('proofForCode')
->action(function (string $userId, string $email, bool $phrase, Request $request, Response $response, User $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, ProofsPassword $proofForPassword, ProofsCode $proofForCode) {
->inject('trustedIp')
->action(function (string $userId, string $email, bool $phrase, Request $request, Response $response, User $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, ProofsPassword $proofForPassword, ProofsCode $proofForCode, string $trustedIp) {
if (empty(System::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled');
}
@@ -2421,7 +2427,7 @@ App::post('/v1/account/tokens/email')
'secret' => $proofForCode->hash($tokenSecret), // One way hash encryption to protect DB leak
'expire' => $expire,
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'ip' => $trustedIp,
]);
Authorization::setRole(Role::user($user->getId())->toString());
@@ -2607,10 +2613,11 @@ App::put('/v1/account/sessions/magic-url')
->inject('queueForMails')
->inject('store')
->inject('proofForCode')
->action(function ($userId, $secret, $request, $response, $user, $dbForProject, $project, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForCode) use ($createSession) {
->inject('trustedIp')
->action(function ($userId, $secret, $request, $response, $user, $dbForProject, $project, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForCode, $trustedIp) use ($createSession) {
$proofForToken = new ProofsToken(TOKEN_LENGTH_MAGIC_URL);
$proofForToken->setHash(new Sha());
$createSession($userId, $secret, $request, $response, $user, $dbForProject, $project, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForToken, $proofForCode);
$createSession($userId, $secret, $request, $response, $user, $dbForProject, $project, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForToken, $proofForCode, $trustedIp);
});
App::put('/v1/account/sessions/phone')
@@ -2655,6 +2662,7 @@ App::put('/v1/account/sessions/phone')
->inject('store')
->inject('proofForToken')
->inject('proofForCode')
->inject('trustedIp')
->action($createSession);
App::post('/v1/account/tokens/phone')
@@ -2697,7 +2705,8 @@ App::post('/v1/account/tokens/phone')
->inject('plan')
->inject('store')
->inject('proofForCode')
->action(function (string $userId, string $phone, Request $request, Response $response, User $user, Document $project, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Locale $locale, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan, Store $store, ProofsCode $proofForCode) {
->inject('trustedIp')
->action(function (string $userId, string $phone, Request $request, Response $response, User $user, Document $project, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Locale $locale, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan, Store $store, ProofsCode $proofForCode, string $trustedIp) {
if (empty(System::getEnv('_APP_SMS_PROVIDER'))) {
throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured');
}
@@ -2792,7 +2801,7 @@ App::post('/v1/account/tokens/phone')
'secret' => $proofForCode->hash($secret),
'expire' => $expire,
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'ip' => $trustedIp,
]);
Authorization::setRole(Role::user($user->getId())->toString());
@@ -3468,7 +3477,8 @@ App::post('/v1/account/recovery')
->inject('queueForMails')
->inject('queueForEvents')
->inject('proofForToken')
->action(function (string $email, string $url, Request $request, Response $response, User $user, Database $dbForProject, Document $project, Locale $locale, Mail $queueForMails, Event $queueForEvents, ProofsToken $proofForToken) {
->inject('trustedIp')
->action(function (string $email, string $url, Request $request, Response $response, User $user, Database $dbForProject, Document $project, Locale $locale, Mail $queueForMails, Event $queueForEvents, ProofsToken $proofForToken, string $trustedIp) {
if (empty(System::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP Disabled');
@@ -3501,7 +3511,7 @@ App::post('/v1/account/recovery')
'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak
'expire' => $expire,
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'ip' => $trustedIp,
]);
Authorization::setRole(Role::user($profile->getId())->toString());
@@ -3764,7 +3774,8 @@ App::post('/v1/account/verifications/email')
->inject('queueForEvents')
->inject('queueForMails')
->inject('proofForToken')
->action(function (string $url, Request $request, Response $response, Document $project, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, ProofsToken $proofForToken) {
->inject('trustedIp')
->action(function (string $url, Request $request, Response $response, Document $project, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, ProofsToken $proofForToken, string $trustedIp) {
if (empty(System::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP Disabled');
@@ -3790,7 +3801,7 @@ App::post('/v1/account/verifications/email')
'secret' => $proofForToken->hash($verificationSecret), // One way hash encryption to protect DB leak
'expire' => $expire,
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'ip' => $trustedIp,
]);
Authorization::setRole(Role::user($user->getId())->toString());
@@ -4052,7 +4063,8 @@ App::post('/v1/account/verifications/phone')
->inject('queueForStatsUsage')
->inject('plan')
->inject('proofForCode')
->action(function (Request $request, Response $response, User $user, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Document $project, Locale $locale, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan, ProofsCode $proofForCode) {
->inject('trustedIp')
->action(function (Request $request, Response $response, User $user, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Document $project, Locale $locale, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan, ProofsCode $proofForCode, string $trustedIp) {
if (empty(System::getEnv('_APP_SMS_PROVIDER'))) {
throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured');
}
@@ -4088,7 +4100,7 @@ App::post('/v1/account/verifications/phone')
'secret' => $proofForCode->hash($secret),
'expire' => $expire,
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'ip' => $trustedIp,
]);
Authorization::setRole(Role::user($user->getId())->toString());
+3 -2
View File
@@ -32,11 +32,12 @@ App::get('/v1/locale')
->inject('response')
->inject('locale')
->inject('geodb')
->action(function (Request $request, Response $response, Locale $locale, Reader $geodb) {
->inject('trustedIp')
->action(function (Request $request, Response $response, Locale $locale, Reader $geodb, string $trustedIp) {
$eu = Config::getParam('locale-eu');
$currencies = Config::getParam('locale-currencies');
$output = [];
$ip = $request->getIP();
$ip = $trustedIp;
$output['ip'] = $ip;
+4 -3
View File
@@ -1209,7 +1209,8 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status')
->inject('queueForEvents')
->inject('store')
->inject('proofForToken')
->action(function (string $teamId, string $membershipId, string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Reader $geodb, Event $queueForEvents, Store $store, Token $proofForToken) {
->inject('trustedIp')
->action(function (string $teamId, string $membershipId, string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Reader $geodb, Event $queueForEvents, Store $store, Token $proofForToken, string $trustedIp) {
$protocol = $request->getProtocol();
$membership = $dbForProject->getDocument('memberships', $membershipId);
@@ -1261,7 +1262,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status')
Authorization::setRole(Role::user($user->getId())->toString());
$detector = new Detector($request->getUserAgent('UNKNOWN'));
$record = $geodb->get($request->getIP());
$record = $geodb->get($trustedIp);
$authDuration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG;
$expire = DateTime::addSeconds(new \DateTime(), $authDuration);
$secret = $proofForToken->generate();
@@ -1278,7 +1279,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status')
'providerUid' => $user->getAttribute('email'),
'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'ip' => $trustedIp,
'factors' => ['email'],
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
'expire' => DateTime::addSeconds(new \DateTime(), $authDuration)
+7 -5
View File
@@ -2216,7 +2216,8 @@ App::post('/v1/users/:userId/sessions')
->inject('queueForEvents')
->inject('store')
->inject('proofForToken')
->action(function (string $userId, Request $request, Response $response, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Store $store, Token $proofForToken) {
->inject('trustedIp')
->action(function (string $userId, Request $request, Response $response, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Store $store, Token $proofForToken, string $trustedIp) {
$user = $dbForProject->getDocument('users', $userId);
if ($user->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND);
@@ -2224,7 +2225,7 @@ App::post('/v1/users/:userId/sessions')
$secret = $proofForToken->generate();
$detector = new Detector($request->getUserAgent('UNKNOWN'));
$record = $geodb->get($request->getIP());
$record = $geodb->get($trustedIp);
$duration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG;
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration));
@@ -2238,7 +2239,7 @@ App::post('/v1/users/:userId/sessions')
'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak
'userAgent' => $request->getUserAgent('UNKNOWN'),
'factors' => ['server'],
'ip' => $request->getIP(),
'ip' => $trustedIp,
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
'expire' => $expire,
],
@@ -2305,7 +2306,8 @@ App::post('/v1/users/:userId/tokens')
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
->action(function (string $userId, int $length, int $expire, Request $request, Response $response, Database $dbForProject, Event $queueForEvents) {
->inject('trustedIp')
->action(function (string $userId, int $length, int $expire, Request $request, Response $response, Database $dbForProject, Event $queueForEvents, string $trustedIp) {
$user = $dbForProject->getDocument('users', $userId);
if ($user->isEmpty()) {
@@ -2325,7 +2327,7 @@ App::post('/v1/users/:userId/tokens')
'secret' => $proofForToken->hash($secret),
'expire' => $expire,
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP()
'ip' => $trustedIp
]);
$token = $dbForProject->createDocument('tokens', $token);
+4 -3
View File
@@ -366,7 +366,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw
$headers['x-appwrite-country-code'] = '';
$headers['x-appwrite-continent-code'] = '';
$headers['x-appwrite-continent-eu'] = 'false';
$ip = $request->getIP();
$ip = $trustedIp;
$headers['x-appwrite-client-ip'] = $ip;
$jwtExpiry = $resource->getAttribute('timeout', 900) + 60; // 1min extra to account for possible cold-starts
@@ -1179,8 +1179,9 @@ App::error()
->inject('logger')
->inject('log')
->inject('queueForStatsUsage')
->inject('trustedIp')
->inject('devKey')
->action(function (Throwable $error, App $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, StatsUsage $queueForStatsUsage) {
->action(function (Throwable $error, App $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, StatsUsage $queueForStatsUsage, string $trustedIp) {
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
$route = $utopia->getRoute();
$class = \get_class($error);
@@ -1291,7 +1292,7 @@ App::error()
if (isset($user) && !$user->isEmpty()) {
$log->setUser(new User($user->getId()));
} else {
$log->setUser(new User('guest-' . hash('sha256', $request->getIP())));
$log->setUser(new User('guest-' . hash('sha256', $trustedIp)));
}
try {
+4 -3
View File
@@ -509,7 +509,8 @@ App::init()
->inject('devKey')
->inject('telemetry')
->inject('platform')
->action(function (App $utopia, Request $request, Response $response, Document $project, Document $user, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, Messaging $queueForMessaging, Audit $queueForAudits, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Mail $queueForMails, Migration $queueForMigrations, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform) use ($usageDatabaseListener, $eventDatabaseListener) {
->inject('trustedIp')
->action(function (App $utopia, Request $request, Response $response, Document $project, Document $user, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, Messaging $queueForMessaging, Audit $queueForAudits, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Mail $queueForMails, Migration $queueForMigrations, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, string $trustedIp) use ($usageDatabaseListener, $eventDatabaseListener) {
$route = $utopia->getRoute();
@@ -537,7 +538,7 @@ App::init()
->setParam('{projectId}', $project->getId())
->setParam('{userId}', $user->getId())
->setParam('{userAgent}', $request->getUserAgent(''))
->setParam('{ip}', $request->getIP())
->setParam('{ip}', $trustedIp)
->setParam('{url}', $request->getHostname() . $route->getPath())
->setParam('{method}', $request->getMethod())
->setParam('{chunkId}', (int)($start / ($end + 1 - $start)));
@@ -598,7 +599,7 @@ App::init()
$queueForAudits
->setMode($mode)
->setUserAgent($request->getUserAgent(''))
->setIP($request->getIP())
->setIP($trustedIp)
->setHostname($request->getHostname())
->setEvent($route->getLabel('audits.event', ''))
->setProject($project);
+3 -2
View File
@@ -36,11 +36,12 @@ App::init()
->inject('request')
->inject('project')
->inject('geodb')
->action(function (App $utopia, Request $request, Document $project, Reader $geodb) {
->inject('trustedIp')
->action(function (App $utopia, Request $request, Document $project, Reader $geodb, string $trustedIp) {
$denylist = System::getEnv('_APP_CONSOLE_COUNTRIES_DENYLIST', '');
if (!empty($denylist && $project->getId() === 'console')) {
$countries = explode(',', $denylist);
$record = $geodb->get($request->getIP()) ?? [];
$record = $geodb->get($trustedIp) ?? [];
$country = $record['country']['iso_code'] ?? '';
if (in_array($country, $countries)) {
throw new Exception(Exception::GENERAL_REGION_ACCESS_DENIED);
+2 -1
View File
@@ -2,6 +2,7 @@
require_once __DIR__ . '/../vendor/autoload.php';
use Apppwrite\Network\TrustedIp;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use Swoole\Constant;
@@ -477,7 +478,7 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool
if (isset($user) && !$user->isEmpty()) {
$log->setUser(new User($user->getId()));
} else {
$log->setUser(new User('guest-' . hash('sha256', $request->getIP())));
$log->setUser(new User('guest-' . hash('sha256', TrustedIp::extract($request, System::getEnv('_APP_TRUSTED_HEADERS', 'x-forwarded-for')))));
}
$log->setNamespace("http");
+5
View File
@@ -22,6 +22,7 @@ use Appwrite\Extend\Exception;
use Appwrite\GraphQL\Schema;
use Appwrite\Network\Cors;
use Appwrite\Network\Platform;
use Appwrite\Network\TrustedIp;
use Appwrite\Network\Validator\Origin;
use Appwrite\Network\Validator\Redirect;
use Appwrite\Utopia\Database\Documents\User;
@@ -859,6 +860,10 @@ App::setResource('servers', function () {
return $languages;
});
App::setResource('trustedIp', function (Request $request) {
return TrustedIp::extract($request, System::getEnv('_APP_TRUSTED_HEADERS', 'x-forwarded-for'));
}, ['request']);
App::setResource('promiseAdapter', function ($register) {
return $register->get('promiseAdapter');
}, ['register']);
+2 -2
View File
@@ -3,7 +3,7 @@
use Appwrite\Extend\Exception;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Messaging\Adapter\Realtime;
use Appwrite\Network\Validator\Origin;
use Appwrite\Network\TrustedIp;
use Appwrite\PubSub\Adapter\Pool as PubSubPool;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Request;
@@ -552,7 +552,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
*/
$timelimit = $timelimit('url:{url},ip:{ip}', 128, 60);
$timelimit
->setParam('{ip}', $request->getIP())
->setParam('{ip}', TrustedIp::extract($request, System::getEnv('_APP_TRUSTED_HEADERS', 'x-forwarded-for')))
->setParam('{url}', $request->getURI());
$abuse = new Abuse($timelimit);
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace Appwrite\Network;
use Appwrite\Utopia\Request;
class TrustedIp
{
/**
* Extract the trusted client IP address from a request.
*
* This method checks configured trusted headers (e.g., X-Forwarded-For)
* and falls back to the remote address if no valid IP is found.
*
* @param Request $request The Utopia request object
* @return string The trusted client IP address
*
*/
public static function extract(Request $request, string $trustedHeaders): string
{
// Fallback to remote address
$remoteAddr = $request->getServer('remote_addr') ?? '0.0.0.0';
$trustedHeaders = explode(',', $trustedHeaders);
$trustedHeaders = array_map('trim', $trustedHeaders);
$trustedHeaders = array_map('strtolower', $trustedHeaders);
$trustedHeaders = array_filter($trustedHeaders);
foreach ($trustedHeaders as $header) {
$headerValue = $request->getHeader($header);
if (empty($headerValue)) {
continue;
}
// Leftmost IP address is the address of the originating client
$ips = explode(',', $headerValue);
$ip = trim($ips[0]);
// Validate IP format (supports both IPv4 and IPv6)
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
}
return $remoteAddr;
}
}
@@ -98,6 +98,7 @@ class Create extends Base
->inject('store')
->inject('proofForToken')
->inject('executor')
->inject('trustedIp')
->inject('platform')
->callback($this->action(...));
}
@@ -123,6 +124,7 @@ class Create extends Base
Store $store,
Token $proofForToken,
Executor $executor,
string $trustedIp,
array $platform
) {
$async = \strval($async) === 'true' || \strval($async) === '1';
@@ -238,7 +240,7 @@ class Create extends Base
$headers['x-appwrite-country-code'] = '';
$headers['x-appwrite-continent-code'] = '';
$headers['x-appwrite-continent-eu'] = 'false';
$ip = $request->getIP();
$ip = $trustedIp;
$headers['x-appwrite-client-ip'] = $ip;
if (!empty($ip)) {
@@ -326,4 +326,38 @@ trait AccountBase
$this->assertEquals($response['headers']['status-code'], 204);
}
public function testTrustedIp(): void
{
$email = uniqid() . 'user@localhost.test';
$password = 'password';
$name = 'User Name';
$response = $this->client->call(Client::METHOD_POST, '/account', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-forwarded-for' => '203.0.113.195',
]), [
'userId' => ID::unique(),
'email' => $email,
'password' => $password,
'name' => $name,
]);
$this->assertEquals($response['headers']['status-code'], 201);
$response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-forwarded-for' => '203.0.113.195',
]), [
'email' => $email,
'password' => $password,
]);
$this->assertEquals($response['headers']['status-code'], 201);
$this->assertEquals('203.0.113.195', $response['body']['clientIp'] ?? $response['body']['ip'] ?? '');
}
}